From 906f5a64e9057e9df19a76248b09c13e668798bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:16:49 +0100 Subject: [PATCH 01/74] agent: Cancel retries when the turn is cancelled (#50580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a completion request fails with a retryable error (e.g. a 500 from the upstream provider), the retry loop waits on a timer before trying again. This timer did not race with the cancellation signal, so if the user switched models and submitted a new message during the retry delay, the old turn would continue retrying with the stale model for up to 15 seconds — making requests to the wrong provider and corrupting the thread's message list with spurious Resume entries. Now the retry delay races with the cancellation receiver, so the old turn exits immediately when cancelled. Release Notes: - Fixed cancelled turns in a conversation that failed (e.g. 500 from the LLM provider) bein retried even after cancellation --- crates/agent/src/tests/mod.rs | 78 +++++++++++++++++++++++++++++++++++ crates/agent/src/thread.rs | 10 ++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 8d75aae7e2948ef9c0934a72da112b926f633941..23ebe41d3c42654cb8fcdc0266009416686858aa 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -2631,6 +2631,84 @@ async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]); } +#[gpui::test] +async fn test_retry_cancelled_promptly_on_new_send(cx: &mut TestAppContext) { + // Regression test: when a completion fails with a retryable error (e.g. upstream 500), + // the retry loop waits on a timer. If the user switches models and sends a new message + // during that delay, the old turn should exit immediately instead of retrying with the + // stale model. + let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; + let model_a = model.as_fake(); + + // Start a turn with model_a. + let events_1 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Hello"], cx) + }) + .unwrap(); + cx.run_until_parked(); + assert_eq!(model_a.completion_count(), 1); + + // Model returns a retryable upstream 500. The turn enters the retry delay. + model_a.send_last_completion_stream_error( + LanguageModelCompletionError::UpstreamProviderError { + message: "Internal server error".to_string(), + status: http_client::StatusCode::INTERNAL_SERVER_ERROR, + retry_after: None, + }, + ); + model_a.end_last_completion_stream(); + cx.run_until_parked(); + + // The old completion was consumed; model_a has no pending requests yet because the + // retry timer hasn't fired. + assert_eq!(model_a.completion_count(), 0); + + // Switch to model_b and send a new message. This cancels the old turn. + let model_b = Arc::new(FakeLanguageModel::with_id_and_thinking( + "fake", "model-b", "Model B", false, + )); + thread.update(cx, |thread, cx| { + thread.set_model(model_b.clone(), cx); + }); + let events_2 = thread + .update(cx, |thread, cx| { + thread.send(UserMessageId::new(), ["Continue"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + // model_b should have received its completion request. + assert_eq!(model_b.as_fake().completion_count(), 1); + + // Advance the clock well past the retry delay (BASE_RETRY_DELAY = 5s). + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + + // model_a must NOT have received another completion request — the cancelled turn + // should have exited during the retry delay rather than retrying with the old model. + assert_eq!( + model_a.completion_count(), + 0, + "old model should not receive a retry request after cancellation" + ); + + // Complete model_b's turn. + model_b + .as_fake() + .send_last_completion_stream_text_chunk("Done!"); + model_b + .as_fake() + .send_last_completion_stream_event(LanguageModelCompletionEvent::Stop(StopReason::EndTurn)); + model_b.as_fake().end_last_completion_stream(); + + let events_1 = events_1.collect::>().await; + assert_eq!(stop_events(events_1), vec![acp::StopReason::Cancelled]); + + let events_2 = events_2.collect::>().await; + assert_eq!(stop_events(events_2), vec![acp::StopReason::EndTurn]); +} + #[gpui::test] async fn test_subsequent_successful_sends_dont_cancel(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c5ca1118ace28b66d555d67aa40c718da292f644..2e693a85cd1f86d232e392860d8bd83509ce131a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1940,7 +1940,15 @@ impl Thread { })??; let timer = cx.background_executor().timer(retry.duration); event_stream.send_retry(retry); - timer.await; + futures::select! { + _ = timer.fuse() => {} + _ = cancellation_rx.changed().fuse() => { + if *cancellation_rx.borrow() { + log::debug!("Turn cancelled during retry delay, exiting"); + return Ok(()); + } + } + } this.update(cx, |this, _cx| { if let Some(Message::Agent(message)) = this.messages.last() { if message.tool_results.is_empty() { From 197cf60d05ea7d2b3e9126c70b05e9554691aa12 Mon Sep 17 00:00:00 2001 From: Rabi Mishra Date: Tue, 3 Mar 2026 15:51:40 +0530 Subject: [PATCH 02/74] agent_ui: Refresh ACP history after thread create/load (#49796) Moves loading of the history connection to once we have a new connection, not on every thread view. Release Notes: - N/A --------- Signed-off-by: rabi Co-authored-by: Ben Brandt --- crates/agent_ui/src/connection_view.rs | 205 +++++++++++++++++++------ 1 file changed, 159 insertions(+), 46 deletions(-) diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 93bf7c98098530b23522c60f987f9e341ebc69ca..bc58120a964b7cb10eb4c779eb24fa8507030bc6 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -728,6 +728,14 @@ impl ConnectionView { } let id = current.read(cx).thread.read(cx).session_id().clone(); + let session_list = if connection.supports_session_history() { + connection.session_list(cx) + } else { + None + }; + this.history.update(cx, |history, cx| { + history.set_session_list(session_list, cx); + }); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, @@ -833,14 +841,6 @@ impl ConnectionView { let connection = thread.read(cx).connection().clone(); let session_id = thread.read(cx).session_id().clone(); - let session_list = if connection.supports_session_history() { - connection.session_list(cx) - } else { - None - }; - self.history.update(cx, |history, cx| { - history.set_session_list(session_list, cx); - }); // Check for config options first // Config options take precedence over legacy mode/model selectors @@ -2835,6 +2835,33 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_new_thread_creation_triggers_session_list_refresh(cx: &mut TestAppContext) { + init_test(cx); + + let session = AgentSessionInfo::new(SessionId::new("history-session")); + let (thread_view, history, cx) = setup_thread_view_with_history( + StubAgentServer::new(SessionHistoryConnection::new(vec![session.clone()])), + cx, + ) + .await; + + history.read_with(cx, |history, _cx| { + assert!( + history.has_session_list(), + "session list should be attached after thread creation" + ); + }); + + active_thread(&thread_view, cx).read_with(cx, |view, _cx| { + assert_eq!(view.recent_history_entries.len(), 1); + assert_eq!( + view.recent_history_entries[0].session_id, + session.session_id + ); + }); + } + #[gpui::test] async fn test_resume_without_history_adds_notice(cx: &mut TestAppContext) { init_test(cx); @@ -3482,6 +3509,18 @@ pub(crate) mod tests { agent: impl AgentServer + 'static, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { + let (thread_view, _history, cx) = setup_thread_view_with_history(agent, cx).await; + (thread_view, cx) + } + + async fn setup_thread_view_with_history( + agent: impl AgentServer + 'static, + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + &mut VisualTestContext, + ) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let (multi_workspace, cx) = @@ -3501,14 +3540,14 @@ pub(crate) mod tests { project, Some(thread_store), None, - history, + history.clone(), window, cx, ) }) }); cx.run_until_parked(); - (thread_view, cx) + (thread_view, history, cx) } fn add_to_workspace(thread_view: Entity, cx: &mut VisualTestContext) { @@ -3648,6 +3687,102 @@ pub(crate) mod tests { ) -> Task> { Task::ready(Ok(AgentSessionListResponse::new(self.sessions.clone()))) } + + fn into_any(self: Rc) -> Rc { + self + } + } + + #[derive(Clone)] + struct SessionHistoryConnection { + sessions: Vec, + } + + impl SessionHistoryConnection { + fn new(sessions: Vec) -> Self { + Self { sessions } + } + } + + fn build_test_thread( + connection: Rc, + project: Entity, + name: &'static str, + session_id: SessionId, + cx: &mut App, + ) -> Entity { + let action_log = cx.new(|_| ActionLog::new(project.clone())); + cx.new(|cx| { + AcpThread::new( + None, + name, + connection, + project, + action_log, + session_id, + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), + cx, + ) + }) + } + + impl AgentConnection for SessionHistoryConnection { + fn telemetry_id(&self) -> SharedString { + "history-connection".into() + } + + fn new_session( + self: Rc, + project: Entity, + _cwd: &Path, + cx: &mut App, + ) -> Task>> { + let thread = build_test_thread( + self, + project, + "SessionHistoryConnection", + SessionId::new("history-session"), + cx, + ); + Task::ready(Ok(thread)) + } + + fn supports_load_session(&self) -> bool { + true + } + + fn session_list(&self, _cx: &mut App) -> Option> { + Some(Rc::new(StubSessionList::new(self.sessions.clone()))) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + &[] + } + + fn authenticate( + &self, + _method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn prompt( + &self, + _id: Option, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {} + fn into_any(self: Rc) -> Rc { self } @@ -3667,24 +3802,13 @@ pub(crate) mod tests { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "ResumeOnlyAgentConnection", - self.clone(), - project, - action_log, - SessionId::new("new-session"), - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); + let thread = build_test_thread( + self, + project, + "ResumeOnlyAgentConnection", + SessionId::new("new-session"), + cx, + ); Task::ready(Ok(thread)) } @@ -3699,24 +3823,13 @@ pub(crate) mod tests { _cwd: &Path, cx: &mut App, ) -> Task>> { - let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|cx| { - AcpThread::new( - None, - "ResumeOnlyAgentConnection", - self.clone(), - project, - action_log, - session.session_id, - watch::Receiver::constant( - acp::PromptCapabilities::new() - .image(true) - .audio(true) - .embedded_context(true), - ), - cx, - ) - }); + let thread = build_test_thread( + self, + project, + "ResumeOnlyAgentConnection", + session.session_id, + cx, + ); Task::ready(Ok(thread)) } From 58ad0ff69184c48439a4ae58a8e7d20b84a6b8b7 Mon Sep 17 00:00:00 2001 From: Tom Zaspel <40226087+tzabbi@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:58:13 +0100 Subject: [PATCH 03/74] Add file icons for YAML, Helm and GitLab (#50529) Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) I used the icons from here: - GitLab: https://about.gitlab.com/press/press-kit/ - Helm: https://www.svgrepo.com/svg/330624/helm - Yaml: https://icons.getbootstrap.com/icons/filetype-yml/ FYI: I'm not familiar with Rust please review the rust code. Release Notes: - Added file icons for YAML, Helm and GitLab files, and used the Docker icon for `Containerfile`. --------- Co-authored-by: Danilo Leal --- assets/icons/file_icons/gitlab.svg | 1 + assets/icons/file_icons/helm.svg | 1 + assets/icons/file_icons/yaml.svg | 1 + crates/theme/src/icon_theme.rs | 35 ++++++++++++++++++++++++++---- 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 assets/icons/file_icons/gitlab.svg create mode 100644 assets/icons/file_icons/helm.svg create mode 100644 assets/icons/file_icons/yaml.svg diff --git a/assets/icons/file_icons/gitlab.svg b/assets/icons/file_icons/gitlab.svg new file mode 100644 index 0000000000000000000000000000000000000000..f0faf570b125c7764e769ae60f7a6ce6f7825ceb --- /dev/null +++ b/assets/icons/file_icons/gitlab.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/file_icons/helm.svg b/assets/icons/file_icons/helm.svg new file mode 100644 index 0000000000000000000000000000000000000000..03e702f2d5081c4e96ff4db7ba7428817b08748f --- /dev/null +++ b/assets/icons/file_icons/helm.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/file_icons/yaml.svg b/assets/icons/file_icons/yaml.svg new file mode 100644 index 0000000000000000000000000000000000000000..2c3efd46cd45ff67d6c46d84476d563dd5ac3a73 --- /dev/null +++ b/assets/icons/file_icons/yaml.svg @@ -0,0 +1 @@ + diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 8415462595cb93a19365a929660b4e8e3f78f8d8..7c2d603281ec50c1daa6f21e1dc3487bfc394a67 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -66,7 +66,7 @@ pub struct IconDefinition { } const FILE_STEMS_BY_ICON_KEY: &[(&str, &[&str])] = &[ - ("docker", &["Dockerfile"]), + ("docker", &["Containerfile", "Dockerfile"]), ("ruby", &["Podfile"]), ("heroku", &["Procfile"]), ]; @@ -99,6 +99,15 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("cue", &["cue"]), ("dart", &["dart"]), ("diff", &["diff"]), + ( + "docker", + &[ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ], + ), ( "document", &[ @@ -138,12 +147,27 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("font", &["otf", "ttf", "woff", "woff2"]), ("fsharp", &["fs"]), ("fsproj", &["fsproj"]), - ("gitlab", &["gitlab-ci.yml"]), + ("gitlab", &["gitlab-ci.yml", "gitlab-ci.yaml"]), ("gleam", &["gleam"]), ("go", &["go", "mod", "work"]), ("graphql", &["gql", "graphql", "graphqls"]), ("haskell", &["hs"]), ("hcl", &["hcl"]), + ( + "helm", + &[ + "helmfile.yaml", + "helmfile.yml", + "Chart.yaml", + "Chart.yml", + "Chart.lock", + "values.yaml", + "values.yml", + "requirements.yaml", + "requirements.yml", + "tpl", + ], + ), ("html", &["htm", "html"]), ( "image", @@ -198,7 +222,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("rust", &["rs"]), ("sass", &["sass", "scss"]), ("scala", &["scala", "sc"]), - ("settings", &["conf", "ini", "yaml", "yml"]), + ("settings", &["conf", "ini"]), ("solidity", &["sol"]), ( "storage", @@ -279,6 +303,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ("vue", &["vue"]), ("vyper", &["vy", "vyi"]), ("wgsl", &["wgsl"]), + ("yaml", &["yaml", "yml"]), ("zig", &["zig"]), ]; @@ -310,12 +335,13 @@ const FILE_ICONS: &[(&str, &str)] = &[ ("font", "icons/file_icons/font.svg"), ("fsharp", "icons/file_icons/fsharp.svg"), ("fsproj", "icons/file_icons/file.svg"), - ("gitlab", "icons/file_icons/settings.svg"), + ("gitlab", "icons/file_icons/gitlab.svg"), ("gleam", "icons/file_icons/gleam.svg"), ("go", "icons/file_icons/go.svg"), ("graphql", "icons/file_icons/graphql.svg"), ("haskell", "icons/file_icons/haskell.svg"), ("hcl", "icons/file_icons/hcl.svg"), + ("helm", "icons/file_icons/helm.svg"), ("heroku", "icons/file_icons/heroku.svg"), ("html", "icons/file_icons/html.svg"), ("image", "icons/file_icons/image.svg"), @@ -371,6 +397,7 @@ const FILE_ICONS: &[(&str, &str)] = &[ ("vue", "icons/file_icons/vue.svg"), ("vyper", "icons/file_icons/vyper.svg"), ("wgsl", "icons/file_icons/wgsl.svg"), + ("yaml", "icons/file_icons/yaml.svg"), ("zig", "icons/file_icons/zig.svg"), ]; From 83fd8fa4dff43fda82567343f7fa8639f7e8b3b8 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:28:47 +0530 Subject: [PATCH 04/74] language_models: Handle usage-only events with empty choices in OpenRouter (#50603) Closes #50569 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Previously, OpenRouter responses containing only usage data (without any choices) would cause an error. Now the mapper properly emits usage updates for these events without failing. Release Notes: - Fixed an error when OpenRouter returns a usage-only event with empty choices. --- .../src/provider/open_router.rs | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index a044c7c25d7858f69dc8c4ac9fa0c8bda73f6e91..3e5128fcc5a366b4156afe6b28f3efc7bd697e12 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::Result; use collections::HashMap; use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; @@ -591,14 +591,21 @@ impl OpenRouterEventMapper { &mut self, event: ResponseStreamEvent, ) -> Vec> { + let mut events = Vec::new(); + + if let Some(usage) = event.usage { + events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { + input_tokens: usage.prompt_tokens, + output_tokens: usage.completion_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }))); + } + let Some(choice) = event.choices.first() else { - return vec![Err(LanguageModelCompletionError::from(anyhow!( - "Response contained no choices" - )))]; + return events; }; - let mut events = Vec::new(); - if let Some(details) = choice.delta.reasoning_details.clone() { // Emit reasoning_details immediately events.push(Ok(LanguageModelCompletionEvent::ReasoningDetails( @@ -646,15 +653,6 @@ impl OpenRouterEventMapper { } } - if let Some(usage) = event.usage { - events.push(Ok(LanguageModelCompletionEvent::UsageUpdate(TokenUsage { - input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }))); - } - match choice.finish_reason.as_deref() { Some("stop") => { // Don't emit reasoning_details here - already emitted immediately when captured @@ -1055,6 +1053,32 @@ mod tests { ); } + #[gpui::test] + async fn test_usage_only_chunk_with_empty_choices_does_not_error() { + let mut mapper = OpenRouterEventMapper::new(); + + let events = mapper.map_event(ResponseStreamEvent { + id: Some("response_123".into()), + created: 1234567890, + model: "google/gemini-3-flash-preview".into(), + choices: Vec::new(), + usage: Some(open_router::Usage { + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + }), + }); + + assert_eq!(events.len(), 1); + match events.into_iter().next().unwrap() { + Ok(LanguageModelCompletionEvent::UsageUpdate(usage)) => { + assert_eq!(usage.input_tokens, 12); + assert_eq!(usage.output_tokens, 7); + } + other => panic!("Expected usage update event, got: {other:?}"), + } + } + #[gpui::test] async fn test_agent_prevents_empty_reasoning_details_overwrite() { // This test verifies that the agent layer prevents empty reasoning_details From e652d967b839e6a0f88dd6025b98e7a8a3d9a967 Mon Sep 17 00:00:00 2001 From: Oleksandr Kholiavko <43780952+HalavicH@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:49:40 +0100 Subject: [PATCH 05/74] Add CSV preview with live table view and interactive features (#48207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description:** **Context:** This PR introduces an initial CSV preview feature for Zed, building upon two previously merged infrastructure PRs: - [#46341](https://github.com/zed-industries/zed/pull/46341) - Data table dynamic column support (removed const generics) - [#46190](https://github.com/zed-industries/zed/pull/46190) - Variable row height mode for data tables This implementation is based on the [original draft PR #44344](https://github.com/zed-industries/zed/pull/44344), which has been carefully decomposed into smaller, reviewable pieces. --- #### **Features Included:** **Core Infrastructure:** - Live CSV parsing with smart debouncing (200ms cooldown) - Performance monitoring with built-in timing metrics (not displayed in UI yet) - Automatic file change detection and re-parsing - Support for quoted fields, multiline cells, and escaped characters **Table Display:** - Variable row height rendering with fallback to uniform mode (switchable via settings) - Draggable column resizing (reusing existing data table infrastructure) - Row identifiers supporting both source line numbers and sequential row numbers - Configurable font rendering (UI font vs monospace) - Tooltips showing full cell content on hover **Interactive Features:** - Column sorting (ascending/descending) with visual indicators **Settings Panel:** - Toggle between variable/uniform row rendering - Font type selection (UI/monospace) - Row identifier type configuration - Debug information display - Multiline cell rendering options --- #### **Features Intentionally Removed for This PR:** To reduce complexity and review scope, the following features were temporarily reverted and will be reintroduced in subsequent PRs: - ❌ Settings pannel with performance metrics overlay - ❌ Cell selection (single, multiple, and range selections) - ❌ Keyboard navigation with arrow keys and selection extension - ❌ Copy functionality supporting CSV, TSV, and Markdown table formats - ❌ Inline cell editing with file persistence - ❌ Viewport following for large datasets - ❌ Column filtering and search capabilities These removals were done via "time-machine" commits that cleanly nuked vertical slices of functionality from the complete implementation. --- **Technical Implementation:** The feature is organized into a dedicated `csv_preview` crate with the following structure: ``` crates/csv_preview/ ├── src/ │ ├── csv_preview.rs # Main view and coordination logic │ ├── parser.rs # CSV parsing and editor integration │ ├── settings.rs # Configuration types and defaults │ ├── table_data_engine.rs # Data transformation logic │ ├── renderer/ # UI rendering modules │ │ ├── preview_view.rs # Main render implementation │ │ ├── render_table.rs # Table component assembly │ │ ├── table_cell.rs # Individual cell rendering │ │ ├── table_header.rs # Header with sorting controls │ │ └── row_identifiers.rs # Line number column │ └── types/ # Core data structures │ ├── table_like_content.rs │ ├── coordinates.rs # Display vs data coordinate systems │ └── table_cell.rs ``` **Key architectural decisions:** - **Dual coordinate system**: Separates data indices from display indices to support sorting/filtering - **Component reuse**: Leverages existing `data_table` infrastructure from the keymap editor --- **Integration:** - Registers `csv::OpenPreview` action (currently without default keybindings) - Follows the same workspace integration pattern as `markdown_preview` and `svg_preview` - Automatically detects `.csv` file extensions - Tab integration with appropriate icons and naming --- **Code Structure Note:** Some code structures, types, and documentation may appear redundant or over-engineered in this initial implementation. This is intentional - the feature was developed as a complete system and then decomposed by functionality rather than being built incrementally. The "extra" infrastructure supports features that were removed for this PR but will be reintroduced in subsequent ones. This approach was chosen over extensive refactoring because: 1. The complete feature took 200+ commits to develop with significant rewrites 2. Clean extraction of vertical slices was more feasible than rebuilding incrementally 3. The end state will utilize all these components, making current "redundancy" temporary I apologize for any inconvenience this may cause during review, but the alternative would have required significant refactoring effort just to make intermediate states "prettier," which seemed counterproductive. --- **Future Work:** This lays the groundwork for upcoming PRs that will reintroduce the removed features: - Cell selection and keyboard navigation - Copy functionality with multiple output formats - Inline editing capabilities with undo/redo - Column filtering and search - TSV and other delimiter support - Improved horizontal scrolling behavior - Settings persistence **Testing:** Includes test fixtures demonstrating multiline cell handling, various column counts, and edge cases. --- **Release Notes:** - N/A This is feature flagged --------- Co-authored-by: Anthony Eid --- Cargo.lock | 15 + Cargo.toml | 2 + crates/csv_preview/Cargo.toml | 21 + crates/csv_preview/LICENSE-GPL | 1 + crates/csv_preview/src/csv_preview.rs | 302 +++++++++++ crates/csv_preview/src/parser.rs | 513 ++++++++++++++++++ crates/csv_preview/src/renderer.rs | 5 + .../csv_preview/src/renderer/preview_view.rs | 50 ++ .../csv_preview/src/renderer/render_table.rs | 193 +++++++ .../src/renderer/row_identifiers.rs | 189 +++++++ crates/csv_preview/src/renderer/table_cell.rs | 72 +++ .../csv_preview/src/renderer/table_header.rs | 94 ++++ crates/csv_preview/src/settings.rs | 46 ++ crates/csv_preview/src/table_data_engine.rs | 90 +++ .../table_data_engine/sorting_by_column.rs | 49 ++ crates/csv_preview/src/types.rs | 17 + crates/csv_preview/src/types/coordinates.rs | 127 +++++ crates/csv_preview/src/types/table_cell.rs | 54 ++ .../src/types/table_like_content.rs | 32 ++ crates/ui/src/components/data_table.rs | 43 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 1 + .../zed/src/zed/quick_action_bar/preview.rs | 17 + 24 files changed, 1928 insertions(+), 7 deletions(-) create mode 100644 crates/csv_preview/Cargo.toml create mode 120000 crates/csv_preview/LICENSE-GPL create mode 100644 crates/csv_preview/src/csv_preview.rs create mode 100644 crates/csv_preview/src/parser.rs create mode 100644 crates/csv_preview/src/renderer.rs create mode 100644 crates/csv_preview/src/renderer/preview_view.rs create mode 100644 crates/csv_preview/src/renderer/render_table.rs create mode 100644 crates/csv_preview/src/renderer/row_identifiers.rs create mode 100644 crates/csv_preview/src/renderer/table_cell.rs create mode 100644 crates/csv_preview/src/renderer/table_header.rs create mode 100644 crates/csv_preview/src/settings.rs create mode 100644 crates/csv_preview/src/table_data_engine.rs create mode 100644 crates/csv_preview/src/table_data_engine/sorting_by_column.rs create mode 100644 crates/csv_preview/src/types.rs create mode 100644 crates/csv_preview/src/types/coordinates.rs create mode 100644 crates/csv_preview/src/types/table_cell.rs create mode 100644 crates/csv_preview/src/types/table_like_content.rs diff --git a/Cargo.lock b/Cargo.lock index c4dcfa054efa372259880c3a813a5d203e9c1be7..99347bd08f0d5b3ae13ab352612e3876a3cf6a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4340,6 +4340,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "csv_preview" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "feature_flags", + "gpui", + "log", + "text", + "ui", + "workspace", +] + [[package]] name = "ctor" version = "0.4.3" @@ -21727,6 +21741,7 @@ dependencies = [ "copilot_chat", "copilot_ui", "crashes", + "csv_preview", "dap", "dap_adapters", "db", diff --git a/Cargo.toml b/Cargo.toml index 98fccfaeb21bc6107323378605c8299d5bd5838f..8e1312f032e19b2c2c189677f144f04dd7f4589c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ members = [ "crates/copilot_chat", "crates/crashes", "crates/credentials_provider", + "crates/csv_preview", "crates/dap", "crates/dap_adapters", "crates/db", @@ -298,6 +299,7 @@ copilot_ui = { path = "crates/copilot_ui" } crashes = { path = "crates/crashes" } credentials_provider = { path = "crates/credentials_provider" } crossbeam = "0.8.4" +csv_preview = { path = "crates/csv_preview"} dap = { path = "crates/dap" } dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } diff --git a/crates/csv_preview/Cargo.toml b/crates/csv_preview/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7e9ce2c4d515cfce9586a0686475a8dfed0ddc95 --- /dev/null +++ b/crates/csv_preview/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "csv_preview" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[lib] +path = "src/csv_preview.rs" + +[dependencies] +anyhow.workspace = true +feature_flags.workspace = true +gpui.workspace = true +editor.workspace = true +ui.workspace = true +workspace.workspace = true +log.workspace = true +text.workspace = true + +[lints] +workspace = true diff --git a/crates/csv_preview/LICENSE-GPL b/crates/csv_preview/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/csv_preview/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..f056f5a12225b000527b9087760e3d683bda1b5b --- /dev/null +++ b/crates/csv_preview/src/csv_preview.rs @@ -0,0 +1,302 @@ +use editor::{Editor, EditorEvent}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use gpui::{ + AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions, +}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +use crate::table_data_engine::TableDataEngine; +use ui::{SharedString, TableColumnWidths, TableInteractionState, prelude::*}; +use workspace::{Item, SplitDirection, Workspace}; + +use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; + +mod parser; +mod renderer; +mod settings; +mod table_data_engine; +mod types; + +actions!(csv, [OpenPreview, OpenPreviewToTheSide]); + +pub struct TabularDataPreviewFeatureFlag; + +impl FeatureFlag for TabularDataPreviewFeatureFlag { + const NAME: &'static str = "tabular-data-preview"; +} + +pub struct CsvPreviewView { + pub(crate) engine: TableDataEngine, + + pub(crate) focus_handle: FocusHandle, + active_editor_state: EditorState, + pub(crate) table_interaction_state: Entity, + pub(crate) column_widths: ColumnWidths, + pub(crate) parsing_task: Option>>, + pub(crate) settings: CsvPreviewSettings, + /// Performance metrics for debugging and monitoring CSV operations. + pub(crate) performance_metrics: PerformanceMetrics, + pub(crate) list_state: gpui::ListState, + /// Time when the last parsing operation ended, used for smart debouncing + pub(crate) last_parse_end_time: Option, +} + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + CsvPreviewView::register(workspace); + }) + .detach() +} + +impl CsvPreviewView { + pub fn register(workspace: &mut Workspace) { + workspace.register_action_renderer(|div, _, _, cx| { + div.when(cx.has_flag::(), |div| { + div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .filter(|editor| Self::is_csv_file(editor, cx)) + { + let csv_preview = Self::new(&editor, cx); + workspace.active_pane().update(cx, |pane, cx| { + let existing = pane + .items_of_type::() + .find(|view| view.read(cx).active_editor_state.editor == editor); + if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { + pane.activate_item(idx, true, true, window, cx); + } else { + pane.add_item(Box::new(csv_preview), true, true, None, window, cx); + } + }); + cx.notify(); + } + })) + .on_action(cx.listener( + |workspace, _: &OpenPreviewToTheSide, window, cx| { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .filter(|editor| Self::is_csv_file(editor, cx)) + { + let csv_preview = Self::new(&editor, cx); + let pane = workspace + .find_pane_in_direction(SplitDirection::Right, cx) + .unwrap_or_else(|| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }); + pane.update(cx, |pane, cx| { + let existing = + pane.items_of_type::().find(|view| { + view.read(cx).active_editor_state.editor == editor + }); + if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { + pane.activate_item(idx, true, true, window, cx); + } else { + pane.add_item( + Box::new(csv_preview), + false, + false, + None, + window, + cx, + ); + } + }); + cx.notify(); + } + }, + )) + }) + }); + } + + fn new(editor: &Entity, cx: &mut Context) -> Entity { + let contents = TableLikeContent::default(); + let table_interaction_state = cx.new(|cx| { + TableInteractionState::new(cx) + .with_custom_scrollbar(ui::Scrollbars::for_settings::()) + }); + + cx.new(|cx| { + let subscription = cx.subscribe( + editor, + |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| { + match event { + EditorEvent::Edited { .. } + | EditorEvent::DirtyChanged + | EditorEvent::ExcerptsEdited { .. } => { + this.parse_csv_from_active_editor(true, cx); + } + _ => {} + }; + }, + ); + + let mut view = CsvPreviewView { + focus_handle: cx.focus_handle(), + active_editor_state: EditorState { + editor: editor.clone(), + _subscription: subscription, + }, + table_interaction_state, + column_widths: ColumnWidths::new(cx, 1), + parsing_task: None, + performance_metrics: PerformanceMetrics::default(), + list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)), + settings: CsvPreviewSettings::default(), + last_parse_end_time: None, + engine: TableDataEngine::default(), + }; + + view.parse_csv_from_active_editor(false, cx); + view + }) + } + + pub(crate) fn editor_state(&self) -> &EditorState { + &self.active_editor_state + } + pub(crate) fn apply_sort(&mut self) { + self.performance_metrics.record("Sort", || { + self.engine.apply_sort(); + }); + } + + /// Update ordered indices when ordering or content changes + pub(crate) fn apply_filter_sort(&mut self) { + self.performance_metrics.record("Filter&sort", || { + self.engine.calculate_d2d_mapping(); + }); + + // Update list state with filtered row count + let visible_rows = self.engine.d2d_mapping().visible_row_count(); + self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.)); + } + + pub fn resolve_active_item_as_csv_editor( + workspace: &Workspace, + cx: &mut Context, + ) -> Option> { + let editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx))?; + Self::is_csv_file(&editor, cx).then_some(editor) + } + + fn is_csv_file(editor: &Entity, cx: &App) -> bool { + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| { + buffer + .read(cx) + .file() + .and_then(|file| file.path().extension()) + .map(|ext| ext.eq_ignore_ascii_case("csv")) + }) + .unwrap_or(false) + } +} + +impl Focusable for CsvPreviewView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter<()> for CsvPreviewView {} + +impl Item for CsvPreviewView { + type Event = (); + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::FileDoc)) + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + self.editor_state() + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .and_then(|b| { + let file = b.read(cx).file()?; + let local_file = file.as_local()?; + local_file + .abs_path(cx) + .file_name() + .map(|name| format!("Preview {}", name.to_string_lossy()).into()) + }) + .unwrap_or_else(|| SharedString::from("CSV Preview")) + } +} + +#[derive(Debug, Default)] +pub struct PerformanceMetrics { + /// Map of timing metrics with their duration and measurement time. + pub timings: HashMap<&'static str, (Duration, Instant)>, + /// List of display indices that were rendered in the current frame. + pub rendered_indices: Vec, +} +impl PerformanceMetrics { + pub fn record(&mut self, name: &'static str, mut f: F) -> R + where + F: FnMut() -> R, + { + let start_time = Instant::now(); + let ret = f(); + let duration = start_time.elapsed(); + self.timings.insert(name, (duration, Instant::now())); + ret + } + + /// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago` + pub fn display(&self) -> String { + let mut metrics = self.timings.iter().collect::>(); + metrics.sort_by_key(|&(name, _)| *name); + metrics + .iter() + .map(|(name, (duration, time))| { + let took = duration.as_secs_f32() * 1000.; + let ago = time.elapsed().as_secs(); + format!("{name}: {took:.2}ms {ago}s ago") + }) + .collect::>() + .join("\n") + } + + /// Get timing for a specific metric + pub fn get_timing(&self, name: &str) -> Option { + self.timings.get(name).map(|(duration, _)| *duration) + } +} + +/// Holds state of column widths for a table component in CSV preview. +pub(crate) struct ColumnWidths { + pub widths: Entity, +} + +impl ColumnWidths { + pub(crate) fn new(cx: &mut Context, cols: usize) -> Self { + Self { + widths: cx.new(|cx| TableColumnWidths::new(cols, cx)), + } + } + /// Replace the current `TableColumnWidths` entity with a new one for the given column count. + pub(crate) fn replace(&self, cx: &mut Context, cols: usize) { + self.widths + .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx)); + } +} diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..b087404e0ebbd13cdaf20cab692f5470ea6ce292 --- /dev/null +++ b/crates/csv_preview/src/parser.rs @@ -0,0 +1,513 @@ +use crate::{ + CsvPreviewView, + types::TableLikeContent, + types::{LineNumber, TableCell}, +}; +use editor::Editor; +use gpui::{AppContext, Context, Entity, Subscription, Task}; +use std::time::{Duration, Instant}; +use text::BufferSnapshot; +use ui::{SharedString, table_row::TableRow}; + +pub(crate) const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); + +pub(crate) struct EditorState { + pub editor: Entity, + pub _subscription: Subscription, +} + +impl CsvPreviewView { + pub(crate) fn parse_csv_from_active_editor( + &mut self, + wait_for_debounce: bool, + cx: &mut Context, + ) { + let editor = self.active_editor_state.editor.clone(); + self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); + } + + fn parse_csv_in_background( + &mut self, + wait_for_debounce: bool, + editor: Entity, + cx: &mut Context, + ) -> Task> { + cx.spawn(async move |view, cx| { + if wait_for_debounce { + // Smart debouncing: check if cooldown period has already passed + let now = Instant::now(); + let should_wait = view.update(cx, |view, _| { + if let Some(last_end) = view.last_parse_end_time { + let cooldown_until = last_end + REPARSE_DEBOUNCE; + if now < cooldown_until { + Some(cooldown_until - now) + } else { + None // Cooldown already passed, parse immediately + } + } else { + None // First parse, no debounce + } + })?; + + if let Some(wait_duration) = should_wait { + cx.background_executor().timer(wait_duration).await; + } + } + + let buffer_snapshot = view.update(cx, |_, cx| { + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .map(|b| b.read(cx).text_snapshot()) + })?; + + let Some(buffer_snapshot) = buffer_snapshot else { + return Ok(()); + }; + + let instant = Instant::now(); + let parsed_csv = cx + .background_spawn(async move { from_buffer(&buffer_snapshot) }) + .await; + let parse_duration = instant.elapsed(); + let parse_end_time: Instant = Instant::now(); + log::debug!("Parsed CSV in {}ms", parse_duration.as_millis()); + view.update(cx, move |view, cx| { + view.performance_metrics + .timings + .insert("Parsing", (parse_duration, Instant::now())); + + log::debug!("Parsed {} rows", parsed_csv.rows.len()); + // Update table width so it can be rendered properly + let cols = parsed_csv.headers.cols(); + view.column_widths.replace(cx, cols + 1); // Add 1 for the line number column + + view.engine.contents = parsed_csv; + view.last_parse_end_time = Some(parse_end_time); + + view.apply_filter_sort(); + cx.notify(); + }) + }) + } +} + +pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent { + let text = buffer_snapshot.text(); + + if text.trim().is_empty() { + return TableLikeContent::default(); + } + + let (parsed_cells_with_positions, line_numbers) = parse_csv_with_positions(&text); + if parsed_cells_with_positions.is_empty() { + return TableLikeContent::default(); + } + let raw_headers = parsed_cells_with_positions[0].clone(); + + // Calculating the longest row, as CSV might have less headers than max row width + let Some(max_number_of_cols) = parsed_cells_with_positions.iter().map(|r| r.len()).max() else { + return TableLikeContent::default(); + }; + + // Convert to TableCell objects with buffer positions + let headers = create_table_row(&buffer_snapshot, max_number_of_cols, raw_headers); + + let rows = parsed_cells_with_positions + .into_iter() + .skip(1) + .map(|row| create_table_row(&buffer_snapshot, max_number_of_cols, row)) + .collect(); + + let row_line_numbers = line_numbers.into_iter().skip(1).collect(); + + TableLikeContent { + headers, + rows, + line_numbers: row_line_numbers, + number_of_cols: max_number_of_cols, + } +} + +/// Parse CSV and track byte positions for each cell +fn parse_csv_with_positions( + text: &str, +) -> ( + Vec)>>, + Vec, +) { + let mut rows = Vec::new(); + let mut line_numbers = Vec::new(); + let mut current_row: Vec<(SharedString, std::ops::Range)> = Vec::new(); + let mut current_field = String::new(); + let mut field_start_offset = 0; + let mut current_offset = 0; + let mut in_quotes = false; + let mut current_line = 1; // 1-based line numbering + let mut row_start_line = 1; + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + let char_byte_len = ch.len_utf8(); + + match ch { + '"' => { + if in_quotes { + if chars.peek() == Some(&'"') { + // Escaped quote + chars.next(); + current_field.push('"'); + current_offset += 1; // Skip the second quote + } else { + // End of quoted field + in_quotes = false; + } + } else { + // Start of quoted field + in_quotes = true; + if current_field.is_empty() { + // Include the opening quote in the range + field_start_offset = current_offset; + } + } + } + ',' if !in_quotes => { + // Field separator + let field_end_offset = current_offset; + if current_field.is_empty() && !in_quotes { + field_start_offset = current_offset; + } + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + field_start_offset = current_offset + char_byte_len; + } + '\n' => { + current_line += 1; + if !in_quotes { + // Row separator (only when not inside quotes) + let field_end_offset = current_offset; + if current_field.is_empty() && current_row.is_empty() { + field_start_offset = 0; + } + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + + // Only add non-empty rows + if !current_row.is_empty() + && !current_row.iter().all(|(field, _)| field.trim().is_empty()) + { + rows.push(current_row); + // Add line number info for this row + let line_info = if row_start_line == current_line - 1 { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line - 1) + }; + line_numbers.push(line_info); + } + current_row = Vec::new(); + row_start_line = current_line; + field_start_offset = current_offset + char_byte_len; + } else { + // Newline inside quotes - preserve it + current_field.push(ch); + } + } + '\r' => { + if chars.peek() == Some(&'\n') { + // Handle Windows line endings (\r\n): account for \r byte, let \n be handled next + current_offset += char_byte_len; + continue; + } else { + // Standalone \r + current_line += 1; + if !in_quotes { + // Row separator (only when not inside quotes) + let field_end_offset = current_offset; + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + + // Only add non-empty rows + if !current_row.is_empty() + && !current_row.iter().all(|(field, _)| field.trim().is_empty()) + { + rows.push(current_row); + // Add line number info for this row + let line_info = if row_start_line == current_line - 1 { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line - 1) + }; + line_numbers.push(line_info); + } + current_row = Vec::new(); + row_start_line = current_line; + field_start_offset = current_offset + char_byte_len; + } else { + // \r inside quotes - preserve it + current_field.push(ch); + } + } + } + _ => { + if current_field.is_empty() && !in_quotes { + field_start_offset = current_offset; + } + current_field.push(ch); + } + } + + current_offset += char_byte_len; + } + + // Add the last field and row if not empty + if !current_field.is_empty() || !current_row.is_empty() { + let field_end_offset = current_offset; + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + } + if !current_row.is_empty() && !current_row.iter().all(|(field, _)| field.trim().is_empty()) { + rows.push(current_row); + // Add line number info for the last row + let line_info = if row_start_line == current_line { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line) + }; + line_numbers.push(line_info); + } + + (rows, line_numbers) +} + +fn create_table_row( + buffer_snapshot: &BufferSnapshot, + max_number_of_cols: usize, + row: Vec<(SharedString, std::ops::Range)>, +) -> TableRow { + let mut raw_row = row + .into_iter() + .map(|(content, range)| { + TableCell::from_buffer_position(content, range.start, range.end, &buffer_snapshot) + }) + .collect::>(); + + let append_elements = max_number_of_cols - raw_row.len(); + if append_elements > 0 { + for _ in 0..append_elements { + raw_row.push(TableCell::Virtual); + } + } + + TableRow::from_vec(raw_row, max_number_of_cols) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_csv_parsing_basic() { + let csv_data = "Name,Age,City\nJohn,30,New York\nJane,25,Los Angeles"; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 3); + assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name"); + assert_eq!(parsed.headers[1].display_value().unwrap().as_ref(), "Age"); + assert_eq!(parsed.headers[2].display_value().unwrap().as_ref(), "City"); + + assert_eq!(parsed.rows.len(), 2); + assert_eq!(parsed.rows[0][0].display_value().unwrap().as_ref(), "John"); + assert_eq!(parsed.rows[0][1].display_value().unwrap().as_ref(), "30"); + assert_eq!( + parsed.rows[0][2].display_value().unwrap().as_ref(), + "New York" + ); + } + + #[test] + fn test_csv_parsing_with_quotes() { + let csv_data = r#"Name,Description +"John Doe","A person with ""special"" characters" +Jane,"Simple name""#; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 2); + assert_eq!(parsed.rows.len(), 2); + assert_eq!( + parsed.rows[0][1].display_value().unwrap().as_ref(), + r#"A person with "special" characters"# + ); + } + + #[test] + fn test_csv_parsing_with_newlines_in_quotes() { + let csv_data = "Name,Description,Status\n\"John\nDoe\",\"A person with\nmultiple lines\",Active\n\"Jane Smith\",\"Simple\",\"Also\nActive\""; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 3); + assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name"); + assert_eq!( + parsed.headers[1].display_value().unwrap().as_ref(), + "Description" + ); + assert_eq!( + parsed.headers[2].display_value().unwrap().as_ref(), + "Status" + ); + + assert_eq!(parsed.rows.len(), 2); + assert_eq!( + parsed.rows[0][0].display_value().unwrap().as_ref(), + "John\nDoe" + ); + assert_eq!( + parsed.rows[0][1].display_value().unwrap().as_ref(), + "A person with\nmultiple lines" + ); + assert_eq!( + parsed.rows[0][2].display_value().unwrap().as_ref(), + "Active" + ); + + assert_eq!( + parsed.rows[1][0].display_value().unwrap().as_ref(), + "Jane Smith" + ); + assert_eq!( + parsed.rows[1][1].display_value().unwrap().as_ref(), + "Simple" + ); + assert_eq!( + parsed.rows[1][2].display_value().unwrap().as_ref(), + "Also\nActive" + ); + + // Check line numbers + assert_eq!(parsed.line_numbers.len(), 2); + match &parsed.line_numbers[0] { + LineNumber::LineRange(start, end) => { + assert_eq!(start, &2); + assert_eq!(end, &4); + } + _ => panic!("Expected LineRange for multiline row"), + } + match &parsed.line_numbers[1] { + LineNumber::LineRange(start, end) => { + assert_eq!(start, &5); + assert_eq!(end, &6); + } + _ => panic!("Expected LineRange for second multiline row"), + } + } + + #[test] + fn test_empty_csv() { + let parsed = TableLikeContent::from_str("".to_string()); + assert_eq!(parsed.headers.cols(), 0); + assert!(parsed.rows.is_empty()); + } + + #[test] + fn test_csv_parsing_quote_offset_handling() { + let csv_data = r#"first,"se,cond",third"#; + let (parsed_cells, _) = parse_csv_with_positions(csv_data); + + assert_eq!(parsed_cells.len(), 1); // One row + assert_eq!(parsed_cells[0].len(), 3); // Three cells + + // first: 0..5 (no quotes) + let (content1, range1) = &parsed_cells[0][0]; + assert_eq!(content1.as_ref(), "first"); + assert_eq!(*range1, 0..5); + + // "se,cond": 6..15 (includes quotes in range, content without quotes) + let (content2, range2) = &parsed_cells[0][1]; + assert_eq!(content2.as_ref(), "se,cond"); + assert_eq!(*range2, 6..15); + + // third: 16..21 (no quotes) + let (content3, range3) = &parsed_cells[0][2]; + assert_eq!(content3.as_ref(), "third"); + assert_eq!(*range3, 16..21); + } + + #[test] + fn test_csv_parsing_complex_quotes() { + let csv_data = r#"id,"name with spaces","description, with commas",status +1,"John Doe","A person with ""quotes"" and, commas",active +2,"Jane Smith","Simple description",inactive"#; + let (parsed_cells, _) = parse_csv_with_positions(csv_data); + + assert_eq!(parsed_cells.len(), 3); // header + 2 rows + + // Check header row + let header_row = &parsed_cells[0]; + assert_eq!(header_row.len(), 4); + + // id: 0..2 + assert_eq!(header_row[0].0.as_ref(), "id"); + assert_eq!(header_row[0].1, 0..2); + + // "name with spaces": 3..21 (includes quotes) + assert_eq!(header_row[1].0.as_ref(), "name with spaces"); + assert_eq!(header_row[1].1, 3..21); + + // "description, with commas": 22..48 (includes quotes) + assert_eq!(header_row[2].0.as_ref(), "description, with commas"); + assert_eq!(header_row[2].1, 22..48); + + // status: 49..55 + assert_eq!(header_row[3].0.as_ref(), "status"); + assert_eq!(header_row[3].1, 49..55); + + // Check first data row + let first_row = &parsed_cells[1]; + assert_eq!(first_row.len(), 4); + + // 1: 56..57 + assert_eq!(first_row[0].0.as_ref(), "1"); + assert_eq!(first_row[0].1, 56..57); + + // "John Doe": 58..68 (includes quotes) + assert_eq!(first_row[1].0.as_ref(), "John Doe"); + assert_eq!(first_row[1].1, 58..68); + + // Content should be stripped of quotes but include escaped quotes + assert_eq!( + first_row[2].0.as_ref(), + r#"A person with "quotes" and, commas"# + ); + // The range should include the outer quotes: 69..107 + assert_eq!(first_row[2].1, 69..107); + + // active: 108..114 + assert_eq!(first_row[3].0.as_ref(), "active"); + assert_eq!(first_row[3].1, 108..114); + } +} + +impl TableLikeContent { + #[cfg(test)] + pub fn from_str(text: String) -> Self { + use text::{Buffer, BufferId, ReplicaId}; + + let buffer_id = BufferId::new(1).unwrap(); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, text); + let snapshot = buffer.snapshot(); + from_buffer(snapshot) + } +} diff --git a/crates/csv_preview/src/renderer.rs b/crates/csv_preview/src/renderer.rs new file mode 100644 index 0000000000000000000000000000000000000000..42ae05936c7ebd3fb9c619793376998b6d33e2c1 --- /dev/null +++ b/crates/csv_preview/src/renderer.rs @@ -0,0 +1,5 @@ +mod preview_view; +mod render_table; +mod row_identifiers; +mod table_cell; +mod table_header; diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..55e62d03806b578f59c2542cf997f90ec22a1f8f --- /dev/null +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -0,0 +1,50 @@ +use std::time::Instant; + +use ui::{div, prelude::*}; + +use crate::{CsvPreviewView, settings::FontType}; + +impl Render for CsvPreviewView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + + self.performance_metrics.rendered_indices.clear(); + let render_prep_start = Instant::now(); + let table_with_settings = v_flex() + .size_full() + .p_4() + .bg(theme.colors().editor_background) + .track_focus(&self.focus_handle) + .child({ + if self.engine.contents.number_of_cols == 0 { + div() + .flex() + .items_center() + .justify_center() + .h_32() + .text_ui(cx) + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .text_color(cx.theme().colors().text_muted) + .child("No CSV content to display") + .into_any_element() + } else { + self.create_table(&self.column_widths.widths, cx) + } + }); + + let render_prep_duration = render_prep_start.elapsed(); + self.performance_metrics.timings.insert( + "render_prep", + (render_prep_duration, std::time::Instant::now()), + ); + + div() + .relative() + .w_full() + .h_full() + .child(table_with_settings) + } +} diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs new file mode 100644 index 0000000000000000000000000000000000000000..0cc3bc3c46fb24570b3c99c9121dff3860c6b820 --- /dev/null +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -0,0 +1,193 @@ +use crate::types::TableCell; +use gpui::{AnyElement, Entity}; +use std::ops::Range; +use ui::Table; +use ui::TableColumnWidths; +use ui::TableResizeBehavior; +use ui::UncheckedTableRow; +use ui::{DefiniteLength, div, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::RowRenderMechanism, + types::{AnyColumn, DisplayCellId, DisplayRow}, +}; + +impl CsvPreviewView { + /// Creates a new table. + /// Column number is derived from the `TableColumnWidths` entity. + pub(crate) fn create_table( + &self, + current_widths: &Entity, + cx: &mut Context, + ) -> AnyElement { + let cols = current_widths.read(cx).cols(); + let remaining_col_number = cols - 1; + let fraction = if remaining_col_number > 0 { + 1. / remaining_col_number as f32 + } else { + 1. // only column with line numbers is present. Put 100%, but it will be overwritten anyways :D + }; + let mut widths = vec![DefiniteLength::Fraction(fraction); cols]; + let line_number_width = self.calculate_row_identifier_column_width(); + widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into())); + + let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; + resize_behaviors[0] = TableResizeBehavior::None; + + self.create_table_inner( + self.engine.contents.rows.len(), + widths, + resize_behaviors, + current_widths, + cx, + ) + } + + fn create_table_inner( + &self, + row_count: usize, + widths: UncheckedTableRow, + resize_behaviors: UncheckedTableRow, + current_widths: &Entity, + cx: &mut Context, + ) -> AnyElement { + let cols = widths.len(); + // Create headers array with interactive elements + let mut headers = Vec::with_capacity(cols); + + headers.push(self.create_row_identifier_header(cx)); + + // Add the actual CSV headers with sort buttons + for i in 0..(cols - 1) { + let header_text = self + .engine + .contents + .headers + .get(AnyColumn(i)) + .and_then(|h| h.display_value().cloned()) + .unwrap_or_else(|| format!("Col {}", i + 1).into()); + + headers.push(self.create_header_element_with_sort_button( + header_text, + cx, + AnyColumn::from(i), + )); + } + + Table::new(cols) + .interactable(&self.table_interaction_state) + .striped() + .column_widths(widths) + .resizable_columns(resize_behaviors, current_widths, cx) + .header(headers) + .disable_base_style() + .map(|table| { + let row_identifier_text_color = cx.theme().colors().editor_line_number; + match self.settings.rendering_with { + RowRenderMechanism::VariableList => { + table.variable_row_height_list(row_count, self.list_state.clone(), { + cx.processor(move |this, display_row: usize, _window, cx| { + this.performance_metrics.rendered_indices.push(display_row); + + let display_row = DisplayRow(display_row); + Self::render_single_table_row( + this, + cols, + display_row, + row_identifier_text_color, + cx, + ) + .unwrap_or_else(|| panic!("Expected to render a table row")) + }) + }) + } + RowRenderMechanism::UniformList => { + table.uniform_list("csv-table", row_count, { + cx.processor(move |this, range: Range, _window, cx| { + // Record all display indices in the range for performance metrics + this.performance_metrics + .rendered_indices + .extend(range.clone()); + + range + .filter_map(|display_index| { + Self::render_single_table_row( + this, + cols, + DisplayRow(display_index), + row_identifier_text_color, + cx, + ) + }) + .collect() + }) + }) + } + } + }) + .into_any_element() + } + + /// Render a single table row + /// + /// Used both by UniformList and VariableRowHeightList + fn render_single_table_row( + this: &CsvPreviewView, + cols: usize, + display_row: DisplayRow, + row_identifier_text_color: gpui::Hsla, + cx: &Context, + ) -> Option> { + // Get the actual row index from our sorted indices + let data_row = this.engine.d2d_mapping().get_data_row(display_row)?; + let row = this.engine.contents.get_row(data_row)?; + + let mut elements = Vec::with_capacity(cols); + elements.push(this.create_row_identifier_cell(display_row, data_row, cx)?); + + // Remaining columns: actual CSV data + for col in (0..this.engine.contents.number_of_cols).map(AnyColumn) { + let table_cell = row.expect_get(col); + + // TODO: Introduce `` cell type + let cell_content = table_cell.display_value().cloned().unwrap_or_default(); + + let display_cell_id = DisplayCellId::new(display_row, col); + + let cell = div().size_full().whitespace_nowrap().text_ellipsis().child( + CsvPreviewView::create_selectable_cell( + display_cell_id, + cell_content, + this.settings.vertical_alignment, + this.settings.font_type, + cx, + ), + ); + + elements.push( + div() + .size_full() + .when(this.settings.show_debug_info, |parent| { + parent.child(div().text_color(row_identifier_text_color).child( + match table_cell { + TableCell::Real { position: pos, .. } => { + let slv = pos.start.timestamp().value; + let so = pos.start.offset; + let elv = pos.end.timestamp().value; + let eo = pos.end.offset; + format!("Pos {so}(L{slv})-{eo}(L{elv})") + } + TableCell::Virtual => "Virtual cell".into(), + }, + )) + }) + .text_ui(cx) + .child(cell) + .into_any_element(), + ); + } + + Some(elements) + } +} diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs new file mode 100644 index 0000000000000000000000000000000000000000..a122aa9bf3d803b9deb9c6211e117ba4aa593d93 --- /dev/null +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -0,0 +1,189 @@ +use ui::{ + ActiveTheme as _, AnyElement, Button, ButtonCommon as _, ButtonSize, ButtonStyle, + Clickable as _, Context, ElementId, FluentBuilder as _, IntoElement as _, ParentElement as _, + SharedString, Styled as _, StyledTypography as _, Tooltip, div, +}; + +use crate::{ + CsvPreviewView, + settings::{FontType, RowIdentifiers}, + types::{DataRow, DisplayRow, LineNumber}, +}; + +pub enum RowIdentDisplayMode { + /// E.g + /// ```text + /// 1 + /// ... + /// 5 + /// ``` + Vertical, + /// E.g. + /// ```text + /// 1-5 + /// ``` + Horizontal, +} + +impl LineNumber { + pub fn display_string(&self, mode: RowIdentDisplayMode) -> String { + match *self { + LineNumber::Line(line) => line.to_string(), + LineNumber::LineRange(start, end) => match mode { + RowIdentDisplayMode::Vertical => { + if start + 1 == end { + format!("{start}\n{end}") + } else { + format!("{start}\n...\n{end}") + } + } + RowIdentDisplayMode::Horizontal => { + format!("{start}-{end}") + } + }, + } + } +} + +impl CsvPreviewView { + /// Calculate the optimal width for the row identifier column (line numbers or row numbers). + /// + /// This ensures the column is wide enough to display the largest identifier comfortably, + /// but not wastefully wide for small files. + pub(crate) fn calculate_row_identifier_column_width(&self) -> f32 { + match self.settings.numbering_type { + RowIdentifiers::SrcLines => self.calculate_line_number_width(), + RowIdentifiers::RowNum => self.calculate_row_number_width(), + } + } + + /// Calculate width needed for line numbers (can be multi-line) + fn calculate_line_number_width(&self) -> f32 { + // Find the maximum line number that could be displayed + let max_line_number = self + .engine + .contents + .line_numbers + .iter() + .map(|ln| match ln { + LineNumber::Line(n) => *n, + LineNumber::LineRange(_, end) => *end, + }) + .max() + .unwrap_or_default(); + + let digit_count = if max_line_number == 0 { + 1 + } else { + (max_line_number as f32).log10().floor() as usize + 1 + }; + + // if !self.settings.multiline_cells_enabled { + // // Uses horizontal line numbers layout like `123-456`. Needs twice the size + // digit_count *= 2; + // } + + let char_width_px = 9.0; // TODO: get real width of the characters + let base_width = (digit_count as f32) * char_width_px; + let padding = 20.0; + let min_width = 60.0; + (base_width + padding).max(min_width) + } + + /// Calculate width needed for sequential row numbers + fn calculate_row_number_width(&self) -> f32 { + let max_row_number = self.engine.contents.rows.len(); + + let digit_count = if max_row_number == 0 { + 1 + } else { + (max_row_number as f32).log10().floor() as usize + 1 + }; + + let char_width_px = 9.0; // TODO: get real width of the characters + let base_width = (digit_count as f32) * char_width_px; + let padding = 20.0; + let min_width = 60.0; + (base_width + padding).max(min_width) + } + + pub(crate) fn create_row_identifier_header( + &self, + cx: &mut Context<'_, CsvPreviewView>, + ) -> AnyElement { + // First column: row identifier (clickable to toggle between Lines and Rows) + let row_identifier_text = match self.settings.numbering_type { + RowIdentifiers::SrcLines => "Lines", + RowIdentifiers::RowNum => "Rows", + }; + + let view = cx.entity(); + let value = div() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child( + Button::new( + ElementId::Name("row-identifier-toggle".into()), + row_identifier_text, + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .tooltip(Tooltip::text( + "Toggle between: file line numbers or sequential row numbers", + )) + .on_click(move |_event, _window, cx| { + view.update(cx, |this, cx| { + this.settings.numbering_type = match this.settings.numbering_type { + RowIdentifiers::SrcLines => RowIdentifiers::RowNum, + RowIdentifiers::RowNum => RowIdentifiers::SrcLines, + }; + cx.notify(); + }); + }), + ) + .into_any_element(); + value + } + + pub(crate) fn create_row_identifier_cell( + &self, + display_row: DisplayRow, + data_row: DataRow, + cx: &Context<'_, CsvPreviewView>, + ) -> Option { + let row_identifier: SharedString = match self.settings.numbering_type { + RowIdentifiers::SrcLines => self + .engine + .contents + .line_numbers + .get(*data_row)? + .display_string(if self.settings.multiline_cells_enabled { + RowIdentDisplayMode::Vertical + } else { + RowIdentDisplayMode::Horizontal + }) + .into(), + RowIdentifiers::RowNum => (*display_row + 1).to_string().into(), + }; + + let value = div() + .flex() + .px_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .h_full() + .text_ui(cx) + // Row identifiers are always centered + .items_center() + .justify_end() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child(row_identifier) + .into_any_element(); + Some(value) + } +} diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs new file mode 100644 index 0000000000000000000000000000000000000000..32900ab77708936e218e9af10a4de5fba796e6a7 --- /dev/null +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -0,0 +1,72 @@ +//! Table Cell Rendering + +use gpui::{AnyElement, ElementId}; +use ui::{SharedString, Tooltip, div, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::{FontType, VerticalAlignment}, + types::DisplayCellId, +}; + +impl CsvPreviewView { + /// Create selectable table cell with mouse event handlers. + pub fn create_selectable_cell( + display_cell_id: DisplayCellId, + cell_content: SharedString, + vertical_alignment: VerticalAlignment, + font_type: FontType, + cx: &Context, + ) -> AnyElement { + create_table_cell( + display_cell_id, + cell_content, + vertical_alignment, + font_type, + cx, + ) + // Mouse events handlers will be here + .into_any_element() + } +} + +/// Create styled table cell div element. +fn create_table_cell( + display_cell_id: DisplayCellId, + cell_content: SharedString, + vertical_alignment: VerticalAlignment, + font_type: FontType, + cx: &Context<'_, CsvPreviewView>, +) -> gpui::Stateful
{ + div() + .id(ElementId::NamedInteger( + format!( + "csv-display-cell-{}-{}", + *display_cell_id.row, *display_cell_id.col + ) + .into(), + 0, + )) + .cursor_pointer() + .flex() + .h_full() + .px_1() + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_r_1() + .border_color(cx.theme().colors().border_variant) + .map(|div| match vertical_alignment { + VerticalAlignment::Top => div.items_start(), + VerticalAlignment::Center => div.items_center(), + }) + .map(|div| match vertical_alignment { + VerticalAlignment::Top => div.content_start(), + VerticalAlignment::Center => div.content_center(), + }) + .map(|div| match font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .tooltip(Tooltip::text(cell_content.clone())) + .child(div().child(cell_content)) +} diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs new file mode 100644 index 0000000000000000000000000000000000000000..52a16be9fc81ef1c3f001513b652a33c3b06dc82 --- /dev/null +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -0,0 +1,94 @@ +use gpui::ElementId; +use ui::{Tooltip, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::FontType, + table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + types::AnyColumn, +}; + +impl CsvPreviewView { + /// Create header for data, which is orderable with text on the left and sort button on the right + pub(crate) fn create_header_element_with_sort_button( + &self, + header_text: SharedString, + cx: &mut Context<'_, CsvPreviewView>, + col_idx: AnyColumn, + ) -> AnyElement { + // CSV data columns: text + filter/sort buttons + h_flex() + .justify_between() + .items_center() + .w_full() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child(div().child(header_text)) + .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .into_any_element() + } + + fn create_sort_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col_idx: AnyColumn, + ) -> Button { + let sort_btn = Button::new( + ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64), + match self.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { + SortDirection::Asc => "↓", + SortDirection::Desc => "↑", + }, + _ => "↕", // Unsorted/available for sorting + }, + ) + .size(ButtonSize::Compact) + .style( + if self + .engine + .applied_sorting + .is_some_and(|o| o.col_idx == col_idx) + { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }, + ) + .tooltip(Tooltip::text(match self.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { + SortDirection::Asc => "Sorted A-Z. Click to sort Z-A", + SortDirection::Desc => "Sorted Z-A. Click to disable sorting", + }, + _ => "Not sorted. Click to sort A-Z", + })) + .on_click(cx.listener(move |this, _event, _window, cx| { + let new_sorting = match this.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => { + // Same column clicked - cycle through states + match ordering.direction { + SortDirection::Asc => Some(AppliedSorting { + col_idx, + direction: SortDirection::Desc, + }), + SortDirection::Desc => None, // Clear sorting + } + } + _ => { + // Different column or no sorting - start with ascending + Some(AppliedSorting { + col_idx, + direction: SortDirection::Asc, + }) + } + }; + + this.engine.applied_sorting = new_sorting; + this.apply_sort(); + cx.notify(); + })); + sort_btn + } +} diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..e627b3cc994a84f54268a05ba17534789f631fe0 --- /dev/null +++ b/crates/csv_preview/src/settings.rs @@ -0,0 +1,46 @@ +#[derive(Default, Clone, Copy)] +pub enum RowRenderMechanism { + /// Default behaviour + #[default] + VariableList, + /// More performance oriented, but all rows are same height + #[allow(dead_code)] // Will be used when settings ui is added + UniformList, +} + +#[derive(Default, Clone, Copy)] +pub enum VerticalAlignment { + /// Align text to the top of cells + #[default] + Top, + /// Center text vertically in cells + Center, +} + +#[derive(Default, Clone, Copy)] +pub enum FontType { + /// Use the default UI font + #[default] + Ui, + /// Use monospace font (same as buffer/editor font) + Monospace, +} + +#[derive(Default, Clone, Copy)] +pub enum RowIdentifiers { + /// Show original line numbers from CSV file + #[default] + SrcLines, + /// Show sequential row numbers starting from 1 + RowNum, +} + +#[derive(Clone, Default)] +pub(crate) struct CsvPreviewSettings { + pub(crate) rendering_with: RowRenderMechanism, + pub(crate) vertical_alignment: VerticalAlignment, + pub(crate) font_type: FontType, + pub(crate) numbering_type: RowIdentifiers, + pub(crate) show_debug_info: bool, + pub(crate) multiline_cells_enabled: bool, +} diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs new file mode 100644 index 0000000000000000000000000000000000000000..382b41a28507213dcc5993adb49a1fddc5e7b64c --- /dev/null +++ b/crates/csv_preview/src/table_data_engine.rs @@ -0,0 +1,90 @@ +//! This module defines core operations and config of tabular data view (CSV table) +//! It operates in 2 coordinate systems: +//! - `DataCellId` - indices of src data cells +//! - `DisplayCellId` - indices of data after applied transformations like sorting/filtering, which is used to render cell on the screen +//! +//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. + +use std::{collections::HashMap, sync::Arc}; + +use ui::table_row::TableRow; + +use crate::{ + table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, + types::{DataRow, DisplayRow, TableCell, TableLikeContent}, +}; + +pub mod sorting_by_column; + +#[derive(Default)] +pub(crate) struct TableDataEngine { + pub applied_sorting: Option, + d2d_mapping: DisplayToDataMapping, + pub contents: TableLikeContent, +} + +impl TableDataEngine { + pub(crate) fn d2d_mapping(&self) -> &DisplayToDataMapping { + &self.d2d_mapping + } + + pub(crate) fn apply_sort(&mut self) { + self.d2d_mapping + .apply_sorting(self.applied_sorting, &self.contents.rows); + self.d2d_mapping.merge_mappings(); + } + + /// Applies sorting and filtering to the data and produces display to data mapping + pub(crate) fn calculate_d2d_mapping(&mut self) { + self.d2d_mapping + .apply_sorting(self.applied_sorting, &self.contents.rows); + self.d2d_mapping.merge_mappings(); + } +} + +/// Relation of Display (rendered) rows to Data (src) rows with applied transformations +/// Transformations applied: +/// - sorting by column +#[derive(Debug, Default)] +pub struct DisplayToDataMapping { + /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + pub sorted_rows: Vec, + /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + pub mapping: Arc>, +} + +impl DisplayToDataMapping { + /// Get the data row for a given display row + pub fn get_data_row(&self, display_row: DisplayRow) -> Option { + self.mapping.get(&display_row).copied() + } + + /// Get the number of filtered rows + pub fn visible_row_count(&self) -> usize { + self.mapping.len() + } + + /// Computes sorting + fn apply_sorting(&mut self, sorting: Option, rows: &[TableRow]) { + let data_rows: Vec = (0..rows.len()).map(DataRow).collect(); + + let sorted_rows = if let Some(sorting) = sorting { + sort_data_rows(&rows, data_rows, sorting) + } else { + data_rows + }; + + self.sorted_rows = sorted_rows; + } + + /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn merge_mappings(&mut self) { + self.mapping = Arc::new( + self.sorted_rows + .iter() + .enumerate() + .map(|(display, data)| (DisplayRow(display), *data)) + .collect(), + ); + } +} diff --git a/crates/csv_preview/src/table_data_engine/sorting_by_column.rs b/crates/csv_preview/src/table_data_engine/sorting_by_column.rs new file mode 100644 index 0000000000000000000000000000000000000000..52d61351a3d4a8fad0cec60d8c6c594fec05c545 --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/sorting_by_column.rs @@ -0,0 +1,49 @@ +use ui::table_row::TableRow; + +use crate::types::{AnyColumn, DataRow, TableCell}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum SortDirection { + Asc, + Desc, +} + +/// Config or currently active sorting +#[derive(Debug, Clone, Copy)] +pub struct AppliedSorting { + /// 0-based column index + pub col_idx: AnyColumn, + /// Direction of sorting (asc/desc) + pub direction: SortDirection, +} + +pub fn sort_data_rows( + content_rows: &[TableRow], + mut data_row_ids: Vec, + sorting: AppliedSorting, +) -> Vec { + data_row_ids.sort_by(|&a, &b| { + let row_a = &content_rows[*a]; + let row_b = &content_rows[*b]; + + // TODO: Decide how to handle nulls (on top or on bottom) + let val_a = row_a + .get(sorting.col_idx) + .and_then(|tc| tc.display_value()) + .map(|tc| tc.as_str()) + .unwrap_or(""); + let val_b = row_b + .get(sorting.col_idx) + .and_then(|tc| tc.display_value()) + .map(|tc| tc.as_str()) + .unwrap_or(""); + + let cmp = val_a.cmp(val_b); + match sorting.direction { + SortDirection::Asc => cmp, + SortDirection::Desc => cmp.reverse(), + } + }); + + data_row_ids +} diff --git a/crates/csv_preview/src/types.rs b/crates/csv_preview/src/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..87fc513f53e61db996d39dcb05409c765fd0c6dc --- /dev/null +++ b/crates/csv_preview/src/types.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +pub use coordinates::*; +mod coordinates; +pub use table_cell::*; +mod table_cell; +pub use table_like_content::*; +mod table_like_content; + +/// Line number information for CSV rows +#[derive(Debug, Clone, Copy)] +pub enum LineNumber { + /// Single line row + Line(usize), + /// Multi-line row spanning from start to end line. Incluisive + LineRange(usize, usize), +} diff --git a/crates/csv_preview/src/types/coordinates.rs b/crates/csv_preview/src/types/coordinates.rs new file mode 100644 index 0000000000000000000000000000000000000000..d800bef6ce0dd54d5ae65301163f79013e447ce3 --- /dev/null +++ b/crates/csv_preview/src/types/coordinates.rs @@ -0,0 +1,127 @@ +//! Type definitions for CSV table coordinates and cell identifiers. +//! +//! Provides newtypes for self-documenting coordinate systems: +//! - Display coordinates: Visual positions in rendered table +//! - Data coordinates: Original CSV data positions + +use std::ops::Deref; + +///// Rows ///// +/// Visual row position in rendered table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DisplayRow(pub usize); + +impl DisplayRow { + /// Create a new display row + pub fn new(row: usize) -> Self { + Self(row) + } + + /// Get the inner row value + pub fn get(self) -> usize { + self.0 + } +} + +impl Deref for DisplayRow { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Original CSV row position. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DataRow(pub usize); + +impl DataRow { + /// Create a new data row + pub fn new(row: usize) -> Self { + Self(row) + } +} + +impl Deref for DataRow { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for DisplayRow { + fn from(row: usize) -> Self { + DisplayRow::new(row) + } +} + +impl From for DataRow { + fn from(row: usize) -> Self { + DataRow::new(row) + } +} + +///// Columns ///// +/// Data column position in CSV table. 0-based +/// +/// Currently represents both display and data coordinate systems since +/// column reordering is not yet implemented. When column reordering is added, +/// this will need to be split into `DisplayColumn` and `DataColumn` types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AnyColumn(pub usize); + +impl AnyColumn { + /// Create a new column ID + pub fn new(col: usize) -> Self { + Self(col) + } + + /// Get the inner column value + pub fn get(self) -> usize { + self.0 + } +} + +impl Deref for AnyColumn { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for AnyColumn { + fn from(col: usize) -> Self { + AnyColumn::new(col) + } +} + +impl From for usize { + fn from(value: AnyColumn) -> Self { + *value + } +} + +///// Cells ///// +/// Visual cell position in rendered table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DisplayCellId { + pub row: DisplayRow, + pub col: AnyColumn, +} + +impl DisplayCellId { + /// Create a new display cell ID + pub fn new(row: impl Into, col: impl Into) -> Self { + Self { + row: row.into(), + col: col.into(), + } + } + + /// Returns (row, column) + pub fn to_raw(&self) -> (usize, usize) { + (self.row.0, self.col.0) + } +} diff --git a/crates/csv_preview/src/types/table_cell.rs b/crates/csv_preview/src/types/table_cell.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6f9adb3fe82b0d468d1ffc8404e707a762e94ea --- /dev/null +++ b/crates/csv_preview/src/types/table_cell.rs @@ -0,0 +1,54 @@ +use text::Anchor; +use ui::SharedString; + +/// Position of a cell within the source CSV buffer +#[derive(Clone, Debug)] +pub struct CellContentSpan { + /// Start anchor of the cell content in the source buffer + pub start: Anchor, + /// End anchor of the cell content in the source buffer + pub end: Anchor, +} + +/// A table cell with its content and position in the source buffer +#[derive(Clone, Debug)] +pub enum TableCell { + /// Cell existing in the CSV + Real { + /// Position of this cell in the source buffer + position: CellContentSpan, + /// Cached display value (for performance) + cached_value: SharedString, + }, + /// Virtual cell, created to pad malformed row + Virtual, +} + +impl TableCell { + /// Create a TableCell with buffer position tracking + pub fn from_buffer_position( + content: SharedString, + start_offset: usize, + end_offset: usize, + buffer_snapshot: &text::BufferSnapshot, + ) -> Self { + let start_anchor = buffer_snapshot.anchor_before(start_offset); + let end_anchor = buffer_snapshot.anchor_after(end_offset); + + Self::Real { + position: CellContentSpan { + start: start_anchor, + end: end_anchor, + }, + cached_value: content, + } + } + + /// Get the display value for this cell + pub fn display_value(&self) -> Option<&SharedString> { + match self { + TableCell::Real { cached_value, .. } => Some(cached_value), + TableCell::Virtual => None, + } + } +} diff --git a/crates/csv_preview/src/types/table_like_content.rs b/crates/csv_preview/src/types/table_like_content.rs new file mode 100644 index 0000000000000000000000000000000000000000..7bf205af812c24d70f33157f8ab7acc454c3b0d5 --- /dev/null +++ b/crates/csv_preview/src/types/table_like_content.rs @@ -0,0 +1,32 @@ +use ui::table_row::TableRow; + +use crate::types::{DataRow, LineNumber, TableCell}; + +/// Generic container struct of table-like data (CSV, TSV, etc) +#[derive(Clone)] +pub struct TableLikeContent { + /// Number of data columns. + /// Defines table width used to validate `TableRow` on creation + pub number_of_cols: usize, + pub headers: TableRow, + pub rows: Vec>, + /// Follows the same indices as `rows` + pub line_numbers: Vec, +} + +impl Default for TableLikeContent { + fn default() -> Self { + Self { + number_of_cols: 0, + headers: TableRow::::from_vec(vec![], 0), + rows: vec![], + line_numbers: vec![], + } + } +} + +impl TableLikeContent { + pub(crate) fn get_row(&self, data_row: DataRow) -> Option<&TableRow> { + self.rows.get(*data_row) + } +} diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 8a40c246ca44ea9dbb25e61bb611882343ba7f94..76ed64850c92e274bd8aeca483dd197cfbccbf52 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -36,6 +36,13 @@ pub mod table_row { pub struct TableRow(Vec); impl TableRow { + pub fn from_element(element: T, length: usize) -> Self + where + T: Clone, + { + Self::from_vec(vec![element; length], length) + } + /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. /// /// Use this when you want to ensure at construction time that the row has the correct number of columns. @@ -70,7 +77,8 @@ pub mod table_row { /// /// # Panics /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). - pub fn expect_get(&self, col: usize) -> &T { + pub fn expect_get(&self, col: impl Into) -> &T { + let col = col.into(); self.0.get(col).unwrap_or_else(|| { panic!( "Expected table row of `{}` to have {col:?}", @@ -79,8 +87,8 @@ pub mod table_row { }) } - pub fn get(&self, col: usize) -> Option<&T> { - self.0.get(col) + pub fn get(&self, col: impl Into) -> Option<&T> { + self.0.get(col.into()) } pub fn as_slice(&self) -> &[T] { @@ -735,6 +743,7 @@ pub struct Table { empty_table_callback: Option AnyElement>>, /// The number of columns in the table. Used to assert column numbers in `TableRow` collections cols: usize, + disable_base_cell_style: bool, } impl Table { @@ -753,9 +762,19 @@ impl Table { use_ui_font: true, empty_table_callback: None, col_widths: None, + disable_base_cell_style: false, } } + /// Disables based styling of row cell (paddings, text ellipsis, nowrap, etc), keeping width settings + /// + /// Doesn't affect base style of header cell. + /// Doesn't remove overflow-hidden + pub fn disable_base_style(mut self) -> Self { + self.disable_base_cell_style = true; + self + } + /// Enables uniform list rendering. /// The provided function will be passed directly to the `uniform_list` element. /// Therefore, if this method is called, any calls to [`Table::row`] before or after @@ -973,10 +992,18 @@ pub fn render_table_row( .into_iter() .zip(column_widths.into_vec()) .map(|(cell, width)| { - base_cell_style_text(width, table_context.use_ui_font, cx) - .px_1() - .py_0p5() - .child(cell) + if table_context.disable_base_cell_style { + div() + .when_some(width, |this, width| this.w(width)) + .when(width.is_none(), |this| this.flex_1()) + .overflow_hidden() + .child(cell) + } else { + base_cell_style_text(width, table_context.use_ui_font, cx) + .px_1() + .py_0p5() + .child(cell) + } }), ); @@ -1071,6 +1098,7 @@ pub struct TableRenderContext { pub column_widths: Option>, pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, pub use_ui_font: bool, + pub disable_base_cell_style: bool, } impl TableRenderContext { @@ -1083,6 +1111,7 @@ impl TableRenderContext { column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), use_ui_font: table.use_ui_font, + disable_base_cell_style: table.disable_base_cell_style, } } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index cf8df08c010bfe643b93b5628cf520ee2ec1dd8b..c04e10636f9088cf5f12dbda526a4e933a5e37e3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -94,6 +94,7 @@ copilot.workspace = true copilot_chat.workspace = true copilot_ui.workspace = true crashes.workspace = true +csv_preview.workspace = true dap_adapters.workspace = true db.workspace = true debug_adapter_extension.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e93bd92d041a18e927e1560379bcdb2886605874..38238d8af519c0506ab451bccaa1abe3a893e4c9 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -715,6 +715,7 @@ fn main() { git_graph::init(cx); feedback::init(cx); markdown_preview::init(cx); + csv_preview::init(cx); svg_preview::init(cx); onboarding::init(cx); settings_ui::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 55f185aae13e49c6b90610a50ad197ee47ee8a98..a0a6e424d46790ad49c860377c5d1e711aae6b61 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4809,6 +4809,7 @@ mod tests { "console", "context_server", "copilot", + "csv", "debug_panel", "debugger", "dev", diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index 5d43e79542357977b06fbbd884472f94ad3595c8..01e2d164d7d7a8a81e64ab77ad646111e4baacd7 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -1,3 +1,8 @@ +use csv_preview::{ + CsvPreviewView, OpenPreview as CsvOpenPreview, OpenPreviewToTheSide as CsvOpenPreviewToTheSide, + TabularDataPreviewFeatureFlag, +}; +use feature_flags::FeatureFlagAppExt as _; use gpui::{AnyElement, Modifiers, WeakEntity}; use markdown_preview::{ OpenPreview as MarkdownOpenPreview, OpenPreviewToTheSide as MarkdownOpenPreviewToTheSide, @@ -16,6 +21,7 @@ use super::QuickActionBar; enum PreviewType { Markdown, Svg, + Csv, } impl QuickActionBar { @@ -35,6 +41,10 @@ impl QuickActionBar { } else if SvgPreviewView::resolve_active_item_as_svg_buffer(workspace, cx).is_some() { preview_type = Some(PreviewType::Svg); + } else if cx.has_flag::() + && CsvPreviewView::resolve_active_item_as_csv_editor(workspace, cx).is_some() + { + preview_type = Some(PreviewType::Csv); } }); } @@ -57,6 +67,13 @@ impl QuickActionBar { Box::new(SvgOpenPreviewToTheSide) as Box, &svg_preview::OpenPreview as &dyn gpui::Action, ), + PreviewType::Csv => ( + "toggle-csv-preview", + "Preview CSV", + Box::new(CsvOpenPreview) as Box, + Box::new(CsvOpenPreviewToTheSide) as Box, + &csv_preview::OpenPreview as &dyn gpui::Action, + ), }; let alt_click = gpui::Keystroke { From 62b9a98ddbc73c5a0f9feb56b6f5b410903aa418 Mon Sep 17 00:00:00 2001 From: Daniel Llamas Date: Tue, 3 Mar 2026 09:06:22 -0600 Subject: [PATCH 06/74] agent_ui: Make file mention chips clickable to open files (#46751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Makes file mention chips in the AI chat input clickable to open the referenced files. Previously, chips like `@README.md` were purely visual indicators with no interaction. ### Changes - **Clickable mention chips**: Users can now click on file mentions in the chat input to open those files in the editor - **Support for all mention types**: - Files → Opens in editor - Files with line numbers → Opens and scrolls to line - Directories → Reveals in project panel - Threads → Navigates to thread - Rules → Opens rules library - URLs → Opens in browser - **Handles files outside workspace**: Falls back to `open_abs_path()` for files not in the current workspace ### Implementation Threads `MentionUri` and `WeakEntity` through the crease rendering pipeline: 1. Updated `insert_crease_for_mention()` to accept mention URI and workspace references 2. Added click handler to `MentionCrease` component using `.when()` for conditional attachment 3. Implemented file opening helpers that mirror the existing `thread_view.rs::open_link()` logic ### Demo https://github.com/user-attachments/assets/21b2afb7-7a86-4a0a-aba1-e24bb1b650c2 ### Testing Manually tested: - [x] Clicking `@README.md` opens file - [x] Clicking file with line numbers navigates correctly - [x] Clicking directory reveals in project panel - [x] Files outside workspace open via absolute path ### Files Changed - `crates/agent_ui/src/mention_set.rs` - Thread URI/workspace through pipeline - `crates/agent_ui/src/ui/mention_crease.rs` - Add click handler and file opening logic - `crates/agent_ui/src/acp/message_editor.rs` - Update call sites ### Review feedback addressed - Replaced `.when()` + `unwrap()` with `.when_some()` + `Option::zip()` (`0e36efb4eb`) - De-duplicated `open_file` and `open_file_at_line` into a single function with `Option>` (`dbcbb69a4b`) - Rebased onto latest `main` and resolved conflicts Also update item 2 under Implementation from: _Added click handler to MentionCrease component using `.when()` for conditional attachment_ to: _Added click handler to MentionCrease component using `.when_some()` with `Option::zip()` for conditional attachment_ ### Release Notes: - agent: File mention chips in the chat input are now clickable and will open the referenced files in the editor. Closes #46746 --------- Co-authored-by: Claude Opus 4.6 --- crates/agent_ui/src/mention_set.rs | 18 ++ crates/agent_ui/src/message_editor.rs | 8 + crates/agent_ui/src/ui/mention_crease.rs | 199 ++++++++++++++++++++++- 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 58e7e4cdfc196862bb3b8936f8582ba1ad54bda5..792bfc11a63471e02b22835823fa8c59cdfc9bcf 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -234,6 +234,8 @@ impl MentionSet { mention_uri.name().into(), IconName::Image.path().into(), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(workspace.downgrade()), Some(image), editor.clone(), window, @@ -247,6 +249,8 @@ impl MentionSet { crease_text, mention_uri.icon_path(cx), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(workspace.downgrade()), None, editor.clone(), window, @@ -699,6 +703,8 @@ pub(crate) async fn insert_images_as_context( MentionUri::PastedImage.name().into(), IconName::Image.path().into(), None, + None, + None, Some(Task::ready(Ok(image.clone())).shared()), editor.clone(), window, @@ -810,6 +816,8 @@ pub(crate) fn insert_crease_for_mention( crease_label: SharedString, crease_icon: SharedString, crease_tooltip: Option, + mention_uri: Option, + workspace: Option>, image: Option, String>>>>, editor: Entity, window: &mut Window, @@ -830,6 +838,8 @@ pub(crate) fn insert_crease_for_mention( crease_label.clone(), crease_icon.clone(), crease_tooltip, + mention_uri.clone(), + workspace.clone(), start..end, rx, image, @@ -1029,6 +1039,8 @@ fn render_mention_fold_button( label: SharedString, icon: SharedString, tooltip: Option, + mention_uri: Option, + workspace: Option>, range: Range, mut loading_finished: postage::barrier::Receiver, image_task: Option, String>>>>, @@ -1049,6 +1061,8 @@ fn render_mention_fold_button( label, icon, tooltip, + mention_uri: mention_uri.clone(), + workspace: workspace.clone(), range, editor, loading: Some(loading), @@ -1063,6 +1077,8 @@ struct LoadingContext { label: SharedString, icon: SharedString, tooltip: Option, + mention_uri: Option, + workspace: Option>, range: Range, editor: WeakEntity, loading: Option>, @@ -1079,6 +1095,8 @@ impl Render for LoadingContext { let id = ElementId::from(("loading_context", self.id)); MentionCrease::new(id, self.icon.clone(), self.label.clone()) + .mention_uri(self.mention_uri.clone()) + .workspace(self.workspace.clone()) .is_toggled(is_in_text_selection) .is_loading(self.loading.is_some()) .when_some(self.tooltip.clone(), |this, tooltip_text| { diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 50b297847b43e4d147978fbcf14dce492fc572d0..36d18a5843dac6d7ae52b591a2e5a402093ac118 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -722,6 +722,8 @@ impl MessageEditor { crease_text.into(), mention_uri.icon_path(cx), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(self.workspace.clone()), None, self.editor.clone(), window, @@ -833,6 +835,8 @@ impl MessageEditor { mention_uri.name().into(), mention_uri.icon_path(cx), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(self.workspace.clone()), None, self.editor.clone(), window, @@ -1014,6 +1018,8 @@ impl MessageEditor { mention_uri.name().into(), mention_uri.icon_path(cx), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(self.workspace.clone()), None, self.editor.clone(), window, @@ -1370,6 +1376,8 @@ impl MessageEditor { mention_uri.name().into(), mention_uri.icon_path(cx), mention_uri.tooltip_text(), + Some(mention_uri.clone()), + Some(self.workspace.clone()), None, self.editor.clone(), window, diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index 2d464039dc552203ad76979239673ec27d5568c7..0a61b8e4ef2ec69714f158a72f83cc0528cc8a8f 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -1,15 +1,25 @@ -use std::time::Duration; +use std::{ops::RangeInclusive, path::PathBuf, time::Duration}; -use gpui::{Animation, AnimationExt, AnyView, IntoElement, Window, pulsating_between}; +use acp_thread::MentionUri; +use agent_client_protocol as acp; +use editor::{Editor, SelectionEffects, scroll::Autoscroll}; +use gpui::{ + Animation, AnimationExt, AnyView, Context, IntoElement, WeakEntity, Window, pulsating_between, +}; +use prompt_store::PromptId; +use rope::Point; use settings::Settings; use theme::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; +use workspace::{OpenOptions, Workspace}; #[derive(IntoElement)] pub struct MentionCrease { id: ElementId, icon: SharedString, label: SharedString, + mention_uri: Option, + workspace: Option>, is_toggled: bool, is_loading: bool, tooltip: Option, @@ -26,6 +36,8 @@ impl MentionCrease { id: id.into(), icon: icon.into(), label: label.into(), + mention_uri: None, + workspace: None, is_toggled: false, is_loading: false, tooltip: None, @@ -33,6 +45,16 @@ impl MentionCrease { } } + pub fn mention_uri(mut self, mention_uri: Option) -> Self { + self.mention_uri = mention_uri; + self + } + + pub fn workspace(mut self, workspace: Option>) -> Self { + self.workspace = workspace; + self + } + pub fn is_toggled(mut self, is_toggled: bool) -> Self { self.is_toggled = is_toggled; self @@ -76,6 +98,14 @@ impl RenderOnce for MentionCrease { .height(button_height) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .toggle_state(self.is_toggled) + .when_some( + self.mention_uri.clone().zip(self.workspace.clone()), + |this, (mention_uri, workspace)| { + this.on_click(move |_event, window, cx| { + open_mention_uri(mention_uri.clone(), &workspace, window, cx); + }) + }, + ) .child( h_flex() .pb_px() @@ -114,3 +144,168 @@ impl RenderOnce for MentionCrease { }) } } + +fn open_mention_uri( + mention_uri: MentionUri, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let Some(workspace) = workspace.upgrade() else { + return; + }; + + workspace.update(cx, |workspace, cx| match mention_uri { + MentionUri::File { abs_path } => { + open_file(workspace, abs_path, None, window, cx); + } + MentionUri::Symbol { + abs_path, + line_range, + .. + } + | MentionUri::Selection { + abs_path: Some(abs_path), + line_range, + } => { + open_file(workspace, abs_path, Some(line_range), window, cx); + } + MentionUri::Directory { abs_path } => { + reveal_in_project_panel(workspace, abs_path, cx); + } + MentionUri::Thread { id, name } => { + open_thread(workspace, id, name, window, cx); + } + MentionUri::TextThread { .. } => {} + MentionUri::Rule { id, .. } => { + open_rule(workspace, id, window, cx); + } + MentionUri::Fetch { url } => { + cx.open_url(url.as_str()); + } + MentionUri::PastedImage + | MentionUri::Selection { abs_path: None, .. } + | MentionUri::Diagnostics { .. } + | MentionUri::TerminalSelection { .. } + | MentionUri::GitDiff { .. } => {} + }); +} + +fn open_file( + workspace: &mut Workspace, + abs_path: PathBuf, + line_range: Option>, + window: &mut Window, + cx: &mut Context, +) { + let project = workspace.project(); + + if let Some(project_path) = + project.update(cx, |project, cx| project.find_project_path(&abs_path, cx)) + { + let item = workspace.open_path(project_path, None, true, window, cx); + if let Some(line_range) = line_range { + window + .spawn(cx, async move |cx| { + let Some(editor) = item.await?.downcast::() else { + return Ok(()); + }; + editor + .update_in(cx, |editor, window, cx| { + let range = Point::new(*line_range.start(), 0) + ..Point::new(*line_range.start(), 0); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges(vec![range]), + ); + }) + .ok(); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } else { + item.detach_and_log_err(cx); + } + } else if abs_path.exists() { + workspace + .open_abs_path( + abs_path, + OpenOptions { + focus: Some(true), + ..Default::default() + }, + window, + cx, + ) + .detach_and_log_err(cx); + } +} + +fn reveal_in_project_panel( + workspace: &mut Workspace, + abs_path: PathBuf, + cx: &mut Context, +) { + let project = workspace.project(); + let Some(entry_id) = project.update(cx, |project, cx| { + let path = project.find_project_path(&abs_path, cx)?; + project.entry_for_path(&path, cx).map(|entry| entry.id) + }) else { + return; + }; + + project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(entry_id)); + }); +} + +fn open_thread( + workspace: &mut Workspace, + id: acp::SessionId, + name: String, + window: &mut Window, + cx: &mut Context, +) { + use crate::AgentPanel; + use acp_thread::AgentSessionInfo; + + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + panel.update(cx, |panel, cx| { + panel.load_agent_thread( + AgentSessionInfo { + session_id: id, + cwd: None, + title: Some(name.into()), + updated_at: None, + meta: None, + }, + window, + cx, + ) + }); +} + +fn open_rule( + _workspace: &mut Workspace, + id: PromptId, + window: &mut Window, + cx: &mut Context, +) { + use zed_actions::assistant::OpenRulesLibrary; + + let PromptId::User { uuid } = id else { + return; + }; + + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: Some(uuid.0), + }), + cx, + ); +} From 7c9a9d40c06c58c30d555d4398224a4737cd5f98 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 3 Mar 2026 10:25:36 -0500 Subject: [PATCH 07/74] Add "Start Thread in New Worktree" (#49141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the thread target selector in the agent panel behind the `agent-git-worktrees` flag: Screenshot 2026-03-02 at 11 50 47 PM - Add a "Start Thread In..." dropdown to the agent panel toolbar, gated behind `AgentV2FeatureFlag` - Options: "Local Project" (default) and "New Worktree" - The "New Worktree" option is disabled when there's no git repository or in collab mode Closes AI-34 Release Notes: - N/A --------- Signed-off-by: Xiaobo Liu Co-authored-by: Oleksiy Syvokon Co-authored-by: Ben Brandt Co-authored-by: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Co-authored-by: Remco Smits Co-authored-by: morgankrey Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Ben Kunkle Co-authored-by: Finn Evers Co-authored-by: Bennet Bo Fenner Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Co-authored-by: cameron Co-authored-by: Max Brunsfeld Co-authored-by: John Tur Co-authored-by: Conrad Irwin Co-authored-by: Wuji Chen Co-authored-by: Claude Co-authored-by: Smit Barmase Co-authored-by: Cole Miller Co-authored-by: Kasper Nyhus Co-authored-by: dino Co-authored-by: Anthony Eid Co-authored-by: Josh Robson Chase Co-authored-by: ozacod <47009516+ozacod@users.noreply.github.com> Co-authored-by: ozacod Co-authored-by: Xiaobo Liu Co-authored-by: Lena <241371603+zelenenka@users.noreply.github.com> Co-authored-by: 0x2CA <2478557459@qq.com> Co-authored-by: Joseph T. Lyons Co-authored-by: Albab Hasan <155961300+Albab-Hasan@users.noreply.github.com> Co-authored-by: KyleBarton Co-authored-by: Kunall Banerjee Co-authored-by: Lukas Wirth Co-authored-by: Tom Houlé <13155277+tomhoule@users.noreply.github.com> Co-authored-by: Nikhil Pandey Co-authored-by: Mikayla Maki Co-authored-by: dancer <144584931+dancer@users.noreply.github.com> Co-authored-by: Kirill Bulatov Co-authored-by: Danilo Leal --- Cargo.lock | 1 + crates/agent_ui/Cargo.toml | 1 + crates/agent_ui/src/agent_panel.rs | 1507 +++++++++++++++-- crates/agent_ui/src/agent_ui.rs | 16 +- crates/agent_ui/src/connection_view.rs | 16 +- .../src/connection_view/thread_view.rs | 93 +- crates/collab/src/db/queries/projects.rs | 3 +- crates/collab/src/db/queries/rooms.rs | 3 +- crates/feature_flags/src/flags.rs | 10 + crates/git/src/repository.rs | 48 + crates/git_ui/src/worktree_picker.rs | 4 +- crates/project/src/git_store.rs | 71 +- crates/proto/proto/git.proto | 1 + crates/workspace/src/persistence/model.rs | 12 +- crates/workspace/src/workspace.rs | 171 +- crates/zed/src/visual_test_runner.rs | 649 ++++++- crates/zed/src/zed.rs | 28 +- 17 files changed, 2379 insertions(+), 255 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99347bd08f0d5b3ae13ab352612e3876a3cf6a11..96caec077edd4bdf8c02a3e1ff1fc10340d2b9b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,7 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", + "git", "gpui", "gpui_tokio", "html_to_markdown", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 2a31781054fd29b30a3c8119e87491edbfb1e658..3e46e14b53c46a2aec3ac9552246a10ffc2aeee9 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -58,6 +58,7 @@ feature_flags.workspace = true file_icons.workspace = true fs.workspace = true futures.workspace = true +git.workspace = true fuzzy.workspace = true gpui.workspace = true gpui_tokio.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 7097e5be156eb33382a1a0f47c1b4256c84ce9b1..c5c1c345318b6f88c59ba2886507324e83d36ad3 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,6 +1,6 @@ use std::{ ops::Range, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::{ Arc, @@ -22,15 +22,18 @@ use project::{ use serde::{Deserialize, Serialize}; use settings::{LanguageModelProviderSetting, LanguageModelSelection}; +use feature_flags::{AgentGitWorktreesFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _}; use zed_actions::agent::{OpenClaudeAgentOnboardingModal, ReauthenticateAgent, ReviewBranchDiff}; +use crate::ManageProfiles; use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal}; use crate::{ AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow, InlineAssistant, LoadThreadFromClipboard, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, - OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ToggleNavigationMenu, - ToggleNewThreadMenu, ToggleOptionsMenu, + OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn, + ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu, agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, + connection_view::{AcpThreadViewEvent, ThreadView}, slash_command::SlashCommandCompletionProvider, text_thread_editor::{AgentPanelDelegate, TextThreadEditor, make_lsp_adapter_delegate}, ui::EndTrialUpsell, @@ -42,7 +45,6 @@ use crate::{ ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent, text_thread_history::{TextThreadHistory, TextThreadHistoryEvent}, }; -use crate::{ManageProfiles, connection_view::ThreadView}; use agent_settings::AgentSettings; use ai_onboarding::AgentPanelOnboarding; use anyhow::{Result, anyhow}; @@ -54,6 +56,7 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; use fs::Fs; +use git::repository::validate_worktree_directory; use gpui::{ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner, DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels, @@ -61,15 +64,17 @@ use gpui::{ }; use language::LanguageRegistry; use language_model::{ConfigurationError, LanguageModelRegistry}; +use project::project_settings::ProjectSettings; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; +use rand::Rng as _; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; use theme::ThemeSettings; use ui::{ - Callout, ContextMenu, ContextMenuEntry, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, - Tooltip, prelude::*, utils::WithRemSize, + Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, + PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -123,6 +128,8 @@ struct SerializedAgentPanel { selected_agent: Option, #[serde(default)] last_active_thread: Option, + #[serde(default)] + start_thread_in: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -324,6 +331,13 @@ pub fn init(cx: &mut App) { cx, ); }); + }) + .register_action(|workspace, action: &StartThreadIn, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(action, cx); + }); + } }); }, ) @@ -371,6 +385,10 @@ pub enum AgentType { } impl AgentType { + pub fn is_native(&self) -> bool { + matches!(self, Self::NativeAgent) + } + fn label(&self) -> SharedString { match self { Self::NativeAgent | Self::TextThread => "Zed Agent".into(), @@ -395,6 +413,29 @@ impl From for AgentType { } } +impl StartThreadIn { + fn label(&self) -> SharedString { + match self { + Self::LocalProject => "Local Project".into(), + Self::NewWorktree => "New Worktree".into(), + } + } + + fn icon(&self) -> IconName { + match self { + Self::LocalProject => IconName::Screen, + Self::NewWorktree => IconName::GitBranchPlus, + } + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub enum WorktreeCreationStatus { + Creating, + Error(SharedString), +} + impl ActiveView { pub fn which_font_size_used(&self) -> WhichFontSize { match self { @@ -515,6 +556,7 @@ pub struct AgentPanel { previous_view: Option, _active_view_observation: Option, new_thread_menu_handle: PopoverMenuHandle, + start_thread_in_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, @@ -525,6 +567,10 @@ pub struct AgentPanel { pending_serialization: Option>>, onboarding: Entity, selected_agent: AgentType, + start_thread_in: StartThreadIn, + worktree_creation_status: Option, + _thread_view_subscription: Option, + _worktree_creation_task: Option>, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, on_boarding_upsell_dismissed: AtomicBool, @@ -538,6 +584,7 @@ impl AgentPanel { let width = self.width; let selected_agent = self.selected_agent.clone(); + let start_thread_in = Some(self.start_thread_in); let last_active_thread = self.active_agent_thread(cx).map(|thread| { let thread = thread.read(cx); @@ -561,6 +608,7 @@ impl AgentPanel { width, selected_agent: Some(selected_agent), last_active_thread, + start_thread_in, }, ) .await?; @@ -605,6 +653,37 @@ impl AgentPanel { })? .await?; + let last_active_thread = if let Some(thread_info) = serialized_panel + .as_ref() + .and_then(|p| p.last_active_thread.clone()) + { + if thread_info.agent_type.is_native() { + let session_id = acp::SessionId::new(thread_info.session_id.clone()); + let load_result = cx.update(|_window, cx| { + let thread_store = ThreadStore::global(cx); + thread_store.update(cx, |store, cx| store.load_thread(session_id, cx)) + }); + let thread_exists = if let Ok(task) = load_result { + task.await.ok().flatten().is_some() + } else { + false + }; + if thread_exists { + Some(thread_info) + } else { + log::warn!( + "last active thread {} not found in database, skipping restoration", + thread_info.session_id + ); + None + } + } else { + Some(thread_info) + } + } else { + None + }; + let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); @@ -615,44 +694,45 @@ impl AgentPanel { if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } + if let Some(start_thread_in) = serialized_panel.start_thread_in { + let is_worktree_flag_enabled = + cx.has_flag::(); + let is_valid = match &start_thread_in { + StartThreadIn::LocalProject => true, + StartThreadIn::NewWorktree => { + let project = panel.project.read(cx); + is_worktree_flag_enabled && !project.is_via_collab() + } + }; + if is_valid { + panel.start_thread_in = start_thread_in; + } else { + log::info!( + "deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject", + start_thread_in, + ); + } + } cx.notify(); }); } - panel - })?; - - if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { - let session_id = acp::SessionId::new(thread_info.session_id.clone()); - let load_task = panel.update(cx, |panel, cx| { - let thread_store = panel.thread_store.clone(); - thread_store.update(cx, |store, cx| store.load_thread(session_id, cx)) - }); - let thread_exists = load_task - .await - .map(|thread: Option| thread.is_some()) - .unwrap_or(false); - - if thread_exists { - panel.update_in(cx, |panel, window, cx| { - panel.selected_agent = thread_info.agent_type.clone(); - let session_info = AgentSessionInfo { - session_id: acp::SessionId::new(thread_info.session_id), - cwd: thread_info.cwd, - title: thread_info.title.map(SharedString::from), - updated_at: None, - meta: None, - }; + if let Some(thread_info) = last_active_thread { + let agent_type = thread_info.agent_type.clone(); + let session_info = AgentSessionInfo { + session_id: acp::SessionId::new(thread_info.session_id), + cwd: thread_info.cwd, + title: thread_info.title.map(SharedString::from), + updated_at: None, + meta: None, + }; + panel.update(cx, |panel, cx| { + panel.selected_agent = agent_type; panel.load_agent_thread(session_info, window, cx); - })?; - } else { - log::error!( - "could not restore last active thread: \ - no thread found in database with ID {:?}", - thread_info.session_id - ); + }); } - } + panel + })?; Ok(panel) }) @@ -800,6 +880,7 @@ impl AgentPanel { previous_view: None, _active_view_observation: None, new_thread_menu_handle: PopoverMenuHandle::default(), + start_thread_in_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, @@ -813,6 +894,10 @@ impl AgentPanel { text_thread_history, thread_store, selected_agent: AgentType::default(), + start_thread_in: StartThreadIn::default(), + worktree_creation_status: None, + _thread_view_subscription: None, + _worktree_creation_task: None, show_trust_workspace_message: false, last_configuration_error_telemetry: None, on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), @@ -1044,7 +1129,7 @@ impl AgentPanel { let server = ext_agent.server(fs, thread_store); this.update_in(cx, |agent_panel, window, cx| { - agent_panel._external_thread( + agent_panel.create_external_thread( server, resume_thread, initial_content, @@ -1618,15 +1703,28 @@ impl AgentPanel { self.active_view = new_view; } + // Subscribe to the active ThreadView's events (e.g. FirstSendRequested) + // so the panel can intercept the first send for worktree creation. + // Re-subscribe whenever the ConnectionView changes, since the inner + // ThreadView may have been replaced (e.g. navigating between threads). self._active_view_observation = match &self.active_view { ActiveView::AgentThread { server_view } => { - Some(cx.observe(server_view, |this, _, cx| { - cx.emit(AgentPanelEvent::ActiveViewChanged); - this.serialize(cx); - cx.notify(); - })) + self._thread_view_subscription = + Self::subscribe_to_active_thread_view(server_view, window, cx); + Some( + cx.observe_in(server_view, window, |this, server_view, window, cx| { + this._thread_view_subscription = + Self::subscribe_to_active_thread_view(&server_view, window, cx); + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + cx.notify(); + }), + ) + } + _ => { + self._thread_view_subscription = None; + None } - _ => None, }; let is_in_agent_history = matches!( @@ -1740,6 +1838,56 @@ impl AgentPanel { self.selected_agent.clone() } + fn subscribe_to_active_thread_view( + server_view: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> Option { + server_view.read(cx).active_thread().cloned().map(|tv| { + cx.subscribe_in( + &tv, + window, + |this, view, event: &AcpThreadViewEvent, window, cx| match event { + AcpThreadViewEvent::FirstSendRequested { content } => { + this.handle_first_send_requested(view.clone(), content.clone(), window, cx); + } + }, + ) + }) + } + + pub fn start_thread_in(&self) -> &StartThreadIn { + &self.start_thread_in + } + + fn set_start_thread_in(&mut self, action: &StartThreadIn, cx: &mut Context) { + if matches!(action, StartThreadIn::NewWorktree) + && !cx.has_flag::() + { + return; + } + + let new_target = match *action { + StartThreadIn::LocalProject => StartThreadIn::LocalProject, + StartThreadIn::NewWorktree => { + if !self.project_has_git_repository(cx) { + log::error!( + "set_start_thread_in: cannot use NewWorktree without a git repository" + ); + return; + } + if self.project.read(cx).is_via_collab() { + log::error!("set_start_thread_in: cannot use NewWorktree in a collab project"); + return; + } + StartThreadIn::NewWorktree + } + }; + self.start_thread_in = new_target; + self.serialize(cx); + cx.notify(); + } + fn selected_external_agent(&self) -> Option { match &self.selected_agent { AgentType::NativeAgent => Some(ExternalAgent::NativeAgent), @@ -1830,7 +1978,7 @@ impl AgentPanel { self.external_thread(Some(agent), Some(thread), None, window, cx); } - fn _external_thread( + pub(crate) fn create_external_thread( &mut self, server: Rc, resume_thread: Option, @@ -1869,140 +2017,616 @@ impl AgentPanel { self.set_active_view(ActiveView::AgentThread { server_view }, true, window, cx); } -} -impl Focusable for AgentPanel { - fn focus_handle(&self, cx: &App) -> FocusHandle { - match &self.active_view { - ActiveView::Uninitialized => self.focus_handle.clone(), - ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), - ActiveView::History { kind } => match kind { - HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), - HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), - }, - ActiveView::TextThread { - text_thread_editor, .. - } => text_thread_editor.focus_handle(cx), - ActiveView::Configuration => { - if let Some(configuration) = self.configuration.as_ref() { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } + fn active_thread_has_messages(&self, cx: &App) -> bool { + self.active_agent_thread(cx) + .is_some_and(|thread| !thread.read(cx).entries().is_empty()) + } + + fn handle_first_send_requested( + &mut self, + thread_view: Entity, + content: Vec, + window: &mut Window, + cx: &mut Context, + ) { + if self.start_thread_in == StartThreadIn::NewWorktree { + self.handle_worktree_creation_requested(content, window, cx); + } else { + cx.defer_in(window, move |_this, window, cx| { + thread_view.update(cx, |thread_view, cx| { + let editor = thread_view.message_editor.clone(); + thread_view.send_impl(editor, window, cx); + }); + }); } } -} -fn agent_panel_dock_position(cx: &App) -> DockPosition { - AgentSettings::get_global(cx).dock.into() -} + fn generate_agent_branch_name() -> String { + let mut rng = rand::rng(); + let id: String = (0..8) + .map(|_| { + let idx: u8 = rng.random_range(0..36); + if idx < 10 { + (b'0' + idx) as char + } else { + (b'a' + idx - 10) as char + } + }) + .collect(); + format!("agent-{id}") + } -pub enum AgentPanelEvent { - ActiveViewChanged, -} + /// Partitions the project's visible worktrees into git-backed repositories + /// and plain (non-git) paths. Git repos will have worktrees created for + /// them; non-git paths are carried over to the new workspace as-is. + /// + /// When multiple worktrees map to the same repository, the most specific + /// match wins (deepest work directory path), with a deterministic + /// tie-break on entity id. Each repository appears at most once. + fn classify_worktrees( + &self, + cx: &App, + ) -> (Vec>, Vec) { + let project = &self.project; + let repositories = project.read(cx).repositories(cx).clone(); + let mut git_repos: Vec> = Vec::new(); + let mut non_git_paths: Vec = Vec::new(); + let mut seen_repo_ids = std::collections::HashSet::new(); + + for worktree in project.read(cx).visible_worktrees(cx) { + let wt_path = worktree.read(cx).abs_path(); + + let matching_repo = repositories + .iter() + .filter_map(|(id, repo)| { + let work_dir = repo.read(cx).work_directory_abs_path.clone(); + if wt_path.starts_with(work_dir.as_ref()) + || work_dir.starts_with(wt_path.as_ref()) + { + Some((*id, repo.clone(), work_dir.as_ref().components().count())) + } else { + None + } + }) + .max_by( + |(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| { + left_depth + .cmp(right_depth) + .then_with(|| left_id.cmp(right_id)) + }, + ); -impl EventEmitter for AgentPanel {} -impl EventEmitter for AgentPanel {} + if let Some((id, repo, _)) = matching_repo { + if seen_repo_ids.insert(id) { + git_repos.push(repo); + } + } else { + non_git_paths.push(wt_path.to_path_buf()); + } + } -impl Panel for AgentPanel { - fn persistent_name() -> &'static str { - "AgentPanel" + (git_repos, non_git_paths) } - fn panel_key() -> &'static str { - AGENT_PANEL_KEY - } + /// Kicks off an async git-worktree creation for each repository. Returns: + /// + /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the + /// receiver resolves once the git worktree command finishes. + /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used + /// later to remap open editor tabs into the new workspace. + fn start_worktree_creations( + git_repos: &[Entity], + branch_name: &str, + worktree_directory_setting: &str, + cx: &mut Context, + ) -> Result<( + Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + Vec<(PathBuf, PathBuf)>, + )> { + let mut creation_infos = Vec::new(); + let mut path_remapping = Vec::new(); + + for repo in git_repos { + let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| { + let original_repo = repo.original_repo_abs_path.clone(); + let directory = + validate_worktree_directory(&original_repo, worktree_directory_setting)?; + let new_path = directory.join(branch_name); + let receiver = repo.create_worktree(branch_name.to_string(), directory, None); + let work_dir = repo.work_directory_abs_path.clone(); + anyhow::Ok((work_dir, new_path, receiver)) + })?; + path_remapping.push((work_dir.to_path_buf(), new_path.clone())); + creation_infos.push((repo.clone(), new_path, receiver)); + } - fn position(&self, _window: &Window, cx: &App) -> DockPosition { - agent_panel_dock_position(cx) + Ok((creation_infos, path_remapping)) } - fn position_is_valid(&self, position: DockPosition) -> bool { - position != DockPosition::Bottom - } + /// Waits for every in-flight worktree creation to complete. If any + /// creation fails, all successfully-created worktrees are rolled back + /// (removed) so the project isn't left in a half-migrated state. + async fn await_and_rollback_on_failure( + creation_infos: Vec<( + Entity, + PathBuf, + futures::channel::oneshot::Receiver>, + )>, + cx: &mut AsyncWindowContext, + ) -> Result> { + let mut created_paths: Vec = Vec::new(); + let mut repos_and_paths: Vec<(Entity, PathBuf)> = + Vec::new(); + let mut first_error: Option = None; + + for (repo, new_path, receiver) in creation_infos { + match receiver.await { + Ok(Ok(())) => { + created_paths.push(new_path.clone()); + repos_and_paths.push((repo, new_path)); + } + Ok(Err(err)) => { + if first_error.is_none() { + first_error = Some(err); + } + } + Err(_canceled) => { + if first_error.is_none() { + first_error = Some(anyhow!("Worktree creation was canceled")); + } + } + } + } - fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { - settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { - settings - .agent - .get_or_insert_default() - .set_dock(position.into()); - }); - } + let Some(err) = first_error else { + return Ok(created_paths); + }; - fn size(&self, window: &Window, cx: &App) -> Pixels { - let settings = AgentSettings::get_global(cx); - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => { - self.width.unwrap_or(settings.default_width) + // Rollback all successfully created worktrees + let mut rollback_receivers = Vec::new(); + for (rollback_repo, rollback_path) in &repos_and_paths { + if let Ok(receiver) = cx.update(|_, cx| { + rollback_repo.update(cx, |repo, _cx| { + repo.remove_worktree(rollback_path.clone(), true) + }) + }) { + rollback_receivers.push((rollback_path.clone(), receiver)); } - DockPosition::Bottom => self.height.unwrap_or(settings.default_height), } - } - - fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { - match self.position(window, cx) { - DockPosition::Left | DockPosition::Right => self.width = size, - DockPosition::Bottom => self.height = size, + let mut rollback_failures: Vec = Vec::new(); + for (path, receiver) in rollback_receivers { + match receiver.await { + Ok(Ok(())) => {} + Ok(Err(rollback_err)) => { + log::error!( + "failed to rollback worktree at {}: {rollback_err}", + path.display() + ); + rollback_failures.push(format!("{}: {rollback_err}", path.display())); + } + Err(rollback_err) => { + log::error!( + "failed to rollback worktree at {}: {rollback_err}", + path.display() + ); + rollback_failures.push(format!("{}: {rollback_err}", path.display())); + } + } } - self.serialize(cx); - cx.notify(); + let mut error_message = format!("Failed to create worktree: {err}"); + if !rollback_failures.is_empty() { + error_message.push_str("\n\nFailed to clean up: "); + error_message.push_str(&rollback_failures.join(", ")); + } + Err(anyhow!(error_message)) } - fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { - if active && matches!(self.active_view, ActiveView::Uninitialized) { + fn set_worktree_creation_error( + &mut self, + message: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message)); + if matches!(self.active_view, ActiveView::Uninitialized) { let selected_agent = self.selected_agent.clone(); self.new_agent_thread(selected_agent, window, cx); } + cx.notify(); } - fn remote_id() -> Option { - Some(proto::PanelId::AssistantPanel) - } + fn handle_worktree_creation_requested( + &mut self, + content: Vec, + window: &mut Window, + cx: &mut Context, + ) { + if matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ) { + return; + } - fn icon(&self, _window: &Window, cx: &App) -> Option { - (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant) - } + self.worktree_creation_status = Some(WorktreeCreationStatus::Creating); + cx.notify(); - fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { - Some("Agent Panel") - } + let branch_name = Self::generate_agent_branch_name(); - fn toggle_action(&self) -> Box { - Box::new(ToggleFocus) - } + let (git_repos, non_git_paths) = self.classify_worktrees(cx); - fn activation_priority(&self) -> u32 { - 3 - } + if git_repos.is_empty() { + self.set_worktree_creation_error( + "No git repositories found in the project".into(), + window, + cx, + ); + return; + } - fn enabled(&self, cx: &App) -> bool { - AgentSettings::get_global(cx).enabled(cx) - } + let worktree_directory_setting = ProjectSettings::get_global(cx) + .git + .worktree_directory + .clone(); - fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { - self.zoomed - } + let (creation_infos, path_remapping) = match Self::start_worktree_creations( + &git_repos, + &branch_name, + &worktree_directory_setting, + cx, + ) { + Ok(result) => result, + Err(err) => { + self.set_worktree_creation_error( + format!("Failed to validate worktree directory: {err}").into(), + window, + cx, + ); + return; + } + }; - fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context) { - self.zoomed = zoomed; - cx.notify(); - } -} + let (dock_structure, open_file_paths) = self + .workspace + .upgrade() + .map(|workspace| { + let dock_structure = workspace.read(cx).capture_dock_state(window, cx); + let open_file_paths = workspace.read(cx).open_item_abs_paths(cx); + (dock_structure, open_file_paths) + }) + .unwrap_or_default(); -impl AgentPanel { - fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { - const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; + let workspace = self.workspace.clone(); + let window_handle = window + .window_handle() + .downcast::(); - let content = match &self.active_view { - ActiveView::AgentThread { server_view } => { - let is_generating_title = server_view - .read(cx) - .as_native_thread(cx) - .map_or(false, |t| t.read(cx).is_generating_title()); + let task = cx.spawn_in(window, async move |this, cx| { + let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await + { + Ok(paths) => paths, + Err(err) => { + this.update_in(cx, |this, window, cx| { + this.set_worktree_creation_error(format!("{err}").into(), window, cx); + })?; + return anyhow::Ok(()); + } + }; - if let Some(title_editor) = server_view + let mut all_paths = created_paths; + let has_non_git = !non_git_paths.is_empty(); + all_paths.extend(non_git_paths.iter().cloned()); + + let app_state = match workspace.upgrade() { + Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?, + None => { + this.update_in(cx, |this, window, cx| { + this.set_worktree_creation_error( + "Workspace no longer available".into(), + window, + cx, + ); + })?; + return anyhow::Ok(()); + } + }; + + let this_for_error = this.clone(); + if let Err(err) = Self::setup_new_workspace( + this, + all_paths, + app_state, + window_handle, + dock_structure, + open_file_paths, + path_remapping, + non_git_paths, + has_non_git, + content, + cx, + ) + .await + { + this_for_error + .update_in(cx, |this, window, cx| { + this.set_worktree_creation_error( + format!("Failed to set up workspace: {err}").into(), + window, + cx, + ); + }) + .log_err(); + } + anyhow::Ok(()) + }); + + self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move { + task.await.log_err(); + })); + } + + async fn setup_new_workspace( + this: WeakEntity, + all_paths: Vec, + app_state: Arc, + window_handle: Option>, + dock_structure: workspace::DockStructure, + open_file_paths: Vec, + path_remapping: Vec<(PathBuf, PathBuf)>, + non_git_paths: Vec, + has_non_git: bool, + content: Vec, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let init: Option< + Box) + Send>, + > = Some(Box::new(move |workspace, window, cx| { + workspace.set_dock_structure(dock_structure, window, cx); + })); + + let (new_window_handle, _) = cx + .update(|_window, cx| { + Workspace::new_local(all_paths, app_state, window_handle, None, init, false, cx) + })? + .await?; + + let new_workspace = new_window_handle.update(cx, |multi_workspace, _window, _cx| { + let workspaces = multi_workspace.workspaces(); + workspaces.last().cloned() + })?; + + let Some(new_workspace) = new_workspace else { + anyhow::bail!("New workspace was not added to MultiWorkspace"); + }; + + let panels_task = new_window_handle.update(cx, |_, _, cx| { + new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task()) + })?; + if let Some(task) = panels_task { + task.await.log_err(); + } + + let initial_content = AgentInitialContent::ContentBlock { + blocks: content, + auto_submit: true, + }; + + new_window_handle.update(cx, |_multi_workspace, window, cx| { + new_workspace.update(cx, |workspace, cx| { + if has_non_git { + let toast_id = workspace::notifications::NotificationId::unique::(); + workspace.show_toast( + workspace::Toast::new( + toast_id, + "Some project folders are not git repositories. \ + They were included as-is without creating a worktree.", + ), + cx, + ); + } + + let remapped_paths: Vec = open_file_paths + .iter() + .filter_map(|original_path| { + let best_match = path_remapping + .iter() + .filter_map(|(old_root, new_root)| { + original_path.strip_prefix(old_root).ok().map(|relative| { + (old_root.components().count(), new_root.join(relative)) + }) + }) + .max_by_key(|(depth, _)| *depth); + + if let Some((_, remapped_path)) = best_match { + return Some(remapped_path); + } + + for non_git in &non_git_paths { + if original_path.starts_with(non_git) { + return Some(original_path.clone()); + } + } + None + }) + .collect(); + + if !remapped_paths.is_empty() { + workspace + .open_paths( + remapped_paths, + workspace::OpenOptions::default(), + None, + window, + cx, + ) + .detach(); + } + + workspace.focus_panel::(window, cx); + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.external_thread(None, None, Some(initial_content), window, cx); + }); + } + }); + })?; + + new_window_handle.update(cx, |multi_workspace, _window, cx| { + multi_workspace.activate(new_workspace.clone(), cx); + })?; + + this.update_in(cx, |this, _window, cx| { + this.worktree_creation_status = None; + cx.notify(); + })?; + + anyhow::Ok(()) + } +} + +impl Focusable for AgentPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.active_view { + ActiveView::Uninitialized => self.focus_handle.clone(), + ActiveView::AgentThread { server_view, .. } => server_view.focus_handle(cx), + ActiveView::History { kind } => match kind { + HistoryKind::AgentThreads => self.acp_history.focus_handle(cx), + HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx), + }, + ActiveView::TextThread { + text_thread_editor, .. + } => text_thread_editor.focus_handle(cx), + ActiveView::Configuration => { + if let Some(configuration) = self.configuration.as_ref() { + configuration.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } + } + } +} + +fn agent_panel_dock_position(cx: &App) -> DockPosition { + AgentSettings::get_global(cx).dock.into() +} + +pub enum AgentPanelEvent { + ActiveViewChanged, +} + +impl EventEmitter for AgentPanel {} +impl EventEmitter for AgentPanel {} + +impl Panel for AgentPanel { + fn persistent_name() -> &'static str { + "AgentPanel" + } + + fn panel_key() -> &'static str { + AGENT_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + agent_panel_dock_position(cx) + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context) { + settings::update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings + .agent + .get_or_insert_default() + .set_dock(position.into()); + }); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.height.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, + } + self.serialize(cx); + cx.notify(); + } + + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active + && matches!(self.active_view, ActiveView::Uninitialized) + && !matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ) + { + let selected_agent = self.selected_agent.clone(); + self.new_agent_thread(selected_agent, window, cx); + } + } + + fn remote_id() -> Option { + Some(proto::PanelId::AssistantPanel) + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agent Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } + + fn activation_priority(&self) -> u32 { + 3 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) + } + + fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { + self.zoomed + } + + fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context) { + self.zoomed = zoomed; + cx.notify(); + } +} + +impl AgentPanel { + fn render_title_view(&self, _window: &mut Window, cx: &Context) -> AnyElement { + const LOADING_SUMMARY_PLACEHOLDER: &str = "Loading Summary…"; + + let content = match &self.active_view { + ActiveView::AgentThread { server_view } => { + let is_generating_title = server_view + .read(cx) + .as_native_thread(cx) + .map_or(false, |t| t.read(cx).is_generating_title()); + + if let Some(title_editor) = server_view .read(cx) .parent_thread(cx) .map(|r| r.read(cx).title_editor.clone()) @@ -2331,6 +2955,99 @@ impl AgentPanel { }) } + fn project_has_git_repository(&self, cx: &App) -> bool { + !self.project.read(cx).repositories(cx).is_empty() + } + + fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement { + let has_git_repo = self.project_has_git_repository(cx); + let is_via_collab = self.project.read(cx).is_via_collab(); + + let is_creating = matches!( + self.worktree_creation_status, + Some(WorktreeCreationStatus::Creating) + ); + + let current_target = self.start_thread_in; + let trigger_label = self.start_thread_in.label(); + + let icon = if self.start_thread_in_menu_handle.is_deployed() { + IconName::ChevronUp + } else { + IconName::ChevronDown + }; + + let trigger_button = Button::new("thread-target-trigger", trigger_label) + .label_size(LabelSize::Small) + .color(Color::Muted) + .icon(icon) + .icon_size(IconSize::XSmall) + .icon_position(IconPosition::End) + .icon_color(Color::Muted) + .disabled(is_creating); + + let dock_position = AgentSettings::get_global(cx).dock; + let documentation_side = match dock_position { + settings::DockPosition::Left => DocumentationSide::Right, + settings::DockPosition::Bottom | settings::DockPosition::Right => { + DocumentationSide::Left + } + }; + + PopoverMenu::new("thread-target-selector") + .trigger(trigger_button) + .anchor(gpui::Corner::BottomRight) + .with_handle(self.start_thread_in_menu_handle.clone()) + .menu(move |window, cx| { + let current_target = current_target; + Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { + let is_local_selected = current_target == StartThreadIn::LocalProject; + let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree; + + let new_worktree_disabled = !has_git_repo || is_via_collab; + + menu.header("Start Thread In…") + .item( + ContextMenuEntry::new("Local Project") + .icon(StartThreadIn::LocalProject.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_local_selected) + .handler(|window, cx| { + window + .dispatch_action(Box::new(StartThreadIn::LocalProject), cx); + }), + ) + .item({ + let entry = ContextMenuEntry::new("New Worktree") + .icon(StartThreadIn::NewWorktree.icon()) + .icon_color(Color::Muted) + .toggleable(IconPosition::End, is_new_worktree_selected) + .disabled(new_worktree_disabled) + .handler(|window, cx| { + window + .dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + }); + + if new_worktree_disabled { + entry.documentation_aside(documentation_side, move |_| { + let reason = if !has_git_repo { + "No git repository found in this project." + } else { + "Not available for remote/collab projects yet." + }; + Label::new(reason) + .color(Color::Muted) + .size(LabelSize::Small) + .into_any_element() + }) + } else { + entry + } + }) + })) + }) + } + fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); @@ -2718,6 +3435,7 @@ impl AgentPanel { }; let show_history_menu = self.history_kind_for_selected_agent(cx).is_some(); + let has_v2_flag = cx.has_flag::(); h_flex() .id("agent-panel-toolbar") @@ -2748,6 +3466,12 @@ impl AgentPanel { .gap(DynamicSpacing::Base02.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) .pr(DynamicSpacing::Base06.rems(cx)) + .when( + has_v2_flag + && cx.has_flag::() + && !self.active_thread_has_messages(cx), + |this| this.child(self.render_start_thread_in_selector(cx)), + ) .child(new_thread_menu) .when(show_history_menu, |this| { this.child(self.render_recent_entries_menu( @@ -2760,6 +3484,51 @@ impl AgentPanel { ) } + fn render_worktree_creation_status(&self, cx: &mut Context) -> Option { + let status = self.worktree_creation_status.as_ref()?; + match status { + WorktreeCreationStatus::Creating => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child(SpinnerLabel::new().size(LabelSize::Small)) + .child( + Label::new("Creating worktree…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ), + WorktreeCreationStatus::Error(message) => Some( + h_flex() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .py(DynamicSpacing::Base02.rems(cx)) + .gap_2() + .bg(cx.theme().colors().surface_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + .child( + Label::new(message.clone()) + .color(Color::Warning) + .size(LabelSize::Small) + .truncate(), + ) + .into_any_element(), + ), + } + } + fn should_render_trial_end_upsell(&self, cx: &mut Context) -> bool { if TrialEndUpsell::dismissed() { return false; @@ -3191,6 +3960,7 @@ impl Render for AgentPanel { } })) .child(self.render_toolbar(window, cx)) + .children(self.render_worktree_creation_status(cx)) .children(self.render_workspace_trust_message(cx)) .children(self.render_onboarding(window, cx)) .map(|parent| { @@ -3456,7 +4226,7 @@ impl AgentPanel { name: server.name(), }; - self._external_thread( + self.create_external_thread( server, None, None, workspace, project, ext_agent, window, cx, ); } @@ -3468,6 +4238,61 @@ impl AgentPanel { pub fn active_thread_view_for_tests(&self) -> Option<&Entity> { self.active_thread_view() } + + /// Sets the start_thread_in value directly, bypassing validation. + /// + /// This is a test-only helper for visual tests that need to show specific + /// start_thread_in states without requiring a real git repository. + pub fn set_start_thread_in_for_tests(&mut self, target: StartThreadIn, cx: &mut Context) { + self.start_thread_in = target; + cx.notify(); + } + + /// Returns the current worktree creation status. + /// + /// This is a test-only helper for visual tests. + pub fn worktree_creation_status_for_tests(&self) -> Option<&WorktreeCreationStatus> { + self.worktree_creation_status.as_ref() + } + + /// Sets the worktree creation status directly. + /// + /// This is a test-only helper for visual tests that need to show the + /// "Creating worktree…" spinner or error banners. + pub fn set_worktree_creation_status_for_tests( + &mut self, + status: Option, + cx: &mut Context, + ) { + self.worktree_creation_status = status; + cx.notify(); + } + + /// Opens the history view. + /// + /// This is a test-only helper that exposes the private `open_history()` + /// method for visual tests. + pub fn open_history_for_tests(&mut self, window: &mut Window, cx: &mut Context) { + self.open_history(window, cx); + } + + /// Opens the start_thread_in selector popover menu. + /// + /// This is a test-only helper for visual tests. + pub fn open_start_thread_in_menu_for_tests( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + self.start_thread_in_menu_handle.show(window, cx); + } + + /// Dismisses the start_thread_in dropdown menu. + /// + /// This is a test-only helper for visual tests. + pub fn close_start_thread_in_menu_for_tests(&mut self, cx: &mut Context) { + self.start_thread_in_menu_handle.hide(cx); + } } #[cfg(test)] @@ -3479,6 +4304,7 @@ mod tests { use fs::FakeFs; use gpui::{TestAppContext, VisualTestContext}; use project::Project; + use serde_json::json; use workspace::MultiWorkspace; #[gpui::test] @@ -3581,9 +4407,7 @@ mod tests { .expect("panel B load should succeed"); cx.run_until_parked(); - // Workspace A should restore width and agent type, but the thread - // should NOT be restored because the stub agent never persisted it - // to the database (the load-side validation skips missing threads). + // Workspace A should restore its thread, width, and agent type loaded_a.read_with(cx, |panel, _cx| { assert_eq!( panel.width, @@ -3594,6 +4418,10 @@ mod tests { panel.selected_agent, agent_type_a, "workspace A agent type should be restored" ); + assert!( + panel.active_thread_view().is_some(), + "workspace A should have its active thread restored" + ); }); // Workspace B should restore its own width and agent type, with no thread @@ -3663,4 +4491,383 @@ mod tests { cx.run_until_parked(); } + + #[gpui::test] + async fn test_thread_target_local_project(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Default thread target should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "default thread target should be LocalProject" + ); + }); + + // Start a new thread with the default LocalProject target. + // Use StubAgentServer so the thread connects immediately in tests. + panel.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + // MultiWorkspace should still have exactly one workspace (no worktree created). + multi_workspace + .read_with(cx, |multi_workspace, _cx| { + assert_eq!( + multi_workspace.workspaces().len(), + 1, + "LocalProject should not create a new workspace" + ); + }) + .unwrap(); + + // The thread should be active in the panel. + panel.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "a thread should be running in the current workspace" + ); + }); + + // The thread target should still be LocalProject (unchanged). + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "thread target should remain LocalProject" + ); + }); + + // No worktree creation status should be set. + panel.read_with(cx, |panel, _cx| { + assert!( + panel.worktree_creation_status.is_none(), + "no worktree creation should have occurred" + ); + }); + } + + #[gpui::test] + async fn test_thread_target_serialization_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Default should be LocalProject. + panel.read_with(cx, |panel, _cx| { + assert_eq!(*panel.start_thread_in(), StartThreadIn::LocalProject); + }); + + // Change thread target to NewWorktree. + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should be NewWorktree after set_thread_target" + ); + }); + + // Let serialization complete. + cx.run_until_parked(); + + // Load a fresh panel from the serialized data. + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_panel = + AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel load should succeed"); + cx.run_until_parked(); + + loaded_panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should survive serialization round-trip" + ); + }); + } + + #[gpui::test] + async fn test_thread_target_deserialization_falls_back_when_worktree_flag_disabled( + cx: &mut TestAppContext, + ) { + init_test(cx); + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.set_branch_name(Path::new("/project/.git"), Some("main")); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + workspace.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // Wait for the project to discover the git repository. + cx.run_until_parked(); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + panel.update(cx, |panel, cx| { + panel.set_start_thread_in(&StartThreadIn::NewWorktree, cx); + }); + + panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::NewWorktree, + "thread target should be NewWorktree before reload" + ); + }); + + // Let serialization complete. + cx.run_until_parked(); + + // Disable worktree flag and reload panel from serialized data. + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_panel = + AgentPanel::load(workspace.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel load should succeed"); + cx.run_until_parked(); + + loaded_panel.read_with(cx, |panel, _cx| { + assert_eq!( + *panel.start_thread_in(), + StartThreadIn::LocalProject, + "thread target should fall back to LocalProject when worktree flag is disabled" + ); + }); + } + + #[gpui::test] + async fn test_set_active_blocked_during_worktree_creation(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + fs.insert_tree( + "/project", + json!({ + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + cx.run_until_parked(); + + // Simulate worktree creation in progress and reset to Uninitialized + panel.update_in(cx, |panel, window, cx| { + panel.worktree_creation_status = Some(WorktreeCreationStatus::Creating); + panel.active_view = ActiveView::Uninitialized; + Panel::set_active(panel, true, window, cx); + assert!( + matches!(panel.active_view, ActiveView::Uninitialized), + "set_active should not create a thread while worktree is being created" + ); + }); + + // Clear the creation status and use open_external_thread_with_server + // (which bypasses new_agent_thread) to verify the panel can transition + // out of Uninitialized. We can't call set_active directly because + // new_agent_thread requires full agent server infrastructure. + panel.update_in(cx, |panel, window, cx| { + panel.worktree_creation_status = None; + panel.active_view = ActiveView::Uninitialized; + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!( + !matches!(panel.active_view, ActiveView::Uninitialized), + "panel should transition out of Uninitialized once worktree creation is cleared" + ); + }); + } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index ad778ca496f7815d0155f98187c8fad3e81365eb..58a8edca779daa50862549058a0068e2ddb7c5bf 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -55,7 +55,9 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate}; +pub use crate::agent_panel::{ + AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate, WorktreeCreationStatus, +}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -222,6 +224,18 @@ impl ExternalAgent { } } +/// Sets where new threads will run. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action, +)] +#[action(namespace = agent)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum StartThreadIn { + #[default] + LocalProject, + NewWorktree, +} + /// Content to initialize new external agent with. pub enum AgentInitialContent { ThreadSummary(acp_thread::AgentSessionInfo), diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index bc58120a964b7cb10eb4c779eb24fa8507030bc6..835ff611288c2bf6867a885ed2be8c6a66679cdb 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -26,10 +26,10 @@ use fs::Fs; use futures::FutureExt as _; use gpui::{ Action, Animation, AnimationExt, AnyView, App, ClickEvent, ClipboardItem, CursorStyle, - ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, ListOffset, ListState, ObjectFit, - PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, list, point, - pulsating_between, + ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, ListOffset, ListState, + ObjectFit, PlatformDisplay, ScrollHandle, SharedString, Subscription, Task, TextStyle, + WeakEntity, Window, WindowHandle, div, ease_in_out, img, linear_color_stop, linear_gradient, + list, point, pulsating_between, }; use language::Buffer; use language_model::LanguageModelRegistry; @@ -295,6 +295,12 @@ impl Conversation { } } +pub enum AcpServerViewEvent { + ActiveThreadChanged, +} + +impl EventEmitter for ConnectionView {} + pub struct ConnectionView { agent: Rc, agent_server_store: Entity, @@ -386,6 +392,7 @@ impl ConnectionView { if let Some(view) = self.active_thread() { view.focus_handle(cx).focus(window, cx); } + cx.emit(AcpServerViewEvent::ActiveThreadChanged); cx.notify(); } } @@ -524,6 +531,7 @@ impl ConnectionView { } self.server_state = state; + cx.emit(AcpServerViewEvent::ActiveThreadChanged); cx.notify(); } diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 2544305bc8f8666b897d11285ffa7711f3af8794..8ce4da360664774342c4167f7c8dfbce914b647e 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -1,6 +1,8 @@ use acp_thread::ContentBlock; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; + +use crate::StartThreadIn; use gpui::{Corner, List}; use language_model::{LanguageModelEffortLevel, Speed}; use settings::update_settings_file; @@ -191,6 +193,12 @@ impl DiffStats { } } +pub enum AcpThreadViewEvent { + FirstSendRequested { content: Vec }, +} + +impl EventEmitter for ThreadView {} + pub struct ThreadView { pub id: acp::SessionId, pub parent_id: Option, @@ -518,6 +526,24 @@ impl ThreadView { .thread(acp_thread.session_id(), cx) } + /// Resolves the message editor's contents into content blocks. For profiles + /// that do not enable any tools, directory mentions are expanded to inline + /// file contents since the agent can't read files on its own. + fn resolve_message_contents( + &self, + message_editor: &Entity, + cx: &mut App, + ) -> Task, Vec>)>> { + let expand = self.as_native_thread(cx).is_some_and(|thread| { + let thread = thread.read(cx); + AgentSettings::get_global(cx) + .profiles + .get(thread.profile()) + .is_some_and(|profile| profile.tools.is_empty()) + }); + message_editor.update(cx, |message_editor, cx| message_editor.contents(expand, cx)) + } + pub fn current_model_id(&self, cx: &App) -> Option { let selector = self.model_selector.as_ref()?; let model = selector.read(cx).active_model(cx)?; @@ -731,6 +757,46 @@ impl ThreadView { } let message_editor = self.message_editor.clone(); + + // Intercept the first send so the agent panel can capture the full + // content blocks — needed for "Start thread in New Worktree", + // which must create a workspace before sending the message there. + let intercept_first_send = self.thread.read(cx).entries().is_empty() + && !message_editor.read(cx).is_empty(cx) + && self + .workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).panel::(cx)) + .is_some_and(|panel| { + panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree + }); + + if intercept_first_send { + let content_task = self.resolve_message_contents(&message_editor, cx); + + cx.spawn(async move |this, cx| match content_task.await { + Ok((content, _tracked_buffers)) => { + if content.is_empty() { + return; + } + + this.update(cx, |_, cx| { + cx.emit(AcpThreadViewEvent::FirstSendRequested { content }); + }) + .ok(); + } + Err(error) => { + this.update(cx, |this, cx| { + this.handle_thread_error(error, cx); + }) + .ok(); + } + }) + .detach(); + + return; + } + let is_editor_empty = message_editor.read(cx).is_empty(cx); let is_generating = thread.read(cx).status() != ThreadStatus::Idle; @@ -794,18 +860,7 @@ impl ThreadView { window: &mut Window, cx: &mut Context, ) { - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - // Include full contents when using minimal profile - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); + let contents = self.resolve_message_contents(&message_editor, cx); self.thread_error.take(); self.thread_feedback.clear(); @@ -1140,21 +1195,11 @@ impl ThreadView { let is_idle = self.thread.read(cx).status() == acp_thread::ThreadStatus::Idle; if is_idle { - self.send_impl(message_editor.clone(), window, cx); + self.send_impl(message_editor, window, cx); return; } - let full_mention_content = self.as_native_thread(cx).is_some_and(|thread| { - let thread = thread.read(cx); - AgentSettings::get_global(cx) - .profiles - .get(thread.profile()) - .is_some_and(|profile| profile.tools.is_empty()) - }); - - let contents = message_editor.update(cx, |message_editor, cx| { - message_editor.contents(full_mention_content, cx) - }); + let contents = self.resolve_message_contents(&message_editor, cx); cx.spawn_in(window, async move |this, cx| { let (content, tracked_buffers) = contents.await?; diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index ed6325c62173358c8deac2dcd6289ce0b8ae5e71..fa3f99e1483e8a5d8410378493556b189eff78f1 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1002,7 +1002,7 @@ impl Database { repositories.push(proto::UpdateRepository { project_id: db_repository_entry.project_id.0 as u64, id: db_repository_entry.id as u64, - abs_path: db_repository_entry.abs_path, + abs_path: db_repository_entry.abs_path.clone(), entry_ids, updated_statuses, removed_statuses: Vec::new(), @@ -1015,6 +1015,7 @@ impl Database { stash_entries: Vec::new(), remote_upstream_url: db_repository_entry.remote_upstream_url.clone(), remote_origin_url: db_repository_entry.remote_origin_url.clone(), + original_repo_abs_path: Some(db_repository_entry.abs_path), }); } } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index d8fca0306f5b2ae5668a735db578061275192b58..7c007a570a0cb25c5302495d7342882eec0e1942 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -791,13 +791,14 @@ impl Database { head_commit_details, project_id: project_id.to_proto(), id: db_repository.id as u64, - abs_path: db_repository.abs_path, + abs_path: db_repository.abs_path.clone(), scan_id: db_repository.scan_id as u64, is_last_update: true, merge_message: db_repository.merge_message, stash_entries: Vec::new(), remote_upstream_url: db_repository.remote_upstream_url.clone(), remote_origin_url: db_repository.remote_origin_url.clone(), + original_repo_abs_path: Some(db_repository.abs_path), }); } } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index eab9f8c1036a83451fc3201f97cfb1cc8c885043..c8524022d9d8295900638a09c528dfc3fdb85afd 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -37,6 +37,16 @@ impl FeatureFlag for AgentSharingFeatureFlag { const NAME: &'static str = "agent-sharing"; } +pub struct AgentGitWorktreesFeatureFlag; + +impl FeatureFlag for AgentGitWorktreesFeatureFlag { + const NAME: &'static str = "agent-git-worktrees"; + + fn enabled_for_staff() -> bool { + false + } +} + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index ba77199d75f624c0dd44ad0b2ba4eec812d9a711..bd07555d05b759a33080b9ae9f166145c3d26d14 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -55,6 +55,26 @@ pub const GRAPH_CHUNK_SIZE: usize = 1000; /// Default value for the `git.worktree_directory` setting. pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees"; +/// Given the git common directory (from `commondir()`), derive the original +/// repository's working directory. +/// +/// For a standard checkout, `common_dir` is `/.git`, so the parent +/// is the working directory. For a git worktree, `common_dir` is the **main** +/// repo's `.git` directory, so the parent is the original repo's working directory. +/// +/// Falls back to returning `common_dir` itself if it doesn't end with `.git` +/// (e.g. bare repos or unusual layouts). +pub fn original_repo_path_from_common_dir(common_dir: &Path) -> PathBuf { + if common_dir.file_name() == Some(OsStr::new(".git")) { + common_dir + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| common_dir.to_path_buf()) + } else { + common_dir.to_path_buf() + } +} + /// Resolves the configured worktree directory to an absolute path. /// /// `worktree_directory_setting` is the raw string from the user setting @@ -4272,6 +4292,34 @@ mod tests { ); } + #[test] + fn test_original_repo_path_from_common_dir() { + // Normal repo: common_dir is /.git + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), + PathBuf::from("/code/zed5") + ); + + // Worktree: common_dir is the main repo's .git + // (same result — that's the point, it always traces back to the original) + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5/.git")), + PathBuf::from("/code/zed5") + ); + + // Bare repo: no .git suffix, returns as-is + assert_eq!( + original_repo_path_from_common_dir(Path::new("/code/zed5.git")), + PathBuf::from("/code/zed5.git") + ); + + // Root-level .git directory + assert_eq!( + original_repo_path_from_common_dir(Path::new("/.git")), + PathBuf::from("/") + ); + } + #[test] fn test_validate_worktree_directory() { let work_dir = Path::new("/code/my-project"); diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f2826a2b543a73c5341653c42bbb5f1540213b2a..9f70c29da86ee52668984f92b247331524fc5936 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -275,9 +275,9 @@ impl WorktreeListDelegate { .git .worktree_directory .clone(); - let work_dir = repo.work_directory_abs_path.clone(); + let original_repo = repo.original_repo_abs_path.clone(); let directory = - validate_worktree_directory(&work_dir, &worktree_directory_setting)?; + validate_worktree_directory(&original_repo, &worktree_directory_setting)?; let new_worktree_path = directory.join(&branch); let receiver = repo.create_worktree(branch.clone(), directory, commit); anyhow::Ok((receiver, new_worktree_path)) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index ae776966a770ccadcffdbf9b140ed10d4871b317..487e7f5f9699382ce4930141f7a0c7c50a1d23b8 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -266,6 +266,11 @@ pub struct RepositorySnapshot { pub id: RepositoryId, pub statuses_by_path: SumTree, pub work_directory_abs_path: Arc, + /// The working directory of the original repository. For a normal + /// checkout this equals `work_directory_abs_path`. For a git worktree + /// checkout, this is the original repo's working directory — used to + /// anchor new worktree creation so they don't nest. + pub original_repo_abs_path: Arc, pub path_style: PathStyle, pub branch: Option, pub head_commit: Option, @@ -1505,16 +1510,19 @@ impl GitStore { new_work_directory_abs_path: Some(work_directory_abs_path), dot_git_abs_path: Some(dot_git_abs_path), repository_dir_abs_path: Some(_repository_dir_abs_path), - common_dir_abs_path: Some(_common_dir_abs_path), + common_dir_abs_path: Some(common_dir_abs_path), .. } = update { + let original_repo_abs_path: Arc = + git::repository::original_repo_path_from_common_dir(common_dir_abs_path).into(); let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( id, work_directory_abs_path.clone(), + original_repo_abs_path.clone(), dot_git_abs_path.clone(), project_environment.downgrade(), fs.clone(), @@ -1840,6 +1848,11 @@ impl GitStore { let id = RepositoryId::from_proto(update.id); let client = this.upstream_client().context("no upstream client")?; + let original_repo_abs_path: Option> = update + .original_repo_abs_path + .as_deref() + .map(|p| Path::new(p).into()); + let mut repo_subscription = None; let repo = this.repositories.entry(id).or_insert_with(|| { let git_store = cx.weak_entity(); @@ -1847,6 +1860,7 @@ impl GitStore { Repository::remote( id, Path::new(&update.abs_path).into(), + original_repo_abs_path.clone(), path_style, ProjectId(update.project_id), client, @@ -3481,10 +3495,17 @@ impl RepositoryId { } impl RepositorySnapshot { - fn empty(id: RepositoryId, work_directory_abs_path: Arc, path_style: PathStyle) -> Self { + fn empty( + id: RepositoryId, + work_directory_abs_path: Arc, + original_repo_abs_path: Option>, + path_style: PathStyle, + ) -> Self { Self { id, statuses_by_path: Default::default(), + original_repo_abs_path: original_repo_abs_path + .unwrap_or_else(|| work_directory_abs_path.clone()), work_directory_abs_path, branch: None, head_commit: None, @@ -3528,6 +3549,9 @@ impl RepositorySnapshot { .collect(), remote_upstream_url: self.remote_upstream_url.clone(), remote_origin_url: self.remote_origin_url.clone(), + original_repo_abs_path: Some( + self.original_repo_abs_path.to_string_lossy().into_owned(), + ), } } @@ -3599,6 +3623,9 @@ impl RepositorySnapshot { .collect(), remote_upstream_url: self.remote_upstream_url.clone(), remote_origin_url: self.remote_origin_url.clone(), + original_repo_abs_path: Some( + self.original_repo_abs_path.to_string_lossy().into_owned(), + ), } } @@ -3757,14 +3784,19 @@ impl Repository { fn local( id: RepositoryId, work_directory_abs_path: Arc, + original_repo_abs_path: Arc, dot_git_abs_path: Arc, project_environment: WeakEntity, fs: Arc, git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = - RepositorySnapshot::empty(id, work_directory_abs_path.clone(), PathStyle::local()); + let snapshot = RepositorySnapshot::empty( + id, + work_directory_abs_path.clone(), + Some(original_repo_abs_path), + PathStyle::local(), + ); let state = cx .spawn(async move |_, cx| { LocalRepositoryState::new( @@ -3818,13 +3850,19 @@ impl Repository { fn remote( id: RepositoryId, work_directory_abs_path: Arc, + original_repo_abs_path: Option>, path_style: PathStyle, project_id: ProjectId, client: AnyProtoClient, git_store: WeakEntity, cx: &mut Context, ) -> Self { - let snapshot = RepositorySnapshot::empty(id, work_directory_abs_path, path_style); + let snapshot = RepositorySnapshot::empty( + id, + work_directory_abs_path, + original_repo_abs_path, + path_style, + ); let repository_state = RemoteRepositoryState { project_id, client }; let job_sender = Self::spawn_remote_git_worker(repository_state.clone(), cx); let repository_state = Task::ready(Ok(RepositoryState::Remote(repository_state))).shared(); @@ -5650,6 +5688,24 @@ impl Repository { ) } + pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { + self.send_job( + Some("git worktree remove".into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.remove_worktree(path, force).await + } + RepositoryState::Remote(_) => { + anyhow::bail!( + "Removing worktrees on remote repositories is not yet supported" + ) + } + } + }, + ) + } + pub fn default_branch( &mut self, include_remote_name: bool, @@ -5988,6 +6044,10 @@ impl Repository { update: proto::UpdateRepository, cx: &mut Context, ) -> Result<()> { + if let Some(main_path) = &update.original_repo_abs_path { + self.snapshot.original_repo_abs_path = Path::new(main_path.as_str()).into(); + } + let new_branch = update.branch_summary.as_ref().map(proto_to_branch); let new_head_commit = update .head_commit_details @@ -6784,6 +6844,7 @@ async fn compute_snapshot( id, statuses_by_path, work_directory_abs_path, + original_repo_abs_path: prev_snapshot.original_repo_abs_path, path_style: prev_snapshot.path_style, scan_id: prev_snapshot.scan_id + 1, branch, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 86f3d4c328af06e1a3f4f7cc406ac84272577cd0..6cb3acfcd878c8f970c4e99789939424a3835709 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -125,6 +125,7 @@ message UpdateRepository { repeated StashEntry stash_entries = 13; optional string remote_upstream_url = 14; optional string remote_origin_url = 15; + optional string original_repo_abs_path = 16; } message RemoveRepository { diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index cdb646ec3b8248bdd0b5784424ed7b8df8ac0ee8..0971ebd0ddc9265ccf9ea10da7745ba59914db30 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -93,9 +93,9 @@ pub(crate) struct SerializedWorkspace { #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] pub struct DockStructure { - pub(crate) left: DockData, - pub(crate) right: DockData, - pub(crate) bottom: DockData, + pub left: DockData, + pub right: DockData, + pub bottom: DockData, } impl RemoteConnectionKind { @@ -143,9 +143,9 @@ impl Bind for DockStructure { #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] pub struct DockData { - pub(crate) visible: bool, - pub(crate) active_panel: Option, - pub(crate) zoom: bool, + pub visible: bool, + pub active_panel: Option, + pub zoom: bool, } impl Column for DockData { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b62f6b5eb60eafb7177f7883b825a208e7c81d62..3839b4446e7399536a12e7951c004cce81d5c4e6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -79,7 +79,10 @@ pub use pane_group::{ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, - model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace}, + model::{ + DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, + SessionWorkspace, + }, read_serialized_multi_workspaces, }; use postage::stream::Stream; @@ -149,7 +152,7 @@ use crate::{item::ItemBufferKind, notifications::NotificationId}; use crate::{ persistence::{ SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + model::{DockData, SerializedItem, SerializedPane, SerializedPaneGroup}, }, security_modal::SecurityModal, }; @@ -628,7 +631,7 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c }) .ok(); } else { - let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, cx); + let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx); cx.spawn(async move |cx| { let (window, _) = task.await?; window.update(cx, |multi_workspace, window, cx| { @@ -1290,6 +1293,7 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + _panels_task: Option>>, } impl EventEmitter for Workspace {} @@ -1660,6 +1664,7 @@ impl Workspace { left_dock, bottom_dock, right_dock, + _panels_task: None, project: project.clone(), follower_states: Default::default(), last_leaders_by_pane: Default::default(), @@ -1703,6 +1708,7 @@ impl Workspace { requesting_window: Option>, env: Option>, init: Option) + Send>>, + activate: bool, cx: &mut App, ) -> Task< anyhow::Result<( @@ -1830,7 +1836,11 @@ impl Workspace { workspace }); - multi_workspace.activate(workspace.clone(), cx); + if activate { + multi_workspace.activate(workspace.clone(), cx); + } else { + multi_workspace.add_workspace(workspace.clone(), cx); + } workspace })?; (window, workspace) @@ -1984,6 +1994,76 @@ impl Workspace { [&self.left_dock, &self.bottom_dock, &self.right_dock] } + pub fn capture_dock_state(&self, _window: &Window, cx: &App) -> DockStructure { + let left_dock = self.left_dock.read(cx); + let left_visible = left_dock.is_open(); + let left_active_panel = left_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + // `zoomed_position` is kept in sync with individual panel zoom state + // by the dock code in `Dock::new` and `Dock::add_panel`. + let left_dock_zoom = self.zoomed_position == Some(DockPosition::Left); + + let right_dock = self.right_dock.read(cx); + let right_visible = right_dock.is_open(); + let right_active_panel = right_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + let right_dock_zoom = self.zoomed_position == Some(DockPosition::Right); + + let bottom_dock = self.bottom_dock.read(cx); + let bottom_visible = bottom_dock.is_open(); + let bottom_active_panel = bottom_dock + .active_panel() + .map(|panel| panel.persistent_name().to_string()); + let bottom_dock_zoom = self.zoomed_position == Some(DockPosition::Bottom); + + DockStructure { + left: DockData { + visible: left_visible, + active_panel: left_active_panel, + zoom: left_dock_zoom, + }, + right: DockData { + visible: right_visible, + active_panel: right_active_panel, + zoom: right_dock_zoom, + }, + bottom: DockData { + visible: bottom_visible, + active_panel: bottom_active_panel, + zoom: bottom_dock_zoom, + }, + } + } + + pub fn set_dock_structure( + &self, + docks: DockStructure, + window: &mut Window, + cx: &mut Context, + ) { + for (dock, data) in [ + (&self.left_dock, docks.left), + (&self.bottom_dock, docks.bottom), + (&self.right_dock, docks.right), + ] { + dock.update(cx, |dock, cx| { + dock.serialized_dock = Some(data); + dock.restore_state(window, cx); + }); + } + } + + pub fn open_item_abs_paths(&self, cx: &App) -> Vec { + self.items(cx) + .filter_map(|item| { + let project_path = item.project_path(cx)?; + self.project.read(cx).absolute_path(&project_path, cx) + }) + .collect() + } + pub fn dock_at_position(&self, position: DockPosition) -> &Entity { match position { DockPosition::Left => &self.left_dock, @@ -2043,6 +2123,14 @@ impl Workspace { &self.app_state } + pub fn set_panels_task(&mut self, task: Task>) { + self._panels_task = Some(task); + } + + pub fn take_panels_task(&mut self) -> Option>> { + self._panels_task.take() + } + pub fn user_store(&self) -> &Entity { &self.app_state.user_store } @@ -2548,7 +2636,15 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); + let task = Self::new_local( + Vec::new(), + self.app_state.clone(), + None, + env, + None, + true, + cx, + ); cx.spawn_in(window, async move |_vh, cx| { let (multi_workspace_window, _) = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { @@ -2578,7 +2674,15 @@ impl Workspace { Task::ready(Ok(callback(self, window, cx))) } else { let env = self.project.read(cx).cli_environment(cx); - let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); + let task = Self::new_local( + Vec::new(), + self.app_state.clone(), + None, + env, + None, + true, + cx, + ); cx.spawn_in(window, async move |_vh, cx| { let (multi_workspace_window, _) = task.await?; multi_workspace_window.update(cx, |multi_workspace, window, cx| { @@ -6012,53 +6116,7 @@ impl Workspace { window: &mut Window, cx: &mut App, ) -> DockStructure { - let left_dock = this.left_dock.read(cx); - let left_visible = left_dock.is_open(); - let left_active_panel = left_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let left_dock_zoom = left_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - let right_dock = this.right_dock.read(cx); - let right_visible = right_dock.is_open(); - let right_active_panel = right_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let right_dock_zoom = right_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - let bottom_dock = this.bottom_dock.read(cx); - let bottom_visible = bottom_dock.is_open(); - let bottom_active_panel = bottom_dock - .active_panel() - .map(|panel| panel.persistent_name().to_string()); - let bottom_dock_zoom = bottom_dock - .active_panel() - .map(|panel| panel.is_zoomed(window, cx)) - .unwrap_or(false); - - DockStructure { - left: DockData { - visible: left_visible, - active_panel: left_active_panel, - zoom: left_dock_zoom, - }, - right: DockData { - visible: right_visible, - active_panel: right_active_panel, - zoom: right_dock_zoom, - }, - bottom: DockData { - visible: bottom_visible, - active_panel: bottom_active_panel, - zoom: bottom_dock_zoom, - }, - } + this.capture_dock_state(window, cx) } match self.workspace_location(cx) { @@ -8087,6 +8145,7 @@ pub async fn restore_multiworkspace( None, None, None, + true, cx, ) }) @@ -8116,6 +8175,7 @@ pub async fn restore_multiworkspace( Some(window_handle), None, None, + true, cx, ) }) @@ -8385,6 +8445,7 @@ pub fn join_channel( requesting_window, None, None, + true, cx, ) }) @@ -8457,7 +8518,7 @@ pub async fn get_any_active_multi_workspace( // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { - cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, cx)) + cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, true, cx)) .await?; } activate_any_workspace_window(&mut cx).context("could not open zed") @@ -8845,6 +8906,7 @@ pub fn open_paths( open_options.replace_window, open_options.env, None, + true, cx, ) }) @@ -8908,6 +8970,7 @@ pub fn open_new( open_options.replace_window, open_options.env, Some(Box::new(init)), + true, cx, ); cx.spawn(async move |cx| { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 0ae98d510aa34b05f7fa1766176f21ea353394d9..df673f0b4869af8fa55b0e83af10553df8afb4d8 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -71,7 +71,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, + workspace::{AppState, MultiWorkspace, Panel as _, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -548,6 +548,27 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } + // Run Test 11: Thread target selector visual tests + #[cfg(feature = "visual-tests")] + { + println!("\n--- Test 11: start_thread_in_selector (6 variants) ---"); + match run_start_thread_in_selector_visual_tests(app_state.clone(), &mut cx, update_baseline) + { + Ok(TestResult::Passed) => { + println!("✓ start_thread_in_selector: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ start_thread_in_selector: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ start_thread_in_selector: FAILED - {}", e); + failed += 1; + } + } + } + // Run Test 9: Tool Permissions Settings UI visual test println!("\n--- Test 9: tool_permissions_settings ---"); match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) { @@ -3066,3 +3087,629 @@ fn run_error_wrapping_visual_tests( Ok(test_result) } + +#[cfg(all(target_os = "macos", feature = "visual-tests"))] +/// Runs a git command in the given directory and returns an error with +/// stderr/stdout context if the command fails (non-zero exit status). +fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "`git {}` failed (exit {})\nstdout: {}\nstderr: {}", + args.join(" "), + output.status, + stdout.trim(), + stderr.trim(), + ); + } + Ok(()) +} + +#[cfg(all(target_os = "macos", feature = "visual-tests"))] +fn run_start_thread_in_selector_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + use agent_ui::{AgentPanel, StartThreadIn, WorktreeCreationStatus}; + + // Enable feature flags so the thread target selector renders + cx.update(|cx| { + cx.update_flags( + true, + vec!["agent-v2".to_string(), "agent-git-worktrees".to_string()], + ); + }); + + // Create a temp directory with a real git repo so "New Worktree" is enabled + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + let project_path = canonical_temp.join("project"); + std::fs::create_dir_all(&project_path)?; + + // Initialize git repo + run_git_command(&["init"], &project_path)?; + run_git_command(&["config", "user.email", "test@test.com"], &project_path)?; + run_git_command(&["config", "user.name", "Test User"], &project_path)?; + + // Create source files + let src_dir = project_path.join("src"); + std::fs::create_dir_all(&src_dir)?; + std::fs::write( + src_dir.join("main.rs"), + r#"fn main() { + println!("Hello, world!"); + + let x = 42; + let y = x * 2; + + if y > 50 { + println!("y is greater than 50"); + } else { + println!("y is not greater than 50"); + } + + for i in 0..10 { + println!("i = {}", i); + } +} + +fn helper_function(a: i32, b: i32) -> i32 { + a + b +} +"#, + )?; + + std::fs::write( + project_path.join("Cargo.toml"), + r#"[package] +name = "test_project" +version = "0.1.0" +edition = "2021" +"#, + )?; + + // Commit so git status is clean + run_git_command(&["add", "."], &project_path)?; + run_git_command(&["commit", "-m", "Initial commit"], &project_path)?; + + 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, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + // Use a wide window so we see project panel + editor + agent panel + let window_size = size(px(1280.0), px(800.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + let workspace_window: WindowHandle = cx + .update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| { + let workspace = cx.new(|cx| { + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| MultiWorkspace::new(workspace, window, cx)) + }, + ) + }) + .context("Failed to open thread target selector test window")?; + + cx.run_until_parked(); + + // Create and register the workspace sidebar + let sidebar = workspace_window + .update(cx, |_multi_workspace, window, cx| { + let multi_workspace_handle = cx.entity(); + cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) + }) + .context("Failed to create sidebar")?; + + workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.register_sidebar(sidebar.clone(), window, cx); + }) + .context("Failed to register sidebar")?; + + // Open the sidebar + workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + cx.run_until_parked(); + + // Add the git project as a worktree + let add_worktree_task = workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&project_path, true, cx) + }) + }) + .context("Failed to start adding worktree")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree_task) + .context("Failed to add worktree")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Wait for worktree scan and git status + for _ in 0..5 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Open the project panel + let (weak_workspace, async_window_cx) = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + (workspace.read(cx).weak_handle(), window.to_async(cx)) + }) + .context("Failed to get workspace handle")?; + + cx.background_executor.allow_parking(); + let project_panel = cx + .foreground_executor + .block_test(ProjectPanel::load(weak_workspace, async_window_cx)) + .context("Failed to load project panel")?; + cx.background_executor.forbid_parking(); + + workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, cx| { + workspace.add_panel(project_panel, window, cx); + workspace.open_panel::(window, cx); + }); + }) + .context("Failed to add project panel")?; + + cx.run_until_parked(); + + // Open main.rs in the editor + let open_file_task = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, 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::rel_path("src/main.rs").into(); + let project_path: project::ProjectPath = (worktree_id, rel_path).into(); + Some(workspace.open_path(project_path, None, true, window, cx)) + } else { + None + } + }) + }) + .log_err() + .flatten(); + + if let Some(task) = open_file_task { + cx.background_executor.allow_parking(); + cx.foreground_executor.block_test(task).log_err(); + cx.background_executor.forbid_parking(); + } + + cx.run_until_parked(); + + // Load the AgentPanel + let (weak_workspace, async_window_cx) = workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + (workspace.read(cx).weak_handle(), window.to_async(cx)) + }) + .context("Failed to get workspace handle for agent panel")?; + + let prompt_builder = + cx.update(|cx| prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx)); + + // Register an observer so that workspaces created by the worktree creation + // flow get AgentPanel and ProjectPanel loaded automatically. Without this, + // `workspace.panel::(cx)` returns None in the new workspace and + // the creation flow's `focus_panel::` call is a no-op. + let _workspace_observer = cx.update({ + let prompt_builder = prompt_builder.clone(); + |cx| { + cx.observe_new(move |workspace: &mut Workspace, window, cx| { + let Some(window) = window else { return }; + let prompt_builder = prompt_builder.clone(); + let panels_task = cx.spawn_in(window, async move |workspace_handle, cx| { + let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); + let agent_panel = + AgentPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()); + if let Ok(panel) = project_panel.await { + workspace_handle + .update_in(cx, |workspace, window, cx| { + workspace.add_panel(panel, window, cx); + }) + .log_err(); + } + if let Ok(panel) = agent_panel.await { + workspace_handle + .update_in(cx, |workspace, window, cx| { + workspace.add_panel(panel, window, cx); + }) + .log_err(); + } + anyhow::Ok(()) + }); + workspace.set_panels_task(panels_task); + }) + } + }); + + cx.background_executor.allow_parking(); + let panel = cx + .foreground_executor + .block_test(AgentPanel::load( + weak_workspace, + prompt_builder, + async_window_cx, + )) + .context("Failed to load AgentPanel")?; + cx.background_executor.forbid_parking(); + + workspace_window + .update(cx, |multi_workspace, window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + workspace.update(cx, |workspace, cx| { + workspace.add_panel(panel.clone(), window, cx); + workspace.open_panel::(window, cx); + }); + }) + .context("Failed to add and open AgentPanel")?; + + cx.run_until_parked(); + + // Inject the stub server and open a thread so the toolbar is visible + let connection = StubAgentConnection::new(); + let stub_agent: Rc = Rc::new(StubAgentServer::new(connection)); + + cx.update_window(workspace_window.into(), |_, window, cx| { + panel.update(cx, |panel, cx| { + panel.open_external_thread_with_server(stub_agent.clone(), window, cx); + }); + })?; + + cx.run_until_parked(); + + // ---- Screenshot 1: Default "Local Project" selector (dropdown closed) ---- + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_default = run_visual_test( + "start_thread_in_selector_default", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 2: Dropdown open showing menu entries ---- + cx.update_window(workspace_window.into(), |_, window, cx| { + panel.update(cx, |panel, cx| { + panel.open_start_thread_in_menu_for_tests(window, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_open_dropdown = run_visual_test( + "start_thread_in_selector_open", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 3: "New Worktree" selected (dropdown closed, label changed) ---- + // First dismiss the dropdown, then change the target so the toolbar label is visible + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.close_start_thread_in_menu_for_tests(cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_start_thread_in_for_tests(StartThreadIn::NewWorktree, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_new_worktree = run_visual_test( + "start_thread_in_selector_new_worktree", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 4: "Creating worktree…" status banner ---- + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel + .set_worktree_creation_status_for_tests(Some(WorktreeCreationStatus::Creating), cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_creating = run_visual_test( + "worktree_creation_status_creating", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 5: Error status banner ---- + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_worktree_creation_status_for_tests( + Some(WorktreeCreationStatus::Error( + "Failed to create worktree: branch already exists".into(), + )), + cx, + ); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_error = run_visual_test( + "worktree_creation_status_error", + workspace_window.into(), + cx, + update_baseline, + ); + + // ---- Screenshot 6: Worktree creation succeeded ---- + // Clear the error status and re-select New Worktree to ensure a clean state. + cx.update_window(workspace_window.into(), |_, _window, cx| { + panel.update(cx, |panel, cx| { + panel.set_worktree_creation_status_for_tests(None, cx); + }); + })?; + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, cx| { + window.dispatch_action(Box::new(StartThreadIn::NewWorktree), cx); + })?; + cx.run_until_parked(); + + // Insert a message into the active thread's message editor and submit. + let thread_view = cx + .read(|cx| panel.read(cx).as_active_thread_view(cx)) + .ok_or_else(|| anyhow::anyhow!("No active thread view"))?; + + cx.update_window(workspace_window.into(), |_, window, cx| { + let message_editor = thread_view.read(cx).message_editor.clone(); + message_editor.update(cx, |message_editor, cx| { + message_editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "Add a CLI flag to set the log level".to_string(), + ))], + window, + cx, + ); + message_editor.send(cx); + }); + })?; + cx.run_until_parked(); + + // Wait for the full worktree creation flow to complete. The creation status + // is cleared to `None` at the very end of the async task, after panels are + // loaded, the agent panel is focused, and the new workspace is activated. + cx.background_executor.allow_parking(); + let mut creation_complete = false; + for _ in 0..120 { + cx.run_until_parked(); + let status_cleared = cx.read(|cx| { + panel + .read(cx) + .worktree_creation_status_for_tests() + .is_none() + }); + let workspace_count = workspace_window.update(cx, |multi_workspace, _window, _cx| { + multi_workspace.workspaces().len() + })?; + if workspace_count == 2 && status_cleared { + creation_complete = true; + break; + } + cx.advance_clock(Duration::from_millis(100)); + } + cx.background_executor.forbid_parking(); + + if !creation_complete { + return Err(anyhow::anyhow!("Worktree creation did not complete")); + } + + // The creation flow called `external_thread` on the new workspace's agent + // panel, which tried to launch a real agent binary and failed. Replace the + // error state by injecting the stub server, and shrink the panel so the + // editor content is visible. + workspace_window.update(cx, |multi_workspace, window, cx| { + let new_workspace = &multi_workspace.workspaces()[1]; + new_workspace.update(cx, |workspace, cx| { + if let Some(new_panel) = workspace.panel::(cx) { + new_panel.update(cx, |panel, cx| { + panel.set_size(Some(px(480.0)), window, cx); + panel.open_external_thread_with_server(stub_agent.clone(), window, cx); + }); + } + }); + })?; + cx.run_until_parked(); + + // Type and send a message so the thread target dropdown disappears. + let new_panel = workspace_window.update(cx, |multi_workspace, _window, cx| { + let new_workspace = &multi_workspace.workspaces()[1]; + new_workspace.read(cx).panel::(cx) + })?; + if let Some(new_panel) = new_panel { + let new_thread_view = cx.read(|cx| new_panel.read(cx).as_active_thread_view(cx)); + if let Some(new_thread_view) = new_thread_view { + cx.update_window(workspace_window.into(), |_, window, cx| { + let message_editor = new_thread_view.read(cx).message_editor.clone(); + message_editor.update(cx, |editor, cx| { + editor.set_message( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "Add a CLI flag to set the log level".to_string(), + ))], + window, + cx, + ); + editor.send(cx); + }); + })?; + cx.run_until_parked(); + } + } + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + cx.run_until_parked(); + + let result_succeeded = run_visual_test( + "worktree_creation_succeeded", + workspace_window.into(), + cx, + update_baseline, + ); + + // Clean up — drop the workspace observer first so no new panels are + // registered on workspaces created during teardown. + drop(_workspace_observer); + + workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace = &multi_workspace.workspaces()[0]; + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + let worktree_ids: Vec<_> = + project.worktrees(cx).map(|wt| wt.read(cx).id()).collect(); + for id in worktree_ids { + project.remove_worktree(id, cx); + } + }); + }) + .log_err(); + + cx.run_until_parked(); + + cx.update_window(workspace_window.into(), |_, window, _cx| { + window.remove_window(); + }) + .log_err(); + + cx.run_until_parked(); + + for _ in 0..15 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Delete the preserved temp directory so visual-test runs don't + // accumulate filesystem artifacts. + if let Err(err) = std::fs::remove_dir_all(&temp_path) { + log::warn!( + "failed to clean up visual-test temp dir {}: {err}", + temp_path.display() + ); + } + + // Reset feature flags + cx.update(|cx| { + cx.update_flags(false, vec![]); + }); + + let results = [ + ("default", result_default), + ("open_dropdown", result_open_dropdown), + ("new_worktree", result_new_worktree), + ("creating", result_creating), + ("error", result_error), + ("succeeded", result_succeeded), + ]; + + let mut has_baseline_update = None; + let mut failures = Vec::new(); + + for (name, result) in &results { + match result { + Ok(TestResult::Passed) => {} + Ok(TestResult::BaselineUpdated(p)) => { + has_baseline_update = Some(p.clone()); + } + Err(e) => { + failures.push(format!("{}: {}", name, e)); + } + } + } + + if !failures.is_empty() { + Err(anyhow::anyhow!( + "start_thread_in_selector failures: {}", + failures.join("; ") + )) + } else if let Some(p) = has_baseline_update { + Ok(TestResult::BaselineUpdated(p)) + } else { + Ok(TestResult::Passed) + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a0a6e424d46790ad49c860377c5d1e711aae6b61..17832bdd1833cabb42af2195f9d9aab1a6bf3fab 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -496,7 +496,8 @@ pub fn initialize_workspace( status_bar.add_right_item(image_info, window, cx); }); - initialize_panels(prompt_builder.clone(), window, cx); + let panels_task = initialize_panels(prompt_builder.clone(), window, cx); + workspace.set_panels_task(panels_task); register_actions(app_state.clone(), workspace, window, cx); workspace.focus_handle(cx).focus(window, cx); @@ -620,7 +621,7 @@ fn initialize_panels( prompt_builder: Arc, window: &mut Window, cx: &mut Context, -) { +) -> Task> { cx.spawn_in(window, async move |workspace_handle, cx| { let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); @@ -662,7 +663,6 @@ fn initialize_panels( anyhow::Ok(()) }) - .detach(); } fn setup_or_teardown_ai_panel( @@ -1103,7 +1103,7 @@ fn register_actions( ); }, ) - .detach(); + .detach_and_log_err(cx); } } }) @@ -5808,7 +5808,15 @@ mod tests { // Window B: workspace for dir3 let (window_a, _) = cx .update(|cx| { - Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx) + Workspace::new_local( + vec![dir1.into()], + app_state.clone(), + None, + None, + None, + true, + cx, + ) }) .await .expect("failed to open first workspace"); @@ -5824,7 +5832,15 @@ mod tests { let (window_b, _) = cx .update(|cx| { - Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx) + Workspace::new_local( + vec![dir3.into()], + app_state.clone(), + None, + None, + None, + true, + cx, + ) }) .await .expect("failed to open third workspace"); From 38c7e63af3a4264598308b6ca07119e097777a8b Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 3 Mar 2026 10:52:00 -0500 Subject: [PATCH 08/74] git: Fix commit message buffer header not being disabled after cloning commit view (#50606) Release Notes: - Fixed extraneous buffer header when splitting the commit view. --- crates/editor/src/display_map.rs | 4 ++++ crates/editor/src/display_map/block_map.rs | 2 ++ crates/editor/src/editor.rs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 57b8eb8ef6c1b29cb99da3e2a4e731d0c828038e..b666557b90a3c1181404d8f09b1d50ff9f8402a9 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -789,6 +789,9 @@ impl DisplayMap { .collect(), cx, ); + for buffer_id in &other.block_snapshot.buffers_with_disabled_headers { + self.disable_header_for_buffer(*buffer_id, cx); + } } /// Creates folds for the given creases. @@ -1003,6 +1006,7 @@ impl DisplayMap { &self.block_map.folded_buffers } + #[instrument(skip_all)] pub(super) fn clear_folded_buffer(&mut self, buffer_id: language::BufferId) { self.block_map.folded_buffers.remove(&buffer_id); } diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index db7eb53b39088c6026d3d36bef636f748c80d587..2673baae84ab74b2852004320cf1d94c5ed1ed42 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -78,6 +78,7 @@ pub struct BlockSnapshot { custom_blocks_by_id: TreeMap>, pub(super) buffer_header_height: u32, pub(super) excerpt_header_height: u32, + pub(super) buffers_with_disabled_headers: HashSet, } impl Deref for BlockSnapshot { @@ -657,6 +658,7 @@ impl BlockMap { custom_blocks_by_id: self.custom_blocks_by_id.clone(), buffer_header_height: self.buffer_header_height, excerpt_header_height: self.excerpt_header_height, + buffers_with_disabled_headers: self.buffers_with_disabled_headers.clone(), }, } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 28d96e721257eaad898408cafba67f9f991e4909..5504305f86eb95dee000cec4099e366bbf86ffef 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1973,6 +1973,8 @@ impl Editor { .clone_state(&self.scroll_manager, &my_snapshot, &clone_snapshot, cx); clone.searchable = self.searchable; clone.read_only = self.read_only; + clone.buffers_with_disabled_indent_guides = + self.buffers_with_disabled_indent_guides.clone(); clone } From c19cc4c51e0f64eec42168943050f2deeccaa076 Mon Sep 17 00:00:00 2001 From: Chriss4123 <87142779+Chriss4123@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:11:51 +0200 Subject: [PATCH 09/74] Fix Linux watcher cleanup for recreated directories (#50412) ## Problem - On Linux, non-recursive watcher registrations remained path-cached after deleting and recreating a directory in the same session. - The recreated directory was not re-watched, so newly created child entries under that path could be missing. ## Summary - Remove directory watcher registrations when worktree paths are removed from snapshot state. - Ensure recreated directories can be watched again on Linux by allowing `scan_dir` to re-add fresh watches. - Add a Linux integration regression test for directory delete/recreate path reuse and child file creation. ## Testing - `cargo test -p project --features test-support --test integration test_recreated_directory_receives_child_events -- --exact` - `cargo test -p project --features test-support --test integration test_rescan_and_remote_updates -- --exact` ## Related - #46709 Release Notes: - Fixed Linux worktree file watching so child entries appear after deleting and recreating a directory at the same path. --- .../tests/integration/project_tests.rs | 46 +++++++++++++++++++ crates/worktree/src/worktree.rs | 18 ++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 6092836c19ef280aa2d13abcb32932f3b47703b6..d597377910a2a837e456ac4384b06c333887dfb3 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -5359,6 +5359,52 @@ async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) { }); } +#[cfg(target_os = "linux")] +#[gpui::test(retries = 5)] +async fn test_recreated_directory_receives_child_events(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + + let dir = TempTree::new(json!({})); + let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; + let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + + tree.flush_fs_events(cx).await; + + let repro_dir = dir.path().join("repro"); + std::fs::create_dir(&repro_dir).unwrap(); + tree.flush_fs_events(cx).await; + + cx.update(|cx| { + assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_some()); + }); + + std::fs::remove_dir_all(&repro_dir).unwrap(); + tree.flush_fs_events(cx).await; + + cx.update(|cx| { + assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_none()); + }); + + std::fs::create_dir(&repro_dir).unwrap(); + tree.flush_fs_events(cx).await; + + cx.update(|cx| { + assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_some()); + }); + + std::fs::write(repro_dir.join("repro-marker"), "").unwrap(); + tree.flush_fs_events(cx).await; + + cx.update(|cx| { + assert!( + tree.read(cx) + .entry_for_path(rel_path("repro/repro-marker")) + .is_some() + ); + }); +} + #[gpui::test(iterations = 10)] async fn test_buffer_identity_across_renames(cx: &mut gpui::TestAppContext) { init_test(cx); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 69b0be24e7ffb09d3fe759ec0bd3d54b54db21d3..9e62beb3c375fb8d580be02382091cafe04d31e2 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2945,7 +2945,7 @@ impl BackgroundScannerState { self.snapshot.check_invariants(false); } - fn remove_path(&mut self, path: &RelPath) { + fn remove_path(&mut self, path: &RelPath, watcher: &dyn Watcher) { log::trace!("background scanner removing path {path:?}"); let mut new_entries; let removed_entries; @@ -2961,7 +2961,12 @@ impl BackgroundScannerState { self.snapshot.entries_by_path = new_entries; let mut removed_ids = Vec::with_capacity(removed_entries.summary().count); + let mut removed_dir_abs_paths = Vec::new(); for entry in removed_entries.cursor::<()>(()) { + if entry.is_dir() { + removed_dir_abs_paths.push(self.snapshot.absolutize(&entry.path)); + } + match self.removed_entries.entry(entry.inode) { hash_map::Entry::Occupied(mut e) => { let prev_removed_entry = e.get_mut(); @@ -2997,6 +3002,10 @@ impl BackgroundScannerState { .git_repositories .retain(|id, _| removed_ids.binary_search(id).is_err()); + for removed_dir_abs_path in removed_dir_abs_paths { + watcher.remove(&removed_dir_abs_path).log_err(); + } + #[cfg(feature = "test-support")] self.snapshot.check_invariants(false); } @@ -4461,7 +4470,10 @@ impl BackgroundScanner { if self.settings.is_path_excluded(&child_path) { log::debug!("skipping excluded child entry {child_path:?}"); - self.state.lock().await.remove_path(&child_path); + self.state + .lock() + .await + .remove_path(&child_path, self.watcher.as_ref()); continue; } @@ -4651,7 +4663,7 @@ impl BackgroundScanner { // detected regardless of the order of the paths. for (path, metadata) in relative_paths.iter().zip(metadata.iter()) { if matches!(metadata, Ok(None)) || doing_recursive_update { - state.remove_path(path); + state.remove_path(path, self.watcher.as_ref()); } } From d2a71b0a6985cf4d2f76d8deee9f6d75a7917fa2 Mon Sep 17 00:00:00 2001 From: Jakub Konka Date: Tue, 3 Mar 2026 17:12:51 +0100 Subject: [PATCH 10/74] auto_update_helper: Rollback for all errors including FileNotFound (#50607) We would mark `FileNotFound` as success and progress the update loop which does not make much sense. Release Notes: - Do not skip update roll back in presence of FileNotFound errors on Windows. Co-authored-by: Miguel Raz Guzman Macedo --- crates/auto_update_helper/src/updater.rs | 27 +++++++++++++----------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/auto_update_helper/src/updater.rs b/crates/auto_update_helper/src/updater.rs index 076e11fb4eef1e5c53e2bdc290be7117330c3e61..70d5e97c67169ce9737c274f90bc72cbe7ceedf5 100644 --- a/crates/auto_update_helper/src/updater.rs +++ b/crates/auto_update_helper/src/updater.rs @@ -279,19 +279,22 @@ pub(crate) fn perform_update(app_dir: &Path, hwnd: Option, launch: bool) unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; break; } - Err(err) => { - // Check if it's a "not found" error - let io_err = err.downcast_ref::().unwrap(); - if io_err.kind() == std::io::ErrorKind::NotFound { - log::warn!("File or folder not found."); - last_successful_job = Some(i); - unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? }; - break; + Err(err) => match err.downcast_ref::() { + Some(io_err) => match io_err.kind() { + std::io::ErrorKind::NotFound => { + log::error!("Operation failed with file not found, aborting: {}", err); + break 'outer; + } + _ => { + log::error!("Operation failed (retrying): {}", err); + std::thread::sleep(Duration::from_millis(50)); + } + }, + None => { + log::error!("Operation failed with unexpected error, aborting: {}", err); + break 'outer; } - - log::error!("Operation failed: {} ({:?})", err, io_err.kind()); - std::thread::sleep(Duration::from_millis(50)); - } + }, } } } From 6195b702d644c4f21bffda1309f119d7846d409d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Mar 2026 09:47:35 -0700 Subject: [PATCH 11/74] Try to fix auto-updates when Explorer.exe holds Zed.exe (#50332) Release Notes: - Windows: make auto-update more robust in the face of apps holding the Zed.exe handle --------- Co-authored-by: Jakub Konka --- Cargo.lock | 1 + Cargo.toml | 1 + crates/auto_update_helper/Cargo.toml | 1 + crates/auto_update_helper/src/updater.rs | 112 ++++++++++++++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96caec077edd4bdf8c02a3e1ff1fc10340d2b9b0..dcecec352bf1426fb76956f04224c66b04143627 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1352,6 +1352,7 @@ version = "0.1.0" dependencies = [ "anyhow", "log", + "scopeguard", "simplelog", "tempfile", "windows 0.61.3", diff --git a/Cargo.toml b/Cargo.toml index 8e1312f032e19b2c2c189677f144f04dd7f4589c..35180020a8d70d83c113172051d12a85f33c55ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -815,6 +815,7 @@ features = [ "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Pipes", + "Win32_System_RestartManager", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", diff --git a/crates/auto_update_helper/Cargo.toml b/crates/auto_update_helper/Cargo.toml index 73c38d80dd12e9c42daa42b7e6f2c9d6975cf47b..aa5bf6ac40b0e1ab20cbde510be5d7f389c7ade8 100644 --- a/crates/auto_update_helper/Cargo.toml +++ b/crates/auto_update_helper/Cargo.toml @@ -19,6 +19,7 @@ log.workspace = true simplelog.workspace = true [target.'cfg(target_os = "windows")'.dependencies] +scopeguard = "1.2" windows.workspace = true [target.'cfg(target_os = "windows")'.dev-dependencies] diff --git a/crates/auto_update_helper/src/updater.rs b/crates/auto_update_helper/src/updater.rs index 70d5e97c67169ce9737c274f90bc72cbe7ceedf5..7821c908c40873637c4ac3993c320416e2a4b978 100644 --- a/crates/auto_update_helper/src/updater.rs +++ b/crates/auto_update_helper/src/updater.rs @@ -1,13 +1,22 @@ use std::{ + ffi::OsStr, + os::windows::ffi::OsStrExt, path::Path, sync::LazyLock, time::{Duration, Instant}, }; use anyhow::{Context as _, Result}; -use windows::Win32::{ - Foundation::{HWND, LPARAM, WPARAM}, - UI::WindowsAndMessaging::PostMessageW, +use windows::{ + Win32::{ + Foundation::{HWND, LPARAM, WPARAM}, + System::RestartManager::{ + CCH_RM_SESSION_KEY, RmEndSession, RmGetList, RmRegisterResources, RmShutdown, + RmStartSession, + }, + UI::WindowsAndMessaging::PostMessageW, + }, + core::{PCWSTR, PWSTR}, }; use crate::windows_impl::WM_JOB_UPDATED; @@ -262,9 +271,106 @@ pub(crate) static JOBS: LazyLock<[Job; 9]> = LazyLock::new(|| { ] }); +/// Attempts to use Windows Restart Manager to release file handles held by other processes +/// (e.g., Explorer.exe) on the files we need to move during the update. +/// +/// This is a best-effort operation - if it fails, we'll still try the update and rely on +/// the retry logic. +fn release_file_handles(app_dir: &Path) -> Result<()> { + // Files that commonly get locked by Explorer or other processes + let files_to_release = [ + app_dir.join("Zed.exe"), + app_dir.join("bin\\Zed.exe"), + app_dir.join("bin\\zed"), + app_dir.join("conpty.dll"), + ]; + + log::info!("Attempting to release file handles using Restart Manager..."); + + let mut session: u32 = 0; + let mut session_key = [0u16; CCH_RM_SESSION_KEY as usize + 1]; + + // Start a Restart Manager session + let err = unsafe { + RmStartSession( + &mut session, + Some(0), + PWSTR::from_raw(session_key.as_mut_ptr()), + ) + }; + if err.is_err() { + anyhow::bail!("RmStartSession failed: {err:?}"); + } + + // Ensure we end the session when done + let _session_guard = scopeguard::guard(session, |s| { + let _ = unsafe { RmEndSession(s) }; + }); + + // Convert paths to wide strings for Windows API + let wide_paths: Vec> = files_to_release + .iter() + .filter(|p| p.exists()) + .map(|p| { + OsStr::new(p) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + }) + .collect(); + + if wide_paths.is_empty() { + log::info!("No files to release handles for"); + return Ok(()); + } + + let pcwstr_paths: Vec = wide_paths + .iter() + .map(|p| PCWSTR::from_raw(p.as_ptr())) + .collect(); + + // Register the files we want to modify + let err = unsafe { RmRegisterResources(session, Some(&pcwstr_paths), None, None) }; + if err.is_err() { + anyhow::bail!("RmRegisterResources failed: {err:?}"); + } + + // Check if any processes are using these files + let mut needed: u32 = 0; + let mut count: u32 = 0; + let mut reboot_reasons: u32 = 0; + let _ = unsafe { RmGetList(session, &mut needed, &mut count, None, &mut reboot_reasons) }; + + if needed == 0 { + log::info!("No processes are holding handles to the files"); + return Ok(()); + } + + log::info!( + "{} process(es) are holding handles to the files, requesting release...", + needed + ); + + // Request processes to release their handles + // RmShutdown with flags=0 asks applications to release handles gracefully + // For Explorer, this typically releases icon cache handles without closing Explorer + let err = unsafe { RmShutdown(session, 0, None) }; + if err.is_err() { + anyhow::bail!("RmShutdown failed: {:?}", err); + } + + log::info!("Successfully requested handle release"); + Ok(()) +} + pub(crate) fn perform_update(app_dir: &Path, hwnd: Option, launch: bool) -> Result<()> { let hwnd = hwnd.map(|ptr| HWND(ptr as _)); + // Try to release file handles before starting the update + if let Err(e) = release_file_handles(app_dir) { + log::warn!("Restart Manager failed (will continue anyway): {}", e); + } + let mut last_successful_job = None; 'outer: for (i, job) in JOBS.iter().enumerate() { let start = Instant::now(); From e20905f2848183c856146634bc5e387cb1c831cb Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 3 Mar 2026 18:16:42 +0100 Subject: [PATCH 12/74] Only use `StreamingEditFileTool` when streaming is available (#50616) Release Notes: - N/A --- crates/agent/src/thread.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 2e693a85cd1f86d232e392860d8bd83509ce131a..c57bd1e99b9ae4fd1a93214e2a5d5937d1ab0274 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2617,7 +2617,8 @@ impl Thread { } } - let use_streaming_edit_tool = cx.has_flag::(); + let use_streaming_edit_tool = + cx.has_flag::() && model.supports_streaming_tools(); let mut tools = self .tools From 528bf7c251362ce75f9f272a3dfafa21bf7fe695 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Tue, 3 Mar 2026 19:27:52 +0200 Subject: [PATCH 13/74] ep: Fix fetching rated-after (#50617) Release Notes: - N/A --- crates/edit_prediction_cli/src/pull_examples.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/edit_prediction_cli/src/pull_examples.rs b/crates/edit_prediction_cli/src/pull_examples.rs index 2f371675b29015795beef550ce5e3956c63751f9..cccd351dcdeda0dbf059d851a44b02bc1e558654 100644 --- a/crates/edit_prediction_cli/src/pull_examples.rs +++ b/crates/edit_prediction_cli/src/pull_examples.rs @@ -34,7 +34,7 @@ pub struct MinCaptureVersion { pub patch: u32, } -const DEFAULT_STATEMENT_TIMEOUT_SECONDS: u64 = 120; +const DEFAULT_STATEMENT_TIMEOUT_SECONDS: u64 = 240; const SETTLED_STATEMENT_TIMEOUT_SECONDS: u64 = 240; pub(crate) const POLL_INTERVAL: Duration = Duration::from_secs(2); pub(crate) const MAX_POLL_ATTEMPTS: usize = 120; @@ -715,7 +715,7 @@ pub async fn fetch_rated_examples_after( AND rated.event_properties:inputs IS NOT NULL AND rated.event_properties:inputs:cursor_excerpt IS NOT NULL AND rated.event_properties:output IS NOT NULL - AND rated.event_properties:can_collect_data = true + AND rated.event_properties:inputs:can_collect_data = true ORDER BY rated.time ASC LIMIT ? OFFSET ? @@ -823,11 +823,11 @@ fn rated_examples_from_response<'a>( let environment = get_string("environment"); let zed_version = get_string("zed_version"); - match (inputs, output.clone(), rating.clone(), device_id.clone(), time.clone()) { - (Some(inputs), Some(output), Some(rating), Some(device_id), Some(time)) => { + match (inputs, output.clone(), rating.clone(), time.clone()) { + (Some(inputs), Some(output), Some(rating), Some(time)) => { Some(build_rated_example( request_id, - device_id, + device_id.unwrap_or_default(), time, inputs, output, @@ -840,11 +840,10 @@ fn rated_examples_from_response<'a>( } _ => { log::warn!( - "skipping row {row_index}: missing fields - inputs={:?} output={:?} rating={:?} device_id={:?} time={:?}", + "skipping row {row_index}: missing fields - inputs={:?} output={:?} rating={:?} time={:?}", inputs_json.is_some(), output.is_some(), rating.is_some(), - device_id.is_some(), time.is_some(), ); None From d312312fa8ded4ea0e9c1ef1f355d7b3f2735e94 Mon Sep 17 00:00:00 2001 From: Justin Su Date: Tue, 3 Mar 2026 12:56:51 -0500 Subject: [PATCH 14/74] Add ctrl-enter keybind (macOS) to type newline in search bars (#50420) I've been using https://github.com/zed-industries/zed/issues/15046#issuecomment-3259286451 for half a year now, and it seems worthy of inclusion in the default keymap. Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Added `ctrl-enter` keybind on macOS to type a newline in search bars --- assets/keymaps/default-macos.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5f210cb4da35f9909767035c941289ee24a2ee3f..410c13687fbe0c19fbcb4c155ebba36dd068354c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -448,6 +448,13 @@ "down": "search::NextHistoryQuery", }, }, + { + "context": "BufferSearchBar || ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + }, + }, { "context": "ProjectSearchBar", "use_key_equivalents": true, From 0a1a92131fc7bbe6b80c7b590d672814d7bbff5e Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:01:28 +0100 Subject: [PATCH 15/74] git: Fix remote worktree support (#50614) The main issue is that we weren't forwarding the proto messages through the collab server to the host. After fixing that I added integration tests to cover local worktrees, remote worktrees, and ssh worktrees. I also fixed a bug with FakeRepository where it wouldn't name its current branch as a worktree when calling git worktree, which doesn't match the behavior of the git binary. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects Release Notes: - git: Fix bug that caused the git worktree picker from displaying and creating worktrees over collab --- crates/collab/src/rpc.rs | 2 + crates/collab/tests/integration/git_tests.rs | 143 ++++++++++++++- .../remote_editing_collaboration_tests.rs | 126 ++++++++++++- crates/fs/Cargo.toml | 2 +- crates/fs/src/fake_git_repo.rs | 168 +++--------------- crates/fs/tests/integration/fake_git_repo.rs | 141 ++++++++++++++- crates/git/src/repository.rs | 3 + crates/project/tests/integration/git_store.rs | 119 +++++++++++++ 8 files changed, 555 insertions(+), 149 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 087dbe2a0ba23851689e75401c62b64775cf2282..b521f6b083ae311d98ec46c900ce821fd8042e4a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -437,6 +437,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index f3abb5bc3f3e1a12e7ecb56c985f2cff46582cee..6792eb92484d34f3085287b57f48a5761e760c92 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,9 +1,9 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use call::ActiveCall; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git_ui::project_diff::ProjectDiff; -use gpui::{AppContext as _, TestAppContext, VisualTestContext}; +use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, VisualTestContext}; use project::ProjectPath; use serde_json::json; use util::{path, rel_path::rel_path}; @@ -141,3 +141,142 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) ); }); } + +#[gpui::test] +async fn test_remote_git_worktrees( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ ".git": {}, "file.txt": "content" }), + ) + .await; + + let (project_a, _) = client_a.build_local_project(path!("/project"), cx_a).await; + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + executor.run_until_parked(); + + let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); + + // Initially only the main worktree (the repo itself) should be present + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, PathBuf::from(path!("/project"))); + + // Client B creates a git worktree via the remote project + let worktree_directory = PathBuf::from(path!("/project")); + cx_b.update(|cx| { + repo_b.update(cx, |repository, _| { + repository.create_worktree( + "feature-branch".to_string(), + worktree_directory.clone(), + Some("abc123".to_string()), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + // Client B lists worktrees — should see main + the one just created + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!(worktrees.len(), 2); + assert_eq!(worktrees[0].path, PathBuf::from(path!("/project"))); + assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch")); + assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch"); + assert_eq!(worktrees[1].sha.as_ref(), "abc123"); + + // Verify from the host side that the worktree was actually created + let host_worktrees = { + let repo_a = cx_a.update(|cx| { + project_a + .read(cx) + .repositories(cx) + .values() + .next() + .unwrap() + .clone() + }); + cx_a.update(|cx| repo_a.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!(host_worktrees.len(), 2); + assert_eq!(host_worktrees[0].path, PathBuf::from(path!("/project"))); + assert_eq!( + host_worktrees[1].path, + worktree_directory.join("feature-branch") + ); + + // Client B creates a second git worktree without an explicit commit + cx_b.update(|cx| { + repo_b.update(cx, |repository, _| { + repository.create_worktree( + "bugfix-branch".to_string(), + worktree_directory.clone(), + None, + ) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + // Client B lists worktrees — should now have main + two created + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repository, _| repository.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!(worktrees.len(), 3); + + let feature_worktree = worktrees + .iter() + .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/feature-branch") + .expect("should find feature-branch worktree"); + assert_eq!( + feature_worktree.path, + worktree_directory.join("feature-branch") + ); + + let bugfix_worktree = worktrees + .iter() + .find(|worktree| worktree.ref_name.as_ref() == "refs/heads/bugfix-branch") + .expect("should find bugfix-branch worktree"); + assert_eq!( + bugfix_worktree.path, + worktree_directory.join("bugfix-branch") + ); + assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha"); +} diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 4556c740ec74f6fb1bc8a2c760812376dae6b4a8..6825c468e783ee8d3a2a6107a031accfc108abd0 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -33,7 +33,7 @@ use settings::{ SettingsStore, }; use std::{ - path::Path, + path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -396,6 +396,130 @@ async fn test_ssh_collaboration_git_branches( }); } +#[gpui::test] +async fn test_ssh_collaboration_git_worktrees( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + server_cx: &mut TestAppContext, +) { + cx_a.set_name("a"); + cx_b.set_name("b"); + server_cx.set_name("server"); + + cx_a.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + server_cx.update(|cx| { + release_channel::init(semver::Version::new(0, 0, 0), cx); + }); + + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let (opts, server_ssh, _) = RemoteClient::fake_server(cx_a, server_cx); + let remote_fs = FakeFs::new(server_cx.executor()); + remote_fs + .insert_tree("/project", json!({ ".git": {}, "file.txt": "content" })) + .await; + + server_cx.update(HeadlessProject::init); + let languages = Arc::new(LanguageRegistry::new(server_cx.executor())); + let headless_project = server_cx.new(|cx| { + HeadlessProject::new( + HeadlessAppState { + session: server_ssh, + fs: remote_fs.clone(), + http_client: Arc::new(BlockedHttpClient), + node_runtime: NodeRuntime::unavailable(), + languages, + extension_host_proxy: Arc::new(ExtensionHostProxy::new()), + startup_time: std::time::Instant::now(), + }, + false, + cx, + ) + }); + + let client_ssh = RemoteClient::connect_mock(opts, cx_a).await; + let (project_a, _) = client_a + .build_ssh_project("/project", client_ssh, false, cx_a) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + + executor.run_until_parked(); + + let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); + + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!(worktrees.len(), 1); + + let worktree_directory = PathBuf::from("/project"); + cx_b.update(|cx| { + repo_b.update(cx, |repo, _| { + repo.create_worktree( + "feature-branch".to_string(), + worktree_directory.clone(), + Some("abc123".to_string()), + ) + }) + }) + .await + .unwrap() + .unwrap(); + + executor.run_until_parked(); + + let worktrees = cx_b + .update(|cx| repo_b.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap(); + assert_eq!(worktrees.len(), 2); + assert_eq!(worktrees[1].path, worktree_directory.join("feature-branch")); + assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch"); + assert_eq!(worktrees[1].sha.as_ref(), "abc123"); + + let server_worktrees = { + let server_repo = server_cx.update(|cx| { + headless_project.update(cx, |headless_project, cx| { + headless_project + .git_store + .read(cx) + .repositories() + .values() + .next() + .unwrap() + .clone() + }) + }); + server_cx + .update(|cx| server_repo.update(cx, |repo, _| repo.worktrees())) + .await + .unwrap() + .unwrap() + }; + assert_eq!(server_worktrees.len(), 2); + assert_eq!( + server_worktrees[1].path, + worktree_directory.join("feature-branch") + ); +} + #[gpui::test] async fn test_ssh_collaboration_formatting_with_prettier( executor: BackgroundExecutor, diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index 6355524e4f328df0ca7fcf24c1df0557676ba6a6..04cae2dd2ad18f85a7c2ed663c1c3482febb22d3 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -58,4 +58,4 @@ gpui = { workspace = true, features = ["test-support"] } git = { workspace = true, features = ["test-support"] } [features] -test-support = ["gpui/test-support", "git/test-support"] +test-support = ["gpui/test-support", "git/test-support", "util/test-support"] diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 12cd67cdae1a250d07468047617c8cc7a52737fa..99295c69d45427c799e3d850d605f63d3950ee57 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -406,7 +406,31 @@ impl GitRepository for FakeGitRepository { } fn worktrees(&self) -> BoxFuture<'_, Result>> { - self.with_state_async(false, |state| Ok(state.worktrees.clone())) + let dot_git_path = self.dot_git_path.clone(); + self.with_state_async(false, move |state| { + let work_dir = dot_git_path + .parent() + .map(PathBuf::from) + .unwrap_or(dot_git_path); + let head_sha = state + .refs + .get("HEAD") + .cloned() + .unwrap_or_else(|| "0000000".to_string()); + let branch_ref = state + .current_branch_name + .as_ref() + .map(|name| format!("refs/heads/{name}")) + .unwrap_or_else(|| "refs/heads/main".to_string()); + let main_worktree = Worktree { + path: work_dir, + ref_name: branch_ref.into(), + sha: head_sha.into(), + }; + let mut all = vec![main_worktree]; + all.extend(state.worktrees.iter().cloned()); + Ok(all) + }) } fn create_worktree( @@ -1012,145 +1036,3 @@ impl GitRepository for FakeGitRepository { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{FakeFs, Fs}; - use gpui::TestAppContext; - use serde_json::json; - use std::path::Path; - - #[gpui::test] - async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) { - let worktree_dir_settings = &["../worktrees", ".git/zed-worktrees", "my-worktrees/"]; - - for worktree_dir_setting in worktree_dir_settings { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"})) - .await; - let repo = fs - .open_repo(Path::new("/project/.git"), None) - .expect("should open fake repo"); - - // Initially no worktrees - let worktrees = repo.worktrees().await.unwrap(); - assert!(worktrees.is_empty()); - - let expected_dir = git::repository::resolve_worktree_directory( - Path::new("/project"), - worktree_dir_setting, - ); - - // Create a worktree - repo.create_worktree( - "feature-branch".to_string(), - expected_dir.clone(), - Some("abc123".to_string()), - ) - .await - .unwrap(); - - // List worktrees — should have one - let worktrees = repo.worktrees().await.unwrap(); - assert_eq!(worktrees.len(), 1); - assert_eq!( - worktrees[0].path, - expected_dir.join("feature-branch"), - "failed for worktree_directory setting: {worktree_dir_setting:?}" - ); - assert_eq!(worktrees[0].ref_name.as_ref(), "refs/heads/feature-branch"); - assert_eq!(worktrees[0].sha.as_ref(), "abc123"); - - // Directory should exist in FakeFs after create - assert!( - fs.is_dir(&expected_dir.join("feature-branch")).await, - "worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}" - ); - - // Create a second worktree (without explicit commit) - repo.create_worktree("bugfix-branch".to_string(), expected_dir.clone(), None) - .await - .unwrap(); - - let worktrees = repo.worktrees().await.unwrap(); - assert_eq!(worktrees.len(), 2); - assert!( - fs.is_dir(&expected_dir.join("bugfix-branch")).await, - "second worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}" - ); - - // Rename the first worktree - repo.rename_worktree( - expected_dir.join("feature-branch"), - expected_dir.join("renamed-branch"), - ) - .await - .unwrap(); - - let worktrees = repo.worktrees().await.unwrap(); - assert_eq!(worktrees.len(), 2); - assert!( - worktrees - .iter() - .any(|w| w.path == expected_dir.join("renamed-branch")), - "renamed worktree should exist at new path for setting {worktree_dir_setting:?}" - ); - assert!( - worktrees - .iter() - .all(|w| w.path != expected_dir.join("feature-branch")), - "old path should no longer exist for setting {worktree_dir_setting:?}" - ); - - // Directory should be moved in FakeFs after rename - assert!( - !fs.is_dir(&expected_dir.join("feature-branch")).await, - "old worktree directory should not exist after rename for setting {worktree_dir_setting:?}" - ); - assert!( - fs.is_dir(&expected_dir.join("renamed-branch")).await, - "new worktree directory should exist after rename for setting {worktree_dir_setting:?}" - ); - - // Rename a nonexistent worktree should fail - let result = repo - .rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere")) - .await; - assert!(result.is_err()); - - // Remove a worktree - repo.remove_worktree(expected_dir.join("renamed-branch"), false) - .await - .unwrap(); - - let worktrees = repo.worktrees().await.unwrap(); - assert_eq!(worktrees.len(), 1); - assert_eq!(worktrees[0].path, expected_dir.join("bugfix-branch")); - - // Directory should be removed from FakeFs after remove - assert!( - !fs.is_dir(&expected_dir.join("renamed-branch")).await, - "worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}" - ); - - // Remove a nonexistent worktree should fail - let result = repo - .remove_worktree(PathBuf::from("/nonexistent"), false) - .await; - assert!(result.is_err()); - - // Remove the last worktree - repo.remove_worktree(expected_dir.join("bugfix-branch"), false) - .await - .unwrap(); - - let worktrees = repo.worktrees().await.unwrap(); - assert!(worktrees.is_empty()); - assert!( - !fs.is_dir(&expected_dir.join("bugfix-branch")).await, - "last worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}" - ); - } - } -} diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs index 36dfcaf168b4f0190c5c49bf4798fac7bc9bd37b..bae7f2fc94dd5161793f85f64cc0a1448a187134 100644 --- a/crates/fs/tests/integration/fake_git_repo.rs +++ b/crates/fs/tests/integration/fake_git_repo.rs @@ -1,9 +1,146 @@ use fs::{FakeFs, Fs}; -use gpui::BackgroundExecutor; +use gpui::{BackgroundExecutor, TestAppContext}; use serde_json::json; -use std::path::Path; +use std::path::{Path, PathBuf}; use util::path; +#[gpui::test] +async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) { + let worktree_dir_settings = &["../worktrees", ".git/zed-worktrees", "my-worktrees/"]; + + for worktree_dir_setting in worktree_dir_settings { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"})) + .await; + let repo = fs + .open_repo(Path::new("/project/.git"), None) + .expect("should open fake repo"); + + // Initially only the main worktree exists + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, PathBuf::from("/project")); + + let expected_dir = git::repository::resolve_worktree_directory( + Path::new("/project"), + worktree_dir_setting, + ); + + // Create a worktree + repo.create_worktree( + "feature-branch".to_string(), + expected_dir.clone(), + Some("abc123".to_string()), + ) + .await + .unwrap(); + + // List worktrees — should have main + one created + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + assert_eq!(worktrees[0].path, PathBuf::from("/project")); + assert_eq!( + worktrees[1].path, + expected_dir.join("feature-branch"), + "failed for worktree_directory setting: {worktree_dir_setting:?}" + ); + assert_eq!(worktrees[1].ref_name.as_ref(), "refs/heads/feature-branch"); + assert_eq!(worktrees[1].sha.as_ref(), "abc123"); + + // Directory should exist in FakeFs after create + assert!( + fs.is_dir(&expected_dir.join("feature-branch")).await, + "worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}" + ); + + // Create a second worktree (without explicit commit) + repo.create_worktree("bugfix-branch".to_string(), expected_dir.clone(), None) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 3); + assert!( + fs.is_dir(&expected_dir.join("bugfix-branch")).await, + "second worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}" + ); + + // Rename the first worktree + repo.rename_worktree( + expected_dir.join("feature-branch"), + expected_dir.join("renamed-branch"), + ) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 3); + assert!( + worktrees + .iter() + .any(|w| w.path == expected_dir.join("renamed-branch")), + "renamed worktree should exist at new path for setting {worktree_dir_setting:?}" + ); + assert!( + worktrees + .iter() + .all(|w| w.path != expected_dir.join("feature-branch")), + "old path should no longer exist for setting {worktree_dir_setting:?}" + ); + + // Directory should be moved in FakeFs after rename + assert!( + !fs.is_dir(&expected_dir.join("feature-branch")).await, + "old worktree directory should not exist after rename for setting {worktree_dir_setting:?}" + ); + assert!( + fs.is_dir(&expected_dir.join("renamed-branch")).await, + "new worktree directory should exist after rename for setting {worktree_dir_setting:?}" + ); + + // Rename a nonexistent worktree should fail + let result = repo + .rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere")) + .await; + assert!(result.is_err()); + + // Remove a worktree + repo.remove_worktree(expected_dir.join("renamed-branch"), false) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 2); + assert_eq!(worktrees[0].path, PathBuf::from("/project")); + assert_eq!(worktrees[1].path, expected_dir.join("bugfix-branch")); + + // Directory should be removed from FakeFs after remove + assert!( + !fs.is_dir(&expected_dir.join("renamed-branch")).await, + "worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}" + ); + + // Remove a nonexistent worktree should fail + let result = repo + .remove_worktree(PathBuf::from("/nonexistent"), false) + .await; + assert!(result.is_err()); + + // Remove the last worktree + repo.remove_worktree(expected_dir.join("bugfix-branch"), false) + .await + .unwrap(); + + let worktrees = repo.worktrees().await.unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].path, PathBuf::from("/project")); + assert!( + !fs.is_dir(&expected_dir.join("bugfix-branch")).await, + "last worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}" + ); + } +} + #[gpui::test] async fn test_checkpoints(executor: BackgroundExecutor) { let fs = FakeFs::new(executor); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index bd07555d05b759a33080b9ae9f166145c3d26d14..6dba1400dffe1fd00844dd7241f39f48a7a759a6 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -303,6 +303,7 @@ impl Branch { pub struct Worktree { pub path: PathBuf, pub ref_name: SharedString, + // todo(git_worktree) This type should be a Oid pub sha: SharedString, } @@ -340,6 +341,8 @@ pub fn parse_worktrees_from_str>(raw_worktrees: T) -> Vec Date: Tue, 3 Mar 2026 21:09:26 +0200 Subject: [PATCH 16/74] ep: Predict by querying Baseten directly (#50626) This can be used like `ep predict --provider baseten:V0131GitMergeMarkersPrefix`. Since it doesn't require load_project, it can be used with captured requests. Release Notes: - N/A --- .../edit_prediction_cli/src/format_prompt.rs | 20 ++-- crates/edit_prediction_cli/src/main.rs | 9 ++ crates/edit_prediction_cli/src/predict.rs | 110 +++++++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index ecacd963023d7d113ea5ad77b61fd1d88306fc95..bee79ae8160eeb815a3739b53a5441f6063fb622 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -53,18 +53,22 @@ pub async fn run_format_prompt( let prompt = format_zeta_prompt(prompt_inputs, zeta_format); let prefill = zeta_prompt::get_prefill(prompt_inputs, zeta_format); - let (expected_patch, expected_cursor_offset) = example + let expected_output = example .spec .expected_patches_with_cursor_positions() .into_iter() .next() - .context("expected patches is empty")?; - let expected_output = zeta2_output_for_patch( - prompt_inputs, - &expected_patch, - expected_cursor_offset, - zeta_format, - )?; + .and_then(|(expected_patch, expected_cursor_offset)| { + zeta2_output_for_patch( + prompt_inputs, + &expected_patch, + expected_cursor_offset, + zeta_format, + ) + .ok() + }) + .unwrap_or_default(); + let rejected_output = example.spec.rejected_patch.as_ref().and_then(|patch| { zeta2_output_for_patch(prompt_inputs, patch, None, zeta_format).ok() }); diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 207a69328fb07277c39463c0c6a460862c95fe42..8bb4b2a8e2f50d448fc314a70e2fc94cfa2c3d71 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -358,6 +358,7 @@ enum PredictionProvider { Mercury, Zeta1, Zeta2(ZetaFormat), + Baseten(ZetaFormat), Teacher(TeacherBackend), TeacherNonBatching(TeacherBackend), Repair, @@ -376,6 +377,7 @@ impl std::fmt::Display for PredictionProvider { PredictionProvider::Mercury => write!(f, "mercury"), PredictionProvider::Zeta1 => write!(f, "zeta1"), PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), + PredictionProvider::Baseten(format) => write!(f, "baseten:{format}"), PredictionProvider::Teacher(backend) => write!(f, "teacher:{backend}"), PredictionProvider::TeacherNonBatching(backend) => { write!(f, "teacher-non-batching:{backend}") @@ -415,6 +417,13 @@ impl std::str::FromStr for PredictionProvider { Ok(PredictionProvider::TeacherNonBatching(backend)) } "repair" => Ok(PredictionProvider::Repair), + "baseten" => { + let format = arg + .map(ZetaFormat::parse) + .transpose()? + .unwrap_or(ZetaFormat::default()); + Ok(PredictionProvider::Baseten(format)) + } _ => { anyhow::bail!( "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-non-batching, repair\n\ diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 02ba24b8a4f2627b9542254e3d118981737f8318..8f537dc0817a9cb0b4fd74348ae5e43d4f63beb9 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -6,14 +6,18 @@ use crate::{ headless::EpAppState, load_project::run_load_project, openai_client::OpenAiClient, + parse_output::parse_prediction_output, paths::{LATEST_EXAMPLE_RUN_DIR, RUN_DIR}, - progress::{ExampleProgress, InfoStyle, Step}, + progress::{ExampleProgress, InfoStyle, Step, StepProgress}, retrieve_context::run_context_retrieval, }; use anyhow::Context as _; +use cloud_llm_client::predict_edits_v3::{RawCompletionRequest, RawCompletionResponse}; use edit_prediction::{DebugEvent, EditPredictionStore, Zeta2RawConfig}; -use futures::{FutureExt as _, StreamExt as _, future::Shared}; +use futures::{AsyncReadExt as _, FutureExt as _, StreamExt as _, future::Shared}; use gpui::{AppContext as _, AsyncApp, Task}; +use http_client::{AsyncBody, HttpClient, Method}; +use reqwest_client::ReqwestClient; use std::{ fs, sync::{ @@ -79,6 +83,22 @@ pub async fn run_prediction( .await; } + if let PredictionProvider::Baseten(format) = provider { + run_format_prompt( + example, + &FormatPromptArgs { + provider: PredictionProvider::Zeta2(format), + }, + app_state.clone(), + example_progress, + cx, + ) + .await?; + + let step_progress = example_progress.start(Step::Predict); + return predict_baseten(example, format, &step_progress).await; + } + run_load_project(example, app_state.clone(), example_progress, cx.clone()).await?; run_context_retrieval(example, app_state.clone(), example_progress, cx.clone()).await?; @@ -116,7 +136,8 @@ pub async fn run_prediction( PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, PredictionProvider::Teacher(..) | PredictionProvider::TeacherNonBatching(..) - | PredictionProvider::Repair => { + | PredictionProvider::Repair + | PredictionProvider::Baseten(_) => { unreachable!() } }; @@ -480,6 +501,89 @@ async fn predict_openai( Ok(()) } +pub async fn predict_baseten( + example: &mut Example, + format: ZetaFormat, + step_progress: &StepProgress, +) -> anyhow::Result<()> { + let model_id = + std::env::var("ZED_ZETA_MODEL").context("ZED_ZETA_MODEL environment variable required")?; + + let api_key = + std::env::var("BASETEN_API_KEY").context("BASETEN_API_KEY environment variable not set")?; + + let prompt = example.prompt.as_ref().context("Prompt is required")?; + let prompt_text = prompt.input.clone(); + let prefill = prompt.prefill.clone().unwrap_or_default(); + + step_progress.set_substatus("running prediction via baseten"); + + let environment: String = <&'static str>::from(&format).to_lowercase(); + let url = format!( + "https://model-{model_id}.api.baseten.co/environments/{environment}/sync/v1/completions" + ); + + let request_body = RawCompletionRequest { + model: model_id, + prompt: prompt_text.clone(), + max_tokens: Some(2048), + temperature: Some(0.), + stop: vec![], + environment: None, + }; + + let body_bytes = + serde_json::to_vec(&request_body).context("Failed to serialize request body")?; + + let http_client: Arc = Arc::new(ReqwestClient::new()); + let request = http_client::Request::builder() + .method(Method::POST) + .uri(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Api-Key {api_key}")) + .body(AsyncBody::from(body_bytes))?; + + let mut response = http_client.send(request).await?; + let status = response.status(); + + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .context("Failed to read Baseten response body")?; + + if !status.is_success() { + anyhow::bail!("Baseten API returned {status}: {body}"); + } + + let completion: RawCompletionResponse = + serde_json::from_str(&body).context("Failed to parse Baseten response")?; + + let actual_output = completion + .choices + .into_iter() + .next() + .map(|choice| choice.text) + .unwrap_or_default(); + + let actual_output = format!("{prefill}{actual_output}"); + + let (actual_patch, actual_cursor) = + parse_prediction_output(example, &actual_output, PredictionProvider::Zeta2(format))?; + + let prediction = ExamplePrediction { + actual_patch: Some(actual_patch), + actual_output, + actual_cursor, + error: None, + provider: PredictionProvider::Baseten(format), + }; + + example.predictions.push(prediction); + Ok(()) +} + pub async fn sync_batches(provider: Option<&PredictionProvider>) -> anyhow::Result<()> { match provider { Some(PredictionProvider::Teacher(backend)) => match backend { From 3d3a66dc9811be2afd2c3690f68e5aef498bc01f Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:46:08 +0100 Subject: [PATCH 17/74] collab: Fix unable to rejoin shared project after leaving a call (#50630) When a downstream project was disconnected from the host (e.g. the guest left the call), `disconnected_from_host_internal` did not clear `client_subscriptions`. These subscriptions hold entries in the `Client`'s entity subscription map, so a subsequent `join_remote_project` with the same project ID would fail with "already subscribed to entity". The fix adds `self.client_subscriptions.clear()` to `disconnected_from_host_internal`, matching what `unshare_internal` already does for the host side. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects Release Notes: - collab: Fix unable to rejoin project bug ("already subscribed to entity") --- crates/collab/tests/integration/git_tests.rs | 1 - .../tests/integration/integration_tests.rs | 86 +++++++++++++++++++ crates/project/src/project.rs | 6 ++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 6792eb92484d34f3085287b57f48a5761e760c92..6e50e41bade5f5dfdf124f5a6d659e81fc2ce0f6 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -9,7 +9,6 @@ use serde_json::json; use util::{path, rel_path::rel_path}; use workspace::{MultiWorkspace, Workspace}; -// use crate::TestServer; #[gpui::test] diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index c26f20c1e294326f275dbfda1d2d41603719cd3e..3bad9c82c26392a935f67efc578b5d293b2cab3d 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -7205,3 +7205,89 @@ async fn test_remote_git_branches( assert_eq!(host_branch.name(), "totally-new-branch"); } + +#[gpui::test] +async fn test_guest_can_rejoin_shared_project_after_leaving_call( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + + client_a + .fs() + .insert_tree( + path!("/project"), + json!({ + "file.txt": "hello\n", + }), + ) + .await; + + let (project_a, _worktree_id) = client_a.build_local_project(path!("/project"), cx_a).await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let _project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + // third client joins call to prevent room from being torn down + let _project_c = client_c.join_remote_project(project_id, cx_c).await; + executor.run_until_parked(); + + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |call, cx| call.hang_up(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + let user_id_b = client_b.current_user_id(cx_b).to_proto(); + let active_call_a = cx_a.read(ActiveCall::global); + active_call_a + .update(cx_a, |call, cx| call.invite(user_id_b, None, cx)) + .await + .unwrap(); + executor.run_until_parked(); + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + executor.run_until_parked(); + + let _project_b2 = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + project_a.read_with(cx_a, |project, _| { + let guest_count = project + .collaborators() + .values() + .filter(|c| !c.is_host) + .count(); + + assert_eq!( + guest_count, 2, + "host should have exactly one guest collaborator after rejoin" + ); + }); + + _project_b.read_with(cx_b, |project, _| { + assert_eq!( + project.client_subscriptions().len(), + 0, + "We should clear all host subscriptions after leaving the project" + ); + }) +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9e37802213dfb8df5cf63af5648044ae8ec65ecb..756f095511a9688678df013458710e69d720c52e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1942,6 +1942,11 @@ impl Project { } } + #[cfg(feature = "test-support")] + pub fn client_subscriptions(&self) -> &Vec { + &self.client_subscriptions + } + #[cfg(feature = "test-support")] pub async fn example( root_paths: impl IntoIterator, @@ -2741,6 +2746,7 @@ impl Project { } = &mut self.client_state { *sharing_has_stopped = true; + self.client_subscriptions.clear(); self.collaborators.clear(); self.worktree_store.update(cx, |store, cx| { store.disconnected_from_host(cx); From a5a1977e985fbabcac2aacd7786f460df28c7eba Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 3 Mar 2026 14:28:48 -0600 Subject: [PATCH 18/74] ep: API keys for OpenAI compatible (#50615) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Added support for providing an API key to OpenAI-compatible edit prediction providers --- crates/edit_prediction/src/edit_prediction.rs | 1 + crates/edit_prediction/src/fim.rs | 11 +- .../edit_prediction/src/open_ai_compatible.rs | 133 ++++++++++++++++++ crates/edit_prediction/src/zeta.rs | 78 ++-------- .../src/edit_prediction_button.rs | 8 +- .../pages/edit_prediction_provider_setup.rs | 115 ++++++++------- .../zed/src/zed/edit_prediction_registry.rs | 5 +- 7 files changed, 230 insertions(+), 121 deletions(-) create mode 100644 crates/edit_prediction/src/open_ai_compatible.rs diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index e6e3a9abdf83deb785cd56d358b065973682b8cc..74988d65933b3bbbc2507077a74dfeb94089ab63 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -69,6 +69,7 @@ pub mod sweep_ai; pub mod udiff; mod capture_example; +pub mod open_ai_compatible; mod zed_edit_prediction_delegate; pub mod zeta; diff --git a/crates/edit_prediction/src/fim.rs b/crates/edit_prediction/src/fim.rs index 66f2e58a3b01b4fbf49b11864db4daec6b4dc1c2..d3e18f73acc665eec28d725530d11297cf4d69ea 100644 --- a/crates/edit_prediction/src/fim.rs +++ b/crates/edit_prediction/src/fim.rs @@ -1,6 +1,7 @@ use crate::{ - EditPredictionId, EditPredictionModelInput, cursor_excerpt, prediction::EditPredictionResult, - zeta, + EditPredictionId, EditPredictionModelInput, cursor_excerpt, + open_ai_compatible::{self, load_open_ai_compatible_api_key_if_needed}, + prediction::EditPredictionResult, }; use anyhow::{Context as _, Result, anyhow}; use gpui::{App, AppContext as _, Entity, Task}; @@ -58,6 +59,8 @@ pub fn request_prediction( return Task::ready(Err(anyhow!("Unsupported edit prediction provider for FIM"))); }; + let api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); + let result = cx.background_spawn(async move { let (excerpt_range, _) = cursor_excerpt::editable_and_context_ranges_for_cursor_position( cursor_point, @@ -90,12 +93,14 @@ pub fn request_prediction( let stop_tokens = get_fim_stop_tokens(); let max_tokens = settings.max_output_tokens; - let (response_text, request_id) = zeta::send_custom_server_request( + + let (response_text, request_id) = open_ai_compatible::send_custom_server_request( provider, &settings, prompt, max_tokens, stop_tokens, + api_key, &http_client, ) .await?; diff --git a/crates/edit_prediction/src/open_ai_compatible.rs b/crates/edit_prediction/src/open_ai_compatible.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca378ba1fd0bc9bdbb3e85c7610e1b94c1be388f --- /dev/null +++ b/crates/edit_prediction/src/open_ai_compatible.rs @@ -0,0 +1,133 @@ +use anyhow::{Context as _, Result}; +use cloud_llm_client::predict_edits_v3::{RawCompletionRequest, RawCompletionResponse}; +use futures::AsyncReadExt as _; +use gpui::{App, AppContext as _, Entity, Global, SharedString, Task, http_client}; +use language::language_settings::{OpenAiCompatibleEditPredictionSettings, all_language_settings}; +use language_model::{ApiKeyState, EnvVar, env_var}; +use std::sync::Arc; + +pub fn open_ai_compatible_api_url(cx: &App) -> SharedString { + all_language_settings(None, cx) + .edit_predictions + .open_ai_compatible_api + .as_ref() + .map(|settings| settings.api_url.clone()) + .unwrap_or_default() + .into() +} + +pub const OPEN_AI_COMPATIBLE_CREDENTIALS_USERNAME: &str = "openai-compatible-api-token"; +pub static OPEN_AI_COMPATIBLE_TOKEN_ENV_VAR: std::sync::LazyLock = + env_var!("ZED_OPEN_AI_COMPATIBLE_EDIT_PREDICTION_API_KEY"); + +struct GlobalOpenAiCompatibleApiKey(Entity); + +impl Global for GlobalOpenAiCompatibleApiKey {} + +pub fn open_ai_compatible_api_token(cx: &mut App) -> Entity { + if let Some(global) = cx.try_global::() { + return global.0.clone(); + } + + let entity = cx.new(|cx| { + ApiKeyState::new( + open_ai_compatible_api_url(cx), + OPEN_AI_COMPATIBLE_TOKEN_ENV_VAR.clone(), + ) + }); + cx.set_global(GlobalOpenAiCompatibleApiKey(entity.clone())); + entity +} + +pub fn load_open_ai_compatible_api_token( + cx: &mut App, +) -> Task> { + let api_url = open_ai_compatible_api_url(cx); + open_ai_compatible_api_token(cx).update(cx, |key_state, cx| { + key_state.load_if_needed(api_url, |s| s, cx) + }) +} + +pub fn load_open_ai_compatible_api_key_if_needed( + provider: settings::EditPredictionProvider, + cx: &mut App, +) -> Option> { + if provider != settings::EditPredictionProvider::OpenAiCompatibleApi { + return None; + } + _ = load_open_ai_compatible_api_token(cx); + let url = open_ai_compatible_api_url(cx); + return open_ai_compatible_api_token(cx).read(cx).key(&url); +} + +pub(crate) async fn send_custom_server_request( + provider: settings::EditPredictionProvider, + settings: &OpenAiCompatibleEditPredictionSettings, + prompt: String, + max_tokens: u32, + stop_tokens: Vec, + api_key: Option>, + http_client: &Arc, +) -> Result<(String, String)> { + match provider { + settings::EditPredictionProvider::Ollama => { + let response = crate::ollama::make_request( + settings.clone(), + prompt, + stop_tokens, + http_client.clone(), + ) + .await?; + Ok((response.response, response.created_at)) + } + _ => { + let request = RawCompletionRequest { + model: settings.model.clone(), + prompt, + max_tokens: Some(max_tokens), + temperature: None, + stop: stop_tokens + .into_iter() + .map(std::borrow::Cow::Owned) + .collect(), + environment: None, + }; + + let request_body = serde_json::to_string(&request)?; + let mut http_request_builder = http_client::Request::builder() + .method(http_client::Method::POST) + .uri(settings.api_url.as_ref()) + .header("Content-Type", "application/json"); + + if let Some(api_key) = api_key { + http_request_builder = + http_request_builder.header("Authorization", format!("Bearer {}", api_key)); + } + + let http_request = + http_request_builder.body(http_client::AsyncBody::from(request_body))?; + + let mut response = http_client.send(http_request).await?; + let status = response.status(); + + if !status.is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + anyhow::bail!("custom server error: {} - {}", status, body); + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + let parsed: RawCompletionResponse = + serde_json::from_str(&body).context("Failed to parse completion response")?; + let text = parsed + .choices + .into_iter() + .next() + .map(|choice| choice.text) + .unwrap_or_default(); + Ok((text, parsed.id)) + } + } +} diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index f6a786572736908556535b9131c1cf7814a6126f..789ff6c0d7fcc269baf30b5e0fb0e849bc865859 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -2,15 +2,14 @@ use crate::cursor_excerpt::compute_excerpt_ranges; use crate::prediction::EditPredictionResult; use crate::{ CurrentEditPrediction, DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, - EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, ollama, + EditPredictionModelInput, EditPredictionStartedDebugEvent, EditPredictionStore, }; -use anyhow::{Context as _, Result}; -use cloud_llm_client::predict_edits_v3::{RawCompletionRequest, RawCompletionResponse}; +use anyhow::Result; +use cloud_llm_client::predict_edits_v3::RawCompletionRequest; use cloud_llm_client::{AcceptEditPredictionBody, EditPredictionRejectReason}; use edit_prediction_types::PredictedCursorPosition; -use futures::AsyncReadExt as _; -use gpui::{App, AppContext as _, Task, http_client, prelude::*}; -use language::language_settings::{OpenAiCompatibleEditPredictionSettings, all_language_settings}; +use gpui::{App, AppContext as _, Task, prelude::*}; +use language::language_settings::all_language_settings; use language::{BufferSnapshot, ToOffset as _, ToPoint, text_diff}; use release_channel::AppVersion; use settings::EditPredictionPromptFormat; @@ -25,6 +24,10 @@ use zeta_prompt::{ zeta1::{self, EDITABLE_REGION_END_MARKER}, }; +use crate::open_ai_compatible::{ + load_open_ai_compatible_api_key_if_needed, send_custom_server_request, +}; + pub fn request_prediction_with_zeta( store: &mut EditPredictionStore, EditPredictionModelInput { @@ -56,6 +59,7 @@ pub fn request_prediction_with_zeta( let buffer_snapshotted_at = Instant::now(); let raw_config = store.zeta2_raw_config().cloned(); let preferred_experiment = store.preferred_experiment().map(|s| s.to_owned()); + let open_ai_compatible_api_key = load_open_ai_compatible_api_key_if_needed(provider, cx); let excerpt_path: Arc = snapshot .file() @@ -131,6 +135,7 @@ pub fn request_prediction_with_zeta( prompt, max_tokens, stop_tokens, + open_ai_compatible_api_key.clone(), &http_client, ) .await?; @@ -157,6 +162,7 @@ pub fn request_prediction_with_zeta( prompt, max_tokens, vec![], + open_ai_compatible_api_key.clone(), &http_client, ) .await?; @@ -400,66 +406,6 @@ pub fn zeta2_prompt_input( (full_context_offset_range, prompt_input) } -pub(crate) async fn send_custom_server_request( - provider: settings::EditPredictionProvider, - settings: &OpenAiCompatibleEditPredictionSettings, - prompt: String, - max_tokens: u32, - stop_tokens: Vec, - http_client: &Arc, -) -> Result<(String, String)> { - match provider { - settings::EditPredictionProvider::Ollama => { - let response = - ollama::make_request(settings.clone(), prompt, stop_tokens, http_client.clone()) - .await?; - Ok((response.response, response.created_at)) - } - _ => { - let request = RawCompletionRequest { - model: settings.model.clone(), - prompt, - max_tokens: Some(max_tokens), - temperature: None, - stop: stop_tokens - .into_iter() - .map(std::borrow::Cow::Owned) - .collect(), - environment: None, - }; - - let request_body = serde_json::to_string(&request)?; - let http_request = http_client::Request::builder() - .method(http_client::Method::POST) - .uri(settings.api_url.as_ref()) - .header("Content-Type", "application/json") - .body(http_client::AsyncBody::from(request_body))?; - - let mut response = http_client.send(http_request).await?; - let status = response.status(); - - if !status.is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!("custom server error: {} - {}", status, body); - } - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - let parsed: RawCompletionResponse = - serde_json::from_str(&body).context("Failed to parse completion response")?; - let text = parsed - .choices - .into_iter() - .next() - .map(|choice| choice.text) - .unwrap_or_default(); - Ok((text, parsed.id)) - } - } -} - pub(crate) fn edit_prediction_accepted( store: &EditPredictionStore, current_prediction: CurrentEditPrediction, diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 6339c7d6cd9fa1cc40101cc1bf14650a6904b3c7..743256970f486b474405e7f034f18501505cb825 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -539,9 +539,15 @@ impl EditPredictionButton { edit_prediction::ollama::ensure_authenticated(cx); let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); + let open_ai_compatible_api_token_task = + edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx); cx.spawn(async move |this, cx| { - _ = futures::join!(sweep_api_token_task, mercury_api_token_task); + _ = futures::join!( + sweep_api_token_task, + mercury_api_token_task, + open_ai_compatible_api_token_task + ); this.update(cx, |_, cx| { cx.notify(); }) diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 338fe4de14f1f7e9060fafe865253f09f0bdc481..32c4bee84bd1f72263ed28bcd44d7e6349c4b24c 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -2,6 +2,7 @@ use codestral::{CODESTRAL_API_URL, codestral_api_key_state, codestral_api_url}; use edit_prediction::{ ApiKeyState, mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token}, + open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url}, sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token}, }; use edit_prediction_ui::{get_available_providers, set_completion_provider}; @@ -33,7 +34,9 @@ pub(crate) fn render_edit_prediction_setup_page( render_api_key_provider( IconName::Inception, "Mercury", - "https://platform.inceptionlabs.ai/dashboard/api-keys".into(), + ApiKeyDocs::Link { + dashboard_url: "https://platform.inceptionlabs.ai/dashboard/api-keys".into(), + }, mercury_api_token(cx), |_cx| MERCURY_CREDENTIALS_URL, None, @@ -46,7 +49,9 @@ pub(crate) fn render_edit_prediction_setup_page( render_api_key_provider( IconName::SweepAi, "Sweep", - "https://app.sweep.dev/".into(), + ApiKeyDocs::Link { + dashboard_url: "https://app.sweep.dev/".into(), + }, sweep_api_token(cx), |_cx| SWEEP_CREDENTIALS_URL, Some( @@ -68,7 +73,9 @@ pub(crate) fn render_edit_prediction_setup_page( render_api_key_provider( IconName::AiMistral, "Codestral", - "https://console.mistral.ai/codestral".into(), + ApiKeyDocs::Link { + dashboard_url: "https://console.mistral.ai/codestral".into(), + }, codestral_api_key_state(cx), |cx| codestral_api_url(cx), Some( @@ -87,7 +94,31 @@ pub(crate) fn render_edit_prediction_setup_page( .into_any_element(), ), Some(render_ollama_provider(settings_window, window, cx).into_any_element()), - Some(render_open_ai_compatible_provider(settings_window, window, cx).into_any_element()), + Some( + render_api_key_provider( + IconName::AiOpenAiCompat, + "OpenAI Compatible API", + ApiKeyDocs::Custom { + message: "Set an API key here. It will be sent as Authorization: Bearer {key}." + .into(), + }, + open_ai_compatible_api_token(cx), + |cx| open_ai_compatible_api_url(cx), + Some( + settings_window + .render_sub_page_items_section( + open_ai_compatible_settings().iter().enumerate(), + true, + window, + cx, + ) + .into_any_element(), + ), + window, + cx, + ) + .into_any_element(), + ), ]; div() @@ -162,10 +193,15 @@ fn render_provider_dropdown(window: &mut Window, cx: &mut App) -> AnyElement { .into_any_element() } +enum ApiKeyDocs { + Link { dashboard_url: SharedString }, + Custom { message: SharedString }, +} + fn render_api_key_provider( icon: IconName, title: &'static str, - link: SharedString, + docs: ApiKeyDocs, api_key_state: Entity, current_url: fn(&mut App) -> SharedString, additional_fields: Option, @@ -209,25 +245,32 @@ fn render_api_key_provider( .icon(icon) .no_padding(true); let button_link_label = format!("{} dashboard", title); - let description = h_flex() - .min_w_0() - .gap_0p5() - .child( - Label::new("Visit the") + let description = match docs { + ApiKeyDocs::Custom { message } => h_flex().min_w_0().gap_0p5().child( + Label::new(message) .size(LabelSize::Small) .color(Color::Muted), - ) - .child( - ButtonLink::new(button_link_label, link) - .no_icon(true) - .label_size(LabelSize::Small) - .label_color(Color::Muted), - ) - .child( - Label::new("to generate an API key.") - .size(LabelSize::Small) - .color(Color::Muted), - ); + ), + ApiKeyDocs::Link { dashboard_url } => h_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new("Visit the") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + ButtonLink::new(button_link_label, dashboard_url) + .no_icon(true) + .label_size(LabelSize::Small) + .label_color(Color::Muted), + ) + .child( + Label::new("to generate an API key.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + }; let configured_card_label = if is_from_env_var { "API Key Set in Environment Variable" } else { @@ -484,34 +527,6 @@ fn ollama_settings() -> Box<[SettingsPageItem]> { ]) } -fn render_open_ai_compatible_provider( - settings_window: &SettingsWindow, - window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { - let open_ai_compatible_settings = open_ai_compatible_settings(); - let additional_fields = settings_window - .render_sub_page_items_section( - open_ai_compatible_settings.iter().enumerate(), - true, - window, - cx, - ) - .into_any_element(); - - v_flex() - .id("open-ai-compatible") - .min_w_0() - .pt_8() - .gap_1p5() - .child( - SettingsSectionHeader::new("OpenAI Compatible API") - .icon(IconName::AiOpenAiCompat) - .no_padding(true), - ) - .child(div().px_neg_8().child(additional_fields)) -} - fn open_ai_compatible_settings() -> Box<[SettingsPageItem]> { Box::new([ SettingsPageItem::SettingItem(SettingItem { diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 67b0d26c88cf0bd254a776834de09fb89d6ea195..39eee233e02a782e2379849247448c8f8c1ea71a 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -154,7 +154,10 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option Date: Tue, 3 Mar 2026 15:11:32 -0600 Subject: [PATCH 19/74] zeta2: Hashlines prompt format (#50623) Closes #ISSUE Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/edit_prediction/src/zeta.rs | 33 +- .../edit_prediction_cli/src/format_prompt.rs | 22 +- .../edit_prediction_cli/src/parse_output.rs | 43 +- crates/zeta_prompt/src/zeta_prompt.rs | 1778 ++++++++++++++++- 4 files changed, 1712 insertions(+), 164 deletions(-) diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 789ff6c0d7fcc269baf30b5e0fb0e849bc865859..f038d2a4ca1929faee2a02391534539b5b63e2d0 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -15,12 +15,10 @@ use release_channel::AppVersion; use settings::EditPredictionPromptFormat; use text::{Anchor, Bias}; -use std::env; -use std::ops::Range; -use std::{path::Path, sync::Arc, time::Instant}; +use std::{env, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::{ CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output, format_zeta_prompt, get_prefill, - prompt_input_contains_special_tokens, + output_with_context_for_format, prompt_input_contains_special_tokens, zeta1::{self, EDITABLE_REGION_END_MARKER}, }; @@ -246,6 +244,25 @@ pub fn request_prediction_with_zeta( return Ok((Some((request_id, None, model_version)), usage)); }; + let editable_range_in_buffer = editable_range_in_excerpt.start + + full_context_offset_range.start + ..editable_range_in_excerpt.end + full_context_offset_range.start; + + let mut old_text = snapshot + .text_for_range(editable_range_in_buffer.clone()) + .collect::(); + + // For the hashline format, the model may return <|set|>/<|insert|> + // edit commands instead of a full replacement. Apply them against + // the original editable region to produce the full replacement text. + // This must happen before cursor marker stripping because the cursor + // marker is embedded inside edit command content. + if let Some(rewritten_output) = + output_with_context_for_format(zeta_version, &old_text, &output_text)? + { + output_text = rewritten_output; + } + // Client-side cursor marker processing (applies to both raw and v3 responses) let cursor_offset_in_output = output_text.find(CURSOR_MARKER); if let Some(offset) = cursor_offset_in_output { @@ -265,14 +282,6 @@ pub fn request_prediction_with_zeta( .ok(); } - let editable_range_in_buffer = editable_range_in_excerpt.start - + full_context_offset_range.start - ..editable_range_in_excerpt.end + full_context_offset_range.start; - - let mut old_text = snapshot - .text_for_range(editable_range_in_buffer.clone()) - .collect::(); - if !output_text.is_empty() && !output_text.ends_with('\n') { output_text.push('\n'); } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index bee79ae8160eeb815a3739b53a5441f6063fb622..f36eaf2799166d6fbd2b7b212003a1a0644b82c4 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -12,7 +12,8 @@ use similar::DiffableStr; use std::ops::Range; use std::sync::Arc; use zeta_prompt::{ - ZetaFormat, excerpt_range_for_format, format_zeta_prompt, resolve_cursor_region, + ZetaFormat, encode_patch_as_output_for_format, excerpt_range_for_format, format_zeta_prompt, + output_end_marker_for_format, resolve_cursor_region, }; pub async fn run_format_prompt( @@ -101,6 +102,12 @@ pub fn zeta2_output_for_patch( old_editable_region.push('\n'); } + if let Some(encoded_output) = + encode_patch_as_output_for_format(version, &old_editable_region, patch, cursor_offset)? + { + return Ok(encoded_output); + } + let (mut result, first_hunk_offset) = udiff::apply_diff_to_string_with_hunk_offset(patch, &old_editable_region).with_context( || { @@ -120,16 +127,11 @@ pub fn zeta2_output_for_patch( result.insert_str(offset, zeta_prompt::CURSOR_MARKER); } - match version { - ZetaFormat::V0120GitMergeMarkers - | ZetaFormat::V0131GitMergeMarkersPrefix - | ZetaFormat::V0211SeedCoder => { - if !result.ends_with('\n') { - result.push('\n'); - } - result.push_str(zeta_prompt::v0120_git_merge_markers::END_MARKER); + if let Some(end_marker) = output_end_marker_for_format(version) { + if !result.ends_with('\n') { + result.push('\n'); } - _ => (), + result.push_str(end_marker); } Ok(result) diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index 4b8af44785c1781de772f569c012ee64eee48aad..2c066b8b32b3eaab54ad6e3b3bcb0796ff27f950 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -6,7 +6,11 @@ use crate::{ }; use anyhow::{Context as _, Result}; use edit_prediction::example_spec::encode_cursor_in_patch; -use zeta_prompt::{CURSOR_MARKER, ZetaFormat}; +use zeta_prompt::{ + CURSOR_MARKER, ZetaFormat, clean_extracted_region_for_format, + current_region_markers_for_format, output_end_marker_for_format, + output_with_context_for_format, +}; pub fn run_parse_output(example: &mut Example) -> Result<()> { example @@ -51,22 +55,7 @@ pub fn parse_prediction_output( } fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result { - let (current_marker, end_marker) = match format { - ZetaFormat::V0112MiddleAtEnd => ("<|fim_middle|>current\n", "<|fim_middle|>updated"), - ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion => { - ("<|fim_middle|>current\n", "<|fim_suffix|>") - } - ZetaFormat::V0120GitMergeMarkers - | ZetaFormat::V0131GitMergeMarkersPrefix - | ZetaFormat::V0211Prefill => ( - zeta_prompt::v0120_git_merge_markers::START_MARKER, - zeta_prompt::v0120_git_merge_markers::SEPARATOR, - ), - ZetaFormat::V0211SeedCoder => ( - zeta_prompt::seed_coder::START_MARKER, - zeta_prompt::seed_coder::SEPARATOR, - ), - }; + let (current_marker, end_marker) = current_region_markers_for_format(format); let start = prompt.find(current_marker).with_context(|| { format!( @@ -82,8 +71,7 @@ fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result { - zeta_prompt::v0131_git_merge_markers_prefix::END_MARKER - } - ZetaFormat::V0120GitMergeMarkers => zeta_prompt::v0120_git_merge_markers::END_MARKER, - ZetaFormat::V0112MiddleAtEnd - | ZetaFormat::V0113Ordered - | ZetaFormat::V0114180EditableRegion => "", - ZetaFormat::V0211SeedCoder => zeta_prompt::seed_coder::END_MARKER, - }; - if !suffix.is_empty() { + if let Some(marker) = output_end_marker_for_format(format) { new_text = new_text - .strip_suffix(suffix) + .strip_suffix(marker) .unwrap_or(&new_text) .to_string(); } diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 0cd37a455397334933dbfa2464c2dbcb72bba456..2ec12e8bebb4a868c0784e2fe52541a1de580555 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -86,6 +86,7 @@ pub enum ZetaFormat { V0131GitMergeMarkersPrefix, V0211Prefill, V0211SeedCoder, + v0226Hashline, } impl std::fmt::Display for ZetaFormat { @@ -122,25 +123,6 @@ impl ZetaFormat { .collect::>() .concat() } - - pub fn special_tokens(&self) -> &'static [&'static str] { - match self { - ZetaFormat::V0112MiddleAtEnd - | ZetaFormat::V0113Ordered - | ZetaFormat::V0114180EditableRegion => &[ - "<|fim_prefix|>", - "<|fim_suffix|>", - "<|fim_middle|>", - "<|file_sep|>", - CURSOR_MARKER, - ], - ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::special_tokens(), - ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { - v0131_git_merge_markers_prefix::special_tokens() - } - ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(), - } - } } #[derive(Clone, Debug, PartialEq, Hash, Serialize, Deserialize)] @@ -212,33 +194,29 @@ pub struct RelatedExcerpt { } pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: ZetaFormat) -> bool { - format - .special_tokens() + special_tokens_for_format(format) .iter() .any(|token| input.cursor_excerpt.contains(token)) } pub fn format_zeta_prompt(input: &ZetaPromptInput, format: ZetaFormat) -> String { - format_zeta_prompt_with_budget(input, format, MAX_PROMPT_TOKENS) + format_prompt_with_budget_for_format(input, format, MAX_PROMPT_TOKENS) } -/// Post-processes model output for the given zeta format by stripping format-specific suffixes. -pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str { +pub fn special_tokens_for_format(format: ZetaFormat) -> &'static [&'static str] { match format { - ZetaFormat::V0120GitMergeMarkers => output - .strip_suffix(v0120_git_merge_markers::END_MARKER) - .unwrap_or(output), - ZetaFormat::V0131GitMergeMarkersPrefix => output - .strip_suffix(v0131_git_merge_markers_prefix::END_MARKER) - .unwrap_or(output), - ZetaFormat::V0211SeedCoder => output - .strip_suffix(seed_coder::END_MARKER) - .unwrap_or(output), - _ => output, + ZetaFormat::V0112MiddleAtEnd => v0112_middle_at_end::special_tokens(), + ZetaFormat::V0113Ordered => v0113_ordered::special_tokens(), + ZetaFormat::V0114180EditableRegion => v0114180_editable_region::special_tokens(), + ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::special_tokens(), + ZetaFormat::V0131GitMergeMarkersPrefix => v0131_git_merge_markers_prefix::special_tokens(), + ZetaFormat::V0211Prefill => v0211_prefill::special_tokens(), + ZetaFormat::V0211SeedCoder => seed_coder::special_tokens(), + ZetaFormat::v0226Hashline => hashline::special_tokens(), } } -pub fn excerpt_range_for_format( +pub fn excerpt_ranges_for_format( format: ZetaFormat, ranges: &ExcerptRanges, ) -> (Range, Range) { @@ -247,129 +225,257 @@ pub fn excerpt_range_for_format( ranges.editable_150.clone(), ranges.editable_150_context_350.clone(), ), - ZetaFormat::V0114180EditableRegion - | ZetaFormat::V0120GitMergeMarkers + ZetaFormat::V0114180EditableRegion => ( + ranges.editable_180.clone(), + ranges.editable_180_context_350.clone(), + ), + ZetaFormat::V0120GitMergeMarkers | ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill - | ZetaFormat::V0211SeedCoder => ( + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline => ( ranges.editable_350.clone(), ranges.editable_350_context_150.clone(), ), } } -pub fn resolve_cursor_region( - input: &ZetaPromptInput, - format: ZetaFormat, -) -> (&str, Range, usize) { - let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges); - let context_start = context_range.start; - let context_text = &input.cursor_excerpt[context_range]; - let adjusted_editable = - (editable_range.start - context_start)..(editable_range.end - context_start); - let adjusted_cursor = input.cursor_offset_in_excerpt - context_start; - - (context_text, adjusted_editable, adjusted_cursor) -} - -fn format_zeta_prompt_with_budget( - input: &ZetaPromptInput, +pub fn write_cursor_excerpt_section_for_format( format: ZetaFormat, - max_tokens: usize, -) -> String { - let (context, editable_range, cursor_offset) = resolve_cursor_region(input, format); - let path = &*input.cursor_path; - - let mut cursor_section = String::new(); + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, +) { match format { - ZetaFormat::V0112MiddleAtEnd => { - v0112_middle_at_end::write_cursor_excerpt_section( - &mut cursor_section, - path, - context, - &editable_range, - cursor_offset, - ); - } + ZetaFormat::V0112MiddleAtEnd => v0112_middle_at_end::write_cursor_excerpt_section( + prompt, + path, + context, + editable_range, + cursor_offset, + ), ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion => { v0113_ordered::write_cursor_excerpt_section( - &mut cursor_section, + prompt, path, context, - &editable_range, + editable_range, cursor_offset, ) } ZetaFormat::V0120GitMergeMarkers => v0120_git_merge_markers::write_cursor_excerpt_section( - &mut cursor_section, + prompt, path, context, - &editable_range, + editable_range, cursor_offset, ), ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { v0131_git_merge_markers_prefix::write_cursor_excerpt_section( - &mut cursor_section, + prompt, path, context, - &editable_range, + editable_range, cursor_offset, ) } - ZetaFormat::V0211SeedCoder => { - return seed_coder::format_prompt_with_budget( + ZetaFormat::V0211SeedCoder => seed_coder::write_cursor_excerpt_section( + prompt, + path, + context, + editable_range, + cursor_offset, + ), + ZetaFormat::v0226Hashline => hashline::write_cursor_excerpt_section( + prompt, + path, + context, + editable_range, + cursor_offset, + ), + } +} + +pub fn format_prompt_with_budget_for_format( + input: &ZetaPromptInput, + format: ZetaFormat, + max_tokens: usize, +) -> String { + let (context, editable_range, cursor_offset) = resolve_cursor_region(input, format); + let path = &*input.cursor_path; + + match format { + ZetaFormat::V0211SeedCoder => seed_coder::format_prompt_with_budget( + path, + context, + &editable_range, + cursor_offset, + &input.events, + &input.related_files, + max_tokens, + ), + _ => { + let mut cursor_section = String::new(); + write_cursor_excerpt_section_for_format( + format, + &mut cursor_section, path, context, &editable_range, cursor_offset, + ); + + let cursor_tokens = estimate_tokens(cursor_section.len()); + let budget_after_cursor = max_tokens.saturating_sub(cursor_tokens); + + let edit_history_section = format_edit_history_within_budget( &input.events, + "<|file_sep|>", + "edit history", + budget_after_cursor, + ); + let edit_history_tokens = estimate_tokens(edit_history_section.len()); + let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); + + let related_files_section = format_related_files_within_budget( &input.related_files, - max_tokens, + "<|file_sep|>", + "", + budget_after_edit_history, ); + + let mut prompt = String::new(); + prompt.push_str(&related_files_section); + prompt.push_str(&edit_history_section); + prompt.push_str(&cursor_section); + prompt } } - - let cursor_tokens = estimate_tokens(cursor_section.len()); - let budget_after_cursor = max_tokens.saturating_sub(cursor_tokens); - - let edit_history_section = format_edit_history_within_budget( - &input.events, - "<|file_sep|>", - "edit history", - budget_after_cursor, - ); - let edit_history_tokens = estimate_tokens(edit_history_section.len()); - let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); - - let related_files_section = format_related_files_within_budget( - &input.related_files, - "<|file_sep|>", - "", - budget_after_edit_history, - ); - - let mut prompt = String::new(); - prompt.push_str(&related_files_section); - prompt.push_str(&edit_history_section); - prompt.push_str(&cursor_section); - prompt } -pub fn get_prefill(input: &ZetaPromptInput, format: ZetaFormat) -> String { +pub fn get_prefill_for_format( + format: ZetaFormat, + context: &str, + editable_range: &Range, +) -> String { match format { + ZetaFormat::V0211Prefill => v0211_prefill::get_prefill(context, editable_range), ZetaFormat::V0112MiddleAtEnd | ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion | ZetaFormat::V0120GitMergeMarkers | ZetaFormat::V0131GitMergeMarkersPrefix - | ZetaFormat::V0211SeedCoder => String::new(), - ZetaFormat::V0211Prefill => { - let (context, editable_range, _) = resolve_cursor_region(input, format); - v0211_prefill::get_prefill(context, &editable_range) + | ZetaFormat::V0211SeedCoder + | ZetaFormat::v0226Hashline => String::new(), + } +} + +pub fn output_end_marker_for_format(format: ZetaFormat) -> Option<&'static str> { + match format { + ZetaFormat::V0120GitMergeMarkers => Some(v0120_git_merge_markers::END_MARKER), + ZetaFormat::V0131GitMergeMarkersPrefix => Some(v0131_git_merge_markers_prefix::END_MARKER), + ZetaFormat::V0211Prefill => Some(v0131_git_merge_markers_prefix::END_MARKER), + ZetaFormat::V0211SeedCoder => Some(seed_coder::END_MARKER), + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::v0226Hashline => None, + } +} + +pub fn current_region_markers_for_format(format: ZetaFormat) -> (&'static str, &'static str) { + match format { + ZetaFormat::V0112MiddleAtEnd => ("<|fim_middle|>current\n", "<|fim_middle|>updated"), + ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::v0226Hashline => ("<|fim_middle|>current\n", "<|fim_suffix|>"), + ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill => ( + v0120_git_merge_markers::START_MARKER, + v0120_git_merge_markers::SEPARATOR, + ), + ZetaFormat::V0211SeedCoder => (seed_coder::START_MARKER, seed_coder::SEPARATOR), + } +} + +pub fn clean_extracted_region_for_format(format: ZetaFormat, region: &str) -> String { + match format { + ZetaFormat::v0226Hashline => hashline::strip_hashline_prefixes(region), + _ => region.to_string(), + } +} + +pub fn encode_patch_as_output_for_format( + format: ZetaFormat, + old_editable_region: &str, + patch: &str, + cursor_offset: Option, +) -> Result> { + match format { + ZetaFormat::v0226Hashline => { + hashline::patch_to_edit_commands(old_editable_region, patch, cursor_offset).map(Some) + } + _ => Ok(None), + } +} + +pub fn output_with_context_for_format( + format: ZetaFormat, + old_editable_region: &str, + output: &str, +) -> Result> { + match format { + ZetaFormat::v0226Hashline => { + if hashline::output_has_edit_commands(output) { + Ok(Some(hashline::apply_edit_commands( + old_editable_region, + output, + ))) + } else { + Ok(None) + } } + _ => Ok(None), } } +/// Post-processes model output for the given zeta format by stripping format-specific suffixes. +pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str { + match output_end_marker_for_format(format) { + Some(marker) => output.strip_suffix(marker).unwrap_or(output), + None => output, + } +} + +pub fn excerpt_range_for_format( + format: ZetaFormat, + ranges: &ExcerptRanges, +) -> (Range, Range) { + excerpt_ranges_for_format(format, ranges) +} + +pub fn resolve_cursor_region( + input: &ZetaPromptInput, + format: ZetaFormat, +) -> (&str, Range, usize) { + let (editable_range, context_range) = excerpt_range_for_format(format, &input.excerpt_ranges); + let context_start = context_range.start; + let context_text = &input.cursor_excerpt[context_range]; + let adjusted_editable = + (editable_range.start - context_start)..(editable_range.end - context_start); + let adjusted_cursor = input.cursor_offset_in_excerpt - context_start; + + (context_text, adjusted_editable, adjusted_cursor) +} + +pub fn get_prefill(input: &ZetaPromptInput, format: ZetaFormat) -> String { + let (context, editable_range, _) = resolve_cursor_region(input, format); + get_prefill_for_format(format, context, &editable_range) +} + fn format_edit_history_within_budget( events: &[Arc], file_marker: &str, @@ -533,6 +639,16 @@ pub fn write_related_files( mod v0112_middle_at_end { use super::*; + pub fn special_tokens() -> &'static [&'static str] { + &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + CURSOR_MARKER, + ] + } + pub fn write_cursor_excerpt_section( prompt: &mut String, path: &Path, @@ -567,6 +683,16 @@ mod v0112_middle_at_end { mod v0113_ordered { use super::*; + pub fn special_tokens() -> &'static [&'static str] { + &[ + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + "<|file_sep|>", + CURSOR_MARKER, + ] + } + pub fn write_cursor_excerpt_section( prompt: &mut String, path: &Path, @@ -601,6 +727,14 @@ mod v0113_ordered { } } +mod v0114180_editable_region { + use super::*; + + pub fn special_tokens() -> &'static [&'static str] { + v0113_ordered::special_tokens() + } +} + pub mod v0120_git_merge_markers { //! A prompt that uses git-style merge conflict markers to represent the editable region. //! @@ -752,6 +886,10 @@ pub mod v0131_git_merge_markers_prefix { pub mod v0211_prefill { use super::*; + pub fn special_tokens() -> &'static [&'static str] { + v0131_git_merge_markers_prefix::special_tokens() + } + pub fn get_prefill(context: &str, editable_range: &Range) -> String { let editable_region = &context[editable_range.start..editable_range.end]; @@ -783,6 +921,1413 @@ pub mod v0211_prefill { } } +pub mod hashline { + + use std::fmt::Display; + + pub const END_MARKER: &str = "<|fim_middle|>updated"; + pub const START_MARKER: &str = "<|fim_middle|>current"; + + use super::*; + + const SET_COMMAND_MARKER: &str = "<|set|>"; + const INSERT_COMMAND_MARKER: &str = "<|insert|>"; + + pub fn special_tokens() -> &'static [&'static str] { + return &[ + SET_COMMAND_MARKER, + "<|set_range|>", + INSERT_COMMAND_MARKER, + CURSOR_MARKER, + "<|file_sep|>", + "<|fim_prefix|>", + "<|fim_suffix|>", + "<|fim_middle|>", + ]; + } + + /// A parsed line reference like `3:c3` (line index 3 with hash 0xc3). + #[derive(Debug, Clone, PartialEq, Eq)] + struct LineRef { + index: usize, + hash: u8, + } + + impl Display for LineRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{:02x}", self.index, self.hash) + } + } + + pub fn hash_line(line: &[u8]) -> u8 { + let mut h: u8 = 0; + for &byte in line { + h = h.wrapping_add(byte); + } + return h; + } + + /// Write the hashline-encoded editable region into `out`. Each line of + /// `editable_text` is prefixed with `{line_index}:{hash}|` and the cursor + /// marker is inserted at `cursor_offset_in_editable` (byte offset relative + /// to the start of `editable_text`). + pub fn write_hashline_editable_region( + out: &mut String, + editable_text: &str, + cursor_offset_in_editable: usize, + ) { + let mut offset = 0; + for (i, line) in editable_text.lines().enumerate() { + let (head, cursor, tail) = if cursor_offset_in_editable > offset + && cursor_offset_in_editable < offset + line.len() + { + ( + &line[..cursor_offset_in_editable - offset], + CURSOR_MARKER, + &line[cursor_offset_in_editable - offset..], + ) + } else { + (line, "", "") + }; + write!( + out, + "\n{}|{head}{cursor}{tail}", + LineRef { + index: i, + hash: hash_line(line.as_bytes()) + } + ) + .unwrap(); + offset += line.len() + 1; + } + } + + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let path_str = path.to_string_lossy(); + write!(prompt, "<|file_sep|>{}\n", path_str).ok(); + + prompt.push_str("<|fim_prefix|>\n"); + prompt.push_str(&context[..editable_range.start]); + prompt.push_str(START_MARKER); + + let cursor_offset_in_editable = cursor_offset.saturating_sub(editable_range.start); + let editable_region = &context[editable_range.clone()]; + write_hashline_editable_region(prompt, editable_region, cursor_offset_in_editable); + + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + + prompt.push_str("<|fim_suffix|>\n"); + prompt.push_str(&context[editable_range.end..]); + if !prompt.ends_with('\n') { + prompt.push('\n'); + } + + prompt.push_str(END_MARKER); + } + + /// A single edit command parsed from the model output. + #[derive(Debug)] + enum EditCommand<'a> { + /// Replace a range of lines (inclusive on both ends). Single-line set is + /// represented by `start == end`. + Set { + start: LineRef, + end: LineRef, + content: &'a str, + }, + /// Insert new lines after the given line, or before the first line if + /// `after` is `None`. + Insert { + after: Option, + content: &'a str, + }, + } + + /// Parse a line reference like `3:c3` into a `LineRef`. + fn parse_line_ref(s: &str) -> Option { + let (idx_str, hash_str) = s.split_once(':')?; + let index = idx_str.parse::().ok()?; + let hash = u8::from_str_radix(hash_str, 16).ok()?; + Some(LineRef { index, hash }) + } + + /// Parse the model output into a list of `EditCommand`s. + fn parse_edit_commands(model_output: &str) -> Vec> { + let mut commands = Vec::new(); + let mut offset = 0usize; + + while offset < model_output.len() { + let next_nl = model_output[offset..] + .find('\n') + .map(|i| offset + i) + .unwrap_or(model_output.len()); + let line = &model_output[offset..next_nl]; + let line_end = if next_nl < model_output.len() { + next_nl + 1 + } else { + next_nl + }; + + let trimmed = line.trim(); + let (is_set, specifier) = if let Some(spec) = trimmed.strip_prefix(SET_COMMAND_MARKER) { + (true, spec) + } else if let Some(spec) = trimmed.strip_prefix(INSERT_COMMAND_MARKER) { + (false, spec) + } else { + offset = line_end; + continue; + }; + + let mut content_end = line_end; + let mut scan = line_end; + + while scan < model_output.len() { + let body_nl = model_output[scan..] + .find('\n') + .map(|i| scan + i) + .unwrap_or(model_output.len()); + let body_line = &model_output[scan..body_nl]; + if body_line.trim().starts_with(SET_COMMAND_MARKER) + || body_line.trim().starts_with(INSERT_COMMAND_MARKER) + { + break; + } + scan = if body_nl < model_output.len() { + body_nl + 1 + } else { + body_nl + }; + content_end = scan; + } + + let content = &model_output[line_end..content_end]; + + if is_set { + if let Some((start_str, end_str)) = specifier.split_once('-') { + if let (Some(start), Some(end)) = + (parse_line_ref(start_str), parse_line_ref(end_str)) + { + commands.push(EditCommand::Set { + start, + end, + content, + }); + } + } else if let Some(target) = parse_line_ref(specifier) { + commands.push(EditCommand::Set { + start: target.clone(), + end: target, + content, + }); + } + } else { + let after = parse_line_ref(specifier); + commands.push(EditCommand::Insert { after, content }); + } + + offset = scan; + } + + commands + } + + /// Returns `true` if the model output contains `<|set|>` or `<|insert|>` commands + /// (as opposed to being a plain full-replacement output). + /// Strip the `{line_num}:{hash}|` prefixes from each line of a hashline-encoded + /// editable region, returning the plain text content. + pub fn strip_hashline_prefixes(region: &str) -> String { + let mut decoded: String = region + .lines() + .map(|line| line.find('|').map_or(line, |pos| &line[pos + 1..])) + .collect::>() + .join("\n"); + if region.ends_with('\n') { + decoded.push('\n'); + } + decoded + } + + pub fn output_has_edit_commands(model_output: &str) -> bool { + model_output.contains(SET_COMMAND_MARKER) || model_output.contains(INSERT_COMMAND_MARKER) + } + + /// Apply `<|set|>` and `<|insert|>` edit commands from the model output to the + /// original editable region text. + /// + /// `editable_region` is the original text of the editable region (without hash + /// prefixes). `model_output` is the raw model response containing edit commands. + /// + /// Returns the full replacement text for the editable region. + pub fn apply_edit_commands(editable_region: &str, model_output: &str) -> String { + let original_lines: Vec<&str> = editable_region.lines().collect(); + let old_hashes: Vec = original_lines + .iter() + .map(|line| hash_line(line.as_bytes())) + .collect(); + + let commands = parse_edit_commands(model_output); + + // For set operations: indexed by start line → Some((end line index, content)) + // For insert operations: indexed by line index → vec of content to insert after + // Insert-before-first is tracked separately. + let mut set_ops: Vec> = vec![None; original_lines.len()]; + let mut insert_before_first: Vec<&str> = Vec::new(); + let mut insert_after: Vec> = vec![Vec::new(); original_lines.len()]; + + for command in &commands { + match command { + EditCommand::Set { + start, + end, + content, + } => { + if start.index < old_hashes.len() + && end.index < old_hashes.len() + && start.index <= end.index + && old_hashes[start.index] == start.hash + && old_hashes[end.index] == end.hash + { + set_ops[start.index] = Some((end.index, *content)); + } + } + EditCommand::Insert { after, content } => match after { + None => insert_before_first.push(*content), + Some(line_ref) => { + if line_ref.index < old_hashes.len() + && old_hashes[line_ref.index] == line_ref.hash + { + insert_after[line_ref.index].push(*content); + } + } + }, + } + } + + let mut result = String::new(); + + // Emit any insertions before the first line + for content in &insert_before_first { + result.push_str(content); + if !content.ends_with('\n') { + result.push('\n'); + } + } + + let mut i = 0; + while i < original_lines.len() { + if let Some((end_index, replacement)) = set_ops[i].as_ref() { + // Replace lines i..=end_index with the replacement content + result.push_str(replacement); + if !replacement.is_empty() && !replacement.ends_with('\n') { + result.push('\n'); + } + // Emit any insertions after the end of this set range + if *end_index < insert_after.len() { + for content in &insert_after[*end_index] { + result.push_str(content); + if !content.ends_with('\n') { + result.push('\n'); + } + } + } + i = end_index + 1; + } else { + // Keep the original line + result.push_str(original_lines[i]); + result.push('\n'); + // Emit any insertions after this line + for content in &insert_after[i] { + result.push_str(content); + if !content.ends_with('\n') { + result.push('\n'); + } + } + i += 1; + } + } + + // Preserve trailing newline behavior: if the original ended with a + // newline the result already has one; if it didn't, trim the extra one + // we added. + if !editable_region.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + result + } + + /// Convert a unified diff patch into hashline edit commands. + /// + /// Parses the unified diff `patch` directly to determine which lines of + /// `old_text` are deleted/replaced and what new lines are added, then emits + /// `<|set|>` and `<|insert|>` edit commands referencing old lines by their + /// `{index}:{hash}` identifiers. + /// + /// `cursor_offset` is an optional byte offset into the first hunk's new + /// text (context + additions) where the cursor marker should be placed. + pub fn patch_to_edit_commands( + old_text: &str, + patch: &str, + cursor_offset: Option, + ) -> Result { + let old_lines: Vec<&str> = old_text.lines().collect(); + let old_hashes: Vec = old_lines + .iter() + .map(|line| hash_line(line.as_bytes())) + .collect(); + + let mut result = String::new(); + let mut first_hunk = true; + + struct Hunk<'a> { + line_range: Range, + new_text_lines: Vec<&'a str>, + cursor_line_offset_in_new_text: Option<(usize, usize)>, + } + + // Parse the patch line by line. We only care about hunk headers, + // context, deletions, and additions. + let mut old_line_index: usize = 0; + let mut current_hunk: Option = None; + // Byte offset tracking within the hunk's new text for cursor placement. + let mut new_text_byte_offset: usize = 0; + // The line index of the last old line seen before/in the current hunk + // (used for insert-after reference). + let mut last_old_line_before_hunk: Option = None; + + fn flush_hunk( + hunk: Hunk, + last_old_line: Option, + result: &mut String, + old_hashes: &[u8], + ) { + if hunk.line_range.is_empty() { + // Pure insertion — reference the old line to insert after when in bounds. + if let Some(after) = last_old_line + && let Some(&hash) = old_hashes.get(after) + { + write!( + result, + "{INSERT_COMMAND_MARKER}{}\n", + LineRef { index: after, hash } + ) + .unwrap(); + } else { + result.push_str(INSERT_COMMAND_MARKER); + result.push('\n'); + } + } else { + let start = hunk.line_range.start; + let end_exclusive = hunk.line_range.end; + let deleted_line_count = end_exclusive.saturating_sub(start); + + if deleted_line_count == 1 { + if let Some(&hash) = old_hashes.get(start) { + write!( + result, + "{SET_COMMAND_MARKER}{}\n", + LineRef { index: start, hash } + ) + .unwrap(); + } else { + result.push_str(SET_COMMAND_MARKER); + result.push('\n'); + } + } else { + let end_inclusive = end_exclusive - 1; + match ( + old_hashes.get(start).copied(), + old_hashes.get(end_inclusive).copied(), + ) { + (Some(start_hash), Some(end_hash)) => { + write!( + result, + "{SET_COMMAND_MARKER}{}-{}\n", + LineRef { + index: start, + hash: start_hash + }, + LineRef { + index: end_inclusive, + hash: end_hash + } + ) + .unwrap(); + } + _ => { + result.push_str(SET_COMMAND_MARKER); + result.push('\n'); + } + } + } + } + for (line_offset, line) in hunk.new_text_lines.iter().enumerate() { + if let Some((cursor_line_offset, char_offset)) = hunk.cursor_line_offset_in_new_text + && line_offset == cursor_line_offset + { + result.push_str(&line[..char_offset]); + result.push_str(CURSOR_MARKER); + result.push_str(&line[char_offset..]); + continue; + } + + result.push_str(line); + } + } + + for raw_line in patch.split_inclusive('\n') { + if raw_line.starts_with("@@") { + // Flush any pending change hunk from a previous patch hunk. + if let Some(hunk) = current_hunk.take() { + flush_hunk(hunk, last_old_line_before_hunk, &mut result, &old_hashes); + } + + // Parse hunk header: @@ -old_start[,old_count] +new_start[,new_count] @@ + // We intentionally do not trust old_start as a direct local index into `old_text`, + // because some patches are produced against a larger file region and carry + // non-local line numbers. We keep indexing local by advancing from parsed patch lines. + if first_hunk { + new_text_byte_offset = 0; + first_hunk = false; + } + continue; + } + + if raw_line.starts_with("---") || raw_line.starts_with("+++") { + continue; + } + if raw_line.starts_with("\\ No newline") { + continue; + } + + if raw_line.starts_with('-') { + // Extend or start a change hunk with this deleted old line. + match &mut current_hunk { + Some(Hunk { + line_range: range, .. + }) => range.end = old_line_index + 1, + None => { + current_hunk = Some(Hunk { + line_range: old_line_index..old_line_index + 1, + new_text_lines: Vec::new(), + cursor_line_offset_in_new_text: None, + }); + } + } + old_line_index += 1; + } else if let Some(added_content) = raw_line.strip_prefix('+') { + // Place cursor marker if cursor_offset falls within this line. + let mut cursor_line_offset = None; + if let Some(cursor_off) = cursor_offset + && (first_hunk + || cursor_off >= new_text_byte_offset + && cursor_off <= new_text_byte_offset + added_content.len()) + { + let line_offset = added_content.floor_char_boundary( + cursor_off + .saturating_sub(new_text_byte_offset) + .min(added_content.len()), + ); + cursor_line_offset = Some(line_offset); + } + + new_text_byte_offset += added_content.len(); + + let hunk = current_hunk.get_or_insert(Hunk { + line_range: old_line_index..old_line_index, + new_text_lines: vec![], + cursor_line_offset_in_new_text: None, + }); + hunk.new_text_lines.push(added_content); + hunk.cursor_line_offset_in_new_text = cursor_line_offset + .map(|offset_in_line| (hunk.new_text_lines.len() - 1, offset_in_line)); + } else { + // Context line (starts with ' ' or is empty). + if let Some(hunk) = current_hunk.take() { + flush_hunk(hunk, last_old_line_before_hunk, &mut result, &old_hashes); + } + last_old_line_before_hunk = Some(old_line_index); + old_line_index += 1; + let content = raw_line.strip_prefix(' ').unwrap_or(raw_line); + new_text_byte_offset += content.len(); + } + } + + // Flush final group. + if let Some(hunk) = current_hunk.take() { + flush_hunk(hunk, last_old_line_before_hunk, &mut result, &old_hashes); + } + + // Trim a single trailing newline. + if result.ends_with('\n') { + result.pop(); + } + + Ok(result) + } + + #[cfg(test)] + mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn test_format_cursor_region() { + struct Case { + name: &'static str, + context: &'static str, + editable_range: Range, + cursor_offset: usize, + expected: &'static str, + } + + let cases = [ + Case { + name: "basic_cursor_placement", + context: "hello world\n", + editable_range: 0..12, + cursor_offset: 5, + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:5c|hello<|user_cursor|> world + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "multiline_cursor_on_second_line", + context: "aaa\nbbb\nccc\n", + editable_range: 0..12, + cursor_offset: 5, // byte 5 → 1 byte into "bbb" + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:23|aaa + 1:26|b<|user_cursor|>bb + 2:29|ccc + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "no_trailing_newline_in_context", + context: "line1\nline2", + editable_range: 0..11, + cursor_offset: 3, + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:d9|lin<|user_cursor|>e1 + 1:da|line2 + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "leading_newline_in_editable_region", + context: "\nabc\n", + editable_range: 0..5, + cursor_offset: 2, // byte 2 = 'a' in "abc" (after leading \n) + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:00| + 1:26|a<|user_cursor|>bc + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "with_suffix", + context: "abc\ndef", + editable_range: 0..4, // editable region = "abc\n", suffix = "def" + cursor_offset: 2, + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:26|ab<|user_cursor|>c + <|fim_suffix|> + def + <|fim_middle|>updated"}, + }, + Case { + name: "unicode_two_byte_chars", + context: "héllo\n", + editable_range: 0..7, + cursor_offset: 3, // byte 3 = after "hé" (h=1 byte, é=2 bytes), before "llo" + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:1b|hé<|user_cursor|>llo + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "unicode_three_byte_chars", + context: "日本語\n", + editable_range: 0..10, + cursor_offset: 6, // byte 6 = after "日本" (3+3 bytes), before "語" + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:80|日本<|user_cursor|>語 + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "unicode_four_byte_chars", + context: "a🌍b\n", + editable_range: 0..7, + cursor_offset: 5, // byte 5 = after "a🌍" (1+4 bytes), before "b" + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:6b|a🌍<|user_cursor|>b + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "cursor_at_start_of_region_not_placed", + context: "abc\n", + editable_range: 0..4, + cursor_offset: 0, // cursor_offset(0) > offset(0) is false → cursor not placed + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:26|abc + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "cursor_at_end_of_line_not_placed", + context: "abc\ndef\n", + editable_range: 0..8, + cursor_offset: 3, // byte 3 = the \n after "abc" → falls between lines, not placed + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + 0:26|abc + 1:2f|def + <|fim_suffix|> + <|fim_middle|>updated"}, + }, + Case { + name: "cursor_offset_relative_to_context_not_editable_region", + // cursor_offset is relative to `context`, so when editable_range.start > 0, + // write_cursor_excerpt_section must subtract it before comparing against + // per-line offsets within the editable region. + context: "pre\naaa\nbbb\nsuf\n", + editable_range: 4..12, // editable region = "aaa\nbbb\n" + cursor_offset: 9, // byte 9 in context = second 'b' in "bbb" + expected: indoc! {" + <|file_sep|>test.rs + <|fim_prefix|> + pre + <|fim_middle|>current + 0:23|aaa + 1:26|b<|user_cursor|>bb + <|fim_suffix|> + suf + <|fim_middle|>updated"}, + }, + ]; + + for case in &cases { + let mut prompt = String::new(); + hashline::write_cursor_excerpt_section( + &mut prompt, + Path::new("test.rs"), + case.context, + &case.editable_range, + case.cursor_offset, + ); + assert_eq!(prompt, case.expected, "failed case: {}", case.name); + } + } + + #[test] + fn test_apply_edit_commands() { + struct Case { + name: &'static str, + original: &'static str, + model_output: &'static str, + expected: &'static str, + } + + let cases = vec![ + Case { + name: "set_single_line", + original: indoc! {" + let mut total = 0; + for product in products { + total += ; + } + total + "}, + model_output: indoc! {" + <|set|>2:87 + total += product.price; + "}, + expected: indoc! {" + let mut total = 0; + for product in products { + total += product.price; + } + total + "}, + }, + Case { + name: "set_range", + original: indoc! {" + fn foo() { + let x = 1; + let y = 2; + let z = 3; + } + "}, + model_output: indoc! {" + <|set|>1:46-3:4a + let sum = 6; + "}, + expected: indoc! {" + fn foo() { + let sum = 6; + } + "}, + }, + Case { + name: "insert_after_line", + original: indoc! {" + fn main() { + let x = 1; + } + "}, + model_output: indoc! {" + <|insert|>1:46 + let y = 2; + "}, + expected: indoc! {" + fn main() { + let x = 1; + let y = 2; + } + "}, + }, + Case { + name: "insert_before_first", + original: indoc! {" + let x = 1; + let y = 2; + "}, + model_output: indoc! {" + <|insert|> + use std::io; + "}, + expected: indoc! {" + use std::io; + let x = 1; + let y = 2; + "}, + }, + Case { + name: "set_with_cursor_marker", + original: indoc! {" + fn main() { + println!(); + } + "}, + model_output: indoc! {" + <|set|>1:34 + eprintln!(\"<|user_cursor|>\"); + "}, + expected: indoc! {" + fn main() { + eprintln!(\"<|user_cursor|>\"); + } + "}, + }, + Case { + name: "multiple_set_commands", + original: indoc! {" + aaa + bbb + ccc + ddd + "}, + model_output: indoc! {" + <|set|>0:23 + AAA + <|set|>2:29 + CCC + "}, + expected: indoc! {" + AAA + bbb + CCC + ddd + "}, + }, + Case { + name: "set_range_multiline_replacement", + original: indoc! {" + fn handle_submit() { + } + + fn handle_keystroke() { + "}, + model_output: indoc! {" + <|set|>0:3f-1:7d + fn handle_submit(modal_state: &mut ModalState) { + <|user_cursor|> + } + "}, + expected: indoc! {" + fn handle_submit(modal_state: &mut ModalState) { + <|user_cursor|> + } + + fn handle_keystroke() { + "}, + }, + Case { + name: "no_edit_commands_returns_original", + original: indoc! {" + hello + world + "}, + model_output: "some random text with no commands", + expected: indoc! {" + hello + world + "}, + }, + Case { + name: "wrong_hash_set_ignored", + original: indoc! {" + aaa + bbb + "}, + model_output: indoc! {" + <|set|>0:ff + ZZZ + "}, + expected: indoc! {" + aaa + bbb + "}, + }, + Case { + name: "insert_and_set_combined", + original: indoc! {" + alpha + beta + gamma + "}, + model_output: indoc! {" + <|set|>0:06 + ALPHA + <|insert|>1:9c + beta_extra + "}, + expected: indoc! {" + ALPHA + beta + beta_extra + gamma + "}, + }, + Case { + name: "no_trailing_newline_preserved", + original: "hello\nworld", + model_output: indoc! {" + <|set|>0:14 + HELLO + "}, + expected: "HELLO\nworld", + }, + Case { + name: "set_range_hash_mismatch_in_end_bound", + original: indoc! {" + one + two + three + "}, + model_output: indoc! {" + <|set|>0:42-2:ff + ONE_TWO_THREE + "}, + expected: indoc! {" + one + two + three + "}, + }, + Case { + name: "set_range_start_greater_than_end_ignored", + original: indoc! {" + a + b + c + "}, + model_output: indoc! {" + <|set|>2:63-1:62 + X + "}, + expected: indoc! {" + a + b + c + "}, + }, + Case { + name: "insert_out_of_bounds_ignored", + original: indoc! {" + x + y + "}, + model_output: indoc! {" + <|insert|>99:aa + z + "}, + expected: indoc! {" + x + y + "}, + }, + Case { + name: "set_out_of_bounds_ignored", + original: indoc! {" + x + y + "}, + model_output: indoc! {" + <|set|>99:aa + z + "}, + expected: indoc! {" + x + y + "}, + }, + Case { + name: "malformed_set_command_ignored", + original: indoc! {" + alpha + beta + "}, + model_output: indoc! {" + <|set|>not-a-line-ref + UPDATED + "}, + expected: indoc! {" + alpha + beta + "}, + }, + Case { + name: "malformed_insert_hash_treated_as_before_first", + original: indoc! {" + alpha + beta + "}, + model_output: indoc! {" + <|insert|>1:nothex + preamble + "}, + expected: indoc! {" + preamble + alpha + beta + "}, + }, + Case { + name: "set_then_insert_same_target_orders_insert_after_replacement", + original: indoc! {" + cat + dog + "}, + model_output: indoc! {" + <|set|>0:38 + CAT + <|insert|>0:38 + TAIL + "}, + expected: indoc! {" + CAT + TAIL + dog + "}, + }, + Case { + name: "overlapping_set_ranges_last_wins", + original: indoc! {" + a + b + c + d + "}, + model_output: indoc! {" + <|set|>0:61-2:63 + FIRST + <|set|>1:62-3:64 + SECOND + "}, + expected: indoc! {" + FIRST + d + "}, + }, + Case { + name: "insert_before_first_and_after_line", + original: indoc! {" + a + b + "}, + model_output: indoc! {" + <|insert|> + HEAD + <|insert|>0:61 + MID + "}, + expected: indoc! {" + HEAD + a + MID + b + "}, + }, + ]; + + for case in &cases { + let result = hashline::apply_edit_commands(case.original, &case.model_output); + assert_eq!(result, case.expected, "failed case: {}", case.name); + } + } + + #[test] + fn test_output_has_edit_commands() { + assert!(hashline::output_has_edit_commands(&format!( + "{}0:ab\nnew", + SET_COMMAND_MARKER + ))); + assert!(hashline::output_has_edit_commands(&format!( + "{}0:ab\nnew", + INSERT_COMMAND_MARKER + ))); + assert!(hashline::output_has_edit_commands(&format!( + "some text\n{}1:cd\nstuff", + SET_COMMAND_MARKER + ))); + assert!(!hashline::output_has_edit_commands("just plain text")); + assert!(!hashline::output_has_edit_commands("NO_EDITS")); + } + + // ---- hashline::patch_to_edit_commands round-trip tests ---- + + #[test] + fn test_patch_to_edit_commands() { + struct Case { + name: &'static str, + old: &'static str, + patch: &'static str, + expected_new: &'static str, + } + + let cases = [ + Case { + name: "single_line_replacement", + old: indoc! {" + let mut total = 0; + for product in products { + total += ; + } + total + "}, + patch: indoc! {" + @@ -1,5 +1,5 @@ + let mut total = 0; + for product in products { + - total += ; + + total += product.price; + } + total + "}, + expected_new: indoc! {" + let mut total = 0; + for product in products { + total += product.price; + } + total + "}, + }, + Case { + name: "multiline_replacement", + old: indoc! {" + fn foo() { + let x = 1; + let y = 2; + let z = 3; + } + "}, + patch: indoc! {" + @@ -1,5 +1,3 @@ + fn foo() { + - let x = 1; + - let y = 2; + - let z = 3; + + let sum = 1 + 2 + 3; + } + "}, + expected_new: indoc! {" + fn foo() { + let sum = 1 + 2 + 3; + } + "}, + }, + Case { + name: "insertion", + old: indoc! {" + fn main() { + let x = 1; + } + "}, + patch: indoc! {" + @@ -1,3 +1,4 @@ + fn main() { + let x = 1; + + let y = 2; + } + "}, + expected_new: indoc! {" + fn main() { + let x = 1; + let y = 2; + } + "}, + }, + Case { + name: "insertion_before_first", + old: indoc! {" + let x = 1; + let y = 2; + "}, + patch: indoc! {" + @@ -1,2 +1,3 @@ + +use std::io; + let x = 1; + let y = 2; + "}, + expected_new: indoc! {" + use std::io; + let x = 1; + let y = 2; + "}, + }, + Case { + name: "deletion", + old: indoc! {" + aaa + bbb + ccc + ddd + "}, + patch: indoc! {" + @@ -1,4 +1,2 @@ + aaa + -bbb + -ccc + ddd + "}, + expected_new: indoc! {" + aaa + ddd + "}, + }, + Case { + name: "multiple_changes", + old: indoc! {" + alpha + beta + gamma + delta + epsilon + "}, + patch: indoc! {" + @@ -1,5 +1,5 @@ + -alpha + +ALPHA + beta + gamma + -delta + +DELTA + epsilon + "}, + expected_new: indoc! {" + ALPHA + beta + gamma + DELTA + epsilon + "}, + }, + Case { + name: "replace_with_insertion", + old: indoc! {r#" + fn handle() { + modal_state.close(); + modal_state.dismiss(); + "#}, + patch: indoc! {r#" + @@ -1,3 +1,4 @@ + fn handle() { + modal_state.close(); + + eprintln!(""); + modal_state.dismiss(); + "#}, + expected_new: indoc! {r#" + fn handle() { + modal_state.close(); + eprintln!(""); + modal_state.dismiss(); + "#}, + }, + Case { + name: "complete_replacement", + old: indoc! {" + aaa + bbb + ccc + "}, + patch: indoc! {" + @@ -1,3 +1,3 @@ + -aaa + -bbb + -ccc + +xxx + +yyy + +zzz + "}, + expected_new: indoc! {" + xxx + yyy + zzz + "}, + }, + Case { + name: "add_function_body", + old: indoc! {" + fn foo() { + modal_state.dismiss(); + } + + fn + + fn handle_keystroke() { + "}, + patch: indoc! {" + @@ -1,6 +1,8 @@ + fn foo() { + modal_state.dismiss(); + } + + -fn + +fn handle_submit() { + + todo() + +} + + fn handle_keystroke() { + "}, + expected_new: indoc! {" + fn foo() { + modal_state.dismiss(); + } + + fn handle_submit() { + todo() + } + + fn handle_keystroke() { + "}, + }, + Case { + name: "with_cursor_offset", + old: indoc! {r#" + fn main() { + println!(); + } + "#}, + patch: indoc! {r#" + @@ -1,3 +1,3 @@ + fn main() { + - println!(); + + eprintln!(""); + } + "#}, + expected_new: indoc! {r#" + fn main() { + eprintln!("<|user_cursor|>"); + } + "#}, + }, + Case { + name: "non_local_hunk_header_pure_insertion_repro", + old: indoc! {" + aaa + bbb + "}, + patch: indoc! {" + @@ -20,2 +20,3 @@ + aaa + +xxx + bbb + "}, + expected_new: indoc! {" + aaa + xxx + bbb + "}, + }, + ]; + + for case in &cases { + // The cursor_offset for patch_to_edit_commands is relative to + // the first hunk's new text (context + additions). We compute + // it by finding where the marker sits in the expected output + // (which mirrors the new text of the hunk). + let cursor_offset = case.expected_new.find(CURSOR_MARKER); + + let commands = + hashline::patch_to_edit_commands(case.old, case.patch, cursor_offset) + .unwrap_or_else(|e| panic!("failed case {}: {e}", case.name)); + + assert!( + hashline::output_has_edit_commands(&commands), + "case {}: expected edit commands, got: {commands:?}", + case.name, + ); + + let applied = hashline::apply_edit_commands(case.old, &commands); + assert_eq!(applied, case.expected_new, "case {}", case.name); + } + } + } +} + pub mod seed_coder { //! Seed-Coder prompt format using SPM (Suffix-Prefix-Middle) FIM mode. //! @@ -847,6 +2392,17 @@ pub mod seed_coder { ] } + pub fn write_cursor_excerpt_section( + prompt: &mut String, + path: &Path, + context: &str, + editable_range: &Range, + cursor_offset: usize, + ) { + let section = build_cursor_prefix_section(path, context, editable_range, cursor_offset); + prompt.push_str(§ion); + } + pub fn format_prompt_with_budget( path: &Path, context: &str, @@ -1186,7 +2742,7 @@ mod tests { } fn format_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { - format_zeta_prompt_with_budget(input, ZetaFormat::V0114180EditableRegion, max_tokens) + format_prompt_with_budget_for_format(input, ZetaFormat::V0114180EditableRegion, max_tokens) } #[test] @@ -1551,11 +3107,11 @@ mod tests { } fn format_seed_coder(input: &ZetaPromptInput) -> String { - format_zeta_prompt_with_budget(input, ZetaFormat::V0211SeedCoder, 10000) + format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, 10000) } fn format_seed_coder_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { - format_zeta_prompt_with_budget(input, ZetaFormat::V0211SeedCoder, max_tokens) + format_prompt_with_budget_for_format(input, ZetaFormat::V0211SeedCoder, max_tokens) } #[test] From 2772db8dcc3c27bc63a09e5db9f4c60340e91436 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 3 Mar 2026 13:21:52 -0800 Subject: [PATCH 20/74] Remove zeta2 feature flag (#50618) Release Notes: - N/A --- crates/edit_prediction/src/edit_prediction.rs | 7 +--- .../src/edit_prediction_button.rs | 20 ++-------- crates/settings_content/src/language.rs | 7 +--- .../zed/src/zed/edit_prediction_registry.rs | 38 ++----------------- 4 files changed, 9 insertions(+), 63 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 74988d65933b3bbbc2507077a74dfeb94089ab63..33c3ea1e56648c73682e06f685f91f54344200d6 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -108,13 +108,8 @@ const EDIT_PREDICTION_SETTLED_EVENT: &str = "Edit Prediction Settled"; const EDIT_PREDICTION_SETTLED_TTL: Duration = Duration::from_secs(60 * 5); const EDIT_PREDICTION_SETTLED_QUIESCENCE: Duration = Duration::from_secs(10); -pub struct Zeta2FeatureFlag; pub struct EditPredictionJumpsFeatureFlag; -impl FeatureFlag for Zeta2FeatureFlag { - const NAME: &'static str = "zeta2"; -} - impl FeatureFlag for EditPredictionJumpsFeatureFlag { const NAME: &'static str = "edit_prediction_jumps"; } @@ -2109,7 +2104,7 @@ impl EditPredictionStore { active_buffer.clone(), position, trigger, - cx.has_flag::(), + cx.has_flag::(), cx, ) } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 743256970f486b474405e7f034f18501505cb825..b00a229164d480d38312ca97cac31a23010f8b69 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -3,7 +3,7 @@ use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; use codestral::{self, CodestralEditPredictionDelegate}; use copilot::Status; -use edit_prediction::{EditPredictionStore, Zeta2FeatureFlag}; +use edit_prediction::EditPredictionStore; use edit_prediction_types::EditPredictionDelegateHandle; use editor::{ Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, @@ -22,9 +22,7 @@ use language::{ }; use project::{DisableAiSettings, Project}; use regex::Regex; -use settings::{ - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file, -}; +use settings::{Settings, SettingsStore, update_settings_file}; use std::{ rc::Rc, sync::{Arc, LazyLock}, @@ -776,13 +774,7 @@ impl EditPredictionButton { menu = menu.separator().header("Privacy"); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - ) - ) { + if matches!(provider, EditPredictionProvider::Zed) { if let Some(provider) = &self.edit_prediction_provider { let data_collection = provider.data_collection_state(cx); @@ -1405,12 +1397,6 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::Zed); - if cx.has_flag::() { - providers.push(EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - )); - } - if let Some(app_state) = workspace::AppState::global(cx).upgrade() && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index d429f53824fd0f4f0a5810bce01b05badcfb9a51..a8d68fea99c024830ee45c66ec5d7d641aa4c250 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -90,7 +90,7 @@ pub enum EditPredictionProvider { Experimental(&'static str), } -pub const EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME: &str = "zeta2"; +const EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME: &str = "zeta2"; impl<'de> Deserialize<'de> for EditPredictionProvider { fn deserialize(deserializer: D) -> Result @@ -157,10 +157,7 @@ impl EditPredictionProvider { EditPredictionProvider::Codestral => Some("Codestral"), EditPredictionProvider::Sweep => Some("Sweep"), EditPredictionProvider::Mercury => Some("Mercury"), - EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - ) => Some("Zeta2"), - EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => None, + EditPredictionProvider::Experimental(_) | EditPredictionProvider::None => None, EditPredictionProvider::Ollama => Some("Ollama"), EditPredictionProvider::OpenAiCompatibleApi => Some("OpenAI-Compatible API"), } diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 39eee233e02a782e2379849247448c8f8c1ea71a..9f05c5795e6f16cab231df8a5586106ed25b03ee 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -2,15 +2,12 @@ use client::{Client, UserStore}; use codestral::{CodestralEditPredictionDelegate, load_codestral_api_key}; use collections::HashMap; use copilot::CopilotEditPredictionDelegate; -use edit_prediction::{EditPredictionModel, ZedEditPredictionDelegate, Zeta2FeatureFlag}; +use edit_prediction::{EditPredictionModel, ZedEditPredictionDelegate}; use editor::Editor; -use feature_flags::FeatureFlagAppExt; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; -use settings::{ - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, EditPredictionPromptFormat, SettingsStore, -}; +use settings::{EditPredictionPromptFormat, SettingsStore}; use std::{cell::RefCell, rc::Rc, sync::Arc}; use ui::Window; @@ -81,9 +78,6 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { .detach(); cx.observe_global::({ - let editors = editors.clone(); - let client = client.clone(); - let user_store = user_store.clone(); let mut previous_config = edit_prediction_provider_config_for_settings(cx); move |cx| { let new_provider_config = edit_prediction_provider_config_for_settings(cx); @@ -107,24 +101,6 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { } }) .detach(); - - cx.observe_flag::({ - let mut previous_config = edit_prediction_provider_config_for_settings(cx); - move |_is_enabled, cx| { - let new_provider_config = edit_prediction_provider_config_for_settings(cx); - if new_provider_config != previous_config { - previous_config = new_provider_config; - assign_edit_prediction_providers( - &editors, - new_provider_config, - &client, - user_store.clone(), - cx, - ); - } - } - }) - .detach(); } fn edit_prediction_provider_config_for_settings(cx: &App) -> Option { @@ -171,15 +147,7 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option Some(EditPredictionProviderConfig::Zed( EditPredictionModel::Mercury, )), - EditPredictionProvider::Experimental(name) => { - if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME - && cx.has_flag::() - { - Some(EditPredictionProviderConfig::Zed(EditPredictionModel::Zeta)) - } else { - None - } - } + EditPredictionProvider::Experimental(_) => None, } } From 4dd42a0f77b11d0bed2a072919bcd9180b9a577c Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 3 Mar 2026 22:32:11 +0100 Subject: [PATCH 21/74] agent: Fix subagent error display (#50638) Since we were no longer just returning a string, we need to update the content in both success and error modes to get a nice rendering experience. Release Notes: - N/A --- crates/agent/src/tools/spawn_agent_tool.rs | 49 ++++++++++++++-------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs index b75c41775258db49577024dca3eb1770937e52e8..162de68b86115056e9579d22a8623d675245cc91 100644 --- a/crates/agent/src/tools/spawn_agent_tool.rs +++ b/crates/agent/src/tools/spawn_agent_tool.rs @@ -161,29 +161,42 @@ impl AgentTool for SpawnAgentTool { Ok((subagent, session_info)) })?; - match subagent.send(input.message, cx).await { - Ok(output) => { - session_info.message_end_index = - cx.update(|cx| Some(subagent.num_entries(cx).saturating_sub(1))); - event_stream.update_fields_with_meta( - acp::ToolCallUpdateFields::new().content(vec![output.clone().into()]), - Some(acp::Meta::from_iter([( - SUBAGENT_SESSION_INFO_META_KEY.into(), - serde_json::json!(&session_info), - )])), - ); + let send_result = subagent.send(input.message, cx).await; + + session_info.message_end_index = + cx.update(|cx| Some(subagent.num_entries(cx).saturating_sub(1))); + + let meta = Some(acp::Meta::from_iter([( + SUBAGENT_SESSION_INFO_META_KEY.into(), + serde_json::json!(&session_info), + )])); + + let (output, result) = match send_result { + Ok(output) => ( + output.clone(), Ok(SpawnAgentToolOutput::Success { session_id: session_info.session_id.clone(), session_info, output, - }) + }), + ), + Err(e) => { + let error = e.to_string(); + ( + error.clone(), + Err(SpawnAgentToolOutput::Error { + session_id: Some(session_info.session_id.clone()), + error, + session_info: Some(session_info), + }), + ) } - Err(e) => Err(SpawnAgentToolOutput::Error { - session_id: Some(session_info.session_id.clone()), - error: e.to_string(), - session_info: Some(session_info), - }), - } + }; + event_stream.update_fields_with_meta( + acp::ToolCallUpdateFields::new().content(vec![output.into()]), + meta, + ); + result }) } From c1cbcb612dc24499adfa7a0219436ecd3c8aaf91 Mon Sep 17 00:00:00 2001 From: John Tur Date: Tue, 3 Mar 2026 16:47:57 -0500 Subject: [PATCH 22/74] Fix handling of `surface.configure` on Linux (#50640) Closes #50574 Release Notes: - Fixed Zed not being responsive on some Linux configurations Co-authored-by: Conrad Irwin --- crates/gpui_wgpu/src/wgpu_renderer.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 5beeef6ad1238f25db7c50f739053e138b2e1295..2fd83b7b065e7ce4fe0ba9ec017f39264a33bee3 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -98,7 +98,6 @@ pub struct WgpuRenderer { queue: Arc, surface: wgpu::Surface<'static>, surface_config: wgpu::SurfaceConfiguration, - surface_configured: bool, pipelines: WgpuPipelines, bind_group_layouts: WgpuBindGroupLayouts, atlas: Arc, @@ -381,7 +380,6 @@ impl WgpuRenderer { queue, surface, surface_config, - surface_configured: true, pipelines, bind_group_layouts, atlas, @@ -875,9 +873,7 @@ impl WgpuRenderer { self.surface_config.width = clamped_width.max(1); self.surface_config.height = clamped_height.max(1); - if self.surface_configured { - self.surface.configure(&self.device, &self.surface_config); - } + self.surface.configure(&self.device, &self.surface_config); // Invalidate intermediate textures - they will be lazily recreated // in draw() after we confirm the surface is healthy. This avoids @@ -928,9 +924,7 @@ impl WgpuRenderer { if new_alpha_mode != self.surface_config.alpha_mode { self.surface_config.alpha_mode = new_alpha_mode; - if self.surface_configured { - self.surface.configure(&self.device, &self.surface_config); - } + self.surface.configure(&self.device, &self.surface_config); self.pipelines = Self::create_pipelines( &self.device, &self.bind_group_layouts, @@ -991,7 +985,7 @@ impl WgpuRenderer { let frame = match self.surface.get_current_texture() { Ok(frame) => frame, Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - self.surface_configured = false; + self.surface.configure(&self.device, &self.surface_config); return; } Err(e) => { From 832782f6b333a89074d55f04ecbecd75467fc48a Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Tue, 3 Mar 2026 14:20:12 -0800 Subject: [PATCH 23/74] Persist token count and scroll position across agent restarts (#50620) Release Notes: - Token counts and scroll position are restored when loading a previous agent thread --- crates/acp_thread/src/acp_thread.rs | 11 ++++ crates/agent/src/agent.rs | 39 +++++++++++- crates/agent/src/db.rs | 60 +++++++++++++++++++ crates/agent/src/thread.rs | 20 +++++++ crates/agent/src/thread_store.rs | 1 + crates/agent_ui/src/connection_view.rs | 4 ++ .../src/connection_view/thread_view.rs | 56 +++++++++++++---- 7 files changed, 178 insertions(+), 13 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index f57ce1f4d188e260624bd90187a21890379fe6b6..1b9271918884dc020986577926d9578e3a6f049c 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -972,6 +972,8 @@ pub struct AcpThread { had_error: bool, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. draft_prompt: Option>, + /// The initial scroll position for the thread view, set during session registration. + ui_scroll_position: Option, } impl From<&AcpThread> for ActionLogTelemetry { @@ -1210,6 +1212,7 @@ impl AcpThread { pending_terminal_exit: HashMap::default(), had_error: false, draft_prompt: None, + ui_scroll_position: None, } } @@ -1229,6 +1232,14 @@ impl AcpThread { self.draft_prompt = prompt; } + pub fn ui_scroll_position(&self) -> Option { + self.ui_scroll_position + } + + pub fn set_ui_scroll_position(&mut self, position: Option) { + self.ui_scroll_position = position; + } + pub fn connection(&self) -> &Rc { &self.connection } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 7cf9416840a6bd2870327c9c68135857c01f7c9b..5421538ca736028a4ea7290c09ef81036e055b81 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -352,6 +352,8 @@ impl NativeAgent { let parent_session_id = thread.parent_thread_id(); let title = thread.title(); let draft_prompt = thread.draft_prompt().map(Vec::from); + let scroll_position = thread.ui_scroll_position(); + let token_usage = thread.latest_token_usage(); let project = thread.project.clone(); let action_log = thread.action_log.clone(); let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); @@ -367,6 +369,8 @@ impl NativeAgent { cx, ); acp_thread.set_draft_prompt(draft_prompt); + acp_thread.set_ui_scroll_position(scroll_position); + acp_thread.update_token_usage(token_usage, cx); acp_thread }); @@ -1917,7 +1921,9 @@ mod internal_tests { use gpui::TestAppContext; use indoc::formatdoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; - use language_model::{LanguageModelProviderId, LanguageModelProviderName}; + use language_model::{ + LanguageModelCompletionEvent, LanguageModelProviderId, LanguageModelProviderName, + }; use serde_json::json; use settings::SettingsStore; use util::{path, rel_path::rel_path}; @@ -2549,6 +2555,13 @@ mod internal_tests { cx.run_until_parked(); model.send_last_completion_stream_text_chunk("Lorem."); + model.send_last_completion_stream_event(LanguageModelCompletionEvent::UsageUpdate( + language_model::TokenUsage { + input_tokens: 150, + output_tokens: 75, + ..Default::default() + }, + )); model.end_last_completion_stream(); cx.run_until_parked(); summary_model @@ -2587,6 +2600,12 @@ mod internal_tests { acp_thread.update(cx, |thread, _cx| { thread.set_draft_prompt(Some(draft_blocks.clone())); }); + thread.update(cx, |thread, _cx| { + thread.set_ui_scroll_position(Some(gpui::ListOffset { + item_ix: 5, + offset_in_item: gpui::px(12.5), + })); + }); thread.update(cx, |_thread, cx| cx.notify()); cx.run_until_parked(); @@ -2632,6 +2651,24 @@ mod internal_tests { acp_thread.read_with(cx, |thread, _| { assert_eq!(thread.draft_prompt(), Some(draft_blocks.as_slice())); }); + + // Ensure token usage survived the round-trip. + acp_thread.read_with(cx, |thread, _| { + let usage = thread + .token_usage() + .expect("token usage should be restored after reload"); + assert_eq!(usage.input_tokens, 150); + assert_eq!(usage.output_tokens, 75); + }); + + // Ensure scroll position survived the round-trip. + acp_thread.read_with(cx, |thread, _| { + let scroll = thread + .ui_scroll_position() + .expect("scroll position should be restored after reload"); + assert_eq!(scroll.item_ix, 5); + assert_eq!(scroll.offset_in_item, gpui::px(12.5)); + }); } fn thread_entries( diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 3a7af37cac85065d8853fbb5332093ef3fd20592..10ecb643b9a17dd6b02b47a416c526a662d12632 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -66,6 +66,14 @@ pub struct DbThread { pub thinking_effort: Option, #[serde(default)] pub draft_prompt: Option>, + #[serde(default)] + pub ui_scroll_position: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SerializedScrollPosition { + pub item_ix: usize, + pub offset_in_item: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,6 +116,7 @@ impl SharedThread { thinking_enabled: false, thinking_effort: None, draft_prompt: None, + ui_scroll_position: None, } } @@ -286,6 +295,7 @@ impl DbThread { thinking_enabled: false, thinking_effort: None, draft_prompt: None, + ui_scroll_position: None, }) } } @@ -637,6 +647,7 @@ mod tests { thinking_enabled: false, thinking_effort: None, draft_prompt: None, + ui_scroll_position: None, } } @@ -841,4 +852,53 @@ mod tests { assert_eq!(threads.len(), 1); assert!(threads[0].folder_paths.is_empty()); } + + #[test] + fn test_scroll_position_defaults_to_none() { + let json = r#"{ + "title": "Old Thread", + "messages": [], + "updated_at": "2024-01-01T00:00:00Z" + }"#; + + let db_thread: DbThread = serde_json::from_str(json).expect("Failed to deserialize"); + + assert!( + db_thread.ui_scroll_position.is_none(), + "Legacy threads without scroll_position field should default to None" + ); + } + + #[gpui::test] + async fn test_scroll_position_roundtrips_through_save_load(cx: &mut TestAppContext) { + let database = ThreadsDatabase::new(cx.executor()).unwrap(); + + let thread_id = session_id("thread-with-scroll"); + + let mut thread = make_thread( + "Thread With Scroll", + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), + ); + thread.ui_scroll_position = Some(SerializedScrollPosition { + item_ix: 42, + offset_in_item: 13.5, + }); + + database + .save_thread(thread_id.clone(), thread, PathList::default()) + .await + .unwrap(); + + let loaded = database + .load_thread(thread_id) + .await + .unwrap() + .expect("thread should exist"); + + let scroll = loaded + .ui_scroll_position + .expect("scroll_position should be restored"); + assert_eq!(scroll.item_ix, 42); + assert!((scroll.offset_in_item - 13.5).abs() < f32::EPSILON); + } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index c57bd1e99b9ae4fd1a93214e2a5d5937d1ab0274..99d77456e3822ae12c65c0a419ceea18f13f41e8 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -901,6 +901,7 @@ pub struct Thread { subagent_context: Option, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. draft_prompt: Option>, + ui_scroll_position: Option, /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, } @@ -1017,6 +1018,7 @@ impl Thread { imported: false, subagent_context: None, draft_prompt: None, + ui_scroll_position: None, running_subagents: Vec::new(), } } @@ -1233,6 +1235,10 @@ impl Thread { imported: db_thread.imported, subagent_context: db_thread.subagent_context, draft_prompt: db_thread.draft_prompt, + ui_scroll_position: db_thread.ui_scroll_position.map(|sp| gpui::ListOffset { + item_ix: sp.item_ix, + offset_in_item: gpui::px(sp.offset_in_item), + }), running_subagents: Vec::new(), } } @@ -1258,6 +1264,12 @@ impl Thread { thinking_enabled: self.thinking_enabled, thinking_effort: self.thinking_effort.clone(), draft_prompt: self.draft_prompt.clone(), + ui_scroll_position: self.ui_scroll_position.map(|lo| { + crate::db::SerializedScrollPosition { + item_ix: lo.item_ix, + offset_in_item: lo.offset_in_item.as_f32(), + } + }), }; cx.background_spawn(async move { @@ -1307,6 +1319,14 @@ impl Thread { self.draft_prompt = prompt; } + pub fn ui_scroll_position(&self) -> Option { + self.ui_scroll_position + } + + pub fn set_ui_scroll_position(&mut self, position: Option) { + self.ui_scroll_position = position; + } + pub fn model(&self) -> Option<&Arc> { self.model.as_ref() } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index f944377e489a88ac0fa6dbb802edf9702e86f5f2..e26820ddacc3132d42946de3b27d25f4424fae02 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -146,6 +146,7 @@ mod tests { thinking_enabled: false, thinking_effort: None, draft_prompt: None, + ui_scroll_position: None, } } diff --git a/crates/agent_ui/src/connection_view.rs b/crates/agent_ui/src/connection_view.rs index 835ff611288c2bf6867a885ed2be8c6a66679cdb..07e34ccd56f0bd867135fe62894a5a3ff388c85e 100644 --- a/crates/agent_ui/src/connection_view.rs +++ b/crates/agent_ui/src/connection_view.rs @@ -845,6 +845,10 @@ impl ConnectionView { ); }); + if let Some(scroll_position) = thread.read(cx).ui_scroll_position() { + list_state.scroll_to(scroll_position); + } + AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx); let connection = thread.read(cx).connection().clone(); diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 8ce4da360664774342c4167f7c8dfbce914b647e..4b0d1686a2dafd2b9975a9109dd56dcf0b3faa00 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -248,7 +248,8 @@ pub struct ThreadView { pub resumed_without_history: bool, pub resume_thread_metadata: Option, pub _cancel_task: Option>, - _draft_save_task: Option>, + _save_task: Option>, + _draft_resolve_task: Option>, pub skip_queue_processing_count: usize, pub user_interrupted_generation: bool, pub can_fast_track_queue: bool, @@ -396,7 +397,7 @@ impl ThreadView { } else { Some(editor.update(cx, |editor, cx| editor.draft_contents(cx))) }; - this._draft_save_task = Some(cx.spawn(async move |this, cx| { + this._draft_resolve_task = Some(cx.spawn(async move |this, cx| { let draft = if let Some(task) = draft_contents_task { let blocks = task.await.ok().filter(|b| !b.is_empty()); blocks @@ -407,15 +408,7 @@ impl ThreadView { this.thread.update(cx, |thread, _cx| { thread.set_draft_prompt(draft); }); - }) - .ok(); - cx.background_executor() - .timer(SERIALIZATION_THROTTLE_TIME) - .await; - this.update(cx, |this, cx| { - if let Some(thread) = this.as_native_thread(cx) { - thread.update(cx, |_thread, cx| cx.notify()); - } + this.schedule_save(cx); }) .ok(); })); @@ -471,7 +464,8 @@ impl ThreadView { is_loading_contents: false, new_server_version_available: None, _cancel_task: None, - _draft_save_task: None, + _save_task: None, + _draft_resolve_task: None, skip_queue_processing_count: 0, user_interrupted_generation: false, can_fast_track_queue: false, @@ -487,12 +481,50 @@ impl ThreadView { _history_subscription: history_subscription, show_codex_windows_warning, }; + let list_state_for_scroll = this.list_state.clone(); + let thread_view = cx.entity().downgrade(); + this.list_state + .set_scroll_handler(move |_event, _window, cx| { + let list_state = list_state_for_scroll.clone(); + let thread_view = thread_view.clone(); + // N.B. We must defer because the scroll handler is called while the + // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() + // directly would panic from a double borrow. + cx.defer(move |cx| { + let scroll_top = list_state.logical_scroll_top(); + let _ = thread_view.update(cx, |this, cx| { + if let Some(thread) = this.as_native_thread(cx) { + thread.update(cx, |thread, _cx| { + thread.set_ui_scroll_position(Some(scroll_top)); + }); + } + this.schedule_save(cx); + }); + }); + }); + if should_auto_submit { this.send(window, cx); } this } + /// Schedule a throttled save of the thread state (draft prompt, scroll position, etc.). + /// Multiple calls within `SERIALIZATION_THROTTLE_TIME` are coalesced into a single save. + fn schedule_save(&mut self, cx: &mut Context) { + self._save_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(SERIALIZATION_THROTTLE_TIME) + .await; + this.update(cx, |this, cx| { + if let Some(thread) = this.as_native_thread(cx) { + thread.update(cx, |_thread, cx| cx.notify()); + } + }) + .ok(); + })); + } + pub fn handle_message_editor_event( &mut self, _editor: &Entity, From 7f3dee85c0fa19853ff64e1064de40db686dd49d Mon Sep 17 00:00:00 2001 From: John Tur Date: Tue, 3 Mar 2026 17:31:53 -0500 Subject: [PATCH 24/74] Fix OpenGL initialization on Intel HD 4000 (#50646) Release Notes: - Fixed Zed failing to initialize OpenGL on certain Linux devices --- Cargo.lock | 67 ++++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dcecec352bf1426fb76956f04224c66b04143627..fee9c5d0cc3aad4ac76e478362981efb760da2f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7598,7 +7598,7 @@ dependencies = [ "mach2 0.5.0", "media", "metal", - "naga", + "naga 28.0.0", "num_cpus", "objc", "objc2", @@ -10702,6 +10702,30 @@ name = "naga" version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "codespan-reporting 0.12.0", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.17", + "unicode-ident", +] + +[[package]] +name = "naga" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "arrayvec", "bit-set", @@ -19890,9 +19914,8 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -19903,7 +19926,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga", + "naga 28.0.1", "parking_lot", "portable-atomic", "profiling", @@ -19920,9 +19943,8 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb4c8b5db5f00e56f1f08869d870a0dff7c8bc7ebc01091fec140b0cf0211a9" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "arrayvec", "bit-set", @@ -19934,7 +19956,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga", + "naga 28.0.1", "once_cell", "parking_lot", "portable-atomic", @@ -19952,36 +19974,32 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293080d77fdd14d6b08a67c5487dfddbf874534bb7921526db56a7b75d7e3bef" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "android_system_properties", "arrayvec", @@ -20004,7 +20022,7 @@ dependencies = [ "libloading", "log", "metal", - "naga", + "naga 28.0.1", "ndk-sys", "objc", "once_cell", @@ -20027,9 +20045,8 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "28.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" +version = "28.0.1" +source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 35180020a8d70d83c113172051d12a85f33c55ca..cc5ff3054161ec2d0651aeac6ff4dc673251c414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -770,7 +770,7 @@ wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = "28.0" +wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" } windows-core = "0.61" yawc = "0.2.5" zeroize = "1.8" From 6a38c5c0a0fe23ec7d6bc80834660d61d7f9255b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Mar 2026 15:32:57 -0700 Subject: [PATCH 25/74] Fix panic in remote workspaces (#50647) Fixes ZED-4JD Release Notes: - Fix a panic when opening the remote server modal --- crates/recent_projects/src/remote_servers.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 6c0ce4b18854320fda8e72f291800049b07cec1a..a94f7b1d57eaef8657fb0d448480f84c97ce7e70 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1161,12 +1161,11 @@ impl RemoteServerProjects { workspace.toggle_modal(window, cx, |window, cx| { RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) }); - let prompt = workspace - .active_modal::(cx) - .unwrap() - .read(cx) - .prompt - .clone(); + // can be None if another copy of this modal opened in the meantime + let Some(modal) = workspace.active_modal::(cx) else { + return; + }; + let prompt = modal.read(cx).prompt.clone(); let connect = connect( ConnectionIdentifier::setup(), From 9c9337a8021f74511625517c3f4fa021106609eb Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 3 Mar 2026 16:09:01 -0800 Subject: [PATCH 26/74] Add cmd-y binding for agent::Keep in agent diff review (#50656) Release Notes: - Added `cmd-y` keybinding for accepting changes in the agent diff review, matching the git diff review shortcut. --- assets/keymaps/default-linux.json | 2 ++ assets/keymaps/default-macos.json | 2 ++ assets/keymaps/default-windows.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9b8f2d337b1f1073bca818cf0b9c66773a3ce4e9..87e76829966b501df4139d4942de604c4fc42d65 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -204,6 +204,7 @@ { "context": "Editor && editor_agent_diff", "bindings": { + "alt-y": "agent::Keep", "ctrl-alt-y": "agent::Keep", "ctrl-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", @@ -214,6 +215,7 @@ { "context": "AgentDiff", "bindings": { + "alt-y": "agent::Keep", "ctrl-alt-y": "agent::Keep", "ctrl-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 410c13687fbe0c19fbcb4c155ebba36dd068354c..ccb3a7fa9116b0771dda94e75e467c4572cdaf2c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -242,6 +242,7 @@ "context": "AgentDiff", "use_key_equivalents": true, "bindings": { + "cmd-y": "agent::Keep", "cmd-alt-y": "agent::Keep", "cmd-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", @@ -252,6 +253,7 @@ "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { + "cmd-y": "agent::Keep", "cmd-alt-y": "agent::Keep", "cmd-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 19f75f858cd45192c4cf30dd6bd0799046c26268..251c3d6541a611737027900e659a94271ed36526 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -203,6 +203,7 @@ "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { + "alt-y": "agent::Keep", "ctrl-alt-y": "agent::Keep", "ctrl-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", @@ -214,6 +215,7 @@ "context": "AgentDiff", "use_key_equivalents": true, "bindings": { + "alt-y": "agent::Keep", "ctrl-alt-y": "agent::Keep", "ctrl-alt-z": "agent::Reject", "shift-alt-y": "agent::KeepAll", From 9a6046cc57cf00c1276c05c7998b6e74bc27ea53 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Wed, 4 Mar 2026 02:18:52 -0500 Subject: [PATCH 27/74] Change miniprofiler file extension to `.miniprof.json` (#50429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main intention behind this change is to support uploading these files to GitHub. `.miniprof` is not a supported extension by GitHub, but `.json` is. The only “downside” to this change is that the cleanup process will have to look for `.miniprof` files AND `.miniprof.json` files. Maybe we can remove that change at a later date? Ref: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/attaching-files Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Changed miniprofiler file extension to `.miniprof.json` --- crates/miniprofiler_ui/src/miniprofiler_ui.rs | 2 +- crates/zed/src/reliability.rs | 6 +++--- docs/src/performance.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index 1f95dc3d230e7c50b4960560a96c9007fd77aab8..12b2bce77b5866e885483a847d40647f525207e6 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -544,7 +544,7 @@ impl Render for ProfilerWindow { let path = cx.prompt_for_new_path( &active_path, - Some("performance_profile.miniprof"), + Some("performance_profile.miniprof.json"), ); cx.background_spawn(async move { diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index b291b9c8493db75e20282c8c9bc5a3750fb5e705..d8dc1c4f8fe412b5e8eeb6b09e482a9ed243aaa3 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -144,7 +144,7 @@ fn cleanup_old_hang_traces() { entry .path() .extension() - .is_some_and(|ext| ext == "miniprof") + .is_some_and(|ext| ext == "json" || ext == "miniprof") }) .collect(); @@ -175,7 +175,7 @@ fn save_hang_trace( .collect::>(); let trace_path = paths::hang_traces_dir().join(&format!( - "hang-{}.miniprof", + "hang-{}.miniprof.json", hang_time.format("%Y-%m-%d_%H-%M-%S") )); @@ -193,7 +193,7 @@ fn save_hang_trace( entry .path() .extension() - .is_some_and(|ext| ext == "miniprof") + .is_some_and(|ext| ext == "json" || ext == "miniprof") }) .collect(); diff --git a/docs/src/performance.md b/docs/src/performance.md index 09abecdeffe4e268413a73b189ef301511b1a20e..e974d63f8816b68d30a1c06d7cbbc083f8564327 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -78,7 +78,7 @@ Download the importer - `cd import && mkdir build && cd build` - Run cmake to generate build files: `cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..` - Build the importer: `ninja` -- Run the importer on the trace file: `./tracy-import-miniprofiler /path/to/trace.miniprof /path/to/output.tracy` +- Run the importer on the trace file: `./tracy-import-miniprofiler /path/to/trace.miniprof.json /path/to/output.tracy` - Open the trace in tracy: - If you're on windows download the v0.12.2 version from the releases on the upstream repo - If you're on other platforms open it on the website: https://tracy.nereid.pl/ (the version might mismatch so your luck might vary, we need to host our own ideally..) @@ -87,7 +87,7 @@ Download the importer - Run the action: `zed open performance profiler` - Hit the save button. This opens a save dialog or if that fails to open the trace gets saved in your working directory. -- Convert the profile so it can be imported in tracy using the importer: `./tracy-import-miniprofiler output.tracy` +- Convert the profile so it can be imported in tracy using the importer: `./tracy-import-miniprofiler output.tracy` - Go to hit the 'power button' in the top left and then open saved trace. - Now zoom in to see the tasks and how long they took From cdb34c30c921a1bd480180c9485d4cac28deede2 Mon Sep 17 00:00:00 2001 From: Xin Zhao Date: Wed, 4 Mar 2026 15:31:27 +0800 Subject: [PATCH 28/74] python: Register LSP adapters directly to the LanguageRegistry (#50662) The purpose of `register_available_lsp_adapter()` is to allow language servers to be reused across multiple languages. Since adapters like `ty`, `pylsp`, and `pyright` are specific to Python, there is no need to register them for other languages. Additionally, registering them directly to the global `LanguageRegistry` results in negligible resource consumption. We can then use the default settings to control the default language server for Python, as referenced here: https://github.com/zed-industries/zed/blob/9c9337a8021f74511625517c3f4fa021106609eb/assets/settings/default.json#L2119-L2130 Additionally, the documentation for Python has been updated to clarify that the `"..."` syntax does not mean "keep the rest at default," but rather "include all other available servers." Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing (no sure how to add test for this) - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/languages/src/lib.rs | 11 +++++++---- docs/src/languages/python.md | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index c31911f372261db47f689d29de9c60c0f9cad56e..4c291b86982a8cb1aa153aa0c036b3d169621339 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -179,7 +179,13 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime }, LanguageInfo { name: "python", - adapters: vec![basedpyright_lsp_adapter, ruff_lsp_adapter], + adapters: vec![ + basedpyright_lsp_adapter, + ruff_lsp_adapter, + ty_lsp_adapter, + py_lsp_adapter, + python_lsp_adapter, + ], context: Some(python_context_provider), toolchain: Some(python_toolchain_provider), manifest_name: Some(SharedString::new_static("pyproject.toml").into()), @@ -281,9 +287,6 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime typescript_lsp_adapter, ); - languages.register_available_lsp_adapter(python_lsp_adapter.name(), python_lsp_adapter); - languages.register_available_lsp_adapter(py_lsp_adapter.name(), py_lsp_adapter); - languages.register_available_lsp_adapter(ty_lsp_adapter.name(), ty_lsp_adapter); // Register Tailwind for the existing languages that should have it by default. // // This can be driven by the `language_servers` setting once we have a way for diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index d66f52c71cb9295fe9ca94e5890de48cd1275e57..fdeabec5069ed20a9b168ab19129dde0cc6280ba 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -89,8 +89,8 @@ Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages "languages": { "Python": { "language_servers": [ - // Disable basedpyright and enable ty, and otherwise - // use the default configuration. + // Disable basedpyright and enable ty, and include all + // other registered language servers (ruff, pylsp, pyright). "ty", "!basedpyright", "..." From e51cd4931c10007f30dd3d3e9351b0a4b99af063 Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Wed, 4 Mar 2026 08:32:12 +0100 Subject: [PATCH 29/74] doc: Improve documentation for language server `...` expansion (#50672) Hi! The `...` entry in the `language_servers` setting was only explained in a single bullet point, which led users to misconfigure their setup, particularly when overriding defaults that disable certain servers with `!`. Add a detailed explanation of how `...` works and a table of examples using Ruby's real server configuration to illustrate the override behavior. Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- docs/src/configuring-languages.md | 37 +++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index 4e9bbce822f2f0d87ac2a8c9617698acd5983243..91775c3df137e38eb0b6b7b333b49d269b2f3a7c 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -122,11 +122,40 @@ You can specify your preference using the `language_servers` setting: In this example: -- `intelephense` is set as the primary language server -- `phpactor` is disabled (note the `!` prefix) -- `...` expands to the rest of the language servers that are registered for PHP +- `intelephense` is set as the primary language server. +- `phpactor` and `phptools` are disabled (note the `!` prefix). +- `"..."` expands to the rest of the language servers registered for PHP that are not already listed. -This configuration allows you to tailor the language server setup to your specific needs, ensuring that you get the most suitable functionality for your development workflow. +The `"..."` entry acts as a wildcard that includes any registered language server you haven't explicitly mentioned. Servers you list by name keep their position, and `"..."` fills in the remaining ones at that point in the list. Servers prefixed with `!` are excluded entirely. This means that if a new language server extension is installed or a new server is registered for a language, `"..."` will automatically include it. If you want full control over which servers are enabled, omit `"..."` — only the servers you list by name will be used. + +#### Examples + +Suppose you're working with Ruby. The default configuration is: + +```json [settings] +{ + "language_servers": [ + "solargraph", + "!ruby-lsp", + "!rubocop", + "!sorbet", + "!steep", + "!kanayago", + "..." + ] +} +``` + +When you override `language_servers` in your settings, your list **replaces** the default entirely. This means default-disabled servers like `kanayago` will be re-enabled by `"..."` unless you explicitly disable them again. + +| Configuration | Result | +| ------------------------------------------------- | ------------------------------------------------------------------ | +| `["..."]` | `solargraph`, `ruby-lsp`, `rubocop`, `sorbet`, `steep`, `kanayago` | +| `["ruby-lsp", "..."]` | `ruby-lsp`, `solargraph`, `rubocop`, `sorbet`, `steep`, `kanayago` | +| `["ruby-lsp", "!solargraph", "!kanayago", "..."]` | `ruby-lsp`, `rubocop`, `sorbet`, `steep` | +| `["ruby-lsp", "solargraph"]` | `ruby-lsp`, `solargraph` | + +> Note: In the first example, `"..."` includes `kanayago` even though it is disabled by default. The override replaced the default list, so the `"!kanayago"` entry is no longer present. To keep it disabled, you must include `"!kanayago"` in your configuration. ### Toolchains From c0fa025bc9ccce8eb538f2d06a74f4ab4a1205b0 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Wed, 4 Mar 2026 17:33:40 +1000 Subject: [PATCH 30/74] repl: Fix image scaling (#48435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues #47114 Release Notes: - Fixed REPL output width clamping to apply to the content area so images don’t get clipped by controls --------- Co-authored-by: MrSubidubi --- crates/repl/src/notebook/cell.rs | 95 ++++++------------- crates/repl/src/outputs.rs | 37 ++++---- crates/repl/src/outputs/image.rs | 8 +- crates/repl/src/outputs/plain.rs | 2 +- crates/repl/src/repl_settings.rs | 6 -- .../settings_content/src/settings_content.rs | 5 - 6 files changed, 55 insertions(+), 98 deletions(-) diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index d66261698b722cfcd0f547e09d84cf83a0d2b1a6..200424742aff113d637fe9aca30999c0f95e79a5 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -1,13 +1,11 @@ -#![allow(unused, dead_code)] use std::sync::Arc; use std::time::{Duration, Instant}; use editor::{Editor, EditorMode, MultiBuffer, SizingBehavior}; use futures::future::Shared; use gpui::{ - App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, KeyContext, - RetainAllImageCache, StatefulInteractiveElement, Task, TextStyleRefinement, image_cache, - prelude::*, + App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, RetainAllImageCache, + StatefulInteractiveElement, Task, TextStyleRefinement, prelude::*, }; use language::{Buffer, Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -236,7 +234,7 @@ pub trait RenderableCell: Render { fn source(&self) -> &String; fn selected(&self) -> bool; fn set_selected(&mut self, selected: bool) -> &mut Self; - fn selected_bg_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { + fn selected_bg_color(&self, _window: &mut Window, cx: &mut Context) -> Hsla { if self.selected() { let mut color = cx.theme().colors().element_hover; color.fade_out(0.5); @@ -253,7 +251,7 @@ pub trait RenderableCell: Render { fn cell_position_spacer( &self, is_first: bool, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Option { let cell_position = self.cell_position(); @@ -328,7 +326,6 @@ pub struct MarkdownCell { editing: bool, selected: bool, cell_position: Option, - languages: Arc, _editor_subscription: gpui::Subscription, } @@ -386,7 +383,6 @@ impl MarkdownCell { let markdown = cx.new(|cx| Markdown::new(source.clone().into(), None, None, cx)); - let cell_id = id.clone(); let editor_subscription = cx.subscribe(&editor, move |this, _editor, event, cx| match event { editor::EditorEvent::Blurred => { @@ -410,7 +406,6 @@ impl MarkdownCell { editing: start_editing, selected: false, cell_position: None, - languages, _editor_subscription: editor_subscription, } } @@ -461,8 +456,6 @@ impl MarkdownCell { .unwrap_or_default(); self.source = source.clone(); - let languages = self.languages.clone(); - self.markdown.update(cx, |markdown, cx| { markdown.reset(source.into(), cx); }); @@ -606,7 +599,7 @@ pub struct CodeCell { outputs: Vec, selected: bool, cell_position: Option, - language_task: Task<()>, + _language_task: Task<()>, execution_start_time: Option, execution_duration: Option, is_executing: bool, @@ -670,10 +663,10 @@ impl CodeCell { outputs: Vec::new(), selected: false, cell_position: None, - language_task, execution_start_time: None, execution_duration: None, is_executing: false, + _language_task: language_task, } } @@ -748,10 +741,10 @@ impl CodeCell { outputs, selected: false, cell_position: None, - language_task, execution_start_time: None, execution_duration: None, is_executing: false, + _language_task: language_task, } } @@ -879,15 +872,7 @@ impl CodeCell { cx.notify(); } - fn output_control(&self) -> Option { - if self.has_outputs() { - Some(CellControlType::ClearCell) - } else { - None - } - } - - pub fn gutter_output(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + pub fn gutter_output(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_selected = self.selected(); div() @@ -948,7 +933,7 @@ impl RenderableCell for CodeCell { &self.source } - fn control(&self, window: &mut Window, cx: &mut Context) -> Option { + fn control(&self, _window: &mut Window, cx: &mut Context) -> Option { let control_type = if self.has_outputs() { CellControlType::RerunCell } else { @@ -1038,8 +1023,7 @@ impl RenderableCell for CodeCell { } impl RunnableCell for CodeCell { - fn run(&mut self, window: &mut Window, cx: &mut Context) { - println!("Running code cell: {}", self.id); + fn run(&mut self, _window: &mut Window, cx: &mut Context) { cx.emit(CellEvent::Run(self.id.clone())); } @@ -1062,11 +1046,8 @@ impl Render for CodeCell { } else { None }; - let output_max_width = plain::max_width_for_columns( - ReplSettings::get_global(cx).output_max_width_columns, - window, - cx, - ); + let output_max_width = + plain::max_width_for_columns(ReplSettings::get_global(cx).max_columns, window, cx); // get the language from the editor's buffer let language_name = self .editor @@ -1198,41 +1179,23 @@ impl Render for CodeCell { }, ) // output at bottom - .child(div().w_full().children(self.outputs.iter().map( - |output| { - let content = match output { - Output::Plain { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Markdown { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Stream { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Image { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Message(message) => Some( - div() - .child(message.clone()) - .into_any_element(), - ), - Output::Table { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Json { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::ErrorOutput(error_view) => { - error_view.render(window, cx) - } - Output::ClearOutputWaitMarker => None, - }; - - div().children(content) - }, - ))), + .child( + div() + .id(( + ElementId::from(self.id.to_string()), + "output-scroll", + )) + .w_full() + .when_some(output_max_width, |div, max_width| { + div.max_w(max_width).overflow_x_scroll() + }) + .when_some(output_max_height, |div, max_height| { + div.max_h(max_height).overflow_y_scroll() + }) + .children(self.outputs.iter().map(|output| { + div().children(output.content(window, cx)) + })), + ), ), ), ) diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index 8be8c57cceee84435a6d99ba5c611d24c563bec3..f6d2bc4d3173ce64700b7b5ac45301df0fe0ab53 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -253,18 +253,8 @@ impl Output { ) } - pub fn render( - &self, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let max_width = plain::max_width_for_columns( - ReplSettings::get_global(cx).output_max_width_columns, - window, - cx, - ); - let content = match self { + pub fn content(&self, window: &mut Window, cx: &mut App) -> Option { + match self { Self::Plain { content, .. } => Some(content.clone().into_any_element()), Self::Markdown { content, .. } => Some(content.clone().into_any_element()), Self::Stream { content, .. } => Some(content.clone().into_any_element()), @@ -274,21 +264,36 @@ impl Output { Self::Json { content, .. } => Some(content.clone().into_any_element()), Self::ErrorOutput(error_view) => error_view.render(window, cx), Self::ClearOutputWaitMarker => None, - }; + } + } - let needs_horizontal_scroll = matches!(self, Self::Table { .. } | Self::Image { .. }); + pub fn render( + &self, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let max_width = + plain::max_width_for_columns(ReplSettings::get_global(cx).max_columns, window, cx); + let content = self.content(window, cx); + + let needs_horizontal_scroll = matches!(self, Self::Table { .. }); h_flex() .id("output-content") .w_full() - .when_some(max_width, |this, max_w| this.max_w(max_w)) - .overflow_x_scroll() + .when_else( + needs_horizontal_scroll, + |this| this.overflow_x_scroll(), + |this| this.overflow_x_hidden(), + ) .items_start() .child( div() .when(!needs_horizontal_scroll, |el| { el.flex_1().w_full().overflow_x_hidden() }) + .when_some(max_width, |el, max_width| el.max_w(max_width)) .children(content), ) .children(match self { diff --git a/crates/repl/src/outputs/image.rs b/crates/repl/src/outputs/image.rs index 9d1ffa3d2065281cd69e67b2faf960c9aa690bcb..e5444be3d779c9541fcadd55b9255d3e25da0cba 100644 --- a/crates/repl/src/outputs/image.rs +++ b/crates/repl/src/outputs/image.rs @@ -3,10 +3,10 @@ use base64::{ Engine as _, alphabet, engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, }; -use gpui::{App, ClipboardItem, Image, ImageFormat, RenderImage, Window, img}; +use gpui::{App, ClipboardItem, Image, ImageFormat, Pixels, RenderImage, Window, img}; use settings::Settings as _; use std::sync::Arc; -use ui::{IntoElement, Styled, div, prelude::*}; +use ui::{IntoElement, Styled, prelude::*}; use crate::outputs::{OutputContent, plain}; use crate::repl_settings::ReplSettings; @@ -113,7 +113,7 @@ impl Render for ImageView { let settings = ReplSettings::get_global(cx); let line_height = window.line_height(); - let max_width = plain::max_width_for_columns(settings.output_max_width_columns, window, cx); + let max_width = plain::max_width_for_columns(settings.max_columns, window, cx); let max_height = if settings.output_max_height_lines > 0 { Some(line_height * settings.output_max_height_lines as f32) @@ -125,7 +125,7 @@ impl Render for ImageView { let image = self.image.clone(); - div().h(height).w(width).child(img(image)) + img(image).w(width).h(height) } } diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 0db2f811fb9ca3b82114db23826e37fe699bd3a0..71e2624f8ad7b0172a86793d5d81b38339b04f36 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -22,7 +22,7 @@ use alacritty_terminal::{ term::Config, vte::ansi::Processor, }; -use gpui::{Bounds, ClipboardItem, Entity, FontStyle, TextStyle, WhiteSpace, canvas, size}; +use gpui::{Bounds, ClipboardItem, Entity, FontStyle, Pixels, TextStyle, WhiteSpace, canvas, size}; use language::Buffer; use settings::Settings as _; use terminal::terminal_settings::TerminalSettings; diff --git a/crates/repl/src/repl_settings.rs b/crates/repl/src/repl_settings.rs index 302164a5b360157edceff1b1f2e18f6c6fd7a50b..5fd7623bb71e6446b8cacd6029108e481efc8680 100644 --- a/crates/repl/src/repl_settings.rs +++ b/crates/repl/src/repl_settings.rs @@ -27,11 +27,6 @@ pub struct ReplSettings { /// /// Default: 0 pub output_max_height_lines: usize, - /// Maximum number of columns of output to display before scaling images. - /// Set to 0 to disable output width limits. - /// - /// Default: 0 - pub output_max_width_columns: usize, } impl Settings for ReplSettings { @@ -44,7 +39,6 @@ impl Settings for ReplSettings { inline_output: repl.inline_output.unwrap_or(true), inline_output_max_length: repl.inline_output_max_length.unwrap_or(50), output_max_height_lines: repl.output_max_height_lines.unwrap_or(0), - output_max_width_columns: repl.output_max_width_columns.unwrap_or(0), } } } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index f94c6a0b98d7fa23686dc1c89012e3b1fe476c70..5a4e87c384d802f3de4c96c07f65cf163c3a6d1a 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1148,11 +1148,6 @@ pub struct ReplSettingsContent { /// /// Default: 0 pub output_max_height_lines: Option, - /// Maximum number of columns of output to display before scaling images. - /// Set to 0 to disable output width limits. - /// - /// Default: 0 - pub output_max_width_columns: Option, } /// Settings for configuring the which-key popup behaviour. From 152d3eafcaf4655ac65e2a25e65cc5ee0545db3f Mon Sep 17 00:00:00 2001 From: Sarthak Mishra Date: Wed, 4 Mar 2026 13:07:50 +0530 Subject: [PATCH 31/74] project_panel: Fix Reveal in File Manager for WSL projects (#50610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #46767 ## Summary The "Reveal in File Manager" action was shown in the context menu for WSL projects (guarded by `is_via_wsl_with_host_interop`), but the action handler in `Render` was only registered when `project.is_local()` — which returns `false` for WSL. Dispatching the action without a handler caused a crash. Adds the same `is_via_wsl_with_host_interop(cx)` check to the handler registration. ## Testing - Ran `cargo test -p project_panel` — 78 passed, 0 failed - Manual testing: connected to WSL Ubuntu, right-clicked a file in the project panel, used "Reveal in File Manager" — Windows Explorer opened correctly without crashing Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed a crash when using "Reveal in File Manager" on files in WSL projects (#46767). --- crates/project_panel/src/project_panel.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 7f746a6ccd7efec2b73354992c593433b0b6f281..0dd19dddde7ab947cfe85a1fd9d96ad7b2d6f23d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -6457,11 +6457,14 @@ impl Render for ProjectPanel { el.on_action(cx.listener(Self::trash)) }) }) - .when(project.is_local(), |el| { - el.on_action(cx.listener(Self::reveal_in_finder)) - .on_action(cx.listener(Self::open_system)) - .on_action(cx.listener(Self::open_in_terminal)) - }) + .when( + project.is_local() || project.is_via_wsl_with_host_interop(cx), + |el| { + el.on_action(cx.listener(Self::reveal_in_finder)) + .on_action(cx.listener(Self::open_system)) + .on_action(cx.listener(Self::open_in_terminal)) + }, + ) .when(project.is_via_remote_server(), |el| { el.on_action(cx.listener(Self::open_in_terminal)) .on_action(cx.listener(Self::download_from_remote)) From f023109a107d1fb50f9519c492fa7868d7be41ad Mon Sep 17 00:00:00 2001 From: john Date: Wed, 4 Mar 2026 03:31:51 -0500 Subject: [PATCH 32/74] docs: Fix incorrect IAM terminology under Bedrock section (#50546) IAM users cannot be assumed; only IAM roles can be. Before you mark this PR as ready for review, make sure that you have: - [ ] Added a solid test coverage and/or screenshots from doing manual testing - [ ] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- docs/src/ai/llm-providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3a32bd96e73d9df427897798681f203c4ceb2273..a4a6274af10d1aea20ed27160704136d9f0eb586 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -88,7 +88,7 @@ With that done, choose one of the three authentication methods: While it's possible to configure through the Agent Panel settings UI by entering your AWS access key and secret directly, we recommend using named profiles instead for better security practices. To do this: -1. Create an IAM User that you can assume in the [IAM Console](https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users). +1. Create an IAM User in the [IAM Console](https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users). 2. Create security credentials for that User, save them and keep them secure. 3. Open the Agent Configuration with (`agent: open settings`) and go to the Amazon Bedrock section 4. Copy the credentials from Step 2 into the respective **Access Key ID**, **Secret Access Key**, and **Region** fields. From f0abcd89957027d66aba37764523f5143a1a1c34 Mon Sep 17 00:00:00 2001 From: John Tur Date: Wed, 4 Mar 2026 03:55:53 -0500 Subject: [PATCH 33/74] More fixes for OpenGL initialization on Intel HD 4000 (#50680) Release Notes: - N/A --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fee9c5d0cc3aad4ac76e478362981efb760da2f2..d79134c6145d3a6644f780097f7dd8f69eeae295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10725,7 +10725,7 @@ dependencies = [ [[package]] name = "naga" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "arrayvec", "bit-set", @@ -19915,7 +19915,7 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "arrayvec", "bitflags 2.10.0", @@ -19944,7 +19944,7 @@ dependencies = [ [[package]] name = "wgpu-core" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "arrayvec", "bit-set", @@ -19975,7 +19975,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "wgpu-hal", ] @@ -19983,7 +19983,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-emscripten" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "wgpu-hal", ] @@ -19991,7 +19991,7 @@ dependencies = [ [[package]] name = "wgpu-core-deps-windows-linux-android" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "wgpu-hal", ] @@ -19999,7 +19999,7 @@ dependencies = [ [[package]] name = "wgpu-hal" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "android_system_properties", "arrayvec", @@ -20046,7 +20046,7 @@ dependencies = [ [[package]] name = "wgpu-types" version = "28.0.1" -source = "git+https://github.com/zed-industries/wgpu?rev=e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2#e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" +source = "git+https://github.com/zed-industries/wgpu?rev=6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d#6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" dependencies = [ "bitflags 2.10.0", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index cc5ff3054161ec2d0651aeac6ff4dc673251c414..15d39992804b5ed7ad99fadd46e350b1357b17d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -770,7 +770,7 @@ wax = "0.7" which = "6.0.0" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "e0f83a6cedc5e0b97da1ebe2d638ad103672e0a2" } +wgpu = { git = "https://github.com/zed-industries/wgpu", rev = "6e0c2546d99dad72ce6ffb5b04349e6a4ce96e6d" } windows-core = "0.61" yawc = "0.2.5" zeroize = "1.8" From 4668dbc83a773049f48f58728b5ca631fc47e5f3 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 4 Mar 2026 10:38:42 +0100 Subject: [PATCH 34/74] agent: Allow for expanding the subagent thread when permissions are requested (#50684) Previously, there was no way to view the full thread context Release Notes: - N/A Co-authored-by: Bennet Bo Fenner Co-authored-by: MrSubidubi --- .../src/connection_view/thread_view.rs | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 4b0d1686a2dafd2b9975a9109dd56dcf0b3faa00..8a1a7d2ea5b0f01ba559e83051861b9d6324985f 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -6768,6 +6768,31 @@ impl ThreadView { .read(cx) .pending_tool_call(thread.read(cx).session_id(), cx); + let session_id = thread.read(cx).session_id().clone(); + + let fullscreen_toggle = h_flex() + .id(entry_ix) + .py_1() + .w_full() + .justify_center() + .border_t_1() + .when(is_failed, |this| this.border_dashed()) + .border_color(self.tool_card_border_color(cx)) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child( + Icon::new(IconName::Maximize) + .color(Color::Muted) + .size(IconSize::Small), + ) + .tooltip(Tooltip::text("Make Subagent Full Screen")) + .on_click(cx.listener(move |this, _event, window, cx| { + this.server_view + .update(cx, |this, cx| { + this.navigate_to_session(session_id.clone(), window, cx); + }) + .ok(); + })); + if is_running && let Some((_, subagent_tool_call_id, _)) = pending_tool_call { if let Some((entry_ix, tool_call)) = thread.read(cx).tool_call(&subagent_tool_call_id) @@ -6782,11 +6807,11 @@ impl ThreadView { window, cx, )) + .child(fullscreen_toggle) } else { this } } else { - let session_id = thread.read(cx).session_id().clone(); this.when(is_expanded, |this| { this.child(self.render_subagent_expanded_content( thread_view, @@ -6803,34 +6828,7 @@ impl ThreadView { .title(message), ) }) - .child( - h_flex() - .id(entry_ix) - .py_1() - .w_full() - .justify_center() - .border_t_1() - .when(is_failed, |this| this.border_dashed()) - .border_color(self.tool_card_border_color(cx)) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - Icon::new(IconName::Maximize) - .color(Color::Muted) - .size(IconSize::Small), - ) - .tooltip(Tooltip::text("Make Subagent Full Screen")) - .on_click(cx.listener(move |this, _event, window, cx| { - this.server_view - .update(cx, |this, cx| { - this.navigate_to_session( - session_id.clone(), - window, - cx, - ); - }) - .ok(); - })), - ) + .child(fullscreen_toggle) }) } }) From 007e3ec527949f25ef8b42f5b8a42136d20aba72 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 4 Mar 2026 11:24:24 +0100 Subject: [PATCH 35/74] docs: Update docs for the subagent tool (#50689) Adds the actual tool name so people can turn it off if they want. Release Notes: - N/A Co-authored-by: Bennet Bo Fenner Co-authored-by: MrSubidubi --- docs/src/ai/tools.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index 66f0af571d70fb8db7add2bd89139bf788369de6..faafc76b164f7f786c91c212bf51960f24a6bb0a 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -91,6 +91,6 @@ Executes shell commands and returns the combined output, creating a new shell pr ## Other Tools -### `subagent` +### `spawn_agent` -Spawns a subagent with its own context window to perform a delegated task. Useful for running parallel investigations, completing self-contained tasks, or performing research where only the outcome matters. Each subagent has access to the same tools as the parent agent. +Spawns a subagent with its own context window to perform a delegated task. Each subagent has access to the same tools as the parent agent. From de107768b10f05f30d2df508547f63410d71b7e8 Mon Sep 17 00:00:00 2001 From: moleium Date: Wed, 4 Mar 2026 13:30:33 +0300 Subject: [PATCH 36/74] Add .cppm (C++20 module interface) to C++ file extensions (#50667) `.cppm` is the widely used extension for C++20 module interface units, supported by MSVC, Clang, and GCC. Currently Zed doesn't recognize it as C++, so users get no syntax highlighting or LSP support. Changes: `crates/languages/src/cpp/config.toml`: add cppm to path_suffixes `crates/theme/src/icon_theme.rs`: add cppm to the C++ icon matcher https://github.com/search?q=path%3A*.cppm&type=code Release Notes: - N/A --- crates/languages/src/cpp/config.toml | 2 +- crates/theme/src/icon_theme.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/languages/src/cpp/config.toml b/crates/languages/src/cpp/config.toml index 10c36a6ded1e1f3a1204d1e15af47fee78b8e049..e2608a8ce5f17cb648e4f86dc27da60ed8bdd2ae 100644 --- a/crates/languages/src/cpp/config.toml +++ b/crates/languages/src/cpp/config.toml @@ -1,6 +1,6 @@ name = "C++" grammar = "cpp" -path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] +path_suffixes = ["cc", "hh", "cpp", "cppm", "h", "hpp", "cxx", "hxx", "c++", "h++", "ipp", "inl", "ino", "ixx", "cu", "cuh", "C", "H"] line_comments = ["// ", "/// ", "//! "] first_line_pattern = '^//.*-\*-\s*C\+\+\s*-\*-' decrease_indent_patterns = [ diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 7c2d603281ec50c1daa6f21e1dc3487bfc394a67..121ff9d7d4fbd841315b89e631606c7e67bc5cde 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -89,7 +89,7 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ ( "cpp", &[ - "c++", "h++", "cc", "cpp", "cxx", "hh", "hpp", "hxx", "inl", "ixx", + "c++", "h++", "cc", "cpp", "cppm", "cxx", "hh", "hpp", "hxx", "inl", "ixx", ], ), ("crystal", &["cr", "ecr"]), From 90ddd58c356c84f1467eb9874be944db192e46c7 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 4 Mar 2026 11:31:29 +0100 Subject: [PATCH 37/74] agent: Move file_read_times logic to ActionLog instead of Thread (#50688) Since the read times always correspond to an action log call anyway, we can let the action log track this internally, and we don't have to provide a reference to the Thread in as many tools. Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner Co-authored-by: MrSubidubi --- Cargo.lock | 1 + crates/action_log/Cargo.toml | 1 + crates/action_log/src/action_log.rs | 288 +++++++++++++++++- .../agent/src/tests/edit_file_thread_test.rs | 2 +- crates/agent/src/thread.rs | 9 +- crates/agent/src/tools/edit_file_tool.rs | 65 ++-- crates/agent/src/tools/read_file_tool.rs | 211 ++----------- .../src/tools/streaming_edit_file_tool.rs | 59 ++-- .../remote_server/src/remote_editing_tests.rs | 24 +- crates/zed/src/visual_test_runner.rs | 25 +- 10 files changed, 349 insertions(+), 336 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d79134c6145d3a6644f780097f7dd8f69eeae295..4e4d86b947be1f68d03b225d4a62747659c99bf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,7 @@ dependencies = [ "clock", "collections", "ctor", + "fs", "futures 0.3.31", "gpui", "indoc", diff --git a/crates/action_log/Cargo.toml b/crates/action_log/Cargo.toml index 8488df691e40ea3bcfc04f4f6f74964fba7863dd..b1a1bf824fb770b8378e596fd0c799a7cf98b13d 100644 --- a/crates/action_log/Cargo.toml +++ b/crates/action_log/Cargo.toml @@ -20,6 +20,7 @@ buffer_diff.workspace = true log.workspace = true clock.workspace = true collections.workspace = true +fs.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/action_log/src/action_log.rs b/crates/action_log/src/action_log.rs index 5f8a639c0559c10546fc5640dc240aeba9dde487..5679f3c58fe52057f7a4a0faa24d5b5db2b5e497 100644 --- a/crates/action_log/src/action_log.rs +++ b/crates/action_log/src/action_log.rs @@ -1,14 +1,20 @@ use anyhow::{Context as _, Result}; use buffer_diff::BufferDiff; use clock; -use collections::BTreeMap; +use collections::{BTreeMap, HashMap}; +use fs::MTime; use futures::{FutureExt, StreamExt, channel::mpsc}; use gpui::{ App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity, }; use language::{Anchor, Buffer, BufferEvent, Point, ToOffset, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; -use std::{cmp, ops::Range, sync::Arc}; +use std::{ + cmp, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, +}; use text::{Edit, Patch, Rope}; use util::{RangeExt, ResultExt as _}; @@ -54,6 +60,8 @@ pub struct ActionLog { linked_action_log: Option>, /// Stores undo information for the most recent reject operation last_reject_undo: Option, + /// Tracks the last time files were read by the agent, to detect external modifications + file_read_times: HashMap, } impl ActionLog { @@ -64,6 +72,7 @@ impl ActionLog { project, linked_action_log: None, last_reject_undo: None, + file_read_times: HashMap::default(), } } @@ -76,6 +85,32 @@ impl ActionLog { &self.project } + pub fn file_read_time(&self, path: &Path) -> Option { + self.file_read_times.get(path).copied() + } + + fn update_file_read_time(&mut self, buffer: &Entity, cx: &App) { + let buffer = buffer.read(cx); + if let Some(file) = buffer.file() { + if let Some(local_file) = file.as_local() { + if let Some(mtime) = file.disk_state().mtime() { + let abs_path = local_file.abs_path(cx); + self.file_read_times.insert(abs_path, mtime); + } + } + } + } + + fn remove_file_read_time(&mut self, buffer: &Entity, cx: &App) { + let buffer = buffer.read(cx); + if let Some(file) = buffer.file() { + if let Some(local_file) = file.as_local() { + let abs_path = local_file.abs_path(cx); + self.file_read_times.remove(&abs_path); + } + } + } + fn track_buffer_internal( &mut self, buffer: Entity, @@ -506,24 +541,69 @@ impl ActionLog { /// Track a buffer as read by agent, so we can notify the model about user edits. pub fn buffer_read(&mut self, buffer: Entity, cx: &mut Context) { - if let Some(linked_action_log) = &mut self.linked_action_log { - linked_action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + self.buffer_read_impl(buffer, true, cx); + } + + fn buffer_read_impl( + &mut self, + buffer: Entity, + record_file_read_time: bool, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + // We don't want to share read times since the other agent hasn't read it necessarily + linked_action_log.update(cx, |log, cx| { + log.buffer_read_impl(buffer.clone(), false, cx); + }); + } + if record_file_read_time { + self.update_file_read_time(&buffer, cx); } self.track_buffer_internal(buffer, false, cx); } /// Mark a buffer as created by agent, so we can refresh it in the context pub fn buffer_created(&mut self, buffer: Entity, cx: &mut Context) { - if let Some(linked_action_log) = &mut self.linked_action_log { - linked_action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + self.buffer_created_impl(buffer, true, cx); + } + + fn buffer_created_impl( + &mut self, + buffer: Entity, + record_file_read_time: bool, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + // We don't want to share read times since the other agent hasn't read it necessarily + linked_action_log.update(cx, |log, cx| { + log.buffer_created_impl(buffer.clone(), false, cx); + }); + } + if record_file_read_time { + self.update_file_read_time(&buffer, cx); } self.track_buffer_internal(buffer, true, cx); } /// Mark a buffer as edited by agent, so we can refresh it in the context pub fn buffer_edited(&mut self, buffer: Entity, cx: &mut Context) { - if let Some(linked_action_log) = &mut self.linked_action_log { - linked_action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + self.buffer_edited_impl(buffer, true, cx); + } + + fn buffer_edited_impl( + &mut self, + buffer: Entity, + record_file_read_time: bool, + cx: &mut Context, + ) { + if let Some(linked_action_log) = &self.linked_action_log { + // We don't want to share read times since the other agent hasn't read it necessarily + linked_action_log.update(cx, |log, cx| { + log.buffer_edited_impl(buffer.clone(), false, cx); + }); + } + if record_file_read_time { + self.update_file_read_time(&buffer, cx); } let new_version = buffer.read(cx).version(); let tracked_buffer = self.track_buffer_internal(buffer, false, cx); @@ -536,6 +616,8 @@ impl ActionLog { } pub fn will_delete_buffer(&mut self, buffer: Entity, cx: &mut Context) { + // Ok to propagate file read time removal to linked action log + self.remove_file_read_time(&buffer, cx); let has_linked_action_log = self.linked_action_log.is_some(); let tracked_buffer = self.track_buffer_internal(buffer.clone(), false, cx); match tracked_buffer.status { @@ -2976,6 +3058,196 @@ mod tests { ); } + #[gpui::test] + async fn test_file_read_time_recorded_on_buffer_read(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello world"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let abs_path = PathBuf::from(path!("/dir/file")); + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "file_read_time should be None before buffer_read" + ); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + }); + + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()), + "file_read_time should be recorded after buffer_read" + ); + } + + #[gpui::test] + async fn test_file_read_time_recorded_on_buffer_edited(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello world"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let abs_path = PathBuf::from(path!("/dir/file")); + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "file_read_time should be None before buffer_edited" + ); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()), + "file_read_time should be recorded after buffer_edited" + ); + } + + #[gpui::test] + async fn test_file_read_time_recorded_on_buffer_created(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "existing content"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let abs_path = PathBuf::from(path!("/dir/file")); + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "file_read_time should be None before buffer_created" + ); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + }); + + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()), + "file_read_time should be recorded after buffer_created" + ); + } + + #[gpui::test] + async fn test_file_read_time_removed_on_delete(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello world"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let abs_path = PathBuf::from(path!("/dir/file")); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + }); + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()), + "file_read_time should exist after buffer_read" + ); + + cx.update(|cx| { + action_log.update(cx, |log, cx| log.will_delete_buffer(buffer.clone(), cx)); + }); + assert!( + action_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "file_read_time should be removed after will_delete_buffer" + ); + } + + #[gpui::test] + async fn test_file_read_time_not_forwarded_to_linked_action_log(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({"file": "hello world"})) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let parent_log = cx.new(|_| ActionLog::new(project.clone())); + let child_log = + cx.new(|_| ActionLog::new(project.clone()).with_linked_action_log(parent_log.clone())); + + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + let abs_path = PathBuf::from(path!("/dir/file")); + + cx.update(|cx| { + child_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + }); + assert!( + child_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_some()), + "child should record file_read_time on buffer_read" + ); + assert!( + parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "parent should NOT get file_read_time from child's buffer_read" + ); + + cx.update(|cx| { + child_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + assert!( + parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "parent should NOT get file_read_time from child's buffer_edited" + ); + + cx.update(|cx| { + child_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + }); + assert!( + parent_log.read_with(cx, |log, _| log.file_read_time(&abs_path).is_none()), + "parent should NOT get file_read_time from child's buffer_created" + ); + } + #[derive(Debug, PartialEq)] struct HunkStatus { range: Range, diff --git a/crates/agent/src/tests/edit_file_thread_test.rs b/crates/agent/src/tests/edit_file_thread_test.rs index 069bf0349299e6f4952f673cbf7607e52d48d9c5..3beb5cb0d51abc55fbf3cf0849ced248a9d1fa5c 100644 --- a/crates/agent/src/tests/edit_file_thread_test.rs +++ b/crates/agent/src/tests/edit_file_thread_test.rs @@ -50,9 +50,9 @@ async fn test_edit_file_tool_in_thread_context(cx: &mut TestAppContext) { // Add just the tools we need for this test let language_registry = project.read(cx).languages().clone(); thread.add_tool(crate::ReadFileTool::new( - cx.weak_entity(), project.clone(), thread.action_log().clone(), + true, )); thread.add_tool(crate::EditFileTool::new( project.clone(), diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 99d77456e3822ae12c65c0a419ceea18f13f41e8..616ae414d4d51a384a18460e8339fd07770fa6b9 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -893,8 +893,6 @@ pub struct Thread { pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, - /// Tracks the last time files were read by the agent, to detect external modifications - pub(crate) file_read_times: HashMap, /// True if this thread was imported from a shared thread and can be synced. imported: bool, /// If this is a subagent thread, contains context about the parent @@ -1014,7 +1012,6 @@ impl Thread { prompt_capabilities_rx, project, action_log, - file_read_times: HashMap::default(), imported: false, subagent_context: None, draft_prompt: None, @@ -1231,7 +1228,6 @@ impl Thread { updated_at: db_thread.updated_at, prompt_capabilities_tx, prompt_capabilities_rx, - file_read_times: HashMap::default(), imported: db_thread.imported, subagent_context: db_thread.subagent_context, draft_prompt: db_thread.draft_prompt, @@ -1436,6 +1432,9 @@ impl Thread { environment: Rc, cx: &mut Context, ) { + // Only update the agent location for the root thread, not for subagents. + let update_agent_location = self.parent_thread_id().is_none(); + let language_registry = self.project.read(cx).languages().clone(); self.add_tool(CopyPathTool::new(self.project.clone())); self.add_tool(CreateDirectoryTool::new(self.project.clone())); @@ -1463,9 +1462,9 @@ impl Thread { self.add_tool(NowTool); self.add_tool(OpenTool::new(self.project.clone())); self.add_tool(ReadFileTool::new( - cx.weak_entity(), self.project.clone(), self.action_log.clone(), + update_agent_location, )); self.add_tool(SaveFileTool::new(self.project.clone())); self.add_tool(RestoreFileFromDiskTool::new(self.project.clone())); diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index d8c380eba326d089b848563cca04557e903ba0f4..29b08ac09db4417123403fd3915b8575791b2a4e 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -305,13 +305,13 @@ impl AgentTool for EditFileTool { // Check if the file has been modified since the agent last read it if let Some(abs_path) = abs_path.as_ref() { - let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| { - let last_read = thread.file_read_times.get(abs_path).copied(); + let last_read_mtime = action_log.read_with(cx, |log, _| log.file_read_time(abs_path)); + let (current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.read_with(cx, |thread, cx| { let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime()); let dirty = buffer.read(cx).is_dirty(); let has_save = thread.has_tool(SaveFileTool::NAME); let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (last_read, current, dirty, has_save, has_restore) + (current, dirty, has_save, has_restore) })?; // Check for unsaved changes first - these indicate modifications we don't know about @@ -470,17 +470,6 @@ impl AgentTool for EditFileTool { log.buffer_edited(buffer.clone(), cx); }); - // Update the recorded read time after a successful edit so consecutive edits work - if let Some(abs_path) = abs_path.as_ref() { - if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { - buffer.file().and_then(|file| file.disk_state().mtime()) - }) { - self.thread.update(cx, |thread, _| { - thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime); - })?; - } - } - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let (new_text, unified_diff) = cx .background_spawn({ @@ -2212,14 +2201,18 @@ mod tests { let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); // Initially, file_read_times should be empty - let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty()); + let is_empty = action_log.read_with(cx, |action_log, _| { + action_log + .file_read_time(path!("/root/test.txt").as_ref()) + .is_none() + }); assert!(is_empty, "file_read_times should start empty"); // Create read tool let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), project.clone(), - action_log, + action_log.clone(), + true, )); // Read the file to record the read time @@ -2238,12 +2231,9 @@ mod tests { .unwrap(); // Verify that file_read_times now contains an entry for the file - let has_entry = thread.read_with(cx, |thread, _| { - thread.file_read_times.len() == 1 - && thread - .file_read_times - .keys() - .any(|path| path.ends_with("test.txt")) + let has_entry = action_log.read_with(cx, |log, _| { + log.file_read_time(path!("/root/test.txt").as_ref()) + .is_some() }); assert!( has_entry, @@ -2265,11 +2255,14 @@ mod tests { .await .unwrap(); - // Should still have exactly one entry - let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1); + // Should still have an entry after re-reading + let has_entry = action_log.read_with(cx, |log, _| { + log.file_read_time(path!("/root/test.txt").as_ref()) + .is_some() + }); assert!( - has_one_entry, - "file_read_times should still have one entry after re-reading" + has_entry, + "file_read_times should still have an entry after re-reading" ); } @@ -2309,11 +2302,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(EditFileTool::new( project.clone(), thread.downgrade(), @@ -2423,11 +2412,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(EditFileTool::new( project.clone(), thread.downgrade(), @@ -2534,11 +2519,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(EditFileTool::new( project.clone(), thread.downgrade(), diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index 8cfc16ddf6174a190ffe7cc11921dc204b05b79d..f7a75bc63a1c461b65c3a2e6f74f2c70e0ca15f6 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -2,7 +2,7 @@ use action_log::ActionLog; use agent_client_protocol::{self as acp, ToolCallUpdateFields}; use anyhow::{Context as _, Result, anyhow}; use futures::FutureExt as _; -use gpui::{App, Entity, SharedString, Task, WeakEntity}; +use gpui::{App, Entity, SharedString, Task}; use indoc::formatdoc; use language::Point; use language_model::{LanguageModelImage, LanguageModelToolResultContent}; @@ -21,7 +21,7 @@ use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_project_path, }; -use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, outline}; +use crate::{AgentTool, ToolCallEventStream, ToolInput, outline}; /// Reads the content of the given file in the project. /// @@ -56,21 +56,21 @@ pub struct ReadFileToolInput { } pub struct ReadFileTool { - thread: WeakEntity, project: Entity, action_log: Entity, + update_agent_location: bool, } impl ReadFileTool { pub fn new( - thread: WeakEntity, project: Entity, action_log: Entity, + update_agent_location: bool, ) -> Self { Self { - thread, project, action_log, + update_agent_location, } } } @@ -119,7 +119,6 @@ impl AgentTool for ReadFileTool { cx: &mut App, ) -> Task> { let project = self.project.clone(); - let thread = self.thread.clone(); let action_log = self.action_log.clone(); cx.spawn(async move |cx| { let input = input @@ -257,20 +256,6 @@ impl AgentTool for ReadFileTool { return Err(tool_content_err(format!("{file_path} not found"))); } - // Record the file read time and mtime - if let Some(mtime) = buffer.read_with(cx, |buffer, _| { - buffer.file().and_then(|file| file.disk_state().mtime()) - }) { - thread - .update(cx, |thread, _| { - thread.file_read_times.insert(abs_path.to_path_buf(), mtime); - }) - .ok(); - } - - - let update_agent_location = self.thread.read_with(cx, |thread, _cx| !thread.is_subagent()).unwrap_or_default(); - let mut anchor = None; // Check if specific line ranges are provided @@ -330,7 +315,7 @@ impl AgentTool for ReadFileTool { }; project.update(cx, |project, cx| { - if update_agent_location { + if self.update_agent_location { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), @@ -362,13 +347,10 @@ impl AgentTool for ReadFileTool { #[cfg(test)] mod test { use super::*; - use crate::{ContextServerRegistry, Templates, Thread}; use agent_client_protocol as acp; use fs::Fs as _; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; - use language_model::fake_provider::FakeLanguageModel; use project::{FakeFs, Project}; - use prompt_store::ProjectContext; use serde_json::json; use settings::SettingsStore; use std::path::PathBuf; @@ -383,20 +365,7 @@ mod test { fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); let (event_stream, _) = ToolCallEventStream::test(); let result = cx @@ -429,20 +398,7 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -476,20 +432,7 @@ mod test { let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(language::rust_lang()); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -569,20 +512,7 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); let result = cx .update(|cx| { let input = ReadFileToolInput { @@ -614,20 +544,7 @@ mod test { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); // start_line of 0 should be treated as 1 let result = cx @@ -757,20 +674,7 @@ mod test { let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); // Reading a file outside the project worktree should fail let result = cx @@ -965,20 +869,7 @@ mod test { let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let tool = Arc::new(ReadFileTool::new(project, action_log, true)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let read_task = cx.update(|cx| { @@ -1084,24 +975,7 @@ mod test { .await; let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log.clone(), - )); + let tool = Arc::new(ReadFileTool::new(project.clone(), action_log.clone(), true)); // Test reading allowed files in worktree1 let result = cx @@ -1288,24 +1162,7 @@ mod test { cx.executor().run_until_parked(); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { @@ -1364,24 +1221,7 @@ mod test { cx.executor().run_until_parked(); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { @@ -1444,24 +1284,7 @@ mod test { cx.executor().run_until_parked(); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let context_server_registry = - cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let tool = Arc::new(ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let tool = Arc::new(ReadFileTool::new(project.clone(), action_log, true)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let result = cx diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index 7e023d7d7e00c2eb13ea78467776816b13151796..62b96d569f34d65889abee6be803674dfa42e709 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -483,7 +483,12 @@ impl EditSession { .await .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; - ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; + let action_log = tool + .thread + .read_with(cx, |thread, _cx| thread.action_log().clone()) + .ok(); + + ensure_buffer_saved(&buffer, &abs_path, tool, action_log.as_ref(), cx)?; let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); event_stream.update_diff(diff.clone()); @@ -495,13 +500,9 @@ impl EditSession { } }) as Box); - tool.thread - .update(cx, |thread, cx| { - thread - .action_log() - .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)) - }) - .ok(); + if let Some(action_log) = &action_log { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + } let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let old_text = cx @@ -637,18 +638,6 @@ impl EditSession { log.buffer_edited(buffer.clone(), cx); }); - if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { - buffer.file().and_then(|file| file.disk_state().mtime()) - }) { - tool.thread - .update(cx, |thread, _| { - thread - .file_read_times - .insert(abs_path.to_path_buf(), new_mtime); - }) - .map_err(|e| StreamingEditFileToolOutput::error(e.to_string()))?; - } - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let (new_text, unified_diff) = cx .background_spawn({ @@ -1018,10 +1007,12 @@ fn ensure_buffer_saved( buffer: &Entity, abs_path: &PathBuf, tool: &StreamingEditFileTool, + action_log: Option<&Entity>, cx: &mut AsyncApp, ) -> Result<(), StreamingEditFileToolOutput> { - let check_result = tool.thread.update(cx, |thread, cx| { - let last_read = thread.file_read_times.get(abs_path).copied(); + let last_read_mtime = + action_log.and_then(|log| log.read_with(cx, |log, _| log.file_read_time(abs_path))); + let check_result = tool.thread.read_with(cx, |thread, cx| { let current = buffer .read(cx) .file() @@ -1029,12 +1020,10 @@ fn ensure_buffer_saved( let dirty = buffer.read(cx).is_dirty(); let has_save = thread.has_tool(SaveFileTool::NAME); let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (last_read, current, dirty, has_save, has_restore) + (current, dirty, has_save, has_restore) }); - let Ok((last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool)) = - check_result - else { + let Ok((current_mtime, is_dirty, has_save_tool, has_restore_tool)) = check_result else { return Ok(()); }; @@ -4006,11 +3995,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(StreamingEditFileTool::new( project.clone(), thread.downgrade(), @@ -4112,11 +4097,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(StreamingEditFileTool::new( project.clone(), thread.downgrade(), @@ -4225,11 +4206,7 @@ mod tests { let languages = project.read_with(cx, |project, _| project.languages().clone()); let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); - let read_tool = Arc::new(crate::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let read_tool = Arc::new(crate::ReadFileTool::new(project.clone(), action_log, true)); let edit_tool = Arc::new(StreamingEditFileTool::new( project.clone(), thread.downgrade(), diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 778f7292d2a032df6995169852deeecee6fa76a7..9b9fe9948ace530d7e55d2843952ca5c9efb3749 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2,15 +2,12 @@ /// The tests in this file assume that server_cx is running on Windows too. /// We neead to find a way to test Windows-Non-Windows interactions. use crate::headless_project::HeadlessProject; -use agent::{ - AgentTool, ReadFileTool, ReadFileToolInput, Templates, Thread, ToolCallEventStream, ToolInput, -}; +use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream, ToolInput}; use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; use git::repository::DiffType; -use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel}; -use prompt_store::ProjectContext; +use language_model::LanguageModelToolResultContent; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs}; @@ -2065,27 +2062,12 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu let action_log = cx.new(|_| action_log::ActionLog::new(project.clone())); - // Create a minimal thread for the ReadFileTool - let context_server_registry = - cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); - let model = Arc::new(FakeLanguageModel::default()); - let thread = cx.new(|cx| { - Thread::new( - project.clone(), - cx.new(|_cx| ProjectContext::default()), - context_server_registry, - Templates::new(), - Some(model), - cx, - ) - }); - let input = ReadFileToolInput { path: "project/b.txt".into(), start_line: None, end_line: None, }; - let read_tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + let read_tool = Arc::new(ReadFileTool::new(project, action_log, true)); let (event_stream, _) = ToolCallEventStream::test(); let exists_result = cx.update(|cx| { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index df673f0b4869af8fa55b0e83af10553df8afb4d8..8f005fa68b6accb5cf5686157bbb065e33bb1b0c 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -2032,32 +2032,9 @@ fn run_agent_thread_view_test( // Create the necessary entities for the ReadFileTool let action_log = cx.update(|cx| cx.new(|_| action_log::ActionLog::new(project.clone()))); - let context_server_registry = cx.update(|cx| { - cx.new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx)) - }); - let fake_model = Arc::new(language_model::fake_provider::FakeLanguageModel::default()); - let project_context = cx.update(|cx| cx.new(|_| prompt_store::ProjectContext::default())); - - // Create the agent Thread - let thread = cx.update(|cx| { - cx.new(|cx| { - agent::Thread::new( - project.clone(), - project_context, - context_server_registry, - agent::Templates::new(), - Some(fake_model), - cx, - ) - }) - }); // Create the ReadFileTool - let tool = Arc::new(agent::ReadFileTool::new( - thread.downgrade(), - project.clone(), - action_log, - )); + let tool = Arc::new(agent::ReadFileTool::new(project.clone(), action_log, true)); // Create a test event stream to capture tool output let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test(); From 932981fca14824cfbacafc900ca9bbe08c1b5a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Wed, 4 Mar 2026 19:59:08 +0800 Subject: [PATCH 38/74] editor: Prevent underlines from appearing in minimap (#48510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I noticed that the minimap seems to render underlines with the same thickness as the main editor, which looks a bit off. This becomes much more noticeable when enabling `semantic_token_rules` (due to the increased number of underlines): ```json "global_lsp_settings": { "semantic_token_rules": [ { "token_modifiers": ["mutable"], "underline": true, }, ], } ``` Looking at the existing code, I found that diagnostic underlines already check `editor_style.show_underlines` to ensure they are only displayed in the main editor. To maintain consistency, I applied the same filtering logic to `chunk_highlight` so that these underlines are no longer rendered in the minimap. Before: CleanShot 2026-02-06 at 02 28 31@2x After: CleanShot 2026-02-06 at 02 31 36@2x Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/editor/src/display_map.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b666557b90a3c1181404d8f09b1d50ff9f8402a9..658239db9a575d4d13c2a6f7877e20fcd6e47673 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1924,6 +1924,9 @@ impl DisplaySnapshot { color } }), + underline: chunk_highlight + .underline + .filter(|_| editor_style.show_underlines), ..chunk_highlight } }); From 0b58d3493612a15bac33665713c522ab6e139041 Mon Sep 17 00:00:00 2001 From: Dino Date: Wed, 4 Mar 2026 12:11:40 +0000 Subject: [PATCH 39/74] editor: Refactor excerpts removed event handling (#50695) Refactor the changes introduced in https://github.com/zed-industries/zed/pull/50525, in order to remove the `DisplayMap.clear_folded_buffer` method and update the editor's handling of `multi_buffer::Event::ExcerptsRemoved` to actually call `DisplayMap.unfold_buffers`, which correctly updates the `BlockMap` using its `BlockMapWriter`, ensuring that the block map is synced. Before you mark this PR as ready for review, make sure that you have: - [X] Added a solid test coverage and/or screenshots from doing manual testing - [X] Done a self-review taking into account security and performance aspects - [X] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- crates/editor/src/display_map.rs | 5 ----- crates/editor/src/editor.rs | 6 +++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 658239db9a575d4d13c2a6f7877e20fcd6e47673..00a48a9ab3d249850b9749d64267d8274e7eaa79 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1006,11 +1006,6 @@ impl DisplayMap { &self.block_map.folded_buffers } - #[instrument(skip_all)] - pub(super) fn clear_folded_buffer(&mut self, buffer_id: language::BufferId) { - self.block_map.folded_buffers.remove(&buffer_id); - } - #[instrument(skip_all)] pub fn insert_creases( &mut self, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5504305f86eb95dee000cec4099e366bbf86ffef..0d1238da21695738e4f6cedc54e172ad456c9bd6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -24147,9 +24147,13 @@ impl Editor { self.display_map.update(cx, |display_map, cx| { display_map.invalidate_semantic_highlights(*buffer_id); display_map.clear_lsp_folding_ranges(*buffer_id, cx); - display_map.clear_folded_buffer(*buffer_id); }); } + + self.display_map.update(cx, |display_map, cx| { + display_map.unfold_buffers(removed_buffer_ids.iter().copied(), cx); + }); + jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx); cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone(), From d5137d76c1f8b24f075768a8f7e247efed62a938 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:19:13 +0100 Subject: [PATCH 40/74] git: Add trusted worktree support to git integrations (#50649) This PR cleans up the git command spawning by wrapping everything in GitBinary instead to follow a builder/factory pattern. It also extends trusted workspace support to git commands. I also added a `clippy.toml` configuration to our git crate that warns against using `Command` struct to spawn git commands instead of going through `GitBinary`. This should help us maintain the factory pattern in the future Before you mark this PR as ready for review, make sure that you have: - [x] Added solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects Release Notes: - git: Add trusted workspace support for Zed's git integration --- crates/fs/src/fake_git_repo.rs | 12 +- crates/fs/src/fs.rs | 1 + crates/git/clippy.toml | 28 + crates/git/src/blame.rs | 24 +- crates/git/src/commit.rs | 17 +- crates/git/src/repository.rs | 661 +++++++++--------- crates/project/src/git_store.rs | 61 +- crates/project/tests/integration/git_store.rs | 205 ++++++ 8 files changed, 644 insertions(+), 365 deletions(-) create mode 100644 crates/git/clippy.toml diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 99295c69d45427c799e3d850d605f63d3950ee57..06ebea9157f97a0323297cd3ae142c4b306fe4ef 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -20,7 +20,7 @@ use ignore::gitignore::GitignoreBuilder; use parking_lot::Mutex; use rope::Rope; use smol::{channel::Sender, future::FutureExt as _}; -use std::{path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc, sync::atomic::AtomicBool}; use text::LineEnding; use util::{paths::PathStyle, rel_path::RelPath}; @@ -32,6 +32,7 @@ pub struct FakeGitRepository { pub(crate) dot_git_path: PathBuf, pub(crate) repository_dir_path: PathBuf, pub(crate) common_dir_path: PathBuf, + pub(crate) is_trusted: Arc, } #[derive(Debug, Clone)] @@ -1035,4 +1036,13 @@ impl GitRepository for FakeGitRepository { fn commit_data_reader(&self) -> Result { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } + + fn set_trusted(&self, trusted: bool) { + self.is_trusted + .store(trusted, std::sync::atomic::Ordering::Release); + } + + fn is_trusted(&self) -> bool { + self.is_trusted.load(std::sync::atomic::Ordering::Acquire) + } } diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 2db9e48a2e77bdb3e49fce0b16ea9b67ffaacbc0..0fde444171042eda859edcac7915c456ab91e265 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -2776,6 +2776,7 @@ impl Fs for FakeFs { repository_dir_path: repository_dir_path.to_owned(), common_dir_path: common_dir_path.to_owned(), checkpoints: Arc::default(), + is_trusted: Arc::default(), }) as _ }, ) diff --git a/crates/git/clippy.toml b/crates/git/clippy.toml new file mode 100644 index 0000000000000000000000000000000000000000..fb3926840493fd5981c1861e7cea96bd54b9647f --- /dev/null +++ b/crates/git/clippy.toml @@ -0,0 +1,28 @@ +allow-private-module-inception = true +avoid-breaking-exported-api = false +ignore-interior-mutability = [ + # Suppresses clippy::mutable_key_type, which is a false positive as the Eq + # and Hash impls do not use fields with interior mutability. + "agent_ui::context::AgentContextKey" +] +disallowed-methods = [ + { path = "std::process::Command::spawn", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::spawn" }, + { path = "std::process::Command::output", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::output" }, + { path = "std::process::Command::status", reason = "Spawning `std::process::Command` can block the current thread for an unknown duration", replacement = "smol::process::Command::status" }, + { path = "std::process::Command::stdin", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdin" }, + { path = "std::process::Command::stdout", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stdout" }, + { path = "std::process::Command::stderr", reason = "`smol::process::Command::from()` does not preserve stdio configuration", replacement = "smol::process::Command::stderr" }, + { path = "smol::Timer::after", reason = "smol::Timer introduces non-determinism in tests", replacement = "gpui::BackgroundExecutor::timer" }, + { path = "serde_json::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892. Use `serde_json::from_slice` instead." }, + { path = "serde_json_lenient::from_reader", reason = "Parsing from a buffer is much slower than first reading the buffer into a Vec/String, see https://github.com/serde-rs/json/issues/160#issuecomment-253446892, Use `serde_json_lenient::from_slice` instead." }, + { path = "cocoa::foundation::NSString::alloc", reason = "NSString must be autoreleased to avoid memory leaks. Use `ns_string()` helper instead." }, + { path = "smol::process::Command::new", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" }, + { path = "util::command::new_command", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" }, + { path = "util::command::Command::new", reason = "Git commands must go through `GitBinary::build_command` to ensure security flags like `-c core.fsmonitor=false` are always applied.", replacement = "GitBinary::build_command" }, +] +disallowed-types = [ + # { path = "std::collections::HashMap", replacement = "collections::HashMap" }, + # { path = "std::collections::HashSet", replacement = "collections::HashSet" }, + # { path = "indexmap::IndexSet", replacement = "collections::IndexSet" }, + # { path = "indexmap::IndexMap", replacement = "collections::IndexMap" }, +] \ No newline at end of file diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index 9dc184bf2ac253c8bc24f6203f13d6654ac2b64b..c44aea74051bb7c190a091703d6c60807fc4e27e 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -1,11 +1,11 @@ use crate::Oid; use crate::commit::get_messages; -use crate::repository::RepoPath; +use crate::repository::{GitBinary, RepoPath}; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use futures::AsyncWriteExt; use serde::{Deserialize, Serialize}; -use std::{ops::Range, path::Path}; +use std::ops::Range; use text::{LineEnding, Rope}; use time::OffsetDateTime; use time::UtcOffset; @@ -21,15 +21,13 @@ pub struct Blame { } impl Blame { - pub async fn for_path( - git_binary: &Path, - working_directory: &Path, + pub(crate) async fn for_path( + git: &GitBinary, path: &RepoPath, content: &Rope, line_ending: LineEnding, ) -> Result { - let output = - run_git_blame(git_binary, working_directory, path, content, line_ending).await?; + let output = run_git_blame(git, path, content, line_ending).await?; let mut entries = parse_git_blame(&output)?; entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start)); @@ -40,7 +38,7 @@ impl Blame { } let shas = unique_shas.into_iter().collect::>(); - let messages = get_messages(working_directory, &shas) + let messages = get_messages(git, &shas) .await .context("failed to get commit messages")?; @@ -52,8 +50,7 @@ const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD"; const GIT_BLAME_NO_PATH: &str = "fatal: no such path"; async fn run_git_blame( - git_binary: &Path, - working_directory: &Path, + git: &GitBinary, path: &RepoPath, contents: &Rope, line_ending: LineEnding, @@ -61,12 +58,7 @@ async fn run_git_blame( let mut child = { let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str()); let _enter = span.enter(); - util::command::new_command(git_binary) - .current_dir(working_directory) - .arg("blame") - .arg("--incremental") - .arg("--contents") - .arg("-") + git.build_command(["blame", "--incremental", "--contents", "-"]) .arg(path.as_unix_str()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 3f3526afc4ba8fa146592684a6d3acfc44ce7e73..46e050ce155fc049a670fdfa26101eb729b34352 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -1,11 +1,11 @@ use crate::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, parse_git_remote_url, - status::StatusCode, + repository::GitBinary, status::StatusCode, }; use anyhow::{Context as _, Result}; use collections::HashMap; use gpui::SharedString; -use std::{path::Path, sync::Arc}; +use std::sync::Arc; #[derive(Clone, Debug, Default)] pub struct ParsedCommitMessage { @@ -48,7 +48,7 @@ impl ParsedCommitMessage { } } -pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result> { +pub(crate) async fn get_messages(git: &GitBinary, shas: &[Oid]) -> Result> { if shas.is_empty() { return Ok(HashMap::default()); } @@ -63,12 +63,12 @@ pub async fn get_messages(working_directory: &Path, shas: &[Oid]) -> Result Result>()) } -async fn get_messages_impl(working_directory: &Path, shas: &[Oid]) -> Result> { +async fn get_messages_impl(git: &GitBinary, shas: &[Oid]) -> Result> { const MARKER: &str = ""; - let output = util::command::new_command("git") - .current_dir(working_directory) - .arg("show") + let output = git + .build_command(["show"]) .arg("-s") .arg(format!("--format=%B{}", MARKER)) .args(shas.iter().map(ToString::to_string)) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 6dba1400dffe1fd00844dd7241f39f48a7a759a6..f5a856325cc80071f2c8ef500e7b07aa24035f59 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -21,6 +21,7 @@ use text::LineEnding; use std::collections::HashSet; use std::ffi::{OsStr, OsString}; +use std::sync::atomic::AtomicBool; use std::process::ExitStatus; use std::str::FromStr; @@ -961,6 +962,9 @@ pub trait GitRepository: Send + Sync { ) -> BoxFuture<'_, Result<()>>; fn commit_data_reader(&self) -> Result; + + fn set_trusted(&self, trusted: bool); + fn is_trusted(&self) -> bool; } pub enum DiffType { @@ -987,6 +991,7 @@ pub struct RealGitRepository { pub any_git_binary_path: PathBuf, any_git_binary_help_output: Arc>>, executor: BackgroundExecutor, + is_trusted: Arc, } impl RealGitRepository { @@ -1005,6 +1010,7 @@ impl RealGitRepository { any_git_binary_path, executor, any_git_binary_help_output: Arc::new(Mutex::new(None)), + is_trusted: Arc::new(AtomicBool::new(false)), }) } @@ -1016,20 +1022,24 @@ impl RealGitRepository { .map(Path::to_path_buf) } + fn git_binary(&self) -> Result { + Ok(GitBinary::new( + self.any_git_binary_path.clone(), + self.working_directory() + .with_context(|| "Can't run git commands without a working directory")?, + self.executor.clone(), + self.is_trusted(), + )) + } + async fn any_git_binary_help_output(&self) -> SharedString { if let Some(output) = self.any_git_binary_help_output.lock().clone() { return output; } - let git_binary_path = self.any_git_binary_path.clone(); - let executor = self.executor.clone(); - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); let output: SharedString = self .executor - .spawn(async move { - GitBinary::new(git_binary_path, working_directory?, executor) - .run(["help", "-a"]) - .await - }) + .spawn(async move { git_binary?.run(["help", "-a"]).await }) .await .unwrap_or_default() .into(); @@ -1072,6 +1082,7 @@ pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter { git_binary_path.unwrap_or(PathBuf::from("git")), paths::home_dir().clone(), cx.background_executor().clone(), + true, ); cx.background_spawn(async move { @@ -1103,14 +1114,12 @@ impl GitRepository for RealGitRepository { } fn show(&self, commit: String) -> BoxFuture<'_, Result> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let output = new_command(git_binary_path) - .current_dir(&working_directory) - .args([ + let git = git_binary?; + let output = git + .build_command([ "--no-optional-locks", "show", "--no-patch", @@ -1141,15 +1150,14 @@ impl GitRepository for RealGitRepository { } fn load_commit(&self, commit: String, cx: AsyncApp) -> BoxFuture<'_, Result> { - let Some(working_directory) = self.repository.lock().workdir().map(ToOwned::to_owned) - else { + if self.repository.lock().workdir().is_none() { return future::ready(Err(anyhow!("no working directory"))).boxed(); - }; - let git_binary_path = self.any_git_binary_path.clone(); + } + let git_binary = self.git_binary(); cx.background_spawn(async move { - let show_output = util::command::new_command(&git_binary_path) - .current_dir(&working_directory) - .args([ + let git = git_binary?; + let show_output = git + .build_command([ "--no-optional-locks", "show", "--format=", @@ -1170,9 +1178,8 @@ impl GitRepository for RealGitRepository { let changes = parse_git_diff_name_status(&show_stdout); let parent_sha = format!("{}^", commit); - let mut cat_file_process = util::command::new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + let mut cat_file_process = git + .build_command(["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1279,18 +1286,17 @@ impl GitRepository for RealGitRepository { mode: ResetMode, env: Arc>, ) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); async move { - let working_directory = self.working_directory(); - let mode_flag = match mode { ResetMode::Mixed => "--mixed", ResetMode::Soft => "--soft", }; - let output = new_command(&self.any_git_binary_path) + let git = git_binary?; + let output = git + .build_command(["reset", mode_flag, &commit]) .envs(env.iter()) - .current_dir(&working_directory?) - .args(["reset", mode_flag, &commit]) .output() .await?; anyhow::ensure!( @@ -1309,17 +1315,16 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); async move { if paths.is_empty() { return Ok(()); } - let output = new_command(&git_binary_path) - .current_dir(&working_directory?) + let git = git_binary?; + let output = git + .build_command(["checkout", &commit, "--"]) .envs(env.iter()) - .args(["checkout", &commit, "--"]) .args(paths.iter().map(|path| path.as_unix_str())) .output() .await?; @@ -1414,18 +1419,16 @@ impl GitRepository for RealGitRepository { env: Arc>, is_executable: bool, ) -> BoxFuture<'_, anyhow::Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; + let git = git_binary?; let mode = if is_executable { "100755" } else { "100644" }; if let Some(content) = content { - let mut child = new_command(&git_binary_path) - .current_dir(&working_directory) + let mut child = git + .build_command(["hash-object", "-w", "--stdin"]) .envs(env.iter()) - .args(["hash-object", "-w", "--stdin"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn()?; @@ -1438,10 +1441,9 @@ impl GitRepository for RealGitRepository { log::debug!("indexing SHA: {sha}, path {path:?}"); - let output = new_command(&git_binary_path) - .current_dir(&working_directory) + let output = git + .build_command(["update-index", "--add", "--cacheinfo", mode, sha]) .envs(env.iter()) - .args(["update-index", "--add", "--cacheinfo", mode, sha]) .arg(path.as_unix_str()) .output() .await?; @@ -1453,10 +1455,9 @@ impl GitRepository for RealGitRepository { ); } else { log::debug!("removing path {path:?} from the index"); - let output = new_command(&git_binary_path) - .current_dir(&working_directory) + let output = git + .build_command(["update-index", "--force-remove"]) .envs(env.iter()) - .args(["update-index", "--force-remove"]) .arg(path.as_unix_str()) .output() .await?; @@ -1485,14 +1486,12 @@ impl GitRepository for RealGitRepository { } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let mut process = new_command(&git_binary_path) - .current_dir(&working_directory) - .args([ + let git = git_binary?; + let mut process = git + .build_command([ "--no-optional-locks", "cat-file", "--batch-check=%(objectname)", @@ -1545,19 +1544,14 @@ impl GitRepository for RealGitRepository { } fn status(&self, path_prefixes: &[RepoPath]) -> Task> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = match self.working_directory() { - Ok(working_directory) => working_directory, + let git = match self.git_binary() { + Ok(git) => git, Err(e) => return Task::ready(Err(e)), }; let args = git_status_args(path_prefixes); log::debug!("Checking for git status in {path_prefixes:?}"); self.executor.spawn(async move { - let output = new_command(&git_binary_path) - .current_dir(working_directory) - .args(args) - .output() - .await?; + let output = git.build_command(args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1569,9 +1563,8 @@ impl GitRepository for RealGitRepository { } fn diff_tree(&self, request: DiffTreeType) -> BoxFuture<'_, Result> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = match self.working_directory() { - Ok(working_directory) => working_directory, + let git = match self.git_binary() { + Ok(git) => git, Err(e) => return Task::ready(Err(e)).boxed(), }; @@ -1596,11 +1589,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let output = new_command(&git_binary_path) - .current_dir(working_directory) - .args(args) - .output() - .await?; + let output = git.build_command(args).output().await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); stdout.parse() @@ -1613,13 +1602,12 @@ impl GitRepository for RealGitRepository { } fn stash_entries(&self) -> BoxFuture<'_, Result> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let output = new_command(&git_binary_path) - .current_dir(working_directory?) - .args(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) + let git = git_binary?; + let output = git + .build_command(&["stash", "list", "--pretty=format:%gd%x00%H%x00%ct%x00%s"]) .output() .await?; if output.status.success() { @@ -1634,8 +1622,7 @@ impl GitRepository for RealGitRepository { } fn branches(&self) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { let fields = [ @@ -1657,12 +1644,8 @@ impl GitRepository for RealGitRepository { "--format", &fields, ]; - let working_directory = working_directory?; - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(args) - .output() - .await?; + let git = git_binary?; + let output = git.build_command(args).output().await?; anyhow::ensure!( output.status.success(), @@ -1676,11 +1659,7 @@ impl GitRepository for RealGitRepository { if branches.is_empty() { let args = vec!["symbolic-ref", "--quiet", "HEAD"]; - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(args) - .output() - .await?; + let output = git.build_command(args).output().await?; // git symbolic-ref returns a non-0 exit code if HEAD points // to something other than a branch @@ -1702,13 +1681,12 @@ impl GitRepository for RealGitRepository { } fn worktrees(&self) -> BoxFuture<'_, Result>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let output = new_command(&git_binary_path) - .current_dir(working_directory?) - .args(&["--no-optional-locks", "worktree", "list", "--porcelain"]) + let git = git_binary?; + let output = git + .build_command(&["--no-optional-locks", "worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { @@ -1728,8 +1706,7 @@ impl GitRepository for RealGitRepository { directory: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); let final_path = directory.join(&name); let mut args = vec![ OsString::from("--no-optional-locks"), @@ -1749,11 +1726,8 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?; - let output = new_command(&git_binary_path) - .current_dir(working_directory?) - .args(args) - .output() - .await?; + let git = git_binary?; + let output = git.build_command(args).output().await?; if output.status.success() { Ok(()) } else { @@ -1765,9 +1739,7 @@ impl GitRepository for RealGitRepository { } fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { @@ -1781,18 +1753,14 @@ impl GitRepository for RealGitRepository { } args.push("--".into()); args.push(path.as_os_str().into()); - GitBinary::new(git_binary_path, working_directory?, executor) - .run(args) - .await?; + git_binary?.run(args).await?; anyhow::Ok(()) }) .boxed() } fn rename_worktree(&self, old_path: PathBuf, new_path: PathBuf) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { @@ -1804,9 +1772,7 @@ impl GitRepository for RealGitRepository { old_path.as_os_str().into(), new_path.as_os_str().into(), ]; - GitBinary::new(git_binary_path, working_directory?, executor) - .run(args) - .await?; + git_binary?.run(args).await?; anyhow::Ok(()) }) .boxed() @@ -1814,9 +1780,7 @@ impl GitRepository for RealGitRepository { fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { let repo = self.repository.clone(); - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); let branch = self.executor.spawn(async move { let repo = repo.lock(); let branch = if let Ok(branch) = repo.find_branch(&name, BranchType::Local) { @@ -1851,9 +1815,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let branch = branch.await?; - GitBinary::new(git_binary_path, working_directory?, executor) - .run(&["checkout", &branch]) - .await?; + git_binary?.run(&["checkout", &branch]).await?; anyhow::Ok(()) }) .boxed() @@ -1864,9 +1826,7 @@ impl GitRepository for RealGitRepository { name: String, base_branch: Option, ) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { @@ -1877,22 +1837,18 @@ impl GitRepository for RealGitRepository { args.push(&base_branch_str); } - GitBinary::new(git_binary_path, working_directory?, executor) - .run(&args) - .await?; + git_binary?.run(&args).await?; anyhow::Ok(()) }) .boxed() } fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - GitBinary::new(git_binary_path, working_directory?, executor) + git_binary? .run(&["branch", "-m", &branch, &new_name]) .await?; anyhow::Ok(()) @@ -1901,15 +1857,11 @@ impl GitRepository for RealGitRepository { } fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - GitBinary::new(git_binary_path, working_directory?, executor) - .run(&["branch", "-d", &name]) - .await?; + git_binary?.run(&["branch", "-d", &name]).await?; anyhow::Ok(()) }) .boxed() @@ -1921,20 +1873,11 @@ impl GitRepository for RealGitRepository { content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - let executor = self.executor.clone(); + let git = self.git_binary(); - executor + self.executor .spawn(async move { - crate::blame::Blame::for_path( - &git_binary_path, - &working_directory?, - &path, - &content, - line_ending, - ) - .await + crate::blame::Blame::for_path(&git?, &path, &content, line_ending).await }) .boxed() } @@ -1949,11 +1892,10 @@ impl GitRepository for RealGitRepository { skip: usize, limit: Option, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; + let git = git_binary?; // Use a unique delimiter with a hardcoded UUID to separate commits // This essentially eliminates any chance of encountering the delimiter in actual commit data let commit_delimiter = @@ -1981,9 +1923,8 @@ impl GitRepository for RealGitRepository { args.push("--"); - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(&args) + let output = git + .build_command(&args) .arg(path.as_unix_str()) .output() .await?; @@ -2028,30 +1969,17 @@ impl GitRepository for RealGitRepository { } fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; + let git = git_binary?; let output = match diff { DiffType::HeadToIndex => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["diff", "--staged"]) - .output() - .await? - } - DiffType::HeadToWorktree => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["diff"]) - .output() - .await? + git.build_command(["diff", "--staged"]).output().await? } + DiffType::HeadToWorktree => git.build_command(["diff"]).output().await?, DiffType::MergeBase { base_ref } => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["diff", "--merge-base", base_ref.as_ref()]) + git.build_command(["diff", "--merge-base", base_ref.as_ref()]) .output() .await? } @@ -2071,38 +1999,29 @@ impl GitRepository for RealGitRepository { &self, diff: DiffType, ) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; + let git = git_binary?; let output = match diff { DiffType::HeadToIndex => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["diff", "--numstat", "--staged"]) + git.build_command(["diff", "--numstat", "--staged"]) .output() .await? } DiffType::HeadToWorktree => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["diff", "--numstat"]) - .output() - .await? + git.build_command(["diff", "--numstat"]).output().await? } DiffType::MergeBase { base_ref } => { - new_command(&git_binary_path) - .current_dir(&working_directory) - .args([ - "diff", - "--numstat", - "--merge-base", - base_ref.as_ref(), - "HEAD", - ]) - .output() - .await? + git.build_command([ + "diff", + "--numstat", + "--merge-base", + base_ref.as_ref(), + "HEAD", + ]) + .output() + .await? } }; @@ -2123,15 +2042,14 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { if !paths.is_empty() { - let output = new_command(&git_binary_path) - .current_dir(&working_directory?) + let git = git_binary?; + let output = git + .build_command(["update-index", "--add", "--remove", "--"]) .envs(env.iter()) - .args(["update-index", "--add", "--remove", "--"]) .args(paths.iter().map(|p| p.as_unix_str())) .output() .await?; @@ -2151,16 +2069,15 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { if !paths.is_empty() { - let output = new_command(&git_binary_path) - .current_dir(&working_directory?) + let git = git_binary?; + let output = git + .build_command(["reset", "--quiet", "--"]) .envs(env.iter()) - .args(["reset", "--quiet", "--"]) .args(paths.iter().map(|p| p.as_std_path())) .output() .await?; @@ -2181,19 +2098,16 @@ impl GitRepository for RealGitRepository { paths: Vec, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let mut cmd = new_command(&git_binary_path); - cmd.current_dir(&working_directory?) + let git = git_binary?; + let output = git + .build_command(["stash", "push", "--quiet", "--include-untracked"]) .envs(env.iter()) - .args(["stash", "push", "--quiet"]) - .arg("--include-untracked"); - - cmd.args(paths.iter().map(|p| p.as_unix_str())); - - let output = cmd.output().await?; + .args(paths.iter().map(|p| p.as_unix_str())) + .output() + .await?; anyhow::ensure!( output.status.success(), @@ -2210,20 +2124,15 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let mut cmd = new_command(git_binary_path); + let git = git_binary?; let mut args = vec!["stash".to_string(), "pop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } - cmd.current_dir(&working_directory?) - .envs(env.iter()) - .args(args); - - let output = cmd.output().await?; + let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), @@ -2240,20 +2149,15 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let mut cmd = new_command(git_binary_path); + let git = git_binary?; let mut args = vec!["stash".to_string(), "apply".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } - cmd.current_dir(&working_directory?) - .envs(env.iter()) - .args(args); - - let output = cmd.output().await?; + let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), @@ -2270,20 +2174,15 @@ impl GitRepository for RealGitRepository { index: Option, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let mut cmd = new_command(git_binary_path); + let git = git_binary?; let mut args = vec!["stash".to_string(), "drop".to_string()]; if let Some(index) = index { args.push(format!("stash@{{{}}}", index)); } - cmd.current_dir(&working_directory?) - .envs(env.iter()) - .args(args); - - let output = cmd.output().await?; + let output = git.build_command(&args).envs(env.iter()).output().await?; anyhow::ensure!( output.status.success(), @@ -2303,16 +2202,14 @@ impl GitRepository for RealGitRepository { ask_pass: AskPassDelegate, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); let executor = self.executor.clone(); // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { - let mut cmd = new_command(git_binary_path); - cmd.current_dir(&working_directory?) - .envs(env.iter()) - .args(["commit", "--quiet", "-m"]) + let git = git_binary?; + let mut cmd = git.build_command(["commit", "--quiet", "-m"]); + cmd.envs(env.iter()) .arg(&message.to_string()) .arg("--cleanup=strip") .arg("--no-verify") @@ -2351,16 +2248,21 @@ impl GitRepository for RealGitRepository { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); + let is_trusted = self.is_trusted(); // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't push")?; let working_directory = working_directory?; - let mut command = new_command(git_binary_path); + let git = GitBinary::new( + git_binary_path, + working_directory, + executor.clone(), + is_trusted, + ); + let mut command = git.build_command(["push"]); command .envs(env.iter()) - .current_dir(&working_directory) - .args(["push"]) .args(options.map(|option| match option { PushOptions::SetUpstream => "--set-upstream", PushOptions::Force => "--force-with-lease", @@ -2388,15 +2290,20 @@ impl GitRepository for RealGitRepository { let working_directory = self.working_directory(); let executor = cx.background_executor().clone(); let git_binary_path = self.system_git_binary_path.clone(); + let is_trusted = self.is_trusted(); // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't pull")?; - let mut command = new_command(git_binary_path); - command - .envs(env.iter()) - .current_dir(&working_directory?) - .arg("pull"); + let working_directory = working_directory?; + let git = GitBinary::new( + git_binary_path, + working_directory, + executor.clone(), + is_trusted, + ); + let mut command = git.build_command(["pull"]); + command.envs(env.iter()); if rebase { command.arg("--rebase"); @@ -2424,15 +2331,21 @@ impl GitRepository for RealGitRepository { let remote_name = format!("{}", fetch_options); let git_binary_path = self.system_git_binary_path.clone(); let executor = cx.background_executor().clone(); + let is_trusted = self.is_trusted(); // Note: Do not spawn this command on the background thread, it might pop open the credential helper // which we want to block on. async move { let git_binary_path = git_binary_path.context("git not found on $PATH, can't fetch")?; - let mut command = new_command(git_binary_path); + let working_directory = working_directory?; + let git = GitBinary::new( + git_binary_path, + working_directory, + executor.clone(), + is_trusted, + ); + let mut command = git.build_command(["fetch", &remote_name]); command .envs(env.iter()) - .current_dir(&working_directory?) - .args(["fetch", &remote_name]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -2442,14 +2355,12 @@ impl GitRepository for RealGitRepository { } fn get_push_remote(&self, branch: String) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["rev-parse", "--abbrev-ref"]) + let git = git_binary?; + let output = git + .build_command(["rev-parse", "--abbrev-ref"]) .arg(format!("{branch}@{{push}}")) .output() .await?; @@ -2469,14 +2380,12 @@ impl GitRepository for RealGitRepository { } fn get_branch_remote(&self, branch: String) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["config", "--get"]) + let git = git_binary?; + let output = git + .build_command(["config", "--get"]) .arg(format!("branch.{branch}.remote")) .output() .await?; @@ -2493,16 +2402,11 @@ impl GitRepository for RealGitRepository { } fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(["remote", "-v"]) - .output() - .await?; + let git = git_binary?; + let output = git.build_command(["remote", "-v"]).output().await?; anyhow::ensure!( output.status.success(), @@ -2551,17 +2455,12 @@ impl GitRepository for RealGitRepository { } fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; + let git = git_binary?; let git_cmd = async |args: &[&str]| -> Result { - let output = new_command(&git_binary_path) - .current_dir(&working_directory) - .args(args) - .output() - .await?; + let output = git.build_command(args).output().await?; anyhow::ensure!( output.status.success(), String::from_utf8_lossy(&output.stderr).to_string() @@ -2610,14 +2509,10 @@ impl GitRepository for RealGitRepository { } fn checkpoint(&self) -> BoxFuture<'static, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let mut git = GitBinary::new(git_binary_path, working_directory.clone(), executor) - .envs(checkpoint_author_envs()); + let mut git = git_binary?.envs(checkpoint_author_envs()); git.with_temp_index(async |git| { let head_sha = git.run(&["rev-parse", "HEAD"]).await.ok(); let mut excludes = exclude_files(git).await?; @@ -2643,15 +2538,10 @@ impl GitRepository for RealGitRepository { } fn restore_checkpoint(&self, checkpoint: GitRepositoryCheckpoint) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = git_binary?; git.run(&[ "restore", "--source", @@ -2682,14 +2572,10 @@ impl GitRepository for RealGitRepository { left: GitRepositoryCheckpoint, right: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = git_binary?; let result = git .run(&[ "diff-tree", @@ -2720,14 +2606,10 @@ impl GitRepository for RealGitRepository { base_checkpoint: GitRepositoryCheckpoint, target_checkpoint: GitRepositoryCheckpoint, ) -> BoxFuture<'_, Result> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = git_binary?; git.run(&[ "diff", "--find-renames", @@ -2744,14 +2626,10 @@ impl GitRepository for RealGitRepository { &self, include_remote_name: bool, ) -> BoxFuture<'_, Result>> { - let working_directory = self.working_directory(); - let git_binary_path = self.any_git_binary_path.clone(); - - let executor = self.executor.clone(); + let git_binary = self.git_binary(); self.executor .spawn(async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = git_binary?; let strip_prefix = if include_remote_name { "refs/remotes/" @@ -2801,15 +2679,19 @@ impl GitRepository for RealGitRepository { hook: RunHook, env: Arc>, ) -> BoxFuture<'_, Result<()>> { - let working_directory = self.working_directory(); + let git_binary = self.git_binary(); let repository = self.repository.clone(); - let git_binary_path = self.any_git_binary_path.clone(); - let executor = self.executor.clone(); let help_output = self.any_git_binary_help_output(); // Note: Do not spawn these commands on the background thread, as this causes some git hooks to hang. async move { - let working_directory = working_directory?; + let git = git_binary?; + + if !git.is_trusted { + bail!("Can't run git commit hooks in restrictive workspace"); + } + + let working_directory = git.working_directory.clone(); if !help_output .await .lines() @@ -2817,6 +2699,7 @@ impl GitRepository for RealGitRepository { { let hook_abs_path = repository.lock().path().join("hooks").join(hook.as_str()); if hook_abs_path.is_file() { + #[allow(clippy::disallowed_methods)] let output = new_command(&hook_abs_path) .envs(env.iter()) .current_dir(&working_directory) @@ -2836,8 +2719,7 @@ impl GitRepository for RealGitRepository { return Ok(()); } - let git = GitBinary::new(git_binary_path, working_directory, executor) - .envs(HashMap::clone(&env)); + let git = git.envs(HashMap::clone(&env)); git.run(&["hook", "run", "--ignore-missing", hook.as_str()]) .await?; Ok(()) @@ -2851,13 +2733,10 @@ impl GitRepository for RealGitRepository { log_order: LogOrder, request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>> { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self.working_directory(); - let executor = self.executor.clone(); + let git_binary = self.git_binary(); async move { - let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = git_binary?; let mut command = git.build_command([ "log", @@ -2911,19 +2790,12 @@ impl GitRepository for RealGitRepository { } fn commit_data_reader(&self) -> Result { - let git_binary_path = self.any_git_binary_path.clone(); - let working_directory = self - .working_directory() - .map_err(|_| anyhow!("no working directory"))?; - let executor = self.executor.clone(); + let git_binary = self.git_binary()?; let (request_tx, request_rx) = smol::channel::bounded::(64); let task = self.executor.spawn(async move { - if let Err(error) = - run_commit_data_reader(git_binary_path, working_directory, executor, request_rx) - .await - { + if let Err(error) = run_commit_data_reader(git_binary, request_rx).await { log::error!("commit data reader failed: {error:?}"); } }); @@ -2933,15 +2805,21 @@ impl GitRepository for RealGitRepository { _task: task, }) } + + fn set_trusted(&self, trusted: bool) { + self.is_trusted + .store(trusted, std::sync::atomic::Ordering::Release); + } + + fn is_trusted(&self) -> bool { + self.is_trusted.load(std::sync::atomic::Ordering::Acquire) + } } async fn run_commit_data_reader( - git_binary_path: PathBuf, - working_directory: PathBuf, - executor: BackgroundExecutor, + git: GitBinary, request_rx: smol::channel::Receiver, ) -> Result<()> { - let git = GitBinary::new(git_binary_path, working_directory, executor); let mut process = git .build_command(["--no-optional-locks", "cat-file", "--batch"]) .stdin(Stdio::piped()) @@ -3117,19 +2995,21 @@ async fn exclude_files(git: &GitBinary) -> Result { Ok(excludes) } -struct GitBinary { +pub(crate) struct GitBinary { git_binary_path: PathBuf, working_directory: PathBuf, executor: BackgroundExecutor, index_file_path: Option, envs: HashMap, + is_trusted: bool, } impl GitBinary { - fn new( + pub(crate) fn new( git_binary_path: PathBuf, working_directory: PathBuf, executor: BackgroundExecutor, + is_trusted: bool, ) -> Self { Self { git_binary_path, @@ -3137,6 +3017,7 @@ impl GitBinary { executor, index_file_path: None, envs: HashMap::default(), + is_trusted, } } @@ -3241,12 +3122,20 @@ impl GitBinary { Ok(String::from_utf8(output.stdout)?) } - fn build_command(&self, args: impl IntoIterator) -> util::command::Command + #[allow(clippy::disallowed_methods)] + pub(crate) fn build_command( + &self, + args: impl IntoIterator, + ) -> util::command::Command where S: AsRef, { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); + command.args(["-c", "core.fsmonitor=false"]); + if !self.is_trusted { + command.args(["-c", "core.hooksPath=/dev/null"]); + } command.args(args); if let Some(index_file_path) = self.index_file_path.as_ref() { command.env("GIT_INDEX_FILE", index_file_path); @@ -3506,6 +3395,102 @@ mod tests { } } + #[gpui::test] + async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let dir = tempfile::tempdir().unwrap(); + let git = GitBinary::new( + PathBuf::from("git"), + dir.path().to_path_buf(), + cx.executor(), + false, + ); + let output = git + .build_command(["version"]) + .output() + .await + .expect("git version should succeed"); + assert!(output.status.success()); + + let git = GitBinary::new( + PathBuf::from("git"), + dir.path().to_path_buf(), + cx.executor(), + false, + ); + let output = git + .build_command(["config", "--get", "core.fsmonitor"]) + .output() + .await + .expect("git config should run"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.trim(), + "false", + "fsmonitor should be disabled for untrusted repos" + ); + + git2::Repository::init(dir.path()).unwrap(); + let git = GitBinary::new( + PathBuf::from("git"), + dir.path().to_path_buf(), + cx.executor(), + false, + ); + let output = git + .build_command(["config", "--get", "core.hooksPath"]) + .output() + .await + .expect("git config should run"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.trim(), + "/dev/null", + "hooksPath should be /dev/null for untrusted repos" + ); + } + + #[gpui::test] + async fn test_build_command_trusted_only_disables_fsmonitor(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let dir = tempfile::tempdir().unwrap(); + git2::Repository::init(dir.path()).unwrap(); + + let git = GitBinary::new( + PathBuf::from("git"), + dir.path().to_path_buf(), + cx.executor(), + true, + ); + let output = git + .build_command(["config", "--get", "core.fsmonitor"]) + .output() + .await + .expect("git config should run"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.trim(), + "false", + "fsmonitor should be disabled even for trusted repos" + ); + + let git = GitBinary::new( + PathBuf::from("git"), + dir.path().to_path_buf(), + cx.executor(), + true, + ); + let output = git + .build_command(["config", "--get", "core.hooksPath"]) + .output() + .await + .expect("git config should run"); + assert!( + !output.status.success(), + "hooksPath should NOT be overridden for trusted repos" + ); + } + #[gpui::test] async fn test_checkpoint_basic(cx: &mut TestAppContext) { disable_git_global_config(); @@ -4398,7 +4383,7 @@ mod tests { .spawn(async move { let git_binary_path = git_binary_path.clone(); let working_directory = working_directory?; - let git = GitBinary::new(git_binary_path, working_directory, executor); + let git = GitBinary::new(git_binary_path, working_directory, executor, true); git.run(&["gc", "--prune"]).await?; Ok(()) }) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 487e7f5f9699382ce4930141f7a0c7c50a1d23b8..b03c7d69ab05daf94254a9d47cb2ae23da3043d1 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -6,6 +6,9 @@ pub mod pending_op; use crate::{ ProjectEnvironment, ProjectItem, ProjectPath, buffer_store::{BufferStore, BufferStoreEvent}, + trusted_worktrees::{ + PathTrust, TrustedWorktrees, TrustedWorktreesEvent, TrustedWorktreesStore, + }, worktree_store::{WorktreeStore, WorktreeStoreEvent}, }; use anyhow::{Context as _, Result, anyhow, bail}; @@ -354,6 +357,7 @@ impl LocalRepositoryState { dot_git_abs_path: Arc, project_environment: WeakEntity, fs: Arc, + is_trusted: bool, cx: &mut AsyncApp, ) -> anyhow::Result { let environment = project_environment @@ -381,6 +385,7 @@ impl LocalRepositoryState { } }) .await?; + backend.set_trusted(is_trusted); Ok(LocalRepositoryState { backend, environment: Arc::new(environment), @@ -495,11 +500,15 @@ impl GitStore { state: GitStoreState, cx: &mut Context, ) -> Self { - let _subscriptions = vec![ + let mut _subscriptions = vec![ cx.subscribe(&worktree_store, Self::on_worktree_store_event), cx.subscribe(&buffer_store, Self::on_buffer_store_event), ]; + if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) { + _subscriptions.push(cx.subscribe(&trusted_worktrees, Self::on_trusted_worktrees_event)); + } + GitStore { state, buffer_store, @@ -1517,6 +1526,13 @@ impl GitStore { let original_repo_abs_path: Arc = git::repository::original_repo_path_from_common_dir(common_dir_abs_path).into(); let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release)); + let is_trusted = TrustedWorktrees::try_get_global(cx) + .map(|trusted_worktrees| { + trusted_worktrees.update(cx, |trusted_worktrees, cx| { + trusted_worktrees.can_trust(&self.worktree_store, worktree_id, cx) + }) + }) + .unwrap_or(false); let git_store = cx.weak_entity(); let repo = cx.new(|cx| { let mut repo = Repository::local( @@ -1526,6 +1542,7 @@ impl GitStore { dot_git_abs_path.clone(), project_environment.downgrade(), fs.clone(), + is_trusted, git_store, cx, ); @@ -1566,6 +1583,39 @@ impl GitStore { } } + fn on_trusted_worktrees_event( + &mut self, + _: Entity, + event: &TrustedWorktreesEvent, + cx: &mut Context, + ) { + if !matches!(self.state, GitStoreState::Local { .. }) { + return; + } + + let (is_trusted, event_paths) = match event { + TrustedWorktreesEvent::Trusted(_, trusted_paths) => (true, trusted_paths), + TrustedWorktreesEvent::Restricted(_, restricted_paths) => (false, restricted_paths), + }; + + for (repo_id, worktree_ids) in &self.worktree_ids { + if worktree_ids + .iter() + .any(|worktree_id| event_paths.contains(&PathTrust::Worktree(*worktree_id))) + { + if let Some(repo) = self.repositories.get(repo_id) { + let repository_state = repo.read(cx).repository_state.clone(); + cx.background_spawn(async move { + if let Ok(RepositoryState::Local(state)) = repository_state.await { + state.backend.set_trusted(is_trusted); + } + }) + .detach(); + } + } + } + } + fn on_buffer_store_event( &mut self, _: Entity, @@ -3763,6 +3813,13 @@ impl MergeDetails { } impl Repository { + pub fn is_trusted(&self) -> bool { + match self.repository_state.peek() { + Some(Ok(RepositoryState::Local(state))) => state.backend.is_trusted(), + _ => false, + } + } + pub fn snapshot(&self) -> RepositorySnapshot { self.snapshot.clone() } @@ -3788,6 +3845,7 @@ impl Repository { dot_git_abs_path: Arc, project_environment: WeakEntity, fs: Arc, + is_trusted: bool, git_store: WeakEntity, cx: &mut Context, ) -> Self { @@ -3804,6 +3862,7 @@ impl Repository { dot_git_abs_path, project_environment, fs, + is_trusted, cx, ) .await diff --git a/crates/project/tests/integration/git_store.rs b/crates/project/tests/integration/git_store.rs index 88614cec68b542b3d08de11cfe0c5f3781d6b379..82e92bc4f1cfb606fb09d5efd5d341ed2951c067 100644 --- a/crates/project/tests/integration/git_store.rs +++ b/crates/project/tests/integration/git_store.rs @@ -1293,3 +1293,208 @@ mod git_worktrees { use crate::Project; } + +mod trust_tests { + use collections::HashSet; + use fs::FakeFs; + use gpui::TestAppContext; + use project::trusted_worktrees::*; + + use serde_json::json; + use settings::SettingsStore; + use util::path; + + use crate::Project; + + fn init_test(cx: &mut TestAppContext) { + zlog::init_test(); + + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + }); + } + + #[gpui::test] + async fn test_repository_defaults_to_untrusted_without_trust_system(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "hello", + }), + ) + .await; + + // Create project without trust system — repos should default to untrusted. + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); + + repository.read_with(cx, |repo, _| { + assert!( + !repo.is_trusted(), + "repository should default to untrusted when no trust system is initialized" + ); + }); + } + + #[gpui::test] + async fn test_multiple_repos_trust_with_single_worktree(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "hello", + "sub": { + ".git": {}, + "b.txt": "world", + }, + }), + ) + .await; + + cx.update(|cx| { + init(DbTrustedPaths::default(), cx); + }); + + let project = + Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_id = worktree_store.read_with(cx, |store, cx| { + store.worktrees().next().unwrap().read(cx).id() + }); + + let repos = project.read_with(cx, |project, cx| { + project + .repositories(cx) + .values() + .cloned() + .collect::>() + }); + assert_eq!(repos.len(), 2, "should have two repositories"); + for repo in &repos { + repo.read_with(cx, |repo, _| { + assert!( + !repo.is_trusted(), + "all repos should be untrusted initially" + ); + }); + } + + let trusted_worktrees = cx + .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set")); + trusted_worktrees.update(cx, |store, cx| { + store.trust( + &worktree_store, + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + cx, + ); + }); + cx.executor().run_until_parked(); + + for repo in &repos { + repo.read_with(cx, |repo, _| { + assert!( + repo.is_trusted(), + "all repos should be trusted after worktree is trusted" + ); + }); + } + } + + #[gpui::test] + async fn test_repository_trust_restrict_trust_cycle(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "a.txt": "hello", + }), + ) + .await; + + cx.update(|cx| { + project::trusted_worktrees::init(DbTrustedPaths::default(), cx); + }); + + let project = + Project::test_with_worktree_trust(fs.clone(), [path!("/project").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let worktree_store = project.read_with(cx, |project, _| project.worktree_store()); + let worktree_id = worktree_store.read_with(cx, |store, cx| { + store.worktrees().next().unwrap().read(cx).id() + }); + + let repository = project.read_with(cx, |project, cx| { + project.repositories(cx).values().next().unwrap().clone() + }); + + repository.read_with(cx, |repo, _| { + assert!(!repo.is_trusted(), "repository should start untrusted"); + }); + + let trusted_worktrees = cx + .update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global should be set")); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + &worktree_store, + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + cx, + ); + }); + cx.executor().run_until_parked(); + + repository.read_with(cx, |repo, _| { + assert!( + repo.is_trusted(), + "repository should be trusted after worktree is trusted" + ); + }); + + trusted_worktrees.update(cx, |store, cx| { + store.restrict( + worktree_store.downgrade(), + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + cx, + ); + }); + cx.executor().run_until_parked(); + + repository.read_with(cx, |repo, _| { + assert!( + !repo.is_trusted(), + "repository should be untrusted after worktree is restricted" + ); + }); + + trusted_worktrees.update(cx, |store, cx| { + store.trust( + &worktree_store, + HashSet::from_iter([PathTrust::Worktree(worktree_id)]), + cx, + ); + }); + cx.executor().run_until_parked(); + + repository.read_with(cx, |repo, _| { + assert!( + repo.is_trusted(), + "repository should be trusted again after second trust" + ); + }); + } +} From 5641ccf250c7140559416342d8ecf59bdd4aabee Mon Sep 17 00:00:00 2001 From: Gaauwe Rombouts Date: Wed, 4 Mar 2026 15:38:31 +0100 Subject: [PATCH 41/74] docs: Add consent banner (#50302) Adds a consent banner, similar to the one on zed.dev Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- .github/workflows/deploy_cloudflare.yml | 1 + crates/docs_preprocessor/src/main.rs | 2 + docs/.prettierignore | 3 + docs/README.md | 16 ++ docs/book.toml | 4 +- docs/theme/analytics.js | 93 ++++++++ docs/theme/c15t@2.0.0-rc.3.js | 1 + docs/theme/consent-banner.css | 292 ++++++++++++++++++++++++ docs/theme/index.hbs | 102 +++++++-- typos.toml | 2 + 10 files changed, 497 insertions(+), 19 deletions(-) create mode 100644 docs/theme/analytics.js create mode 100644 docs/theme/c15t@2.0.0-rc.3.js create mode 100644 docs/theme/consent-banner.css diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index cb0dfc2187a06cf62255b049b7e5fe74b10c505a..37f23b20d2825e9f3d26c456903962a10c2d0081 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -26,6 +26,7 @@ jobs: CC: clang CXX: clang++ DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }} + DOCS_CONSENT_IO_INSTANCE: ${{ secrets.DOCS_CONSENT_IO_INSTANCE }} - name: Deploy Docs uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3 diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 6ef599542a5b2f511915d7435af192162a5dbd3b..43efbeea0b0310cf70cd9bdb560b1b0d2b0c14ef 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -578,6 +578,7 @@ fn handle_postprocessing() -> Result<()> { .expect("Default title not a string") .to_string(); let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default(); + let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default(); output.insert("html".to_string(), zed_html); mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?; @@ -647,6 +648,7 @@ fn handle_postprocessing() -> Result<()> { zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir)); let contents = contents.replace("#description#", meta_description); let contents = contents.replace("#amplitude_key#", &litude_key); + let contents = contents.replace("#consent_io_instance#", &consent_io_instance); let contents = title_regex() .replace(&contents, |_: ®ex::Captures| { format!("{}", meta_title) diff --git a/docs/.prettierignore b/docs/.prettierignore index a52439689a83a1c2e834918c39441186b47120e5..c742ed4b6859f32219cecbac9f722db8a6929710 100644 --- a/docs/.prettierignore +++ b/docs/.prettierignore @@ -1,2 +1,5 @@ # Handlebars partials are not supported by Prettier. *.hbs + +# Automatically generated +theme/c15t@*.js diff --git a/docs/README.md b/docs/README.md index e1649f4bc99e1668352a46ee2071dcfe1775f4a7..a0f9bbd5c628f41d291880239ca555ea7ec0e3ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,22 @@ This will render a human-readable version of the action name, e.g., "zed: open s Templates are functions that modify the source of the docs pages (usually with a regex match and replace). You can see how the actions and keybindings are templated in `crates/docs_preprocessor/src/main.rs` for reference on how to create new templates. +## Consent Banner + +We pre-bundle the `c15t` package because the docs pipeline does not include a JS bundler. If you need to update `c15t` and rebuild the bundle, use: + +``` +mkdir c15t-bundle && cd c15t-bundle +npm init -y +npm install c15t@ esbuild +echo "import { getOrCreateConsentRuntime } from 'c15t'; window.c15t = { getOrCreateConsentRuntime };" > entry.js +npx esbuild entry.js --bundle --format=iife --minify --outfile=c15t@.js +cp c15t@.js ../theme/c15t@.js +cd .. && rm -rf c15t-bundle +``` + +Replace `` with the new version of `c15t` you are installing. Then update `book.toml` to reference the new bundle filename. + ### References - Template Trait: `crates/docs_preprocessor/src/templates.rs` diff --git a/docs/book.toml b/docs/book.toml index 86fa447f581fba88ff7df53bb51e08440585a9dc..3269003a1d37ede19ec18b62809a928a08764d2f 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -23,8 +23,8 @@ default-description = "Learn how to use and customize Zed, the fast, collaborati default-title = "Zed Code Editor Documentation" no-section-label = true preferred-dark-theme = "dark" -additional-css = ["theme/page-toc.css", "theme/plugins.css", "theme/highlight.css"] -additional-js = ["theme/page-toc.js", "theme/plugins.js"] +additional-css = ["theme/page-toc.css", "theme/plugins.css", "theme/highlight.css", "theme/consent-banner.css"] +additional-js = ["theme/page-toc.js", "theme/plugins.js", "theme/c15t@2.0.0-rc.3.js", "theme/analytics.js"] [output.zed-html.print] enable = false diff --git a/docs/theme/analytics.js b/docs/theme/analytics.js new file mode 100644 index 0000000000000000000000000000000000000000..6e9df27f30fc6d38ba6fb322f9888fda089bb20c --- /dev/null +++ b/docs/theme/analytics.js @@ -0,0 +1,93 @@ +const amplitudeKey = document.querySelector( + 'meta[name="amplitude-key"]', +)?.content; +const consentInstance = document.querySelector( + 'meta[name="consent-io-instance"]', +)?.content; + +document.addEventListener("DOMContentLoaded", () => { + if (!consentInstance || consentInstance.length === 0) return; + const { getOrCreateConsentRuntime } = window.c15t; + + const { consentStore } = getOrCreateConsentRuntime({ + mode: "c15t", + backendURL: consentInstance, + consentCategories: ["necessary", "measurement", "marketing"], + storageConfig: { + crossSubdomain: true, + }, + scripts: [ + { + id: "amplitude", + src: `https://cdn.amplitude.com/script/${amplitudeKey}.js`, + category: "measurement", + onLoad: () => { + window.amplitude.init(amplitudeKey, { + fetchRemoteConfig: true, + autocapture: true, + }); + }, + }, + ], + }); + + let previousActiveUI = consentStore.getState().activeUI; + const banner = document.getElementById("c15t-banner"); + const configureSection = document.getElementById("c15t-configure-section"); + const configureBtn = document.getElementById("c15t-configure-btn"); + const measurementToggle = document.getElementById("c15t-toggle-measurement"); + const marketingToggle = document.getElementById("c15t-toggle-marketing"); + + const toggleConfigureMode = () => { + const currentConsents = consentStore.getState().consents; + measurementToggle.checked = currentConsents + ? (currentConsents.measurement ?? false) + : false; + marketingToggle.checked = currentConsents + ? (currentConsents.marketing ?? false) + : false; + configureSection.style.display = "flex"; + configureBtn.innerHTML = "Save"; + configureBtn.className = "c15t-button secondary"; + configureBtn.title = ""; + }; + + consentStore.subscribe((state) => { + const hideBanner = + state.activeUI === "none" || + (state.activeUI === "banner" && state.mode === "opt-out"); + banner.style.display = hideBanner ? "none" : "block"; + + if (state.activeUI === "dialog" && previousActiveUI !== "dialog") { + toggleConfigureMode(); + } + + previousActiveUI = state.activeUI; + }); + + configureBtn.addEventListener("click", () => { + if (consentStore.getState().activeUI === "dialog") { + consentStore + .getState() + .setConsent("measurement", measurementToggle.checked); + consentStore.getState().setConsent("marketing", marketingToggle.checked); + consentStore.getState().saveConsents("custom"); + } else { + consentStore.getState().setActiveUI("dialog"); + } + }); + + document.getElementById("c15t-accept").addEventListener("click", () => { + consentStore.getState().saveConsents("all"); + }); + + document.getElementById("c15t-decline").addEventListener("click", () => { + consentStore.getState().saveConsents("necessary"); + }); + + document + .getElementById("c15t-manage-consent-btn") + .addEventListener("click", () => { + consentStore.getState().setActiveUI("dialog"); + }); +}); diff --git a/docs/theme/c15t@2.0.0-rc.3.js b/docs/theme/c15t@2.0.0-rc.3.js new file mode 100644 index 0000000000000000000000000000000000000000..5e4a38c12b605062bd8e7e77809d03e3aa11ff74 --- /dev/null +++ b/docs/theme/c15t@2.0.0-rc.3.js @@ -0,0 +1 @@ +(()=>{var ni=Object.defineProperty;var P=(n,e)=>()=>(n&&(e=n(n=0)),e);var Nt=(n,e)=>{for(var t in e)ni(n,t,{get:e[t],enumerable:!0})};var G,$t=P(()=>{G=class extends Error{constructor(e){super(e),this.name="DecodingError"}}});var q,Kt=P(()=>{q=class extends Error{constructor(e){super(e),this.name="EncodingError"}}});var oe,Yt=P(()=>{oe=class extends Error{constructor(e){super(e),this.name="GVLError"}}});var W,Wt=P(()=>{W=class extends Error{constructor(e,t,i=""){super(`invalid value ${t} passed for ${e} ${i}`),this.name="TCModelError"}}});var J=P(()=>{$t();Kt();Yt();Wt()});var pe,rt=P(()=>{J();pe=class{static DICT="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";static REVERSE_DICT=new Map([["A",0],["B",1],["C",2],["D",3],["E",4],["F",5],["G",6],["H",7],["I",8],["J",9],["K",10],["L",11],["M",12],["N",13],["O",14],["P",15],["Q",16],["R",17],["S",18],["T",19],["U",20],["V",21],["W",22],["X",23],["Y",24],["Z",25],["a",26],["b",27],["c",28],["d",29],["e",30],["f",31],["g",32],["h",33],["i",34],["j",35],["k",36],["l",37],["m",38],["n",39],["o",40],["p",41],["q",42],["r",43],["s",44],["t",45],["u",46],["v",47],["w",48],["x",49],["y",50],["z",51],["0",52],["1",53],["2",54],["3",55],["4",56],["5",57],["6",58],["7",59],["8",60],["9",61],["-",62],["_",63]]);static BASIS=6;static LCM=24;static encode(e){if(!/^[0-1]+$/.test(e))throw new q("Invalid bitField");let t=e.length%this.LCM;e+=t?"0".repeat(this.LCM-t):"";let i="";for(let s=0;s{Ee=class n{static langSet=new Set(["AR","BG","BS","CA","CS","CY","DA","DE","EL","EN","ES","ET","EU","FI","FR","GL","HE","HI","HR","HU","ID","IS","IT","JA","KA","KO","LT","LV","MK","MS","MT","NL","NO","PL","PT-BR","PT-PT","RO","RU","SK","SL","SQ","SR-LATN","SR-CYRL","SV","SW","TH","TL","TR","UK","VI","ZH","ZH-HANT"]);has(e){return n.langSet.has(e)}parseLanguage(e){e=e.toUpperCase();let t=e.split("-")[0];if(e.length>=2&&t.length==2){if(n.langSet.has(e))return e;if(n.langSet.has(t))return t;let i=t+"-"+t;if(n.langSet.has(i))return i;for(let s of n.langSet)if(s.indexOf(e)!==-1||s.indexOf(t)!==-1)return s}throw new Error(`unsupported language ${e}`)}forEach(e){n.langSet.forEach(e)}get size(){return n.langSet.size}}});var v,ot=P(()=>{v=class{static cmpId="cmpId";static cmpVersion="cmpVersion";static consentLanguage="consentLanguage";static consentScreen="consentScreen";static created="created";static supportOOB="supportOOB";static isServiceSpecific="isServiceSpecific";static lastUpdated="lastUpdated";static numCustomPurposes="numCustomPurposes";static policyVersion="policyVersion";static publisherCountryCode="publisherCountryCode";static publisherCustomConsents="publisherCustomConsents";static publisherCustomLegitimateInterests="publisherCustomLegitimateInterests";static publisherLegitimateInterests="publisherLegitimateInterests";static publisherConsents="publisherConsents";static publisherRestrictions="publisherRestrictions";static purposeConsents="purposeConsents";static purposeLegitimateInterests="purposeLegitimateInterests";static purposeOneTreatment="purposeOneTreatment";static specialFeatureOptins="specialFeatureOptins";static useNonStandardTexts="useNonStandardTexts";static vendorConsents="vendorConsents";static vendorLegitimateInterests="vendorLegitimateInterests";static vendorListVersion="vendorListVersion";static vendorsAllowed="vendorsAllowed";static vendorsDisclosed="vendorsDisclosed";static version="version"}});var Zt=P(()=>{});var Qt=P(()=>{});var te,fe=P(()=>{te=class{clone(){let e=new this.constructor;return Object.keys(this).forEach(i=>{let s=this.deepClone(this[i]);s!==void 0&&(e[i]=s)}),e}deepClone(e){let t=typeof e;if(t==="number"||t==="string"||t==="boolean")return e;if(e!==null&&t==="object"){if(typeof e.clone=="function")return e.clone();if(e instanceof Date)return new Date(e.getTime());if(e[Symbol.iterator]!==void 0){let i=[];for(let s of e)i.push(this.deepClone(s));return e instanceof Array?i:new e.constructor(i)}else{let i={};for(let s in e)e.hasOwnProperty(s)&&(i[s]=this.deepClone(e[s]));return i}}}}});var Z,Ge=P(()=>{(function(n){n[n.NOT_ALLOWED=0]="NOT_ALLOWED",n[n.REQUIRE_CONSENT=1]="REQUIRE_CONSENT",n[n.REQUIRE_LI=2]="REQUIRE_LI"})(Z||(Z={}))});var ae,at=P(()=>{fe();J();Ge();ae=class n extends te{static hashSeparator="-";purposeId_;restrictionType;constructor(e,t){super(),e!==void 0&&(this.purposeId=e),t!==void 0&&(this.restrictionType=t)}static unHash(e){let t=e.split(this.hashSeparator),i=new n;if(t.length!==2)throw new W("hash",e);return i.purposeId=parseInt(t[0],10),i.restrictionType=parseInt(t[1],10),i}get hash(){if(!this.isValid())throw new Error("cannot hash invalid PurposeRestriction");return`${this.purposeId}${n.hashSeparator}${this.restrictionType}`}get purposeId(){return this.purposeId_}set purposeId(e){this.purposeId_=e}isValid(){return Number.isInteger(this.purposeId)&&this.purposeId>0&&(this.restrictionType===Z.NOT_ALLOWED||this.restrictionType===Z.REQUIRE_CONSENT||this.restrictionType===Z.REQUIRE_LI)}isSameAs(e){return this.purposeId===e.purposeId&&this.restrictionType===e.restrictionType}}});var he,Xt=P(()=>{at();Ge();fe();he=class extends te{bitLength=0;map=new Map;gvl_;has(e){return this.map.has(e)}isOkToHave(e,t,i){let s=!0;if(this.gvl?.vendors){let r=this.gvl.vendors[i];if(r)if(e===Z.NOT_ALLOWED)s=r.legIntPurposes.includes(t)||r.purposes.includes(t);else if(r.flexiblePurposes.length)switch(e){case Z.REQUIRE_CONSENT:s=r.flexiblePurposes.includes(t)&&r.legIntPurposes.includes(t);break;case Z.REQUIRE_LI:s=r.flexiblePurposes.includes(t)&&r.purposes.includes(t);break}else s=!1;else s=!1}return s}add(e,t){if(this.isOkToHave(t.restrictionType,t.purposeId,e)){let i=t.hash;this.has(i)||(this.map.set(i,new Set),this.bitLength=0),this.map.get(i).add(e)}}restrictPurposeToLegalBasis(e){let t=Array.from(this.gvl.vendorIds),i=e.hash,s=t[t.length-1],r=[...Array(s).keys()].map(o=>o+1);if(!this.has(i))this.map.set(i,new Set(r)),this.bitLength=0;else for(let o=1;o<=s;o++)this.map.get(i).add(o)}getVendors(e){let t=[];if(e){let i=e.hash;this.has(i)&&(t=Array.from(this.map.get(i)))}else{let i=new Set;this.map.forEach(s=>{s.forEach(r=>{i.add(r)})}),t=Array.from(i)}return t.sort((i,s)=>i-s)}getRestrictionType(e,t){let i;return this.getRestrictions(e).forEach(s=>{s.purposeId===t&&(i===void 0||i>s.restrictionType)&&(i=s.restrictionType)}),i}vendorHasRestriction(e,t){let i=!1,s=this.getRestrictions(e);for(let r=0;r{e=Math.max(Array.from(t)[t.size-1],e)}),e}getRestrictions(e){let t=[];return this.map.forEach((i,s)=>{e?i.has(e)&&t.push(ae.unHash(s)):t.push(ae.unHash(s))}),t}getPurposes(){let e=new Set;return this.map.forEach((t,i)=>{e.add(ae.unHash(i).purposeId)}),Array.from(e)}remove(e,t){let i=t.hash,s=this.map.get(i);s&&(s.delete(e),s.size==0&&(this.map.delete(i),this.bitLength=0))}set gvl(e){this.gvl_||(this.gvl_=e,this.map.forEach((t,i)=>{let s=ae.unHash(i);Array.from(t).forEach(o=>{this.isOkToHave(s.restrictionType,s.purposeId,o)||t.delete(o)})}))}get gvl(){return this.gvl_}isEmpty(){return this.map.size===0}get numRestrictions(){return this.map.size}}});var ct,en=P(()=>{(function(n){n.COOKIE="cookie",n.WEB="web",n.APP="app"})(ct||(ct={}))});var tn=P(()=>{});var N,lt=P(()=>{(function(n){n.CORE="core",n.VENDORS_DISCLOSED="vendorsDisclosed",n.VENDORS_ALLOWED="vendorsAllowed",n.PUBLISHER_TC="publisherTC"})(N||(N={}))});var ke,nn=P(()=>{lt();ke=class{static ID_TO_KEY=[N.CORE,N.VENDORS_DISCLOSED,N.VENDORS_ALLOWED,N.PUBLISHER_TC];static KEY_TO_ID={[N.CORE]:0,[N.VENDORS_DISCLOSED]:1,[N.VENDORS_ALLOWED]:2,[N.PUBLISHER_TC]:3}}});var H,sn=P(()=>{fe();J();H=class extends te{bitLength=0;maxId_=0;set_=new Set;*[Symbol.iterator](){for(let e=1;e<=this.maxId;e++)yield[e,this.has(e)]}values(){return this.set_.values()}get maxId(){return this.maxId_}has(e){return this.set_.has(e)}unset(e){Array.isArray(e)?e.forEach(t=>this.unset(t)):typeof e=="object"?this.unset(Object.keys(e).map(t=>Number(t))):(this.set_.delete(Number(e)),this.bitLength=0,e===this.maxId&&(this.maxId_=0,this.set_.forEach(t=>{this.maxId_=Math.max(this.maxId,t)})))}isIntMap(e){let t=typeof e=="object";return t=t&&Object.keys(e).every(i=>{let s=Number.isInteger(parseInt(i,10));return s=s&&this.isValidNumber(e[i].id),s=s&&e[i].name!==void 0,s}),t}isValidNumber(e){return parseInt(e,10)>0}isSet(e){let t=!1;return e instanceof Set&&(t=Array.from(e).every(this.isValidNumber)),t}set(e){if(Array.isArray(e))e.forEach(t=>this.set(t));else if(this.isSet(e))this.set(Array.from(e));else if(this.isIntMap(e))this.set(Object.keys(e).map(t=>Number(t)));else if(this.isValidNumber(e))this.set_.add(e),this.maxId_=Math.max(this.maxId,e),this.bitLength=0;else throw new W("set()",e,"must be positive integer array, positive integer, Set, or IntMap")}empty(){this.set_=new Set,this.maxId_=0}forEach(e){for(let t=1;t<=this.maxId;t++)e(this.has(t),t)}get size(){return this.set_.size}setAll(e){this.set(e)}unsetAll(e){this.unset(e)}}});var rn=P(()=>{});var on=P(()=>{});var an=P(()=>{});var cn=P(()=>{});var ln=P(()=>{});var un=P(()=>{});var dn=P(()=>{});var pn=P(()=>{});var gn=P(()=>{});var mn=P(()=>{});var fn=P(()=>{});var hn=P(()=>{rn();on();an();cn();ln();un();dn();pn();gn();mn();fn()});var Q=P(()=>{Jt();ot();Zt();Qt();at();Xt();en();tn();Ge();lt();nn();sn();hn()});var S,He=P(()=>{Q();S=class{static[v.cmpId]=12;static[v.cmpVersion]=12;static[v.consentLanguage]=12;static[v.consentScreen]=6;static[v.created]=36;static[v.isServiceSpecific]=1;static[v.lastUpdated]=36;static[v.policyVersion]=6;static[v.publisherCountryCode]=12;static[v.publisherLegitimateInterests]=24;static[v.publisherConsents]=24;static[v.purposeConsents]=24;static[v.purposeLegitimateInterests]=24;static[v.purposeOneTreatment]=1;static[v.specialFeatureOptins]=12;static[v.useNonStandardTexts]=1;static[v.vendorListVersion]=12;static[v.version]=6;static anyBoolean=1;static encodingType=1;static maxId=16;static numCustomPurposes=6;static numEntries=12;static numRestrictions=12;static purposeId=6;static restrictionType=2;static segmentType=3;static singleOrRange=1;static vendorId=16}});var kn=P(()=>{});var $,je=P(()=>{$=class{static encode(e){return String(Number(e))}static decode(e){return e==="1"}}});var L,ge=P(()=>{J();L=class{static encode(e,t){let i;if(typeof e=="string"&&(e=parseInt(e,10)),i=e.toString(2),i.length>t||e<0)throw new q(`${e} too large to encode into ${t}`);return i.length{ge();J();Se=class{static encode(e,t){return L.encode(Math.round(e.getTime()/100),t)}static decode(e,t){if(t!==e.length)throw new G("invalid bit length");let i=new Date;return i.setTime(L.decode(e,t)*100),i}}});var ne,qe=P(()=>{je();J();Q();ne=class{static encode(e,t){let i="";for(let s=1;s<=t;s++)i+=$.encode(e.has(s));return i}static decode(e,t){if(e.length!==t)throw new G("bitfield encoding length mismatch");let i=new H;for(let s=1;s<=t;s++)$.decode(e[s-1])&&i.set(s);return i.bitLength=e.length,i}}});var Ae,dt=P(()=>{ge();J();Ae=class{static encode(e,t){e=e.toUpperCase();let i=65,s=e.charCodeAt(0)-i,r=e.charCodeAt(1)-i;if(s<0||s>25||r<0||r>25)throw new q(`invalid language code: ${e}`);if(t%2===1)throw new q(`numBits must be even, ${t} is not valid`);t=t/2;let o=L.encode(s,t),a=L.encode(r,t);return o+a}static decode(e,t){let i;if(t===e.length&&!(e.length%2)){let r=e.length/2,o=L.decode(e.slice(0,r),r)+65,a=L.decode(e.slice(r),r)+65;i=String.fromCharCode(o)+String.fromCharCode(a)}else throw new G("invalid bit length for language");return i}}});var Ve,pt=P(()=>{He();je();J();ge();Q();Ve=class{static encode(e){let t=L.encode(e.numRestrictions,S.numRestrictions);if(!e.isEmpty()){let i=(s,r)=>{for(let o=s+1;o<=r;o++)if(e.gvl.vendorIds.has(o))return o;return s};e.getRestrictions().forEach(s=>{t+=L.encode(s.purposeId,S.purposeId),t+=L.encode(s.restrictionType,S.restrictionType);let r=e.getVendors(s),o=r.length,a=0,c=0,u="";for(let p=0;pi(d,r[o-1])){let l=d!==c;u+=$.encode(l),u+=L.encode(c,S.vendorId),l&&(u+=L.encode(d,S.vendorId)),c=0}}t+=L.encode(a,S.numEntries),t+=u})}return t}static decode(e){let t=0,i=new he,s=L.decode(e.substr(t,S.numRestrictions),S.numRestrictions);t+=S.numRestrictions;for(let r=0;r{(function(n){n[n.FIELD=0]="FIELD",n[n.RANGE=1]="RANGE"})(ve||(ve={}))});var ce,mt=P(()=>{Q();$e();ge();je();qe();gt();J();ce=class{static encode(e){let t=[],i=[],s=L.encode(e.maxId,S.maxId),r="",o,a=S.maxId+S.encodingType,c=a+e.maxId,u=S.vendorId*2+S.singleOrRange+S.numEntries,p=a+S.numEntries;return e.forEach((d,l)=>{r+=$.encode(d),o=e.maxId>u&&p{let r=s.length===1;i+=$.encode(!r),i+=L.encode(s[0],S.vendorId),r||(i+=L.encode(s[1],S.vendorId))}),i}}});function Ke(){return{[v.version]:L,[v.created]:Se,[v.lastUpdated]:Se,[v.cmpId]:L,[v.cmpVersion]:L,[v.consentScreen]:L,[v.consentLanguage]:Ae,[v.vendorListVersion]:L,[v.policyVersion]:L,[v.isServiceSpecific]:$,[v.useNonStandardTexts]:$,[v.specialFeatureOptins]:ne,[v.purposeConsents]:ne,[v.purposeLegitimateInterests]:ne,[v.purposeOneTreatment]:$,[v.publisherCountryCode]:Ae,[v.vendorConsents]:ce,[v.vendorLegitimateInterests]:ce,[v.publisherRestrictions]:Ve,segmentType:L,[v.vendorsDisclosed]:ce,[v.vendorsAllowed]:ce,[v.publisherConsents]:ne,[v.publisherLegitimateInterests]:ne,[v.numCustomPurposes]:L,[v.publisherCustomConsents]:ne,[v.publisherCustomLegitimateInterests]:ne}}var vn=P(()=>{Q();je();ut();qe();ge();dt();pt();mt()});var ft=P(()=>{je();ut();vn();qe();ge();dt();pt();gt();mt()});var xe,yn=P(()=>{Q();xe=class{1={[N.CORE]:[v.version,v.created,v.lastUpdated,v.cmpId,v.cmpVersion,v.consentScreen,v.consentLanguage,v.vendorListVersion,v.purposeConsents,v.vendorConsents]};2={[N.CORE]:[v.version,v.created,v.lastUpdated,v.cmpId,v.cmpVersion,v.consentScreen,v.consentLanguage,v.vendorListVersion,v.policyVersion,v.isServiceSpecific,v.useNonStandardTexts,v.specialFeatureOptins,v.purposeConsents,v.purposeLegitimateInterests,v.purposeOneTreatment,v.publisherCountryCode,v.vendorConsents,v.vendorLegitimateInterests,v.publisherRestrictions],[N.VENDORS_DISCLOSED]:[v.vendorsDisclosed],[N.PUBLISHER_TC]:[v.publisherConsents,v.publisherLegitimateInterests,v.numCustomPurposes,v.publisherCustomConsents,v.publisherCustomLegitimateInterests],[N.VENDORS_ALLOWED]:[v.vendorsAllowed]}}});var Fe,bn=P(()=>{Q();Fe=class{1=[N.CORE];2=[N.CORE];constructor(e,t){if(e.version===2)if(e.isServiceSpecific)this[2].push(N.VENDORS_DISCLOSED),this[2].push(N.PUBLISHER_TC);else{let i=!!(t&&t.isForVendors);(!i||e[v.supportOOB]===!0)&&this[2].push(N.VENDORS_DISCLOSED),i&&(e[v.supportOOB]&&e[v.vendorsAllowed].size>0&&this[2].push(N.VENDORS_ALLOWED),this[2].push(N.PUBLISHER_TC))}}}});var wn=P(()=>{});var ht=P(()=>{yn();bn();wn()});var ze,Cn=P(()=>{rt();He();ft();ht();J();ot();Q();ze=class{static fieldSequence=new xe;static encode(e,t){let i;try{i=this.fieldSequence[String(e.version)][t]}catch{throw new q(`Unable to encode version: ${e.version}, segment: ${t}`)}let s="";t!==N.CORE&&(s=L.encode(ke.KEY_TO_ID[t],S.segmentType));let r=Ke();return i.forEach(o=>{let a=e[o],c=r[o],u=S[o];u===void 0&&this.isPublisherCustom(o)&&(u=Number(e[v.numCustomPurposes]));try{s+=c.encode(a,u)}catch(p){throw new q(`Error encoding ${t}->${o}: ${p.message}`)}}),pe.encode(s)}static decode(e,t,i){let s=pe.decode(e),r=0;i===N.CORE&&(t.version=L.decode(s.substr(r,S[v.version]),S[v.version])),i!==N.CORE&&(r+=S.segmentType);let o=this.fieldSequence[String(t.version)][i],a=Ke();return o.forEach(c=>{let u=a[c],p=S[c];if(p===void 0&&this.isPublisherCustom(c)&&(p=Number(t[v.numCustomPurposes])),p!==0){let d=s.substr(r,p);if(u===ce?t[c]=u.decode(d,t.version):t[c]=u.decode(d,p),Number.isInteger(p))r+=p;else if(Number.isInteger(t[c].bitLength))r+=t[c].bitLength;else throw new G(c)}}),t}static isPublisherCustom(e){return e.indexOf("publisherCustom")===0}}});var Be,In=P(()=>{J();Q();Be=class{static processor=[e=>e,(e,t)=>{e.publisherRestrictions.gvl=t,e.purposeLegitimateInterests.unset([1,3,4,5,6]);let i=new Map;return i.set("legIntPurposes",e.vendorLegitimateInterests),i.set("purposes",e.vendorConsents),i.forEach((s,r)=>{s.forEach((o,a)=>{if(o){let c=t.vendors[a];if(!c||c.deletedDate)s.unset(a);else if(c[r].length===0)if(r==="legIntPurposes"&&c.purposes.length===0&&c.legIntPurposes.length===0&&c.specialPurposes.length>0)s.set(a);else if(r==="legIntPurposes"&&c.purposes.length>0&&c.legIntPurposes.length===0&&c.specialPurposes.length>0)s.set(a);else if(e.isServiceSpecific)if(c.flexiblePurposes.length===0)s.unset(a);else{let u=e.publisherRestrictions.getRestrictions(a),p=!1;for(let d=0,l=u.length;d0&&t?.version<=this.processor.length?e.version=t.version:e.version=this.processor.length;let s=e.version-1;if(!this.processor[s])throw new q(`Invalid version: ${e.version}`);return this.processor[s](e,i)}}});var $e=P(()=>{rt();He();kn();Cn();In();ft();ht()});var De,kt=P(()=>{De=class{static absCall(e,t,i,s){return new Promise((r,o)=>{let a=new XMLHttpRequest,c=()=>{if(a.readyState==XMLHttpRequest.DONE)if(a.status>=200&&a.status<300){let l=a.response;if(typeof l=="string")try{l=JSON.parse(l)}catch{}r(l)}else o(new Error(`HTTP Status: ${a.status} response type: ${a.responseType}`))},u=()=>{o(new Error("error"))},p=()=>{o(new Error("aborted"))},d=()=>{o(new Error("Timeout "+s+"ms "+e))};a.withCredentials=i,a.addEventListener("load",c),a.addEventListener("error",u),a.addEventListener("abort",p),t===null?a.open("GET",e,!0):a.open("POST",e,!0),a.responseType="json",a.timeout=s,a.ontimeout=d,a.send(t)})}static post(e,t,i=!1,s=0){return this.absCall(e,JSON.stringify(t),i,s)}static fetch(e,t=!1,i=0){return this.absCall(e,null,t,i)}}});var ye,vt=P(()=>{fe();J();kt();Q();ye=class n extends te{static LANGUAGE_CACHE=new Map;static CACHE=new Map;static LATEST_CACHE_KEY=0;static DEFAULT_LANGUAGE="EN";static consentLanguages=new Ee;static baseUrl_;static set baseUrl(e){if(/^https?:\/\/vendorlist\.consensu\.org\//.test(e))throw new oe("Invalid baseUrl! You may not pull directly from vendorlist.consensu.org and must provide your own cache");e.length>0&&e[e.length-1]!=="/"&&(e+="/"),this.baseUrl_=e}static get baseUrl(){return this.baseUrl_}static latestFilename="vendor-list.json";static versionedFilename="archives/vendor-list-v[VERSION].json";static languageFilename="purposes-[LANG].json";readyPromise;gvlSpecificationVersion;vendorListVersion;tcfPolicyVersion;lastUpdated;purposes;specialPurposes;features;specialFeatures;isReady_=!1;vendors_;vendorIds;fullVendorList;byPurposeVendorMap;bySpecialPurposeVendorMap;byFeatureVendorMap;bySpecialFeatureVendorMap;stacks;dataCategories;lang_;cacheLang_;isLatest=!1;constructor(e,t){super();let i=n.baseUrl,s=t?.language;if(s)try{s=n.consentLanguages.parseLanguage(s)}catch(r){throw new oe("Error during parsing the language: "+r.message)}if(this.lang_=s||n.DEFAULT_LANGUAGE,this.cacheLang_=s||n.DEFAULT_LANGUAGE,this.isVendorList(e))this.populate(e),this.readyPromise=Promise.resolve();else{if(!i)throw new oe("must specify GVL.baseUrl before loading GVL json");if(e>0){let r=e;n.CACHE.has(r)?(this.populate(n.CACHE.get(r)),this.readyPromise=Promise.resolve()):(i+=n.versionedFilename.replace("[VERSION]",String(r)),this.readyPromise=this.fetchJson(i))}else n.CACHE.has(n.LATEST_CACHE_KEY)?(this.populate(n.CACHE.get(n.LATEST_CACHE_KEY)),this.readyPromise=Promise.resolve()):(this.isLatest=!0,this.readyPromise=this.fetchJson(i+n.latestFilename))}}static emptyLanguageCache(e){let t=!1;return e==null&&n.LANGUAGE_CACHE.size>0?(n.LANGUAGE_CACHE=new Map,t=!0):typeof e=="string"&&this.consentLanguages.has(e.toUpperCase())&&(n.LANGUAGE_CACHE.delete(e.toUpperCase()),t=!0),t}static emptyCache(e){let t=!1;return Number.isInteger(e)&&e>=0?(n.CACHE.delete(e),t=!0):e===void 0&&(n.CACHE=new Map,t=!0),t}cacheLanguage(){n.LANGUAGE_CACHE.has(this.cacheLang_)||n.LANGUAGE_CACHE.set(this.cacheLang_,{purposes:this.purposes,specialPurposes:this.specialPurposes,features:this.features,specialFeatures:this.specialFeatures,stacks:this.stacks,dataCategories:this.dataCategories})}async fetchJson(e){try{this.populate(await De.fetch(e))}catch(t){throw new oe(t.message)}}getJson(){return{gvlSpecificationVersion:this.gvlSpecificationVersion,vendorListVersion:this.vendorListVersion,tcfPolicyVersion:this.tcfPolicyVersion,lastUpdated:this.lastUpdated,purposes:this.clonePurposes(),specialPurposes:this.cloneSpecialPurposes(),features:this.cloneFeatures(),specialFeatures:this.cloneSpecialFeatures(),stacks:this.cloneStacks(),...this.dataCategories?{dataCategories:this.cloneDataCategories()}:{},vendors:this.cloneVendors()}}cloneSpecialFeatures(){let e={};for(let t of Object.keys(this.specialFeatures))e[t]=n.cloneFeature(this.specialFeatures[t]);return e}cloneFeatures(){let e={};for(let t of Object.keys(this.features))e[t]=n.cloneFeature(this.features[t]);return e}cloneStacks(){let e={};for(let t of Object.keys(this.stacks))e[t]=n.cloneStack(this.stacks[t]);return e}cloneDataCategories(){let e={};for(let t of Object.keys(this.dataCategories))e[t]=n.cloneDataCategory(this.dataCategories[t]);return e}cloneSpecialPurposes(){let e={};for(let t of Object.keys(this.specialPurposes))e[t]=n.clonePurpose(this.specialPurposes[t]);return e}clonePurposes(){let e={};for(let t of Object.keys(this.purposes))e[t]=n.clonePurpose(this.purposes[t]);return e}static clonePurpose(e){return{id:e.id,name:e.name,description:e.description,...e.descriptionLegal?{descriptionLegal:e.descriptionLegal}:{},...e.illustrations?{illustrations:Array.from(e.illustrations)}:{}}}static cloneFeature(e){return{id:e.id,name:e.name,description:e.description,...e.descriptionLegal?{descriptionLegal:e.descriptionLegal}:{},...e.illustrations?{illustrations:Array.from(e.illustrations)}:{}}}static cloneDataCategory(e){return{id:e.id,name:e.name,description:e.description}}static cloneStack(e){return{id:e.id,name:e.name,description:e.description,purposes:Array.from(e.purposes),specialFeatures:Array.from(e.specialFeatures)}}static cloneDataRetention(e){return{...typeof e.stdRetention=="number"?{stdRetention:e.stdRetention}:{},purposes:{...e.purposes},specialPurposes:{...e.specialPurposes}}}static cloneVendorUrls(e){return e.map(t=>({langId:t.langId,privacy:t.privacy,...t.legIntClaim?{legIntClaim:t.legIntClaim}:{}}))}static cloneVendor(e){return{id:e.id,name:e.name,purposes:Array.from(e.purposes),legIntPurposes:Array.from(e.legIntPurposes),flexiblePurposes:Array.from(e.flexiblePurposes),specialPurposes:Array.from(e.specialPurposes),features:Array.from(e.features),specialFeatures:Array.from(e.specialFeatures),...e.overflow?{overflow:{httpGetLimit:e.overflow.httpGetLimit}}:{},...typeof e.cookieMaxAgeSeconds=="number"||e.cookieMaxAgeSeconds===null?{cookieMaxAgeSeconds:e.cookieMaxAgeSeconds}:{},...e.usesCookies!==void 0?{usesCookies:e.usesCookies}:{},...e.policyUrl?{policyUrl:e.policyUrl}:{},...e.cookieRefresh!==void 0?{cookieRefresh:e.cookieRefresh}:{},...e.usesNonCookieAccess!==void 0?{usesNonCookieAccess:e.usesNonCookieAccess}:{},...e.dataRetention?{dataRetention:this.cloneDataRetention(e.dataRetention)}:{},...e.urls?{urls:this.cloneVendorUrls(e.urls)}:{},...e.dataDeclaration?{dataDeclaration:Array.from(e.dataDeclaration)}:{},...e.deviceStorageDisclosureUrl?{deviceStorageDisclosureUrl:e.deviceStorageDisclosureUrl}:{},...e.deletedDate?{deletedDate:e.deletedDate}:{}}}cloneVendors(){let e={};for(let t of Object.keys(this.fullVendorList))e[t]=n.cloneVendor(this.fullVendorList[t]);return e}async changeLanguage(e){let t=e;try{t=n.consentLanguages.parseLanguage(e)}catch(s){throw new oe("Error during parsing the language: "+s.message)}let i=e.toUpperCase();if(!(t.toLowerCase()===n.DEFAULT_LANGUAGE.toLowerCase()&&!n.LANGUAGE_CACHE.has(i))&&t!==this.lang_)if(this.lang_=t,n.LANGUAGE_CACHE.has(i)){let s=n.LANGUAGE_CACHE.get(i);for(let r in s)s.hasOwnProperty(r)&&(this[r]=s[r])}else{let s=n.baseUrl+n.languageFilename.replace("[LANG]",this.lang_.toLowerCase());try{await this.fetchJson(s),this.cacheLang_=i,this.cacheLanguage()}catch(r){throw new oe("unable to load language: "+r.message)}}}get language(){return this.lang_}isVendorList(e){return e!==void 0&&e.vendors!==void 0}populate(e){this.purposes=e.purposes,this.specialPurposes=e.specialPurposes,this.features=e.features,this.specialFeatures=e.specialFeatures,this.stacks=e.stacks,this.dataCategories=e.dataCategories,this.isVendorList(e)&&(this.gvlSpecificationVersion=e.gvlSpecificationVersion,this.tcfPolicyVersion=e.tcfPolicyVersion,this.vendorListVersion=e.vendorListVersion,this.lastUpdated=e.lastUpdated,typeof this.lastUpdated=="string"&&(this.lastUpdated=new Date(this.lastUpdated)),this.vendors_=e.vendors,this.fullVendorList=e.vendors,this.mapVendors(),this.isReady_=!0,this.isLatest&&n.CACHE.set(n.LATEST_CACHE_KEY,this.getJson()),n.CACHE.has(this.vendorListVersion)||n.CACHE.set(this.vendorListVersion,this.getJson())),this.cacheLanguage()}mapVendors(e){this.byPurposeVendorMap={},this.bySpecialPurposeVendorMap={},this.byFeatureVendorMap={},this.bySpecialFeatureVendorMap={},Object.keys(this.purposes).forEach(t=>{this.byPurposeVendorMap[t]={legInt:new Set,consent:new Set,flexible:new Set}}),Object.keys(this.specialPurposes).forEach(t=>{this.bySpecialPurposeVendorMap[t]=new Set}),Object.keys(this.features).forEach(t=>{this.byFeatureVendorMap[t]=new Set}),Object.keys(this.specialFeatures).forEach(t=>{this.bySpecialFeatureVendorMap[t]=new Set}),Array.isArray(e)||(e=Object.keys(this.fullVendorList).map(t=>+t)),this.vendorIds=new Set(e),this.vendors_=e.reduce((t,i)=>{let s=this.vendors_[String(i)];return s&&s.deletedDate===void 0&&(s.purposes.forEach(r=>{this.byPurposeVendorMap[String(r)].consent.add(i)}),s.specialPurposes.forEach(r=>{this.bySpecialPurposeVendorMap[String(r)].add(i)}),s.legIntPurposes.forEach(r=>{this.byPurposeVendorMap[String(r)].legInt.add(i)}),s.flexiblePurposes&&s.flexiblePurposes.forEach(r=>{this.byPurposeVendorMap[String(r)].flexible.add(i)}),s.features.forEach(r=>{this.byFeatureVendorMap[String(r)].add(i)}),s.specialFeatures.forEach(r=>{this.bySpecialFeatureVendorMap[String(r)].add(i)}),t[i]=s),t},{})}getFilteredVendors(e,t,i,s){let r=e.charAt(0).toUpperCase()+e.slice(1),o,a={};return e==="purpose"&&i?o=this["by"+r+"VendorMap"][String(t)][i]:o=this["by"+(s?"Special":"")+r+"VendorMap"][String(t)],o.forEach(c=>{a[String(c)]=this.vendors[String(c)]}),a}getVendorsWithConsentPurpose(e){return this.getFilteredVendors("purpose",e,"consent")}getVendorsWithLegIntPurpose(e){return this.getFilteredVendors("purpose",e,"legInt")}getVendorsWithFlexiblePurpose(e){return this.getFilteredVendors("purpose",e,"flexible")}getVendorsWithSpecialPurpose(e){return this.getFilteredVendors("purpose",e,void 0,!0)}getVendorsWithFeature(e){return this.getFilteredVendors("feature",e)}getVendorsWithSpecialFeature(e){return this.getFilteredVendors("feature",e,void 0,!0)}get vendors(){return this.vendors_}narrowVendorsTo(e){this.mapVendors(e)}get isReady(){return this.isReady_}clone(){let e=new n(this.getJson());return this.lang_!==n.DEFAULT_LANGUAGE&&e.changeLanguage(this.lang_),e}static isInstanceOf(e){return typeof e=="object"&&typeof e.narrowVendorsTo=="function"}}});var Ne,yt=P(()=>{fe();J();vt();Q();Ne=class extends te{static consentLanguages=ye.consentLanguages;isServiceSpecific_=!0;supportOOB_=!1;useNonStandardTexts_=!1;purposeOneTreatment_=!1;publisherCountryCode_="AA";version_=2;consentScreen_=0;policyVersion_=5;consentLanguage_="EN";cmpId_=0;cmpVersion_=0;vendorListVersion_=0;numCustomPurposes_=0;gvl_;created;lastUpdated;specialFeatureOptins=new H;purposeConsents=new H;purposeLegitimateInterests=new H;publisherConsents=new H;publisherLegitimateInterests=new H;publisherCustomConsents=new H;publisherCustomLegitimateInterests=new H;customPurposes;vendorConsents=new H;vendorLegitimateInterests=new H;vendorsDisclosed=new H;vendorsAllowed=new H;publisherRestrictions=new he;constructor(e){super(),e&&(this.gvl=e),this.updated()}set gvl(e){ye.isInstanceOf(e)||(e=new ye(e)),this.gvl_=e,this.publisherRestrictions.gvl=e}get gvl(){return this.gvl_}set cmpId(e){if(e=Number(e),Number.isInteger(e)&&e>1)this.cmpId_=e;else throw new W("cmpId",e)}get cmpId(){return this.cmpId_}set cmpVersion(e){if(e=Number(e),Number.isInteger(e)&&e>-1)this.cmpVersion_=e;else throw new W("cmpVersion",e)}get cmpVersion(){return this.cmpVersion_}set consentScreen(e){if(e=Number(e),Number.isInteger(e)&&e>-1)this.consentScreen_=e;else throw new W("consentScreen",e)}get consentScreen(){return this.consentScreen_}set consentLanguage(e){this.consentLanguage_=e}get consentLanguage(){return this.consentLanguage_}set publisherCountryCode(e){if(/^([A-z]){2}$/.test(e))this.publisherCountryCode_=e.toUpperCase();else throw new W("publisherCountryCode",e)}get publisherCountryCode(){return this.publisherCountryCode_}set vendorListVersion(e){if(e=Number(e)>>0,e<0)throw new W("vendorListVersion",e);this.vendorListVersion_=e}get vendorListVersion(){return this.gvl?this.gvl.vendorListVersion:this.vendorListVersion_}set policyVersion(e){if(this.policyVersion_=parseInt(e,10),this.policyVersion_<0)throw new W("policyVersion",e)}get policyVersion(){return this.gvl?this.gvl.tcfPolicyVersion:this.policyVersion_}set version(e){this.version_=parseInt(e,10)}get version(){return this.version_}set isServiceSpecific(e){this.isServiceSpecific_=e}get isServiceSpecific(){return this.isServiceSpecific_}set useNonStandardTexts(e){this.useNonStandardTexts_=e}get useNonStandardTexts(){return this.useNonStandardTexts_}set supportOOB(e){this.supportOOB_=e}get supportOOB(){return this.supportOOB_}set purposeOneTreatment(e){this.purposeOneTreatment_=e}get purposeOneTreatment(){return this.purposeOneTreatment_}setAllVendorConsents(){this.vendorConsents.set(this.gvl.vendors)}unsetAllVendorConsents(){this.vendorConsents.empty()}setAllVendorsDisclosed(){this.vendorsDisclosed.set(this.gvl.vendors)}unsetAllVendorsDisclosed(){this.vendorsDisclosed.empty()}setAllVendorsAllowed(){this.vendorsAllowed.set(this.gvl.vendors)}unsetAllVendorsAllowed(){this.vendorsAllowed.empty()}setAllVendorLegitimateInterests(){this.vendorLegitimateInterests.set(this.gvl.vendors)}unsetAllVendorLegitimateInterests(){this.vendorLegitimateInterests.empty()}setAllPurposeConsents(){this.purposeConsents.set(this.gvl.purposes)}unsetAllPurposeConsents(){this.purposeConsents.empty()}setAllPurposeLegitimateInterests(){this.purposeLegitimateInterests.set(this.gvl.purposes)}unsetAllPurposeLegitimateInterests(){this.purposeLegitimateInterests.empty()}setAllSpecialFeatureOptins(){this.specialFeatureOptins.set(this.gvl.specialFeatures)}unsetAllSpecialFeatureOptins(){this.specialFeatureOptins.empty()}setAll(){this.setAllVendorConsents(),this.setAllPurposeLegitimateInterests(),this.setAllSpecialFeatureOptins(),this.setAllPurposeConsents(),this.setAllVendorLegitimateInterests()}unsetAll(){this.unsetAllVendorConsents(),this.unsetAllPurposeLegitimateInterests(),this.unsetAllSpecialFeatureOptins(),this.unsetAllPurposeConsents(),this.unsetAllVendorLegitimateInterests()}get numCustomPurposes(){let e=this.numCustomPurposes_;if(typeof this.customPurposes=="object"){let t=Object.keys(this.customPurposes).sort((i,s)=>Number(i)-Number(s));e=parseInt(t.pop(),10)}return e}set numCustomPurposes(e){if(this.numCustomPurposes_=parseInt(e,10),this.numCustomPurposes_<0)throw new W("numCustomPurposes",e)}updated(){let e=new Date,t=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()));this.created=t,this.lastUpdated=t}}});var bt,jn=P(()=>{$e();Q();ge();yt();bt=class{static encode(e,t){let i="",s;return e=Be.process(e,t),Array.isArray(t?.segments)?s=t.segments:s=new Fe(e,t)[""+e.version],s.forEach((r,o)=>{let a="";ope,BitLength:()=>S,BooleanEncoder:()=>$,Cloneable:()=>te,ConsentLanguages:()=>Ee,DateEncoder:()=>Se,DecodingError:()=>G,DeviceDisclosureStorageAccessType:()=>ct,EncodingError:()=>q,FieldEncoderMap:()=>Ke,FieldSequence:()=>xe,Fields:()=>v,FixedVectorEncoder:()=>ne,GVL:()=>ye,GVLError:()=>oe,IntEncoder:()=>L,Json:()=>De,LangEncoder:()=>Ae,PurposeRestriction:()=>ae,PurposeRestrictionVector:()=>he,PurposeRestrictionVectorEncoder:()=>Ve,RestrictionType:()=>Z,Segment:()=>N,SegmentEncoder:()=>ze,SegmentIDs:()=>ke,SegmentSequence:()=>Fe,SemanticPreEncoder:()=>Be,TCModel:()=>Ne,TCModelError:()=>W,TCString:()=>bt,Vector:()=>H,VectorEncodingType:()=>ve,VendorVectorEncoder:()=>ce});var An=P(()=>{$e();J();Q();fe();vt();kt();yt();jn()});var st={};Nt(st,{baseTranslations:()=>Bi,deepMergeTranslations:()=>Rt,detectBrowserLanguage:()=>Gt,enTranslations:()=>it,mergeTranslationConfigs:()=>Ut,parseAcceptLanguage:()=>Mt,prepareTranslationConfig:()=>Ni,selectLanguage:()=>Di});var ii={common:{acceptAll:"\u041F\u0440\u0438\u0435\u043C\u0438 \u0432\u0441\u0438\u0447\u043A\u0438",rejectAll:"\u041E\u0442\u0445\u0432\u044A\u0440\u043B\u0438 \u0432\u0441\u0438\u0447\u043A\u0438",customize:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u0439",save:"\u0417\u0430\u043F\u0430\u0437\u0438 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438\u0442\u0435"},cookieBanner:{title:"\u0426\u0435\u043D\u0438\u043C \u0432\u0430\u0448\u0430\u0442\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",description:"\u0422\u043E\u0437\u0438 \u0441\u0430\u0439\u0442 \u0438\u0437\u043F\u043E\u043B\u0437\u0432\u0430 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438, \u0437\u0430 \u0434\u0430 \u043F\u043E\u0434\u043E\u0431\u0440\u0438 \u0432\u0430\u0448\u0435\u0442\u043E \u043F\u043E\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043B\u0441\u043A\u043E \u0438\u0437\u0436\u0438\u0432\u044F\u0432\u0430\u043D\u0435, \u0434\u0430 \u0430\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430 \u0442\u0440\u0430\u0444\u0438\u043A\u0430 \u043D\u0430 \u0441\u0430\u0439\u0442\u0430 \u0438 \u0434\u0430 \u043F\u043E\u043A\u0430\u0437\u0432\u0430 \u043F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u043D\u043E \u0441\u044A\u0434\u044A\u0440\u0436\u0430\u043D\u0438\u0435."},consentManagerDialog:{title:"\u041D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",description:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0430\u0448\u0438\u0442\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442 \u0442\u0443\u043A. \u041C\u043E\u0436\u0435\u0442\u0435 \u0434\u0430 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043A\u043E\u0438 \u0432\u0438\u0434\u043E\u0432\u0435 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u0438 \u0442\u0435\u0445\u043D\u043E\u043B\u043E\u0433\u0438\u0438 \u0437\u0430 \u043F\u0440\u043E\u0441\u043B\u0435\u0434\u044F\u0432\u0430\u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u0442\u0435."},consentTypes:{necessary:{title:"\u0421\u0442\u0440\u043E\u0433\u043E \u043D\u0435\u043E\u0431\u0445\u043E\u0434\u0438\u043C\u0438",description:"\u0422\u0435\u0437\u0438 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u0441\u0430 \u043E\u0442 \u0441\u044A\u0449\u0435\u0441\u0442\u0432\u0435\u043D\u043E \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0437\u0430 \u043F\u0440\u0430\u0432\u0438\u043B\u043D\u043E\u0442\u043E \u0444\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0438\u0440\u0430\u043D\u0435 \u043D\u0430 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u0430 \u0438 \u043D\u0435 \u043C\u043E\u0433\u0430\u0442 \u0434\u0430 \u0431\u044A\u0434\u0430\u0442 \u0434\u0435\u0430\u043A\u0442\u0438\u0432\u0438\u0440\u0430\u043D\u0438."},functionality:{title:"\u0424\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0430\u043B\u043D\u043E\u0441\u0442",description:"\u0422\u0435\u0437\u0438 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u043F\u043E\u0437\u0432\u043E\u043B\u044F\u0432\u0430\u0442 \u043F\u043E\u0434\u043E\u0431\u0440\u0435\u043D\u0430 \u0444\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0430\u043B\u043D\u043E\u0441\u0442 \u0438 \u043F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u043D\u0435 \u043D\u0430 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u0430."},marketing:{title:"\u041C\u0430\u0440\u043A\u0435\u0442\u0438\u043D\u0433",description:"\u0422\u0435\u0437\u0438 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u0441\u0435 \u0438\u0437\u043F\u043E\u043B\u0437\u0432\u0430\u0442 \u0437\u0430 \u043F\u043E\u043A\u0430\u0437\u0432\u0430\u043D\u0435 \u043D\u0430 \u043F\u043E\u0434\u0445\u043E\u0434\u044F\u0449\u0438 \u0440\u0435\u043A\u043B\u0430\u043C\u0438 \u0438 \u043F\u0440\u043E\u0441\u043B\u0435\u0434\u044F\u0432\u0430\u043D\u0435 \u043D\u0430 \u0442\u044F\u0445\u043D\u0430\u0442\u0430 \u0435\u0444\u0435\u043A\u0442\u0438\u0432\u043D\u043E\u0441\u0442."},measurement:{title:"\u0410\u043D\u0430\u043B\u0438\u0442\u0438\u043A\u0430",description:"\u0422\u0435\u0437\u0438 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u043D\u0438 \u043F\u043E\u043C\u0430\u0433\u0430\u0442 \u0434\u0430 \u0440\u0430\u0437\u0431\u0435\u0440\u0435\u043C \u043A\u0430\u043A \u043F\u043E\u0441\u0435\u0442\u0438\u0442\u0435\u043B\u0438\u0442\u0435 \u0432\u0437\u0430\u0438\u043C\u043E\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u0442 \u0441 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u0430 \u0438 \u0434\u0430 \u043F\u043E\u0434\u043E\u0431\u0440\u0438\u043C \u043D\u0435\u0433\u043E\u0432\u0430\u0442\u0430 \u043F\u0440\u043E\u0438\u0437\u0432\u043E\u0434\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442."},experience:{title:"\u041F\u043E\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043B\u0441\u043A\u043E \u0438\u0437\u0436\u0438\u0432\u044F\u0432\u0430\u043D\u0435",description:"\u0422\u0435\u0437\u0438 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u043D\u0438 \u043F\u043E\u043C\u0430\u0433\u0430\u0442 \u0434\u0430 \u043E\u0441\u0438\u0433\u0443\u0440\u0438\u043C \u043F\u043E-\u0434\u043E\u0431\u0440\u043E \u043F\u043E\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043B\u0441\u043A\u043E \u0438\u0437\u0436\u0438\u0432\u044F\u0432\u0430\u043D\u0435 \u0438 \u0434\u0430 \u0442\u0435\u0441\u0442\u0432\u0430\u043C\u0435 \u043D\u043E\u0432\u0438 \u0444\u0443\u043D\u043A\u0446\u0438\u0438."}},frame:{title:"\u041F\u0440\u0438\u0435\u043C\u0435\u0442\u0435 \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435 \u0437\u0430 {category}, \u0437\u0430 \u0434\u0430 \u0432\u0438\u0434\u0438\u0442\u0435 \u0442\u043E\u0432\u0430 \u0441\u044A\u0434\u044A\u0440\u0436\u0430\u043D\u0438\u0435.",actionButton:"\u0410\u043A\u0442\u0438\u0432\u0438\u0440\u0430\u0439\u0442\u0435 \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435 \u0437\u0430 {category}"},legalLinks:{privacyPolicy:"\u041F\u043E\u043B\u0438\u0442\u0438\u043A\u0430 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",cookiePolicy:"\u041F\u043E\u043B\u0438\u0442\u0438\u043A\u0430 \u0437\u0430 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438",termsOfService:"\u041E\u0431\u0449\u0438 \u0443\u0441\u043B\u043E\u0432\u0438\u044F"},iab:{banner:{title:"\u041D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",description:"\u041D\u0438\u0435 \u0438 \u043D\u0430\u0448\u0438\u0442\u0435 {partnerCount} \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438 \u0441\u044A\u0445\u0440\u0430\u043D\u044F\u0432\u0430\u043C\u0435 \u0438/\u0438\u043B\u0438 \u043E\u0441\u044A\u0449\u0435\u0441\u0442\u0432\u044F\u0432\u0430\u043C\u0435 \u0434\u043E\u0441\u0442\u044A\u043F \u0434\u043E \u0438\u043D\u0444\u043E\u0440\u043C\u0430\u0446\u0438\u044F \u043D\u0430 \u0432\u0430\u0448\u0435\u0442\u043E \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432\u043E \u0438 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0432\u0430\u043C\u0435 \u043B\u0438\u0447\u043D\u0438 \u0434\u0430\u043D\u043D\u0438, \u043A\u0430\u0442\u043E \u0443\u043D\u0438\u043A\u0430\u043B\u043D\u0438 \u0438\u0434\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0442\u043E\u0440\u0438 \u0438 \u0434\u0430\u043D\u043D\u0438 \u0437\u0430 \u0441\u044A\u0440\u0444\u0438\u0440\u0430\u043D\u0435, \u0437\u0430 \u0442\u043E\u0437\u0438 \u0443\u0435\u0431\u0441\u0430\u0439\u0442, \u0437\u0430 \u0434\u0430:",partnersLink:"{count, plural, one {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440} other {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0430}}",andMore:"\u0418 \u043E\u0449\u0435 {count, plural, one {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440} other {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0430}}...",legitimateInterestNotice:"\u041D\u044F\u043A\u043E\u0438 \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438 \u043F\u0440\u0435\u0442\u0435\u043D\u0434\u0438\u0440\u0430\u0442 \u0437\u0430 \u0437\u0430\u043A\u043E\u043D\u0435\u043D \u0438\u043D\u0442\u0435\u0440\u0435\u0441 \u0434\u0430 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0432\u0430\u0442 \u0432\u0430\u0448\u0438\u0442\u0435 \u0434\u0430\u043D\u043D\u0438. \u0418\u043C\u0430\u0442\u0435 \u043F\u0440\u0430\u0432\u043E \u0434\u0430 \u0432\u044A\u0437\u0440\u0430\u0437\u0438\u0442\u0435 \u0441\u0440\u0435\u0449\u0443 \u0442\u0430\u0437\u0438 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0430, \u0434\u0430 \u043F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u0442\u0435 \u0432\u0430\u0448\u0438\u0442\u0435 \u0438\u0437\u0431\u043E\u0440\u0438 \u0438 \u0434\u0430 \u043E\u0442\u0442\u0435\u0433\u043B\u0438\u0442\u0435 \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435\u0442\u043E \u0441\u0438 \u043F\u043E \u0432\u0441\u044F\u043A\u043E \u0432\u0440\u0435\u043C\u0435.",scopeServiceSpecific:"\u0412\u0430\u0448\u0435\u0442\u043E \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435 \u0432\u0430\u0436\u0438 \u0441\u0430\u043C\u043E \u0437\u0430 \u0442\u043E\u0437\u0438 \u0443\u0435\u0431\u0441\u0430\u0439\u0442 \u0438 \u043D\u044F\u043C\u0430 \u0434\u0430 \u043F\u043E\u0432\u043B\u0438\u044F\u0435 \u043D\u0430 \u0434\u0440\u0443\u0433\u0438 \u0443\u0441\u043B\u0443\u0433\u0438.",scopeGroup:"\u0412\u0430\u0448\u0438\u044F\u0442 \u0438\u0437\u0431\u043E\u0440 \u0441\u0435 \u043F\u0440\u0438\u043B\u0430\u0433\u0430 \u043A\u044A\u043C \u0432\u0441\u0438\u0447\u043A\u0438 \u043D\u0430\u0448\u0438 \u0443\u0435\u0431\u0441\u0430\u0439\u0442\u043E\u0432\u0435 \u0432 \u0442\u0430\u0437\u0438 \u0433\u0440\u0443\u043F\u0430."},preferenceCenter:{title:"\u041D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",description:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0430\u0448\u0438\u0442\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442 \u0442\u0443\u043A. \u041C\u043E\u0436\u0435\u0442\u0435 \u0434\u0430 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043A\u043E\u0438 \u0432\u0438\u0434\u043E\u0432\u0435 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438 \u0438 \u0442\u0435\u0445\u043D\u043E\u043B\u043E\u0433\u0438\u0438 \u0437\u0430 \u043F\u0440\u043E\u0441\u043B\u0435\u0434\u044F\u0432\u0430\u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u0442\u0435.",tabs:{purposes:"\u0426\u0435\u043B\u0438",vendors:"\u0414\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u0446\u0438"},purposeItem:{partners:"{count, plural, one {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440} other {# \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0430}}",vendorsUseLegitimateInterest:"{count, plural, one {# \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u043A \u043F\u0440\u0435\u0442\u0435\u043D\u0434\u0438\u0440\u0430} other {# \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u043A\u0430 \u043F\u0440\u0435\u0442\u0435\u043D\u0434\u0438\u0440\u0430\u0442}} \u0437\u0430 \u0437\u0430\u043A\u043E\u043D\u0435\u043D \u0438\u043D\u0442\u0435\u0440\u0435\u0441",examples:"\u041F\u0440\u0438\u043C\u0435\u0440\u0438",partnersUsingPurpose:"\u041F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438, \u0438\u0437\u043F\u043E\u043B\u0437\u0432\u0430\u0449\u0438 \u0442\u0430\u0437\u0438 \u0446\u0435\u043B",withYourPermission:"\u0421 \u0432\u0430\u0448\u0435\u0442\u043E \u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043D\u0438\u0435",legitimateInterest:"\u0417\u0430\u043A\u043E\u043D\u0435\u043D \u0438\u043D\u0442\u0435\u0440\u0435\u0441",objectButton:"\u0412\u044A\u0437\u0440\u0430\u0437\u044F\u0432\u0430\u043C",objected:"\u0412\u044A\u0437\u0440\u0430\u0437\u0435\u043D\u043E",rightToObject:"\u0418\u043C\u0430\u0442\u0435 \u043F\u0440\u0430\u0432\u043E \u0434\u0430 \u0432\u044A\u0437\u0440\u0430\u0437\u0438\u0442\u0435 \u0441\u0440\u0435\u0449\u0443 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u043A\u0430, \u0431\u0430\u0437\u0438\u0440\u0430\u043D\u0430 \u043D\u0430 \u0437\u0430\u043A\u043E\u043D\u0435\u043D \u0438\u043D\u0442\u0435\u0440\u0435\u0441."},specialPurposes:{title:"\u041E\u0441\u043D\u043E\u0432\u043D\u0438 \u0444\u0443\u043D\u043A\u0446\u0438\u0438 (\u0437\u0430\u0434\u044A\u043B\u0436\u0438\u0442\u0435\u043B\u043D\u0438)",tooltip:"\u0422\u0435 \u0441\u0430 \u043D\u0435\u043E\u0431\u0445\u043E\u0434\u0438\u043C\u0438 \u0437\u0430 \u0444\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0430\u043B\u043D\u043E\u0441\u0442\u0442\u0430 \u0438 \u0441\u0438\u0433\u0443\u0440\u043D\u043E\u0441\u0442\u0442\u0430 \u043D\u0430 \u0441\u0430\u0439\u0442\u0430. \u0421\u044A\u0433\u043B\u0430\u0441\u043D\u043E IAB TCF \u043D\u0435 \u043C\u043E\u0436\u0435\u0442\u0435 \u0434\u0430 \u0432\u044A\u0437\u0440\u0430\u0437\u0438\u0442\u0435 \u0441\u0440\u0435\u0449\u0443 \u0442\u0435\u0437\u0438 \u0441\u043F\u0435\u0446\u0438\u0430\u043B\u043D\u0438 \u0446\u0435\u043B\u0438."},vendorList:{search:"\u0422\u044A\u0440\u0441\u0435\u043D\u0435 \u043D\u0430 \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u0446\u0438...",showingCount:"{filtered} \u043E\u0442 {total, plural, one {# \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u043A} other {# \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u043A\u0430}}",iabVendorsHeading:"\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043D\u0438 \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u0446\u0438 \u0432 IAB",iabVendorsNotice:"\u0422\u0435\u0437\u0438 \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438 \u0441\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043D\u0438 \u0432 IAB Transparency & Consent Framework (TCF), \u0438\u043D\u0434\u0443\u0441\u0442\u0440\u0438\u0430\u043B\u0435\u043D \u0441\u0442\u0430\u043D\u0434\u0430\u0440\u0442 \u0437\u0430 \u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043D\u0430 \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435\u0442\u043E",customVendorsHeading:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u043D\u0438 \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438",customVendorsNotice:"\u0422\u043E\u0432\u0430 \u0441\u0430 \u043F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u043D\u0438 \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440\u0438, \u043A\u043E\u0438\u0442\u043E \u043D\u0435 \u0441\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043D\u0438 \u0432 IAB Transparency & Consent Framework (TCF). \u0422\u0435 \u043E\u0431\u0440\u0430\u0431\u043E\u0442\u0432\u0430\u0442 \u0434\u0430\u043D\u043D\u0438 \u0432\u044A\u0437 \u043E\u0441\u043D\u043E\u0432\u0430 \u043D\u0430 \u0432\u0430\u0448\u0435\u0442\u043E \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435 \u0438 \u043C\u043E\u0436\u0435 \u0434\u0430 \u0438\u043C\u0430\u0442 \u0440\u0430\u0437\u043B\u0438\u0447\u043D\u0438 \u043F\u0440\u0430\u043A\u0442\u0438\u043A\u0438 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442 \u043E\u0442 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043D\u0438\u0442\u0435 \u0432 IAB \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u0446\u0438.",purposes:"\u0426\u0435\u043B\u0438",specialPurposes:"\u0421\u043F\u0435\u0446\u0438\u0430\u043B\u043D\u0438 \u0446\u0435\u043B\u0438",specialFeatures:"\u0421\u043F\u0435\u0446\u0438\u0430\u043B\u043D\u0438 \u0444\u0443\u043D\u043A\u0446\u0438\u0438",features:"\u0424\u0443\u043D\u043A\u0446\u0438\u0438",dataCategories:"\u041A\u0430\u0442\u0435\u0433\u043E\u0440\u0438\u0438 \u0434\u0430\u043D\u043D\u0438",usesCookies:"\u0418\u0437\u043F\u043E\u043B\u0437\u0432\u0430 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438",nonCookieAccess:"\u0414\u043E\u0441\u0442\u044A\u043F \u0431\u0435\u0437 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0438",maxAge:"\u041C\u0430\u043A\u0441\u0438\u043C\u0430\u043B\u043D\u0430 \u0434\u0430\u0432\u043D\u043E\u0441\u0442: {days} \u0434",retention:"\u0421\u044A\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435: {days} \u0434",legitimateInterest:"\u0417\u0430\u043A\u043E\u043D\u0435\u043D \u0438\u043D\u0442\u0435\u0440\u0435\u0441",privacyPolicy:"\u041F\u043E\u043B\u0438\u0442\u0438\u043A\u0430 \u0437\u0430 \u043F\u043E\u0432\u0435\u0440\u0438\u0442\u0435\u043B\u043D\u043E\u0441\u0442",storageDisclosure:"\u0414\u0435\u043A\u043B\u0430\u0440\u0430\u0446\u0438\u044F \u0437\u0430 \u0441\u044A\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435",requiredNotice:"\u041D\u0435\u043E\u0431\u0445\u043E\u0434\u0438\u043C\u043E \u0437\u0430 \u0444\u0443\u043D\u043A\u0446\u0438\u043E\u043D\u0430\u043B\u043D\u043E\u0441\u0442\u0442\u0430 \u043D\u0430 \u0441\u0430\u0439\u0442\u0430, \u043D\u0435 \u043C\u043E\u0436\u0435 \u0434\u0430 \u0431\u044A\u0434\u0435 \u0434\u0435\u0430\u043A\u0442\u0438\u0432\u0438\u0440\u0430\u043D\u043E"},footer:{consentStorage:'\u041F\u0440\u0435\u0434\u043F\u043E\u0447\u0438\u0442\u0430\u043D\u0438\u044F\u0442\u0430 \u0437\u0430 \u0441\u044A\u0433\u043B\u0430\u0441\u0438\u0435 \u0441\u0435 \u0441\u044A\u0445\u0440\u0430\u043D\u044F\u0432\u0430\u0442 \u0432 \u0431\u0438\u0441\u043A\u0432\u0438\u0442\u043A\u0430 \u0441 \u0438\u043C\u0435 "euconsent-v2" \u0437\u0430 13 \u043C\u0435\u0441\u0435\u0446\u0430. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"\u041F\u0440\u0438\u0435\u043C\u0438 \u0432\u0441\u0438\u0447\u043A\u0438",rejectAll:"\u041E\u0442\u0445\u0432\u044A\u0440\u043B\u0438 \u0432\u0441\u0438\u0447\u043A\u0438",customize:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u0439",saveSettings:"\u0417\u0430\u043F\u0430\u0437\u0438 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438\u0442\u0435",loading:"\u0417\u0430\u0440\u0435\u0436\u0434\u0430\u043D\u0435...",showingSelectedVendor:"\u041F\u043E\u043A\u0430\u0437\u0432\u0430\u043D\u0435 \u043D\u0430 \u0438\u0437\u0431\u0440\u0430\u043D \u0434\u043E\u0441\u0442\u0430\u0432\u0447\u0438\u043A",clearSelection:"\u0418\u0437\u0447\u0438\u0441\u0442\u0438",customPartner:"\u041F\u0435\u0440\u0441\u043E\u043D\u0430\u043B\u0438\u0437\u0438\u0440\u0430\u043D \u043F\u0430\u0440\u0442\u043D\u044C\u043E\u0440, \u043D\u0435\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043D \u0432 IAB"}}},si={common:{acceptAll:"P\u0159ijmout v\u0161e",rejectAll:"Odm\xEDtnout v\u0161e",customize:"P\u0159izp\u016Fsobit",save:"Ulo\u017Eit nastaven\xED"},cookieBanner:{title:"V\xE1\u017E\xEDme si va\u0161eho soukrom\xED",description:"Tento web pou\u017E\xEDv\xE1 soubory cookie ke zlep\u0161en\xED va\u0161eho prohl\xED\u017Een\xED, anal\xFDze provozu na webu a zobrazov\xE1n\xED personalizovan\xE9ho obsahu."},consentManagerDialog:{title:"Nastaven\xED soukrom\xED",description:"Zde si m\u016F\u017Eete p\u0159izp\u016Fsobit nastaven\xED soukrom\xED. M\u016F\u017Eete zvolit, kter\xE9 typy soubor\u016F cookie a sledovac\xEDch technologi\xED povol\xEDte."},consentTypes:{necessary:{title:"Nezbytn\u011B nutn\xE9",description:"Tyto soubory cookie jsou nezbytn\xE9 pro spr\xE1vn\xE9 fungov\xE1n\xED webov\xFDch str\xE1nek a nelze je deaktivovat."},functionality:{title:"Funk\u010Dnost",description:"Tyto soubory cookie umo\u017E\u0148uj\xED roz\u0161\xED\u0159enou funk\u010Dnost a personalizaci webov\xFDch str\xE1nek."},marketing:{title:"Marketing",description:"Tyto soubory cookie se pou\u017E\xEDvaj\xED k doru\u010Dov\xE1n\xED relevantn\xEDch reklam a sledov\xE1n\xED jejich \xFA\u010Dinnosti."},measurement:{title:"Analytika",description:"Tyto soubory cookie n\xE1m pom\xE1haj\xED pochopit, jak n\xE1v\u0161t\u011Bvn\xEDci interaguj\xED s webem a zlep\u0161uj\xED jeho v\xFDkon."},experience:{title:"U\u017Eivatelsk\xE1 zku\u0161enost",description:"Tyto soubory cookie n\xE1m pom\xE1haj\xED poskytovat lep\u0161\xED u\u017Eivatelskou zku\u0161enost a testovat nov\xE9 funkce."}},frame:{title:"Pro zobrazen\xED tohoto obsahu p\u0159ijm\u011Bte souhlas s kategori\xED {category}.",actionButton:"Povolit souhlas s kategori\xED {category}"},legalLinks:{privacyPolicy:"Z\xE1sady ochrany osobn\xEDch \xFAdaj\u016F",cookiePolicy:"Z\xE1sady pou\u017E\xEDv\xE1n\xED soubor\u016F cookie",termsOfService:"Podm\xEDnky slu\u017Eby"},iab:{banner:{title:"Nastaven\xED soukrom\xED",description:"My a na\u0161ich {partnerCount} partner\u016F ukl\xE1d\xE1me a/nebo p\u0159istupujeme k informac\xEDm na va\u0161em za\u0159\xEDzen\xED a zpracov\xE1v\xE1me osobn\xED \xFAdaje, jako jsou jedine\u010Dn\xE9 identifik\xE1tory a \xFAdaje o prohl\xED\u017Een\xED, pro tento web za \xFA\u010Delem:",partnersLink:"{count, plural, one {# partner} few {# partne\u0159i} other {# partner\u016F}}",andMore:"A dal\u0161\xEDch {count}...",legitimateInterestNotice:"N\u011Bkte\u0159\xED partne\u0159i uplat\u0148uj\xED opr\xE1vn\u011Bn\xFD z\xE1jem na zpracov\xE1n\xED va\u0161ich \xFAdaj\u016F. M\xE1te pr\xE1vo proti tomuto zpracov\xE1n\xED vzn\xE9st n\xE1mitku, p\u0159izp\u016Fsobit sv\xE9 volby a kdykoli odvolat sv\u016Fj souhlas.",scopeServiceSpecific:"V\xE1\u0161 souhlas plat\xED pouze pro tento web a neovlivn\xED jin\xE9 slu\u017Eby.",scopeGroup:"Va\u0161e volba plat\xED pro v\u0161echny na\u0161e weby v t\xE9to skupin\u011B."},preferenceCenter:{title:"Nastaven\xED soukrom\xED",description:"Zde si m\u016F\u017Eete p\u0159izp\u016Fsobit nastaven\xED soukrom\xED. M\u016F\u017Eete zvolit, kter\xE9 typy soubor\u016F cookie a sledovac\xEDch technologi\xED povol\xEDte.",tabs:{purposes:"\xDA\u010Dely",vendors:"Partne\u0159i"},purposeItem:{partners:"{count, plural, one {# partner} few {# partne\u0159i} other {# partner\u016F}}",vendorsUseLegitimateInterest:"{count, plural, one {# partner uplat\u0148uje} few {# partne\u0159i uplat\u0148uj\xED} other {# partner\u016F uplat\u0148uje}} opr\xE1vn\u011Bn\xFD z\xE1jem",examples:"P\u0159\xEDklady",partnersUsingPurpose:"Partne\u0159i vyu\u017E\xEDvaj\xEDc\xED tento \xFA\u010Del",withYourPermission:"S va\u0161\xEDm svolen\xEDm",legitimateInterest:"Opr\xE1vn\u011Bn\xFD z\xE1jem",objectButton:"Vzn\xE9st n\xE1mitku",objected:"N\xE1mitka vznesena",rightToObject:"M\xE1te pr\xE1vo vzn\xE9st n\xE1mitku proti zpracov\xE1n\xED zalo\u017Een\xE9mu na opr\xE1vn\u011Bn\xE9m z\xE1jmu."},specialPurposes:{title:"Z\xE1kladn\xED funkce (povinn\xE9)",tooltip:"Tyto funkce jsou nezbytn\xE9 pro funk\u010Dnost a zabezpe\u010Den\xED webu. Podle IAB TCF nem\u016F\u017Eete proti t\u011Bmto zvl\xE1\u0161tn\xEDm \xFA\u010Del\u016Fm vzn\xE9st n\xE1mitku."},vendorList:{search:"Hledat partnery...",showingCount:"{filtered} z {total, plural, one {# partnera} few {# partner\u016F} other {# partner\u016F}}",iabVendorsHeading:"Partne\u0159i registrovan\xED v IAB",iabVendorsNotice:"Tito partne\u0159i jsou registrov\xE1ni v r\xE1mci IAB Transparency & Consent Framework (TCF), co\u017E je pr\u016Fmyslov\xFD standard pro spr\xE1vu souhlasu",customVendorsHeading:"Vlastn\xED partne\u0159i",customVendorsNotice:"Toto jsou vlastn\xED partne\u0159i, kte\u0159\xED nejsou registrov\xE1ni v r\xE1mci IAB Transparency & Consent Framework (TCF). Zpracov\xE1vaj\xED data na z\xE1klad\u011B va\u0161eho souhlasu a mohou m\xEDt odli\u0161n\xE9 postupy ochrany osobn\xEDch \xFAdaj\u016F ne\u017E partne\u0159i registrovan\xED v IAB.",purposes:"\xDA\u010Dely",specialPurposes:"Zvl\xE1\u0161tn\xED \xFA\u010Dely",specialFeatures:"Zvl\xE1\u0161tn\xED funkce",features:"Funkce",dataCategories:"Kategorie dat",usesCookies:"Pou\u017E\xEDv\xE1 cookies",nonCookieAccess:"P\u0159\xEDstup bez cookies",maxAge:"Maxim\xE1ln\xED doba: {days} d",retention:"Uchov\xE1v\xE1n\xED: {days} d",legitimateInterest:"Opr\xE1vn\u011Bn\xFD z\xE1jem",privacyPolicy:"Z\xE1sady ochrany osobn\xEDch \xFAdaj\u016F",storageDisclosure:"Informace o ukl\xE1d\xE1n\xED",requiredNotice:"Vy\u017Eadov\xE1no pro funk\u010Dnost webu, nelze zak\xE1zat"},footer:{consentStorage:'P\u0159edvolby souhlasu jsou ulo\u017Eeny v cookie s n\xE1zvem "euconsent-v2" po dobu 13 m\u011Bs\xEDc\u016F. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"P\u0159ijmout v\u0161e",rejectAll:"Odm\xEDtnout v\u0161e",customize:"P\u0159izp\u016Fsobit",saveSettings:"Ulo\u017Eit nastaven\xED",loading:"Na\u010D\xEDt\xE1n\xED...",showingSelectedVendor:"Zobrazen\xED vybran\xE9ho partnera",clearSelection:"Vymazat",customPartner:"Vlastn\xED partner neregistrovan\xFD v IAB"}}},ri={common:{acceptAll:"Derbyn pob un",rejectAll:"Gwrthod pob un",customize:"Addasu",save:"Cadw gosodiadau"},cookieBanner:{title:"Rydym yn gwerthfawrogi eich preifatrwydd",description:"Mae'r wefan hon yn defnyddio cwcis i wella eich profiad pori, dadansoddi traffig y wefan, a dangos cynnwys wedi'i bersonoli."},consentManagerDialog:{title:"Gosodiadau preifatrwydd",description:"Addaswch eich gosodiadau preifatrwydd yma. Gallwch ddewis pa fathau o gwcis a thechnolegau tracio rydych yn eu caniat\xE1u."},consentTypes:{necessary:{title:"Cwbl angenrheidiol",description:"Mae'r cwcis hyn yn hanfodol i'r wefan weithredu'n iawn ac ni ellir eu hanalluogi."},functionality:{title:"Swyddogaeth",description:"Mae'r cwcis hyn yn galluogi swyddogaeth a phersonoli gwell o'r wefan."},marketing:{title:"Marchnata",description:"Defnyddir y cwcis hyn i ddarparu hysbysebion perthnasol a thracio eu heffeithiolrwydd."},measurement:{title:"Dadansoddeg",description:"Mae'r cwcis hyn yn ein helpu i ddeall sut mae ymwelwyr yn rhyngweithio \xE2'r wefan a gwella ei pherfformiad."},experience:{title:"Profiad",description:"Mae'r cwcis hyn yn ein helpu i ddarparu profiad defnyddiwr gwell a phrofi nodweddion newydd."}},frame:{title:"Derbyn caniat\xE2d {category} i weld y cynnwys hwn.",actionButton:"Galluogi caniat\xE2d {category}"},legalLinks:{privacyPolicy:"Polisi preifatrwydd",cookiePolicy:"Polisi cwcis",termsOfService:"Telerau gwasanaeth"},iab:{banner:{title:"Gosodiadau preifatrwydd",description:"Rydym ni a\u2019n {partnerCount} partner yn storio a/neu\u2019n cyrchu gwybodaeth ar eich dyfais ac yn prosesu data personol, megis dynodwyr unigryw a data pori, ar gyfer y wefan hon, er mwyn:",partnersLink:"{count} partner",andMore:"Ac {count} arall...",legitimateInterestNotice:"Mae rhai partneriaid yn hawlio buddiant cyfreithlon i brosesu eich data. Mae gennych hawl i wrthwynebu\u2019r prosesu hwn, addasu eich dewisiadau, a thynnu eich cydsyniad yn \xF4l unrhyw bryd.",scopeServiceSpecific:"Mae eich caniat\xE2d yn berthnasol i\u2019r wefan hon yn unig ac ni fydd yn effeithio ar wasanaethau eraill.",scopeGroup:"Mae eich dewis yn berthnasol ar draws ein gwefannau yn y gr\u0175p hwn."},preferenceCenter:{title:"Gosodiadau preifatrwydd",description:"Addaswch eich gosodiadau preifatrwydd yma. Gallwch ddewis pa fathau o gwcis a thechnolegau tracio rydych yn eu caniat\xE1u.",tabs:{purposes:"Dibenion",vendors:"Gwerthwyr"},purposeItem:{partners:"{count} partner",vendorsUseLegitimateInterest:"{count} gwerthwr yn hawlio buddiant cyfreithlon",examples:"Enghreifftiau",partnersUsingPurpose:"Partneriaid sy\u2019n Defnyddio\u2019r Diben Hwn",withYourPermission:"Gyda\u2019ch Caniat\xE2d",legitimateInterest:"Buddiant Cyfreithlon",objectButton:"Gwrthwynebu",objected:"Gwrthwynebwyd",rightToObject:"Mae gennych hawl i wrthwynebu prosesu sy\u2019n seiliedig ar fuddiant cyfreithlon."},specialPurposes:{title:"Swyddogaethau Hanfodol (Angenrheidiol)",tooltip:"Mae\u2019r rhain yn angenrheidiol ar gyfer swyddogaethau a diogelwch y wefan. Yn unol ag IAB TCF, ni allwch wrthwynebu\u2019r dibenion arbennig hyn."},vendorList:{search:"Chwilio gwerthwyr...",showingCount:"{filtered} o {total} gwerthwr",iabVendorsHeading:"Gwerthwyr Cofrestredig IAB",iabVendorsNotice:"Mae\u2019r partneriaid hyn wedi\u2019u cofrestru gyda Fframwaith Tryloywder a Chydsyniad (TCF) yr IAB, safon diwydiant ar gyfer rheoli cydsyniad",customVendorsHeading:"Partneriaid Personol",customVendorsNotice:"Partneriaid personol yw\u2019r rhain nad ydynt wedi\u2019u cofrestru gyda Fframwaith Tryloywder a Chydsyniad (TCF) yr IAB. Maent yn prosesu data yn seiliedig ar eich cydsyniad ac fe allant fod ag arferion preifatrwydd gwahanol i werthwyr cofrestredig IAB.",purposes:"Dibenion",specialPurposes:"Dibenion Arbennig",specialFeatures:"Nodweddion Arbennig",features:"Nodweddion",dataCategories:"Categor\xEFau Data",usesCookies:"Yn Defnyddio Cwcis",nonCookieAccess:"Mynediad Heb Gwcis",maxAge:"Oed Uchaf: {days}d",retention:"Cadw: {days}d",legitimateInterest:"Buddiant Cyf.",privacyPolicy:"Polisi Preifatrwydd",storageDisclosure:"Datgelu Storio",requiredNotice:"Angenrheidiol ar gyfer swyddogaeth y wefan, ni ellir ei analluogi"},footer:{consentStorage:'Mae dewisiadau cydsyniad yn cael eu storio mewn cwci o\u2019r enw "euconsent-v2" am 13 mis. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Derbyn pob un",rejectAll:"Gwrthod pob un",customize:"Addasu",saveSettings:"Cadw gosodiadau",loading:"Wrthi\u2019n llwytho...",showingSelectedVendor:"Yn dangos y gwerthwr a ddewiswyd",clearSelection:"Clirio",customPartner:"Partner personol heb ei gofrestru gyda\u2019r IAB"}}},oi={common:{acceptAll:"Accepter alle",rejectAll:"Afvis alle",customize:"Tilpas",save:"Gem indstillinger"},cookieBanner:{title:"Vi v\xE6rds\xE6tter dit privatliv",description:"Denne side bruger cookies til at forbedre din browsingoplevelse, analysere trafikken p\xE5 siden og vise personligt tilpasset indhold."},consentManagerDialog:{title:"Privatlivsindstillinger",description:"Tilpas dine privatlivsindstillinger her. Du kan v\xE6lge, hvilke typer cookies og sporingsteknologier du vil tillade."},consentTypes:{necessary:{title:"Strengt n\xF8dvendige",description:"Disse cookies er essentielle for, at hjemmesiden fungerer korrekt, og de kan ikke deaktiveres."},functionality:{title:"Funktionalitet",description:"Disse cookies muligg\xF8r forbedret funktionalitet og personalisering af hjemmesiden."},marketing:{title:"Markedsf\xF8ring",description:"Disse cookies bruges til at levere relevante annoncer og spore deres effektivitet."},measurement:{title:"Analyse",description:"Disse cookies hj\xE6lper os med at forst\xE5, hvordan bes\xF8gende interagerer med hjemmesiden og forbedre dens ydeevne."},experience:{title:"Oplevelse",description:"Disse cookies hj\xE6lper os med at levere en bedre brugeroplevelse og teste nye funktioner."}},frame:{title:"Accepter {category}-samtykke for at se dette indhold.",actionButton:"Aktiv\xE9r {category}-samtykke"},legalLinks:{privacyPolicy:"Privatlivspolitik",cookiePolicy:"Cookiepolitik",termsOfService:"Servicevilk\xE5r"},iab:{banner:{title:"Privatlivsindstillinger",description:"Vi og vores {partnerCount} partnere gemmer og/eller f\xE5r adgang til oplysninger p\xE5 din enhed og behandler personoplysninger, s\xE5som unikke id'er og browserdata, for dette website, for at:",partnersLink:"{count} partnere",andMore:"Og {count} mere...",legitimateInterestNotice:"Nogle partnere p\xE5ber\xE5ber sig legitim interesse for at behandle dine data. Du har ret til at g\xF8re indsigelse mod denne behandling, tilpasse dine valg og tr\xE6kke dit samtykke tilbage til enhver tid.",scopeServiceSpecific:"Dit samtykke g\xE6lder kun for dette websted og vil ikke p\xE5virke andre tjenester.",scopeGroup:"Dit valg g\xE6lder p\xE5 tv\xE6rs af vores websteder i denne gruppe."},preferenceCenter:{title:"Privatlivsindstillinger",description:"Tilpas dine privatlivsindstillinger her. Du kan v\xE6lge, hvilke typer cookies og sporingsteknologier du vil tillade.",tabs:{purposes:"Form\xE5l",vendors:"Leverand\xF8rer"},purposeItem:{partners:"{count} partnere",vendorsUseLegitimateInterest:"{count} leverand\xF8rer p\xE5ber\xE5ber sig legitim interesse",examples:"Eksempler",partnersUsingPurpose:"Partnere, der bruger dette form\xE5l",withYourPermission:"Med dit samtykke",legitimateInterest:"Legitim interesse",objectButton:"G\xF8r indsigelse",objected:"Indsigelse gjort",rightToObject:"Du har ret til at g\xF8re indsigelse mod behandling baseret p\xE5 legitim interesse."},specialPurposes:{title:"N\xF8dvendige funktioner (p\xE5kr\xE6vet)",tooltip:"Disse er n\xF8dvendige for sidens funktionalitet og sikkerhed. If\xF8lge IAB TCF kan du ikke g\xF8re indsigelse mod disse s\xE6rlige form\xE5l."},vendorList:{search:"S\xF8g leverand\xF8rer...",showingCount:"Viser {filtered} af {total} leverand\xF8rer",iabVendorsHeading:"IAB-registrerede leverand\xF8rer",iabVendorsNotice:"Disse partnere er registreret hos IAB Transparency & Consent Framework (TCF), en branchestandard for h\xE5ndtering af samtykke",customVendorsHeading:"Brugerdefinerede partnere",customVendorsNotice:"Disse er tilpassede partnere, som ikke er registreret hos IAB Transparency & Consent Framework (TCF). De behandler data baseret p\xE5 dit samtykke og kan have andre privatlivspraksisser end IAB-registrerede leverand\xF8rer.",purposes:"Form\xE5l",specialPurposes:"S\xE6rlige form\xE5l",specialFeatures:"S\xE6rlige funktioner",features:"Funktioner",dataCategories:"Datakategorier",usesCookies:"Bruger cookies",nonCookieAccess:"Adgang uden cookies",maxAge:"Maks. alder: {days}d",retention:"Opbevaring: {days}d",legitimateInterest:"Legitim interesse",privacyPolicy:"Privatlivspolitik",storageDisclosure:"Oplysning om lagring",requiredNotice:"P\xE5kr\xE6vet for sidens funktionalitet, kan ikke deaktiveres"},footer:{consentStorage:'Samtykkepr\xE6ferencer gemmes i en cookie med navnet "euconsent-v2" i 13 m\xE5neder. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Accepter alle",rejectAll:"Afvis alle",customize:"Tilpas",saveSettings:"Gem indstillinger",loading:"Indl\xE6ser...",showingSelectedVendor:"Viser valgt leverand\xF8r",clearSelection:"Ryd",customPartner:"Tilpasset partner, ikke registreret hos IAB"}}},ai={common:{acceptAll:"Alle akzeptieren",rejectAll:"Alle ablehnen",customize:"Anpassen",save:"Einstellungen speichern"},cookieBanner:{title:"Wir respektieren Deine Privatsph\xE4re.",description:"Diese Website verwendet Cookies, um Deine Surf-Erfahrung zu verbessern, den Seitenverkehr zu analysieren und pers\xF6nliche Inhalte anzuzeigen."},consentManagerDialog:{title:"Einstellungen",description:"Passe Deine Datenschutz-Einstellungen hier an. W\xE4hle aus, welche Arten von Cookies und Tracking-Technologien zugelassen werden."},consentTypes:{necessary:{title:"Unbedingt erforderliche Cookies",description:"Diese Cookies sind f\xFCr das reibungslose Funktionieren der Website unerl\xE4sslich und k\xF6nnen nicht deaktiviert werden."},functionality:{title:"Funktionalit\xE4t",description:"Diese Cookies erm\xF6glichen erweiterte Funktionalit\xE4ten und eine Personalisierung der Website."},marketing:{title:"Marketing",description:"Diese Cookies werden verwendet, um relevante Werbung anzuzeigen und ihre Wirksamkeit zu messen."},measurement:{title:"Analyse",description:"Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren um die Surf-Erfahrung zu verbessern."},experience:{title:"Erfahrung",description:"Diese Cookies helfen uns dabei, ein besseres Nutzerlebnis zu bieten und neue Funktionen zu testen."}},frame:{title:"Akzeptieren Sie {category}, um diesen Inhalt anzuzeigen.",actionButton:"Zustimmung f\xFCr {category} aktivieren"},legalLinks:{privacyPolicy:"Datenschutzerkl\xE4rung",cookiePolicy:"Cookie-Richtlinie",termsOfService:"Nutzungsbedingungen"},iab:{banner:{title:"Datenschutz-Einstellungen",description:"Wir und unsere {partnerCount} Partner speichern und/oder greifen auf Informationen auf Deinem Ger\xE4t zu und verarbeiten personenbezogene Daten, wie eindeutige Kennungen und Browsing-Daten, f\xFCr diese Website, um:",partnersLink:"{count} Partner",andMore:"Und {count} weitere...",legitimateInterestNotice:"Einige Partner beanspruchen ein berechtigtes Interesse zur Verarbeitung Deiner Daten. Du hast das Recht, dieser Verarbeitung zu widersprechen, Deine Auswahl anzupassen und Deine Einwilligung jederzeit zu widerrufen.",scopeServiceSpecific:"Deine Einwilligung gilt nur f\xFCr diese Website und hat keinen Einfluss auf andere Dienste.",scopeGroup:"Ihre Auswahl gilt f\xFCr alle unsere Websites in dieser Gruppe."},preferenceCenter:{title:"Datenschutz-Einstellungen",description:"Passe Deine Datenschutz-Einstellungen hier an. W\xE4hle aus, welche Arten von Cookies und Tracking-Technologien zugelassen werden.",tabs:{purposes:"Zwecke",vendors:"Anbieter"},purposeItem:{partners:"{count} Partner",vendorsUseLegitimateInterest:"{count} Anbieter beanspruchen berechtigtes Interesse",examples:"Beispiele",partnersUsingPurpose:"Partner, die diesen Zweck nutzen",withYourPermission:"Mit Deiner Erlaubnis",legitimateInterest:"Berechtigtes Interesse",objectButton:"Widersprechen",objected:"Widersprochen",rightToObject:"Du hast das Recht, der Verarbeitung auf Grundlage berechtigten Interesses zu widersprechen."},specialPurposes:{title:"Wesentliche Funktionen (erforderlich)",tooltip:"Diese sind f\xFCr die Funktionalit\xE4t und Sicherheit der Website erforderlich. Gem\xE4\xDF IAB TCF kannst Du diesen besonderen Zwecken nicht widersprechen."},vendorList:{search:"Anbieter suchen...",showingCount:"{filtered} von {total} Anbietern",iabVendorsHeading:"IAB-registrierte Anbieter",iabVendorsNotice:"Diese Partner sind beim IAB Transparency & Consent Framework (TCF) registriert, einem Industriestandard f\xFCr die Verwaltung von Einwilligungen",customVendorsHeading:"Benutzerdefinierte Partner",customVendorsNotice:"Dies sind benutzerdefinierte Partner, die nicht beim IAB Transparency & Consent Framework (TCF) registriert sind. Sie verarbeiten Daten auf Grundlage Ihrer Einwilligung und k\xF6nnen andere Datenschutzpraktiken haben als IAB-registrierte Anbieter.",purposes:"Zwecke",specialPurposes:"Besondere Zwecke",specialFeatures:"Besondere Merkmale",features:"Merkmale",dataCategories:"Datenkategorien",usesCookies:"Verwendet Cookies",nonCookieAccess:"Zugriff ohne Cookies",maxAge:"Max. Alter: {days} Tage",retention:"Aufbewahrung: {days} Tage",legitimateInterest:"Berecht. Interesse",privacyPolicy:"Datenschutzerkl\xE4rung",storageDisclosure:"Speicheroffenlegung",requiredNotice:"Erforderlich f\xFCr die Funktionalit\xE4t der Website, kann nicht deaktiviert werden"},footer:{consentStorage:'Einwilligungspr\xE4ferenzen werden in einem Cookie namens "euconsent-v2" f\xFCr 13 Monate gespeichert. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Alle akzeptieren",rejectAll:"Alle ablehnen",customize:"Anpassen",saveSettings:"Einstellungen speichern",loading:"Wird geladen...",showingSelectedVendor:"Ausgew\xE4hlter Anbieter wird angezeigt",clearSelection:"L\xF6schen",customPartner:"Benutzerdefinierter Partner, nicht beim IAB registriert"}}},ci={common:{acceptAll:"\u0391\u03C0\u03BF\u03B4\u03BF\u03C7\u03AE \u03CC\u03BB\u03C9\u03BD",rejectAll:"\u0391\u03C0\u03CC\u03C1\u03C1\u03B9\u03C8\u03B7 \u03CC\u03BB\u03C9\u03BD",customize:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03BF\u03B3\u03AE",save:"\u0391\u03C0\u03BF\u03B8\u03AE\u03BA\u03B5\u03C5\u03C3\u03B7 \u03C1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03C9\u03BD"},cookieBanner:{title:"\u0395\u03BA\u03C4\u03B9\u03BC\u03BF\u03CD\u03BC\u03B5 \u03C4\u03BF \u03B1\u03C0\u03CC\u03C1\u03C1\u03B7\u03C4\u03CC \u03C3\u03B1\u03C2",description:"\u0391\u03C5\u03C4\u03CC\u03C2 \u03BF \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C2 \u03C7\u03C1\u03B7\u03C3\u03B9\u03BC\u03BF\u03C0\u03BF\u03B9\u03B5\u03AF cookies \u03B3\u03B9\u03B1 \u03C4\u03B7 \u03B2\u03B5\u03BB\u03C4\u03AF\u03C9\u03C3\u03B7 \u03C4\u03B7\u03C2 \u03B5\u03BC\u03C0\u03B5\u03B9\u03C1\u03AF\u03B1\u03C2 \u03C0\u03B5\u03C1\u03B9\u03AE\u03B3\u03B7\u03C3\u03AE\u03C2 \u03C3\u03B1\u03C2, \u03C4\u03B7\u03BD \u03B1\u03BD\u03AC\u03BB\u03C5\u03C3\u03B7 \u03C4\u03B7\u03C2 \u03B5\u03C0\u03B9\u03C3\u03BA\u03B5\u03C8\u03B9\u03BC\u03CC\u03C4\u03B7\u03C4\u03B1\u03C2 \u03C4\u03BF\u03C5 \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C5 \u03BA\u03B1\u03B9 \u03C4\u03B7\u03BD \u03C0\u03C1\u03BF\u03B2\u03BF\u03BB\u03AE \u03B5\u03BE\u03B1\u03C4\u03BF\u03BC\u03B9\u03BA\u03B5\u03C5\u03BC\u03AD\u03BD\u03BF\u03C5 \u03C0\u03B5\u03C1\u03B9\u03B5\u03C7\u03BF\u03BC\u03AD\u03BD\u03BF\u03C5."},consentManagerDialog:{title:"\u03A1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03B9\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5",description:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03CC\u03C3\u03C4\u03B5 \u03C4\u03B9\u03C2 \u03C1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03B9\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5 \u03C3\u03B1\u03C2 \u03B5\u03B4\u03CE. \u039C\u03C0\u03BF\u03C1\u03B5\u03AF\u03C4\u03B5 \u03BD\u03B1 \u03B5\u03C0\u03B9\u03BB\u03AD\u03BE\u03B5\u03C4\u03B5 \u03C0\u03BF\u03B9\u03BF\u03C5\u03C2 \u03C4\u03CD\u03C0\u03BF\u03C5\u03C2 cookies \u03BA\u03B1\u03B9 \u03C4\u03B5\u03C7\u03BD\u03BF\u03BB\u03BF\u03B3\u03B9\u03CE\u03BD \u03C0\u03B1\u03C1\u03B1\u03BA\u03BF\u03BB\u03BF\u03CD\u03B8\u03B7\u03C3\u03B7\u03C2 \u03B5\u03C0\u03B9\u03C4\u03C1\u03AD\u03C0\u03B5\u03C4\u03B5."},consentTypes:{necessary:{title:"\u0391\u03C0\u03BF\u03BB\u03CD\u03C4\u03C9\u03C2 \u03B1\u03C0\u03B1\u03C1\u03B1\u03AF\u03C4\u03B7\u03C4\u03B1",description:"\u0391\u03C5\u03C4\u03AC \u03C4\u03B1 cookies \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B1\u03C0\u03B1\u03C1\u03B1\u03AF\u03C4\u03B7\u03C4\u03B1 \u03B3\u03B9\u03B1 \u03C4\u03B7 \u03C3\u03C9\u03C3\u03C4\u03AE \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03AF\u03B1 \u03C4\u03BF\u03C5 \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C5 \u03BA\u03B1\u03B9 \u03B4\u03B5\u03BD \u03BC\u03C0\u03BF\u03C1\u03BF\u03CD\u03BD \u03BD\u03B1 \u03B1\u03C0\u03B5\u03BD\u03B5\u03C1\u03B3\u03BF\u03C0\u03BF\u03B9\u03B7\u03B8\u03BF\u03CD\u03BD."},functionality:{title:"\u039B\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03B9\u03BA\u03CC\u03C4\u03B7\u03C4\u03B1",description:"\u0391\u03C5\u03C4\u03AC \u03C4\u03B1 cookies \u03B5\u03C0\u03B9\u03C4\u03C1\u03AD\u03C0\u03BF\u03C5\u03BD \u03B2\u03B5\u03BB\u03C4\u03B9\u03C9\u03BC\u03AD\u03BD\u03B7 \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03B9\u03BA\u03CC\u03C4\u03B7\u03C4\u03B1 \u03BA\u03B1\u03B9 \u03B5\u03BE\u03B1\u03C4\u03BF\u03BC\u03AF\u03BA\u03B5\u03C5\u03C3\u03B7 \u03C4\u03BF\u03C5 \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C5."},marketing:{title:"\u039C\u03AC\u03C1\u03BA\u03B5\u03C4\u03B9\u03BD\u03B3\u03BA",description:"\u0391\u03C5\u03C4\u03AC \u03C4\u03B1 cookies \u03C7\u03C1\u03B7\u03C3\u03B9\u03BC\u03BF\u03C0\u03BF\u03B9\u03BF\u03CD\u03BD\u03C4\u03B1\u03B9 \u03B3\u03B9\u03B1 \u03C4\u03B7\u03BD \u03C0\u03C1\u03BF\u03B2\u03BF\u03BB\u03AE \u03C3\u03C7\u03B5\u03C4\u03B9\u03BA\u03CE\u03BD \u03B4\u03B9\u03B1\u03C6\u03B7\u03BC\u03AF\u03C3\u03B5\u03C9\u03BD \u03BA\u03B1\u03B9 \u03C4\u03B7\u03BD \u03C0\u03B1\u03C1\u03B1\u03BA\u03BF\u03BB\u03BF\u03CD\u03B8\u03B7\u03C3\u03B7 \u03C4\u03B7\u03C2 \u03B1\u03C0\u03BF\u03C4\u03B5\u03BB\u03B5\u03C3\u03BC\u03B1\u03C4\u03B9\u03BA\u03CC\u03C4\u03B7\u03C4\u03AC\u03C2 \u03C4\u03BF\u03C5\u03C2."},measurement:{title:"\u0391\u03BD\u03B1\u03BB\u03C5\u03C4\u03B9\u03BA\u03AC \u03C3\u03C4\u03BF\u03B9\u03C7\u03B5\u03AF\u03B1",description:"\u0391\u03C5\u03C4\u03AC \u03C4\u03B1 cookies \u03BC\u03B1\u03C2 \u03B2\u03BF\u03B7\u03B8\u03BF\u03CD\u03BD \u03BD\u03B1 \u03BA\u03B1\u03C4\u03B1\u03BD\u03BF\u03AE\u03C3\u03BF\u03C5\u03BC\u03B5 \u03C0\u03CE\u03C2 \u03B1\u03BB\u03BB\u03B7\u03BB\u03B5\u03C0\u03B9\u03B4\u03C1\u03BF\u03CD\u03BD \u03BF\u03B9 \u03B5\u03C0\u03B9\u03C3\u03BA\u03AD\u03C0\u03C4\u03B5\u03C2 \u03BC\u03B5 \u03C4\u03BF\u03BD \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF \u03BA\u03B1\u03B9 \u03BD\u03B1 \u03B2\u03B5\u03BB\u03C4\u03B9\u03CE\u03C3\u03BF\u03C5\u03BC\u03B5 \u03C4\u03B7\u03BD \u03B1\u03C0\u03CC\u03B4\u03BF\u03C3\u03AE \u03C4\u03BF\u03C5."},experience:{title:"\u0395\u03BC\u03C0\u03B5\u03B9\u03C1\u03AF\u03B1",description:"\u0391\u03C5\u03C4\u03AC \u03C4\u03B1 cookies \u03BC\u03B1\u03C2 \u03B2\u03BF\u03B7\u03B8\u03BF\u03CD\u03BD \u03BD\u03B1 \u03C0\u03B1\u03C1\u03AD\u03C7\u03BF\u03C5\u03BC\u03B5 \u03BA\u03B1\u03BB\u03CD\u03C4\u03B5\u03C1\u03B7 \u03B5\u03BC\u03C0\u03B5\u03B9\u03C1\u03AF\u03B1 \u03C7\u03C1\u03AE\u03C3\u03C4\u03B7 \u03BA\u03B1\u03B9 \u03BD\u03B1 \u03B4\u03BF\u03BA\u03B9\u03BC\u03AC\u03B6\u03BF\u03C5\u03BC\u03B5 \u03BD\u03AD\u03B5\u03C2 \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03AF\u03B5\u03C2."}},frame:{title:"\u0391\u03C0\u03BF\u03B4\u03B5\u03C7\u03C4\u03B5\u03AF\u03C4\u03B5 \u03C4\u03B7 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03B7 {category} \u03B3\u03B9\u03B1 \u03BD\u03B1 \u03B4\u03B5\u03AF\u03C4\u03B5 \u03B1\u03C5\u03C4\u03CC \u03C4\u03BF \u03C0\u03B5\u03C1\u03B9\u03B5\u03C7\u03CC\u03BC\u03B5\u03BD\u03BF.",actionButton:"\u0395\u03BD\u03B5\u03C1\u03B3\u03BF\u03C0\u03BF\u03AF\u03B7\u03C3\u03B7 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03B7\u03C2 {category}"},legalLinks:{privacyPolicy:"\u03A0\u03BF\u03BB\u03B9\u03C4\u03B9\u03BA\u03AE \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5",cookiePolicy:"\u03A0\u03BF\u03BB\u03B9\u03C4\u03B9\u03BA\u03AE cookies",termsOfService:"\u038C\u03C1\u03BF\u03B9 \u03C7\u03C1\u03AE\u03C3\u03B7\u03C2"},iab:{banner:{title:"\u03A1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03B9\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5",description:"\u0395\u03BC\u03B5\u03AF\u03C2 \u03BA\u03B1\u03B9 \u03BF\u03B9 {partnerCount} \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03BC\u03B1\u03C2 \u03B1\u03C0\u03BF\u03B8\u03B7\u03BA\u03B5\u03CD\u03BF\u03C5\u03BC\u03B5 \u03AE/\u03BA\u03B1\u03B9 \u03AD\u03C7\u03BF\u03C5\u03BC\u03B5 \u03C0\u03C1\u03CC\u03C3\u03B2\u03B1\u03C3\u03B7 \u03C3\u03B5 \u03C0\u03BB\u03B7\u03C1\u03BF\u03C6\u03BF\u03C1\u03AF\u03B5\u03C2 \u03C3\u03C4\u03B7 \u03C3\u03C5\u03C3\u03BA\u03B5\u03C5\u03AE \u03C3\u03B1\u03C2 \u03BA\u03B1\u03B9 \u03B5\u03C0\u03B5\u03BE\u03B5\u03C1\u03B3\u03B1\u03B6\u03CC\u03BC\u03B1\u03C3\u03C4\u03B5 \u03C0\u03C1\u03BF\u03C3\u03C9\u03C0\u03B9\u03BA\u03AC \u03B4\u03B5\u03B4\u03BF\u03BC\u03AD\u03BD\u03B1, \u03CC\u03C0\u03C9\u03C2 \u03BC\u03BF\u03BD\u03B1\u03B4\u03B9\u03BA\u03AC \u03B1\u03BD\u03B1\u03B3\u03BD\u03C9\u03C1\u03B9\u03C3\u03C4\u03B9\u03BA\u03AC \u03BA\u03B1\u03B9 \u03B4\u03B5\u03B4\u03BF\u03BC\u03AD\u03BD\u03B1 \u03C0\u03B5\u03C1\u03B9\u03AE\u03B3\u03B7\u03C3\u03B7\u03C2, \u03B3\u03B9\u03B1 \u03B1\u03C5\u03C4\u03CC\u03BD \u03C4\u03BF\u03BD \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF, \u03B3\u03B9\u03B1 \u03BD\u03B1:",partnersLink:"{count} \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2",andMore:"\u039A\u03B1\u03B9 {count} \u03B1\u03BA\u03CC\u03BC\u03B7...",legitimateInterestNotice:"\u039F\u03C1\u03B9\u03C3\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03B5\u03C0\u03B9\u03BA\u03B1\u03BB\u03BF\u03CD\u03BD\u03C4\u03B1\u03B9 \u03AD\u03BD\u03BD\u03BF\u03BC\u03BF \u03C3\u03C5\u03BC\u03C6\u03AD\u03C1\u03BF\u03BD \u03B3\u03B9\u03B1 \u03C4\u03B7\u03BD \u03B5\u03C0\u03B5\u03BE\u03B5\u03C1\u03B3\u03B1\u03C3\u03AF\u03B1 \u03C4\u03C9\u03BD \u03B4\u03B5\u03B4\u03BF\u03BC\u03AD\u03BD\u03C9\u03BD \u03C3\u03B1\u03C2. \u0388\u03C7\u03B5\u03C4\u03B5 \u03C4\u03BF \u03B4\u03B9\u03BA\u03B1\u03AF\u03C9\u03BC\u03B1 \u03BD\u03B1 \u03B1\u03BD\u03C4\u03B9\u03C4\u03B1\u03C7\u03B8\u03B5\u03AF\u03C4\u03B5 \u03C3\u03B5 \u03B1\u03C5\u03C4\u03AE\u03BD \u03C4\u03B7\u03BD \u03B5\u03C0\u03B5\u03BE\u03B5\u03C1\u03B3\u03B1\u03C3\u03AF\u03B1, \u03BD\u03B1 \u03C0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03CC\u03C3\u03B5\u03C4\u03B5 \u03C4\u03B9\u03C2 \u03B5\u03C0\u03B9\u03BB\u03BF\u03B3\u03AD\u03C2 \u03C3\u03B1\u03C2 \u03BA\u03B1\u03B9 \u03BD\u03B1 \u03B1\u03BD\u03B1\u03BA\u03B1\u03BB\u03AD\u03C3\u03B5\u03C4\u03B5 \u03C4\u03B7 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03AE \u03C3\u03B1\u03C2 \u03B1\u03BD\u03AC \u03C0\u03AC\u03C3\u03B1 \u03C3\u03C4\u03B9\u03B3\u03BC\u03AE.",scopeServiceSpecific:"\u0397 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03AE \u03C3\u03B1\u03C2 \u03B9\u03C3\u03C7\u03CD\u03B5\u03B9 \u03BC\u03CC\u03BD\u03BF \u03B3\u03B9\u03B1 \u03B1\u03C5\u03C4\u03CC\u03BD \u03C4\u03BF\u03BD \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF \u03BA\u03B1\u03B9 \u03B4\u03B5\u03BD \u03B8\u03B1 \u03B5\u03C0\u03B7\u03C1\u03B5\u03AC\u03C3\u03B5\u03B9 \u03AC\u03BB\u03BB\u03B5\u03C2 \u03C5\u03C0\u03B7\u03C1\u03B5\u03C3\u03AF\u03B5\u03C2.",scopeGroup:"\u0397 \u03B5\u03C0\u03B9\u03BB\u03BF\u03B3\u03AE \u03C3\u03B1\u03C2 \u03B9\u03C3\u03C7\u03CD\u03B5\u03B9 \u03B3\u03B9\u03B1 \u03CC\u03BB\u03B5\u03C2 \u03C4\u03B9\u03C2 \u03B9\u03C3\u03C4\u03BF\u03C3\u03B5\u03BB\u03AF\u03B4\u03B5\u03C2 \u03BC\u03B1\u03C2 \u03C3\u03B5 \u03B1\u03C5\u03C4\u03AE \u03C4\u03B7\u03BD \u03BF\u03BC\u03AC\u03B4\u03B1."},preferenceCenter:{title:"\u03A1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03B9\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5",description:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03CC\u03C3\u03C4\u03B5 \u03C4\u03B9\u03C2 \u03C1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03B9\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5 \u03C3\u03B1\u03C2 \u03B5\u03B4\u03CE. \u039C\u03C0\u03BF\u03C1\u03B5\u03AF\u03C4\u03B5 \u03BD\u03B1 \u03B5\u03C0\u03B9\u03BB\u03AD\u03BE\u03B5\u03C4\u03B5 \u03C0\u03BF\u03B9\u03BF\u03C5\u03C2 \u03C4\u03CD\u03C0\u03BF\u03C5\u03C2 cookies \u03BA\u03B1\u03B9 \u03C4\u03B5\u03C7\u03BD\u03BF\u03BB\u03BF\u03B3\u03B9\u03CE\u03BD \u03C0\u03B1\u03C1\u03B1\u03BA\u03BF\u03BB\u03BF\u03CD\u03B8\u03B7\u03C3\u03B7\u03C2 \u03B5\u03C0\u03B9\u03C4\u03C1\u03AD\u03C0\u03B5\u03C4\u03B5.",tabs:{purposes:"\u03A3\u03BA\u03BF\u03C0\u03BF\u03AF",vendors:"\u03A3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2"},purposeItem:{partners:"{count} \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2",vendorsUseLegitimateInterest:"{count} \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03B5\u03C0\u03B9\u03BA\u03B1\u03BB\u03BF\u03CD\u03BD\u03C4\u03B1\u03B9 \u03AD\u03BD\u03BD\u03BF\u03BC\u03BF \u03C3\u03C5\u03BC\u03C6\u03AD\u03C1\u03BF\u03BD",examples:"\u03A0\u03B1\u03C1\u03B1\u03B4\u03B5\u03AF\u03B3\u03BC\u03B1\u03C4\u03B1",partnersUsingPurpose:"\u03A3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03C0\u03BF\u03C5 \u03C7\u03C1\u03B7\u03C3\u03B9\u03BC\u03BF\u03C0\u03BF\u03B9\u03BF\u03CD\u03BD \u03B1\u03C5\u03C4\u03CC\u03BD \u03C4\u03BF\u03BD \u03C3\u03BA\u03BF\u03C0\u03CC",withYourPermission:"\u039C\u03B5 \u03C4\u03B7 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03AE \u03C3\u03B1\u03C2",legitimateInterest:"\u0388\u03BD\u03BD\u03BF\u03BC\u03BF \u03C3\u03C5\u03BC\u03C6\u03AD\u03C1\u03BF\u03BD",objectButton:"\u0391\u03BD\u03C4\u03AF\u03C1\u03C1\u03B7\u03C3\u03B7",objected:"\u0391\u03BD\u03C4\u03B9\u03C4\u03AC\u03C7\u03B8\u03B7\u03BA\u03B5",rightToObject:"\u0388\u03C7\u03B5\u03C4\u03B5 \u03C4\u03BF \u03B4\u03B9\u03BA\u03B1\u03AF\u03C9\u03BC\u03B1 \u03BD\u03B1 \u03B1\u03BD\u03C4\u03B9\u03C4\u03B1\u03C7\u03B8\u03B5\u03AF\u03C4\u03B5 \u03C3\u03C4\u03B7\u03BD \u03B5\u03C0\u03B5\u03BE\u03B5\u03C1\u03B3\u03B1\u03C3\u03AF\u03B1 \u03C0\u03BF\u03C5 \u03B2\u03B1\u03C3\u03AF\u03B6\u03B5\u03C4\u03B1\u03B9 \u03C3\u03B5 \u03AD\u03BD\u03BD\u03BF\u03BC\u03BF \u03C3\u03C5\u03BC\u03C6\u03AD\u03C1\u03BF\u03BD."},specialPurposes:{title:"\u0392\u03B1\u03C3\u03B9\u03BA\u03AD\u03C2 \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03AF\u03B5\u03C2 (\u03B1\u03C0\u03B1\u03B9\u03C4\u03BF\u03CD\u03BD\u03C4\u03B1\u03B9)",tooltip:"\u0391\u03C5\u03C4\u03AD\u03C2 \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B1\u03C0\u03B1\u03C1\u03B1\u03AF\u03C4\u03B7\u03C4\u03B5\u03C2 \u03B3\u03B9\u03B1 \u03C4\u03B7 \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03B9\u03BA\u03CC\u03C4\u03B7\u03C4\u03B1 \u03BA\u03B1\u03B9 \u03C4\u03B7\u03BD \u03B1\u03C3\u03C6\u03AC\u03BB\u03B5\u03B9\u03B1 \u03C4\u03BF\u03C5 \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C5. \u03A3\u03CD\u03BC\u03C6\u03C9\u03BD\u03B1 \u03BC\u03B5 \u03C4\u03BF IAB TCF, \u03B4\u03B5\u03BD \u03BC\u03C0\u03BF\u03C1\u03B5\u03AF\u03C4\u03B5 \u03BD\u03B1 \u03B1\u03BD\u03C4\u03B9\u03C4\u03B1\u03C7\u03B8\u03B5\u03AF\u03C4\u03B5 \u03C3\u03B5 \u03B1\u03C5\u03C4\u03BF\u03CD\u03C2 \u03C4\u03BF\u03C5\u03C2 \u03B5\u03B9\u03B4\u03B9\u03BA\u03BF\u03CD\u03C2 \u03C3\u03BA\u03BF\u03C0\u03BF\u03CD\u03C2."},vendorList:{search:"\u0391\u03BD\u03B1\u03B6\u03AE\u03C4\u03B7\u03C3\u03B7 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03B1\u03C4\u03CE\u03BD...",showingCount:"{filtered} \u03B1\u03C0\u03CC {total} \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2",iabVendorsHeading:"\u0395\u03B3\u03B3\u03B5\u03B3\u03C1\u03B1\u03BC\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 IAB",iabVendorsNotice:"\u0391\u03C5\u03C4\u03BF\u03AF \u03BF\u03B9 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B5\u03B3\u03B3\u03B5\u03B3\u03C1\u03B1\u03BC\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C4\u03BF IAB Transparency & Consent Framework (TCF), \u03AD\u03BD\u03B1 \u03B2\u03B9\u03BF\u03BC\u03B7\u03C7\u03B1\u03BD\u03B9\u03BA\u03CC \u03C0\u03C1\u03CC\u03C4\u03C5\u03C0\u03BF \u03B3\u03B9\u03B1 \u03C4\u03B7 \u03B4\u03B9\u03B1\u03C7\u03B5\u03AF\u03C1\u03B9\u03C3\u03B7 \u03C4\u03B7\u03C2 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03B7\u03C2",customVendorsHeading:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03BF\u03C3\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2",customVendorsNotice:"\u0391\u03C5\u03C4\u03BF\u03AF \u03B5\u03AF\u03BD\u03B1\u03B9 \u03C0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03BF\u03C3\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03C0\u03BF\u03C5 \u03B4\u03B5\u03BD \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B5\u03B3\u03B3\u03B5\u03B3\u03C1\u03B1\u03BC\u03BC\u03AD\u03BD\u03BF\u03B9 \u03C3\u03C4\u03BF IAB Transparency & Consent Framework (TCF). \u0395\u03C0\u03B5\u03BE\u03B5\u03C1\u03B3\u03AC\u03B6\u03BF\u03BD\u03C4\u03B1\u03B9 \u03B4\u03B5\u03B4\u03BF\u03BC\u03AD\u03BD\u03B1 \u03BC\u03B5 \u03B2\u03AC\u03C3\u03B7 \u03C4\u03B7 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03AE \u03C3\u03B1\u03C2 \u03BA\u03B1\u03B9 \u03B5\u03BD\u03B4\u03AD\u03C7\u03B5\u03C4\u03B1\u03B9 \u03BD\u03B1 \u03AD\u03C7\u03BF\u03C5\u03BD \u03B4\u03B9\u03B1\u03C6\u03BF\u03C1\u03B5\u03C4\u03B9\u03BA\u03AD\u03C2 \u03C0\u03C1\u03B1\u03BA\u03C4\u03B9\u03BA\u03AD\u03C2 \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5 \u03B1\u03C0\u03CC \u03C4\u03BF\u03C5\u03C2 \u03B5\u03B3\u03B3\u03B5\u03B3\u03C1\u03B1\u03BC\u03BC\u03AD\u03BD\u03BF\u03C5\u03C2 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B5\u03C2 \u03C4\u03BF\u03C5 IAB.",purposes:"\u03A3\u03BA\u03BF\u03C0\u03BF\u03AF",specialPurposes:"\u0395\u03B9\u03B4\u03B9\u03BA\u03BF\u03AF \u03C3\u03BA\u03BF\u03C0\u03BF\u03AF",specialFeatures:"\u0395\u03B9\u03B4\u03B9\u03BA\u03AC \u03C7\u03B1\u03C1\u03B1\u03BA\u03C4\u03B7\u03C1\u03B9\u03C3\u03C4\u03B9\u03BA\u03AC",features:"\u03A7\u03B1\u03C1\u03B1\u03BA\u03C4\u03B7\u03C1\u03B9\u03C3\u03C4\u03B9\u03BA\u03AC",dataCategories:"\u039A\u03B1\u03C4\u03B7\u03B3\u03BF\u03C1\u03AF\u03B5\u03C2 \u03B4\u03B5\u03B4\u03BF\u03BC\u03AD\u03BD\u03C9\u03BD",usesCookies:"\u03A7\u03C1\u03B7\u03C3\u03B9\u03BC\u03BF\u03C0\u03BF\u03B9\u03B5\u03AF cookies",nonCookieAccess:"\u03A0\u03C1\u03CC\u03C3\u03B2\u03B1\u03C3\u03B7 \u03C7\u03C9\u03C1\u03AF\u03C2 cookies",maxAge:"\u039C\u03AD\u03B3\u03B9\u03C3\u03C4\u03B7 \u03B4\u03B9\u03AC\u03C1\u03BA\u03B5\u03B9\u03B1: {days} \u03B7\u03BC.",retention:"\u0394\u03B9\u03B1\u03C4\u03AE\u03C1\u03B7\u03C3\u03B7: {days} \u03B7\u03BC.",legitimateInterest:"\u0388\u03BD\u03BD\u03BF\u03BC\u03BF \u03C3\u03C5\u03BC\u03C6\u03AD\u03C1\u03BF\u03BD",privacyPolicy:"\u03A0\u03BF\u03BB\u03B9\u03C4\u03B9\u03BA\u03AE \u03B1\u03C0\u03BF\u03C1\u03C1\u03AE\u03C4\u03BF\u03C5",storageDisclosure:"\u0393\u03BD\u03C9\u03C3\u03C4\u03BF\u03C0\u03BF\u03AF\u03B7\u03C3\u03B7 \u03B1\u03C0\u03BF\u03B8\u03AE\u03BA\u03B5\u03C5\u03C3\u03B7\u03C2",requiredNotice:"\u0391\u03C0\u03B1\u03B9\u03C4\u03B5\u03AF\u03C4\u03B1\u03B9 \u03B3\u03B9\u03B1 \u03C4\u03B7 \u03BB\u03B5\u03B9\u03C4\u03BF\u03C5\u03C1\u03B3\u03B9\u03BA\u03CC\u03C4\u03B7\u03C4\u03B1 \u03C4\u03BF\u03C5 \u03B9\u03C3\u03C4\u03CC\u03C4\u03BF\u03C0\u03BF\u03C5, \u03B4\u03B5\u03BD \u03BC\u03C0\u03BF\u03C1\u03B5\u03AF \u03BD\u03B1 \u03B1\u03C0\u03B5\u03BD\u03B5\u03C1\u03B3\u03BF\u03C0\u03BF\u03B9\u03B7\u03B8\u03B5\u03AF"},footer:{consentStorage:'\u039F\u03B9 \u03C0\u03C1\u03BF\u03C4\u03B9\u03BC\u03AE\u03C3\u03B5\u03B9\u03C2 \u03C3\u03C5\u03B3\u03BA\u03B1\u03C4\u03AC\u03B8\u03B5\u03C3\u03B7\u03C2 \u03B1\u03C0\u03BF\u03B8\u03B7\u03BA\u03B5\u03CD\u03BF\u03BD\u03C4\u03B1\u03B9 \u03C3\u03B5 cookie \u03BC\u03B5 \u03C4\u03BF \u03CC\u03BD\u03BF\u03BC\u03B1 "euconsent-v2" \u03B3\u03B9\u03B1 13 \u03BC\u03AE\u03BD\u03B5\u03C2. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"\u0391\u03C0\u03BF\u03B4\u03BF\u03C7\u03AE \u03CC\u03BB\u03C9\u03BD",rejectAll:"\u0391\u03C0\u03CC\u03C1\u03C1\u03B9\u03C8\u03B7 \u03CC\u03BB\u03C9\u03BD",customize:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03BF\u03B3\u03AE",saveSettings:"\u0391\u03C0\u03BF\u03B8\u03AE\u03BA\u03B5\u03C5\u03C3\u03B7 \u03C1\u03C5\u03B8\u03BC\u03AF\u03C3\u03B5\u03C9\u03BD",loading:"\u03A6\u03CC\u03C1\u03C4\u03C9\u03C3\u03B7...",showingSelectedVendor:"\u0395\u03BC\u03C6\u03AC\u03BD\u03B9\u03C3\u03B7 \u03B5\u03C0\u03B9\u03BB\u03B5\u03B3\u03BC\u03AD\u03BD\u03BF\u03C5 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B7",clearSelection:"\u0395\u03BA\u03BA\u03B1\u03B8\u03AC\u03C1\u03B9\u03C3\u03B7",customPartner:"\u03A0\u03C1\u03BF\u03C3\u03B1\u03C1\u03BC\u03BF\u03C3\u03BC\u03AD\u03BD\u03BF\u03C2 \u03C3\u03C5\u03BD\u03B5\u03C1\u03B3\u03AC\u03C4\u03B7\u03C2 \u03C0\u03BF\u03C5 \u03B4\u03B5\u03BD \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B5\u03B3\u03B3\u03B5\u03B3\u03C1\u03B1\u03BC\u03BC\u03AD\u03BD\u03BF\u03C2 \u03C3\u03C4\u03BF IAB"}}},it={common:{acceptAll:"Accept All",rejectAll:"Reject All",customize:"Customize",save:"Save Settings"},cookieBanner:{title:"We value your privacy",description:"This site uses cookies to improve your browsing experience, analyze site traffic, and show personalized content."},consentManagerDialog:{title:"Privacy Settings",description:"Customize your privacy settings here. You can choose which types of cookies and tracking technologies you allow."},consentTypes:{necessary:{title:"Strictly Necessary",description:"These cookies are essential for the website to function properly and cannot be disabled."},functionality:{title:"Functionality",description:"These cookies enable enhanced functionality and personalization of the website."},marketing:{title:"Marketing",description:"These cookies are used to deliver relevant advertisements and track their effectiveness."},measurement:{title:"Analytics",description:"These cookies help us understand how visitors interact with the website and improve its performance."},experience:{title:"Experience",description:"These cookies help us provide a better user experience and test new features."}},frame:{title:"Accept {category} consent to view this content.",actionButton:"Enable {category} consent"},legalLinks:{privacyPolicy:"Privacy Policy",cookiePolicy:"Cookie Policy",termsOfService:"Terms of Service"},iab:{banner:{title:"Privacy Settings",description:"We and our {partnerCount} partners store and/or access information on your device and process personal data, such as unique identifiers and browsing data, for this website, to:",partnersLink:"{count} partners",andMore:"And {count} more...",legitimateInterestNotice:"Some partners claim a legitimate interest to process your data. You have the right to object to this processing, customize your choices, and withdraw your consent at any time.",scopeServiceSpecific:"Your consent applies only to this website and will not affect other services.",scopeGroup:"Your choice applies across our websites in this group."},preferenceCenter:{title:"Privacy Settings",description:"Customize your privacy settings here. You can choose which types of cookies and tracking technologies you allow.",tabs:{purposes:"Purposes",vendors:"Vendors"},purposeItem:{partners:"{count} partners",vendorsUseLegitimateInterest:"{count} vendors claim legitimate interest",examples:"Examples",partnersUsingPurpose:"Partners Using This Purpose",withYourPermission:"With Your Permission",legitimateInterest:"Legitimate Interest",objectButton:"Object",objected:"Objected",rightToObject:"You have the right to object to processing based on legitimate interest."},specialPurposes:{title:"Essential Functions (Required)",tooltip:"These are required for site functionality and security. Per IAB TCF, you cannot object to these special purposes."},vendorList:{search:"Search vendors...",showingCount:"{filtered} of {total} vendors",iabVendorsHeading:"IAB Registered Vendors",iabVendorsNotice:"These partners are registered with the IAB Transparency & Consent Framework (TCF), an industry standard for managing consent",customVendorsHeading:"Custom Partners",customVendorsNotice:"These are custom partners not registered with IAB Transparency & Consent Framework (TCF). They process data based on your consent and may have different privacy practices than IAB-registered vendors.",purposes:"Purposes",specialPurposes:"Special Purposes",specialFeatures:"Special Features",features:"Features",dataCategories:"Data Categories",usesCookies:"Uses Cookies",nonCookieAccess:"Non-Cookie Access",maxAge:"Max Age: {days}d",retention:"Retention: {days}d",legitimateInterest:"Leg. Interest",privacyPolicy:"Privacy Policy",storageDisclosure:"Storage Disclosure",requiredNotice:"Required for site functionality, cannot be disabled"},footer:{consentStorage:'Consent preferences are stored in a cookie named "euconsent-v2" for 13 months. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Accept All",rejectAll:"Reject All",customize:"Customize",saveSettings:"Save Settings",loading:"Loading...",showingSelectedVendor:"Showing selected vendor",clearSelection:"Clear",customPartner:"Custom partner not registered with IAB"}}},li={common:{acceptAll:"Aceptar todo",rejectAll:"Rechazar todo",customize:"Personalizar",save:"Guardar ajustes"},cookieBanner:{title:"Valoramos tu privacidad",description:"Este sitio web utiliza cookies para mejorar tu experiencia de navegaci\xF3n, analizar el tr\xE1fico del sitio y mostrar contenido personalizado."},consentManagerDialog:{title:"Configuraci\xF3n de privacidad",description:"Personaliza tus ajustes de privacidad aqu\xED. Puedes elegir qu\xE9 tipos de cookies y tecnolog\xEDas de seguimiento permites."},consentTypes:{necessary:{title:"Necesario",description:"Estas cookies son esenciales para que el sitio web funcione correctamente y no pueden ser deshabilitadas."},functionality:{title:"Funcionalidad",description:"Estas cookies permiten una mejor funcionalidad y personalizaci\xF3n del sitio web."},marketing:{title:"Marketing",description:"Estas cookies se utilizan para ofrecer anuncios relevantes y realizar un seguimiento de su eficacia."},measurement:{title:"Anal\xEDtica",description:"Estas cookies nos ayudan a comprender c\xF3mo los visitantes interact\xFAan con el sitio web y a mejorar su rendimiento."},experience:{title:"Experiencia",description:"Estas cookies nos ayudan a proporcionar una mejor experiencia de usuario y a probar nuevas funciones."}},frame:{title:"Acepta {category} para ver este contenido.",actionButton:"Habilitar consentimiento de {category}"},legalLinks:{privacyPolicy:"Pol\xEDtica de Privacidad",cookiePolicy:"Pol\xEDtica de Cookies",termsOfService:"T\xE9rminos de Servicio"},iab:{banner:{title:"Configuraci\xF3n de privacidad",description:"Nosotros y nuestros {partnerCount} socios almacenamos y/o accedemos a informaci\xF3n en tu dispositivo y procesamos datos personales, como identificadores \xFAnicos y datos de navegaci\xF3n, para este sitio web, con el fin de:",partnersLink:"{count} socios",andMore:"Y {count} m\xE1s...",legitimateInterestNotice:"Algunos socios reclaman un inter\xE9s leg\xEDtimo para procesar tus datos. Tienes derecho a oponerte a este procesamiento, personalizar tus opciones y retirar tu consentimiento en cualquier momento.",scopeServiceSpecific:"Tu consentimiento se aplica solo a este sitio web y no afectar\xE1 a otros servicios.",scopeGroup:"Su elecci\xF3n se aplica a todos nuestros sitios web de este grupo."},preferenceCenter:{title:"Configuraci\xF3n de privacidad",description:"Personaliza tus ajustes de privacidad aqu\xED. Puedes elegir qu\xE9 tipos de cookies y tecnolog\xEDas de seguimiento permites.",tabs:{purposes:"Prop\xF3sitos",vendors:"Proveedores"},purposeItem:{partners:"{count} socios",vendorsUseLegitimateInterest:"{count} proveedores reclaman inter\xE9s leg\xEDtimo",examples:"Ejemplos",partnersUsingPurpose:"Socios que utilizan este prop\xF3sito",withYourPermission:"Con tu permiso",legitimateInterest:"Inter\xE9s leg\xEDtimo",objectButton:"Oponerse",objected:"Opuesto",rightToObject:"Tienes derecho a oponerte al procesamiento basado en inter\xE9s leg\xEDtimo."},specialPurposes:{title:"Funciones esenciales (requeridas)",tooltip:"Estas son necesarias para la funcionalidad y seguridad del sitio. Seg\xFAn el TCF de IAB, no puedes oponerte a estos prop\xF3sitos especiales."},vendorList:{search:"Buscar proveedores...",showingCount:"{filtered} de {total} proveedores",iabVendorsHeading:"Proveedores registrados en IAB",iabVendorsNotice:"Estos socios est\xE1n registrados en el Marco de Transparencia y Consentimiento (TCF) de IAB, un est\xE1ndar de la industria para gestionar el consentimiento",customVendorsHeading:"Socios personalizados",customVendorsNotice:"Estos son socios personalizados no registrados en el Marco de Transparencia y Consentimiento de IAB (TCF). Procesan datos bas\xE1ndose en tu consentimiento y pueden tener pr\xE1cticas de privacidad diferentes a las de los proveedores registrados en IAB.",purposes:"Finalidades",specialPurposes:"Finalidades especiales",specialFeatures:"Caracter\xEDsticas especiales",features:"Caracter\xEDsticas",dataCategories:"Categor\xEDas de datos",usesCookies:"Usa cookies",nonCookieAccess:"Acceso sin cookies",maxAge:"Duraci\xF3n m\xE1xima: {days}d",retention:"Retenci\xF3n: {days}d",legitimateInterest:"Inter\xE9s leg\xEDtimo",privacyPolicy:"Pol\xEDtica de privacidad",storageDisclosure:"Divulgaci\xF3n de almacenamiento",requiredNotice:"Requerido para la funcionalidad del sitio, no se puede desactivar"},footer:{consentStorage:'Las preferencias de consentimiento se almacenan en una cookie llamada "euconsent-v2" durante 13 meses. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Aceptar todo",rejectAll:"Rechazar todo",customize:"Personalizar",saveSettings:"Guardar ajustes",loading:"Cargando...",showingSelectedVendor:"Mostrando proveedor seleccionado",clearSelection:"Limpiar",customPartner:"Socio personalizado no registrado en IAB"}}},ui={common:{acceptAll:"N\xF5ustu k\xF5igiga",rejectAll:"Keeldu k\xF5igist",customize:"Kohanda",save:"Salvesta seaded"},cookieBanner:{title:"Hindame teie privaatsust",description:"See sait kasutab k\xFCpsiseid, et parandada teie sirvimiskogemust, anal\xFC\xFCsida saidi liiklust ja n\xE4idata isikup\xE4rastatud sisu."},consentManagerDialog:{title:"Privaatsusseaded",description:"Kohandage siin oma privaatsusseadeid. Saate valida, milliseid k\xFCpsiseid ja j\xE4lgimistehnoloogiaid lubate."},consentTypes:{necessary:{title:"H\xE4davajalikud",description:"Need k\xFCpsised on veebisaidi n\xF5uetekohaseks toimimiseks h\xE4davajalikud ja neid ei saa keelata."},functionality:{title:"Funktsionaalsus",description:"Need k\xFCpsised v\xF5imaldavad veebisaidi t\xE4iustatud funktsionaalsust ja isikup\xE4rastamist."},marketing:{title:"Turundus",description:"Neid k\xFCpsiseid kasutatakse asjakohaste reklaamide edastamiseks ja nende t\xF5hususe j\xE4lgimiseks."},measurement:{title:"Anal\xFC\xFCtika",description:"Need k\xFCpsised aitavad meil m\xF5ista, kuidas k\xFClastajad veebisaidiga suhtlevad, ja parandada selle toimivust."},experience:{title:"Kogemus",description:"Need k\xFCpsised aitavad meil pakkuda paremat kasutajakogemust ja testida uusi funktsioone."}},frame:{title:"Selle sisu vaatamiseks n\xF5ustuge kategooria {category} n\xF5usolekuga.",actionButton:"Luba kategooria {category} n\xF5usolek"},legalLinks:{privacyPolicy:"Privaatsuspoliitika",cookiePolicy:"K\xFCpsiste poliitika",termsOfService:"Kasutustingimused"},iab:{banner:{title:"Privaatsusseaded",description:"Meie ja meie {partnerCount} partnerit salvestavad ja/v\xF5i p\xE4\xE4sevad ligi teie seadmes olevatele andmetele ning t\xF6\xF6tlevad isikuandmeid, nagu unikaalsed identifikaatorid ja sirvimisandmed sellel veebilehel, et:",partnersLink:"{count} partnerit",andMore:"Ja veel {count}...",legitimateInterestNotice:"M\xF5ned partnerid v\xE4idavad, et neil on \xF5igustatud huvi teie andmete t\xF6\xF6tlemiseks. Teil on \xF5igus sellele t\xF6\xF6tlemisele vastu vaielda, oma valikuid kohandada ja n\xF5usolek igal ajal tagasi v\xF5tta.",scopeServiceSpecific:"Sinu n\xF5usolek kehtib ainult sellele veebisaidile ega m\xF5juta teisi teenuseid.",scopeGroup:"Teie valik kehtib k\xF5igil meie veebisaitidel selles grupis."},preferenceCenter:{title:"Privaatsusseaded",description:"Kohandage siin oma privaatsusseadeid. Saate valida, milliseid k\xFCpsiseid ja j\xE4lgimistehnoloogiaid lubate.",tabs:{purposes:"Eesm\xE4rgid",vendors:"Teenusepakkujad"},purposeItem:{partners:"{count} partnerit",vendorsUseLegitimateInterest:"{count} teenusepakkujat v\xE4idavad \xF5igustatud huvi",examples:"N\xE4ited",partnersUsingPurpose:"Selle eesm\xE4rgi kasutavad partnerid",withYourPermission:"Teie loal",legitimateInterest:"\xD5igustatud huvi",objectButton:"Vaidle vastu",objected:"Vastu vaieldud",rightToObject:"Teil on \xF5igus vaielda vastu t\xF6\xF6tlemisele, mis p\xF5hineb \xF5igustatud huvil."},specialPurposes:{title:"Olulised funktsioonid (n\xF5utud)",tooltip:"Need on vajalikud saidi toimimiseks ja turvalisuseks. IAB TCF-i kohaselt ei saa nendele erieesm\xE4rkidele vastu vaielda."},vendorList:{search:"Otsi teenusepakkujaid...",showingCount:"Kuvatakse {filtered} / {total} teenusepakkujat",iabVendorsHeading:"IAB registreeritud teenusepakkujad",iabVendorsNotice:"Need partnerid on registreeritud IAB l\xE4bipaistvuse ja n\xF5usoleku raamistikus (TCF), mis on t\xF6\xF6stusstandard n\xF5usoleku haldamiseks",customVendorsHeading:"Kohandatud partnerid",customVendorsNotice:"Need on kohandatud partnerid, kes ei ole registreeritud IAB l\xE4bipaistvuse ja n\xF5usoleku raamistikus (TCF). Nad t\xF6\xF6tlevad andmeid teie n\xF5usoleku alusel ning nende privaatsustavad v\xF5ivad erineda IAB-sertifitseeritud partnerite omadest.",purposes:"Eesm\xE4rgid",specialPurposes:"Eriotstarbed",specialFeatures:"Eriomadused",features:"Omadused",dataCategories:"Andmekategooriad",usesCookies:"Kasutab k\xFCpsiseid",nonCookieAccess:"K\xFCpsisteta juurdep\xE4\xE4s",maxAge:"Maksimaalne vanus: {days}p",retention:"S\xE4ilitamine: {days}p",legitimateInterest:"\xD5igustatud huvi",privacyPolicy:"Privaatsuspoliitika",storageDisclosure:"Salvestamise teave",requiredNotice:"Vajalik saidi toimimiseks, ei saa keelata"},footer:{consentStorage:'N\xF5usoleku eelistused salvestatakse k\xFCpsisesse nimega "euconsent-v2" 13 kuuks. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"N\xF5ustu k\xF5igiga",rejectAll:"Keeldu k\xF5igist",customize:"Kohanda",saveSettings:"Salvesta seaded",loading:"Laadimine...",showingSelectedVendor:"Kuvatakse valitud partner",clearSelection:"T\xFChjenda",customPartner:"Kohandatud partner, kes ei ole IAB-s registreeritud"}}},di={common:{acceptAll:"Hyv\xE4ksy kaikki",rejectAll:"Hylk\xE4\xE4 kaikki",customize:"Mukauta",save:"Tallenna asetukset"},cookieBanner:{title:"Arvostamme yksityisyytt\xE4si",description:"T\xE4m\xE4 sivusto k\xE4ytt\xE4\xE4 ev\xE4steit\xE4 parantaakseen selauskokemustasi, analysoidakseen sivuston liikennett\xE4 ja n\xE4ytt\xE4\xE4kseen yksil\xF6llist\xE4 sis\xE4lt\xF6\xE4."},consentManagerDialog:{title:"Tietosuoja-asetukset",description:"Mukauta yksityisyysasetuksiasi t\xE4\xE4ll\xE4. Voit valita, mink\xE4 tyyppiset ev\xE4steet ja seurantatekniikat sallit."},consentTypes:{necessary:{title:"Ehdottoman tarpeellinen",description:"N\xE4m\xE4 ev\xE4steet ovat v\xE4ltt\xE4m\xE4tt\xF6mi\xE4, jotta verkkosivusto toimisi oikein, eik\xE4 niit\xE4 voi poistaa k\xE4yt\xF6st\xE4."},functionality:{title:"Toiminnallisuus",description:"N\xE4m\xE4 ev\xE4steet mahdollistavat verkkosivuston tehostetun toiminnallisuuden ja personoinnin."},marketing:{title:"Markkinointi",description:"N\xE4it\xE4 ev\xE4steit\xE4 k\xE4ytet\xE4\xE4n relevanttien mainosten l\xE4hett\xE4miseen ja niiden tehokkuuden seurantaan."},measurement:{title:"Analytiikka",description:"N\xE4m\xE4 ev\xE4steet auttavat meit\xE4 ymm\xE4rt\xE4m\xE4\xE4n, miten k\xE4vij\xE4t ovat vuorovaikutuksessa verkkosivuston kanssa, ja parantamaan sen suorituskyky\xE4."},experience:{title:"Kokemus",description:"N\xE4m\xE4 ev\xE4steet auttavat meit\xE4 tarjoamaan paremman k\xE4ytt\xF6kokemuksen ja testaamaan uusia ominaisuuksia."}},frame:{title:"Hyv\xE4ksy {category}, jotta voit tarkastella t\xE4t\xE4 sis\xE4lt\xF6\xE4.",actionButton:"Ota {category} k\xE4ytt\xF6\xF6n"},legalLinks:{privacyPolicy:"Tietosuojak\xE4yt\xE4nt\xF6",cookiePolicy:"Ev\xE4stek\xE4yt\xE4nt\xF6",termsOfService:"K\xE4ytt\xF6ehdot"},iab:{banner:{title:"Tietosuoja-asetukset",description:"Me ja {partnerCount} kumppaniamme tallennamme ja/tai k\xE4yt\xE4mme tietoja laitteellasi ja k\xE4sittelemme henkil\xF6tietoja, kuten yksil\xF6llisi\xE4 tunnisteita ja selaustietoja, t\xE4ll\xE4 verkkosivustolla seuraaviin tarkoituksiin:",partnersLink:"{count} kumppania",andMore:"Ja {count} muuta...",legitimateInterestNotice:"Jotkut kumppanit vetoavat oikeutettuun etuun tietojesi k\xE4sittelyss\xE4. Sinulla on oikeus vastustaa t\xE4t\xE4 k\xE4sittely\xE4, mukauttaa valintojasi ja peruuttaa suostumuksesi milloin tahansa.",scopeServiceSpecific:"Suostumuksesi koskee vain t\xE4t\xE4 verkkosivustoa eik\xE4 vaikuta muihin palveluihin.",scopeGroup:"Valintasi koskee kaikkia verkkosivujamme t\xE4ss\xE4 ryhm\xE4ss\xE4."},preferenceCenter:{title:"Tietosuoja-asetukset",description:"Mukauta yksityisyysasetuksiasi t\xE4\xE4ll\xE4. Voit valita, mink\xE4 tyyppiset ev\xE4steet ja seurantatekniikat sallit.",tabs:{purposes:"K\xE4ytt\xF6tarkoitukset",vendors:"Kumppanit"},purposeItem:{partners:"{count} kumppania",vendorsUseLegitimateInterest:"{count} kumppania vetoaa oikeutettuun etuun",examples:"Esimerkit",partnersUsingPurpose:"T\xE4t\xE4 k\xE4ytt\xF6tarkoitusta k\xE4ytt\xE4v\xE4t kumppanit",withYourPermission:"Luvallasi",legitimateInterest:"Oikeutettu etu",objectButton:"Vastusta",objected:"Vastustettu",rightToObject:"Sinulla on oikeus vastustaa oikeutettuun etuun perustuvaa k\xE4sittely\xE4."},specialPurposes:{title:"V\xE4ltt\xE4m\xE4tt\xF6m\xE4t toiminnot (pakollinen)",tooltip:"N\xE4m\xE4 ovat v\xE4ltt\xE4m\xE4tt\xF6mi\xE4 sivuston toimivuuden ja turvallisuuden kannalta. IAB TCF:n mukaan et voi vastustaa n\xE4it\xE4 erityisi\xE4 k\xE4ytt\xF6tarkoituksia."},vendorList:{search:"Hae kumppaneita...",showingCount:"{filtered}/{total} kumppania",iabVendorsHeading:"IAB-rekister\xF6idyt kumppanit",iabVendorsNotice:"N\xE4m\xE4 kumppanit on rekister\xF6ity IAB Transparency & Consent Framework (TCF) -j\xE4rjestelm\xE4\xE4n, joka on alan standardi suostumusten hallintaan",customVendorsHeading:"Mukautetut kumppanit",customVendorsNotice:"N\xE4m\xE4 ovat mukautettuja kumppaneita, jotka eiv\xE4t ole rekister\xF6ityneet IAB Transparency & Consent Framework (TCF) -j\xE4rjestelm\xE4\xE4n. Ne k\xE4sittelev\xE4t tietoja suostumuksesi perusteella, ja niill\xE4 voi olla erilaiset tietosuojak\xE4yt\xE4nn\xF6t kuin IAB:hen rekister\xF6ityneill\xE4 toimittajilla.",purposes:"Tarkoitukset",specialPurposes:"Erityistarkoitukset",specialFeatures:"Erikoisominaisuudet",features:"Ominaisuudet",dataCategories:"Tietoluokat",usesCookies:"K\xE4ytt\xE4\xE4 ev\xE4steit\xE4",nonCookieAccess:"Muu kuin ev\xE4stepohjainen k\xE4ytt\xF6",maxAge:"Enimm\xE4isik\xE4: {days} pv",retention:"S\xE4ilytys: {days} pv",legitimateInterest:"Oikeutettu etu",privacyPolicy:"Tietosuojak\xE4yt\xE4nt\xF6",storageDisclosure:"Tallennustietojen julkistaminen",requiredNotice:"Vaaditaan sivuston toiminnallisuuden vuoksi, ei voi poistaa k\xE4yt\xF6st\xE4"},footer:{consentStorage:'Suostumusasetukset tallennetaan ev\xE4steeseen nimelt\xE4 "euconsent-v2" 13 kuukaudeksi. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Hyv\xE4ksy kaikki",rejectAll:"Hylk\xE4\xE4 kaikki",customize:"Mukauta",saveSettings:"Tallenna asetukset",loading:"Ladataan...",showingSelectedVendor:"N\xE4ytet\xE4\xE4n valittu toimittaja",clearSelection:"Tyhjenn\xE4",customPartner:"Mukautettu kumppani, joka ei ole rekister\xF6itynyt IAB:hen"}}},pi={common:{acceptAll:"Accepter tout",rejectAll:"Tout rejeter",customize:"Personnaliser",save:"Enregistrer les param\xE8tres"},cookieBanner:{title:"Nous respectons votre vie priv\xE9e",description:"Ce site utilise des cookies pour am\xE9liorer votre exp\xE9rience de navigation, analyser le trafic du site et afficher du contenu personnalis\xE9."},consentManagerDialog:{title:"Param\xE8tres de confidentialit\xE9",description:"Personnalisez vos param\xE8tres de confidentialit\xE9 ici. Vous pouvez choisir les types de cookies et de technologies de suivi que vous autorisez."},consentTypes:{necessary:{title:"Strictement n\xE9cessaire",description:"Ces cookies sont essentiels pour que le site web fonctionne correctement et ne peuvent pas \xEAtre d\xE9sactiv\xE9s."},functionality:{title:"Fonctionnalit\xE9",description:"Ces cookies permettent d'am\xE9liorer la fonctionnalit\xE9 et la personnalisation du site web."},marketing:{title:"Marketing",description:"Ces cookies sont utilis\xE9s pour offrir des publicit\xE9s pertinentes et suivre leur efficacit\xE9."},measurement:{title:"Analyse",description:"Ces cookies nous permettent de comprendre comment les visiteurs interagissent avec le site web et am\xE9liorent ses performances."},experience:{title:"Exp\xE9rience",description:"Ces cookies nous permettent de fournir une meilleure exp\xE9rience utilisateur et de tester de nouvelles fonctionnalit\xE9s."}},frame:{title:"Acceptez {category} pour afficher ce contenu.",actionButton:"Activer le consentement {category}"},legalLinks:{privacyPolicy:"Politique de Confidentialit\xE9",cookiePolicy:"Politique des Cookies",termsOfService:"Conditions de Service"},iab:{banner:{title:"Param\xE8tres de confidentialit\xE9",description:"Nous et nos {partnerCount} partenaires stockons et/ou acc\xE9dons \xE0 des informations sur votre appareil et traitons des donn\xE9es personnelles, telles que des identifiants uniques et des donn\xE9es de navigation, pour ce site web, afin de :",partnersLink:"{count} partenaires",andMore:"Et {count} de plus...",legitimateInterestNotice:"Certains partenaires revendiquent un int\xE9r\xEAt l\xE9gitime pour traiter vos donn\xE9es. Vous avez le droit de vous opposer \xE0 ce traitement, de personnaliser vos choix et de retirer votre consentement \xE0 tout moment.",scopeServiceSpecific:"Votre consentement s'applique uniquement \xE0 ce site web et n'affecte pas d'autres services.",scopeGroup:"Votre choix s'applique \xE0 tous nos sites web de ce groupe."},preferenceCenter:{title:"Param\xE8tres de confidentialit\xE9",description:"Personnalisez vos param\xE8tres de confidentialit\xE9 ici. Vous pouvez choisir les types de cookies et de technologies de suivi que vous autorisez.",tabs:{purposes:"Finalit\xE9s",vendors:"Fournisseurs"},purposeItem:{partners:"{count} partenaires",vendorsUseLegitimateInterest:"{count} fournisseurs revendiquent un int\xE9r\xEAt l\xE9gitime",examples:"Exemples",partnersUsingPurpose:"Partenaires utilisant cette finalit\xE9",withYourPermission:"Avec votre autorisation",legitimateInterest:"Int\xE9r\xEAt l\xE9gitime",objectButton:"S'opposer",objected:"Opposition enregistr\xE9e",rightToObject:"Vous avez le droit de vous opposer au traitement fond\xE9 sur l'int\xE9r\xEAt l\xE9gitime."},specialPurposes:{title:"Fonctions essentielles (obligatoires)",tooltip:"Ces fonctions sont n\xE9cessaires au fonctionnement et \xE0 la s\xE9curit\xE9 du site. Conform\xE9ment au TCF de l'IAB, vous ne pouvez pas vous opposer \xE0 ces finalit\xE9s sp\xE9ciales."},vendorList:{search:"Rechercher des fournisseurs...",showingCount:"{filtered} sur {total} fournisseurs",iabVendorsHeading:"Fournisseurs enregistr\xE9s IAB",iabVendorsNotice:"Ces partenaires sont enregistr\xE9s aupr\xE8s du Transparency & Consent Framework (TCF) de l'IAB, une norme industrielle pour la gestion du consentement",customVendorsHeading:"Partenaires personnalis\xE9s",customVendorsNotice:"Il s'agit de partenaires personnalis\xE9s non enregistr\xE9s aupr\xE8s de l'IAB Transparency & Consent Framework (TCF). Ils traitent les donn\xE9es sur la base de votre consentement et peuvent avoir des pratiques de confidentialit\xE9 diff\xE9rentes de celles des fournisseurs enregistr\xE9s aupr\xE8s de l'IAB.",purposes:"Finalit\xE9s",specialPurposes:"Finalit\xE9s sp\xE9ciales",specialFeatures:"Fonctionnalit\xE9s sp\xE9ciales",features:"Fonctionnalit\xE9s",dataCategories:"Cat\xE9gories de donn\xE9es",usesCookies:"Utilise des cookies",nonCookieAccess:"Acc\xE8s sans cookie",maxAge:"Dur\xE9e max. : {days} j",retention:"R\xE9tention : {days} j",legitimateInterest:"Int\xE9r\xEAt l\xE9gitime",privacyPolicy:"Politique de confidentialit\xE9",storageDisclosure:"Divulgation du stockage",requiredNotice:"Requis pour le fonctionnement du site, ne peut pas \xEAtre d\xE9sactiv\xE9"},footer:{consentStorage:"Les pr\xE9f\xE9rences de consentement sont stock\xE9es dans un cookie nomm\xE9 \xAB euconsent-v2 \xBB pendant 13 mois. The storage duration may be refreshed when you update your preferences."}},common:{acceptAll:"Accepter tout",rejectAll:"Tout rejeter",customize:"Personnaliser",saveSettings:"Enregistrer les param\xE8tres",loading:"Chargement...",showingSelectedVendor:"Affichage du fournisseur s\xE9lectionn\xE9",clearSelection:"Effacer",customPartner:"Partenaire personnalis\xE9 non enregistr\xE9 aupr\xE8s de l'IAB"}}},gi={common:{acceptAll:"Glac le Gach Rud",rejectAll:"Di\xFAltaigh do Gach Rud",customize:"Saincheap",save:"S\xE1bh\xE1il Socruithe"},cookieBanner:{title:"Tugaimid luach do do phr\xEDobh\xE1ideachas",description:"\xDAs\xE1ideann an su\xEDomh seo fian\xE1in chun do thaith\xED bhrabhs\xE1la a fheabhs\xFA, tr\xE1cht su\xEDmh a anail\xEDsi\xFA, agus \xE1bhar pearsantaithe a thaispe\xE1int."},consentManagerDialog:{title:"Socruithe Pr\xEDobh\xE1ideachais",description:"Saincheap do shocruithe pr\xEDobh\xE1ideachais anseo. Is f\xE9idir leat na cine\xE1lacha fian\xE1n agus teicneola\xEDochta\xED rianaithe a cheada\xEDonn t\xFA a roghn\xFA."},consentTypes:{necessary:{title:"F\xEDor-Riachtanach",description:"T\xE1 na fian\xE1in seo riachtanach chun go bhfeidhmeoidh an su\xEDomh gr\xE9as\xE1in i gceart agus n\xED f\xE9idir iad a dh\xEDchumas\xFA."},functionality:{title:"Feidhmi\xFAlacht",description:"Cumasa\xEDonn na fian\xE1in seo feidhmi\xFAlacht fheabhsaithe agus pearsant\xFA an tsu\xEDmh ghr\xE9as\xE1in."},marketing:{title:"Marga\xEDocht",description:"\xDAs\xE1idtear na fian\xE1in seo chun f\xF3gra\xED \xE1bhartha a sheachadadh agus a n-\xE9ifeachtacht a rian\xFA."},measurement:{title:"Anail\xEDs\xEDocht",description:"Cabhra\xEDonn na fian\xE1in seo linn tuiscint a fh\xE1il ar conas a idirghn\xEDomha\xEDonn cuairteoir\xED leis an su\xEDomh gr\xE9as\xE1in agus a fheidhm\xEDocht a fheabhs\xFA."},experience:{title:"Taith\xED",description:"Cabhra\xEDonn na fian\xE1in seo linn taith\xED \xFAs\xE1ideora n\xEDos fearr a shol\xE1thar agus gn\xE9ithe nua a th\xE1st\xE1il."}},frame:{title:"Glac le toili\xFA {category} chun an t-\xE1bhar seo a fheice\xE1il.",actionButton:"Cumasaigh toili\xFA {category}"},legalLinks:{privacyPolicy:"Beartas Pr\xEDobh\xE1ideachta",cookiePolicy:"Beartas Fian\xE1n",termsOfService:"T\xE9arma\xED Seirbh\xEDse"},iab:{banner:{title:"Socruithe pr\xEDobh\xE1ideachais",description:"St\xF3r\xE1laimid agus/n\xF3 faighimid rochtain ar fhaisn\xE9is ar do ghl\xE9as, muid f\xE9in agus \xE1r {partnerCount} comhph\xE1irt\xED, agus pr\xF3ise\xE1laimid sonra\xED pearsanta, amhail aitheant\xF3ir\xED uath\xFAla agus sonra\xED brabhs\xE1la, don su\xEDomh gr\xE9as\xE1in seo, chun:",partnersLink:"{count} comhph\xE1irt\xED",andMore:"Agus {count} eile...",legitimateInterestNotice:"\xC9il\xEDonn roinnt comhph\xE1irtithe leas dlisteanach chun do shonra\xED a phr\xF3ise\xE1il. T\xE1 an ceart agat cur in aghaidh an phr\xF3ise\xE1la seo, do roghanna a shaincheapadh, agus do thoili\xFA a tharraingt siar am ar bith.",scopeServiceSpecific:"Baineann do thoili\xFA leis an su\xEDomh gr\xE9as\xE1in seo amh\xE1in agus n\xED dh\xE9anfaidh s\xE9 difear do sheirbh\xEDs\xED eile.",scopeGroup:"Baineann do rogha le gach ceann d\xE1r l\xE1ithre\xE1in ghr\xE9as\xE1in sa ghr\xFApa seo."},preferenceCenter:{title:"Socruithe pr\xEDobh\xE1ideachais",description:"Saincheap do shocruithe pr\xEDobh\xE1ideachais anseo. Is f\xE9idir leat na cine\xE1lacha fian\xE1n agus teicneola\xEDochta\xED rianaithe a cheada\xEDonn t\xFA a roghn\xFA.",tabs:{purposes:"Cusp\xF3ir\xED",vendors:"Sol\xE1thr\xF3ir\xED"},purposeItem:{partners:"{count} comhph\xE1irt\xED",vendorsUseLegitimateInterest:"\xC9il\xEDonn {count} sol\xE1thr\xF3ir leas dlisteanach",examples:"Sampla\xED",partnersUsingPurpose:"Comhph\xE1irtithe a \xFAs\xE1ideann an cusp\xF3ir seo",withYourPermission:"Le do chead",legitimateInterest:"Leas dlisteanach",objectButton:"Cuir in aghaidh",objected:"Curtha in aghaidh",rightToObject:"T\xE1 an ceart agat cur in aghaidh pr\xF3ise\xE1la bunaithe ar leas dlisteanach."},specialPurposes:{title:"Feidhmeanna riachtanacha (\xE9igeantach)",tooltip:"T\xE1 siad seo riachtanach d'fheidhmi\xFAlacht agus sl\xE1nd\xE1il an tsu\xEDmh. De r\xE9ir IAB TCF, n\xED f\xE9idir leat cur in aghaidh na gcusp\xF3ir\xED speisialta seo."},vendorList:{search:"Cuardaigh sol\xE1thr\xF3ir\xED...",showingCount:"{filtered} as {total} sol\xE1thr\xF3ir",iabVendorsHeading:"Sol\xE1thr\xF3ir\xED cl\xE1raithe IAB",iabVendorsNotice:"T\xE1 na comhph\xE1irtithe seo cl\xE1raithe le Creat Tr\xE9dhearcachta agus Toilithe IAB (TCF), caighde\xE1n tionscail chun toili\xFA a bhainisti\xFA",customVendorsHeading:"Comhph\xE1irtithe saincheaptha",customVendorsNotice:"Is comhph\xE1irtithe saincheaptha iad seo nach bhfuil cl\xE1raithe le Creat Tr\xE9dhearcachta agus Toilithe IAB (TCF). Pr\xF3ise\xE1lann siad sonra\xED bunaithe ar do thoili\xFA agus d'fh\xE9adfadh cleachtais phr\xEDobh\xE1ideachta \xE9ags\xFAla a bheith acu \xF3 dh\xEDolt\xF3ir\xED cl\xE1raithe IAB.",purposes:"Cusp\xF3ir\xED",specialPurposes:"Cusp\xF3ir\xED speisialta",specialFeatures:"Gn\xE9ithe speisialta",features:"Gn\xE9ithe",dataCategories:"Catag\xF3ir\xED sonra\xED",usesCookies:"\xDAs\xE1ideann fian\xE1in",nonCookieAccess:"Rochtain neamh-fhian\xE1n",maxAge:"Uasaois: {days}l",retention:"Coinne\xE1il: {days}l",legitimateInterest:"Leas dlisteanach",privacyPolicy:"Beartas pr\xEDobh\xE1ideachta",storageDisclosure:"Nochtadh st\xF3r\xE1la",requiredNotice:"Riachtanach d'fheidhmi\xFAlacht an tsu\xEDmh, n\xED f\xE9idir \xE9 a dh\xEDchumas\xFA"},footer:{consentStorage:'St\xF3r\xE1iltear roghanna toilithe i bhfian\xE1n darb ainm "euconsent-v2" ar feadh 13 mh\xED. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Glac le gach rud",rejectAll:"Di\xFAltaigh do gach rud",customize:"Saincheap",saveSettings:"S\xE1bh\xE1il socruithe",loading:"\xC1 l\xF3d\xE1il...",showingSelectedVendor:"D\xEDolt\xF3ir roghnaithe \xE1 thaispe\xE1int",clearSelection:"Glan",customPartner:"Comhph\xE1irt\xED saincheaptha nach bhfuil cl\xE1raithe le IAB"}}},mi={common:{acceptAll:"\u05D0\u05E4\u05E9\u05E8 \u05D4\u05DB\u05DC",rejectAll:"\u05D3\u05D7\u05D4 \u05D4\u05DB\u05DC",customize:"\u05D4\u05EA\u05D0\u05DE\u05D4 \u05D0\u05D9\u05E9\u05D9\u05EA",save:"\u05E9\u05DE\u05D5\u05E8 \u05D4\u05D2\u05D3\u05E8\u05D5\u05EA"},cookieBanner:{title:"\u05E4\u05E8\u05D8\u05D9\u05D5\u05EA\u05DA \u05D7\u05E9\u05D5\u05D1\u05D4 \u05DC\u05E0\u05D5",description:"\u05D0\u05EA\u05E8 \u05D6\u05D4 \u05DE\u05E9\u05EA\u05DE\u05E9 \u05D1\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA (\u05E7\u05D5\u05E7\u05D9\u05D6) \u05D1\u05DB\u05D3\u05D9 \u05DC\u05E9\u05E4\u05E8 \u05D0\u05EA \u05D7\u05D5\u05D5\u05D9\u05D9\u05EA \u05D4\u05E9\u05D9\u05DE\u05D5\u05E9, \u05DC\u05E0\u05D8\u05E8 \u05D0\u05EA \u05EA\u05E2\u05D1\u05D5\u05E8\u05EA \u05D4\u05D0\u05EA\u05E8 \u05D5\u05DC\u05D4\u05E6\u05D9\u05D2 \u05EA\u05D5\u05DB\u05DF \u05DE\u05D5\u05EA\u05D0\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA."},consentManagerDialog:{title:"\u05D4\u05D2\u05D3\u05E8\u05D5\u05EA \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",description:"\u05D1\u05D7\u05E8 \u05D0\u05EA \u05D4\u05D2\u05D3\u05E8\u05D5\u05EA \u05D4\u05E4\u05E8\u05D8\u05D9\u05D5\u05EA \u05E9\u05DC\u05DA \u05DB\u05D0\u05DF. \u05D1\u05D0\u05E4\u05E9\u05E8\u05D5\u05EA\u05DA \u05DC\u05D1\u05D7\u05D5\u05E8 \u05D0\u05D9\u05DC\u05D5 \u05E1\u05D5\u05D2\u05D9 \u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D5\u05D8\u05DB\u05E0\u05D5\u05DC\u05D5\u05D2\u05D9\u05D5\u05EA \u05DE\u05E2\u05E7\u05D1 \u05EA\u05E8\u05E6\u05D4 \u05DC\u05D0\u05E4\u05E9\u05E8."},consentTypes:{necessary:{title:"\u05D4\u05DB\u05E8\u05D7\u05D9\u05D5\u05EA",description:"\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D0\u05DC\u05D5 \u05D3\u05E8\u05D5\u05E9\u05D5\u05EA \u05DC\u05E4\u05E2\u05D5\u05DC\u05EA \u05D4\u05D0\u05EA\u05E8 \u05D5\u05DC\u05D0 \u05E0\u05D9\u05EA\u05DF \u05DC\u05D4\u05E9\u05D1\u05D9\u05EA \u05D0\u05D5\u05EA\u05DF."},functionality:{title:"\u05E4\u05D5\u05E0\u05E7\u05E6\u05D9\u05D5\u05E0\u05DC\u05D9\u05D5\u05EA",description:"\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D0\u05DC\u05D5 \u05DE\u05D0\u05E4\u05E9\u05E8\u05D5\u05EA \u05E4\u05D5\u05E0\u05E7\u05E6\u05D9\u05D5\u05E0\u05DC\u05D9\u05D5\u05EA \u05DE\u05E9\u05D5\u05E4\u05E8\u05EA \u05D5\u05D4\u05EA\u05D0\u05DE\u05D4 \u05D0\u05D9\u05E9\u05D9\u05EA."},marketing:{title:"\u05E9\u05D9\u05D5\u05D5\u05E7",description:"\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D0\u05DC\u05D5 \u05DE\u05E9\u05DE\u05E9\u05D5\u05EA \u05DC\u05D4\u05EA\u05D0\u05DE\u05EA \u05E4\u05E8\u05E1\u05D5\u05DE\u05D5\u05EA \u05D5\u05DE\u05E2\u05E7\u05D1 \u05D0\u05D7\u05E8 \u05D9\u05E2\u05D9\u05DC\u05D5\u05EA\u05DF."},measurement:{title:"\u05E0\u05D9\u05EA\u05D5\u05D7",description:"\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D0\u05DC\u05D5 \u05DE\u05E1\u05D9\u05D9\u05E2\u05D5\u05EA \u05DC\u05D4\u05D1\u05D9\u05DF \u05D0\u05D9\u05DA \u05DE\u05E9\u05EA\u05DE\u05E9\u05D9\u05DD \u05D1\u05D0\u05EA\u05E8 \u05D5\u05DC\u05E9\u05E4\u05E8 \u05D0\u05EA \u05D1\u05D9\u05E6\u05D5\u05E2\u05D9\u05D5."},experience:{title:"\u05D7\u05D5\u05D5\u05D9\u05D9\u05EA \u05DE\u05E9\u05EA\u05DE\u05E9",description:"\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D0\u05DC\u05D5 \u05DE\u05D0\u05E4\u05E9\u05E8\u05D5\u05EA \u05D7\u05D5\u05D5\u05D9\u05D9\u05EA \u05DE\u05E9\u05EA\u05DE\u05E9 \u05D8\u05D5\u05D1\u05D4 \u05D9\u05D5\u05EA\u05E8 \u05D5\u05D1\u05D3\u05D9\u05E7\u05EA \u05E4\u05D5\u05E0\u05E7\u05E6\u05D9\u05D5\u05E0\u05DC\u05D9\u05D5\u05EA \u05D7\u05D3\u05E9\u05D4 \u05D1\u05D0\u05EA\u05E8."}},frame:{title:"\u05E7\u05D1\u05DC {category} \u05DB\u05D3\u05D9 \u05DC\u05D4\u05E6\u05D9\u05D2 \u05EA\u05D5\u05DB\u05DF \u05D6\u05D4.",actionButton:"\u05D4\u05E4\u05E2\u05DC {category} \u05E8\u05E9\u05D5\u05EA"},legalLinks:{privacyPolicy:"\u05DE\u05D3\u05D9\u05E0\u05D9\u05D5\u05EA \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",cookiePolicy:"\u05DE\u05D3\u05D9\u05E0\u05D9\u05D5\u05EA \u05E2\u05D5\u05D2\u05D9\u05D5\u05EA",termsOfService:"\u05EA\u05E0\u05D0\u05D9 \u05E9\u05D9\u05E8\u05D5\u05EA"},iab:{banner:{title:"\u05D4\u05D2\u05D3\u05E8\u05D5\u05EA \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",description:"\u05D0\u05E0\u05D7\u05E0\u05D5 \u05D5-{partnerCount} \u05D4\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05E9\u05DC\u05E0\u05D5 \u05DE\u05D0\u05D7\u05E1\u05E0\u05D9\u05DD \u05D5/\u05D0\u05D5 \u05E0\u05D9\u05D2\u05E9\u05D9\u05DD \u05DC\u05DE\u05D9\u05D3\u05E2 \u05D1\u05DE\u05DB\u05E9\u05D9\u05E8 \u05E9\u05DC\u05DA \u05D5\u05DE\u05E2\u05D1\u05D3\u05D9\u05DD \u05E0\u05EA\u05D5\u05E0\u05D9\u05DD \u05D0\u05D9\u05E9\u05D9\u05D9\u05DD, \u05DB\u05D2\u05D5\u05DF \u05DE\u05D6\u05D4\u05D9\u05DD \u05D9\u05D9\u05D7\u05D5\u05D3\u05D9\u05D9\u05DD \u05D5\u05E0\u05EA\u05D5\u05E0\u05D9 \u05D2\u05DC\u05D9\u05E9\u05D4, \u05E2\u05D1\u05D5\u05E8 \u05D0\u05EA\u05E8 \u05D6\u05D4, \u05DB\u05D3\u05D9:",partnersLink:"{count} \u05E9\u05D5\u05EA\u05E4\u05D9\u05DD",andMore:"\u05D5\u05E2\u05D5\u05D3 {count}...",legitimateInterestNotice:"\u05D7\u05DC\u05E7 \u05DE\u05D4\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05D8\u05D5\u05E2\u05E0\u05D9\u05DD \u05DC\u05D0\u05D9\u05E0\u05D8\u05E8\u05E1 \u05DC\u05D2\u05D9\u05D8\u05D9\u05DE\u05D9 \u05DC\u05E2\u05D1\u05D3 \u05D0\u05EA \u05D4\u05E0\u05EA\u05D5\u05E0\u05D9\u05DD \u05E9\u05DC\u05DA. \u05D9\u05E9 \u05DC\u05DA \u05D6\u05DB\u05D5\u05EA \u05DC\u05D4\u05EA\u05E0\u05D2\u05D3 \u05DC\u05E2\u05D9\u05D1\u05D5\u05D3 \u05D6\u05D4, \u05DC\u05D4\u05EA\u05D0\u05D9\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA \u05D0\u05EA \u05D4\u05D1\u05D7\u05D9\u05E8\u05D5\u05EA \u05E9\u05DC\u05DA \u05D5\u05DC\u05D1\u05D8\u05DC \u05D0\u05EA \u05D4\u05E1\u05DB\u05DE\u05EA\u05DA \u05D1\u05DB\u05DC \u05E2\u05EA.",scopeServiceSpecific:"\u05D4\u05D4\u05E1\u05DB\u05DE\u05D4 \u05E9\u05DC\u05DA \u05D7\u05DC\u05D4 \u05E8\u05E7 \u05E2\u05DC \u05D0\u05EA\u05E8 \u05D6\u05D4 \u05D5\u05DC\u05D0 \u05EA\u05E9\u05E4\u05D9\u05E2 \u05E2\u05DC \u05E9\u05D9\u05E8\u05D5\u05EA\u05D9\u05DD \u05D0\u05D7\u05E8\u05D9\u05DD.",scopeGroup:"\u05D4\u05D1\u05D7\u05D9\u05E8\u05D4 \u05E9\u05DC\u05DA \u05D7\u05DC\u05D4 \u05E2\u05DC \u05DB\u05DC \u05D4\u05D0\u05EA\u05E8\u05D9\u05DD \u05E9\u05DC\u05E0\u05D5 \u05D1\u05E7\u05D1\u05D5\u05E6\u05D4 \u05D6\u05D5."},preferenceCenter:{title:"\u05D4\u05D2\u05D3\u05E8\u05D5\u05EA \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",description:"\u05D4\u05EA\u05D0\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA \u05D0\u05EA \u05D4\u05D2\u05D3\u05E8\u05D5\u05EA \u05D4\u05E4\u05E8\u05D8\u05D9\u05D5\u05EA \u05E9\u05DC\u05DA \u05DB\u05D0\u05DF. \u05D1\u05D0\u05E4\u05E9\u05E8\u05D5\u05EA\u05DA \u05DC\u05D1\u05D7\u05D5\u05E8 \u05D0\u05D9\u05DC\u05D5 \u05E1\u05D5\u05D2\u05D9 \u05E2\u05D5\u05D2\u05D9\u05D5\u05EA \u05D5\u05D8\u05DB\u05E0\u05D5\u05DC\u05D5\u05D2\u05D9\u05D5\u05EA \u05DE\u05E2\u05E7\u05D1 \u05EA\u05E8\u05E6\u05D4 \u05DC\u05D0\u05E4\u05E9\u05E8.",tabs:{purposes:"\u05DE\u05D8\u05E8\u05D5\u05EA",vendors:"\u05E1\u05E4\u05E7\u05D9\u05DD"},purposeItem:{partners:"{count} \u05E9\u05D5\u05EA\u05E4\u05D9\u05DD",vendorsUseLegitimateInterest:"{count} \u05E1\u05E4\u05E7\u05D9\u05DD \u05D8\u05D5\u05E2\u05E0\u05D9\u05DD \u05DC\u05D0\u05D9\u05E0\u05D8\u05E8\u05E1 \u05DC\u05D2\u05D9\u05D8\u05D9\u05DE\u05D9",examples:"\u05D3\u05D5\u05D2\u05DE\u05D0\u05D5\u05EA",partnersUsingPurpose:"\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05D4\u05DE\u05E9\u05EA\u05DE\u05E9\u05D9\u05DD \u05D1\u05DE\u05D8\u05E8\u05D4 \u05D6\u05D5",withYourPermission:"\u05D1\u05D4\u05E1\u05DB\u05DE\u05EA\u05DA",legitimateInterest:"\u05D0\u05D9\u05E0\u05D8\u05E8\u05E1 \u05DC\u05D2\u05D9\u05D8\u05D9\u05DE\u05D9",objectButton:"\u05D4\u05EA\u05E0\u05D2\u05D3",objected:"\u05D4\u05EA\u05E0\u05D2\u05D3\u05EA",rightToObject:"\u05D9\u05E9 \u05DC\u05DA \u05D6\u05DB\u05D5\u05EA \u05DC\u05D4\u05EA\u05E0\u05D2\u05D3 \u05DC\u05E2\u05D9\u05D1\u05D5\u05D3 \u05D4\u05DE\u05D1\u05D5\u05E1\u05E1 \u05E2\u05DC \u05D0\u05D9\u05E0\u05D8\u05E8\u05E1 \u05DC\u05D2\u05D9\u05D8\u05D9\u05DE\u05D9."},specialPurposes:{title:"\u05E4\u05D5\u05E0\u05E7\u05E6\u05D9\u05D5\u05EA \u05D7\u05D9\u05D5\u05E0\u05D9\u05D5\u05EA (\u05E0\u05D3\u05E8\u05E9)",tooltip:"\u05D0\u05DC\u05D5 \u05E0\u05D3\u05E8\u05E9\u05D5\u05EA \u05DC\u05EA\u05E4\u05E7\u05D5\u05D3 \u05D5\u05D0\u05D1\u05D8\u05D7\u05EA \u05D4\u05D0\u05EA\u05E8. \u05E2\u05DC \u05E4\u05D9 IAB TCF, \u05D0\u05D9\u05E0\u05DA \u05D9\u05DB\u05D5\u05DC \u05DC\u05D4\u05EA\u05E0\u05D2\u05D3 \u05DC\u05DE\u05D8\u05E8\u05D5\u05EA \u05DE\u05D9\u05D5\u05D7\u05D3\u05D5\u05EA \u05D0\u05DC\u05D5."},vendorList:{search:"\u05D7\u05E4\u05E9 \u05E1\u05E4\u05E7\u05D9\u05DD...",showingCount:"{filtered} \u05DE\u05EA\u05D5\u05DA {total} \u05E1\u05E4\u05E7\u05D9\u05DD",iabVendorsHeading:"\u05E1\u05E4\u05E7\u05D9\u05DD \u05E8\u05E9\u05D5\u05DE\u05D9\u05DD \u05D1-IAB",iabVendorsNotice:"\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05D0\u05DC\u05D5 \u05E8\u05E9\u05D5\u05DE\u05D9\u05DD \u05D1\u05DE\u05E1\u05D2\u05E8\u05EA \u05D4\u05E9\u05E7\u05D9\u05E4\u05D5\u05EA \u05D5\u05D4\u05D4\u05E1\u05DB\u05DE\u05D4 \u05E9\u05DC IAB (TCF), \u05EA\u05E7\u05DF \u05EA\u05E2\u05E9\u05D9\u05D9\u05EA\u05D9 \u05DC\u05E0\u05D9\u05D4\u05D5\u05DC \u05D4\u05E1\u05DB\u05DE\u05D4",customVendorsHeading:"\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05DE\u05D5\u05EA\u05D0\u05DE\u05D9\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA",customVendorsNotice:"\u05D0\u05DC\u05D5 \u05D4\u05DD \u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05DE\u05D5\u05EA\u05D0\u05DE\u05D9\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA \u05E9\u05D0\u05D9\u05E0\u05DD \u05E8\u05E9\u05D5\u05DE\u05D9\u05DD \u05D1-IAB Transparency & Consent Framework (TCF). \u05D4\u05DD \u05DE\u05E2\u05D1\u05D3\u05D9\u05DD \u05E0\u05EA\u05D5\u05E0\u05D9\u05DD \u05E2\u05DC \u05D1\u05E1\u05D9\u05E1 \u05D4\u05E1\u05DB\u05DE\u05EA\u05DA \u05D5\u05E2\u05E9\u05D5\u05D9\u05D9\u05DD \u05DC\u05D4\u05D9\u05D5\u05EA \u05DC\u05D4\u05DD \u05E0\u05D4\u05DC\u05D9 \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA \u05E9\u05D5\u05E0\u05D9\u05DD \u05DE\u05E9\u05D5\u05EA\u05E4\u05D9\u05DD \u05D4\u05E8\u05E9\u05D5\u05DE\u05D9\u05DD \u05D1-IAB.",purposes:"\u05DE\u05D8\u05E8\u05D5\u05EA",specialPurposes:"\u05DE\u05D8\u05E8\u05D5\u05EA \u05DE\u05D9\u05D5\u05D7\u05D3\u05D5\u05EA",specialFeatures:"\u05EA\u05DB\u05D5\u05E0\u05D5\u05EA \u05DE\u05D9\u05D5\u05D7\u05D3\u05D5\u05EA",features:"\u05EA\u05DB\u05D5\u05E0\u05D5\u05EA",dataCategories:"\u05E7\u05D8\u05D2\u05D5\u05E8\u05D9\u05D5\u05EA \u05E0\u05EA\u05D5\u05E0\u05D9\u05DD",usesCookies:"\u05DE\u05E9\u05EA\u05DE\u05E9 \u05D1\u05E2\u05D5\u05D2\u05D9\u05D5\u05EA",nonCookieAccess:"\u05D2\u05D9\u05E9\u05D4 \u05DC\u05DC\u05D0 \u05E2\u05D5\u05D2\u05D9\u05D5\u05EA",maxAge:"\u05EA\u05D5\u05E7\u05E3 \u05DE\u05E7\u05E1\u05D9\u05DE\u05DC\u05D9: {days} \u05D9\u05DE\u05D9\u05DD",retention:"\u05E9\u05DE\u05D9\u05E8\u05D4: {days} \u05D9\u05DE\u05D9\u05DD",legitimateInterest:"\u05D0\u05D9\u05E0\u05D8\u05E8\u05E1 \u05DC\u05D2\u05D9\u05D8\u05D9\u05DE\u05D9",privacyPolicy:"\u05DE\u05D3\u05D9\u05E0\u05D9\u05D5\u05EA \u05E4\u05E8\u05D8\u05D9\u05D5\u05EA",storageDisclosure:"\u05D2\u05D9\u05DC\u05D5\u05D9 \u05D0\u05D7\u05E1\u05D5\u05DF",requiredNotice:"\u05E0\u05D3\u05E8\u05E9 \u05DC\u05EA\u05E4\u05E2\u05D5\u05DC \u05D4\u05D0\u05EA\u05E8, \u05DC\u05D0 \u05E0\u05D9\u05EA\u05DF \u05DC\u05D4\u05E9\u05D1\u05D9\u05EA"},footer:{consentStorage:'\u05D4\u05E2\u05D3\u05E4\u05D5\u05EA \u05D4\u05E1\u05DB\u05DE\u05D4 \u05E0\u05E9\u05DE\u05E8\u05D5\u05EA \u05D1\u05E2\u05D5\u05D2\u05D9\u05D9\u05D4 \u05D1\u05E9\u05DD "euconsent-v2" \u05DC\u05DE\u05E9\u05DA 13 \u05D7\u05D5\u05D3\u05E9\u05D9\u05DD. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"\u05D0\u05E4\u05E9\u05E8 \u05D4\u05DB\u05DC",rejectAll:"\u05D3\u05D7\u05D4 \u05D4\u05DB\u05DC",customize:"\u05D4\u05EA\u05D0\u05DE\u05D4 \u05D0\u05D9\u05E9\u05D9\u05EA",saveSettings:"\u05E9\u05DE\u05D5\u05E8 \u05D4\u05D2\u05D3\u05E8\u05D5\u05EA",loading:"\u05D8\u05D5\u05E2\u05DF...",showingSelectedVendor:"\u05DE\u05E6\u05D9\u05D2 \u05E9\u05D5\u05EA\u05E3 \u05E0\u05D1\u05D7\u05E8",clearSelection:"\u05E0\u05E7\u05D4",customPartner:"\u05E9\u05D5\u05EA\u05E3 \u05DE\u05D5\u05EA\u05D0\u05DD \u05D0\u05D9\u05E9\u05D9\u05EA \u05E9\u05D0\u05D9\u05E0\u05D5 \u05E8\u05E9\u05D5\u05DD \u05D1-IAB"}}},fi={common:{acceptAll:"Prihvati sve",rejectAll:"Odbij sve",customize:"Prilagodi",save:"Spremi postavke"},cookieBanner:{title:"Cijenimo va\u0161u privatnost",description:"Ova stranica koristi kola\u010Di\u0107e za pobolj\u0161anje va\u0161eg iskustva pregledavanja, analizu prometa na stranici i prikaz personaliziranog sadr\u017Eaja."},consentManagerDialog:{title:"Postavke privatnosti",description:"Ovdje mo\u017Eete prilagoditi svoje postavke privatnosti. Mo\u017Eete odabrati koje vrste kola\u010Di\u0107a i tehnologija pra\u0107enja dopu\u0161tate."},consentTypes:{necessary:{title:"Strogo nu\u017Eno",description:"Ovi kola\u010Di\u0107i su klju\u010Dni za ispravno funkcioniranje web stranice i ne mogu se onemogu\u0107iti."},functionality:{title:"Funkcionalnost",description:"Ovi kola\u010Di\u0107i omogu\u0107uju pobolj\u0161anu funkcionalnost i personalizaciju web stranice."},marketing:{title:"Marketing",description:"Ovi kola\u010Di\u0107i se koriste za prikaz relevantnih oglasa i pra\u0107enje njihove u\u010Dinkovitosti."},measurement:{title:"Analitika",description:"Ovi kola\u010Di\u0107i nam poma\u017Eu razumjeti kako posjetitelji koriste web stranicu i pobolj\u0161ati njezine performanse."},experience:{title:"Iskustvo",description:"Ovi kola\u010Di\u0107i nam poma\u017Eu pru\u017Eiti bolje korisni\u010Dko iskustvo i testirati nove zna\u010Dajke."}},frame:{title:"Prihvatite {category} privolu za prikaz ovog sadr\u017Eaja.",actionButton:"Omogu\u0107i {category} privolu"},legalLinks:{privacyPolicy:"Pravila o privatnosti",cookiePolicy:"Pravila o kola\u010Di\u0107ima",termsOfService:"Uvjeti pru\u017Eanja usluge"},iab:{banner:{title:"Postavke privatnosti",description:"Mi i na\u0161ih {partnerCount} partnera pohranjujemo i/ili pristupamo informacijama na va\u0161em ure\u0111aju i obra\u0111ujemo osobne podatke, kao \u0161to su jedinstveni identifikatori i podaci o pregledavanju, za ovu web stranicu, kako bismo:",partnersLink:"{count} partnera",andMore:"I jo\u0161 {count}...",legitimateInterestNotice:"Neki partneri pola\u017Eu pravo na legitimni interes za obradu va\u0161ih podataka. Imate pravo prigovora na ovu obradu, prilagodbe svojih izbora i povla\u010Denja privole u bilo kojem trenutku.",scopeServiceSpecific:"Va\u0161 pristanak odnosi se samo na ovu web stranicu i ne\u0107e utjecati na druge usluge.",scopeGroup:"Va\u0161 izbor vrijedi za sve na\u0161e web stranice u ovoj grupi."},preferenceCenter:{title:"Postavke privatnosti",description:"Ovdje mo\u017Eete prilagoditi svoje postavke privatnosti. Mo\u017Eete odabrati koje vrste kola\u010Di\u0107a i tehnologija pra\u0107enja dopu\u0161tate.",tabs:{purposes:"Svrhe",vendors:"Prodava\u010Di"},purposeItem:{partners:"{count} partnera",vendorsUseLegitimateInterest:"{count} prodava\u010Da pola\u017Ee pravo na legitimni interes",examples:"Primjeri",partnersUsingPurpose:"Partneri koji koriste ovu svrhu",withYourPermission:"Uz va\u0161e dopu\u0161tenje",legitimateInterest:"Legitimni interes",objectButton:"Prigovori",objected:"Prigovoreno",rightToObject:"Imate pravo prigovora na obradu temeljenu na legitimnom interesu."},specialPurposes:{title:"Osnovne funkcije (obavezno)",tooltip:"Ove su funkcije potrebne za funkcionalnost i sigurnost stranice. Prema IAB TCF-u, ne mo\u017Eete ulo\u017Eiti prigovor na ove posebne svrhe."},vendorList:{search:"Pretra\u017Ei prodava\u010De...",showingCount:"{filtered} od {total} prodava\u010Da",iabVendorsHeading:"IAB registrirani prodava\u010Di",iabVendorsNotice:"Ovi partneri su registrirani u IAB Transparency & Consent Framework (TCF), industrijskom standardu za upravljanje privolama",customVendorsHeading:"Prilago\u0111eni partneri",customVendorsNotice:"Ovo su prilago\u0111eni partneri koji nisu registrirani u IAB Transparency & Consent Framework (TCF). Oni obra\u0111uju podatke na temelju va\u0161e privole i mogu imati druga\u010Dije prakse privatnosti od IAB registriranih prodava\u010Da.",purposes:"Svrhe",specialPurposes:"Posebne svrhe",specialFeatures:"Posebne zna\u010Dajke",features:"Zna\u010Dajke",dataCategories:"Kategorije podataka",usesCookies:"Koristi kola\u010Di\u0107e",nonCookieAccess:"Pristup bez kola\u010Di\u0107a",maxAge:"Maks. starost: {days}d",retention:"Zadr\u017Eavanje: {days}d",legitimateInterest:"Leg. interes",privacyPolicy:"Pravila o privatnosti",storageDisclosure:"Objavljivanje pohrane",requiredNotice:"Potrebno za funkcionalnost stranice, ne mo\u017Ee se onemogu\u0107iti"},footer:{consentStorage:'Postavke privole pohranjuju se u kola\u010Di\u0107u pod nazivom "euconsent-v2" tijekom 13 mjeseci. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Prihvati sve",rejectAll:"Odbij sve",customize:"Prilagodi",saveSettings:"Spremi postavke",loading:"U\u010Ditavanje...",showingSelectedVendor:"Prikaz odabranog prodava\u010Da",clearSelection:"O\u010Disti",customPartner:"Prilago\u0111eni partner koji nije registriran u IAB-u"}}},hi={common:{acceptAll:"\xD6sszes elfogad\xE1sa",rejectAll:"\xD6sszes elutas\xEDt\xE1sa",customize:"Testreszab\xE1s",save:"Be\xE1ll\xEDt\xE1sok ment\xE9se"},cookieBanner:{title:"\xC9rt\xE9kelj\xFCk az adatv\xE9delmet",description:"Ez a webhely s\xFCtiket haszn\xE1l a b\xF6ng\xE9sz\xE9si \xE9lm\xE9ny jav\xEDt\xE1s\xE1ra, a forgalom elemz\xE9s\xE9re \xE9s szem\xE9lyre szabott tartalom megjelen\xEDt\xE9s\xE9re."},consentManagerDialog:{title:"Adatv\xE9delmi be\xE1ll\xEDt\xE1sok",description:"Testreszabhatja adatv\xE9delmi be\xE1ll\xEDt\xE1sait itt. Kiv\xE1laszthatja, hogy milyen t\xEDpus\xFA s\xFCtiket \xE9s nyomk\xF6vet\u0151 technol\xF3gi\xE1kat enged\xE9lyez."},consentTypes:{necessary:{title:"Felt\xE9tlen\xFCl sz\xFCks\xE9ges",description:"Ezek a s\xFCtik elengedhetetlenek a weboldal megfelel\u0151 m\u0171k\xF6d\xE9s\xE9hez, \xE9s nem kapcsolhat\xF3k ki."},functionality:{title:"Funkcionalit\xE1s",description:"Ezek a s\xFCtik lehet\u0151v\xE9 teszik a weboldal tov\xE1bbfejlesztett funkci\xF3it \xE9s szem\xE9lyre szab\xE1s\xE1t."},marketing:{title:"Marketing",description:"Ezeket a s\xFCtiket relev\xE1ns hirdet\xE9sek megjelen\xEDt\xE9s\xE9re \xE9s hat\xE9konys\xE1guk nyomon k\xF6vet\xE9s\xE9re haszn\xE1ljuk."},measurement:{title:"Analitika",description:"Ezek a s\xFCtik seg\xEDtenek meg\xE9rteni, hogyan l\xE9pnek kapcsolatba a l\xE1togat\xF3k a weboldallal, \xE9s jav\xEDtj\xE1k annak teljes\xEDtm\xE9ny\xE9t."},experience:{title:"Felhaszn\xE1l\xF3i \xE9lm\xE9ny",description:"Ezek a s\xFCtik seg\xEDtenek jobb felhaszn\xE1l\xF3i \xE9lm\xE9nyt ny\xFAjtani \xE9s \xFAj funkci\xF3kat tesztelni."}},frame:{title:"Fogadja el a(z) {category} hozz\xE1j\xE1rul\xE1st a tartalom megtekint\xE9s\xE9hez.",actionButton:"A(z) {category} hozz\xE1j\xE1rul\xE1s enged\xE9lyez\xE9se"},legalLinks:{privacyPolicy:"Adatv\xE9delmi szab\xE1lyzat",cookiePolicy:"S\xFCti szab\xE1lyzat",termsOfService:"Felhaszn\xE1l\xE1si felt\xE9telek"},iab:{banner:{title:"Adatv\xE9delmi be\xE1ll\xEDt\xE1sok",description:"Mi \xE9s a(z) {partnerCount} partner\xFCnk inform\xE1ci\xF3kat t\xE1rolunk az \xD6n eszk\xF6z\xE9n \xE9s/vagy \xE9r\xFCnk el azokhoz, valamint szem\xE9lyes adatokat, p\xE9ld\xE1ul egyedi azonos\xEDt\xF3kat \xE9s b\xF6ng\xE9sz\xE9si adatokat dolgozunk fel ezen a weboldalon a k\xF6vetkez\u0151 c\xE9lokb\xF3l:",partnersLink:"{count} partner",andMore:"\xC9s m\xE9g {count}...",legitimateInterestNotice:"N\xE9h\xE1ny partner jogos \xE9rdekre hivatkozik az \xD6n adatainak feldolgoz\xE1s\xE1hoz. \xD6nnek joga van tiltakozni ez ellen a feldolgoz\xE1s ellen, testreszabni v\xE1laszt\xE1sait, \xE9s b\xE1rmikor visszavonni hozz\xE1j\xE1rul\xE1s\xE1t.",scopeServiceSpecific:"Az \xD6n hozz\xE1j\xE1rul\xE1sa csak erre a webhelyre vonatkozik, \xE9s nem \xE9rinti m\xE1s szolg\xE1ltat\xE1sokat.",scopeGroup:"A v\xE1laszt\xE1sa az ebben a csoportban l\xE9v\u0151 \xF6sszes weboldalunkra vonatkozik."},preferenceCenter:{title:"Adatv\xE9delmi be\xE1ll\xEDt\xE1sok",description:"Testreszabhatja adatv\xE9delmi be\xE1ll\xEDt\xE1sait itt. Kiv\xE1laszthatja, hogy milyen t\xEDpus\xFA s\xFCtiket \xE9s nyomk\xF6vet\u0151 technol\xF3gi\xE1kat enged\xE9lyez.",tabs:{purposes:"C\xE9lok",vendors:"Szolg\xE1ltat\xF3k"},purposeItem:{partners:"{count} partner",vendorsUseLegitimateInterest:"{count} szolg\xE1ltat\xF3 jogos \xE9rdekre hivatkozik",examples:"P\xE9ld\xE1k",partnersUsingPurpose:"Ezt a c\xE9lt haszn\xE1l\xF3 partnerek",withYourPermission:"Az \xD6n enged\xE9ly\xE9vel",legitimateInterest:"Jogos \xE9rdek",objectButton:"Tiltakoz\xE1s",objected:"Tiltakozott",rightToObject:"\xD6nnek joga van tiltakozni a jogos \xE9rdeken alapul\xF3 adatkezel\xE9s ellen."},specialPurposes:{title:"Alapvet\u0151 funkci\xF3k (sz\xFCks\xE9ges)",tooltip:"Ezek a webhely m\u0171k\xF6d\xE9s\xE9hez \xE9s biztons\xE1g\xE1hoz sz\xFCks\xE9gesek. Az IAB TCF szerint \xD6n nem tiltakozhat ezen k\xFCl\xF6nleges c\xE9lok ellen."},vendorList:{search:"Szolg\xE1ltat\xF3k keres\xE9se...",showingCount:"{total} szolg\xE1ltat\xF3b\xF3l {filtered} megjelen\xEDt\xE9se",iabVendorsHeading:"IAB regisztr\xE1lt szolg\xE1ltat\xF3k",iabVendorsNotice:"Ezek a partnerek regisztr\xE1lva vannak az IAB Transparency & Consent Framework (TCF) rendszer\xE9ben, amely a hozz\xE1j\xE1rul\xE1sok kezel\xE9s\xE9nek ipar\xE1gi szabv\xE1nya",customVendorsHeading:"Egyedi partnerek",customVendorsNotice:"Ezek olyan egyedi partnerek, akik nincsenek regisztr\xE1lva az IAB Transparency & Consent Framework (TCF) rendszer\xE9ben. Az \xD6n hozz\xE1j\xE1rul\xE1sa alapj\xE1n kezelik az adatokat, \xE9s az IAB-regisztr\xE1lt szolg\xE1ltat\xF3kt\xF3l elt\xE9r\u0151 adatv\xE9delmi gyakorlatot folytathatnak.",purposes:"C\xE9lok",specialPurposes:"K\xFCl\xF6nleges c\xE9lok",specialFeatures:"K\xFCl\xF6nleges funkci\xF3k",features:"Funkci\xF3k",dataCategories:"Adatkateg\xF3ri\xE1k",usesCookies:"S\xFCtiket haszn\xE1l",nonCookieAccess:"Nem s\xFCti alap\xFA hozz\xE1f\xE9r\xE9s",maxAge:"Max. \xE9lettartam: {days} nap",retention:"Meg\u0151rz\xE9s: {days} nap",legitimateInterest:"Jogos \xE9rdek",privacyPolicy:"Adatv\xE9delmi szab\xE1lyzat",storageDisclosure:"T\xE1rol\xE1si t\xE1j\xE9koztat\xF3",requiredNotice:"A webhely m\u0171k\xF6d\xE9s\xE9hez sz\xFCks\xE9ges, nem kapcsolhat\xF3 ki"},footer:{consentStorage:'A hozz\xE1j\xE1rul\xE1si be\xE1ll\xEDt\xE1sokat egy "euconsent-v2" nev\u0171 s\xFCtiben t\xE1roljuk 13 h\xF3napig. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"\xD6sszes elfogad\xE1sa",rejectAll:"\xD6sszes elutas\xEDt\xE1sa",customize:"Testreszab\xE1s",saveSettings:"Be\xE1ll\xEDt\xE1sok ment\xE9se",loading:"Bet\xF6lt\xE9s...",showingSelectedVendor:"A kiv\xE1lasztott szolg\xE1ltat\xF3 megjelen\xEDt\xE9se",clearSelection:"T\xF6rl\xE9s",customPartner:"IAB-n k\xEDv\xFCli egyedi partner"}}},ki={common:{acceptAll:"Terima Semua",rejectAll:"Tolak Semua",customize:"Sesuaikan",save:"Simpan Pengaturan"},cookieBanner:{title:"Kami menghargai privasi Anda",description:"Situs ini menggunakan cookie untuk meningkatkan pengalaman penelusuran Anda, menganalisis lalu lintas situs, dan menampilkan konten yang dipersonalisasi."},consentManagerDialog:{title:"Pengaturan Privasi",description:"Atur preferensi privasi Anda di sini. Anda dapat memilih jenis cookie dan teknologi pelacakan yang diizinkan."},consentTypes:{necessary:{title:"Sangat Diperlukan",description:"Cookie ini penting agar situs web dapat berfungsi dengan baik dan tidak dapat dinonaktifkan."},functionality:{title:"Fungsionalitas",description:"Cookie ini memungkinkan peningkatan fungsionalitas dan personalisasi situs web."},marketing:{title:"Pemasaran",description:"Cookie ini digunakan untuk menampilkan iklan yang relevan dan melacak efektivitasnya."},measurement:{title:"Analitik",description:"Cookie ini membantu kami memahami bagaimana pengunjung berinteraksi dengan situs web dan meningkatkan kinerjanya."},experience:{title:"Pengalaman",description:"Cookie ini membantu kami memberikan pengalaman pengguna yang lebih baik dan menguji fitur baru."}},frame:{title:"Setujui {category} untuk melihat konten ini.",actionButton:"Aktifkan persetujuan {category}"},legalLinks:{privacyPolicy:"Kebijakan Privasi",cookiePolicy:"Kebijakan Cookie",termsOfService:"Syarat Layanan"},iab:{banner:{title:"Pengaturan Privasi",description:"Kami dan {partnerCount} mitra kami menyimpan dan/atau mengakses informasi pada perangkat Anda dan memproses data pribadi, seperti pengidentifikasi unik dan data penelusuran, untuk situs web ini, untuk:",partnersLink:"{count} mitra",andMore:"Dan {count} lainnya...",legitimateInterestNotice:"Beberapa mitra mengklaim kepentingan sah untuk memproses data Anda. Anda memiliki hak untuk menolak pemrosesan ini, menyesuaikan pilihan Anda, dan menarik persetujuan Anda kapan saja.",scopeServiceSpecific:"Persetujuan Anda hanya berlaku untuk situs web ini dan tidak memengaruhi layanan lainnya.",scopeGroup:"Pilihan Anda berlaku untuk semua situs web kami dalam grup ini."},preferenceCenter:{title:"Pengaturan Privasi",description:"Atur preferensi privasi Anda di sini. Anda dapat memilih jenis cookie dan teknologi pelacakan yang diizinkan.",tabs:{purposes:"Tujuan",vendors:"Vendor"},purposeItem:{partners:"{count} mitra",vendorsUseLegitimateInterest:"{count} vendor mengklaim kepentingan sah",examples:"Contoh",partnersUsingPurpose:"Mitra yang Menggunakan Tujuan Ini",withYourPermission:"Dengan Izin Anda",legitimateInterest:"Kepentingan Sah",objectButton:"Keberatan",objected:"Ditolak",rightToObject:"Anda memiliki hak untuk menolak pemrosesan berdasarkan kepentingan sah."},specialPurposes:{title:"Fungsi Penting (Wajib)",tooltip:"Ini diperlukan untuk fungsionalitas dan keamanan situs. Per IAB TCF, Anda tidak dapat menolak tujuan khusus ini."},vendorList:{search:"Cari vendor...",showingCount:"{filtered} dari {total} vendor",iabVendorsHeading:"Vendor Terdaftar IAB",iabVendorsNotice:"Mitra-mitra ini terdaftar di IAB Transparency & Consent Framework (TCF), standar industri untuk mengelola persetujuan",customVendorsHeading:"Mitra Kustom",customVendorsNotice:"Ini adalah mitra kustom yang tidak terdaftar di IAB Transparency & Consent Framework (TCF). Mereka memproses data berdasarkan persetujuan Anda dan mungkin memiliki praktik privasi yang berbeda dari vendor terdaftar IAB.",purposes:"Tujuan",specialPurposes:"Tujuan Khusus",specialFeatures:"Fitur Khusus",features:"Fitur",dataCategories:"Kategori Data",usesCookies:"Menggunakan Cookie",nonCookieAccess:"Akses Non-Cookie",maxAge:"Usia Maks: {days}h",retention:"Retensi: {days}h",legitimateInterest:"Kepent. Sah",privacyPolicy:"Kebijakan Privasi",storageDisclosure:"Pengungkapan Penyimpanan",requiredNotice:"Diperlukan untuk fungsionalitas situs, tidak dapat dinonaktifkan"},footer:{consentStorage:'Preferensi persetujuan disimpan dalam cookie bernama "euconsent-v2" selama 13 bulan. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Terima Semua",rejectAll:"Tolak Semua",customize:"Sesuaikan",saveSettings:"Simpan Pengaturan",loading:"Memuat...",showingSelectedVendor:"Menampilkan vendor terpilih",clearSelection:"Bersihkan",customPartner:"Mitra kustom tidak terdaftar di IAB"}}},vi={common:{acceptAll:"Sam\xFEykkja allt",rejectAll:"Hafna \xF6llu",customize:"S\xE9rsn\xED\xF0a",save:"Vista stillingar"},cookieBanner:{title:"Vi\xF0 metum fri\xF0helgi \xFE\xEDna",description:"\xDEessi vefur notar vafrak\xF6kur til a\xF0 b\xE6ta vafraupplifun \xFE\xEDna, greina umfer\xF0 \xE1 vefnum og s\xFDna pers\xF3numi\xF0a\xF0 efni."},consentManagerDialog:{title:"Pers\xF3nuverndastillingar",description:"S\xE9rsn\xED\xF0a\xF0u pers\xF3nuverndastillingar \xFE\xEDnar h\xE9r. \xDE\xFA getur vali\xF0 hva\xF0a tegundir af vafrak\xF6kum og rakningart\xE6kni \xFE\xFA leyfir."},consentTypes:{necessary:{title:"Nau\xF0synlegar",description:"\xDEessar vafrak\xF6kur eru nau\xF0synlegar til a\xF0 vefs\xED\xF0an virki r\xE9tt og ekki er h\xE6gt a\xF0 sl\xF6kkva \xE1 \xFEeim."},functionality:{title:"Virkni",description:"\xDEessar vafrak\xF6kur gera m\xF6gulegt a\xF0 auka virkni og pers\xF3numi\xF0a vefs\xED\xF0una."},marketing:{title:"Marka\xF0ssetning",description:"\xDEessar vafrak\xF6kur eru nota\xF0ar til a\xF0 birta vi\xF0eigandi augl\xFDsingar og fylgjast me\xF0 \xE1rangri \xFEeirra."},measurement:{title:"Greining",description:"\xDEessar vafrak\xF6kur hj\xE1lpa okkur a\xF0 skilja hvernig gestir nota vefs\xED\xF0una og b\xE6ta frammist\xF6\xF0u hennar."},experience:{title:"Upplifun",description:"\xDEessar vafrak\xF6kur hj\xE1lpa okkur a\xF0 veita betri notendaupplifun og pr\xF3fa n\xFDja eiginleika."}},frame:{title:"Sam\xFEykktu {category} sam\xFEykki til a\xF0 sko\xF0a \xFEetta efni.",actionButton:"Virkja {category} sam\xFEykki"},legalLinks:{privacyPolicy:"Pers\xF3nuverndarstefna",cookiePolicy:"Stefna um vafrak\xF6kur",termsOfService:"\xDEj\xF3nustuskilm\xE1lar"},iab:{banner:{title:"Pers\xF3nuverndastillingar",description:"Vi\xF0 og {partnerCount} samstarfsa\xF0ilar okkar geymum og/e\xF0a h\xF6fum a\xF0gang a\xF0 uppl\xFDsingum \xE1 t\xE6kinu \xFE\xEDnu og vinnum pers\xF3nuuppl\xFDsingar, svo sem einst\xF6k au\xF0kenni og vafrauppl\xFDsingar, fyrir \xFEessa vefs\xED\xF0u, til a\xF0:",partnersLink:"{count} samstarfsa\xF0ilar",andMore:"Og {count} til vi\xF0b\xF3tar...",legitimateInterestNotice:"Sumir samstarfsa\xF0ilar krefjast l\xF6gm\xE6tra hagsmuna til a\xF0 vinna g\xF6gnin \xFE\xEDn. \xDE\xFA \xE1tt r\xE9tt \xE1 a\xF0 andm\xE6la \xFEessari vinnslu, s\xE9rsn\xED\xF0a val \xFEitt og draga sam\xFEykki \xFEitt til baka hven\xE6r sem er.",scopeServiceSpecific:"Sam\xFEykki \xFEitt gildir a\xF0eins fyrir \xFEessa vefs\xED\xF0u og hefur ekki \xE1hrif \xE1 a\xF0rar \xFEj\xF3nustur.",scopeGroup:"Val \xFEitt gildir \xE1 \xF6llum vefs\xED\xF0um okkar \xED \xFEessum h\xF3p."},preferenceCenter:{title:"Pers\xF3nuverndastillingar",description:"S\xE9rsn\xED\xF0a\xF0u pers\xF3nuverndastillingar \xFE\xEDnar h\xE9r. \xDE\xFA getur vali\xF0 hva\xF0a tegundir af vafrak\xF6kum og rakningart\xE6kni \xFE\xFA leyfir.",tabs:{purposes:"Tilgangur",vendors:"S\xF6lua\xF0ilar"},purposeItem:{partners:"{count} samstarfsa\xF0ilar",vendorsUseLegitimateInterest:"{count} s\xF6lua\xF0ilar krefjast l\xF6gm\xE6tra hagsmuna",examples:"D\xE6mi",partnersUsingPurpose:"Samstarfsa\xF0ilar sem nota \xFEennan tilgang",withYourPermission:"Me\xF0 \xFE\xEDnu leyfi",legitimateInterest:"L\xF6gm\xE6tir hagsmunir",objectButton:"Andm\xE6la",objected:"Andm\xE6lt",rightToObject:"\xDE\xFA \xE1tt r\xE9tt \xE1 a\xF0 andm\xE6la vinnslu sem byggir \xE1 l\xF6gm\xE6tum hagsmunum."},specialPurposes:{title:"Nau\xF0synleg virkni (krafist)",tooltip:"\xDEetta er nau\xF0synlegt fyrir virkni og \xF6ryggi vefsins. Samkv\xE6mt IAB TCF getur\xF0u ekki andm\xE6lt \xFEessum s\xE9rst\xF6ku markmi\xF0um."},vendorList:{search:"Leita a\xF0 s\xF6lua\xF0ilum...",showingCount:"{filtered} af {total} s\xF6lua\xF0ilum",iabVendorsHeading:"IAB skr\xE1\xF0ir s\xF6lua\xF0ilar",iabVendorsNotice:"\xDEessir samstarfsa\xF0ilar eru skr\xE1\xF0ir hj\xE1 IAB Transparency & Consent Framework (TCF), i\xF0na\xF0arsta\xF0li til a\xF0 stj\xF3rna sam\xFEykki",customVendorsHeading:"S\xE9rsni\xF0nir samstarfsa\xF0ilar",customVendorsNotice:"\xDEetta eru s\xE9rsni\xF0nir samstarfsa\xF0ilar sem eru ekki skr\xE1\xF0ir hj\xE1 IAB Transparency & Consent Framework (TCF). \xDEeir vinna g\xF6gn byggt \xE1 sam\xFEykki \xFE\xEDnu og g\xE6tu haft a\xF0rar pers\xF3nuverndarreglur en IAB-skr\xE1\xF0ir s\xF6lua\xF0ilar.",purposes:"Tilgangur",specialPurposes:"S\xE9rstakur tilgangur",specialFeatures:"S\xE9rstakir eiginleikar",features:"Eiginleikar",dataCategories:"Gagnaflokkar",usesCookies:"Notar vafrak\xF6kur",nonCookieAccess:"A\xF0gangur \xE1n vafrakaka",maxAge:"H\xE1marksaldur: {days}d",retention:"Var\xF0veisla: {days}d",legitimateInterest:"L\xF6gm. hagsmunir",privacyPolicy:"Pers\xF3nuverndarstefna",storageDisclosure:"Uppl\xFDsingar um geymslu",requiredNotice:"Nau\xF0synlegt fyrir virkni vefsins, ekki h\xE6gt a\xF0 sl\xF6kkva \xE1"},footer:{consentStorage:'Sam\xFEykkisstillingar eru geymdar \xED vafrak\xF6ku sem heitir "euconsent-v2" \xED 13 m\xE1nu\xF0i. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Sam\xFEykkja allt",rejectAll:"Hafna \xF6llu",customize:"S\xE9rsn\xED\xF0a",saveSettings:"Vista stillingar",loading:"Hle\xF0ur...",showingSelectedVendor:"S\xFDnir valdan s\xF6lua\xF0ila",clearSelection:"Hreinsa",customPartner:"S\xE9rsni\xF0inn samstarfsa\xF0ili ekki skr\xE1\xF0ur hj\xE1 IAB"}}},yi={common:{acceptAll:"Accetta tutto",rejectAll:"Rifiuta tutto",customize:"Personalizza",save:"Salva impostazioni"},cookieBanner:{title:"Rispettiamo la tua privacy",description:"Questo sito utilizza cookies per migliorare la tua esperienza di navigazione, analizzare il traffico e mostrare contenuti personalizzati."},consentManagerDialog:{title:"Impostazioni di privacy",description:"Personalizza le tue impostazioni di privacy. Puoi scegliere i tipi di cookies e tecnologie di tracciamento che autorizzi."},consentTypes:{necessary:{title:"Strettamente necessari",description:"Questi cookies sono essenziali per il sito web per funzionare correttamente e non possono essere disabilitati."},functionality:{title:"Funzionalit\xE0",description:"Questi cookies permettono di migliorare la funzionalit\xE0 e la personalizzazione del sito web."},marketing:{title:"Marketing",description:"Questi cookies sono utilizzati per fornire pubblicit\xE0 pertinenti e misurare la loro efficacia."},measurement:{title:"Misurazione",description:"Questi cookies ci aiutano a comprendere come i visitatori interagiscano con il sito web per migliorarne le sue prestazioni."},experience:{title:"Esperienza",description:"Questi cookies ci aiutano a fornire una migliore esperienza utente e per testare nuove funzionalit\xE0."}},frame:{title:"Accetta {category} per visualizzare questo contenuto",actionButton:"Abilita consenso {category}"},legalLinks:{privacyPolicy:"Informativa sulla Privacy",cookiePolicy:"Politica sui Cookie",termsOfService:"Termini di Servizio"},iab:{banner:{title:"Impostazioni di privacy",description:"Noi e i nostri {partnerCount} partner archiviamo e/o accediamo a informazioni su un dispositivo e trattiamo dati personali, come identificatori univoci e informazioni di navigazione, per questo sito web, per:",partnersLink:"{count} partner",andMore:"E altri {count}...",legitimateInterestNotice:"Alcuni partner rivendicano un interesse legittimo per trattare i tuoi dati. Hai il diritto di opporti a questo trattamento, personalizzare le tue scelte e revocare il tuo consenso in qualsiasi momento.",scopeServiceSpecific:"Il tuo consenso si applica solo a questo sito web e non influisce su altri servizi.",scopeGroup:"La tua scelta si applica a tutti i nostri siti web di questo gruppo."},preferenceCenter:{title:"Impostazioni di privacy",description:"Personalizza le tue impostazioni di privacy. Puoi scegliere i tipi di cookies e tecnologie di tracciamento che autorizzi.",tabs:{purposes:"Finalit\xE0",vendors:"Fornitori"},purposeItem:{partners:"{count} partner",vendorsUseLegitimateInterest:"{count} fornitori rivendicano un interesse legittimo",examples:"Esempi",partnersUsingPurpose:"Partner che utilizzano questa finalit\xE0",withYourPermission:"Con la tua autorizzazione",legitimateInterest:"Interesse legittimo",objectButton:"Opponiti",objected:"Opposizione registrata",rightToObject:"Hai il diritto di opporti al trattamento basato sull\u2019interesse legittimo."},specialPurposes:{title:"Funzioni essenziali (obbligatorie)",tooltip:"Queste sono necessarie per la funzionalit\xE0 e la sicurezza del sito. Secondo l\u2019IAB TCF, non puoi opporti a queste finalit\xE0 speciali."},vendorList:{search:"Cerca fornitori...",showingCount:"{filtered} di {total} fornitori",iabVendorsHeading:"Fornitori registrati IAB",iabVendorsNotice:"Questi partner sono registrati presso l\u2019IAB Transparency & Consent Framework (TCF), uno standard industriale per la gestione del consenso",customVendorsHeading:"Partner personalizzati",customVendorsNotice:"Si tratta di partner personalizzati non registrati presso l\u2019IAB Transparency & Consent Framework (TCF). Trattano i dati sulla base del tuo consenso e possono avere pratiche di privacy diverse rispetto ai fornitori registrati IAB.",purposes:"Finalit\xE0",specialPurposes:"Finalit\xE0 speciali",specialFeatures:"Funzionalit\xE0 speciali",features:"Funzionalit\xE0",dataCategories:"Categorie di dati",usesCookies:"Utilizza cookie",nonCookieAccess:"Accesso senza cookie",maxAge:"Durata massima: {days}g",retention:"Conservazione: {days}g",legitimateInterest:"Int. legittimo",privacyPolicy:"Informativa sulla privacy",storageDisclosure:"Informativa sull\u2019archiviazione",requiredNotice:"Richiesto per la funzionalit\xE0 del sito, non pu\xF2 essere disabilitato"},footer:{consentStorage:'Le preferenze di consenso vengono memorizzate in un cookie denominato "euconsent-v2" per 13 mesi. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Accetta tutto",rejectAll:"Rifiuta tutto",customize:"Personalizza",saveSettings:"Salva impostazioni",loading:"Caricamento...",showingSelectedVendor:"Visualizzazione del fornitore selezionato",clearSelection:"Cancella",customPartner:"Partner personalizzato non registrato presso l\u2019IAB"}}},bi={common:{acceptAll:"All akzept\xE9ieren",rejectAll:"All refus\xE9ieren",customize:"Upassen",save:"Astellunge sp\xE4icheren"},cookieBanner:{title:"Mir sch\xE4tzen \xC4r Privatsph\xE4r",description:"D\xEBs Webs\xE4it benotzt Cookien fir \xC4r Surferfahrung ze verbesseren, Webs\xE4it-Verk\xE9ier ze analys\xE9ieren an personalis\xE9ierten Inhalt unzebidden."},consentManagerDialog:{title:"Privatsph\xE4r Astellungen",description:"Passt \xC4r Privatsph\xE4r Astellungen hei un. Dir k\xEBnnt wielen w\xE9i eng Zorte vu Cookien an Tracking-Technologien Dir erlaabt."},consentTypes:{necessary:{title:"Strikt n\xE9ideg",description:"D\xEBs Cookien si wesentlech fir datt d'Webs\xE4it richteg funktion\xE9iert a k\xEBnnen net desaktiv\xE9iert ginn."},functionality:{title:"Funktionalit\xE9it",description:"D\xEBs Cookien erm\xE9iglechen erweidert Funktionalit\xE9it a Personalis\xE9ierung vun der Webs\xE4it."},marketing:{title:"Marketing",description:"D\xEBs Cookien ginn benotzt fir relevant Reklammen ze liwweren an hir Wierksamkeet ze verfolgen."},measurement:{title:"Analytik",description:"D\xEBs Cookien h\xEBllefen eis ze verstoen w\xE9i d'Besicher mat der Webs\xE4it interag\xE9ieren an hir Leeschtung verbesseren."},experience:{title:"Erfahrung",description:"D\xEBs Cookien h\xEBllefen eis eng besser Benotzererfabrung ze bidden an nei Funktiounen ze testen."}},frame:{title:"Akzept\xE9iert {category} Zoust\xEBmmung fir d\xEBsen Inhalt ze gesinn.",actionButton:"{category} Zoust\xEBmmung aktiv\xE9ieren"},legalLinks:{privacyPolicy:"Dateschutzrichtlinn",cookiePolicy:"Cookie-Politik",termsOfService:"Notzungsbedingungen"},iab:{banner:{title:"Privatsph\xE4r Astellungen",description:"Mir an eis {partnerCount} Partner sp\xE4icheren an/oder gr\xE4ifen op Informatiounen op \xC4rem Apparat zou a veraarbechten pers\xE9inlech Daten, w\xE9i eenzegaarteg Identifiz\xE9ierer a Browserdaten, fir d\xEBs Webs\xE4it, fir:",partnersLink:"{count} Partner",andMore:"An nach {count}...",legitimateInterestNotice:"E puer Partner behaapten e berechtegten Interessi fir \xC4r Daten ze veraarbechten. Dir hutt d\u2019Recht g\xE9int d\xEBs Veraarbechtung ze protest\xE9ieren, \xC4r Wiel unzepassen an \xC4r Zoust\xEBmmung zu all Moment zr\xE9ckzez\xE9ien.",scopeServiceSpecific:"\xC4r Zoust\xEBmmung g\xEBllt n\xEBmme fir d\xEBs Webs\xE4it a w\xE4ert aner Servicer net beaflossen.",scopeGroup:"\xC4r Auswiel g\xEBllt fir all eis Webs\xE4iten an d\xEBser Grupp."},preferenceCenter:{title:"Privatsph\xE4r Astellungen",description:"Passt \xC4r Privatsph\xE4r Astellungen hei un. Dir k\xEBnnt wielen w\xE9i eng Zorte vu Cookien an Tracking-Technologien Dir erlaabt.",tabs:{purposes:"Zwecker",vendors:"Ubidder"},purposeItem:{partners:"{count} Partner",vendorsUseLegitimateInterest:"{count} Ubidder behaapten berechtegten Interessi",examples:"Beispiller",partnersUsingPurpose:"Partner d\xE9i d\xEBsen Zweck benotzen",withYourPermission:"Mat \xC4rer Erlaabnis",legitimateInterest:"Berechtegten Interessi",objectButton:"Protest\xE9ieren",objected:"Protest\xE9iert",rightToObject:"Dir hutt d\u2019Recht g\xE9int d\u2019Veraarbechtung op Basis vu berechtegten Interessi ze protest\xE9ieren."},specialPurposes:{title:"Wichteg Funktiounen (erfuerderlech)",tooltip:"D\xEBs sinn erfuerderlech fir d\u2019Funktionalit\xE9it an d\u2019S\xE9cherheet vum Site. Gem\xE9iss IAB TCF k\xEBnnt Dir net g\xE9int d\xEBs speziell Zwecker protest\xE9ieren."},vendorList:{search:"Ubidder sichen...",showingCount:"{filtered} vun {total} Ubidder",iabVendorsHeading:"IAB registr\xE9iert Ubidder",iabVendorsNotice:"D\xEBs Partner sinn am IAB Transparency & Consent Framework (TCF) registr\xE9iert, en Industriestandard fir d\u2019Gestioun vun der Zoust\xEBmmung",customVendorsHeading:"Benotzerdefin\xE9iert Partner",customVendorsNotice:"D\xEBst si benotzerdefin\xE9iert Partner d\xE9i net am IAB Transparency & Consent Framework (TCF) registr\xE9iert sinn. Si veraarbechten Daten op Basis vun \xC4rer Zoust\xEBmmung a k\xEBnnen aner Dateschutzpraktiken hunn w\xE9i IAB-registr\xE9iert Ubidder.",purposes:"Zwecker",specialPurposes:"Speziell Zwecker",specialFeatures:"Speziell Fonctiounen",features:"Fonctiounen",dataCategories:"Datekategorien",usesCookies:"Benotzt Cookien",nonCookieAccess:"Net-Cookie-Zougang",maxAge:"Max Alter: {days}d",retention:"Bewaaren: {days}d",legitimateInterest:"Ber. Interessi",privacyPolicy:"Dateschutzrichtlinn",storageDisclosure:"Sp\xE4icher-Offenlegung",requiredNotice:"Erfuerderlech fir d\u2019Funktionalit\xE9it vum Site, kann net desaktiv\xE9iert ginn"},footer:{consentStorage:'Zoust\xEBmmungsvirl\xE9iften ginn an engem Cookie mam Numm "euconsent-v2" fir 13 M\xE9int gesp\xE4ichert. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"All akzept\xE9ieren",rejectAll:"All refus\xE9ieren",customize:"Upassen",saveSettings:"Astellunge sp\xE4icheren",loading:"Lueden...",showingSelectedVendor:"Gewielten Ubider g\xEBtt ugewisen",clearSelection:"L\xE4schen",customPartner:"Benotzerdefin\xE9ierte Partner net am IAB registr\xE9iert"}}},wi={common:{acceptAll:"Priimti visus",rejectAll:"Atmesti visus",customize:"Tinkinti",save:"I\u0161saugoti nustatymus"},cookieBanner:{title:"Mes vertiname j\u016Bs\u0173 privatum\u0105",description:"\u0160i svetain\u0117 naudoja slapukus nar\u0161ymo patir\u010Diai gerinti, svetain\u0117s srautui analizuoti ir rodyti jums pritaikyt\u0105 turin\u012F."},consentManagerDialog:{title:"Privatumo nustatymai",description:"\u010Cia galite tinkinti savo privatumo nustatymus. Galite pasirinkti, koki\u0173 tip\u0173 slapukus ir sekimo technologijas leid\u017Eiate naudoti."},consentTypes:{necessary:{title:"B\u016Btinieji",description:"\u0160ie slapukai yra b\u016Btini tinkamam svetain\u0117s veikimui ir negali b\u016Bti i\u0161jungti."},functionality:{title:"Funkcionalumo",description:"\u0160ie slapukai \u012Fgalina i\u0161pl\u0117stin\u012F funkcionalum\u0105 ir svetain\u0117s personalizavim\u0105."},marketing:{title:"Rinkodaros",description:"\u0160ie slapukai naudojami pateikti aktualius skelbimus ir sekti j\u0173 efektyvum\u0105."},measurement:{title:"Analitikos",description:"\u0160ie slapukai padeda mums suprasti, kaip lankytojai s\u0105veikauja su svetaine, ir pagerinti jos veikim\u0105."},experience:{title:"Patirties",description:"\u0160ie slapukai padeda mums u\u017Etikrinti geresn\u0119 vartotojo patirt\u012F ir i\u0161bandyti naujas funkcijas."}},frame:{title:"Priimkite {category} sutikim\u0105, kad gal\u0117tum\u0117te per\u017Ei\u016Br\u0117ti \u0161\u012F turin\u012F.",actionButton:"\u012Egalinti {category} sutikim\u0105"},legalLinks:{privacyPolicy:"Privatumo politika",cookiePolicy:"Slapuk\u0173 politika",termsOfService:"Naudojimosi s\u0105lygos"},iab:{banner:{title:"Privatumo nustatymai",description:"Mes ir m\u016Bs\u0173 {partnerCount} partneriai saugome ir (arba) pasiekiame informacij\u0105 j\u016Bs\u0173 \u012Frenginyje ir tvarkome asmens duomenis, tokius kaip unikal\u016Bs identifikatoriai ir nar\u0161ymo duomenys, \u0161ioje svetain\u0117je, kad gal\u0117tume:",partnersLink:"{count} partneriai",andMore:"Ir dar {count}...",legitimateInterestNotice:"Kai kurie partneriai teigia turintys teis\u0117t\u0105 interes\u0105 tvarkyti j\u016Bs\u0173 duomenis. J\u016Bs turite teis\u0119 nesutikti su tokiu tvarkymu, tinkinti savo pasirinkimus ir bet kada at\u0161aukti sutikim\u0105.",scopeServiceSpecific:"J\u016Bs\u0173 sutikimas taikomas tik \u0161iai svetainei ir netur\u0117s \u012Ftakos kitoms paslaugoms.",scopeGroup:"J\u016Bs\u0173 pasirinkimas taikomas visoms m\u016Bs\u0173 svetain\u0117ms \u0161ioje grup\u0117je."},preferenceCenter:{title:"Privatumo nustatymai",description:"\u010Cia galite tinkinti savo privatumo nustatymus. Galite pasirinkti, koki\u0173 tip\u0173 slapukus ir sekimo technologijas leid\u017Eiate naudoti.",tabs:{purposes:"Tikslai",vendors:"Tiek\u0117jai"},purposeItem:{partners:"{count} partneriai",vendorsUseLegitimateInterest:"{count} tiek\u0117jai teigia turintys teis\u0117t\u0105 interes\u0105",examples:"Pavyzd\u017Eiai",partnersUsingPurpose:"Partneriai, naudojantys \u0161\u012F tiksl\u0105",withYourPermission:"Su j\u016Bs\u0173 leidimu",legitimateInterest:"Teis\u0117tas interesas",objectButton:"Nesutikti",objected:"Prie\u0161tarauta",rightToObject:"J\u016Bs turite teis\u0119 nesutikti su tvarkymu, pagr\u012Fstu teis\u0117tu interesu."},specialPurposes:{title:"Esmin\u0117s funkcijos (privaloma)",tooltip:"Jos reikalingos svetain\u0117s funkcionalumui ir saugumui u\u017Etikrinti. Pagal IAB TCF negalite nesutikti su \u0161iais specialiais tikslais."},vendorList:{search:"Ie\u0161koti tiek\u0117j\u0173...",showingCount:"Rodoma {filtered} i\u0161 {total} tiek\u0117j\u0173",iabVendorsHeading:"IAB registruoti tiek\u0117jai",iabVendorsNotice:"\u0160ie partneriai yra u\u017Eregistruoti IAB Transparency & Consent Framework (TCF) \u2013 pramon\u0117s standarte, skirtame sutikim\u0173 valdymui",customVendorsHeading:"Pasirinktiniai partneriai",customVendorsNotice:"Tai yra pasirinktiniai partneriai, kurie n\u0117ra u\u017Eregistruoti IAB Transparency & Consent Framework (TCF). Jie tvarko duomenis remdamiesi j\u016Bs\u0173 sutikimu ir gali taikyti kitoki\u0105 privatumo praktik\u0105 nei IAB registruoti tiek\u0117jai.",purposes:"Tikslai",specialPurposes:"Special\u016Bs tikslai",specialFeatures:"Specialios funkcijos",features:"Funkcijos",dataCategories:"Duomen\u0173 kategorijos",usesCookies:"Naudoja slapukus",nonCookieAccess:"Prieiga be slapuk\u0173",maxAge:"Maks. am\u017Eius: {days}d",retention:"Saugojimas: {days}d",legitimateInterest:"Teis\u0117tas int.",privacyPolicy:"Privatumo politika",storageDisclosure:"Informacija apie saugojim\u0105",requiredNotice:"Reikalinga svetain\u0117s funkcionalumui, negalima i\u0161jungti"},footer:{consentStorage:"Sutikimo nuostatos saugomos slapuke pavadinimu \u201Eeuconsent-v2\u201C 13 m\u0117nesi\u0173. The storage duration may be refreshed when you update your preferences."}},common:{acceptAll:"Priimti visus",rejectAll:"Atmesti visus",customize:"Tinkinti",saveSettings:"I\u0161saugoti nustatymus",loading:"\u012Ekeliama...",showingSelectedVendor:"Rodomas pasirinktas tiek\u0117jas",clearSelection:"I\u0161valyti",customPartner:"Pasirinktinis partneris, ne\u012Fregistruotas IAB"}}},Ci={common:{acceptAll:"Pie\u0146emt visu",rejectAll:"Noraid\u012Bt visu",customize:"Piel\u0101got",save:"Saglab\u0101t iestat\u012Bjumus"},cookieBanner:{title:"M\u0113s nov\u0113rt\u0113jam j\u016Bsu priv\u0101tumu",description:"\u0160\u012B vietne izmanto s\u012Bkdatnes, lai uzlabotu j\u016Bsu p\u0101rl\u016Bko\u0161anas pieredzi, analiz\u0113tu vietnes datpl\u016Bsmu un r\u0101d\u012Btu personaliz\u0113tu saturu."},consentManagerDialog:{title:"Priv\u0101tuma iestat\u012Bjumi",description:"Piel\u0101gojiet savus priv\u0101tuma iestat\u012Bjumus \u0161eit. J\u016Bs varat izv\u0113l\u0113ties, k\u0101da veida s\u012Bkdatnes un izseko\u0161anas tehnolo\u0123ijas at\u013Caut."},consentTypes:{necessary:{title:"Stingri nepiecie\u0161am\u0101s",description:"\u0160\u012Bs s\u012Bkdatnes ir b\u016Btiskas, lai vietne darbotos pareizi, un t\u0101s nevar atsp\u0113jot."},functionality:{title:"Funkcionalit\u0101te",description:"\u0160\u012Bs s\u012Bkdatnes nodro\u0161ina uzlabotu funkcionalit\u0101ti un vietnes personaliz\u0101ciju."},marketing:{title:"M\u0101rketings",description:"\u0160\u012Bs s\u012Bkdatnes tiek izmantotas, lai pieg\u0101d\u0101tu atbilsto\u0161as rekl\u0101mas un izsekotu to efektivit\u0101ti."},measurement:{title:"Anal\u012Btika",description:"\u0160\u012Bs s\u012Bkdatnes pal\u012Bdz mums saprast, k\u0101 apmekl\u0113t\u0101ji mijiedarbojas ar vietni un uzlabo t\u0101s veiktsp\u0113ju."},experience:{title:"Pieredze",description:"\u0160\u012Bs s\u012Bkdatnes pal\u012Bdz mums nodro\u0161in\u0101t lab\u0101ku lietot\u0101ja pieredzi un test\u0113t jaunas funkcijas."}},frame:{title:"Pie\u0146emiet {category} piekri\u0161anu, lai skat\u012Btu \u0161o saturu.",actionButton:"Iesp\u0113jot {category} piekri\u0161anu"},legalLinks:{privacyPolicy:"Priv\u0101tuma politika",cookiePolicy:"S\u012Bkdat\u0146u politika",termsOfService:"Pakalpojumu snieg\u0161anas noteikumi"},iab:{banner:{title:"Priv\u0101tuma iestat\u012Bjumi",description:"M\u0113s un m\u016Bsu {partnerCount} partneri uzglab\u0101jam un/vai piek\u013C\u016Bstam inform\u0101cijai j\u016Bsu ier\u012Bc\u0113 un apstr\u0101d\u0101jam personas datus, piem\u0113ram, unik\u0101lus identifikatorus un p\u0101rl\u016Bko\u0161anas datus, \u0161ai vietnei, lai:",partnersLink:"{count} partneri",andMore:"Un v\u0113l {count}...",legitimateInterestNotice:"Da\u017Ei partneri pieprasa le\u0123it\u012Bmas intereses j\u016Bsu datu apstr\u0101dei. Jums ir ties\u012Bbas iebilst pret \u0161o apstr\u0101di, piel\u0101got savu izv\u0113li un jebkur\u0101 laik\u0101 atsaukt savu piekri\u0161anu.",scopeServiceSpecific:"J\u016Bsu piekri\u0161ana attiecas tikai uz \u0161o vietni un neietekm\u0113s citus pakalpojumus.",scopeGroup:"J\u016Bsu izv\u0113le attiecas uz vis\u0101m m\u016Bsu vietn\u0113m \u0161aj\u0101 grup\u0101."},preferenceCenter:{title:"Priv\u0101tuma iestat\u012Bjumi",description:"Piel\u0101gojiet savus priv\u0101tuma iestat\u012Bjumus \u0161eit. J\u016Bs varat izv\u0113l\u0113ties, k\u0101da veida s\u012Bkdatnes un izseko\u0161anas tehnolo\u0123ijas at\u013Caut.",tabs:{purposes:"M\u0113r\u0137i",vendors:"Pieg\u0101d\u0101t\u0101ji"},purposeItem:{partners:"{count} partneri",vendorsUseLegitimateInterest:"{count} pieg\u0101d\u0101t\u0101ji pieprasa le\u0123it\u012Bmas intereses",examples:"Piem\u0113ri",partnersUsingPurpose:"Partneri, kas izmanto \u0161o m\u0113r\u0137i",withYourPermission:"Ar j\u016Bsu at\u013Cauju",legitimateInterest:"Le\u0123it\u012Bm\u0101s intereses",objectButton:"Iebilst",objected:"Iebilsts",rightToObject:"Jums ir ties\u012Bbas iebilst pret apstr\u0101di, kuras pamat\u0101 ir le\u0123it\u012Bmas intereses."},specialPurposes:{title:"B\u016Btiskas funkcijas (nepiecie\u0161ams)",tooltip:"T\u0101s ir nepiecie\u0161amas vietnes funkcionalit\u0101tei un dro\u0161\u012Bbai. Saska\u0146\u0101 ar IAB TCF j\u016Bs nevarat iebilst pret \u0161iem \u012Bpa\u0161ajiem m\u0113r\u0137iem."},vendorList:{search:"Mekl\u0113t pieg\u0101d\u0101t\u0101jus...",showingCount:"R\u0101da {filtered} no {total} pieg\u0101d\u0101t\u0101jiem",iabVendorsHeading:"IAB re\u0123istr\u0113tie pieg\u0101d\u0101t\u0101ji",iabVendorsNotice:"\u0160ie partneri ir re\u0123istr\u0113ti IAB Transparency & Consent Framework (TCF) \u2014 nozares standart\u0101 piekri\u0161anas p\u0101rvald\u012Bbai",customVendorsHeading:"Piel\u0101goti partneri",customVendorsNotice:"\u0160ie ir piel\u0101goti partneri, kas nav re\u0123istr\u0113ti IAB Transparency & Consent Framework (TCF). Vi\u0146i apstr\u0101d\u0101 datus, pamatojoties auf j\u016Bsu piekri\u0161anu, un vi\u0146iem var b\u016Bt at\u0161\u0137ir\u012Bga priv\u0101tuma prakse nek\u0101 IAB re\u0123istr\u0113tajiem pieg\u0101d\u0101t\u0101jiem.",purposes:"M\u0113r\u0137i",specialPurposes:"\u012Apa\u0161ie m\u0113r\u0137i",specialFeatures:"\u012Apa\u0161\u0101s funkcijas",features:"Funkcijas",dataCategories:"Datu kategorijas",usesCookies:"Izmanto s\u012Bkdatnes",nonCookieAccess:"Piek\u013Cuve bez s\u012Bkdatn\u0113m",maxAge:"Maks. vecums: {days}d",retention:"Saglab\u0101\u0161ana: {days}d",legitimateInterest:"Le\u0123. intereses",privacyPolicy:"Priv\u0101tuma politika",storageDisclosure:"Inform\u0101cija par glab\u0101\u0161anu",requiredNotice:"Nepiecie\u0161ams vietnes funkcionalit\u0101tei, nevar atsp\u0113jot"},footer:{consentStorage:'Piekri\u0161anas preferences tiek glab\u0101tas s\u012Bkdatn\u0113 ar nosaukumu "euconsent-v2" 13 m\u0113ne\u0161us. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Pie\u0146emt visu",rejectAll:"Noraid\u012Bt visu",customize:"Piel\u0101got",saveSettings:"Saglab\u0101t iestat\u012Bjumus",loading:"Iel\u0101d\u0113...",showingSelectedVendor:"R\u0101da atlas\u012Bto pieg\u0101d\u0101t\u0101ju",clearSelection:"Not\u012Br\u012Bt",customPartner:"Piel\u0101gots partneris, kas nav re\u0123istr\u0113ts IAB"}}},Ii={common:{acceptAll:"A\u010B\u010Betta kollox",rejectAll:"Irrifjuta kollox",customize:"Personalizza",save:"Issejvja s-settings"},cookieBanner:{title:"Napprezzaw il-privatezza tieg\u0127ek",description:"Dan is-sit ju\u017Ca cookies biex itejjeb l-esperjenza tal-browsing tieg\u0127ek, janalizza t-traffiku tas-sit, u juri kontenut personalizzat."},consentManagerDialog:{title:"Settings tal-privatezza",description:"Personalizza s-settings tal-privatezza tieg\u0127ek hawn. Tista' tag\u0127\u017Cel liema tipi ta' cookies u teknolo\u0121iji ta' tra\u010B\u010Bar tippermetti."},consentTypes:{necessary:{title:"Strettament ne\u010Bessarji",description:"Dawn il-cookies huma essenzjali biex is-sit web ja\u0127dem sew u ma jistg\u0127ux ji\u0121u di\u017Cattivati."},functionality:{title:"Funzjonalit\xE0",description:"Dawn il-cookies jippermettu funzjonalit\xE0 mtejba u personalizzazzjoni tas-sit web."},marketing:{title:"Marketing",description:"Dawn il-cookies jintu\u017Caw biex iwasslu riklami rilevanti u jittra\u010B\u010Baw l-effettivit\xE0 tag\u0127hom."},measurement:{title:"Analitika",description:"Dawn il-cookies jg\u0127inuna nifhmu kif il-vi\u017Citaturi jintera\u0121ixxu mas-sit web u ntejbu l-prestazzjoni tieg\u0127u."},experience:{title:"Esperjenza",description:"Dawn il-cookies jg\u0127inuna nipprovdu esperjenza a\u0127jar g\u0127all-utent u nittestjaw karatteristi\u010Bi \u0121odda."}},frame:{title:"A\u010B\u010Betta l-kunsens ta' {category} biex tara dan il-kontenut.",actionButton:"Attiva l-kunsens ta' {category}"},legalLinks:{privacyPolicy:"Politika tal-Privatezza",cookiePolicy:"Politika tal-Cookies",termsOfService:"Termini tas-Servizz"},iab:{banner:{title:"Settings tal-privatezza",description:"A\u0127na u l-{partnerCount} sie\u0127eb tag\u0127na na\u0127\u017Cnu u/jew na\u010B\u010Bessaw informazzjoni fuq apparat u nippro\u010Bessaw data personali, b\u0127al identifikaturi uni\u010Bi u data tal-browsing, g\u0127al dan is-sit web, biex:",partnersLink:"{count} sie\u0127eb",andMore:"U {count} o\u0127ra...",legitimateInterestNotice:"Xi s\u0127ab jitolbu interess le\u0121ittimu biex jippro\u010Bessaw id-data tieg\u0127ek. G\u0127andek id-dritt li to\u0121\u0121ezzjona g\u0127al dan il-pro\u010Bessar, tippersonalizza l-g\u0127a\u017Cliet tieg\u0127ek, u tirtira l-kunsens tieg\u0127ek fi kwalunkwe \u0127in.",scopeServiceSpecific:"Il-kunsens tieg\u0127ek japplika biss g\u0127al dan is-sit web u ma jaffettwax servizzi o\u0127ra.",scopeGroup:"L-g\u0127a\u017Cla tieg\u0127ek tapplika g\u0127al kull sit web tag\u0127na f'din il-grupp."},preferenceCenter:{title:"Settings tal-privatezza",description:"Personalizza s-settings tal-privatezza tieg\u0127ek hawn. Tista' tag\u0127\u017Cel liema tipi ta' cookies u teknolo\u0121iji ta' tra\u010B\u010Bar tippermetti.",tabs:{purposes:"G\u0127anijiet",vendors:"Bejjieg\u0127a"},purposeItem:{partners:"{count} sie\u0127eb",vendorsUseLegitimateInterest:"{count} bejjieg\u0127 jitolbu interess le\u0121ittimu",examples:"E\u017Cempji",partnersUsingPurpose:"S\u0127ab li Ju\u017Caw dan l-G\u0127an",withYourPermission:"Bil-Permess Tieg\u0127ek",legitimateInterest:"Interess Le\u0121ittimu",objectButton:"O\u0121\u0121ezzjona",objected:"O\u0121\u0121ezzjonat",rightToObject:"G\u0127andek id-dritt li to\u0121\u0121ezzjona g\u0127all-ippro\u010Bessar ibba\u017Cat fuq interess le\u0121ittimu."},specialPurposes:{title:"Funzjonijiet Essenzjali (Me\u0127tie\u0121a)",tooltip:"Dawn huma me\u0127tie\u0121a g\u0127all-funzjonalit\xE0 u s-sigurt\xE0 tas-sit. Skont l-IAB TCF, ma tistax to\u0121\u0121ezzjona g\u0127al dawn l-g\u0127anijiet spe\u010Bjali."},vendorList:{search:"Fittex bejjieg\u0127a...",showingCount:"Qed jintwerew {filtered} minn {total} bejjieg\u0127",iabVendorsHeading:"Bejjieg\u0127a Re\u0121istrati fl-IAB",iabVendorsNotice:"Dawn is-s\u0127ab huma re\u0121istrati mal-IAB Transparency & Consent Framework (TCF), standard tal-industrija g\u0127all-immani\u0121\u0121jar tal-kunsens",customVendorsHeading:"S\u0127ab Personalizzati",customVendorsNotice:"Dawn huma s\u0127ab personalizzati mhux re\u0121istrati mal-IAB Transparency & Consent Framework (TCF). Huma jippro\u010Bessaw id-data abba\u017Ci tal-kunsens tieg\u0127ek u jista\u2019 jkollhom prattiki ta\u2019 privatezza differenti minn bejjieg\u0127a re\u0121istrati fl-IAB.",purposes:"G\u0127anijiet",specialPurposes:"G\u0127anijiet Spe\u010Bjali",specialFeatures:"Karatteristi\u010Bi Spe\u010Bjali",features:"Karatteristi\u010Bi",dataCategories:"Kategoriji tad-Data",usesCookies:"Ju\u017Ca l-Cookies",nonCookieAccess:"A\u010B\u010Bess Mhux tal-Cookie",maxAge:"Et\xE0 Massima: {days}j",retention:"\u017Bamma: {days}j",legitimateInterest:"Int. Le\u0121ittimu",privacyPolicy:"Politika tal-Privatezza",storageDisclosure:"\u017Bvelar tal-\u0126a\u017Cna",requiredNotice:"Me\u0127tie\u0121 g\u0127all-funzjonalit\xE0 tas-sit, ma jistax ji\u0121i di\u017Cattivat"},footer:{consentStorage:'Il-preferenzi tal-kunsens huma ma\u0127\u017Cuna f\u2019cookie msemmija "euconsent-v2" g\u0127al 13-il xahar. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"A\u010B\u010Betta kollox",rejectAll:"Irrifjuta kollox",customize:"Personalizza",saveSettings:"Issejvja s-settings",loading:"Qed jillowdja...",showingSelectedVendor:"Qed jintwera l-bejjieg\u0127 mag\u0127\u017Cul",clearSelection:"Ikklerja",customPartner:"Sie\u0127eb personalizzat mhux re\u0121istrat mal-IAB"}}},ji={common:{acceptAll:"Godta alle",rejectAll:"Avsl\xE5 alle",customize:"Tilpass",save:"Lagre innstillinger"},cookieBanner:{title:"Vi verdsetter ditt personvern",description:"Dette nettstedet bruker informasjonskapsler for \xE5 forbedre din nettopplevelse, analysere trafikk og vise personlig tilpasset innhold."},consentManagerDialog:{title:"Personverninnstillinger",description:"Tilpass personverninnstillingene dine her. Du kan velge hvilke typer informasjonskapsler og sporingsteknologier du vil tillate."},consentTypes:{necessary:{title:"Strengt n\xF8dvendige",description:"Disse informasjonskapslene er essensielle for at nettstedet skal fungere riktig og kan ikke deaktiveres."},functionality:{title:"Funksjonalitet",description:"Disse informasjonskapslene muliggj\xF8r forbedret funksjonalitet og personalisering av nettstedet."},marketing:{title:"Markedsf\xF8ring",description:"Disse informasjonskapslene brukes til \xE5 levere relevante annonser og spore deres effektivitet."},measurement:{title:"Analyse",description:"Disse informasjonskapslene hjelper oss med \xE5 forst\xE5 hvordan bes\xF8kende samhandler med nettstedet og forbedre ytelsen."},experience:{title:"Opplevelse",description:"Disse informasjonskapslene hjelper oss med \xE5 gi en bedre brukeropplevelse og teste nye funksjoner."}},frame:{title:"Godta {category}-samtykke for \xE5 se dette innholdet.",actionButton:"Aktiver {category}-samtykke"},legalLinks:{privacyPolicy:"Personvernerkl\xE6ring",cookiePolicy:"Retningslinjer for informasjonskapsler",termsOfService:"Vilk\xE5r for bruk"},iab:{banner:{title:"Personverninnstillinger",description:"Vi og v\xE5re {partnerCount} partnere lagrer og/eller har tilgang til informasjon p\xE5 enheten din og behandler personopplysninger, som unike identifikatorer og nettleserdata, for dette nettstedet, for \xE5:",partnersLink:"{count} partnere",andMore:"Og {count} til...",legitimateInterestNotice:"Noen partnere krever legitim interesse for \xE5 behandle dataene dine. Du har rett til \xE5 protestere mot denne behandlingen, tilpasse valgene dine og trekke tilbake samtykket ditt n\xE5r som helst.",scopeServiceSpecific:"Samtykket ditt gjelder bare for dette nettstedet og p\xE5virker ikke andre tjenester.",scopeGroup:"Valget ditt gjelder p\xE5 tvers av v\xE5re nettsider i denne gruppen."},preferenceCenter:{title:"Personverninnstillinger",description:"Tilpass personverninnstillingene dine her. Du kan velge hvilke typer informasjonskapsler og sporingsteknologier du vil tillate.",tabs:{purposes:"Form\xE5l",vendors:"Leverand\xF8rer"},purposeItem:{partners:"{count} partnere",vendorsUseLegitimateInterest:"{count} leverand\xF8rer krever legitim interesse",examples:"Eksempler",partnersUsingPurpose:"Partnere som bruker dette form\xE5let",withYourPermission:"Med din tillatelse",legitimateInterest:"Legitim interesse",objectButton:"Protester",objected:"Protestert",rightToObject:"Du har rett til \xE5 protestere mot behandling basert p\xE5 legitim interesse."},specialPurposes:{title:"Viktige funksjoner (p\xE5krevd)",tooltip:"Disse er n\xF8dvendige for nettstedets funksjonalitet og sikkerhet. I henhold til IAB TCF kan du ikke protestere mot disse spesielle form\xE5lene."},vendorList:{search:"S\xF8k etter leverand\xF8rer...",showingCount:"{filtered} av {total} leverand\xF8rer",iabVendorsHeading:"IAB-registrerte leverand\xF8rer",iabVendorsNotice:"Disse partnerne er registrert i IAB Transparency & Consent Framework (TCF), en bransjestandard for administrasjon av samtykke",customVendorsHeading:"Egendefinerte partnere",customVendorsNotice:"Dette er egendefinerte partnere som ikke er registrert i IAB Transparency & Consent Framework (TCF). De behandler data basert p\xE5 ditt samtykke og kan ha annen personvernpraksis enn IAB-registrerte leverand\xF8rer.",purposes:"Form\xE5l",specialPurposes:"Spesielle form\xE5l",specialFeatures:"Spesielle funksjoner",features:"Funksjoner",dataCategories:"Datakategorier",usesCookies:"Bruker informasjonskapsler",nonCookieAccess:"Ikke-informasjonskapsel-tilgang",maxAge:"Maks alder: {days}d",retention:"Oppbevaring: {days}d",legitimateInterest:"Leg. interesse",privacyPolicy:"Personvernerkl\xE6ring",storageDisclosure:"Lagringsinformasjon",requiredNotice:"P\xE5krevd for nettstedets funksjonalitet, kan ikke deaktiveres"},footer:{consentStorage:'Samtykkepreferanser lagres i en informasjonskapsel kalt "euconsent-v2" i 13 m\xE5neder. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Godta alle",rejectAll:"Avsl\xE5 alle",customize:"Tilpass",saveSettings:"Lagre innstillinger",loading:"Laster...",showingSelectedVendor:"Viser valgt leverand\xF8r",clearSelection:"T\xF8m",customPartner:"Egendefinert partner ikke registrert i IAB"}}},Si={common:{acceptAll:"Alles accepteren",rejectAll:"Alles weigeren",customize:"Aanpassen",save:"Instellingen opslaan"},cookieBanner:{title:"Wij hechten waarde aan uw privacy",description:"Deze site gebruikt cookies om uw surfervaring te verbeteren, het verkeer op de site te analyseren en gepersonaliseerde inhoud te tonen"},consentManagerDialog:{title:"Privacy-instellingen",description:"Pas hier uw privacyinstellingen aan. U kunt kiezen welke soorten cookies en trackingtechnologie\xEBn u toestaat."},consentTypes:{necessary:{title:"Strikt noodzakelijk",description:"Deze cookies zijn essentieel voor het goed functioneren van de website en kunnen niet worden uitgeschakeld"},functionality:{title:"Functionaliteit",description:"Deze cookies maken verbeterde functionaliteit en personalisatie van de website mogelijk."},marketing:{title:"Marketing",description:"Deze cookies worden gebruikt om relevante advertenties aan te bieden en de effectiviteit ervan bij te houden"},measurement:{title:"Analytics",description:"Deze cookies helpen ons te begrijpen hoe bezoekers omgaan met de website en de prestaties ervan te verbeteren"},experience:{title:"Ervaring",description:"Deze cookies helpen ons om een betere gebruikerservaring te bieden en nieuwe functies te testen"}},frame:{title:"Accepteer {category} om deze inhoud te bekijken",actionButton:"Schakel {category} toestemming in"},legalLinks:{privacyPolicy:"Privacybeleid",cookiePolicy:"Cookiebeleid",termsOfService:"Servicevoorwaarden"},iab:{banner:{title:"Privacy-instellingen",description:"Wij en onze {partnerCount} partners slaan informatie op een apparaat op en/of openen deze en verwerken persoonlijke gegevens, zoals unieke identificatoren en browsegegevens, voor deze website, om:",partnersLink:"{count} partners",andMore:"En nog {count}...",legitimateInterestNotice:"Sommige partners maken aanspraak op een gerechtvaardigd belang om uw gegevens te verwerken. U heeft het recht om bezwaar te maken tegen deze verwerking, uw keuzes aan te passen en uw toestemming op elk moment in te trekken.",scopeServiceSpecific:"Je toestemming geldt alleen voor deze website en heeft geen invloed op andere diensten.",scopeGroup:"Uw keuze geldt voor al onze websites in deze groep."},preferenceCenter:{title:"Privacy-instellingen",description:"Pas hier uw privacyinstellingen aan. U kunt kiezen welke soorten cookies en trackingtechnologie\xEBn u toestaat.",tabs:{purposes:"Doeleinden",vendors:"Leveranciers"},purposeItem:{partners:"{count} partners",vendorsUseLegitimateInterest:"{count} leveranciers maken aanspraak op gerechtvaardigd belang",examples:"Voorbeelden",partnersUsingPurpose:"Partners die dit doeleinde gebruiken",withYourPermission:"Met uw toestemming",legitimateInterest:"Gerechtvaardigd belang",objectButton:"Bezwaar maken",objected:"Bezwaar gemaakt",rightToObject:"U heeft het recht om bezwaar te maken tegen verwerking op basis van gerechtvaardigd belang."},specialPurposes:{title:"Essenti\xEBle functies (vereist)",tooltip:"Deze zijn vereist voor de functionaliteit en beveiliging van de site. Volgens IAB TCF kunt u geen bezwaar maken tegen deze speciale doeleinden."},vendorList:{search:"Zoek leveranciers...",showingCount:"{filtered} van {total} leveranciers",iabVendorsHeading:"IAB-geregistreerde leveranciers",iabVendorsNotice:"Deze partners zijn geregistreerd bij het IAB Transparency & Consent Framework (TCF), een industriestandaard voor het beheren van toestemming",customVendorsHeading:"Aangepaste partners",customVendorsNotice:"Dit zijn aangepaste partners die niet zijn geregistreerd bij het IAB Transparency & Consent Framework (TCF). Ze verwerken gegevens op basis van uw toestemming en kunnen andere privacypraktijken hebben dan IAB-geregistreerde leveranciers.",purposes:"Doeleinden",specialPurposes:"Speciale doeleinden",specialFeatures:"Speciale functies",features:"Functies",dataCategories:"Datacategorie\xEBn",usesCookies:"Gebruikt cookies",nonCookieAccess:"Toegang zonder cookies",maxAge:"Max. leeftijd: {days}d",retention:"Bewaartermijn: {days}d",legitimateInterest:"Gerechtv. belang",privacyPolicy:"Privacybeleid",storageDisclosure:"Openbaarmaking van opslag",requiredNotice:"Vereist voor websitefunctionaliteit, kan niet worden uitgeschakeld"},footer:{consentStorage:'Toestemmingsvoorkeuren worden gedurende 13 maanden opgeslagen in een cookie genaamd "euconsent-v2". The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Alles accepteren",rejectAll:"Alles weigeren",customize:"Aanpassen",saveSettings:"Instellingen opslaan",loading:"Laden...",showingSelectedVendor:"Geselecteerde leverancier wordt getoond",clearSelection:"Wissen",customPartner:"Aangepaste partner niet geregistreerd bij het IAB"}}},Ai={common:{acceptAll:"Godta alle",rejectAll:"Avvis alle",customize:"Tilpass",save:"Lagre innstillingar"},cookieBanner:{title:"Vi verdset personvernet ditt",description:"Denne nettstaden brukar informasjonskapslar for \xE5 forbetre nettopplevinga di, analysere nettstadtrafikk og vise personleg tilpassa innhald."},consentManagerDialog:{title:"Personverninnstillingar",description:"Tilpass personverninnstillingane dine her. Du kan velje kva typar informasjonskapslar og sporingsteknologiar du till\xE8t."},consentTypes:{necessary:{title:"Strengt n\xF8dvendige",description:"Desse informasjonskapslane er n\xF8dvendige for at nettstaden skal fungere riktig og kan ikkje deaktiverast."},functionality:{title:"Funksjonalitet",description:"Desse informasjonskapslane gjer det mogleg med forbetra funksjonalitet og personleggjering av nettstaden."},marketing:{title:"Marknadsf\xF8ring",description:"Desse informasjonskapslane blir brukte til \xE5 levere relevante annonsar og spore effektiviteten deira."},measurement:{title:"Analyse",description:"Desse informasjonskapslane hjelper oss \xE5 forst\xE5 korleis bes\xF8kande samhandlar med nettstaden og forbetre ytinga."},experience:{title:"Oppleving",description:"Desse informasjonskapslane hjelper oss \xE5 gi ei betre brukaroppleving og teste nye funksjonar."}},frame:{title:"Godta {category}-samtykke for \xE5 sj\xE5 dette innhaldet.",actionButton:"Aktiver {category}-samtykke"},legalLinks:{privacyPolicy:"Personvernerkl\xE6ring",cookiePolicy:"Retningslinjer for informasjonskapslar",termsOfService:"Brukarvilk\xE5r"},iab:{banner:{title:"Personverninnstillingar",description:"Vi og v\xE5re {partnerCount} partnarar lagrar og/eller har tilgang til informasjon p\xE5 eininga di og behandlar personopplysningar, som unike identifikatorar og nettlesardata, for denne nettstaden, for \xE5:",partnersLink:"{count} partnarar",andMore:"Og {count} til...",legitimateInterestNotice:"Nokre partnarar krev legitim interesse for \xE5 behandle dataa dine. Du har rett til \xE5 protestere mot denne behandlinga, tilpasse vala dine og trekkje tilbake samtykket ditt n\xE5r som helst.",scopeServiceSpecific:"Samtykket ditt gjeld berre for denne nettstaden og p\xE5verkar ikkje andre tenester.",scopeGroup:"Valet ditt gjeld p\xE5 tvers av nettsidene v\xE5re i denne gruppa."},preferenceCenter:{title:"Personverninnstillingar",description:"Tilpass personverninnstillingane dine her. Du kan velje kva typar informasjonskapslar og sporingsteknologiar du till\xE8t.",tabs:{purposes:"F\xF8rem\xE5l",vendors:"Leverand\xF8rar"},purposeItem:{partners:"{count} partnarar",vendorsUseLegitimateInterest:"{count} leverand\xF8rar krev legitim interesse",examples:"D\xF8me",partnersUsingPurpose:"Partnarar som brukar dette f\xF8rem\xE5let",withYourPermission:"Med di tillating",legitimateInterest:"Legitim interesse",objectButton:"Protester",objected:"Protestert",rightToObject:"Du har rett til \xE5 protestere mot behandling basert p\xE5 legitim interesse."},specialPurposes:{title:"Viktige funksjonar (p\xE5kravd)",tooltip:"Desse er n\xF8dvendige for funksjonaliteten og tryggleiken til nettstaden. I f\xF8lgje IAB TCF kan du ikkje protestere mot desse spesielle f\xF8rem\xE5la."},vendorList:{search:"S\xF8k etter leverand\xF8rar...",showingCount:"{filtered} av {total} leverand\xF8rar",iabVendorsHeading:"IAB-registrerte leverand\xF8rar",iabVendorsNotice:"Disse partnarane er registrerte i IAB Transparency & Consent Framework (TCF), ein bransjestandard for administrasjon av samtykke",customVendorsHeading:"Eigendefinerte partnarar",customVendorsNotice:"Dette er eigendefinerte partnarar som ikkje er registrerte i IAB Transparency & Consent Framework (TCF). Dei behandlar data basert p\xE5 ditt samtykke og kan ha annan personvernpraksis enn IAB-registrerte leverand\xF8rar.",purposes:"F\xF8rem\xE5l",specialPurposes:"Spesielle f\xF8rem\xE5l",specialFeatures:"Spesielle funksjonar",features:"Funksjonar",dataCategories:"Datakategoriar",usesCookies:"Brukar informasjonskapslar",nonCookieAccess:"Ikkje-informasjonskapsel-tilgang",maxAge:"Maks alder: {days}d",retention:"Lagring: {days}d",legitimateInterest:"Leg. interesse",privacyPolicy:"Personvernerkl\xE6ring",storageDisclosure:"Lagringsinformasjon",requiredNotice:"P\xE5kravd for funksjonaliteten til nettstaden, kan ikkje deaktiverast"},footer:{consentStorage:'Samtykkepreferansar blir lagra i ein informasjonskapsel kalla "euconsent-v2" i 13 m\xE5nader. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Godta alle",rejectAll:"Avvis alle",customize:"Tilpass",saveSettings:"Lagre innstillingar",loading:"Lastar...",showingSelectedVendor:"Viser vald leverand\xF8r",clearSelection:"T\xF8m",customPartner:"Eigendefinert partnar ikkje registrert i IAB"}}},zi={common:{acceptAll:"Zaakceptuj wszystkie",rejectAll:"Odrzu\u0107 wszystkie",customize:"Dostosuj",save:"Zapisz ustawienia"},cookieBanner:{title:"Cenimy Twoj\u0105 prywatno\u015B\u0107",description:"Ta strona u\u017Cywa plik\xF3w cookie, aby poprawi\u0107 Twoje wra\u017Cenia z przegl\u0105dania, analizowa\u0107 ruch na stronie i wy\u015Bwietla\u0107 spersonalizowane tre\u015Bci."},consentManagerDialog:{title:"Ustawienia prywatno\u015Bci",description:"Dostosuj tutaj swoje ustawienia prywatno\u015Bci. Mo\u017Cesz wybra\u0107, kt\xF3re rodzaje plik\xF3w cookie i technologii \u015Bledzenia chcesz zaakceptowa\u0107."},consentTypes:{necessary:{title:"\u015Aci\u015Ble niezb\u0119dne",description:"Te pliki cookie s\u0105 niezb\u0119dne do prawid\u0142owego funkcjonowania strony internetowej i nie mo\u017Cna ich wy\u0142\u0105czy\u0107."},functionality:{title:"Funkcjonalno\u015B\u0107",description:"Te pliki cookie umo\u017Cliwiaj\u0105 ulepszon\u0105 funkcjonalno\u015B\u0107 i personalizacj\u0119 strony internetowej."},marketing:{title:"Marketing",description:"Te pliki cookie s\u0105 u\u017Cywane do dostarczania odpowiednich reklam i \u015Bledzenia ich skuteczno\u015Bci."},measurement:{title:"Analityka",description:"Te pliki cookie pomagaj\u0105 nam zrozumie\u0107, jak odwiedzaj\u0105cy korzystaj\u0105 ze strony internetowej, i poprawi\u0107 jej wydajno\u015B\u0107."},experience:{title:"Do\u015Bwiadczenie",description:"Te pliki cookie pomagaj\u0105 nam zapewni\u0107 lepsze wra\u017Cenia u\u017Cytkownika i testowa\u0107 nowe funkcje."}},frame:{title:"Zaakceptuj zgod\u0119 na {category}, aby wy\u015Bwietli\u0107 t\u0119 tre\u015B\u0107.",actionButton:"W\u0142\u0105cz zgod\u0119 na {category}"},legalLinks:{privacyPolicy:"Polityka prywatno\u015Bci",cookiePolicy:"Polityka plik\xF3w cookie",termsOfService:"Regulamin"},iab:{banner:{title:"Ustawienia prywatno\u015Bci",description:"My i nasi {partnerCount} partnerzy przechowujemy i/lub uzyskujemy dost\u0119p do informacji na urz\u0105dzeniu oraz przetwarzamy dane osobowe, takie jak unikalne identyfikatory i dane dotycz\u0105ce przegl\u0105dania, w tej witrynie, aby:",partnersLink:"{count} partner\xF3w",andMore:"I {count} wi\u0119cej...",legitimateInterestNotice:"Niekt\xF3rzy partnerzy powo\u0142uj\u0105 si\u0119 na uzasadniony interes w przetwarzaniu Twoich danych. Masz prawo sprzeciwi\u0107 si\u0119 temu przetwarzaniu, dostosowa\u0107 swoje wybory i wycofa\u0107 zgod\u0119 w dowolnym momencie.",scopeServiceSpecific:"Twoja zgoda dotyczy tylko tej strony internetowej i nie wp\u0142ywa na inne us\u0142ugi.",scopeGroup:"Tw\xF3j wyb\xF3r ma zastosowanie do wszystkich naszych stron w tej grupie."},preferenceCenter:{title:"Ustawienia prywatno\u015Bci",description:"Dostosuj tutaj swoje ustawienia prywatno\u015Bci. Mo\u017Cesz wybra\u0107, kt\xF3re rodzaje plik\xF3w cookie i technologii \u015Bledzenia chcesz zaakceptowa\u0107.",tabs:{purposes:"Cele",vendors:"Dostawcy"},purposeItem:{partners:"{count} partner\xF3w",vendorsUseLegitimateInterest:"{count} dostawc\xF3w powo\u0142uje si\u0119 na uzasadniony interes",examples:"Przyk\u0142ady",partnersUsingPurpose:"Partnerzy korzystaj\u0105cy z tego celu",withYourPermission:"Za Twoj\u0105 zgod\u0105",legitimateInterest:"Uzasadniony interes",objectButton:"Sprzeciw",objected:"Zg\u0142oszono sprzeciw",rightToObject:"Masz prawo sprzeciwi\u0107 si\u0119 przetwarzaniu opartemu na uzasadnionym interesie."},specialPurposes:{title:"Funkcje niezb\u0119dne (wymagane)",tooltip:"S\u0105 one wymagane dla funkcjonalno\u015Bci i bezpiecze\u0144stwa witryny. Zgodnie z IAB TCF nie mo\u017Cna sprzeciwi\u0107 si\u0119 tym celom specjalnym."},vendorList:{search:"Szukaj dostawc\xF3w...",showingCount:"{filtered} z {total} dostawc\xF3w",iabVendorsHeading:"Dostawcy zarejestrowani w IAB",iabVendorsNotice:"Ci partnerzy s\u0105 zarejestrowani w IAB Transparency & Consent Framework (TCF), standardzie bran\u017Cowym dotycz\u0105cym zarz\u0105dzania zgodami",customVendorsHeading:"Partnerzy niestandardowi",customVendorsNotice:"S\u0105 to partnerzy niestandardowi, kt\xF3rzy nie s\u0105 zarejestrowani w IAB Transparency & Consent Framework (TCF). Przetwarzaj\u0105 dane na podstawie Twojej zgody i mog\u0105 stosowa\u0107 inne praktyki prywatno\u015Bci ni\u017C dostawcy zarejestrowani w IAB.",purposes:"Cele",specialPurposes:"Cele specjalne",specialFeatures:"Funkcje specjalne",features:"Funkcje",dataCategories:"Kategorie danych",usesCookies:"U\u017Cywa plik\xF3w cookie",nonCookieAccess:"Dost\u0119p bez plik\xF3w cookie",maxAge:"Maks. wiek: {days}d",retention:"Przechowywanie: {days}d",legitimateInterest:"Uzasadn. interes",privacyPolicy:"Polityka prywatno\u015Bci",storageDisclosure:"Ujawnienie informacji o przechowywaniu",requiredNotice:"Wymagane dla funkcjonalno\u015Bci witryny, nie mo\u017Cna wy\u0142\u0105czy\u0107"},footer:{consentStorage:"Preferencje dotycz\u0105ce zgody s\u0105 przechowywane w pliku cookie o nazwie \u201Eeuconsent-v2\u201D przez 13 miesi\u0119cy. The storage duration may be refreshed when you update your preferences."}},common:{acceptAll:"Zaakceptuj wszystkie",rejectAll:"Odrzu\u0107 wszystkie",customize:"Dostosuj",saveSettings:"Zapisz ustawienia",loading:"\u0141adowanie...",showingSelectedVendor:"Pokazywanie wybranego dostawcy",clearSelection:"Wyczy\u015B\u0107",customPartner:"Partner niestandardowy niezarejestrowany w IAB"}}},Pi={common:{acceptAll:"Aceitar todos",rejectAll:"Rejeitar todos",customize:"Personalizar",save:"Salvar configura\xE7\xF5es"},cookieBanner:{title:"Respeitamos a sua privacidade",description:"Este site utiliza cookies para melhorar a sua experi\xEAncia de navega\xE7\xE3o, analisar o tr\xE1fego do site e mostrar conte\xFAdos personalizados."},consentManagerDialog:{title:"Configura\xE7\xF5es",description:"Personalize suas configura\xE7\xF5es de privacidade aqui. Voc\xEA pode escolher quais tipos de cookies e tecnologias de rastreamento voc\xEA permite."},consentTypes:{necessary:{title:"Estritamente necess\xE1rio",description:"Estes cookies s\xE3o essenciais para o site funcionar corretamente e n\xE3o podem ser desativados."},functionality:{title:"Funcionalidade",description:"Estes cookies permitem funcionalidades aprimoradas e personaliza\xE7\xE3o do site."},marketing:{title:"Marketing",description:"Estes cookies s\xE3o utilizados para fornecer publicidade relevante e rastrear a sua efic\xE1cia."},measurement:{title:"An\xE1lise",description:"Estes cookies nos ajudam a compreender como os visitantes interagem com o site e melhoram o seu desempenho."},experience:{title:"Experi\xEAncia",description:"Estes cookies nos ajudam a fornecer uma experi\xEAncia de usu\xE1rio melhor e testar novas funcionalidades."}},frame:{title:"Aceite {category} para ver este conte\xFAdo",actionButton:"Ativar consentimento {category}"},legalLinks:{privacyPolicy:"Pol\xEDtica de Privacidade",cookiePolicy:"Pol\xEDtica de Cookies",termsOfService:"Termos de Servi\xE7o"},iab:{banner:{title:"Configura\xE7\xF5es de privacidade",description:"N\xF3s e os nossos {partnerCount} parceiros armazenamos e/ou acedemos a informa\xE7\xF5es num dispositivo e processamos dados pessoais, tais como identificadores \xFAnicos e informa\xE7\xF5es de navega\xE7\xE3o, para este website, para:",partnersLink:"{count} parceiros",andMore:"E mais {count}...",legitimateInterestNotice:"Alguns parceiros alegam um interesse leg\xEDtimo para processar os seus dados. Tem o direito de se opor a este processamento, personalizar as suas escolhas e retirar o seu consentimento a qualquer momento.",scopeServiceSpecific:"O seu consentimento aplica-se apenas a este site e n\xE3o afetar\xE1 outros servi\xE7os.",scopeGroup:"A sua escolha aplica-se a todos os nossos sites neste grupo."},preferenceCenter:{title:"Configura\xE7\xF5es de privacidade",description:"Personalize suas configura\xE7\xF5es de privacidade aqui. Voc\xEA pode escolher quais tipos de cookies e tecnologias de rastreamento voc\xEA permite.",tabs:{purposes:"Finalidades",vendors:"Fornecedores"},purposeItem:{partners:"{count} parceiros",vendorsUseLegitimateInterest:"{count} fornecedores alegam interesse leg\xEDtimo",examples:"Exemplos",partnersUsingPurpose:"Parceiros que utilizam esta finalidade",withYourPermission:"Com a sua permiss\xE3o",legitimateInterest:"Interesse leg\xEDtimo",objectButton:"Opor-se",objected:"Oposi\xE7\xE3o registada",rightToObject:"Tem o direito de se opor ao processamento baseado no interesse leg\xEDtimo."},specialPurposes:{title:"Fun\xE7\xF5es essenciais (obrigat\xF3rias)",tooltip:"Estas s\xE3o necess\xE1rias para a funcionalidade e seguran\xE7a do site. De acordo com o IAB TCF, n\xE3o pode opor-se a estas finalidades especiais."},vendorList:{search:"Procurar fornecedores...",showingCount:"{filtered} de {total} fornecedores",iabVendorsHeading:"Fornecedores registados no IAB",iabVendorsNotice:"Estes parceiros est\xE3o registados no IAB Transparency & Consent Framework (TCF), um padr\xE3o da ind\xFAstria para gerir o consentimento",customVendorsHeading:"Parceiros personalizados",customVendorsNotice:"Estes s\xE3o parceiros personalizados n\xE3o registados no IAB Transparency & Consent Framework (TCF). Processam dados com base no seu consentimento e podem ter pr\xE1ticas de privacidade diferentes das dos fornecedores registados no IAB.",purposes:"Finalidades",specialPurposes:"Finalidades especiais",specialFeatures:"Funcionalidades especiais",features:"Funcionalidades",dataCategories:"Categorias de dados",usesCookies:"Utiliza cookies",nonCookieAccess:"Acesso sem cookies",maxAge:"Idade m\xE1x.: {days}d",retention:"Reten\xE7\xE3o: {days}d",legitimateInterest:"Int. leg\xEDtimo",privacyPolicy:"Pol\xEDtica de privacidade",storageDisclosure:"Divulga\xE7\xE3o de armazenamento",requiredNotice:"Necess\xE1rio para a funcionalidade do site, n\xE3o pode ser desativado"},footer:{consentStorage:'As prefer\xEAncias de consentimento s\xE3o armazenadas num cookie chamado "euconsent-v2" durante 13 meses. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Aceitar todos",rejectAll:"Rejeitar todos",customize:"Personalizar",saveSettings:"Salvar configura\xE7\xF5es",loading:"A carregar...",showingSelectedVendor:"A mostrar o fornecedor selecionado",clearSelection:"Limpar",customPartner:"Parceiro personalizado n\xE3o registado no IAB"}}},Ti={common:{acceptAll:"Acceptar tut",rejectAll:"Refusar tut",customize:"Persunalisar",save:"Memorisar las configuraziuns"},cookieBanner:{title:"Nus stimain vossa sfera privata",description:"Questa pagina d'internet dovra cookies per meglierar vossa experientscha da navigaziun, analisar il traffic da la pagina e mussar cuntegns persunalisads."},consentManagerDialog:{title:"Configuraziuns da la sfera privata",description:"Persunalisai vossas configuraziuns da la sfera privata qua. Vus pudais tscherner tge tips da cookies e tecnologias da tracking che vus lubis."},consentTypes:{necessary:{title:"Absolutamain necessari",description:"Quests cookies \xE8n essenzials per il funcziunament da la pagina d'internet e na pon betg vegnir deactivads."},functionality:{title:"Funcziunalitad",description:"Quests cookies permettan funcziunalitads avanzadas e la persunalisaziun da la pagina d'internet."},marketing:{title:"Marketing",description:"Quests cookies vegnan duvrads per mussar reclamas relevantas e per evaluar lur efficacitad."},measurement:{title:"Analisa",description:"Quests cookies ans gidan a chapir co ils visitaders interageschan cun la pagina d'internet e meglierar sia prestaziun."},experience:{title:"Experientscha",description:"Quests cookies ans gidan a porscher ina meglra experientscha d'utilisader e testar novas funcziuns."}},frame:{title:"Acceptai il consentiment da {category} per vesair quest cuntegn.",actionButton:"Activar il consentiment da {category}"},legalLinks:{privacyPolicy:"Directivas da protecziun da datas",cookiePolicy:"Directivas da cookies",termsOfService:"Cundiziuns d'utilisaziun"},iab:{banner:{title:"Configuraziuns da la sfera privata",description:"Nus ed noss {partnerCount} partunaris memorisain e/u accessain ad infurmaziuns sin voss apparat e processain datas persunalas, sco identificaturs unics e datas da navigaziun, per questa pagina d\u2019internet, per:",partnersLink:"{count} partunaris",andMore:"Ed anc {count}...",legitimateInterestNotice:"Inscunter partunaris pretendan in interess legitim per processar vossas datas. Vus avais il dretg da far opposiziun cunter quest processament, persunalisar vossas tschernas e revocar voss consentiment en mintga mument.",scopeServiceSpecific:"Voss consent vala be per questa pagina web e na pertutga betg auters servetschs.",scopeGroup:"Vossa tscherna vala per tut nossas websites en quest gruppa."},preferenceCenter:{title:"Configuraziuns da la sfera privata",description:"Persunalisai vossas configuraziuns da la sfera privata qua. Vus pudais tscherner tge tips da cookies e tecnologias da tracking che vus lubis.",tabs:{purposes:"Finamiras",vendors:"Proveders"},purposeItem:{partners:"{count} partunaris",vendorsUseLegitimateInterest:"{count} proveders pretendan in interess legitim",examples:"Exempels",partnersUsingPurpose:"Partunaris che duvran questa finamira",withYourPermission:"Cun vossa permissiun",legitimateInterest:"Interess legitim",objectButton:"Far opposiziun",objected:"Opposiziun fatta",rightToObject:"Vus avais il dretg da far opposiziun cunter il processament sa basond sin in interess legitim."},specialPurposes:{title:"Funcziuns essenzialas (necessari)",tooltip:"Questas \xE8n necessarias per la funcziunalitad e la segirezza da la pagina. Tenor IAB TCF na pudais vus betg far opposiziun cunter questas finamiras spezialas."},vendorList:{search:"Tscherchar proveders...",showingCount:"Mussa {filtered} da {total} proveders",iabVendorsHeading:"Proveders registrads tar l\u2019IAB",iabVendorsNotice:"Quests partunaris \xE8n registrads tar l\u2019IAB Transparency & Consent Framework (TCF), in standard industrial per la gestiun dal consentiment",customVendorsHeading:"Partunaris persunalisads",customVendorsNotice:"Quai \xE8n partunaris persunalisads che n\u2019\xE8n betg registrads tar l\u2019IAB Transparency & Consent Framework (TCF). Els processan datas sa basond sin voss consentiment e pon avair autras praticas da protecziun da datas che proveders registrads tar l\u2019IAB.",purposes:"Finamiras",specialPurposes:"Finamiras spezialas",specialFeatures:"Funcziuns spezialas",features:"Funcziuns",dataCategories:"Categorias da datas",usesCookies:"Dovra cookies",nonCookieAccess:"Access betg tras cookies",maxAge:"Gradi maximal: {days}d",retention:"Retegnida: {days}d",legitimateInterest:"Int. legitim",privacyPolicy:"Directivas da protecziun da datas",storageDisclosure:"Infurmaziun davart la memorisaziun",requiredNotice:"Necessari per la funcziunalitad da la pagina, na po betg vegnir deactiv\xE0"},footer:{consentStorage:'Las preferenzas da consentiment vegnan memorisadas en in cookie numn\xE0 "euconsent-v2" per 13 mais. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Acceptar tut",rejectAll:"Refusar tut",customize:"Persunalisar",saveSettings:"Memorisar las configuraziuns",loading:"Chargia...",showingSelectedVendor:"Mussa il proveder tschern\xEC",clearSelection:"Stizzar",customPartner:"Partunari persunalis\xE0 betg registr\xE0 tar l\u2019IAB"}}},Li={common:{acceptAll:"Accept\u0103 toate",rejectAll:"Respinge toate",customize:"Personalizeaz\u0103",save:"Salveaz\u0103 set\u0103rile"},cookieBanner:{title:"Pre\u021Buim confiden\u021Bialitatea ta",description:"Acest site folose\u0219te cookie-uri pentru a \xEEmbun\u0103t\u0103\u021Bi experien\u021Ba de navigare, a analiza traficul site-ului \u0219i a afi\u0219a con\u021Binut personalizat."},consentManagerDialog:{title:"Set\u0103ri de confiden\u021Bialitate",description:"Personalizeaz\u0103 set\u0103rile de confiden\u021Bialitate aici. Po\u021Bi alege ce tipuri de cookie-uri \u0219i tehnologii de urm\u0103rire permi\u021Bi."},consentTypes:{necessary:{title:"Strict necesare",description:"Aceste cookie-uri sunt esen\u021Biale pentru func\u021Bionarea corect\u0103 a site-ului \u0219i nu pot fi dezactivate."},functionality:{title:"Func\u021Bionalitate",description:"Aceste cookie-uri permit func\u021Bionalit\u0103\u021Bi avansate \u0219i personalizarea site-ului."},marketing:{title:"Marketing",description:"Aceste cookie-uri sunt utilizate pentru a livra reclame relevante \u0219i pentru a urm\u0103ri eficien\u021Ba acestora."},measurement:{title:"Analitice",description:"Aceste cookie-uri ne ajut\u0103 s\u0103 \xEEn\u021Belegem cum interac\u021Bioneaz\u0103 vizitatorii cu site-ul \u0219i s\u0103 \xEEi \xEEmbun\u0103t\u0103\u021Bim performan\u021Ba."},experience:{title:"Experien\u021B\u0103",description:"Aceste cookie-uri ne ajut\u0103 s\u0103 oferim o experien\u021B\u0103 mai bun\u0103 utilizatorilor \u0219i s\u0103 test\u0103m func\u021Bionalit\u0103\u021Bi noi."}},frame:{title:"Accept\u0103 consim\u021B\u0103m\xE2ntul pentru {category} pentru a vizualiza acest con\u021Binut.",actionButton:"Activeaz\u0103 consim\u021B\u0103m\xE2ntul pentru {category}"},legalLinks:{privacyPolicy:"Politica de confiden\u021Bialitate",cookiePolicy:"Politica privind cookie-urile",termsOfService:"Termeni \u0219i condi\u021Bii"},iab:{banner:{title:"Set\u0103ri de confiden\u021Bialitate",description:"Noi \u0219i cei {partnerCount} parteneri ai no\u0219tri stoc\u0103m \u0219i/sau acces\u0103m informa\u021Bii pe dispozitivul t\u0103u \u0219i proces\u0103m date personale, cum ar fi identificatori unici \u0219i date de navigare, pentru acest site web, pentru:",partnersLink:"{count} parteneri",andMore:"\u0218i \xEEnc\u0103 {count}...",legitimateInterestNotice:"Unii parteneri invoc\u0103 un interes legitim pentru a procesa datele tale. Ai dreptul de a te opune acestei proces\u0103ri, de a-\u021Bi personaliza alegerile \u0219i de a-\u021Bi retrage consim\u021B\u0103m\xE2ntul \xEEn orice moment.",scopeServiceSpecific:"Consim\u021B\u0103m\xE2ntul t\u0103u se aplic\u0103 doar acestui site web \u0219i nu va afecta alte servicii.",scopeGroup:"Alegerea dvs. se aplic\u0103 tuturor site-urilor noastre din acest grup."},preferenceCenter:{title:"Set\u0103ri de confiden\u021Bialitate",description:"Personalizeaz\u0103 set\u0103rile de confiden\u021Bialitate aici. Po\u021Bi alege ce tipuri de cookie-uri \u0219i tehnologii de urm\u0103rire permi\u021Bi.",tabs:{purposes:"Scopuri",vendors:"Furnizori"},purposeItem:{partners:"{count} parteneri",vendorsUseLegitimateInterest:"{count} furnizori invoc\u0103 interes legitim",examples:"Exemple",partnersUsingPurpose:"Parteneri care utilizeaz\u0103 acest scop",withYourPermission:"Cu permisiunea ta",legitimateInterest:"Interes legitim",objectButton:"Opunere",objected:"Opozi\u021Bie exprimat\u0103",rightToObject:"Ai dreptul de a te opune proces\u0103rii bazate pe interesul legitim."},specialPurposes:{title:"Func\u021Bii esen\u021Biale (obligatorii)",tooltip:"Acestea sunt necesare pentru func\u021Bionalitatea \u0219i securitatea site-ului. Conform IAB TCF, nu te po\u021Bi opune acestor scopuri speciale."},vendorList:{search:"Caut\u0103 furnizori...",showingCount:"Se afi\u0219eaz\u0103 {filtered} din {total} furnizori",iabVendorsHeading:"Furnizori \xEEnregistra\u021Bi IAB",iabVendorsNotice:"Ace\u0219ti parteneri sunt \xEEnregistra\u021Bi \xEEn cadrul IAB Transparency & Consent Framework (TCF), un standard industrial pentru gestionarea consim\u021B\u0103m\xE2ntului",customVendorsHeading:"Parteneri personaliza\u021Bi",customVendorsNotice:"Ace\u0219tia sunt parteneri personaliza\u021Bi care nu sunt \xEEnregistra\u021Bi \xEEn IAB Transparency & Consent Framework (TCF). Ei proceseaz\u0103 datele pe baza consim\u021B\u0103m\xE2ntului t\u0103u \u0219i pot avea practici de confiden\u021Bialitate diferite de cele ale furnizorilor \xEEnregistra\u021Bi IAB.",purposes:"Scopuri",specialPurposes:"Scopuri speciale",specialFeatures:"Func\u021Bionalit\u0103\u021Bi speciale",features:"Func\u021Bionalit\u0103\u021Bi",dataCategories:"Categorii de date",usesCookies:"Utilizeaz\u0103 cookie-uri",nonCookieAccess:"Acces non-cookie",maxAge:"V\xE2rst\u0103 max.: {days}z",retention:"Reten\u021Bie: {days}z",legitimateInterest:"Int. legitim",privacyPolicy:"Politic\u0103 de confiden\u021Bialitate",storageDisclosure:"Prezentarea stoc\u0103rii",requiredNotice:"Necesar pentru func\u021Bionalitatea site-ului, nu poate fi dezactivat"},footer:{consentStorage:"Preferin\u021Bele de consim\u021B\u0103m\xE2nt sunt stocate \xEEntr-un cookie numit \u201Eeuconsent-v2\u201D timp de 13 luni. The storage duration may be refreshed when you update your preferences."}},common:{acceptAll:"Accept\u0103 toate",rejectAll:"Respinge toate",customize:"Personalizeaz\u0103",saveSettings:"Salveaz\u0103 set\u0103rile",loading:"Se \xEEncarc\u0103...",showingSelectedVendor:"Se afi\u0219eaz\u0103 furnizorul selectat",clearSelection:"\u0218terge",customPartner:"Partener personalizat ne\xEEnregistrat \xEEn IAB"}}},Ei={common:{acceptAll:"Prija\u0165 v\u0161etko",rejectAll:"Odmietnu\u0165 v\u0161etko",customize:"Prisp\xF4sobi\u0165",save:"Ulo\u017Ei\u0165 nastavenia"},cookieBanner:{title:"V\xE1\u017Eime si va\u0161e s\xFAkromie",description:"T\xE1to str\xE1nka pou\u017E\xEDva cookies na zlep\u0161enie v\xE1\u0161ho prehliadania, anal\xFDzu n\xE1v\u0161tevnosti a zobrazovanie personalizovan\xE9ho obsahu."},consentManagerDialog:{title:"Nastavenia s\xFAkromia",description:"Prisp\xF4sobte si nastavenia s\xFAkromia tu. M\xF4\u017Eete si vybra\u0165, ktor\xE9 typy cookies a sledovac\xEDch technol\xF3gi\xED povol\xEDte."},consentTypes:{necessary:{title:"Nevyhnutn\xE9",description:"Tieto cookies s\xFA nevyhnutn\xE9 pre spr\xE1vne fungovanie webovej str\xE1nky a nemo\u017Eno ich deaktivova\u0165."},functionality:{title:"Funk\u010Dnos\u0165",description:"Tieto cookies umo\u017E\u0148uj\xFA roz\u0161\xEDren\xFA funk\u010Dnos\u0165 a personaliz\xE1ciu webovej str\xE1nky."},marketing:{title:"Marketing",description:"Tieto cookies sa pou\u017E\xEDvaj\xFA na doru\u010Dovanie relevantn\xFDch rekl\xE1m a sledovanie ich \xFA\u010Dinnosti."},measurement:{title:"Analytika",description:"Tieto cookies n\xE1m pom\xE1haj\xFA pochopi\u0165, ako n\xE1v\u0161tevn\xEDci interaguj\xFA s webovou str\xE1nkou a zlep\u0161i\u0165 jej v\xFDkon."},experience:{title:"Pou\u017E\xEDvate\u013Esk\xE1 sk\xFAsenos\u0165",description:"Tieto cookies n\xE1m pom\xE1haj\xFA poskytova\u0165 lep\u0161iu pou\u017E\xEDvate\u013Esk\xFA sk\xFAsenos\u0165 a testova\u0165 nov\xE9 funkcie."}},frame:{title:"Prijmite s\xFAhlas pre kateg\xF3riu {category} na zobrazenie tohto obsahu.",actionButton:"Povoli\u0165 s\xFAhlas pre {category}"},legalLinks:{privacyPolicy:"Z\xE1sady ochrany osobn\xFDch \xFAdajov",cookiePolicy:"Z\xE1sady pou\u017E\xEDvania s\xFAborov cookie",termsOfService:"Podmienky pou\u017E\xEDvania slu\u017Eby"},iab:{banner:{title:"Nastavenia s\xFAkromia",description:"My a na\u0161i {partnerCount} partneri uklad\xE1me a/alebo pristupujeme k inform\xE1ci\xE1m vo va\u0161om zariaden\xED a sprac\xFAvame osobn\xE9 \xFAdaje, ako s\xFA jedine\u010Dn\xE9 identifik\xE1tory a \xFAdaje o prehliadan\xED, pre t\xFAto webov\xFA str\xE1nku s cie\u013Eom:",partnersLink:"{count} partneri",andMore:"A \u010Fal\u0161\xEDch {count}...",legitimateInterestNotice:"Niektor\xED partneri si uplat\u0148uj\xFA opr\xE1vnen\xFD z\xE1ujem na sprac\xFAvanie va\u0161ich \xFAdajov. M\xE1te pr\xE1vo vznies\u0165 n\xE1mietku proti tomuto sprac\xFAvaniu, prisp\xF4sobi\u0165 svoje vo\u013Eby a kedyko\u013Evek odvola\u0165 svoj s\xFAhlas.",scopeServiceSpecific:"V\xE1\u0161 s\xFAhlas plat\xED len pre t\xFAto webov\xFA str\xE1nku a neovplyvn\xED in\xE9 slu\u017Eby.",scopeGroup:"Va\u0161a vo\u013Eba plat\xED pre v\u0161etky na\u0161e weby v tejto skupine."},preferenceCenter:{title:"Nastavenia s\xFAkromia",description:"Prisp\xF4sobte si nastavenia s\xFAkromia tu. M\xF4\u017Eete si vybra\u0165, ktor\xE9 typy cookies a sledovac\xEDch technol\xF3gi\xED povol\xEDte.",tabs:{purposes:"\xDA\u010Dely",vendors:"Dod\xE1vatelia"},purposeItem:{partners:"{count} partneri",vendorsUseLegitimateInterest:"{count} dod\xE1vatelia si uplat\u0148uj\xFA opr\xE1vnen\xFD z\xE1ujem",examples:"Pr\xEDklady",partnersUsingPurpose:"Partneri vyu\u017E\xEDvaj\xFAci tento \xFA\u010Del",withYourPermission:"S va\u0161\xEDm povolen\xEDm",legitimateInterest:"Opr\xE1vnen\xFD z\xE1ujem",objectButton:"Vznies\u0165 n\xE1mietku",objected:"N\xE1mietka vznesen\xE1",rightToObject:"M\xE1te pr\xE1vo vznies\u0165 n\xE1mietku proti sprac\xFAvaniu zalo\u017Een\xE9mu na opr\xE1vnenom z\xE1ujme."},specialPurposes:{title:"Z\xE1kladn\xE9 funkcie (povinn\xE9)",tooltip:"Tieto s\xFA potrebn\xE9 pre funk\u010Dnos\u0165 a bezpe\u010Dnos\u0165 str\xE1nky. Pod\u013Ea IAB TCF nem\xF4\u017Eete vznies\u0165 n\xE1mietku proti t\xFDmto osobitn\xFDm \xFA\u010Delom."},vendorList:{search:"H\u013Eada\u0165 dod\xE1vate\u013Eov...",showingCount:"Zobrazuje sa {filtered} z {total} dod\xE1vate\u013Eov",iabVendorsHeading:"Dod\xE1vatelia registrovan\xED v IAB",iabVendorsNotice:"T\xEDto partneri s\xFA registrovan\xED v r\xE1mci IAB Transparency & Consent Framework (TCF), priemyseln\xE9ho \u0161tandardu pre spr\xE1vu s\xFAhlasu",customVendorsHeading:"Vlastn\xED partneri",customVendorsNotice:"Toto s\xFA vlastn\xED partneri, ktor\xED nie s\xFA registrovan\xED v r\xE1mci IAB Transparency & Consent Framework (TCF). Sprac\xFAvaj\xFA \xFAdaje na z\xE1klade v\xE1\u0161ho s\xFAhlasu a m\xF4\u017Eu ma\u0165 in\xE9 postupy ochrany s\xFAkromia ako dod\xE1vatelia registrovan\xED v IAB.",purposes:"\xDA\u010Dely",specialPurposes:"Osobitn\xE9 \xFA\u010Dely",specialFeatures:"Osobitn\xE9 funkcie",features:"Funkcie",dataCategories:"Kateg\xF3rie \xFAdajov",usesCookies:"Pou\u017E\xEDva cookies",nonCookieAccess:"Pr\xEDstup bez cookies",maxAge:"Max. vek: {days}d",retention:"Uchov\xE1vanie: {days}d",legitimateInterest:"Opr\xE1v. z\xE1ujem",privacyPolicy:"Z\xE1sady ochrany s\xFAkromia",storageDisclosure:"Zverejnenie inform\xE1ci\xED o ukladan\xED",requiredNotice:"Vy\u017Eaduje sa pre funk\u010Dnos\u0165 str\xE1nky, nemo\u017Eno zak\xE1za\u0165"},footer:{consentStorage:'Predvo\u013Eby s\xFAhlasu s\xFA ulo\u017Een\xE9 v cookie s n\xE1zvom "euconsent-v2" po dobu 13 mesiacov. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Prija\u0165 v\u0161etko",rejectAll:"Odmietnu\u0165 v\u0161etko",customize:"Prisp\xF4sobi\u0165",saveSettings:"Ulo\u017Ei\u0165 nastavenia",loading:"Na\u010D\xEDtava sa...",showingSelectedVendor:"Zobrazenie vybran\xE9ho dod\xE1vate\u013Ea",clearSelection:"Vymaza\u0165",customPartner:"Vlastn\xFD partner neregistrovan\xFD v IAB"}}},Vi={common:{acceptAll:"Sprejmi vse",rejectAll:"Zavrni vse",customize:"Prilagodi",save:"Shrani nastavitve"},cookieBanner:{title:"Cenimo va\u0161o zasebnost",description:"Ta spletna stran uporablja pi\u0161kotke za izbolj\u0161anje va\u0161e uporabni\u0161ke izku\u0161nje, analizo prometa na strani in prikaz personaliziranih vsebin."},consentManagerDialog:{title:"Nastavitve zasebnosti",description:"Tukaj prilagodite svoje nastavitve zasebnosti. Izberete lahko, katere vrste pi\u0161kotkov in tehnologij sledenja dovolite."},consentTypes:{necessary:{title:"Nujno potrebni",description:"Ti pi\u0161kotki so bistveni za pravilno delovanje spletne strani in jih ni mogo\u010De onemogo\u010Diti."},functionality:{title:"Funkcionalnost",description:"Ti pi\u0161kotki omogo\u010Dajo izbolj\u0161ano funkcionalnost in personalizacijo spletne strani."},marketing:{title:"Tr\u017Eenje",description:"Ti pi\u0161kotki se uporabljajo za prikazovanje relevantnih oglasov in spremljanje njihove u\u010Dinkovitosti."},measurement:{title:"Analitika",description:"Ti pi\u0161kotki nam pomagajo razumeti, kako obiskovalci uporabljajo spletno stran, in izbolj\u0161ati njeno delovanje."},experience:{title:"Izku\u0161nja",description:"Ti pi\u0161kotki nam pomagajo zagotoviti bolj\u0161o uporabni\u0161ko izku\u0161njo in testirati nove funkcije."}},frame:{title:"Za ogled te vsebine sprejmite soglasje za kategorijo {category}.",actionButton:"Omogo\u010Di soglasje za {category}"},legalLinks:{privacyPolicy:"Pravilnik o zasebnosti",cookiePolicy:"Pravilnik o pi\u0161kotkih",termsOfService:"Pogoji uporabe"},iab:{banner:{title:"Nastavitve zasebnosti",description:"Mi in na\u0161ih {partnerCount} partnerjev shranjujemo in/ali dostopamo do informacij na va\u0161i napravi ter obdelujemo osebne podatke, kot so edinstveni identifikatorji in podatki o brskanju, za to spletno mesto, da bi:",partnersLink:"{count} partnerjev",andMore:"In \u0161e {count}...",legitimateInterestNotice:"Nekateri partnerji uveljavljajo zakoniti interes za obdelavo va\u0161ih podatkov. Imate pravico do ugovora tej obdelavi, prilagoditve svojih izbir in preklica soglasja kadar koli.",scopeServiceSpecific:"Va\u0161e soglasje velja samo za to spletno mesto in ne bo vplivalo na druge storitve.",scopeGroup:"Va\u0161a izbira velja za vse na\u0161e spletne strani v tej skupini."},preferenceCenter:{title:"Nastavitve zasebnosti",description:"Tukaj prilagodite svoje nastavitve zasebnosti. Izberete lahko, katere vrste pi\u0161kotkov in tehnologij sledenja dovolite.",tabs:{purposes:"Nameni",vendors:"Ponudniki"},purposeItem:{partners:"{count} partnerjev",vendorsUseLegitimateInterest:"{count} ponudnikov uveljavlja zakoniti interes",examples:"Primeri",partnersUsingPurpose:"Partnerji, ki uporabljajo ta namen",withYourPermission:"Z va\u0161im dovoljenjem",legitimateInterest:"Zakoniti interes",objectButton:"Ugovarjaj",objected:"Ugovarjano",rightToObject:"Imate pravico do ugovora obdelavi, ki temelji na zakonitem interesu."},specialPurposes:{title:"Bistvene funkcije (obvezno)",tooltip:"Te so potrebne for funkcionalnost in varnost spletnega mesta. V skladu z IAB TCF ne morete ugovarjati tem posebnim namenom."},vendorList:{search:"I\u0161\u010Di ponudnike...",showingCount:"Prikazano {filtered} od {total} ponudnikov",iabVendorsHeading:"Ponudniki, registrirani v IAB",iabVendorsNotice:"Ti partnerji so registrirani v okviru IAB Transparency & Consent Framework (TCF), industrijskega standarda za upravljanje soglasij",customVendorsHeading:"Partnerji po meri",customVendorsNotice:"To so partnerji po meri, ki niso registrirani v okviru IAB Transparency & Consent Framework (TCF). Podatke obdelujejo na podlagi va\u0161ega soglasja in imajo lahko druga\u010Dne prakse zasebnosti kot ponudniki, registrirani v IAB.",purposes:"Nameni",specialPurposes:"Posebni nameni",specialFeatures:"Posebne funkcije",features:"Funkcije",dataCategories:"Kategorije podatkov",usesCookies:"Uporablja pi\u0161kotke",nonCookieAccess:"Dostop brez pi\u0161kotkov",maxAge:"Najv. starost: {days}d",retention:"Hramba: {days}d",legitimateInterest:"Zakoniti int.",privacyPolicy:"Pravilnik o zasebnosti",storageDisclosure:"Razkritje shranjevanja",requiredNotice:"Zahtevano za delovanje spletnega mesta, ni mogo\u010De onemogo\u010Diti"},footer:{consentStorage:'Preference glede soglasja so shranjene v pi\u0161kotku z imenom "euconsent-v2" 13 mesecev. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Sprejmi vse",rejectAll:"Zavrni vse",customize:"Prilagodi",saveSettings:"Shrani nastavitve",loading:"Nalaganje...",showingSelectedVendor:"Prikaz izbranega ponudnika",clearSelection:"Po\u010Disti",customPartner:"Partner po meri, ki ni registriran v IAB"}}},xi={common:{acceptAll:"Acceptera alla",rejectAll:"Avvisa alla",customize:"Anpassa",save:"Spara inst\xE4llningar"},cookieBanner:{title:"Vi v\xE4rdes\xE4tter din integritet",description:"Den h\xE4r webbplatsen anv\xE4nder cookies f\xF6r att f\xF6rb\xE4ttra din surfupplevelse, analysera webbplatstrafik och visa personligt anpassat inneh\xE5ll."},consentManagerDialog:{title:"Integritetsinst\xE4llningar",description:"Anpassa dina integritetsinst\xE4llningar h\xE4r. Du kan v\xE4lja vilka typer av cookies och sp\xE5rningstekniker du till\xE5ter."},consentTypes:{necessary:{title:"Absolut n\xF6dv\xE4ndiga",description:"Dessa cookies \xE4r n\xF6dv\xE4ndiga f\xF6r att webbplatsen ska fungera korrekt och kan inte inaktiveras."},functionality:{title:"Funktionalitet",description:"Dessa cookies m\xF6jligg\xF6r f\xF6rb\xE4ttrad funktionalitet och personalisering av webbplatsen."},marketing:{title:"Marknadsf\xF6ring",description:"Dessa cookies anv\xE4nds f\xF6r att leverera relevanta annonser och sp\xE5ra deras effektivitet."},measurement:{title:"Analys",description:"Dessa cookies hj\xE4lper oss att f\xF6rst\xE5 hur bes\xF6kare interagerar med webbplatsen och f\xF6rb\xE4ttra dess prestanda."},experience:{title:"Upplevelse",description:"Dessa cookies hj\xE4lper oss att ge en b\xE4ttre anv\xE4ndarupplevelse och testa nya funktioner."}},frame:{title:"Acceptera {category}-samtycke f\xF6r att visa detta inneh\xE5ll.",actionButton:"Aktivera {category}-samtycke"},legalLinks:{privacyPolicy:"Integritetspolicy",cookiePolicy:"Cookiepolicy",termsOfService:"Anv\xE4ndarvillkor"},iab:{banner:{title:"Integritetsinst\xE4llningar",description:"Vi och v\xE5ra {partnerCount} partner lagrar och/eller f\xE5r tillg\xE5ng till information p\xE5 din enhet och behandlar personuppgifter, s\xE5som unika identifierare och webbl\xE4sardata, f\xF6r denna webbplats, f\xF6r att:",partnersLink:"{count} partner",andMore:"Och {count} till...",legitimateInterestNotice:"Vissa partner h\xE4vdar ett ber\xE4ttigat intresse f\xF6r att behandla dina uppgifter. Du har r\xE4tt att inv\xE4nda mot denna behandling, anpassa dina val och n\xE4r som helst \xE5terkalla ditt samtycke.",scopeServiceSpecific:"Ditt samtycke g\xE4ller endast f\xF6r den h\xE4r webbplatsen och p\xE5verkar inte andra tj\xE4nster.",scopeGroup:"Ditt val g\xE4ller f\xF6r alla v\xE5ra webbplatser i denna grupp."},preferenceCenter:{title:"Integritetsinst\xE4llningar",description:"Anpassa dina integritetsinst\xE4llningar h\xE4r. Du kan v\xE4lja vilka typer av cookies och sp\xE5rningstekniker du till\xE5ter.",tabs:{purposes:"\xC4ndam\xE5l",vendors:"Leverant\xF6rer"},purposeItem:{partners:"{count} partner",vendorsUseLegitimateInterest:"{count} leverant\xF6rer h\xE4vdar ber\xE4ttigat intresse",examples:"Exempel",partnersUsingPurpose:"Partner som anv\xE4nder detta \xE4ndam\xE5l",withYourPermission:"Med ditt tillst\xE5nd",legitimateInterest:"Ber\xE4ttigat intresse",objectButton:"Inv\xE4nd",objected:"Inv\xE4nt",rightToObject:"Du har r\xE4tt att inv\xE4nda mot behandling baserad p\xE5 ber\xE4ttigat intresse."},specialPurposes:{title:"Viktiga funktioner (kr\xE4vs)",tooltip:"Dessa kr\xE4vs f\xF6r webbplatsens funktionalitet och s\xE4kerhet. Enligt IAB TCF kan du inte inv\xE4nda mot dessa speciella \xE4ndam\xE5l."},vendorList:{search:"S\xF6k leverant\xF6rer...",showingCount:"{filtered} av {total} leverant\xF6rer",iabVendorsHeading:"IAB-registrerade leverant\xF6rer",iabVendorsNotice:"Dessa partner \xE4r registrerade i IAB Transparency & Consent Framework (TCF), en branschstandard f\xF6r hantering av samtycke",customVendorsHeading:"Anpassade partner",customVendorsNotice:"Dessa \xE4r anpassade partner som inte \xE4r registrerade i IAB Transparency & Consent Framework (TCF). De behandlar data baserat p\xE5 ditt samtycke och kan ha andra integritetspraxis \xE4n IAB-registrerade leverant\xF6rer.",purposes:"\xC4ndam\xE5l",specialPurposes:"Speciella \xE4ndam\xE5l",specialFeatures:"Speciella funktioner",features:"Funktioner",dataCategories:"Datakategorier",usesCookies:"Anv\xE4nder cookies",nonCookieAccess:"Icke-cookie-\xE5tkomst",maxAge:"Max \xE5lder: {days}d",retention:"Lagring: {days}d",legitimateInterest:"Ber\xE4tt. intresse",privacyPolicy:"Integritetspolicy",storageDisclosure:"Lagringsinformation",requiredNotice:"Kr\xE4vs f\xF6r webbplatsens funktionalitet, kan inte inaktiveras"},footer:{consentStorage:'Samtyckesinst\xE4llningar lagras i en cookie med namnet "euconsent-v2" i 13 m\xE5nader. The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"Acceptera alla",rejectAll:"Avvisa alla",customize:"Anpassa",saveSettings:"Spara inst\xE4llningar",loading:"Laddar...",showingSelectedVendor:"Visar vald leverant\xF6r",clearSelection:"Rensa",customPartner:"Anpassad partner som inte \xE4r registrerad i IAB"}}},Fi={common:{acceptAll:"\u5168\u90E8\u540C\u610F",rejectAll:"\u5168\u90E8\u62D2\u7EDD",customize:"\u81EA\u5B9A\u4E49\u8BBE\u7F6E",save:"\u4FDD\u5B58\u8BBE\u7F6E"},cookieBanner:{title:"\u6211\u4EEC\u91CD\u89C6\u60A8\u7684\u9690\u79C1",description:"\u672C\u7F51\u7AD9\u4F7F\u7528cookies\u6765\u63D0\u5347\u60A8\u7684\u6D4F\u89C8\u4F53\u9A8C\u3001\u5206\u6790\u7F51\u7AD9\u6D41\u91CF\u5E76\u5C55\u793A\u4E2A\u6027\u5316\u5185\u5BB9\u3002"},consentManagerDialog:{title:"\u9690\u79C1\u8BBE\u7F6E",description:"\u5728\u6B64\u81EA\u5B9A\u4E49\u60A8\u7684\u9690\u79C1\u8BBE\u7F6E\u3002\u60A8\u53EF\u4EE5\u9009\u62E9\u5141\u8BB8\u54EA\u4E9B\u7C7B\u578B\u7684cookies\u548C\u8DDF\u8E2A\u6280\u672F\u3002"},consentTypes:{necessary:{title:"\u4E25\u683C\u5FC5\u8981\u7C7B",description:"\u8FD9\u4E9Bcookies\u662F\u7F51\u7AD9\u6B63\u5E38\u8FD0\u884C\u6240\u5FC5\u9700\u7684\uFF0C\u65E0\u6CD5\u88AB\u7981\u7528\u3002"},functionality:{title:"\u529F\u80FD\u7C7B",description:"\u8FD9\u4E9Bcookies\u53EF\u589E\u5F3A\u7F51\u7AD9\u7684\u529F\u80FD\u548C\u4E2A\u6027\u5316\u4F53\u9A8C\u3002"},marketing:{title:"\u8425\u9500\u7C7B",description:"\u8FD9\u4E9Bcookies\u7528\u4E8E\u6295\u653E\u76F8\u5173\u5E7F\u544A\u5E76\u8DDF\u8E2A\u5E7F\u544A\u6548\u679C\u3002"},measurement:{title:"\u5206\u6790\u7C7B",description:"\u8FD9\u4E9Bcookies\u5E2E\u52A9\u6211\u4EEC\u4E86\u89E3\u8BBF\u5BA2\u5982\u4F55\u4E0E\u7F51\u7AD9\u4E92\u52A8\u5E76\u6539\u8FDB\u5176\u6027\u80FD\u3002"},experience:{title:"\u4F53\u9A8C\u7C7B",description:"\u8FD9\u4E9Bcookies\u5E2E\u52A9\u6211\u4EEC\u63D0\u4F9B\u66F4\u597D\u7684\u7528\u6237\u4F53\u9A8C\u5E76\u6D4B\u8BD5\u65B0\u529F\u80FD\u3002"}},frame:{title:"\u63A5\u53D7 {category} \u4EE5\u67E5\u770B\u6B64\u5185\u5BB9\u3002",actionButton:"\u542F\u7528 {category} \u540C\u610F"},legalLinks:{privacyPolicy:"\u9690\u79C1\u653F\u7B56",cookiePolicy:"Cookie\u653F\u7B56",termsOfService:"\u670D\u52A1\u6761\u6B3E"},iab:{banner:{title:"\u9690\u79C1\u8BBE\u7F6E",description:"\u6211\u4EEC\u548C\u6211\u4EEC\u7684 {partnerCount} \u4E2A\u5408\u4F5C\u4F19\u4F34\u5728\u60A8\u7684\u8BBE\u5907\u4E0A\u5B58\u50A8\u548C/\u6216\u8BBF\u95EE\u4FE1\u606F\uFF0C\u5E76\u4E3A\u6B64\u7F51\u7AD9\u5904\u7406\u4E2A\u4EBA\u6570\u636E\uFF08\u5982\u552F\u4E00\u6807\u8BC6\u7B26\u548C\u6D4F\u89C8\u6570\u636E\uFF09\uFF0C\u4EE5\u4FBF\uFF1A",partnersLink:"{count} \u4E2A\u5408\u4F5C\u4F19\u4F34",andMore:"\u8FD8\u6709 {count} \u4E2A...",legitimateInterestNotice:"\u67D0\u4E9B\u5408\u4F5C\u4F19\u4F34\u58F0\u79F0\u5BF9\u5904\u7406\u60A8\u7684\u6570\u636E\u5177\u6709\u6B63\u5F53\u5229\u76CA\u3002\u60A8\u6709\u6743\u53CD\u5BF9\u8FD9\u79CD\u5904\u7406\u3001\u81EA\u5B9A\u4E49\u60A8\u7684\u9009\u62E9\u5E76\u968F\u65F6\u64A4\u56DE\u60A8\u7684\u540C\u610F\u3002",scopeServiceSpecific:"\u60A8\u7684\u540C\u610F\u4EC5\u9002\u7528\u4E8E\u672C\u7F51\u7AD9\uFF0C\u4E0D\u4F1A\u5F71\u54CD\u5176\u4ED6\u670D\u52A1\u3002",scopeGroup:"\u60A8\u7684\u9009\u62E9\u9002\u7528\u4E8E\u672C\u7EC4\u5185\u7684\u6240\u6709\u7F51\u7AD9\u3002"},preferenceCenter:{title:"\u9690\u79C1\u8BBE\u7F6E",description:"\u5728\u6B64\u81EA\u5B9A\u4E49\u60A8\u7684\u9690\u79C1\u8BBE\u7F6E\u3002\u60A8\u53EF\u4EE5\u9009\u62E9\u5141\u8BB8\u54EA\u4E9B\u7C7B\u578B\u7684 cookies \u548C\u8DDF\u8E2A\u6280\u672F\u3002",tabs:{purposes:"\u76EE\u7684",vendors:"\u4F9B\u5E94\u5546"},purposeItem:{partners:"{count} \u4E2A\u5408\u4F5C\u4F19\u4F34",vendorsUseLegitimateInterest:"{count} \u4E2A\u4F9B\u5E94\u5546\u58F0\u79F0\u5177\u6709\u6B63\u5F53\u5229\u76CA",examples:"\u793A\u4F8B",partnersUsingPurpose:"\u4F7F\u7528\u6B64\u76EE\u7684\u7684\u5408\u4F5C\u4F19\u4F34",withYourPermission:"\u5F81\u5F97\u60A8\u7684\u8BB8\u53EF",legitimateInterest:"\u6B63\u5F53\u5229\u76CA",objectButton:"\u53CD\u5BF9",objected:"\u5DF2\u53CD\u5BF9",rightToObject:"\u60A8\u6709\u6743\u53CD\u5BF9\u57FA\u4E8E\u6B63\u5F53\u5229\u76CA\u7684\u5904\u7406\u3002"},specialPurposes:{title:"\u57FA\u672C\u529F\u80FD\uFF08\u5FC5\u9700\uFF09",tooltip:"\u8FD9\u4E9B\u662F\u7F51\u7AD9\u529F\u80FD\u548C\u5B89\u5168\u6240\u5FC5\u9700\u7684\u3002\u6839\u636E IAB TCF\uFF0C\u60A8\u4E0D\u80FD\u53CD\u5BF9\u8FD9\u4E9B\u7279\u6B8A\u76EE\u7684\u3002"},vendorList:{search:"\u641C\u7D22\u4F9B\u5E94\u5546...",showingCount:"\u663E\u793A {total} \u4E2A\u4F9B\u5E94\u5546\u4E2D\u7684 {filtered} \u4E2A",iabVendorsHeading:"IAB \u6CE8\u518C\u4F9B\u5E94\u5546",iabVendorsNotice:"\u8FD9\u4E9B\u5408\u4F5C\u4F19\u4F34\u5DF2\u5728 IAB \u900F\u660E\u5EA6\u4E0E\u540C\u610F\u6846\u67B6 (TCF) \u6CE8\u518C\uFF0C\u8FD9\u662F\u7BA1\u7406\u540C\u610F\u7684\u884C\u4E1A\u6807\u51C6",customVendorsHeading:"\u81EA\u5B9A\u4E49\u5408\u4F5C\u4F19\u4F34",customVendorsNotice:"\u8FD9\u4E9B\u662F\u672A\u5728 IAB \u900F\u660E\u5EA6\u4E0E\u540C\u610F\u6846\u67B6 (TCF) \u6CE8\u518C\u7684\u81EA\u5B9A\u4E49\u5408\u4F5C\u4F19\u4F34\u3002\u4ED6\u4EEC\u6839\u636E\u60A8\u7684\u540C\u610F\u5904\u7406\u6570\u636E\uFF0C\u5E76\u4E14\u53EF\u80FD\u5177\u6709\u4E0E IAB \u6CE8\u518C\u4F9B\u5E94\u5546\u4E0D\u540C\u7684\u9690\u79C1\u60EF\u4F8B\u3002",purposes:"\u76EE\u7684",specialPurposes:"\u7279\u6B8A\u76EE\u7684",specialFeatures:"\u7279\u6B8A\u529F\u80FD",features:"\u529F\u80FD",dataCategories:"\u6570\u636E\u7C7B\u522B",usesCookies:"\u4F7F\u7528 Cookies",nonCookieAccess:"\u975E Cookie \u8BBF\u95EE",maxAge:"\u6700\u957F\u671F\u9650\uFF1A{days}\u5929",retention:"\u4FDD\u7559\u671F\u9650\uFF1A{days}\u5929",legitimateInterest:"\u6B63\u5F53\u5229\u76CA",privacyPolicy:"\u9690\u79C1\u653F\u7B56",storageDisclosure:"\u5B58\u50A8\u62AB\u9732",requiredNotice:"\u7F51\u7AD9\u529F\u80FD\u5FC5\u9700\uFF0C\u65E0\u6CD5\u7981\u7528"},footer:{consentStorage:'\u540C\u610F\u504F\u597D\u5B58\u50A8\u5728\u540D\u4E3A "euconsent-v2" \u7684 cookie \u4E2D\uFF0C\u6709\u6548\u671F\u4E3A 13 \u4E2A\u6708\u3002 The storage duration may be refreshed when you update your preferences.'}},common:{acceptAll:"\u5168\u90E8\u540C\u610F",rejectAll:"\u5168\u90E8\u62D2\u7EDD",customize:"\u81EA\u5B9A\u4E49\u8BBE\u7F6E",saveSettings:"\u4FDD\u5B58\u8BBE\u7F6E",loading:"\u52A0\u8F7D\u4E2D...",showingSelectedVendor:"\u663E\u793A\u9009\u5B9A\u7684\u4F9B\u5E94\u5546",clearSelection:"\u6E05\u9664",customPartner:"\u672A\u5728 IAB \u6CE8\u518C\u7684\u81EA\u5B9A\u4E49\u5408\u4F5C\u4F19\u4F34"}}},Bi={bg:ii,cs:si,da:oi,de:ai,el:ci,en:it,es:li,et:ui,fi:di,fr:pi,ga:gi,he:mi,hr:fi,hu:hi,id:ki,it:yi,lt:wi,lv:Ci,mt:Ii,nl:Si,pl:zi,pt:Pi,ro:Li,sk:Ei,sl:Vi,sv:xi,zh:Fi,is:vi,nb:ji,nn:Ai,lb:bi,rm:Ti,cy:ri};function _t(n){return!(!n||typeof n!="object"||Array.isArray(n))}function Ot(n,e){if(!n&&!e)return{};let t={};if(n)for(let i of Object.keys(n))t[i]=n[i];if(!e)return t;for(let i of Object.keys(e)){let s=e[i];if(s===void 0)continue;let r=n?n[i]:void 0;_t(r)&&_t(s)?t[i]=Ot(r,s):t[i]=s}return t}function Rt(n,e){let t=["cookieBanner","consentManagerDialog","common","consentTypes","frame","legalLinks","iab"],i={};for(let s of t){let r=n[s],o=e[s];(r||o)&&(i[s]=Ot(r,o))}return i}function Mt(n){return n?n.split(",").map(e=>e.split(";")[0]?.trim().toLowerCase()).filter(e=>!!e).map(e=>e.split("-")[0]??e):[]}function Di(n,e){let t=e?.fallback??"en";if(!n.length)return t;let i=Mt(e?.header);for(let s of i)if(n.includes(s))return s;return t}function Ut(n,e){let t={en:it},i=[n.translations,e?.translations];for(let s of i)if(s)for(let[r,o]of Object.entries(s)){if(!o)continue;let a=t[r]||t.en;t[r]=Rt(a,o)}return{...n,...e,translations:t}}function Gt(n,e,t=!1){if(t||typeof window>"u")return e||"en";let i=window.navigator.language?.split("-")[0]||"";return i&&i in n?i:e||"en"}function Ni(n,e){let t=Ut(n,e),i=Gt(t.translations,t.defaultLanguage,t.disableAutoLanguageSwitch);return{...t,defaultLanguage:i}}var Ht=n=>{let e,t=new Set,i=(u,p)=>{let d=typeof u=="function"?u(e):u;if(!Object.is(d,e)){let l=e;e=p??(typeof d!="object"||d===null)?d:Object.assign({},e,d),t.forEach(y=>y(e,l))}},s=()=>e,a={setState:i,getState:s,getInitialState:()=>c,subscribe:u=>(t.add(u),()=>t.delete(u))},c=e=n(i,s,a);return a},qt=(n=>n?Ht(n):Ht);var _i={"./src/libs/cookie/index.ts"(n,e,t){t.d(e,{_y:()=>D,If:()=>O,TV:()=>y,Yj:()=>b,Xk:()=>s,jD:()=>X,Ri:()=>m});function i(g){return{expiryDays:g?.defaultExpiryDays??365,crossSubdomain:g?.crossSubdomain??!1,domain:g?.defaultDomain??"",path:"/",secure:typeof window<"u"&&window.location.protocol==="https:",sameSite:"Lax"}}function s(){if(typeof window>"u")return"";let g=window.location.hostname;if(g==="localhost"||/^\d+\.\d+\.\d+\.\d+$/.test(g))return g;let f=g.split(".");return f.length>=2?`.${f.slice(-2).join(".")}`:g}let r={consents:"c",consentInfo:"i",timestamp:"ts",iabCustomVendorConsents:"icv",iabCustomVendorLegitimateInterests:"icvli",time:"t",type:"y",id:"id",subjectId:"sid",externalId:"eid",identityProvider:"idp"},o=Object.entries(r).reduce((g,[f,k])=>(g[k]=f,g),{});function a(g){let f={};for(let[k,j]of Object.entries(g)){let z=k.split(".").map(T=>r[T]||T);f[z.join(".")]=j}return f}function c(g){let f={};for(let[k,j]of Object.entries(g)){let z=k.split(".").map(T=>o[T]||T);f[z.join(".")]=j}return f}function u(g,f=""){let k={};for(let[j,I]of Object.entries(g)){let z=f?`${f}.${j}`:j;I==null?k[z]="":typeof I=="boolean"?I&&(k[z]="1"):typeof I!="object"||Array.isArray(I)?k[z]=String(I):Object.assign(k,u(I,z))}return k}function p(g){let f={};for(let[k,j]of Object.entries(g)){let I=k.split(".");if(I.length===0)continue;let z=f;for(let E=0;E`${f}:${k}`).join(",")}function l(g){if(!g)return{};let f={},k=g.split(",");for(let j of k){let I=j.indexOf(":");if(I===-1)continue;let z=j.substring(0,I),T=j.substring(I+1);f[z]=T}return f}function y(g,f,k,j){if(typeof document>"u")return;let I={...i(j),...k};I.crossSubdomain&&!k?.domain&&(I.domain=s());try{let z;if(typeof f=="string")z=f;else{let _=u(f),R=a(_);z=d(R)}let T=new Date;T.setTime(T.getTime()+24*I.expiryDays*36e5);let E=`expires=${T.toUTCString()}`,V=[`${g}=${z}`,E,`path=${I.path}`];I.domain&&V.push(`domain=${I.domain}`),I.secure&&V.push("secure"),I.sameSite&&V.push(`SameSite=${I.sameSite}`),document.cookie=V.join("; ")}catch(z){console.warn(`Failed to set cookie "${g}":`,z)}}function m(g){if(typeof document>"u")return null;try{let f=`${g}=`,k=document.cookie.split(";");for(let j of k){let I=j;for(;I.charAt(0)===" ";)I=I.substring(1);if(I.indexOf(f)===0){let z=I.substring(f.length);if(z.includes(":")){let T=l(z),E=c(T);return p(E)}return z}}return null}catch(f){return console.warn(`Failed to get cookie "${g}":`,f),null}}function b(g,f,k){if(typeof document>"u")return;let j={...i(k),...f};j.crossSubdomain&&!f?.domain&&(j.domain=s());try{let I=[`${g}=`,"expires=Thu, 01 Jan 1970 00:00:00 GMT",`path=${j.path}`];j.domain&&I.push(`domain=${j.domain}`),document.cookie=I.join("; ")}catch(I){console.warn(`Failed to delete cookie "${g}":`,I)}}var h=t("./src/store/initial-state.ts"),w=t("./src/types/consent-types.ts"),C=t("./src/libs/debug.ts");function A(g){if(typeof g!="object"||g===null)return!1;let k=g.consentInfo;if(!k||typeof k!="object")return!1;let j=typeof k.id=="string",I=typeof k.subjectId=="string";return j&&!I}function x(g){let f=g?.storageKey||h.ln,k=h.AQ;if(f!==k)try{if(typeof window<"u"&&window.localStorage){if(window.localStorage.getItem(f))return void window.localStorage.removeItem(k);let I=window.localStorage.getItem(k);I&&(window.localStorage.setItem(f,I),window.localStorage.removeItem(k),(0,C.YA)().log(`Migrated consent data from "${k}" to "${f}"`))}}catch(j){console.warn("[c15t] Failed to migrate legacy storage:",j)}}function D(g,f,k){let j=!1,I=!1,z=k?.storageKey||h.ln,T=O(k),V={...{...T,...g,iabCustomVendorConsents:g.iabCustomVendorConsents??T?.iabCustomVendorConsents,iabCustomVendorLegitimateInterests:g.iabCustomVendorLegitimateInterests??T?.iabCustomVendorLegitimateInterests}};(!V.iabCustomVendorConsents||Object.keys(V.iabCustomVendorConsents).length===0)&&delete V.iabCustomVendorConsents,(!V.iabCustomVendorLegitimateInterests||Object.keys(V.iabCustomVendorLegitimateInterests).length===0)&&delete V.iabCustomVendorLegitimateInterests;try{typeof window<"u"&&window.localStorage&&(window.localStorage.setItem(z,JSON.stringify(V)),j=!0)}catch(_){console.warn("Failed to save consent to localStorage:",_)}try{y(z,V,f,k),I=!0}catch(_){console.warn("Failed to save consent to cookie:",_)}if(!j&&!I)throw new Error("Failed to save consent to any storage method")}function F(g){let f=g.consents||{},k={...f};for(let j of w.W)k[j]=f[j]??!1;return{...g,consents:k}}function O(g){x(g);let f=g?.storageKey||h.ln,k=null,j=null;try{if(typeof window<"u"&&window.localStorage){let T=window.localStorage.getItem(f);T&&(k=JSON.parse(T))}}catch(T){console.warn("Failed to read consent from localStorage:",T)}try{j=m(f)}catch(T){console.warn("Failed to read consent from cookie:",T)}let I=null,z=null;if(j?(I=j,z="cookie"):k&&(I=k,z="localStorage"),I&&z){let T=g?.crossSubdomain===!0||!!g?.defaultDomain;if(z!=="localStorage"||j){if(z==="cookie")try{if(typeof window<"u"&&window.localStorage){let E=I;typeof E=="object"&&E!==null&&"consents"in E&&(E=F(E));let V=null;try{let ue=window.localStorage.getItem(f);if(ue){let re=JSON.parse(ue);V=typeof re=="object"&&re!==null&&"consents"in re?F(re):re}}catch{V=null}let _=JSON.stringify(E),R=JSON.stringify(V);_!==R&&(window.localStorage.setItem(f,_),V?T?(0,C.YA)().log("Updated localStorage with consent from cookie (cross-subdomain mode)"):(0,C.YA)().log("Updated localStorage with consent from cookie"):(0,C.YA)().log("Synced consent from cookie to localStorage"))}}catch(E){console.warn("[c15t] Failed to sync consent to localStorage:",E)}}else try{y(f,I,void 0,g),(0,C.YA)().log("Synced consent from localStorage to cookie")}catch(E){console.warn("[c15t] Failed to sync consent to cookie:",E)}}return I&&A(I)?((0,C.YA)().log("Detected legacy consent format (v1.x). Re-consent required for v2.0."),X(void 0,g),null):I&&typeof I=="object"?F(I):I}function X(g,f){let k=f?.storageKey||h.ln;try{typeof window<"u"&&window.localStorage&&(window.localStorage.removeItem(k),k!==h.AQ&&window.localStorage.removeItem(h.AQ))}catch(j){console.warn("Failed to remove consent from localStorage:",j)}try{b(k,g,f),k!==h.AQ&&b(h.AQ,g,f)}catch(j){console.warn("Failed to remove consent cookie:",j)}}},"./src/libs/debug.ts"(n,e,t){t.d(e,{YA:()=>o,tJ:()=>a});let i=()=>{};function s(c){return c?{log:(...u)=>console.log("[c15t]",...u),debug:(...u)=>console.debug("[c15t]",...u)}:{log:i,debug:i}}let r=s(!1);function o(){return r}function a(c){r=s(c)}},"./src/libs/generate-subject-id.ts"(n,e,t){t.d(e,{L:()=>o,U:()=>a});let i="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";function s(c){let u=BigInt(58),p=BigInt(0);for(let l of c)p=p*BigInt(256)+BigInt(l);let d=[];for(;p>0;){let l=p%u;d.unshift(i.charAt(Number(l))),p/=u}for(let l of c)if(l===0)d.unshift(i.charAt(0));else break;return d.join("")||i.charAt(0)}let r=17e11;function o(){let c=crypto.getRandomValues(new Uint8Array(20)),u=Date.now()-r,p=Math.floor(u/4294967296),d=u>>>0;return c[0]=p>>>24&255,c[1]=p>>>16&255,c[2]=p>>>8&255,c[3]=255&p,c[4]=d>>>24&255,c[5]=d>>>16&255,c[6]=d>>>8&255,c[7]=255&d,`sub_${s(c)}`}function a(c){if(!c.startsWith("sub_"))return!1;let u=c.slice(4);if(u.length===0)return!1;for(let p of u)if(!i.includes(p))return!1;return!0}},"./src/libs/iab-tcf/cmp-defaults.ts"(n,e,t){t.d(e,{D:()=>s,I:()=>r});var i=t("./src/version.ts");let s=0,r=i.r},"./src/libs/iab-tcf/fetch-gvl.ts"(n,e,t){t.d(e,{Ww:()=>a,ix:()=>o,wL:()=>p,xe:()=>c});var i=t("./src/libs/iab-tcf/types.ts");let s=new Map,r;async function o(d,l={}){let y=typeof window<"u"?window.__c15t_mock_gvl:void 0;if(y!==void 0)return r=y,y;if(u!==void 0)return r=u,u;let{endpoint:m=i.w,headers:b}=l,h=d?[...d].sort((F,O)=>F-O):[],w=b?JSON.stringify(b):"",C=`${m}|${h.join(",")}|${w}`,A=s.get(C);if(A)return A;let x=new URL(m);h.length>0&&x.searchParams.set("vendorIds",h.join(","));let D=(async()=>{try{let F=await fetch(x.toString(),{headers:b});if(F.status===204)return r=null,null;if(!F.ok)throw new Error(`Failed to fetch GVL: ${F.status} ${F.statusText}`);let O=await F.json();if(!O.vendorListVersion||!O.purposes||!O.vendors)throw new Error("Invalid GVL response: missing required fields");return r=O,O}finally{s.delete(C)}})();return s.set(C,D),D}function a(){return r}function c(){s.clear(),r=void 0,u=void 0}let u;function p(d){u=d,d!==void 0&&(r=d)}},"./src/libs/iab-tcf/index.ts"(n,e,t){t.d(e,{generateTCString:()=>h,initializeIABStub:()=>p,wL:()=>F.wL,Ww:()=>F.Ww,decodeTCString:()=>w,xe:()=>F.xe,fetchGVL:()=>F.ix,iabPurposesToC15tConsents:()=>X,createCMPApi:()=>D});var i=t("./src/libs/iab-tcf/cmp-defaults.ts"),s=t("./src/version.ts");let r=!1;function o(){return{gdprApplies:void 0,cmpLoaded:!1,cmpStatus:"stub",displayStatus:"hidden",apiVersion:"2.3",cmpVersion:s.r,cmpId:0,gvlVersion:0,tcfPolicyVersion:5}}function a(){let g=[],f=(k,j,I,z)=>{k==="ping"?I(o(),!0):g.push([k,j,I,z])};return f.queue=g,f}function c(){if(typeof document>"u"||document.querySelector('iframe[name="__tcfapiLocator"]'))return null;let g=document.createElement("iframe");return g.name="__tcfapiLocator",g.style.display="none",g.setAttribute("aria-hidden","true"),g.tabIndex=-1,(document.body??document.documentElement).appendChild(g),g}function u(g){if(typeof window>"u"||!window.__tcfapi)return;let{data:f}=g;if(!f||typeof f!="object"||!("__tcfapiCall"in f))return;let k=f.__tcfapiCall;!k||!k.command||!k.callId||window.__tcfapi(k.command,k.version,(j,I)=>{let z={__tcfapiReturn:{returnValue:j,success:I,callId:k.callId}};g.source&&typeof g.source.postMessage=="function"&&g.source.postMessage(z,"*")},k.parameter)}function p(){typeof window>"u"||r||(window.__tcfapi||(window.__tcfapi=a()),c(),window.addEventListener("message",u),r=!0)}function d(){return typeof window>"u"||!window.__tcfapi?[]:window.__tcfapi.queue??[]}function l(){typeof window<"u"&&window.__tcfapi?.queue&&(window.__tcfapi.queue=[])}let y=null,m=null;async function b(){return y||m||(m=Promise.resolve().then(()=>(An(),Sn)).then(g=>(y=g,m=null,g)).catch(g=>{throw m=null,new Error(`Failed to load @iabtechlabtcf/core: ${g instanceof Error?g.message:"Unknown error"}. Make sure it is installed as a dependency.`)}),m)}async function h(g,f,k){let{TCModel:j,TCString:I,GVL:z}=await b(),T=new z(f),E=new j(T);E.cmpId=k.cmpId,E.cmpVersion=typeof k.cmpVersion=="number"?k.cmpVersion:Number.parseInt(String(k.cmpVersion??"1"),10)||1,E.consentScreen=k.consentScreen??1,E.consentLanguage=k.consentLanguage??"EN",E.publisherCountryCode=k.publisherCountryCode??"US",E.isServiceSpecific=k.isServiceSpecific??!0;for(let[V,_]of Object.entries(g.purposeConsents))_&&E.purposeConsents.set(Number(V));for(let[V,_]of Object.entries(g.purposeLegitimateInterests))_&&E.purposeLegitimateInterests.set(Number(V));for(let[V,_]of Object.entries(g.vendorConsents)){let R=Number(V);_&&Number.isFinite(R)&&E.vendorConsents.set(R)}for(let[V,_]of Object.entries(g.vendorLegitimateInterests)){let R=Number(V);_&&Number.isFinite(R)&&E.vendorLegitimateInterests.set(R)}for(let[V,_]of Object.entries(g.specialFeatureOptIns))_&&E.specialFeatureOptins.set(Number(V));for(let[V,_]of Object.entries(g.vendorsDisclosed))_&&E.vendorsDisclosed.set(Number(V));return I.encode(E)}async function w(g){let{TCString:f}=await b(),k=f.decode(g),j=(I,z)=>{let T={};for(let E=1;E<=z;E++)I.has(E)&&(T[E]=!0);return T};return{cmpId:k.cmpId,cmpVersion:k.cmpVersion,consentLanguage:k.consentLanguage,isServiceSpecific:k.isServiceSpecific,purposeConsents:j(k.purposeConsents,11),purposeLegitimateInterests:j(k.purposeLegitimateInterests,11),vendorConsents:j(k.vendorConsents,1e3),vendorLegitimateInterests:j(k.vendorLegitimateInterests,1e3),specialFeatureOptIns:j(k.specialFeatureOptins,2),vendorsDisclosed:j(k.vendorsDisclosed,1e3),created:k.created,lastUpdated:k.lastUpdated,vendorListVersion:k.vendorListVersion,policyVersion:k.policyVersion}}var C=t("./src/libs/iab-tcf/types.ts");function A(g,f,k){if(typeof document>"u")return;let j=24*k*3600;document.cookie=`${g}=${encodeURIComponent(f)}; max-age=${j}; path=/; SameSite=Lax`}function x(g){if(typeof document>"u")return null;let f=document.cookie.match(new RegExp(`(^| )${g}=([^;]+)`));return f?.[2]?decodeURIComponent(f[2]):null}function D(g){let{cmpId:f=i.D,cmpVersion:k=i.I,gvl:j,gdprApplies:I=!0}=g,z="",T="loading",E="hidden",V=new Map,_=0,R=null;async function ue(B,M){if(R&&R.tcString===z&&!B)return R;let se={},ie={},Ie={},Ft={},Bt={};if(z)try{let Le=await w(z);se=Le.purposeConsents,ie=Le.purposeLegitimateInterests,Ie=Le.vendorConsents,Ft=Le.vendorLegitimateInterests,Bt=Le.specialFeatureOptIns}catch{}let ti=typeof k=="number"?k:Number.parseInt(String(k),10)||1,Dt={tcString:z,tcfPolicyVersion:j.tcfPolicyVersion,cmpId:f,cmpVersion:ti,gdprApplies:I,listenerId:M,eventStatus:B,cmpStatus:T,isServiceSpecific:!0,useNonStandardTexts:!1,publisherCC:"US",purposeOneTreatment:!1,purpose:{consents:se,legitimateInterests:ie},vendor:{consents:Ie,legitimateInterests:Ft},specialFeatureOptins:Bt,publisher:{consents:{},legitimateInterests:{},customPurpose:{consents:{},legitimateInterests:{}},restrictions:{}}};return B||(R=Dt),Dt}function re(B){let M={gdprApplies:I,cmpLoaded:T==="loaded",cmpStatus:T,displayStatus:E,apiVersion:"2.3",cmpVersion:typeof k=="string"?k:String(k),cmpId:f,gvlVersion:j.vendorListVersion,tcfPolicyVersion:j.tcfPolicyVersion};B(M,!0)}async function ee(B,M){let se=await ue();B(se,!0)}async function Ce(B){return ee(B)}function Zn(B,M){B(j,!0)}async function Qn(B){let M=_++;V.set(M,B);let se=await ue("tcloaded",M);B(se,!0)}function Xn(B,M){let se=V.has(M);V.delete(M),B(se,!0)}async function Ue(B){for(let[M,se]of V){let ie=await ue(B,M);se(ie,!0)}}function ei(){if(typeof window>"u")return;let B=d();window.__tcfapi=(M,se,ie,Ie)=>{switch(M){case"ping":re(ie);break;case"getTCData":ee(ie,Ie);break;case"getInAppTCData":Ce(ie);break;case"getVendorList":Zn(ie,Ie);break;case"addEventListener":Qn(ie);break;case"removeEventListener":Xn(ie,Ie);break;default:ie(null,!1)}},l();for(let M of B)window.__tcfapi?.(...M);T="loaded"}return ei(),{updateConsent:B=>{z=B,R=null,T="loaded",Ue("useractioncomplete")},setDisplayStatus:B=>{E=B,B==="visible"&&Ue("cmpuishown")},loadFromStorage:()=>{let B=x(C.Y.TC_STRING_COOKIE);if(B)return z=B,R=null,Ue("tcloaded"),B;if(typeof localStorage<"u")try{let M=localStorage.getItem(C.Y.TC_STRING_LOCAL);if(M)return z=M,R=null,Ue("tcloaded"),M}catch{}return null},saveToStorage:B=>{if(A(C.Y.TC_STRING_COOKIE,B,395),typeof localStorage<"u")try{localStorage.setItem(C.Y.TC_STRING_LOCAL,B)}catch{}},getTcString:()=>z,destroy:()=>{V.clear(),R=null,typeof window<"u"&&delete window.__tcfapi}}}var F=t("./src/libs/iab-tcf/fetch-gvl.ts");let O={necessary:[1],marketing:[2,3,4],experience:[5,6],measurement:[7,8,9],functionality:[10,11]};function X(g){let f={necessary:!1,marketing:!1,experience:!1,measurement:!1,functionality:!1};for(let[k,j]of Object.entries(O)){let I=j.every(z=>g[z]===!0);f[k]=I}return f}t("./src/libs/iab-tcf/store.ts")},"./src/libs/iab-tcf/store.ts"(n,e,t){t.d(e,{yx:()=>c});var i=t("./src/libs/cookie/index.ts"),s=t("./src/libs/generate-subject-id.ts"),r=t("./src/libs/iab-tcf/cmp-defaults.ts");function o(l){return{config:l,gvl:null,isLoadingGVL:!1,nonIABVendors:[],tcString:null,vendorConsents:{},vendorLegitimateInterests:{},purposeConsents:{},purposeLegitimateInterests:{},specialFeatureOptIns:{},vendorsDisclosed:{},cmpApi:null,preferenceCenterTab:"purposes"}}function a(l,y,m){let b=h=>{let{iab:w}=l();w&&y({iab:{...w,...h}})};return{_updateState:b,setPurposeConsent:(h,w)=>{let{iab:C}=l();C&&b({purposeConsents:{...C.purposeConsents,[h]:w}})},setPurposeLegitimateInterest:(h,w)=>{let{iab:C}=l();C&&b({purposeLegitimateInterests:{...C.purposeLegitimateInterests,[h]:w}})},setVendorConsent:(h,w)=>{let{iab:C}=l();C&&b({vendorConsents:{...C.vendorConsents,[String(h)]:w}})},setVendorLegitimateInterest:(h,w)=>{let{iab:C}=l();C&&b({vendorLegitimateInterests:{...C.vendorLegitimateInterests,[String(h)]:w}})},setSpecialFeatureOptIn:(h,w)=>{let{iab:C}=l();C&&b({specialFeatureOptIns:{...C.specialFeatureOptIns,[h]:w}})},setPreferenceCenterTab:h=>{b({preferenceCenterTab:h})},acceptAll:()=>{let{iab:h}=l();if(!h?.gvl)return;let{purposeConsents:w,purposeLegitimateInterests:C}=u(h.gvl,!0),{vendorConsents:A,vendorLegitimateInterests:x}=p(h.gvl,h.nonIABVendors,!0),D=d(h.gvl,!0);b({purposeConsents:w,purposeLegitimateInterests:C,vendorConsents:A,vendorLegitimateInterests:x,specialFeatureOptIns:D})},rejectAll:()=>{let{iab:h}=l();if(!h?.gvl)return;let w={1:!0},C={};for(let F of Object.keys(h.gvl.purposes))Number(F)!==1&&(w[Number(F)]=!1,C[Number(F)]=!1);let{vendorConsents:A,vendorLegitimateInterests:x}=p(h.gvl,h.nonIABVendors,!1),D=d(h.gvl,!1);b({purposeConsents:w,purposeLegitimateInterests:C,vendorConsents:A,vendorLegitimateInterests:x,specialFeatureOptIns:D})},save:async()=>{let{iab:h,locationInfo:w,user:C,callbacks:A}=l();if(!h?.cmpApi||!h.gvl)return;let{config:x,gvl:D,cmpApi:F,purposeConsents:O,purposeLegitimateInterests:X,vendorConsents:g,vendorLegitimateInterests:f,specialFeatureOptIns:k}=h,{generateTCString:j,iabPurposesToC15tConsents:I}=await Promise.resolve().then(t.bind(t,"./src/libs/iab-tcf/index.ts")),z={};for(let ee of Object.keys(D.vendors))z[Number(ee)]=!0;let T=await j({purposeConsents:O,purposeLegitimateInterests:X,vendorConsents:g,vendorLegitimateInterests:f,specialFeatureOptIns:k,vendorsDisclosed:z},D,{cmpId:x.cmpId??r.D,cmpVersion:x.cmpVersion??r.I,publisherCountryCode:x.publisherCountryCode??"GB",isServiceSpecific:x.isServiceSpecific??!0});F.saveToStorage(T),F.updateConsent(T);let E=I(O),V=Date.now();b({tcString:T,vendorsDisclosed:z});let _=l().consentInfo?.subjectId;_||(_=(0,s.L)()),y({consents:E,selectedConsents:E,activeUI:"none",consentInfo:{time:V,subjectId:_,externalId:C?.id,identityProvider:C?.identityProvider}});let R={},ue={};for(let ee of h.nonIABVendors){let Ce=String(ee.id);ee.purposes&&ee.purposes.length>0&&(R[Ce]=g[Ce]??!1),ee.legIntPurposes&&ee.legIntPurposes.length>0&&(ue[Ce]=f[Ce]??!0)}(0,i._y)({consents:E,consentInfo:{time:V,subjectId:_,externalId:C?.id,identityProvider:C?.identityProvider},iabCustomVendorConsents:R,iabCustomVendorLegitimateInterests:ue},void 0,l().storageConfig),l().updateScripts();let re=await m.setConsent({body:{subjectId:_,givenAt:V,type:"cookie_banner",domain:typeof window<"u"?window.location.hostname:"",preferences:E,externalSubjectId:C?.id,identityProvider:C?.identityProvider,tcString:T,jurisdiction:w?.jurisdiction??void 0,jurisdictionModel:"iab",metadata:{source:"iab_tcf",acceptanceMethod:"iab"}}});if(!re.ok){let ee=re.error?.message??"Failed to save IAB consents";A.onError?.({error:ee}),A.onError||console.error(ee)}}}}function c(l,y,m,b){let h=o(l),w=a(y,m,b);return{...h,...w}}function u(l,y){let m={},b={};for(let h of Object.keys(l.purposes))m[Number(h)]=y,b[Number(h)]=y;return{purposeConsents:m,purposeLegitimateInterests:b}}function p(l,y,m){let b={},h={};for(let[w,C]of Object.entries(l.vendors)){let A=String(w);C.purposes&&C.purposes.length>0&&(b[A]=m),C.legIntPurposes&&C.legIntPurposes.length>0&&(h[A]=m)}return y.forEach(w=>{let C=String(w.id);w.purposes&&w.purposes.length>0&&(b[C]=m),w.legIntPurposes&&w.legIntPurposes.length>0&&(h[C]=m)}),{vendorConsents:b,vendorLegitimateInterests:h}}function d(l,y){let m={};for(let b of Object.keys(l.specialFeatures))m[Number(b)]=y;return m}},"./src/libs/iab-tcf/types.ts"(n,e,t){t.d(e,{Y:()=>i,w:()=>s});let i={TC_STRING_COOKIE:"euconsent-v2",TC_STRING_LOCAL:"euconsent-v2"},s="https://gvl.consent.io"},"./src/store/initial-state.ts"(n,e,t){t.d(e,{AQ:()=>a,ln:()=>o,ue:()=>c});var i=t("./src/translations/index.ts"),s=t("./src/types/consent-types.ts"),r=t("./src/version.ts");let o="c15t",a="privacy-consent-storage",c={debug:!1,config:{pkg:"c15t",version:r.r,mode:"Unknown"},consents:s.y.reduce((u,p)=>(u[p.name]=p.defaultValue,u),{}),selectedConsents:s.y.reduce((u,p)=>(u[p.name]=p.defaultValue,u),{}),consentInfo:null,branding:"c15t",activeUI:"none",isLoadingConsentInfo:!1,hasFetchedBanner:!1,lastBannerFetchData:null,consentCategories:["necessary"],callbacks:{},locationInfo:null,overrides:void 0,legalLinks:{},translationConfig:i.Z,user:void 0,networkBlocker:void 0,storageConfig:void 0,includeNonDisplayedConsents:!1,consentTypes:s.y,iframeBlockerConfig:{disableAutomaticBlocking:!1},scripts:[],loadedScripts:{},scriptIdMap:{},model:"opt-in",iab:null,reloadOnConsentRevoked:!0,ssrDataUsed:!1,ssrSkippedReason:null}},"./src/translations/index.ts"(n,e,t){t.d(e,{Z:()=>s});var i=t("@c15t/translations");let s={translations:{en:i.enTranslations},defaultLanguage:"en",disableAutoLanguageSwitch:!1}},"./src/types/consent-types.ts"(n,e,t){t.d(e,{W:()=>s,y:()=>i});let i=[{defaultValue:!0,description:"These trackers are used for activities that are strictly necessary to operate or deliver the service you requested from us and, therefore, do not require you to consent.",disabled:!0,display:!0,gdprType:1,name:"necessary"},{defaultValue:!1,description:"These trackers enable basic interactions and functionalities that allow you to access selected features of our service and facilitate your communication with us.",display:!1,gdprType:2,name:"functionality"},{defaultValue:!1,description:"These trackers help us to measure traffic and analyze your behavior to improve our service.",display:!1,gdprType:4,name:"measurement"},{defaultValue:!1,description:"These trackers help us to improve the quality of your user experience and enable interactions with external content, networks, and platforms.",display:!1,gdprType:3,name:"experience"},{defaultValue:!1,description:"These trackers help us to deliver personalized ads or marketing content to you, and to measure their performance.",display:!1,gdprType:5,name:"marketing"}],s=i.map(r=>r.name)},"./src/version.ts"(n,e,t){t.d(e,{r:()=>i});let i="2.0.0-rc.3"},"@c15t/translations"(n){n.exports=st}},zn={};function U(n){var e=zn[n];if(e!==void 0)return e.exports;var t=zn[n]={exports:{}};return _i[n](t,t.exports,U),t.exports}U.d=(n,e)=>{for(var t in e)U.o(e,t)&&!U.o(n,t)&&Object.defineProperty(n,t,{enumerable:!0,get:e[t]})};U.o=(n,e)=>Object.prototype.hasOwnProperty.call(n,e);var le=U("@c15t/translations"),_n=/^\/+/;function Lt(n,e=null,t=null,i=null){return{data:e,error:t,ok:n,response:i}}function Oi(n,e=500,t="ERROR",i){return Lt(!1,null,{message:n,status:e,code:t,cause:i},null)}var de={maxRetries:3,initialDelayMs:100,backoffFactor:2,retryableStatusCodes:[500,502,503,504],nonRetryableStatusCodes:[400,401,403,404],retryOnNetworkError:!0,shouldRetry:void 0},Ri=/^(?:[a-z+]+:)?\/\//i,Pn=_n,K=U("./src/libs/debug.ts"),Je=n=>new Promise(e=>setTimeout(e,n));function Mi(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,n=>{let e=16*Math.random()|0;return(n==="x"?e:3&e|8).toString(16)})}function Tn(n){let e=n.length;for(;e>0&&n[e-1]==="/";)e--;return n.slice(0,e)}function Ui(n,e){if(Ri.test(n)){let s=new URL(n),r=Tn(s.pathname),o=e.replace(Pn,""),a=`${r}/${o}`;return s.pathname=a,s.toString()}let t=Tn(n),i=e.replace(Pn,"");return`${t}/${i}`}var me=Lt;async function _e(n,e,t){let i={...n.retryConfig,...t?.retryConfig||{},retryableStatusCodes:t?.retryConfig?.retryableStatusCodes??n.retryConfig.retryableStatusCodes??de.retryableStatusCodes,nonRetryableStatusCodes:t?.retryConfig?.nonRetryableStatusCodes??n.retryConfig.nonRetryableStatusCodes??de.nonRetryableStatusCodes},{maxRetries:s,initialDelayMs:r,backoffFactor:o,retryableStatusCodes:a,nonRetryableStatusCodes:c,retryOnNetworkError:u}=i,p=0,d=r,l=null;for(;p<=(s??0);){let m=Mi(),b=n.customFetch||globalThis.fetch,h=Ui(n.backendURL,e),w;try{w=new URL(h)}catch{w=new URL(h,window.location.origin)}if(t?.query)for(let[A,x]of Object.entries(t.query))x!==void 0&&w.searchParams.append(A,String(x));let C={method:t?.method||"GET",mode:n.corsMode,credentials:"include",headers:{...n.headers,"X-Request-ID":m,...t?.headers},...t?.fetchOptions};t?.body&&C.method!=="GET"&&(C.body=JSON.stringify(t.body));try{let A=await b(w.toString(),C),x=null,D=null;try{A.headers.get("content-type")?.includes("application/json")&&A.status!==204&&A.headers.get("content-length")!=="0"?x=await A.json():A.status===204&&(x=null)}catch(f){D=f}if(D){let f=me(!1,null,{message:"Failed to parse response",status:A.status,code:"PARSE_ERROR",cause:D},A);if(t?.onError?.(f,e),t?.throw)throw new Error("Failed to parse response");return f}if(A.status>=200&&A.status<300){let f=me(!0,x,null,A);return t?.onSuccess?.(f),f}let O=x,X=me(!1,null,{message:O?.message||`Request failed with status ${A.status}`,status:A.status,code:O?.code||"API_ERROR",details:O?.details||null},A);l=X;let g=!1;if(c?.includes(A.status))(0,K.YA)().debug(`Not retrying request to ${e} with status ${A.status} (nonRetryableStatusCodes)`),g=!1;else if(typeof i.shouldRetry=="function")try{g=i.shouldRetry(A,{attemptsMade:p,url:w.toString(),method:C.method||"GET"}),(0,K.YA)().debug(`Custom retry strategy for ${e} with status ${A.status}: ${g}`)}catch{g=a?.includes(A.status)??!1,(0,K.YA)().debug(`Custom retry strategy failed, falling back to status code check: ${g}`)}else g=a?.includes(A.status)??!1,(0,K.YA)().debug(`Standard retry check for ${e} with status ${A.status}: ${g}`);if(!g||p>=(s??0)){if(t?.onError?.(X,e),t?.throw)throw new Error(X.error?.message||"Request failed");return X}p++,await Je(d??0),d=(d??0)*(o??2)}catch(A){if(A&&A.message==="Failed to parse response")throw A;let x=!(A instanceof Response),D=me(!1,null,{message:A instanceof Error?A.message:String(A),status:0,code:"NETWORK_ERROR",cause:A},null);if(l=D,!(x&&u)||p>=(s??0)){if(t?.onError?.(D,e),t?.throw)throw A;return D}p++,await Je(d??0),d=(d??0)*(o??2)}}let y=l||me(!1,null,{message:`Request failed after ${s} retries`,status:0,code:"MAX_RETRIES_EXCEEDED"},null);if(t?.onError?.(y,e),t?.throw)throw new Error(`Request failed after ${s} retries`);return y}var Y=U("./src/libs/cookie/index.ts"),Oe={INIT:"/init",POST_SUBJECT:"/subjects",GET_SUBJECT:"/subjects",PATCH_SUBJECT:"/subjects",CHECK_CONSENT:"/consents/check",LIST_SUBJECTS:"/subjects"};async function On(n,e,t,i,s){try{let r=await _e(n,e,{method:t,...i});return r.ok?r:(console.warn(`API request failed, falling back to offline mode for ${e}`),s(i))}catch(r){return console.warn(`Error calling ${e}, falling back to offline mode:`,r),s(i)}}async function Gi(n){let e="c15t-pending-identify-submissions";try{if(typeof window<"u"&&n?.body&&window.localStorage){let i=[];try{let o=window.localStorage.getItem(e);o&&(i=JSON.parse(o))}catch(o){console.warn("Error parsing pending identify submissions:",o),i=[]}let s=n.body;i.some(o=>o.id===s.id&&o.externalId===s.externalId)||(i.push(s),window.localStorage.setItem(e,JSON.stringify(i)),(0,K.YA)().log("Queued identify user submission for retry on next page load"))}}catch(i){console.warn("Failed to write to localStorage in identify offline fallback:",i)}let t=me(!0,null,null,null);return n?.onSuccess&&await n.onSuccess(t),t}async function Hi(n,e,t){let{body:i,...s}=t;if(!i?.id)return{ok:!1,data:null,response:null,error:{message:"Subject ID is required to identify user",status:400,code:"MISSING_SUBJECT_ID"}};let r=(0,Y.If)(e);(0,Y._y)({consents:r?.consents||{},consentInfo:{...r?.consentInfo,time:r?.consentInfo?.time||Date.now(),subjectId:i.id,externalId:i.externalId,identityProvider:i.identityProvider}},void 0,e);let o=`${Oe.PATCH_SUBJECT}/${i.id}`,{id:a,...c}=i;return On(n,o,"PATCH",{...s,body:c},async u=>{let p={id:i.id,...u?.body};return Gi({...u,body:p})})}var Rn=U("./src/libs/iab-tcf/fetch-gvl.ts");async function Ln(n,e){let t=null;if(e?.enabled)try{t=await(0,Rn.ix)(e.vendorIds)}catch(s){console.warn("Failed to fetch GVL in offline fallback:",s)}let i=me(!0,{jurisdiction:"NONE",location:{countryCode:null,regionCode:null},translations:{language:"en",translations:le.enTranslations},branding:"c15t",gvl:t},null,null);return n?.onSuccess&&await n.onSuccess(i),i}async function qi(n,e,t){try{let i=await _e(n,Oe.INIT,{method:"GET",...e});return i.ok?i:(console.warn("API request failed, falling back to offline mode for consent banner"),Ln(e,t))}catch(i){return console.warn("Error fetching consent banner info, falling back to offline mode:",i),Ln(e,t)}}var Mn="c15t-pending-consent-submissions",Ze="c15t-pending-identify-submissions";function $i(n,e){let t=Mn;if(!(typeof window>"u"||!window.localStorage))try{window.localStorage.setItem("c15t-storage-test-key","test"),window.localStorage.removeItem("c15t-storage-test-key");let i=window.localStorage.getItem(t);if(!i)return;let s=JSON.parse(i);if(!s.length)return void window.localStorage.removeItem(t);(0,K.YA)().log(`Found ${s.length} pending consent submission(s) to retry`),setTimeout(()=>{e(s)},2e3)}catch(i){console.warn("Failed to check for pending consent submissions:",i)}}async function Ki(n,e){let t=Mn,i=3,s=[...e];for(let r=0;r0;r++){let o=[];for(let a=0;a=0;a--){let c=o[a];c!==void 0&&s.splice(c,1)}if(s.length===0)break;r0?(window.localStorage.setItem(t,JSON.stringify(s)),(0,K.YA)().log(`${s.length} consent submissions still pending for future retry`)):(window.localStorage.removeItem(t),(0,K.YA)().log("All pending consent submissions processed successfully")))}catch(r){console.warn("Error updating pending submissions storage:",r)}}function Yi(n,e){if(!(typeof window>"u"||!window.localStorage))try{let t=window.localStorage.getItem(Ze);if(!t)return;let i=JSON.parse(t);if(!i.length)return void window.localStorage.removeItem(Ze);(0,K.YA)().log(`Found ${i.length} pending identify user submission(s) to retry`),setTimeout(()=>{e(i)},2500)}catch(t){console.warn("Failed to check for pending identify submissions:",t)}}async function Wi(n,e){let i=[...e];for(let s=0;s<3&&i.length>0;s++){let r=[];for(let o=0;o=0;o--){let a=r[o];a!==void 0&&i.splice(a,1)}if(i.length===0)break;s<2&&await Je(1e3*(s+1))}try{typeof window<"u"&&window.localStorage&&(i.length>0?(window.localStorage.setItem(Ze,JSON.stringify(i)),(0,K.YA)().log(`${i.length} identify submissions still pending for future retry`)):(window.localStorage.removeItem(Ze),(0,K.YA)().log("All pending identify submissions processed successfully")))}catch(s){console.warn("Error updating pending identify submissions storage:",s)}}async function Ji(n,e){let t="c15t-pending-consent-submissions",i=e?.body?.subjectId;try{if(typeof window<"u"&&((0,Y._y)({consents:e?.body?.preferences||{},consentInfo:{time:Date.now(),subjectId:i,externalId:e?.body?.externalSubjectId,identityProvider:e?.body?.identityProvider}},void 0,n),e?.body&&window.localStorage)){let r=[];try{let c=window.localStorage.getItem(t);c&&(r=JSON.parse(c))}catch(c){console.warn("Error parsing pending submissions:",c),r=[]}let o=e.body;r.some(c=>JSON.stringify(c)===JSON.stringify(o))||(r.push(o),window.localStorage.setItem(t,JSON.stringify(r)),(0,K.YA)().log("Queued consent submission for retry on next page load"))}}catch(r){console.warn("Failed to write to localStorage in offline fallback:",r)}let s=me(!0,null,null,null);return e?.onSuccess&&await e.onSuccess(s),s}async function Zi(n,e,t){return(0,Y._y)({consents:t?.body?.preferences||{},consentInfo:{time:Date.now(),subjectId:t?.body?.subjectId,externalId:t?.body?.externalSubjectId,identityProvider:t?.body?.identityProvider}},void 0,e),await On(n,Oe.POST_SUBJECT,"POST",t,async s=>Ji(e,s))}var Qe=class{backendURL;storageConfig;iabConfig;headers;customFetch;corsMode;retryConfig;fetcherContext;constructor(e){this.backendURL=e.backendURL.endsWith("/")?e.backendURL.slice(0,-1):e.backendURL,this.headers={"Content-Type":"application/json",...e.headers},this.customFetch=e.customFetch,this.corsMode=e.corsMode||"cors",this.storageConfig=e.storageConfig,this.iabConfig=e.iabConfig,this.retryConfig={maxRetries:e.retryConfig?.maxRetries??de.maxRetries??3,initialDelayMs:e.retryConfig?.initialDelayMs??de.initialDelayMs??100,backoffFactor:e.retryConfig?.backoffFactor??de.backoffFactor??2,retryableStatusCodes:e.retryConfig?.retryableStatusCodes??de.retryableStatusCodes,nonRetryableStatusCodes:e.retryConfig?.nonRetryableStatusCodes??de.nonRetryableStatusCodes,shouldRetry:e.retryConfig?.shouldRetry??de.shouldRetry,retryOnNetworkError:e.retryConfig?.retryOnNetworkError??de.retryOnNetworkError},this.fetcherContext={backendURL:this.backendURL,headers:this.headers,customFetch:this.customFetch,corsMode:this.corsMode,retryConfig:this.retryConfig},this.checkPendingConsentSubmissions(),this.checkPendingIdentifySubmissions()}async init(e){return qi(this.fetcherContext,e,this.iabConfig)}async setConsent(e){return Zi(this.fetcherContext,this.storageConfig,e)}async identifyUser(e){return Hi(this.fetcherContext,this.storageConfig,e)}async $fetch(e,t){return _e(this.fetcherContext,e,t)}checkPendingConsentSubmissions(){$i(this.fetcherContext,e=>this.processPendingConsentSubmissions(e))}async processPendingConsentSubmissions(e){return Ki(this.fetcherContext,e)}checkPendingIdentifySubmissions(){Yi(this.fetcherContext,e=>this.processPendingIdentifySubmissions(e))}async processPendingIdentifySubmissions(e){return Wi(this.fetcherContext,e)}};function Xe(n,e=500,t="HANDLER_ERROR",i){return Oi(n,e,t,i)}async function Et(n,e,t){let i=n[e];if(!i){let s=Xe(`No endpoint handler found for '${String(e)}'`,404,"ENDPOINT_NOT_FOUND");if(t?.throw)throw new Error(`No endpoint handler found for '${String(e)}'`);return s}try{let s=await i(t);return{data:s.data,error:s.error,ok:s.ok??!s.error,response:s.response}}catch(s){let r=Xe(s instanceof Error?s.message:String(s),0,"HANDLER_ERROR",s);if(t?.throw)throw s;return r}}async function Qi(n,e,t,i){let s=t.replace(_n,"").split("/")[0],r=e[t];if(r)try{return await r(i)}catch(o){return Xe(o instanceof Error?o.message:String(o),0,"HANDLER_ERROR",o)}return!s||!(s in n)?Xe(`No endpoint handler found for '${t}'`,404,"ENDPOINT_NOT_FOUND"):await Et(n,s,i)}async function Xi(n,e){let t=("init"in n&&n.init!==void 0,"init");return await Et(n,t,e)}async function es(n,e){return await Et(n,"setConsent",e)}var jt=class{endpointHandlers;dynamicHandlers={};constructor(e){this.endpointHandlers=e.endpointHandlers}async init(e){return Xi(this.endpointHandlers,e)}async setConsent(e){return es(this.endpointHandlers,e)}async identifyUser(e){if(this.endpointHandlers.identifyUser)return this.endpointHandlers.identifyUser(e);let t=e.body?.id;return t?this.$fetch(`/subjects/${t}`,{...e,method:"PATCH"}):{ok:!1,data:null,response:null,error:{message:"Subject ID is required to identify user",status:400,code:"MISSING_SUBJECT_ID"}}}registerHandler(e,t){this.dynamicHandlers[e]=t}async $fetch(e,t){return Qi(this.endpointHandlers,this.dynamicHandlers,e,t)}};function ts(n,e){let t={EU:new Set(["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE"]),EEA:new Set(["IS","NO","LI"]),UK:new Set(["GB"]),CH:new Set(["CH"]),BR:new Set(["BR"]),CA:new Set(["CA"]),AU:new Set(["AU"]),JP:new Set(["JP"]),KR:new Set(["KR"]),CA_QC_REGIONS:new Set(["QC"])},i="NONE";if(n){let s=n.toUpperCase(),r=e&&typeof e=="string"?(e.includes("-")?e.split("-").pop():e).toUpperCase():null;if(s==="CA"&&r&&t.CA_QC_REGIONS.has(r))return"QC_LAW25";let o=[{sets:[t.EU,t.EEA,t.UK],code:"GDPR"},{sets:[t.CH],code:"CH"},{sets:[t.BR],code:"BR"},{sets:[t.CA],code:"PIPEDA"},{sets:[t.AU],code:"AU"},{sets:[t.JP],code:"APPI"},{sets:[t.KR],code:"PIPA"}];for(let{sets:a,code:c}of o)if(a.some(u=>u.has(s))){i=c;break}}return i}var We=U("./src/translations/index.ts");function Un(n=null){return Lt(!0,n)}async function St(n){let e=Un();return n?.onSuccess&&await n.onSuccess(e),e}async function ns(n,e,t){let i=e?.headers?.["x-c15t-country"]??"GB",s=e?.headers?.["x-c15t-region"]??null,r,o,a=e?.headers?.["accept-language"]??null;if(n?.translations&&Object.keys(n.translations).length>0){let d=n.translations,l=Array.from(new Set(["en",...Object.keys(d)])),y=n.defaultLanguage??"en";r=(0,le.selectLanguage)(l,{header:a,fallback:y});let m=d[r]??{};o=(0,le.deepMergeTranslations)(le.enTranslations,m)}else{let d=Object.keys(We.Z.translations),l=We.Z.defaultLanguage??"en";r=(0,le.selectLanguage)(d,{header:a,fallback:l}),o=We.Z.translations[r]}let c=ts(i,s),u=null;if(t?.enabled)if(t.gvl)u=t.gvl;else try{u=await(0,Rn.ix)(t.vendorIds)}catch(d){console.warn("Failed to fetch GVL in offline mode:",d)}let p=Un({jurisdiction:c,location:{countryCode:i,regionCode:s},translations:{language:r,translations:o},branding:"c15t",gvl:u});return e?.onSuccess&&await e.onSuccess(p),p}async function is(n,e){let t=e?.body?.subjectId;try{typeof window<"u"&&(0,Y._y)({consentInfo:{time:Date.now(),subjectId:t,externalId:e?.body?.externalSubjectId,identityProvider:e?.body?.identityProvider},consents:e?.body?.preferences||{}},void 0,n)}catch(i){console.warn("Failed to write to storage:",i)}return await St(e)}var At=class{storageConfig;initialTranslationConfig;iabConfig;constructor(e,t,i){this.storageConfig=e,this.initialTranslationConfig=t,this.iabConfig=i}async init(e){return ns(this.initialTranslationConfig,e,this.iabConfig)}async setConsent(e){return is(this.storageConfig,e)}async identifyUser(e){return console.warn("identifyUser called in offline mode - external ID will not be linked"),St(e)}async $fetch(e,t){return await St(t)}},ss="/api/c15t",rs="c15t",Ye=new Map;function os(n){return n?Object.keys(n).sort().map(t=>{let i=n[t];return i==null?`${t}:null`:`${t}:${String(i)}`}).join("|"):""}function as(n){let e=os(n.storageConfig),t=e?`:storage:${e}`:"";if(n.mode==="offline"){let s=n.store?.initialTranslationConfig?.translations,r=s?`:translations:${Object.keys(s).sort().join(",")}`:"",a=n.store?.iab?.enabled?":iab:enabled":"";return`offline${t}${r}${a}`}if(n.mode==="custom")return`custom:${Object.keys(n.endpointHandlers||{}).sort().join(",")}${t}`;let i="";return"headers"in n&&n.headers&&(i=`:headers:${Object.keys(n.headers).sort().map(r=>`${r}=${n.headers?.[r]}`).join(",")}`),`c15t:${n.backendURL||""}${i}${t}`}function wt(n){let e=as(n);if(Ye.has(e)){if(n.mode!=="offline"&&n.mode!=="custom"&&"headers"in n&&n.headers){let r=Ye.get(e);r instanceof Qe&&(r.headers={"Content-Type":"application/json",...n.headers})}let s=Ye.get(e);if(s)return new Proxy(s,{get(r,o){return r[o]}})}let t=n.mode||rs,i;switch(t){case"custom":{let s=n;i=new jt({endpointHandlers:s.endpointHandlers});break}case"offline":{let s=n.store?.iab;i=new At(n.storageConfig,n.store?.initialTranslationConfig,s?{enabled:s.enabled,vendorIds:s.vendors,gvl:s.gvl}:void 0);break}default:{let s=n,r=n.store?.iab;i=new Qe({backendURL:s.backendURL||ss,headers:s.headers,customFetch:s.customFetch,retryConfig:s.retryConfig,storageConfig:n.storageConfig,iabConfig:r?{enabled:r.enabled,vendorIds:r.vendors,gvl:r.gvl}:void 0});break}}return Ye.set(e,i),i}var Vt=U("./src/libs/generate-subject-id.ts");function Gn(n,e){if(n.length===0)throw new TypeError(`${e} condition cannot be empty`)}function cs(n,e){if(!(n in e))throw new Error(`Consent category "${n}" not found in consent state`);return e[n]||!1}function ls(n,e){let t=Array.isArray(n)?n:[n];return Gn(t,"AND"),t.every(i=>et(i,e))}function us(n,e){let t=Array.isArray(n)?n:[n];return Gn(t,"OR"),t.some(i=>et(i,e))}function et(n,e){if(typeof n=="string")return cs(n,e);if(typeof n=="object"&&n!==null){if("and"in n)return ls(n.and,e);if("or"in n)return us(n.or,e);if("not"in n)return!et(n.not,e)}throw new TypeError(`Invalid condition structure: ${JSON.stringify(n)}`)}function tt(n,e){return et(n,e)}function Hn(n){let e=new Set;function t(i){if(typeof i=="string")return void e.add(i);typeof i=="object"&&i!==null&&("and"in i?(Array.isArray(i.and)?i.and:[i.and]).forEach(t):"or"in i?(Array.isArray(i.or)?i.or:[i.or]).forEach(t):"not"in i&&t(i.not))}return t(n),Array.from(e)}var nt=U("./src/libs/iab-tcf/index.ts"),Te=U("./src/types/consent-types.ts");function ds(n){let e=n.getAttribute("data-category");if(e){if(!Te.W.includes(e))throw new Error(`Invalid category attribute "${e}" on iframe. Must be one of: ${Te.W.join(", ")}`);return e}}function zt(n,e){let t=n.getAttribute("data-src"),i=ds(n);if(!i)return;tt(i,e)?t&&!n.src&&(n.src=t,n.removeAttribute("data-src")):n.src&&n.removeAttribute("src")}function qn(){if(typeof document>"u")return[];let n=document.querySelectorAll("iframe[data-category]"),e=new Set;return n?(n.forEach(t=>{let i=t.getAttribute("data-category");if(!i)return;let s=i.trim();Te.W.includes(s)&&e.add(s)}),Array.from(e)):[]}function En(n){if(typeof document>"u")return;let e=document.querySelectorAll("iframe");e&&e.forEach(t=>{zt(t,n)})}function ps(n,e){let t=new MutationObserver(i=>{let s=n(),r=!1;if(i.forEach(o=>{o.addedNodes.forEach(a=>{if(a.nodeType===Node.ELEMENT_NODE){let c=a;c.tagName&&c.tagName.toUpperCase()==="IFRAME"&&(zt(c,s),c.hasAttribute("data-category")&&(r=!0));let u=c.querySelectorAll?.("iframe");u&&u.length>0&&u.forEach(p=>{zt(p,s),p.hasAttribute("data-category")&&(r=!0)})}})}),r&&e){let o=qn();o.length>0&&e(o)}});return t.observe(document.body,{childList:!0,subtree:!0}),t}function $n(){if(typeof crypto<"u"&&crypto.randomUUID)return crypto.randomUUID().replace(/-/g,"").substring(0,8);if(typeof crypto<"u"&&crypto.getRandomValues){let e=new Uint8Array(4);return crypto.getRandomValues(e),Array.from(e,t=>t.toString(36)).join("").padEnd(8,"0").substring(0,8)}return Math.random().toString(36).substring(2).padEnd(8,"0").substring(0,8)}function Ct(n,e,t){return e?(t[n]||(t[n]=$n()),t[n]):`c15t-script-${n}`}var Re=new Map;function Me(n){return Re.has(n)}function xt(n){return Re.get(n)}function It(n,e){Re.set(n,e)}function Pe(n){Re.delete(n)}function gs(){return Re}function ms(n,e){if(n.vendorId!==void 0){let t=String(n.vendorId);if(!e.vendorConsents[t])return!1}return!(n.iabPurposes&&n.iabPurposes.length>0&&!n.iabPurposes.every(i=>e.purposeConsents[i]===!0)||n.iabLegIntPurposes&&n.iabLegIntPurposes.length>0&&!n.iabLegIntPurposes.every(i=>e.purposeLegitimateInterests[i]===!0)||n.iabSpecialFeatures&&n.iabSpecialFeatures.length>0&&!n.iabSpecialFeatures.every(i=>e.specialFeatureOptIns[i]===!0))}function we(n,e,t){return t?.model==="iab"&&t.iabConsent&&(n.vendorId!==void 0||n.iabPurposes||n.iabLegIntPurposes||n.iabSpecialFeatures)?ms(n,t.iabConsent):tt(n.category,e)}function Kn(n,e,t={},i){let s=[];return n.forEach(r=>{if(!r.alwaysLoad&&!we(r,e,i))return;if(Me(r.id))return void r.onConsentChange?.({id:r.id,elementId:Ct(r.id,r.anonymizeId!==!1,t),hasConsent:we(r,e,i),consents:e});if(r.src&&r.textContent)throw new Error(`Script '${r.id}' cannot have both 'src' and 'textContent'. Choose one.`);if(!r.src&&!r.textContent&&!r.callbackOnly)throw new Error(`Script '${r.id}' must have either 'src', 'textContent', or 'callbackOnly' set to true.`);if(r.callbackOnly===!0){let l=r.anonymizeId!==!1,y=Ct(r.id,l,t),m={id:r.id,elementId:y,consents:e,hasConsent:we(r,e,i)};r.onBeforeLoad&&r.onBeforeLoad(m),r.onLoad&&r.onLoad(m),It(r.id,null),s.push(r.id);return}let o=r.anonymizeId!==!1,a=Ct(r.id,o,t);if(r.persistAfterConsentRevoked===!0){let l=document.getElementById(a);if(l){let y={id:r.id,hasConsent:we(r,e,i),elementId:a,consents:e,element:l};r.onConsentChange?.(y),r.onLoad?.(y),It(r.id,l),s.push(r.id);return}}let c=document.createElement("script");c.id=a,r.src?c.src=r.src:r.textContent&&(c.textContent=r.textContent),r.fetchPriority&&(c.fetchPriority=r.fetchPriority),r.async&&(c.async=!0),r.defer&&(c.defer=!0),r.nonce&&(c.nonce=r.nonce),r.attributes&&Object.entries(r.attributes).forEach(([l,y])=>{c.setAttribute(l,y)});let u={id:r.id,hasConsent:we(r,e,i),elementId:a,consents:e,element:c};r.onLoad&&(r.textContent?setTimeout(()=>{r.onLoad?.({...u})},0):c.addEventListener("load",()=>{r.onLoad?.({...u})})),r.onError&&(r.textContent||c.addEventListener("error",()=>{r.onError?.({...u,error:new Error(`Failed to load script: ${r.src}`)})})),r.onBeforeLoad&&r.onBeforeLoad(u);let p=r.target??"head",d=p==="body"?document.body:document.head;if(!d)throw new Error(`Document ${p} is not available for script injection`);d.appendChild(c),It(r.id,c),s.push(r.id)}),s}function fs(n,e,t={},i){let s=[];return n.forEach(r=>{if(Me(r.id)&&!r.alwaysLoad&&!we(r,e,i)){let o=xt(r.id);r.callbackOnly===!0||o===null?(Pe(r.id),s.push(r.id)):o&&(r.persistAfterConsentRevoked?(Pe(r.id),s.push(r.id)):(o.remove(),Pe(r.id),s.push(r.id)))}}),s}function hs(n,e,t={},i){let s=fs(n,e,t,i);return{loaded:Kn(n,e,t,i),unloaded:s}}function ks(n){return Me(n)}function vs(){return Array.from(gs().keys())}function ys(n,e,t,i={},s){let r=e.find(o=>o.id===n);if(!r)return!1;if(Me(n)){let o=xt(n);r.callbackOnly===!0||o===null?Pe(n):o&&(r.persistAfterConsentRevoked||o.remove(),Pe(n))}return!r.alwaysLoad&&!we(r,t,s)?!1:(Kn([r],t,i,s),!0)}function bs(n,e){let t=()=>{let{scripts:i,consents:s,scriptIdMap:r,model:o,iab:a}=n(),c=a?.config.enabled?{vendorConsents:a.vendorConsents,vendorLegitimateInterests:a.vendorLegitimateInterests,purposeConsents:a.purposeConsents,purposeLegitimateInterests:a.purposeLegitimateInterests,specialFeatureOptIns:a.specialFeatureOptIns}:void 0,u=hs(i,s,r,{model:o,iabConsent:c}),p={...n().loadedScripts};return u.loaded.forEach(d=>{p[d]=!0}),u.unloaded.forEach(d=>{p[d]=!1}),e({loadedScripts:p}),u};return{updateScripts:()=>t(),setScripts:i=>{let s=n(),r={...s.scriptIdMap};i.forEach(u=>{u.anonymizeId!==!1&&(r[u.id]=$n())});let o=i.flatMap(u=>Hn(u.category)),a=new Set([...s.consentCategories,...o]),c=Array.from(a);e({scripts:[...s.scripts,...i],scriptIdMap:r,consentCategories:c}),t()},removeScript:i=>{let s=n();if(Me(i)){let o=xt(i);o&&(o.remove(),Pe(i))}let r={...s.scriptIdMap};delete r[i],e({scripts:s.scripts.filter(o=>o.id!==i),loadedScripts:{...s.loadedScripts,[i]:!1},scriptIdMap:r})},reloadScript:i=>{let s=n();return ys(i,s.scripts,s.consents,s.scriptIdMap)},isScriptLoaded:i=>ks(i),getLoadedScriptIds:()=>vs()}}var ws=U("./src/version.ts"),Cs=U("./src/libs/iab-tcf/store.ts");function Is(n,e){let t=null,i=!1;return{initializeIframeBlocker:()=>{if(i||typeof document>"u")return;let s=n();if(s.iframeBlockerConfig?.disableAutomaticBlocking)return;let r=()=>{let o=qn();o.length>0&&n().updateConsentCategories(o)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r(),setTimeout(r,100),En(s.consents),t=ps(()=>n().consents,o=>n().updateConsentCategories(o)),i=!0},updateIframeConsents:()=>{if(!i||typeof document>"u")return;let s=n(),{consents:r,iframeBlockerConfig:o}=s;o?.disableAutomaticBlocking||En(r)},destroyIframeBlocker:()=>{if(!i||typeof document>"u")return;let s=n(),{iframeBlockerConfig:r}=s;r?.disableAutomaticBlocking||(t&&(t.disconnect(),t=null),i=!1)}}}var Pt="c15t:pending-consent-sync";function js(n,e,t,i,s){if(!i||t===null)return!1;let r=new Set(s.filter(a=>a.disabled).map(a=>a.name));return Object.entries(e).some(([a,c])=>!r.has(a)&&n[a]===!0&&c===!1)}async function Ss({manager:n,type:e,get:t,set:i,options:s}){let{callbacks:r,selectedConsents:o,consents:a,consentTypes:c,updateScripts:u,updateIframeConsents:p,updateNetworkBlockerConsents:d,consentCategories:l,locationInfo:y,model:m,consentInfo:b,reloadOnConsentRevoked:h}=t(),w={...a},C=b,A={...o??a??{}},x=Date.now();if(e==="all")for(let f of c)l.includes(f.name)&&(A[f.name]=!0);else if(e==="necessary")for(let f of c)A[f.name]=f.disabled===!0?f.defaultValue:!1;let D=b?.subjectId;D||(D=(0,Vt.L)());let F=t().consentInfo?.externalId||t().user?.id,O=t().consentInfo?.identityProvider||t().user?.identityProvider,X=js(w,A,C,h,c);if(i({consents:A,selectedConsents:A,activeUI:"none",consentInfo:{time:x,subjectId:D,externalId:F,identityProvider:O}}),X){let f={type:e,subjectId:D,externalId:F,identityProvider:O,preferences:A,givenAt:x,jurisdiction:y?.jurisdiction??void 0,jurisdictionModel:m,domain:window.location.hostname,uiSource:s?.uiSource??"api"};try{localStorage.setItem(Pt,JSON.stringify(f))}catch{}r.onConsentSet?.({preferences:A}),r.onBeforeConsentRevocationReload?.({preferences:A}),window.location.reload();return}await new Promise(f=>setTimeout(f,0)),p(),u(),d(),r.onConsentSet?.({preferences:A});let g=await n.setConsent({body:{type:"cookie_banner",domain:window.location.hostname,preferences:A,subjectId:D,externalSubjectId:String(F),identityProvider:O,jurisdiction:y?.jurisdiction??void 0,jurisdictionModel:m??void 0,givenAt:x,uiSource:s?.uiSource??"api",consentAction:e}});if(!g.ok){let f=g.error?.message??"Failed to save consents";r.onError?.({error:f}),r.onError||console.error(f)}}function As(n,e){return n==null||n==="NONE"?null:e&&["UK_GDPR","GDPR"].includes(n)?"iab":["UK_GDPR","GDPR","CH","BR","APPI","PIPA","QC_LAW25"].includes(n)?"opt-in":["CCPA","AU","PIPEDA"].includes(n)?"opt-out":"opt-in"}function zs(){if(typeof window>"u")return!1;try{let e=window.navigator.globalPrivacyControl;return e===!0||e==="1"}catch{return!1}}var Vn=U("./src/libs/iab-tcf/cmp-defaults.ts");function be({get:n,set:e},t){let{iab:i}=n();i&&e({iab:{...i,...t}})}async function Ps(n,e,t){let{get:i}=e;if(t!==null){be(e,{isLoadingGVL:!0,nonIABVendors:n.customVendors??[]});try{let{initializeIABStub:s,fetchGVL:r,createCMPApi:o}=await Promise.resolve().then(U.bind(U,"./src/libs/iab-tcf/index.ts"));s();let a;if(t)a=t;else if(a=await r(),a===null)return void be(e,{isLoadingGVL:!1});be(e,{gvl:a,isLoadingGVL:!1});let c={},u={};for(let[h,w]of Object.entries(a.vendors)){let C=String(h);w.purposes&&w.purposes.length>0&&(c[C]=!1),w.legIntPurposes&&w.legIntPurposes.length>0&&(u[C]=!0)}(n.customVendors??[]).forEach(h=>{let w=String(h.id);h.purposes&&h.purposes.length>0&&(c[w]=!1),h.legIntPurposes&&h.legIntPurposes.length>0&&(u[w]=!0)});let d=(0,Y.If)(i().storageConfig);d?.iabCustomVendorConsents&&Object.assign(c,d.iabCustomVendorConsents),d?.iabCustomVendorLegitimateInterests&&Object.assign(u,d.iabCustomVendorLegitimateInterests),be(e,{vendorConsents:c,vendorLegitimateInterests:u});let l=n.cmpId??Vn.D,y=n.cmpVersion??Vn.I;if(l===0)throw new Error("[c15t] IAB TCF Error: CMP ID is 0. A valid CMP ID registered with IAB Europe is required for IAB TCF compliance.\nIf using consent.io, the CMP ID should be provided automatically via /init.\nIf self-hosting, configure it on the backend via `advanced.iab.cmpId` or on the client via `iab.cmpId`.\nTo register your own CMP: https://iabeurope.eu/tcf-for-cmps/");let m=o({cmpId:l,cmpVersion:y,gvl:a,gdprApplies:!0});be(e,{cmpApi:m});let b=m.loadFromStorage();b&&await Ts(b,e),i().updateScripts()}catch(s){console.error("Failed to initialize IAB mode:",s),be(e,{isLoadingGVL:!1})}}}async function Ts(n,e){let{set:t}=e;try{let{decodeTCString:i,iabPurposesToC15tConsents:s}=await Promise.resolve().then(U.bind(U,"./src/libs/iab-tcf/index.ts")),r=await i(n),o=(0,Y.If)(e.get().storageConfig),a={...r.vendorConsents,...o?.iabCustomVendorConsents??{}},c={...r.vendorLegitimateInterests,...o?.iabCustomVendorLegitimateInterests??{}},u=s(r.purposeConsents);be(e,{tcString:n,purposeConsents:r.purposeConsents,purposeLegitimateInterests:r.purposeLegitimateInterests,vendorConsents:a,vendorLegitimateInterests:c,specialFeatureOptIns:r.specialFeatureOptIns}),t({consents:u,selectedConsents:u,activeUI:"none"})}catch{}}function Ls(n,e){return n?{necessary:!0,functionality:!0,experience:!0,marketing:!e,measurement:!e}:null}function Yn(n,e,t,i){let s=As(n,e),r=i!==void 0?i:zs(),a=Ls((s===null||s==="opt-out")&&t===null,r);return{consentModel:s,autoGrantedConsents:a}}function Es(n,e,t,i){let{get:s,initialTranslationConfig:r}=e,{consentInfo:o}=s(),{translations:a,location:c}=n,{consentModel:u,autoGrantedConsents:p}=Yn(n.jurisdiction??null,i,o,e.get().overrides?.gpc),d={model:u,isLoadingConsentInfo:!1,branding:n.branding??"c15t",hasFetchedBanner:!0,lastBannerFetchData:n,locationInfo:{countryCode:c?.countryCode??null,regionCode:c?.regionCode??null,jurisdiction:n.jurisdiction??null}};return o===null&&(d.activeUI=u?"banner":"none"),p&&(d.consents=p,d.selectedConsents=p),a?.language&&a?.translations&&(d.translationConfig=(0,le.prepareTranslationConfig)({translations:{[a.language]:a.translations},disableAutoLanguageSwitch:!0,defaultLanguage:a.language},r)),d}function Vs(n,e,t){let{get:i}=e,{callbacks:s}=i(),{translations:r}=n;t&&s?.onConsentSet?.({preferences:t}),r?.language&&r?.translations&&s?.onBannerFetched?.({jurisdiction:n.jurisdiction,location:n.location,translations:{language:r.language,translations:r.translations}})}function Wn(n,e,t,i){let{set:s,get:r}=e,{consentInfo:o,iab:a}=r(),c=a?.config.enabled&&!i,u=a?.config.enabled&&!c;c&&console.warn("IAB mode disabled: Server returned 200 without GVL. Client IAB settings overridden.");let{consentModel:p,autoGrantedConsents:d}=Yn(n.jurisdiction??null,u,o,r().overrides?.gpc),l=Es(n,e,t,u);if(c&&a?l.iab={...a,config:{...a.config,enabled:!1}}:a&&n.cmpId!=null&&(l.iab={...a,config:{...a.config,cmpId:n.cmpId}}),s(l),Vs(n,e,d),r().updateScripts(),u&&p==="iab"&&a){let y=n.customVendors??[],m=a.config.customVendors??[],b=new Set(y.map(C=>C.id)),h=[...y,...m.filter(C=>!b.has(C.id))],w={...a.config,customVendors:h,...n.cmpId!=null&&{cmpId:n.cmpId}};Ps(w,{set:s,get:r},i).catch(C=>{console.error("Failed to initialize IAB mode in updateStore:",C)})}}function xs(n){try{if(window.localStorage)return window.localStorage.setItem("c15t-storage-test-key","test"),window.localStorage.removeItem("c15t-storage-test-key"),!0}catch(e){console.warn("localStorage not available, skipping consent banner:",e),n({isLoadingConsentInfo:!1,activeUI:"none"})}return!1}async function xn(n){let{get:e,set:t,manager:i}=n,{callbacks:s}=e();if(typeof window>"u")return;let r=xs(t);if(!r)return;t({isLoadingConsentInfo:!0}),Ds(i,s);let o=await Fs(n);return o||Bs(n,r,i,s)}async function Fs(n){let{ssrData:e,get:t,set:i}=n;if(!e||t().overrides)return void i({ssrDataUsed:!1,ssrSkippedReason:"no_data"});let s=await e;if(s?.init)return Wn(s.init,n,!0,s.gvl),i({ssrDataUsed:!0,ssrSkippedReason:null}),s.init;i({ssrDataUsed:!1,ssrSkippedReason:"fetch_failed"})}async function Bs(n,e,t,i){let{set:s}=n;try{let{language:r,country:o,region:a}=n.get().overrides??{},{data:c,error:u}=await t.init({headers:{...r&&{"accept-language":r},...o&&{"x-c15t-country":o},...a&&{"x-c15t-region":a}},onError:i.onError?p=>{i.onError?.({error:p.error?.message||"Unknown error"})}:void 0});if(u||!c)throw new Error(`Failed to fetch consent banner info: ${u?.message}`);return Wn(c,n,e,c.gvl??void 0),c}catch(r){console.error("Error fetching consent banner information:",r),s({isLoadingConsentInfo:!1,activeUI:"none"});let o=r instanceof Error?r.message:"Unknown error fetching consent banner information";i.onError?.({error:o});return}}function Ds(n,e){try{let t=localStorage.getItem(Pt);if(!t)return;localStorage.removeItem(Pt);let i=JSON.parse(t);n.setConsent({body:{type:"cookie_banner",domain:i.domain,preferences:i.preferences,subjectId:i.subjectId,externalSubjectId:i.externalId,identityProvider:i.identityProvider,jurisdiction:i.jurisdiction,jurisdictionModel:i.jurisdictionModel??void 0,givenAt:i.givenAt,uiSource:i.uiSource??"api"}}).then(s=>{if(!s.ok){let r=s.error?.message??"Failed to sync consent after reload";e.onError?.({error:r}),e.onError||console.error("Failed to sync consent after reload:",r)}}).catch(s=>{let r=s instanceof Error?s.message:"Failed to sync consent after reload";e.onError?.({error:r}),e.onError||console.error("Failed to sync consent after reload:",s)})}catch{}}function Tt(n){return n?n.toUpperCase():"GET"}function Ns(n){if(!n)return null;try{return typeof window>"u"?null:new URL(n,window.location.href)}catch{return null}}function _s(n,e){if(!n)return!1;let t=e.domain.trim().toLowerCase(),i=n.trim().toLowerCase();if(!t||!i)return!1;if(i===t)return!0;let s=`.${t}`;return i.endsWith(s)}function Os(n,e){return typeof e.pathIncludes=="string"?n?n.includes(e.pathIncludes):!1:!0}function Rs(n,e){if(!e.methods||e.methods.length===0)return!0;if(!n)return!1;let t=Tt(n);return e.methods.some(i=>Tt(i)===t)}function Ms(n,e,t){return!(!_s(n.hostname,t)||!Os(n.pathname,t)||!Rs(e,t))}function Fn(n,e,t){if(!t)return{shouldBlock:!1};if(!(t.enabled!==!1))return{shouldBlock:!1};if(!t.rules||t.rules.length===0)return{shouldBlock:!1};let s=Ns(n.url);if(!s)return{shouldBlock:!1};let r=Tt(n.method);for(let o of t.rules){if(!Ms(s,r,o))continue;if(!tt(o.category,e))return{shouldBlock:!0,rule:o}}return{shouldBlock:!1}}function Us(n,e){let t=null,i=null,s=null,r=!1,o=null,a=(d,l)=>{if(d){if(d.logBlockedRequests!==!1){let y=l.rule?.id??"unknown";console.warn("[c15t] Network request blocked by consent manager",{method:l.method,url:l.url,ruleId:y})}d.onRequestBlocked&&d.onRequestBlocked(l)}},c=()=>o||n().consents,u=()=>{typeof window>"u"||!(typeof window.fetch=="function")||t||(t=window.fetch,window.fetch=(l,y)=>{let b=n().networkBlocker;if(!t)throw new Error("Network blocker fetch wrapper not initialized.");if(!(b?.enabled&&b?.rules&&b?.rules.length>0))return t.call(window,l,y);let w="GET";y?.method?w=y.method:l instanceof Request&&(w=l.method);let C;C=typeof l=="string"||l instanceof URL?l.toString():l.url;let A=c(),{shouldBlock:x,rule:D}=Fn({url:C,method:w},A,b);if(x){a(b,{method:w,url:C,rule:D});let F=new Response(null,{status:451,statusText:"Request blocked by consent manager"});return Promise.resolve(F)}return t.call(window,l,y)})},p=()=>{typeof window>"u"||!(window.XMLHttpRequest!==void 0&&typeof window.XMLHttpRequest.prototype.open=="function"&&typeof window.XMLHttpRequest.prototype.send=="function")||i||s||(i=window.XMLHttpRequest.prototype.open,s=window.XMLHttpRequest.prototype.send,window.XMLHttpRequest.prototype.open=function(l,y,m,b,h){let w=this;if(w.__c15tMethod=l,w.__c15tUrl=y,!i)throw new Error("Network blocker XHR open wrapper not initialized.");return i.call(this,l,y,m??!0,b,h)},window.XMLHttpRequest.prototype.send=function(l){let m=n().networkBlocker;if(m?.enabled!==!1&&m?.rules&&m?.rules.length>0){let w=this,C=w.__c15tMethod||"GET",A=w.__c15tUrl||"",x=c(),{shouldBlock:D,rule:F}=Fn({url:A,method:C},x,m);if(D){a(m,{method:C,url:A,rule:F});try{this.abort()}catch{}let O=new ProgressEvent("error");typeof this.onerror=="function"&&this.onerror(O),this.dispatchEvent(O);return}}if(!s)throw new Error("Network blocker XHR send wrapper not initialized.");return s.call(this,l)})};return{initializeNetworkBlocker:()=>{if(r||typeof window>"u")return;let d=n(),l=d.networkBlocker;l?.enabled&&l?.rules&&l?.rules.length>0&&(o=d.consents,u(),p(),r=!0)},updateNetworkBlockerConsents:()=>{r&&(o=n().consents)},setNetworkBlocker:d=>{let y=d?.enabled!==!1&&d?.rules&&d?.rules.length>0;if(e({networkBlocker:d}),!y){if(!r||typeof window>"u")return;t&&(window.fetch=t,t=null),i&&s&&(window.XMLHttpRequest.prototype.open=i,window.XMLHttpRequest.prototype.send=s,i=null,s=null),o=null,r=!1;return}r||(o=n().consents,u(),p(),r=!0)},destroyNetworkBlocker:()=>{r&&(typeof window>"u"||(t&&(window.fetch=t,t=null),i&&s&&(window.XMLHttpRequest.prototype.open=i,window.XMLHttpRequest.prototype.send=s,i=null,s=null),o=null,r=!1))}}}var Gs=U("./src/store/initial-state.ts"),Bn=n=>{if(typeof window>"u")return null;try{return(0,Y.If)(n)}catch(e){return console.error("Failed to retrieve stored consent:",e),null}},Hs=(n,e={})=>{let{namespace:t="c15tStore",iab:i,ssrData:s,initialConsentCategories:r,initialTranslationConfig:o,enabled:a,debug:c,...u}=e;(0,K.tJ)(e.debug===!0);let p=Bn(e.storageConfig),d=qt((l,y)=>({...Gs.ue,...u,namespace:t,iab:i?(0,Cs.yx)(i,y,l,n):null,...r&&{consentCategories:r},...p?{consents:p.consents,selectedConsents:p.consents,consentInfo:p.consentInfo,user:p.consentInfo?.externalId?{id:p.consentInfo.externalId,identityProvider:p.consentInfo.identityProvider}:void 0,activeUI:"none",isLoadingConsentInfo:!1}:{activeUI:"none",isLoadingConsentInfo:!0},setActiveUI:(m,b={})=>{if(m==="none"||m==="dialog")return void l({activeUI:m});if(b.force)return void l({activeUI:"banner"});let h=y();!Bn()&&!h.consentInfo&&!h.isLoadingConsentInfo&&l({activeUI:"banner"})},setSelectedConsent:(m,b)=>{l(h=>h.consentTypes.find(C=>C.name===m)?.disabled?h:{selectedConsents:{...h.selectedConsents,[m]:b}})},saveConsents:async(m,b)=>await Ss({manager:n,type:m,get:y,set:l,options:b}),setConsent:(m,b)=>{l(h=>h.consentTypes.find(A=>A.name===m)?.disabled?h:{selectedConsents:{...h.consents,[m]:b}}),y().saveConsents("custom")},resetConsents:()=>{l(()=>{let m=Te.y.reduce((h,w)=>(h[w.name]=w.defaultValue,h),{}),b={consents:m,selectedConsents:m,consentInfo:null};return(0,Y.jD)(void 0,e.storageConfig),b})},setConsentCategories:m=>l({consentCategories:m}),setCallback:(m,b)=>{let h=y();if(l(w=>({callbacks:{...w.callbacks,[m]:b}})),m==="onConsentSet"&&b&&typeof b=="function"&&b?.({preferences:h.consents}),m==="onBannerFetched"&&h.hasFetchedBanner&&h.lastBannerFetchData&&b&&typeof b=="function"){let{lastBannerFetchData:w}=h,C=w.jurisdiction??"NONE";b?.({jurisdiction:{code:C,message:""},location:{countryCode:w.location.countryCode??null,regionCode:w.location.regionCode??null},translations:{language:w.translations.language,translations:w.translations.translations}})}},setLocationInfo:m=>l({locationInfo:m}),initConsentManager:()=>xn({manager:n,ssrData:e.ssrData,initialTranslationConfig:e.initialTranslationConfig,get:y,set:l}),getDisplayedConsents:()=>{let{consentCategories:m,consentTypes:b}=y();return b.filter(h=>m.includes(h.name))},hasConsented:()=>{let{consentInfo:m}=y();return m!=null},has:m=>{let{consents:b}=y();return tt(m,b)},setTranslationConfig:m=>{l({translationConfig:m})},updateConsentCategories:m=>{let b=new Set([...y().consentCategories,...m]),h=Array.from(b);l({consentCategories:h})},identifyUser:async m=>{let b=y().consentInfo,h=b?.subjectId;l({user:m}),h&&(String(b?.externalId)===String(m.id)&&b?.identityProvider===m.identityProvider||(await n.identifyUser({body:{id:h,externalId:m.id,identityProvider:m.identityProvider}}),l({consentInfo:{...b,time:b?.time||Date.now(),subjectId:h,externalId:m.id,identityProvider:m.identityProvider}})))},setOverrides:async m=>(l({overrides:{...y().overrides,...m}}),await xn({manager:n,initialTranslationConfig:e.initialTranslationConfig,get:y,set:l})),setLanguage:async m=>await y().setOverrides({...y().overrides??{},language:m}),...bs(y,l),...Is(y,l),...Us(y,l)}));return d.getState().initializeIframeBlocker(),e.networkBlocker&&(d.setState({networkBlocker:e.networkBlocker}),d.getState().initializeNetworkBlocker()),e.scripts&&e.scripts.length>0&&d.getState().updateConsentCategories(e.scripts.flatMap(l=>Hn(l.category))),typeof window<"u"&&(window[t]=d,d.getState().callbacks.onConsentSet?.({preferences:d.getState().consents}),e.user&&d.getState().identifyUser(e.user),d.getState().initConsentManager()),d},qs="/api/c15t",Dn=new Map,Nn=new Map;function $s(n){let e=n.enabled===!1?"disabled":"enabled";return`${n.mode??"c15t"}:${n.backendURL??"default"}:${n.endpointHandlers?"custom":"none"}:${n.storageConfig?.storageKey??"default"}:${n.defaultLanguage??"default"}:${e}`}function Jn(n,e){let{mode:t,backendURL:i,store:s,translations:r,storageConfig:o,enabled:a,iab:c,consentCategories:u,debug:p}=n,d=$s({mode:t,backendURL:i,endpointHandlers:"endpointHandlers"in n?n.endpointHandlers:void 0,storageConfig:o,defaultLanguage:r?.defaultLanguage,enabled:a}),l=Dn.get(d);if(!l){let m={...s,initialTranslationConfig:r,iab:c};l=t==="offline"?wt({mode:"offline",store:m,storageConfig:o}):t==="custom"&&"endpointHandlers"in n?wt({mode:"custom",endpointHandlers:n.endpointHandlers,store:m,storageConfig:o}):wt({mode:"c15t",backendURL:i||qs,store:m,storageConfig:o}),Dn.set(d,l)}let y=Nn.get(d);return y||(y=Hs(l,{config:{pkg:e?.pkg||"c15t",version:e?.version||ws.r,mode:t||"Unknown"},...n,...s,initialTranslationConfig:r,initialConsentCategories:u,debug:p}),Nn.set(d,y)),{consentManager:l,consentStore:y,cacheKey:d}}var Ha=Te.W,qa=nt.xe,$a=Te.y,Ka=le.deepMergeTranslations,Ya=We.Z,Wa=Y.jD,Ja=Y.Yj,Za=le.detectBrowserLanguage,Qa=nt.fetchGVL,Xa=Vt.L,ec=nt.Ww,tc=Y.If,nc=Y.Ri,ic=Y.Xk,sc=Vt.U,rc=le.mergeTranslationConfigs,oc=le.prepareTranslationConfig,ac=Y._y,cc=Y.TV,lc=nt.wL;window.c15t={getOrCreateConsentRuntime:Jn};})(); diff --git a/docs/theme/consent-banner.css b/docs/theme/consent-banner.css new file mode 100644 index 0000000000000000000000000000000000000000..bdebbed80a997ca57be2516cdd0472fd4f52cae9 --- /dev/null +++ b/docs/theme/consent-banner.css @@ -0,0 +1,292 @@ +#c15t-banner { + --color-offgray-50: hsl(218, 12%, 95%); + --color-offgray-100: hsl(218, 12%, 88%); + --color-offgray-200: hsl(218, 12%, 80%); + --color-offgray-300: hsl(218, 12%, 75%); + --color-offgray-400: hsl(218, 12%, 64%); + --color-offgray-500: hsl(218, 12%, 56%); + --color-offgray-600: hsl(218, 12%, 48%); + --color-offgray-700: hsl(218, 12%, 40%); + --color-offgray-800: hsl(218, 12%, 34%); + --color-offgray-900: hsl(218, 12%, 24%); + --color-offgray-950: hsl(218, 12%, 15%); + --color-offgray-1000: hsl(218, 12%, 5%); + + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-900: oklch(37.9% 0.146 265.522); + --color-blue-950: oklch(28.2% 0.091 267.935); + + --color-accent-blue: hsla(218, 93%, 42%, 1); + + position: fixed; + z-index: 9999; + bottom: 16px; + right: 16px; + border-radius: 4px; + max-width: 300px; + background: white; + border: 1px solid + color-mix(in oklab, var(--color-offgray-200) 50%, transparent); + box-shadow: 6px 6px 0 + color-mix(in oklab, var(--color-accent-blue) 6%, transparent); +} + +.dark #c15t-banner { + border-color: color-mix(in oklab, var(--color-offgray-600) 14%, transparent); + background: var(--color-offgray-1000); + box-shadow: 5px 5px 0 + color-mix(in oklab, var(--color-accent-blue) 8%, transparent); +} + +#c15t-banner > div:first-child { + padding: 12px; + display: flex; + flex-direction: column; +} + +#c15t-banner a { + color: var(--links); + text-decoration: underline; + text-decoration-color: var(--link-line-decoration); +} + +#c15t-banner a:hover { + text-decoration-color: var(--link-line-decoration-hover); +} + +#c15t-description { + font-size: 12px; + margin: 0; + margin-top: 4px; +} + +#c15t-configure-section { + display: flex; + flex-direction: column; + gap: 8px; + border-top: 1px solid var(--divider); + padding: 12px; +} + +#c15t-configure-section > div { + display: flex; + align-items: center; + justify-content: space-between; +} + +#c15t-configure-section label { + text-transform: uppercase; + font-size: 11px; +} + +#c15t-footer { + padding: 12px; + display: flex; + justify-content: space-between; + border-top: 1px solid var(--divider); + background-color: color-mix( + in oklab, + var(--color-offgray-50) 50%, + transparent + ); +} + +.dark #c15t-footer { + background-color: color-mix( + in oklab, + var(--color-offgray-600) 4%, + transparent + ); +} + +.c15t-button { + display: inline-flex; + align-items: center; + justify-content: center; + max-height: 28px; + color: black; + padding: 4px 8px; + font-size: 14px; + border-radius: 4px; + background: transparent; + border: 1px solid transparent; + transition: 100ms; + transition-property: box-shadow, border-color, background-color; +} + +.c15t-button:hover { + background: color-mix(in oklab, var(--color-offgray-100) 50%, transparent); +} + +.dark .c15t-button { + color: var(--color-offgray-50); +} + +.dark .c15t-button:hover { + background: color-mix(in oklab, var(--color-offgray-500) 10%, transparent); +} + +.c15t-button.icon { + padding: 0; + width: 24px; + height: 24px; +} + +.c15t-button.primary { + color: var(--color-blue-700); + background: color-mix(in oklab, var(--color-blue-50) 60%, transparent); + border-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent); + box-shadow: color-mix(in oklab, var(--color-blue-400) 10%, transparent) 0 -2px + 0 0 inset; +} + +.c15t-button.primary:hover { + background: color-mix(in oklab, var(--color-blue-100) 50%, transparent); + box-shadow: none; +} + +.dark .c15t-button.primary { + color: var(--color-blue-50); + background: color-mix(in oklab, var(--color-blue-500) 10%, transparent); + border-color: color-mix(in oklab, var(--color-blue-300) 10%, transparent); + box-shadow: color-mix(in oklab, var(--color-blue-300) 8%, transparent) 0 -2px + 0 0 inset; +} + +.dark .c15t-button.primary:hover { + background: color-mix(in oklab, var(--color-blue-500) 20%, transparent); + box-shadow: none; +} + +.c15t-button.secondary { + background: color-mix(in oklab, var(--color-offgray-50) 60%, transparent); + border-color: color-mix(in oklab, var(--color-offgray-200) 50%, transparent); + box-shadow: color-mix(in oklab, var(--color-offgray-500) 10%, transparent) + 0 -2px 0 0 inset; +} + +.c15t-button.secondary:hover { + background: color-mix(in oklab, var(--color-offgray-100) 50%, transparent); + box-shadow: none; +} + +.dark .c15t-button.secondary { + background: color-mix(in oklab, var(--color-offgray-300) 5%, transparent); + border-color: color-mix(in oklab, var(--color-offgray-400) 20%, transparent); + box-shadow: color-mix(in oklab, var(--color-offgray-300) 8%, transparent) + 0 -2px 0 0 inset; +} + +.dark .c15t-button.secondary:hover { + background: color-mix(in oklab, var(--color-offgray-200) 10%, transparent); + box-shadow: none; +} + +.c15t-switch { + position: relative; + display: inline-block; + width: 32px; + height: 20px; + flex-shrink: 0; +} + +.c15t-switch input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.c15t-slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: color-mix( + in oklab, + var(--color-offgray-100) 80%, + transparent + ); + border-radius: 20px; + box-shadow: inset 0 0 0 1px color-mix(in oklab, #000 5%, transparent); + transition: background-color 0.2s; +} + +.c15t-slider:hover { + background-color: var(--color-offgray-100); +} + +.dark .c15t-slider { + background-color: color-mix(in oklab, #fff 5%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, #fff 15%, transparent); +} + +.dark .c15t-slider:hover { + background-color: color-mix(in oklab, #fff 10%, transparent); +} + +.c15t-slider:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + border-radius: 50%; + box-shadow: + 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); + transition: transform 0.2s; +} + +.c15t-switch input:checked + .c15t-slider { + background-color: var(--color-accent-blue); + box-shadow: inset 0 0 0 1px color-mix(in oklab, #000 5%, transparent); +} + +.c15t-switch input:checked + .c15t-slider:hover { + background-color: var(--color-accent-blue); +} + +.dark .c15t-switch input:checked + .c15t-slider { + background-color: var(--color-accent-blue); + box-shadow: inset 0 0 0 1px color-mix(in oklab, #fff 15%, transparent); +} + +.c15t-switch input:checked + .c15t-slider:before { + transform: translateX(12px); +} + +.c15t-switch input:disabled + .c15t-slider { + opacity: 0.5; + cursor: default; + pointer-events: none; +} + +.c15t-switch input:disabled + .c15t-slider:hover { + background-color: color-mix( + in oklab, + var(--color-offgray-100) 80%, + transparent + ); +} + +#c15t-manage-consent-btn { + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +#c15t-manage-consent-btn:hover { + text-decoration-color: var(--link-line-decoration-hover); +} diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 8e6d185a57874a84bd373115e2f4b988a6c0b864..1c833ee94d428a1578b35c7944c4d300a04a21db 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -70,6 +70,8 @@ {{/if}} + +
@@ -343,6 +345,13 @@ href="https://zed.dev/blog" >Blog + +
@@ -444,23 +453,82 @@ {{/if}} {{/if}} - - + +
diff --git a/typos.toml b/typos.toml index 6f76cc75d25add39d841c07bbde82f93514adac5..c4e326359dec6e2a47861df1aab7b66f0644d7a3 100644 --- a/typos.toml +++ b/typos.toml @@ -42,6 +42,8 @@ extend-exclude = [ "crates/gpui_windows/src/window.rs", # Some typos in the base mdBook CSS. "docs/theme/css/", + # Automatically generated JS. + "docs/theme/c15t@*.js", # Spellcheck triggers on `|Fixe[sd]|` regex part. "script/danger/dangerfile.ts", # Eval examples for prompts and criteria From a1d40370cfbcc086a89350cc798125d055773947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20Houl=C3=A9?= <13155277+tomhoule@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:45:33 +0100 Subject: [PATCH 42/74] cloud_api_client: Send the organization ID in LLM token requests (#50517) This is already expected on the cloud side. This lets us know under which organization the user is logged in when requesting an llm_api token. Closes CLO-337 Release Notes: - N/A --- Cargo.lock | 1 + .../cloud_api_client/src/cloud_api_client.rs | 10 +- crates/cloud_api_types/src/cloud_api_types.rs | 6 + crates/edit_prediction/src/edit_prediction.rs | 67 ++++++++--- crates/edit_prediction/src/zeta.rs | 13 +++ crates/http_client/src/async_body.rs | 14 +++ crates/http_client/src/http_client.rs | 2 +- .../language_model/src/model/cloud_model.rs | 28 ++++- crates/language_models/src/provider/cloud.rs | 104 ++++++++++++++---- crates/web_search_providers/Cargo.toml | 1 + crates/web_search_providers/src/cloud.rs | 36 ++++-- .../src/web_search_providers.rs | 22 +++- crates/zed/src/main.rs | 2 +- crates/zed/src/zed.rs | 2 +- 14 files changed, 247 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e4d86b947be1f68d03b225d4a62747659c99bf8..b1ff28fcf52e118830e2100d35a6cdbca6f6f013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19851,6 +19851,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "cloud_api_types", "cloud_llm_client", "futures 0.3.31", "gpui", diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index f485e2d20c619715ea342fccd2a5cec0ecaa6f4e..13d67838b216f4990f15ec22c1701aa7aef9dbf2 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -9,7 +9,9 @@ use futures::AsyncReadExt as _; use gpui::{App, Task}; use gpui_tokio::Tokio; use http_client::http::request; -use http_client::{AsyncBody, HttpClientWithUrl, HttpRequestExt, Method, Request, StatusCode}; +use http_client::{ + AsyncBody, HttpClientWithUrl, HttpRequestExt, Json, Method, Request, StatusCode, +}; use parking_lot::RwLock; use thiserror::Error; use yawc::WebSocket; @@ -141,6 +143,7 @@ impl CloudApiClient { pub async fn create_llm_token( &self, system_id: Option, + organization_id: Option, ) -> Result { let request_builder = Request::builder() .method(Method::POST) @@ -153,7 +156,10 @@ impl CloudApiClient { builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id) }); - let request = self.build_request(request_builder, AsyncBody::default())?; + let request = self.build_request( + request_builder, + Json(CreateLlmTokenBody { organization_id }), + )?; let mut response = self.http_client.send(request).await?; diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 2d457fc6630d5b32f049e67a6a460047e925973a..42d3442bfc016f5cb1a39ba421ccdfe386bcbc65 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -52,6 +52,12 @@ pub struct AcceptTermsOfServiceResponse { #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] +pub struct CreateLlmTokenBody { + #[serde(default)] + pub organization_id: Option, +} + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct CreateLlmTokenResponse { pub token: LlmToken, diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 33c3ea1e56648c73682e06f685f91f54344200d6..6b2019aa30030b0852f74bc851e2012feac4f0e2 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1,7 +1,7 @@ use anyhow::Result; use arrayvec::ArrayVec; use client::{Client, EditPredictionUsage, UserStore}; -use cloud_api_types::SubmitEditPredictionFeedbackBody; +use cloud_api_types::{OrganizationId, SubmitEditPredictionFeedbackBody}; use cloud_llm_client::predict_edits_v3::{ PredictEditsV3Request, PredictEditsV3Response, RawCompletionRequest, RawCompletionResponse, }; @@ -143,7 +143,7 @@ pub struct EditPredictionStore { pub sweep_ai: SweepAi, pub mercury: Mercury, data_collection_choice: DataCollectionChoice, - reject_predictions_tx: mpsc::UnboundedSender, + reject_predictions_tx: mpsc::UnboundedSender, settled_predictions_tx: mpsc::UnboundedSender, shown_predictions: VecDeque, rated_predictions: HashSet, @@ -151,6 +151,11 @@ pub struct EditPredictionStore { settled_event_callback: Option>, } +pub(crate) struct EditPredictionRejectionPayload { + rejection: EditPredictionRejection, + organization_id: Option, +} + #[derive(Copy, Clone, PartialEq, Eq)] pub enum EditPredictionModel { Zeta, @@ -719,8 +724,13 @@ impl EditPredictionStore { |this, _listener, _event, cx| { let client = this.client.clone(); let llm_token = this.llm_token.clone(); + let organization_id = this + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); cx.spawn(async move |_this, _cx| { - llm_token.refresh(&client).await?; + llm_token.refresh(&client, organization_id).await?; anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -781,11 +791,17 @@ impl EditPredictionStore { let client = self.client.clone(); let llm_token = self.llm_token.clone(); let app_version = AppVersion::global(cx); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); + cx.spawn(async move |this, cx| { let experiments = cx .background_spawn(async move { let http_client = client.http_client(); - let token = llm_token.acquire(&client).await?; + let token = llm_token.acquire(&client, organization_id).await?; let url = http_client.build_zed_llm_url("/edit_prediction_experiments", &[])?; let request = http_client::Request::builder() .method(Method::GET) @@ -1424,7 +1440,7 @@ impl EditPredictionStore { } async fn handle_rejected_predictions( - rx: UnboundedReceiver, + rx: UnboundedReceiver, client: Arc, llm_token: LlmApiToken, app_version: Version, @@ -1433,7 +1449,11 @@ impl EditPredictionStore { let mut rx = std::pin::pin!(rx.peekable()); let mut batched = Vec::new(); - while let Some(rejection) = rx.next().await { + while let Some(EditPredictionRejectionPayload { + rejection, + organization_id, + }) = rx.next().await + { batched.push(rejection); if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { @@ -1471,6 +1491,7 @@ impl EditPredictionStore { }, client.clone(), llm_token.clone(), + organization_id, app_version.clone(), true, ) @@ -1676,13 +1697,23 @@ impl EditPredictionStore { all_language_settings(None, cx).edit_predictions.provider, EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi ); + if is_cloud { + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); + self.reject_predictions_tx - .unbounded_send(EditPredictionRejection { - request_id: prediction_id.to_string(), - reason, - was_shown, - model_version, + .unbounded_send(EditPredictionRejectionPayload { + rejection: EditPredictionRejection { + request_id: prediction_id.to_string(), + reason, + was_shown, + model_version, + }, + organization_id, }) .log_err(); } @@ -2337,6 +2368,7 @@ impl EditPredictionStore { client: Arc, custom_url: Option>, llm_token: LlmApiToken, + organization_id: Option, app_version: Version, ) -> Result<(RawCompletionResponse, Option)> { let url = if let Some(custom_url) = custom_url { @@ -2356,6 +2388,7 @@ impl EditPredictionStore { }, client, llm_token, + organization_id, app_version, true, ) @@ -2366,6 +2399,7 @@ impl EditPredictionStore { input: ZetaPromptInput, client: Arc, llm_token: LlmApiToken, + organization_id: Option, app_version: Version, trigger: PredictEditsRequestTrigger, ) -> Result<(PredictEditsV3Response, Option)> { @@ -2388,6 +2422,7 @@ impl EditPredictionStore { }, client, llm_token, + organization_id, app_version, true, ) @@ -2441,6 +2476,7 @@ impl EditPredictionStore { build: impl Fn(http_client::http::request::Builder) -> Result>, client: Arc, llm_token: LlmApiToken, + organization_id: Option, app_version: Version, require_auth: bool, ) -> Result<(Res, Option)> @@ -2450,9 +2486,12 @@ impl EditPredictionStore { let http_client = client.http_client(); let mut token = if require_auth { - Some(llm_token.acquire(&client).await?) + Some(llm_token.acquire(&client, organization_id.clone()).await?) } else { - llm_token.acquire(&client).await.ok() + llm_token + .acquire(&client, organization_id.clone()) + .await + .ok() }; let mut did_retry = false; @@ -2494,7 +2533,7 @@ impl EditPredictionStore { return Ok((serde_json::from_slice(&body)?, usage)); } else if !did_retry && token.is_some() && response.needs_llm_token_refresh() { did_retry = true; - token = Some(llm_token.refresh(&client).await?); + token = Some(llm_token.refresh(&client, organization_id.clone()).await?); } else { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index f038d2a4ca1929faee2a02391534539b5b63e2d0..8c158c074bf926d2cee9b77cec65b28c4317a22a 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -66,6 +66,11 @@ pub fn request_prediction_with_zeta( let client = store.client.clone(); let llm_token = store.llm_token.clone(); + let organization_id = store + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); let app_version = AppVersion::global(cx); let request_task = cx.background_spawn({ @@ -201,6 +206,7 @@ pub fn request_prediction_with_zeta( client, None, llm_token, + organization_id, app_version, ) .await?; @@ -219,6 +225,7 @@ pub fn request_prediction_with_zeta( prompt_input.clone(), client, llm_token, + organization_id, app_version, trigger, ) @@ -430,6 +437,11 @@ pub(crate) fn edit_prediction_accepted( let require_auth = custom_accept_url.is_none(); let client = store.client.clone(); let llm_token = store.llm_token.clone(); + let organization_id = store + .user_store + .read(cx) + .current_organization() + .map(|organization| organization.id.clone()); let app_version = AppVersion::global(cx); cx.background_spawn(async move { @@ -454,6 +466,7 @@ pub(crate) fn edit_prediction_accepted( }, client, llm_token, + organization_id, app_version, require_auth, ) diff --git a/crates/http_client/src/async_body.rs b/crates/http_client/src/async_body.rs index 8fb49f218568ea36078d772a7225229f31a916c4..a59a7339db1e4449b875e2c539e98c86b4279365 100644 --- a/crates/http_client/src/async_body.rs +++ b/crates/http_client/src/async_body.rs @@ -7,6 +7,7 @@ use std::{ use bytes::Bytes; use futures::AsyncRead; use http_body::{Body, Frame}; +use serde::Serialize; /// Based on the implementation of AsyncBody in /// . @@ -88,6 +89,19 @@ impl From<&'static str> for AsyncBody { } } +/// Newtype wrapper that serializes a value as JSON into an `AsyncBody`. +pub struct Json(pub T); + +impl From> for AsyncBody { + fn from(json: Json) -> Self { + Self::from_bytes( + serde_json::to_vec(&json.0) + .expect("failed to serialize JSON") + .into(), + ) + } +} + impl> From> for AsyncBody { fn from(body: Option) -> Self { match body { diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 5cf25a8277872ba3c6d502565e8057623b267d42..bbbe3b1a832332bd6bee693b4c0b916b4f4c182a 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -5,7 +5,7 @@ pub mod github; pub mod github_download; pub use anyhow::{Result, anyhow}; -pub use async_body::{AsyncBody, Inner}; +pub use async_body::{AsyncBody, Inner, Json}; use derive_more::Deref; use http::HeaderValue; pub use http::{self, Method, Request, Response, StatusCode, Uri, request::Builder}; diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index 18e099b4d6fc62867bf35fbd1d4573093af44744..b2af80a3c295cab1cf40a330eb8d84f94a137eb7 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use client::Client; use cloud_api_client::ClientApiError; +use cloud_api_types::OrganizationId; use cloud_api_types::websocket_protocol::MessageToClient; use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, OUTDATED_LLM_TOKEN_HEADER_NAME}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _}; @@ -26,29 +27,46 @@ impl fmt::Display for PaymentRequiredError { pub struct LlmApiToken(Arc>>); impl LlmApiToken { - pub async fn acquire(&self, client: &Arc) -> Result { + pub async fn acquire( + &self, + client: &Arc, + organization_id: Option, + ) -> Result { let lock = self.0.upgradable_read().await; if let Some(token) = lock.as_ref() { Ok(token.to_string()) } else { - Self::fetch(RwLockUpgradableReadGuard::upgrade(lock).await, client).await + Self::fetch( + RwLockUpgradableReadGuard::upgrade(lock).await, + client, + organization_id, + ) + .await } } - pub async fn refresh(&self, client: &Arc) -> Result { - Self::fetch(self.0.write().await, client).await + pub async fn refresh( + &self, + client: &Arc, + organization_id: Option, + ) -> Result { + Self::fetch(self.0.write().await, client, organization_id).await } async fn fetch( mut lock: RwLockWriteGuard<'_, Option>, client: &Arc, + organization_id: Option, ) -> Result { let system_id = client .telemetry() .system_id() .map(|system_id| system_id.to_string()); - let result = client.cloud_client().create_llm_token(system_id).await; + let result = client + .cloud_client() + .create_llm_token(system_id, organization_id) + .await; match result { Ok(response) => { *lock = Some(response.token.0.clone()); diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 19009013bf84ad9751e9ed0de2d3338b279a258e..b84b19b038905ba9f3d9a0637c770acc95687976 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -3,7 +3,7 @@ use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use client::{Client, UserStore, zed_urls}; -use cloud_api_types::Plan; +use cloud_api_types::{OrganizationId, Plan}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CLIENT_SUPPORTS_STATUS_STREAM_ENDED_HEADER_NAME, CLIENT_SUPPORTS_X_AI_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, @@ -122,15 +122,25 @@ impl State { recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { maybe!(async move { - let (client, llm_api_token) = this - .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; + let (client, llm_api_token, organization_id) = + this.read_with(cx, |this, cx| { + ( + client.clone(), + this.llm_api_token.clone(), + this.user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()), + ) + })?; while current_user.borrow().is_none() { current_user.next().await; } let response = - Self::fetch_models(client.clone(), llm_api_token.clone()).await?; + Self::fetch_models(client.clone(), llm_api_token.clone(), organization_id) + .await?; this.update(cx, |this, cx| this.update_models(response, cx))?; anyhow::Ok(()) }) @@ -146,9 +156,17 @@ impl State { move |this, _listener, _event, cx| { let client = this.client.clone(); let llm_api_token = this.llm_api_token.clone(); + let organization_id = this + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); cx.spawn(async move |this, cx| { - llm_api_token.refresh(&client).await?; - let response = Self::fetch_models(client, llm_api_token).await?; + llm_api_token + .refresh(&client, organization_id.clone()) + .await?; + let response = + Self::fetch_models(client, llm_api_token, organization_id).await?; this.update(cx, |this, cx| { this.update_models(response, cx); }) @@ -209,9 +227,10 @@ impl State { async fn fetch_models( client: Arc, llm_api_token: LlmApiToken, + organization_id: Option, ) -> Result { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client).await?; + let token = llm_api_token.acquire(&client, organization_id).await?; let request = http_client::Request::builder() .method(Method::GET) @@ -273,11 +292,13 @@ impl CloudLanguageModelProvider { &self, model: Arc, llm_api_token: LlmApiToken, + user_store: Entity, ) -> Arc { Arc::new(CloudLanguageModel { id: LanguageModelId(SharedString::from(model.id.0.clone())), model, llm_api_token, + user_store, client: self.client.clone(), request_limiter: RateLimiter::new(4), }) @@ -306,36 +327,46 @@ impl LanguageModelProvider for CloudLanguageModelProvider { } fn default_model(&self, cx: &App) -> Option> { - let default_model = self.state.read(cx).default_model.clone()?; - let llm_api_token = self.state.read(cx).llm_api_token.clone(); - Some(self.create_language_model(default_model, llm_api_token)) + let state = self.state.read(cx); + let default_model = state.default_model.clone()?; + let llm_api_token = state.llm_api_token.clone(); + let user_store = state.user_store.clone(); + Some(self.create_language_model(default_model, llm_api_token, user_store)) } fn default_fast_model(&self, cx: &App) -> Option> { - let default_fast_model = self.state.read(cx).default_fast_model.clone()?; - let llm_api_token = self.state.read(cx).llm_api_token.clone(); - Some(self.create_language_model(default_fast_model, llm_api_token)) + let state = self.state.read(cx); + let default_fast_model = state.default_fast_model.clone()?; + let llm_api_token = state.llm_api_token.clone(); + let user_store = state.user_store.clone(); + Some(self.create_language_model(default_fast_model, llm_api_token, user_store)) } fn recommended_models(&self, cx: &App) -> Vec> { - let llm_api_token = self.state.read(cx).llm_api_token.clone(); - self.state - .read(cx) + let state = self.state.read(cx); + let llm_api_token = state.llm_api_token.clone(); + let user_store = state.user_store.clone(); + state .recommended_models .iter() .cloned() - .map(|model| self.create_language_model(model, llm_api_token.clone())) + .map(|model| { + self.create_language_model(model, llm_api_token.clone(), user_store.clone()) + }) .collect() } fn provided_models(&self, cx: &App) -> Vec> { - let llm_api_token = self.state.read(cx).llm_api_token.clone(); - self.state - .read(cx) + let state = self.state.read(cx); + let llm_api_token = state.llm_api_token.clone(); + let user_store = state.user_store.clone(); + state .models .iter() .cloned() - .map(|model| self.create_language_model(model, llm_api_token.clone())) + .map(|model| { + self.create_language_model(model, llm_api_token.clone(), user_store.clone()) + }) .collect() } @@ -367,6 +398,7 @@ pub struct CloudLanguageModel { id: LanguageModelId, model: Arc, llm_api_token: LlmApiToken, + user_store: Entity, client: Arc, request_limiter: RateLimiter, } @@ -380,12 +412,15 @@ impl CloudLanguageModel { async fn perform_llm_completion( client: Arc, llm_api_token: LlmApiToken, + organization_id: Option, app_version: Option, body: CompletionBody, ) -> Result { let http_client = &client.http_client(); - let mut token = llm_api_token.acquire(&client).await?; + let mut token = llm_api_token + .acquire(&client, organization_id.clone()) + .await?; let mut refreshed_token = false; loop { @@ -416,7 +451,9 @@ impl CloudLanguageModel { } if !refreshed_token && response.needs_llm_token_refresh() { - token = llm_api_token.refresh(&client).await?; + token = llm_api_token + .refresh(&client, organization_id.clone()) + .await?; refreshed_token = true; continue; } @@ -670,12 +707,17 @@ impl LanguageModel for CloudLanguageModel { cloud_llm_client::LanguageModelProvider::Google => { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); + let organization_id = self + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); let model_id = self.model.id.to_string(); let generate_content_request = into_google(request, model_id.clone(), GoogleModelMode::Default); async move { let http_client = &client.http_client(); - let token = llm_api_token.acquire(&client).await?; + let token = llm_api_token.acquire(&client, organization_id).await?; let request_body = CountTokensBody { provider: cloud_llm_client::LanguageModelProvider::Google, @@ -736,6 +778,13 @@ impl LanguageModel for CloudLanguageModel { let prompt_id = request.prompt_id.clone(); let intent = request.intent; let app_version = Some(cx.update(|cx| AppVersion::global(cx))); + let user_store = self.user_store.clone(); + let organization_id = cx.update(|cx| { + user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()) + }); let thinking_allowed = request.thinking_allowed; let enable_thinking = thinking_allowed && self.model.supports_thinking; let provider_name = provider_name(&self.model.provider); @@ -767,6 +816,7 @@ impl LanguageModel for CloudLanguageModel { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); + let organization_id = organization_id.clone(); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { response, @@ -774,6 +824,7 @@ impl LanguageModel for CloudLanguageModel { } = Self::perform_llm_completion( client.clone(), llm_api_token, + organization_id, app_version, CompletionBody { thread_id, @@ -803,6 +854,7 @@ impl LanguageModel for CloudLanguageModel { cloud_llm_client::LanguageModelProvider::OpenAi => { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); + let organization_id = organization_id.clone(); let effort = request .thinking_effort .as_ref() @@ -828,6 +880,7 @@ impl LanguageModel for CloudLanguageModel { } = Self::perform_llm_completion( client.clone(), llm_api_token, + organization_id, app_version, CompletionBody { thread_id, @@ -861,6 +914,7 @@ impl LanguageModel for CloudLanguageModel { None, ); let llm_api_token = self.llm_api_token.clone(); + let organization_id = organization_id.clone(); let future = self.request_limiter.stream(async move { let PerformLlmCompletionResponse { response, @@ -868,6 +922,7 @@ impl LanguageModel for CloudLanguageModel { } = Self::perform_llm_completion( client.clone(), llm_api_token, + organization_id, app_version, CompletionBody { thread_id, @@ -902,6 +957,7 @@ impl LanguageModel for CloudLanguageModel { } = Self::perform_llm_completion( client.clone(), llm_api_token, + organization_id, app_version, CompletionBody { thread_id, diff --git a/crates/web_search_providers/Cargo.toml b/crates/web_search_providers/Cargo.toml index ecdca5883ff541459e94170986df3b7f16036c5a..ff264edcb150063237c633de746b2f6b9f6f250c 100644 --- a/crates/web_search_providers/Cargo.toml +++ b/crates/web_search_providers/Cargo.toml @@ -14,6 +14,7 @@ path = "src/web_search_providers.rs" [dependencies] anyhow.workspace = true client.workspace = true +cloud_api_types.workspace = true cloud_llm_client.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index 2f3ccdbb52a884471250ad458e8b7922437cb9ae..c8bc89953f2b2d3ec62bac07e80f2737522824f7 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -1,7 +1,8 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; -use client::Client; +use client::{Client, UserStore}; +use cloud_api_types::OrganizationId; use cloud_llm_client::{WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; use gpui::{App, AppContext, Context, Entity, Subscription, Task}; @@ -14,8 +15,8 @@ pub struct CloudWebSearchProvider { } impl CloudWebSearchProvider { - pub fn new(client: Arc, cx: &mut App) -> Self { - let state = cx.new(|cx| State::new(client, cx)); + pub fn new(client: Arc, user_store: Entity, cx: &mut App) -> Self { + let state = cx.new(|cx| State::new(client, user_store, cx)); Self { state } } @@ -23,24 +24,31 @@ impl CloudWebSearchProvider { pub struct State { client: Arc, + user_store: Entity, llm_api_token: LlmApiToken, _llm_token_subscription: Subscription, } impl State { - pub fn new(client: Arc, cx: &mut Context) -> Self { + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); Self { client, + user_store, llm_api_token: LlmApiToken::default(), _llm_token_subscription: cx.subscribe( &refresh_llm_token_listener, |this, _, _event, cx| { let client = this.client.clone(); let llm_api_token = this.llm_api_token.clone(); + let organization_id = this + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); cx.spawn(async move |_this, _cx| { - llm_api_token.refresh(&client).await?; + llm_api_token.refresh(&client, organization_id).await?; anyhow::Ok(()) }) .detach_and_log_err(cx); @@ -61,21 +69,31 @@ impl WebSearchProvider for CloudWebSearchProvider { let state = self.state.read(cx); let client = state.client.clone(); let llm_api_token = state.llm_api_token.clone(); + let organization_id = state + .user_store + .read(cx) + .current_organization() + .map(|o| o.id.clone()); let body = WebSearchBody { query }; - cx.background_spawn(async move { perform_web_search(client, llm_api_token, body).await }) + cx.background_spawn(async move { + perform_web_search(client, llm_api_token, organization_id, body).await + }) } } async fn perform_web_search( client: Arc, llm_api_token: LlmApiToken, + organization_id: Option, body: WebSearchBody, ) -> Result { const MAX_RETRIES: usize = 3; let http_client = &client.http_client(); let mut retries_remaining = MAX_RETRIES; - let mut token = llm_api_token.acquire(&client).await?; + let mut token = llm_api_token + .acquire(&client, organization_id.clone()) + .await?; loop { if retries_remaining == 0 { @@ -100,7 +118,9 @@ async fn perform_web_search( response.body_mut().read_to_string(&mut body).await?; return Ok(serde_json::from_str(&body)?); } else if response.needs_llm_token_refresh() { - token = llm_api_token.refresh(&client).await?; + token = llm_api_token + .refresh(&client, organization_id.clone()) + .await?; retries_remaining -= 1; } else { // For now we will only retry if the LLM token is expired, diff --git a/crates/web_search_providers/src/web_search_providers.rs b/crates/web_search_providers/src/web_search_providers.rs index 8ab0aee47a414c4cc669ab05e727a827d17c2844..509632429fb167cd489cd4253ceae0ce479b10a8 100644 --- a/crates/web_search_providers/src/web_search_providers.rs +++ b/crates/web_search_providers/src/web_search_providers.rs @@ -1,26 +1,28 @@ mod cloud; -use client::Client; +use client::{Client, UserStore}; use gpui::{App, Context, Entity}; use language_model::LanguageModelRegistry; use std::sync::Arc; use web_search::{WebSearchProviderId, WebSearchRegistry}; -pub fn init(client: Arc, cx: &mut App) { +pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let registry = WebSearchRegistry::global(cx); registry.update(cx, |registry, cx| { - register_web_search_providers(registry, client, cx); + register_web_search_providers(registry, client, user_store, cx); }); } fn register_web_search_providers( registry: &mut WebSearchRegistry, client: Arc, + user_store: Entity, cx: &mut Context, ) { register_zed_web_search_provider( registry, client.clone(), + user_store.clone(), &LanguageModelRegistry::global(cx), cx, ); @@ -29,7 +31,13 @@ fn register_web_search_providers( &LanguageModelRegistry::global(cx), move |this, registry, event, cx| { if let language_model::Event::DefaultModelChanged = event { - register_zed_web_search_provider(this, client.clone(), ®istry, cx) + register_zed_web_search_provider( + this, + client.clone(), + user_store.clone(), + ®istry, + cx, + ) } }, ) @@ -39,6 +47,7 @@ fn register_web_search_providers( fn register_zed_web_search_provider( registry: &mut WebSearchRegistry, client: Arc, + user_store: Entity, language_model_registry: &Entity, cx: &mut Context, ) { @@ -47,7 +56,10 @@ fn register_zed_web_search_provider( .default_model() .is_some_and(|default| default.is_provided_by_zed()); if using_zed_provider { - registry.register_provider(cloud::CloudWebSearchProvider::new(client, cx), cx) + registry.register_provider( + cloud::CloudWebSearchProvider::new(client, user_store, cx), + cx, + ) } else { registry.unregister_provider(WebSearchProviderId( cloud::ZED_WEB_SEARCH_PROVIDER_ID.into(), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 38238d8af519c0506ab451bccaa1abe3a893e4c9..a3379a6017b7e3b7c26e2a98346e4926e90e0999 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -645,7 +645,7 @@ fn main() { zed::remote_debug::init(cx); edit_prediction_ui::init(cx); web_search::init(cx); - web_search_providers::init(app_state.client.clone(), cx); + web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx); snippet_provider::init(cx); edit_prediction_registry::init(app_state.client.clone(), app_state.user_store.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 17832bdd1833cabb42af2195f9d9aab1a6bf3fab..20629785c7172241f49a0e7a69f9dcc1953f6a95 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5021,7 +5021,7 @@ mod tests { language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); git_graph::init(cx); - web_search_providers::init(app_state.client.clone(), cx); + web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); project::AgentRegistryStore::init_global( cx, From 9b8ad0176928a324d828294c67b10fab272c2f95 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 4 Mar 2026 16:56:56 +0200 Subject: [PATCH 43/74] ep: Option to configure custom Baseten environment (#50706) Release Notes: - N/A --- crates/edit_prediction/src/edit_prediction.rs | 8 +++++++- crates/edit_prediction/src/zeta.rs | 6 +++++- crates/edit_prediction_cli/src/predict.rs | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 6b2019aa30030b0852f74bc851e2012feac4f0e2..5c7ce045121739f341b84dd87d827878550f4048 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -125,6 +125,7 @@ impl Global for EditPredictionStoreGlobal {} #[derive(Clone)] pub struct Zeta2RawConfig { pub model_id: Option, + pub environment: Option, pub format: ZetaFormat, } @@ -760,7 +761,12 @@ impl EditPredictionStore { let version_str = env::var("ZED_ZETA_FORMAT").ok()?; let format = ZetaFormat::parse(&version_str).ok()?; let model_id = env::var("ZED_ZETA_MODEL").ok(); - Some(Zeta2RawConfig { model_id, format }) + let environment = env::var("ZED_ZETA_ENVIRONMENT").ok(); + Some(Zeta2RawConfig { + model_id, + environment, + format, + }) } pub fn set_edit_prediction_model(&mut self, model: EditPredictionModel) { diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 8c158c074bf926d2cee9b77cec65b28c4317a22a..ccb058e1193eaf2919c286c6e675a907e4af159f 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -186,13 +186,17 @@ pub fn request_prediction_with_zeta( let prompt = format_zeta_prompt(&prompt_input, config.format); let prefill = get_prefill(&prompt_input, config.format); let prompt = format!("{prompt}{prefill}"); + let environment = config + .environment + .clone() + .or_else(|| Some(config.format.to_string().to_lowercase())); let request = RawCompletionRequest { model: config.model_id.clone().unwrap_or_default(), prompt, temperature: None, stop: vec![], max_tokens: Some(2048), - environment: Some(config.format.to_string().to_lowercase()), + environment, }; editable_range_in_excerpt = zeta_prompt::excerpt_range_for_format( diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 8f537dc0817a9cb0b4fd74348ae5e43d4f63beb9..bd89d54ab37521ecb9661b6f1bb0156f30ba1acb 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -148,7 +148,12 @@ pub async fn run_prediction( if let PredictionProvider::Zeta2(format) = provider { if format != ZetaFormat::default() { let model_id = std::env::var("ZED_ZETA_MODEL").ok(); - store.set_zeta2_raw_config(Zeta2RawConfig { model_id, format }); + let environment = std::env::var("ZED_ZETA_ENVIRONMENT").ok(); + store.set_zeta2_raw_config(Zeta2RawConfig { + model_id, + environment, + format, + }); } } }); From 866ec42371b091c3a4d451a8d62182c9e40bdc14 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 4 Mar 2026 10:53:26 -0500 Subject: [PATCH 44/74] Remove deprecated Gemini 3 Pro Preview (#50503) Gemini 3 Pro Preview has been deprecated in favor of Gemini 3.1 Pro. This removes the `Gemini3Pro` variant from the `Model` enum and all associated match arms, updates eval model lists, docs, and test fixtures. A serde alias (`"gemini-3-pro-preview"`) is kept on `Gemini31Pro` so existing user settings gracefully migrate to the replacement model. Closes AI-66 Release Notes: - Removed deprecated Gemini 3 Pro Preview model; existing configurations automatically migrate to Gemini 3.1 Pro. --- .github/ISSUE_TEMPLATE/10_bug_report.yml | 2 +- .github/workflows/run_cron_unit_evals.yml | 2 +- crates/google_ai/src/google_ai.rs | 14 ++------------ crates/language_models/src/provider/open_router.rs | 8 ++++---- crates/ui/src/components/callout.rs | 2 +- docs/src/ai/models.md | 8 ++------ .../xtask/src/tasks/workflows/run_agent_evals.rs | 2 +- 7 files changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index 13e43219dd65a78af4afec479330bbc5fd85fe42..5eb8e8a6299c5189384b6d060e12cd61a2249a3c 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -100,7 +100,7 @@ body: label: (for AI issues) Model provider details placeholder: | - Provider: (Anthropic via ZedPro, Anthropic via API key, Copilot Chat, Mistral, OpenAI, etc.) - - Model Name: (Claude Sonnet 4.5, Gemini 3 Pro, GPT-5) + - Model Name: (Claude Sonnet 4.5, Gemini 3.1 Pro, GPT-5) - Mode: (Agent Panel, Inline Assistant, Terminal Assistant or Text Threads) - Other details (ACPs, MCPs, other settings, etc.): validations: diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index e57b54e4f2249b92630b2d3636ce2316a0814625..2a204a9d40d78bf52f38825b4db060216e348a87 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -16,7 +16,7 @@ jobs: model: - anthropic/claude-sonnet-4-5-latest - anthropic/claude-opus-4-5-latest - - google/gemini-3-pro + - google/gemini-3.1-pro - openai/gpt-5 fail-fast: false steps: diff --git a/crates/google_ai/src/google_ai.rs b/crates/google_ai/src/google_ai.rs index 3a686f97a8825b30a8f02f4149b110c3d1aacb1e..7659be8ab44da35efd16389c4abd0bf99d8cf3a4 100644 --- a/crates/google_ai/src/google_ai.rs +++ b/crates/google_ai/src/google_ai.rs @@ -510,11 +510,9 @@ pub enum Model { alias = "gemini-2.5-pro-preview-06-05" )] Gemini25Pro, - #[serde(rename = "gemini-3-pro-preview")] - Gemini3Pro, #[serde(rename = "gemini-3-flash-preview")] Gemini3Flash, - #[serde(rename = "gemini-3.1-pro-preview")] + #[serde(rename = "gemini-3.1-pro-preview", alias = "gemini-3-pro-preview")] Gemini31Pro, #[serde(rename = "custom")] Custom { @@ -537,7 +535,6 @@ impl Model { Self::Gemini25FlashLite => "gemini-2.5-flash-lite", Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", - Self::Gemini3Pro => "gemini-3-pro-preview", Self::Gemini3Flash => "gemini-3-flash-preview", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, @@ -548,7 +545,6 @@ impl Model { Self::Gemini25FlashLite => "gemini-2.5-flash-lite", Self::Gemini25Flash => "gemini-2.5-flash", Self::Gemini25Pro => "gemini-2.5-pro", - Self::Gemini3Pro => "gemini-3-pro-preview", Self::Gemini3Flash => "gemini-3-flash-preview", Self::Gemini31Pro => "gemini-3.1-pro-preview", Self::Custom { name, .. } => name, @@ -560,7 +556,6 @@ impl Model { Self::Gemini25FlashLite => "Gemini 2.5 Flash-Lite", Self::Gemini25Flash => "Gemini 2.5 Flash", Self::Gemini25Pro => "Gemini 2.5 Pro", - Self::Gemini3Pro => "Gemini 3 Pro", Self::Gemini3Flash => "Gemini 3 Flash", Self::Gemini31Pro => "Gemini 3.1 Pro", Self::Custom { @@ -574,7 +569,6 @@ impl Model { Self::Gemini25FlashLite | Self::Gemini25Flash | Self::Gemini25Pro - | Self::Gemini3Pro | Self::Gemini3Flash | Self::Gemini31Pro => 1_048_576, Self::Custom { max_tokens, .. } => *max_tokens, @@ -586,7 +580,6 @@ impl Model { Model::Gemini25FlashLite | Model::Gemini25Flash | Model::Gemini25Pro - | Model::Gemini3Pro | Model::Gemini3Flash | Model::Gemini31Pro => Some(65_536), Model::Custom { .. } => None, @@ -603,10 +596,7 @@ impl Model { pub fn mode(&self) -> GoogleModelMode { match self { - Self::Gemini25FlashLite - | Self::Gemini25Flash - | Self::Gemini25Pro - | Self::Gemini3Pro => { + Self::Gemini25FlashLite | Self::Gemini25Flash | Self::Gemini25Pro => { GoogleModelMode::Thinking { // By default these models are set to "auto", so we preserve that behavior // but indicate they are capable of thinking mode diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 3e5128fcc5a366b4156afe6b28f3efc7bd697e12..7a74125d606ddc4be56d113fbbf3fa66866fb595 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -889,7 +889,7 @@ mod tests { ResponseStreamEvent { id: Some("response_123".into()), created: 1234567890, - model: "google/gemini-3-pro-preview".into(), + model: "google/gemini-3.1-pro-preview".into(), choices: vec![ChoiceDelta { index: 0, delta: ResponseMessageDelta { @@ -914,7 +914,7 @@ mod tests { ResponseStreamEvent { id: Some("response_123".into()), created: 1234567890, - model: "google/gemini-3-pro-preview".into(), + model: "google/gemini-3.1-pro-preview".into(), choices: vec![ChoiceDelta { index: 0, delta: ResponseMessageDelta { @@ -940,7 +940,7 @@ mod tests { ResponseStreamEvent { id: Some("response_123".into()), created: 1234567890, - model: "google/gemini-3-pro-preview".into(), + model: "google/gemini-3.1-pro-preview".into(), choices: vec![ChoiceDelta { index: 0, delta: ResponseMessageDelta { @@ -967,7 +967,7 @@ mod tests { ResponseStreamEvent { id: Some("response_123".into()), created: 1234567890, - model: "google/gemini-3-pro-preview".into(), + model: "google/gemini-3.1-pro-preview".into(), choices: vec![ChoiceDelta { index: 0, delta: ResponseMessageDelta { diff --git a/crates/ui/src/components/callout.rs b/crates/ui/src/components/callout.rs index 24762ec1765a58259b061194ea31ed7e8721c2a0..23c820cd545adff2985a4116a6efb00c1e731693 100644 --- a/crates/ui/src/components/callout.rs +++ b/crates/ui/src/components/callout.rs @@ -295,7 +295,7 @@ impl Component for Callout { "Error details:", "• Quota exceeded for metric", "• Limit: 0", - "• Model: gemini-3-pro", + "• Model: gemini-3.1-pro", "Please retry in 26.33s.", "Additional details:", "- Request ID: abc123def456", diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index a86b873ef8aff112ceddbe7da000e4350023ec42..bbf41cf66cc4d93b38123c12fadd7a60c119dfef 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -43,10 +43,6 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir | | OpenAI | Cached Input | $0.005 | $0.0055 | | Gemini 3.1 Pro | Google | Input | $2.00 | $2.20 | | | Google | Output | $12.00 | $13.20 | -| Gemini 3.1 Pro | Google | Input | $2.00 | $2.20 | -| | Google | Output | $12.00 | $13.20 | -| Gemini 3 Pro | Google | Input | $2.00 | $2.20 | -| | Google | Output | $12.00 | $13.20 | | Gemini 3 Flash | Google | Input | $0.30 | $0.33 | | | Google | Output | $2.50 | $2.75 | | Grok 4 | X.ai | Input | $3.00 | $3.30 | @@ -70,7 +66,8 @@ As of February 19, 2026, Zed Pro serves newer model versions in place of the ret - Claude Sonnet 4 → Claude Sonnet 4.5 or Claude Sonnet 4.6 - Claude Sonnet 3.7 (retired Feb 19) → Claude Sonnet 4.5 or Claude Sonnet 4.6 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2 Codex -- Gemini 2.5 Pro → Gemini 3 Pro or Gemini 3.1 Pro +- Gemini 2.5 Pro → Gemini 3.1 Pro +- Gemini 3 Pro → Gemini 3.1 Pro - Gemini 2.5 Flash → Gemini 3 Flash ## Usage {#usage} @@ -95,7 +92,6 @@ A context window is the maximum span of text and code an LLM can consider at onc | GPT-5 mini | OpenAI | 400k | | GPT-5 nano | OpenAI | 400k | | Gemini 3.1 Pro | Google | 200k | -| Gemini 3 Pro | Google | 200k | | Gemini 3 Flash | Google | 200k | > Context window limits for hosted Sonnet 4.5/4.6 and Gemini 3.1 Pro/3 Pro/Flash may increase in future releases. diff --git a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs index e83d3a07f079c1f40360f413f3007813dbe552ce..521f419d9b317c42a1106ebe8500ccf0a3f494ec 100644 --- a/tooling/xtask/src/tasks/workflows/run_agent_evals.rs +++ b/tooling/xtask/src/tasks/workflows/run_agent_evals.rs @@ -123,7 +123,7 @@ fn cron_unit_evals() -> NamedJob { const UNIT_EVAL_MODELS: &[&str] = &[ "anthropic/claude-sonnet-4-5-latest", "anthropic/claude-opus-4-5-latest", - "google/gemini-3-pro", + "google/gemini-3.1-pro", "openai/gpt-5", ]; From 489ec6611ea2db9f1366670911ba95e3536b7b2c Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 4 Mar 2026 10:54:11 -0500 Subject: [PATCH 45/74] Bump Zed to v0.228 (#50710) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1ff28fcf52e118830e2100d35a6cdbca6f6f013..02d2026fe828d956bae8d134d7d6acef91c7fec6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21725,7 +21725,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.227.0" +version = "0.228.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c04e10636f9088cf5f12dbda526a4e933a5e37e3..3d9e433d73dac7d79fc008c79b3ab2db5863a8db 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.227.0" +version = "0.228.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From d329961d7c6468b9760307f82b6d3d044ec38e67 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:02:26 +0100 Subject: [PATCH 46/74] workspace: Remove superfluous call dependency (#50713) Closes #50701 Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --- Cargo.lock | 1 - crates/workspace/Cargo.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02d2026fe828d956bae8d134d7d6acef91c7fec6..dabf43599e8a44396935235c773c4609e84f76f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21272,7 +21272,6 @@ dependencies = [ "any_vec", "anyhow", "async-recursion", - "call", "chrono", "client", "clock", diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index dcd0bf640fdf279fb1874ba77307ccbd3c431393..84fd10c8c03e4f7411fc8c813b70255f5e00031d 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -14,7 +14,6 @@ doctest = false [features] test-support = [ - "call/test-support", "client/test-support", "http_client/test-support", "db/test-support", @@ -72,7 +71,6 @@ zed_actions.workspace = true windows.workspace = true [dev-dependencies] -call = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } dap = { workspace = true, features = ["test-support"] } db = { workspace = true, features = ["test-support"] } From 731a80053cdaf95b567a333fd786ed7130ebaa3a Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:44:32 +0530 Subject: [PATCH 47/74] repl: Bump `runtimed` ecosystem packages and add support for V3 Jupyter Notebooks (#49914) - Add support for v3 Jupyter Notebooks ( nbformat 1.2.0 <-> https://github.com/runtimed/runtimed/pull/275 ) - This means that we can now open notebooks like [Signal Processing for Python](https://nbviewer.org/github/unpingco/Python-for-Signal-Processing/tree/master/) and much more. Release Notes: - N/A --- Cargo.lock | 12 ++++++------ Cargo.toml | 6 +++--- crates/repl/src/notebook/notebook_ui.rs | 6 ++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dabf43599e8a44396935235c773c4609e84f76f2..e09d057f706615a58f8762b51fd01965c0c43614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9141,9 +9141,9 @@ dependencies = [ [[package]] name = "jupyter-protocol" -version = "1.2.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c75a69caf8b8e781224badfb76c4a8da4d49856de36ce72ae3cf5d4a1c94e42" +checksum = "4649647741f9794a7a02e3be976f1b248ba28a37dbfc626d5089316fd4fbf4c8" dependencies = [ "async-trait", "bytes 1.11.1", @@ -10785,9 +10785,9 @@ dependencies = [ [[package]] name = "nbformat" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b10a89a2d910233ec3fca4de359b16ebe95e833c8b2162643ef98c6053a0549d" +checksum = "d4983a40792c45e8639f77ef8e4461c55679cbc618f4b9e83830e8c7e79c8383" dependencies = [ "anyhow", "chrono", @@ -14648,9 +14648,9 @@ dependencies = [ [[package]] name = "runtimelib" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d80685459e1e5fa5603182058351ae91c98ca458dfef4e85f0a37be4f7cf1e6c" +checksum = "fa84884e45ed4a1e663120cef3fc11f14d1a2a1933776e1c31599f7bd2dd0c9e" dependencies = [ "async-dispatcher", "async-std", diff --git a/Cargo.toml b/Cargo.toml index 15d39992804b5ed7ad99fadd46e350b1357b17d1..40a81636a4fd558ddae317f051587f09409cb748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -574,7 +574,7 @@ itertools = "0.14.0" json_dotpath = "1.1" jsonschema = "0.37.0" jsonwebtoken = "10.0" -jupyter-protocol = "1.2.0" +jupyter-protocol = "1.4.0" jupyter-websocket-client = "1.0.0" libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } @@ -590,7 +590,7 @@ minidumper = "0.8" moka = { version = "0.12.10", features = ["sync"] } naga = { version = "28.0", features = ["wgsl-in"] } nanoid = "0.4" -nbformat = "1.1.0" +nbformat = "1.2.0" nix = "0.29" num-format = "0.4.4" objc = "0.2" @@ -660,7 +660,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } rsa = "0.9.6" -runtimelib = { version = "1.2.0", default-features = false, features = [ +runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } rust-embed = { version = "8.4", features = ["include-exclude"] } diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 5b8c0746cdf1289ac3c612139fab1819b5596c07..87f18708a1988c70d66dc4cef5355d4cbcb11dba 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -1514,6 +1514,9 @@ impl project::ProjectItem for NotebookItem { nbformat::upgrade_legacy_notebook(legacy_notebook)? } + nbformat::Notebook::V3(v3_notebook) => { + nbformat::upgrade_v3_notebook(v3_notebook)? + } } }; @@ -1791,6 +1794,9 @@ impl Item for NotebookEditor { Ok(nbformat::Notebook::Legacy(legacy_notebook)) => { nbformat::upgrade_legacy_notebook(legacy_notebook)? } + Ok(nbformat::Notebook::V3(v3_notebook)) => { + nbformat::upgrade_v3_notebook(v3_notebook)? + } Err(e) => { anyhow::bail!("Failed to parse notebook: {:?}", e); } From 0394341c814c64711272fca19e4e7fc0370cdf35 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 4 Mar 2026 18:28:48 +0200 Subject: [PATCH 48/74] ep: Collapse whitespace in deltaChrF (#50716) Release Notes: - N/A --- crates/edit_prediction_cli/src/metrics.rs | 50 +++++++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs index fc870c36c9c62f4d74486ddd4b2d35176b00bb5c..1bfd8e542fa3d74b55f091d2ac13aa22883f6a2f 100644 --- a/crates/edit_prediction_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -76,14 +76,21 @@ impl ClassificationMetrics { } enum ChrfWhitespace { + /// Preserve whitespace as-is #[allow(unused)] Unchanged, + + /// Ignore all whitespace differences + #[allow(unused)] Ignore, + + /// Collapse whitespace into single spaces + Collapse, } const CHR_F_CHAR_ORDER: usize = 6; const CHR_F_BETA: f64 = 2.0; -const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore; +const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse; /// Computes a delta-chrF score that compares two sets of edits. /// @@ -196,9 +203,34 @@ fn filter_whitespace_chars(text: &str) -> Vec { match CHR_F_WHITESPACE { ChrfWhitespace::Unchanged => text.chars().collect(), ChrfWhitespace::Ignore => text.chars().filter(|c| !c.is_whitespace()).collect(), + ChrfWhitespace::Collapse => collapse_whitespace(text.chars()), } } +/// Collapse whitespace into single spaces. +/// Newlines and spaces are collapsed separately. +fn collapse_whitespace(chars: impl Iterator) -> Vec { + let mut result = Vec::new(); + let mut last_whitespace = None; + for c in chars { + if c.is_whitespace() && c != '\n' { + if last_whitespace != Some(' ') { + result.push(' '); + last_whitespace = Some(' '); + } + } else if c == '\n' { + if last_whitespace != Some('\n') { + result.push(c); + last_whitespace = Some('\n'); + } + } else { + result.push(c); + last_whitespace = None; + } + } + result +} + /// Extract only the changed regions between two texts, with context for n-gram boundaries. /// /// Returns (original_affected_region, modified_affected_region) as Vec. @@ -269,15 +301,15 @@ fn count_ngrams_from_chars(chars: &[char], n: usize) -> Counts { #[allow(dead_code)] fn chr_f_ngram_counts(text: &str) -> Vec { - // Ignore whitespace. The original chrF implementation skips all - // whitespace. We should consider compressing multiple consecutive - // spaces into one -- this may reflect our task more closely. let text = match CHR_F_WHITESPACE { ChrfWhitespace::Unchanged => text.to_string(), ChrfWhitespace::Ignore => text .chars() .filter(|c| !c.is_whitespace()) .collect::(), + ChrfWhitespace::Collapse => collapse_whitespace(text.chars()) + .into_iter() + .collect::(), }; (1..=CHR_F_CHAR_ORDER) @@ -1175,4 +1207,14 @@ index abc123..def456 100644 assert!(counts.deleted_tokens >= 2); assert!(counts.inserted_tokens >= 2); } + + #[test] + fn test_whitespace_collapse() { + let text = "abc \n\n\n 123"; + let collapsed = collapse_whitespace(text.chars()); + assert_eq!( + collapsed, + vec!['a', 'b', 'c', ' ', '\n', ' ', '1', '2', '3'] + ); + } } From 87bc2aac5cc99e8425e1c29c1af6bb7bc15e280f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 4 Mar 2026 17:36:25 +0100 Subject: [PATCH 49/74] Add support for streaming tool input to more providers (#50682) To test: - [x] Bedrock - [x] Copilot Chat - [x] Deepseek - [x] Open AI - [x] Open Router - [x] Vercel - [x] Vercel AI Gateway - [x] xAI - [x] Mistral Release Notes: - N/A --- crates/agent/src/thread.rs | 12 +- .../language_models/src/provider/bedrock.rs | 23 ++- .../src/provider/copilot_chat.rs | 21 +++ .../language_models/src/provider/deepseek.rs | 21 +++ .../language_models/src/provider/mistral.rs | 21 +++ .../language_models/src/provider/open_ai.rs | 142 +++++++++++++++++- .../src/provider/open_ai_compatible.rs | 4 + .../src/provider/open_router.rs | 21 +++ crates/language_models/src/provider/vercel.rs | 4 + .../src/provider/vercel_ai_gateway.rs | 4 + crates/language_models/src/provider/x_ai.rs | 7 +- crates/x_ai/src/x_ai.rs | 12 ++ 12 files changed, 279 insertions(+), 13 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 616ae414d4d51a384a18460e8339fd07770fa6b9..be87a6a1e1e5ddba8a5d4b3b5bca82168a141840 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2335,20 +2335,18 @@ impl Thread { ) { // Ensure the last message ends in the current tool use let last_message = self.pending_message(); - let push_new_tool_use = last_message.content.last_mut().is_none_or(|content| { + + let has_tool_use = last_message.content.iter_mut().rev().any(|content| { if let AgentMessageContent::ToolUse(last_tool_use) = content { if last_tool_use.id == tool_use.id { *last_tool_use = tool_use.clone(); - false - } else { - true + return true; } - } else { - true } + false }); - if push_new_tool_use { + if !has_tool_use { event_stream.send_tool_call( &tool_use.id, &tool_use.name, diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index bcf8401c1c14ae1a74bb7136141d0b35509cdd40..5b493fdf1087911372d8796cc88f4ad14eef8df0 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -658,6 +658,10 @@ impl LanguageModel for BedrockModel { } } + fn supports_streaming_tools(&self) -> bool { + true + } + fn telemetry_id(&self) -> String { format!("bedrock/{}", self.model.id()) } @@ -1200,8 +1204,25 @@ pub fn map_to_language_model_completion_events( .get_mut(&cb_delta.content_block_index) { tool_use.input_json.push_str(tool_output.input()); + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&tool_use.input_json), + ) { + Some(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: tool_use.id.clone().into(), + name: tool_use.name.clone().into(), + is_input_complete: false, + raw_input: tool_use.input_json.clone(), + input, + thought_signature: None, + }, + ))) + } else { + None + } + } else { + None } - None } Some(ContentBlockDelta::ReasoningContent(thinking)) => match thinking { ReasoningContentBlockDelta::Text(thoughts) => { diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 4363430f865de63ed5fec0d6b40b085d9413fc2a..7d714cd93a2a93dbb9fd02ec4d2b95149bb43330 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -246,6 +246,10 @@ impl LanguageModel for CopilotChatLanguageModel { self.model.supports_tools() } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_images(&self) -> bool { self.model.supports_vision() } @@ -455,6 +459,23 @@ pub fn map_to_language_model_completion_events( entry.thought_signature = Some(thought_signature); } } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: entry.thought_signature.clone(), + }, + ))); + } + } } if let Some(usage) = event.usage { diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 2a9f7322b1fb5d3d1e6713c5a084b83dc2b01ce2..0bf86ef15c91b16dbc496ff732b087fedd0da0a9 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -246,6 +246,10 @@ impl LanguageModel for DeepSeekLanguageModel { true } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool { true } @@ -469,6 +473,23 @@ impl DeepSeekEventMapper { entry.arguments.push_str(&arguments); } } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: None, + }, + ))); + } + } } } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 02d46dcaa7ce7acc76d85c93cad610a7d2489bf0..6af66f4e9a9d257b385c84a6c0c6d989f04c013f 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -280,6 +280,10 @@ impl LanguageModel for MistralLanguageModel { self.model.supports_tools() } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool { self.model.supports_tools() } @@ -629,6 +633,23 @@ impl MistralEventMapper { entry.arguments.push_str(&arguments); } } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: None, + }, + ))); + } + } } } diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 7fb65df0a534c7600f7315fd85d7adda0d66314a..57b3a6b20a9712e7c4d99b3ccfc48719e632da9d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -328,6 +328,10 @@ impl LanguageModel for OpenAiLanguageModel { } } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_thinking(&self) -> bool { self.model.reasoning_effort().is_some() } @@ -824,6 +828,23 @@ impl OpenAiEventMapper { entry.arguments.push_str(&arguments); } } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: None, + }, + ))); + } + } } } } @@ -954,6 +975,20 @@ impl OpenAiResponseEventMapper { ResponsesStreamEvent::FunctionCallArgumentsDelta { item_id, delta, .. } => { if let Some(entry) = self.function_calls_by_item.get_mut(&item_id) { entry.arguments.push_str(&delta); + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + return vec![Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: LanguageModelToolUseId::from(entry.call_id.clone()), + name: entry.name.clone(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: None, + }, + ))]; + } } Vec::new() } @@ -1670,19 +1705,30 @@ mod tests { ]; let mapped = map_response_events(events); + assert_eq!(mapped.len(), 3); + // First event is the partial tool use (from FunctionCallArgumentsDelta) assert!(matches!( mapped[0], + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + is_input_complete: false, + .. + }) + )); + // Second event is the complete tool use (from FunctionCallArgumentsDone) + assert!(matches!( + mapped[1], LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref id, ref name, ref raw_input, + is_input_complete: true, .. }) if id.to_string() == "call_123" && name.as_ref() == "get_weather" && raw_input == "{\"city\":\"Boston\"}" )); assert!(matches!( - mapped[1], + mapped[2], LanguageModelCompletionEvent::Stop(StopReason::ToolUse) )); } @@ -1878,13 +1924,27 @@ mod tests { ]; let mapped = map_response_events(events); + assert_eq!(mapped.len(), 3); + // First event is the partial tool use (from FunctionCallArgumentsDelta) assert!(matches!( mapped[0], - LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { ref raw_input, .. }) - if raw_input == "{\"city\":\"Boston\"}" + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + is_input_complete: false, + .. + }) )); + // Second event is the complete tool use (from the Incomplete response output) assert!(matches!( mapped[1], + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + ref raw_input, + is_input_complete: true, + .. + }) + if raw_input == "{\"city\":\"Boston\"}" + )); + assert!(matches!( + mapped[2], LanguageModelCompletionEvent::Stop(StopReason::MaxTokens) )); } @@ -1976,4 +2036,80 @@ mod tests { LanguageModelCompletionEvent::Stop(StopReason::ToolUse) )); } + + #[test] + fn responses_stream_emits_partial_tool_use_events() { + let events = vec![ + ResponsesStreamEvent::OutputItemAdded { + output_index: 0, + sequence_number: None, + item: ResponseOutputItem::FunctionCall(ResponseFunctionToolCall { + id: Some("item_fn".to_string()), + status: Some("in_progress".to_string()), + name: Some("get_weather".to_string()), + call_id: Some("call_abc".to_string()), + arguments: String::new(), + }), + }, + ResponsesStreamEvent::FunctionCallArgumentsDelta { + item_id: "item_fn".into(), + output_index: 0, + delta: "{\"city\":\"Bos".into(), + sequence_number: None, + }, + ResponsesStreamEvent::FunctionCallArgumentsDelta { + item_id: "item_fn".into(), + output_index: 0, + delta: "ton\"}".into(), + sequence_number: None, + }, + ResponsesStreamEvent::FunctionCallArgumentsDone { + item_id: "item_fn".into(), + output_index: 0, + arguments: "{\"city\":\"Boston\"}".into(), + sequence_number: None, + }, + ResponsesStreamEvent::Completed { + response: ResponseSummary::default(), + }, + ]; + + let mapped = map_response_events(events); + // Two partial events + one complete event + Stop + assert!(mapped.len() >= 3); + + // The last complete ToolUse event should have is_input_complete: true + let complete_tool_use = mapped.iter().find(|e| { + matches!( + e, + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + is_input_complete: true, + .. + }) + ) + }); + assert!( + complete_tool_use.is_some(), + "should have a complete tool use event" + ); + + // All ToolUse events before the final one should have is_input_complete: false + let tool_uses: Vec<_> = mapped + .iter() + .filter(|e| matches!(e, LanguageModelCompletionEvent::ToolUse(_))) + .collect(); + assert!( + tool_uses.len() >= 2, + "should have at least one partial and one complete event" + ); + + let last = tool_uses.last().unwrap(); + assert!(matches!( + last, + LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse { + is_input_complete: true, + .. + }) + )); + } } diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index d47ea26c594ab0abb5c859ed549d43e0ed3f859b..b478bc843c05e01d428561d9c255ef0d2ca97148 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -319,6 +319,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_split_token_display(&self) -> bool { true } diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 7a74125d606ddc4be56d113fbbf3fa66866fb595..e0e56bc1beadd8309a4c1b3c7626efa99c1c6473 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -314,6 +314,10 @@ impl LanguageModel for OpenRouterLanguageModel { self.model.supports_tool_calls() } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_thinking(&self) -> bool { matches!(self.model.mode, OpenRouterModelMode::Thinking { .. }) } @@ -650,6 +654,23 @@ impl OpenRouterEventMapper { entry.thought_signature = Some(signature); } } + + if !entry.id.is_empty() && !entry.name.is_empty() { + if let Ok(input) = serde_json::from_str::( + &partial_json_fixer::fix_json(&entry.arguments), + ) { + events.push(Ok(LanguageModelCompletionEvent::ToolUse( + LanguageModelToolUse { + id: entry.id.clone().into(), + name: entry.name.as_str().into(), + is_input_complete: false, + input, + raw_input: entry.arguments.clone(), + thought_signature: entry.thought_signature.clone(), + }, + ))); + } + } } } diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 3b324e46927f5864d83a5e4b74c46f5e39e8ab3a..b71da5b7db05710ee30115ab54379c9ee4e4c750 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -248,6 +248,10 @@ impl LanguageModel for VercelLanguageModel { true } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 69c54e624b9e7289abaefbe7ab654d73df385b62..78f900de0c94fd3bbbff3962e92d1a8cb9f3e118 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -385,6 +385,10 @@ impl LanguageModel for VercelAiGatewayLanguageModel { } } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_split_token_display(&self) -> bool { true } diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 06564224dea9621d594e5cf3f4a84093f1620446..f1f8bb658f04a91341951d1602af04f858af7bd3 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -257,6 +257,10 @@ impl LanguageModel for XAiLanguageModel { self.model.supports_images() } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto @@ -265,8 +269,7 @@ impl LanguageModel for XAiLanguageModel { } } fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { - let model_id = self.model.id().trim().to_lowercase(); - if model_id.eq(x_ai::Model::Grok4.id()) || model_id.eq(x_ai::Model::GrokCodeFast1.id()) { + if self.model.requires_json_schema_subset() { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs index 072a893a6a8f4fc7fbc8a6f4f5ed43316915b974..1abb2b53771fa1e29e2979560e9f394744b26158 100644 --- a/crates/x_ai/src/x_ai.rs +++ b/crates/x_ai/src/x_ai.rs @@ -165,6 +165,18 @@ impl Model { } } + pub fn requires_json_schema_subset(&self) -> bool { + match self { + Self::Grok4 + | Self::Grok4FastReasoning + | Self::Grok4FastNonReasoning + | Self::Grok41FastNonReasoning + | Self::Grok41FastReasoning + | Self::GrokCodeFast1 => true, + _ => false, + } + } + pub fn supports_prompt_cache_key(&self) -> bool { false } From 68cb60afdd53007496320a54b9ef8da12abfa5d9 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Wed, 4 Mar 2026 17:41:34 +0100 Subject: [PATCH 50/74] Staff-ship streaming edit file tool (#50720) Release Notes: - N/A --- crates/feature_flags/src/flags.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index c8524022d9d8295900638a09c528dfc3fdb85afd..77a98aae05572ac72b239db8bb3d4496bd1c0f4d 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -69,6 +69,6 @@ impl FeatureFlag for StreamingEditFileToolFeatureFlag { const NAME: &'static str = "streaming-edit-file-tool"; fn enabled_for_staff() -> bool { - false + true } } From 83b05f1cbba7ab38d2aaae869ecfc3fef57cfcc0 Mon Sep 17 00:00:00 2001 From: xcb3d <122720156+xcb3d@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:42:14 +0700 Subject: [PATCH 51/74] Fix terminal path click failing when path is prefixed with '0:' (#50663) The path hyperlink regex's middle-char pattern [[:(][^0-9()]](cci:2://file:///d:/zed/crates/fs/src/fs.rs:89:0-157:1) allowed colon+space because space was not in the exclusion set. This caused `0: foo/bar.txt` to be matched as a single path instead of just `foo/bar.txt`. Fix: add space to the exclusion class: [[:(][^0-9()\\ ]](cci:2://file:///d:/zed/crates/fs/src/fs.rs:89:0-157:1) Closes #50531 - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [ ] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) (N/A) Release Notes: - Fixed terminal Ctrl-click path detection failing when path is preceded by a prefix like `0:` (#50531) --- assets/settings/default.json | 4 ++-- crates/terminal/src/terminal_hyperlinks.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index b193c0f60d0087972381f4f85f2b864b52fdbc7d..6593c3b192cb9ac388c67170fe20787bdbcf1bbc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1831,8 +1831,8 @@ " (", " # multi-char path: first char (not opening delimiter, space, or box drawing char)", " [^({\\[<\"'`\\ \\u2500-\\u257F]", - " # middle chars: non-space, and colon/paren only if not followed by digit/paren", - " ([^\\ :(]|[:(][^0-9()])*", + " # middle chars: non-space, and colon/paren only if not followed by digit/paren/space", + " ([^\\ :(]|[:(][^0-9()\\ ])*", " # last char: not closing delimiter or colon", " [^()}\\]>\"'`.,;:\\ ]", " |", diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index d239f680f9e2ecbd3d320e731d3cc74303a552ed..0ca6cb2edd916019a4a7822830faa1fdfaa238f3 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -905,6 +905,18 @@ mod tests { ); } + #[test] + // + fn issue_50531() { + // Paths preceded by "N:" prefix (e.g. grep output line numbers) + // should still be clickable + test_path!("0: ‹«foo/👉bar.txt»›"); + test_path!("0: ‹«👉foo/bar.txt»›"); + test_path!("42: ‹«👉foo/bar.txt»›"); + test_path!("1: ‹«/👉test/cool.rs»›"); + test_path!("1: ‹«/👉test/cool.rs»:«4»:«2»›"); + } + #[test] // fn issue_46795() { From 55ae7b09e68d579b7d7937066941070609d54c96 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 4 Mar 2026 12:25:12 -0500 Subject: [PATCH 52/74] Increase timeout for `test_random_blocks` (#50724) See https://github.com/zed-industries/zed/actions/runs/22679055818 Release Notes: - N/A --- .config/nextest.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.config/nextest.toml b/.config/nextest.toml index ab03abd839600e1a84ebd5eea9709f60cea1c7f0..b18a3f31e4a75af0636b4d8d8fdd81f48d8d93e6 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -42,3 +42,7 @@ slow-timeout = { period = "300s", terminate-after = 1 } [[profile.default.overrides]] filter = 'package(editor) and test(test_random_split_editor)' slow-timeout = { period = "300s", terminate-after = 1 } + +[[profile.default.overrides]] +filter = 'package(editor) and test(test_random_blocks)' +slow-timeout = { period = "300s", terminate-after = 1 } From 74e747a6c77ee7c5a6eb5dd70e4aae23002a2947 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Wed, 4 Mar 2026 09:58:51 -0800 Subject: [PATCH 53/74] repl: Support kernel language aliases in REPL (#49762) Add a `kernel_language_names` field to `LanguageConfig` that allows languages to declare alternative names that Jupyter kernels may use. This fixes REPL matching for cases where a kernel reports a different language identifier than Zed's language name. For example, the Nu extension would set `kernel_language_names = ["nushell", "nu"]` in its config.toml, enabling REPL support for nu-jupyter-kernel which reports `"language": "nushell"` in its kernelspec. The change consolidates kernel language matching logic into a single `Language::matches_kernel_language()` method that checks the code fence block name, language name, and the new aliases list (all case-insensitive). - [x] Done a self-review taking into account security and performance aspects Release Notes: - Added `kernel_language_names` field for extensions to self identify REPL mappings --- crates/language/src/language.rs | 23 +++++++++++++++++++++++ crates/repl/src/repl_editor.rs | 9 +++------ crates/repl/src/repl_store.rs | 9 ++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index fe5c5d09aa0765e2c305d88c65e86d6832443b1e..435d3d4e27998cb135dc3145ad7800ed8da97c9e 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -835,6 +835,11 @@ pub struct LanguageConfig { pub name: LanguageName, /// The name of this language for a Markdown code fence block pub code_fence_block_name: Option>, + /// Alternative language names that Jupyter kernels may report for this language. + /// Used when a kernel's `language` field differs from Zed's language name. + /// For example, the Nu extension would set this to `["nushell"]`. + #[serde(default)] + pub kernel_language_names: Vec>, // The name of the grammar in a WASM bundle (experimental). pub grammar: Option>, /// The criteria for matching this language to a given file. @@ -1141,6 +1146,7 @@ impl Default for LanguageConfig { Self { name: LanguageName::new_static(""), code_fence_block_name: None, + kernel_language_names: Default::default(), grammar: None, matcher: LanguageMatcher::default(), brackets: Default::default(), @@ -2075,6 +2081,23 @@ impl Language { .unwrap_or_else(|| self.config.name.as_ref().to_lowercase().into()) } + pub fn matches_kernel_language(&self, kernel_language: &str) -> bool { + let kernel_language_lower = kernel_language.to_lowercase(); + + if self.code_fence_block_name().to_lowercase() == kernel_language_lower { + return true; + } + + if self.config.name.as_ref().to_lowercase() == kernel_language_lower { + return true; + } + + self.config + .kernel_language_names + .iter() + .any(|name| name.to_lowercase() == kernel_language_lower) + } + pub fn context_provider(&self) -> Option> { self.context_provider.clone() } diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 6e061c3e2e37aa94074f17f94791ad147f56f344..56b79e20ffca74ab3f9f9c7948a7caeffc4ad4ce 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -636,12 +636,9 @@ fn language_supported(language: &Arc, cx: &mut App) -> bool { let store = ReplStore::global(cx); let store_read = store.read(cx); - // Since we're just checking for general language support, we only need to look at - // the pure Jupyter kernels - these are all the globally available ones - store_read.pure_jupyter_kernel_specifications().any(|spec| { - // Convert to lowercase for case-insensitive comparison since kernels might report "python" while our language is "Python" - spec.language().as_ref().to_lowercase() == language.name().as_ref().to_lowercase() - }) + store_read + .pure_jupyter_kernel_specifications() + .any(|spec| language.matches_kernel_language(spec.language().as_ref())) } fn get_language(editor: WeakEntity, cx: &mut App) -> Option> { diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index 1c6ce99c2177260c1b9aaf1733326ddbda85a64f..8da94eaa7fe40e28a1d6336a648d7eae5c6767ae 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -289,7 +289,6 @@ impl ReplStore { } let language_at_cursor = language_at_cursor?; - let language_name = language_at_cursor.code_fence_block_name().to_lowercase(); // Prefer the recommended (active toolchain) kernel if it has ipykernel if let Some(active_path) = self.active_python_toolchain_path(worktree_id) { @@ -297,7 +296,7 @@ impl ReplStore { .kernel_specifications_for_worktree(worktree_id) .find(|spec| { spec.has_ipykernel() - && spec.language().as_ref().to_lowercase() == language_name + && language_at_cursor.matches_kernel_language(spec.language().as_ref()) && spec.path().as_ref() == active_path.as_ref() }) .cloned(); @@ -312,7 +311,7 @@ impl ReplStore { .find(|spec| { matches!(spec, KernelSpecification::PythonEnv(_)) && spec.has_ipykernel() - && spec.language().as_ref().to_lowercase() == language_name + && language_at_cursor.matches_kernel_language(spec.language().as_ref()) }) .cloned(); if python_env.is_some() { @@ -350,10 +349,10 @@ impl ReplStore { return Some(found_by_name); } - let language_name = language_at_cursor.code_fence_block_name().to_lowercase(); self.kernel_specifications_for_worktree(worktree_id) .find(|spec| { - spec.has_ipykernel() && spec.language().as_ref().to_lowercase() == language_name + spec.has_ipykernel() + && language_at_cursor.matches_kernel_language(spec.language().as_ref()) }) .cloned() } From f3e4c152a366123e3abe3fb992998874f27a8ea6 Mon Sep 17 00:00:00 2001 From: Viraj Bhartiya Date: Wed, 4 Mar 2026 23:43:32 +0530 Subject: [PATCH 54/74] project_panel: Fix scrolling in empty area below file list (#50683) Closes #50624 The empty bottom section of the project panel showed a horizontal scrollbar on hover, but scrolling didn't work there. Added a scroll wheel handler to the blank area that forwards scroll events to the uniform list's scroll handle, making both horizontal and vertical scrolling work from anywhere in the panel. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zedindustries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Fixed project panel empty area showing a non-functional scrollbar; scrolling now works from anywhere in the panel --------- Co-authored-by: MrSubidubi --- Cargo.lock | 60 ++++++------- Cargo.toml | 11 ++- .../src/session/running/memory_view.rs | 4 +- crates/gpui/src/elements/div.rs | 12 +-- crates/gpui/src/elements/list.rs | 4 +- crates/gpui/src/elements/svg.rs | 5 +- crates/gpui/src/geometry.rs | 90 +++---------------- crates/miniprofiler_ui/src/miniprofiler_ui.rs | 2 +- crates/project_panel/src/project_panel.rs | 19 ++++ .../terminal_view/src/terminal_scrollbar.rs | 6 +- crates/ui/src/components/scrollbar.rs | 18 ++-- crates/workspace/src/pane.rs | 4 +- 12 files changed, 95 insertions(+), 140 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e09d057f706615a58f8762b51fd01965c0c43614..d1b0a39869a44af1295235214836d446c509c360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,7 +170,7 @@ dependencies = [ "context_server", "ctor", "db", - "derive_more 0.99.20", + "derive_more", "editor", "env_logger 0.11.8", "eval_utils", @@ -242,7 +242,7 @@ dependencies = [ "anyhow", "async-broadcast", "async-trait", - "derive_more 2.0.1", + "derive_more", "futures 0.3.31", "log", "serde", @@ -256,7 +256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" dependencies = [ "anyhow", - "derive_more 2.0.1", + "derive_more", "schemars", "serde", "serde_json", @@ -815,7 +815,7 @@ dependencies = [ "anyhow", "async-trait", "collections", - "derive_more 0.99.20", + "derive_more", "extension", "futures 0.3.31", "gpui", @@ -3002,7 +3002,7 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", - "derive_more 0.99.20", + "derive_more", "feature_flags", "fs", "futures 0.3.31", @@ -3440,7 +3440,7 @@ name = "command_palette_hooks" version = "0.1.0" dependencies = [ "collections", - "derive_more 0.99.20", + "derive_more", "gpui", "workspace", ] @@ -3616,15 +3616,18 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.4.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "convert_case" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -4794,34 +4797,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.106", -] - -[[package]] -name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.106", "unicode-xid", ] @@ -7130,7 +7122,7 @@ version = "0.8.0" source = "git+https://github.com/zed-industries/gh-workflow?rev=c9eac0ed361583e1072860d96776fa52775b82ac#c9eac0ed361583e1072860d96776fa52775b82ac" dependencies = [ "async-trait", - "derive_more 2.0.1", + "derive_more", "derive_setters", "gh-workflow-macros", "indexmap", @@ -7199,7 +7191,7 @@ dependencies = [ "askpass", "async-trait", "collections", - "derive_more 0.99.20", + "derive_more", "futures 0.3.31", "git2", "gpui", @@ -7578,7 +7570,7 @@ dependencies = [ "core-text", "core-video", "ctor", - "derive_more 0.99.20", + "derive_more", "embed-resource", "env_logger 0.11.8", "etagere", @@ -7706,7 +7698,7 @@ dependencies = [ "core-text", "core-video", "ctor", - "derive_more 0.99.20", + "derive_more", "dispatch2", "etagere", "foreign-types 0.5.0", @@ -8264,7 +8256,7 @@ dependencies = [ "async-fs", "async-tar", "bytes 1.11.1", - "derive_more 0.99.20", + "derive_more", "futures 0.3.31", "http 1.3.1", "http-body 1.0.1", @@ -15556,7 +15548,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more 0.99.20", + "derive_more", "gpui", "log", "schemars", @@ -17339,7 +17331,7 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "derive_more 0.99.20", + "derive_more", "fs", "futures 0.3.31", "gpui", diff --git a/Cargo.toml b/Cargo.toml index 40a81636a4fd558ddae317f051587f09409cb748..d88868f9582e34228991847e30aeaeab565933a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -538,7 +538,16 @@ criterion = { version = "0.5", features = ["html_reports"] } ctor = "0.4.0" dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" } dashmap = "6.0" -derive_more = "0.99.17" +derive_more = { version = "2.1.1", features = [ + "add", + "add_assign", + "deref", + "deref_mut", + "from_str", + "mul", + "mul_assign", + "not", +] } dirs = "4.0" documented = "0.9.1" dotenvy = "0.15.0" diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index f10e5179e37f87be0e27985b557fcb63cf089a42..69ea556018fdadeb1e270b1d7c2520d25752e670 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -133,7 +133,7 @@ impl ViewState { fn set_offset(&mut self, point: Point) { if point.y >= -Pixels::ZERO { self.schedule_scroll_up(); - } else if point.y <= -self.scroll_handle.max_offset().height { + } else if point.y <= -self.scroll_handle.max_offset().y { self.schedule_scroll_down(); } self.scroll_handle.set_offset(point); @@ -141,7 +141,7 @@ impl ViewState { } impl ScrollableHandle for ViewStateHandle { - fn max_offset(&self) -> gpui::Size { + fn max_offset(&self) -> gpui::Point { self.0.borrow().scroll_handle.max_offset() } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 2b4a3c84e8111796bf7ce32a4c6ad83854ded6fd..58f11a7fa1fb876ef4b4ef80fedf1948423a24f5 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -1886,18 +1886,18 @@ impl Interactivity { // high for the maximum scroll, we round the scroll max to 2 decimal // places here. let padded_content_size = self.content_size + padding_size; - let scroll_max = (padded_content_size - bounds.size) + let scroll_max = Point::from(padded_content_size - bounds.size) .map(round_to_two_decimals) .max(&Default::default()); // Clamp scroll offset in case scroll max is smaller now (e.g., if children // were removed or the bounds became larger). let mut scroll_offset = scroll_offset.borrow_mut(); - scroll_offset.x = scroll_offset.x.clamp(-scroll_max.width, px(0.)); + scroll_offset.x = scroll_offset.x.clamp(-scroll_max.x, px(0.)); if scroll_to_bottom { - scroll_offset.y = -scroll_max.height; + scroll_offset.y = -scroll_max.y; } else { - scroll_offset.y = scroll_offset.y.clamp(-scroll_max.height, px(0.)); + scroll_offset.y = scroll_offset.y.clamp(-scroll_max.y, px(0.)); } if let Some(mut scroll_handle_state) = tracked_scroll_handle { @@ -3285,7 +3285,7 @@ impl ScrollAnchor { struct ScrollHandleState { offset: Rc>>, bounds: Bounds, - max_offset: Size, + max_offset: Point, child_bounds: Vec>, scroll_to_bottom: bool, overflow: Point, @@ -3329,7 +3329,7 @@ impl ScrollHandle { } /// Get the maximum scroll offset. - pub fn max_offset(&self) -> Size { + pub fn max_offset(&self) -> Point { self.0.borrow().max_offset } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 5403bf10eb9a078dfd113462644636b49d1840e4..92b5389fecf219c0c113f682463498902df4c07d 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -491,7 +491,7 @@ impl ListState { /// Returns the maximum scroll offset according to the items we have measured. /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. - pub fn max_offset_for_scrollbar(&self) -> Size { + pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); let bounds = state.last_layout_bounds.unwrap_or_default(); @@ -499,7 +499,7 @@ impl ListState { .scrollbar_drag_start_height .unwrap_or_else(|| state.items.summary().height); - Size::new(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) } /// Returns the current scroll offset adjusted for the scrollbar diff --git a/crates/gpui/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs index dff389fb93fe7abd2862be70731cc9e6fb613e94..a29b106c0e223b01340ecab27b45fdb94163d207 100644 --- a/crates/gpui/src/elements/svg.rs +++ b/crates/gpui/src/elements/svg.rs @@ -3,8 +3,7 @@ use std::{fs, path::Path, sync::Arc}; use crate::{ App, Asset, Bounds, Element, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size, - StyleRefinement, Styled, TransformationMatrix, Window, geometry::Negate as _, point, px, - radians, size, + StyleRefinement, Styled, TransformationMatrix, Window, point, px, radians, size, }; use gpui_util::ResultExt; @@ -254,7 +253,7 @@ impl Transformation { .translate(center.scale(scale_factor) + self.translate.scale(scale_factor)) .rotate(self.rotate) .scale(self.scale) - .translate(center.scale(scale_factor).negate()) + .translate(center.scale(-scale_factor)) } } diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 73fa9906267412c9f1c840d8403beeef4718119e..76157a06a587ac851d19f19fc5a4ed23c634bab5 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -78,6 +78,7 @@ pub trait Along { Deserialize, JsonSchema, Hash, + Neg, )] #[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[repr(C)] @@ -182,12 +183,6 @@ impl Along for Point { } } -impl Negate for Point { - fn negate(self) -> Self { - self.map(Negate::negate) - } -} - impl Point { /// Scales the point by a given factor, which is typically derived from the resolution /// of a target display to ensure proper sizing of UI elements. @@ -393,7 +388,9 @@ impl Display for Point { /// /// This struct is generic over the type `T`, which can be any type that implements `Clone`, `Default`, and `Debug`. /// It is commonly used to specify dimensions for elements in a UI, such as a window or element. -#[derive(Refineable, Default, Clone, Copy, PartialEq, Div, Hash, Serialize, Deserialize)] +#[derive( + Add, Clone, Copy, Default, Deserialize, Div, Hash, Neg, PartialEq, Refineable, Serialize, Sub, +)] #[refineable(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] #[repr(C)] pub struct Size { @@ -598,34 +595,6 @@ where } } -impl Sub for Size -where - T: Sub + Clone + Debug + Default + PartialEq, -{ - type Output = Size; - - fn sub(self, rhs: Self) -> Self::Output { - Size { - width: self.width - rhs.width, - height: self.height - rhs.height, - } - } -} - -impl Add for Size -where - T: Add + Clone + Debug + Default + PartialEq, -{ - type Output = Size; - - fn add(self, rhs: Self) -> Self::Output { - Size { - width: self.width + rhs.width, - height: self.height + rhs.height, - } - } -} - impl Mul for Size where T: Mul + Clone + Debug + Default + PartialEq, @@ -1245,6 +1214,15 @@ where } } +impl From> for Point { + fn from(size: Size) -> Self { + Self { + x: size.width, + y: size.height, + } + } +} + impl Bounds where T: Add + Clone + Debug + Default + PartialEq, @@ -3754,48 +3732,6 @@ impl Half for Rems { } } -/// Provides a trait for types that can negate their values. -pub trait Negate { - /// Returns the negation of the given value - fn negate(self) -> Self; -} - -impl Negate for i32 { - fn negate(self) -> Self { - -self - } -} - -impl Negate for f32 { - fn negate(self) -> Self { - -self - } -} - -impl Negate for DevicePixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for ScaledPixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for Pixels { - fn negate(self) -> Self { - Self(-self.0) - } -} - -impl Negate for Rems { - fn negate(self) -> Self { - Self(-self.0) - } -} - /// A trait for checking if a value is zero. /// /// This trait provides a method to determine if a value is considered to be zero. diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index 12b2bce77b5866e885483a847d40647f525207e6..9ae0a33471d31f32852b4b376bbc71ff0911c60b 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -464,7 +464,7 @@ impl Render for ProfilerWindow { let scroll_offset = self.scroll_handle.offset(); let max_offset = self.scroll_handle.max_offset(); - self.autoscroll = -scroll_offset.y >= (max_offset.height - px(24.)); + self.autoscroll = -scroll_offset.y >= (max_offset.y - px(24.)); if self.autoscroll { self.scroll_handle.scroll_to_bottom(); } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 0dd19dddde7ab947cfe85a1fd9d96ad7b2d6f23d..082086d6a0a946e610be4c96e50d626b7000bda4 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -46,6 +46,7 @@ use settings::{ update_settings_file, }; use smallvec::SmallVec; +use std::ops::Neg; use std::{any::TypeId, time::Instant}; use std::{ cell::OnceCell, @@ -6691,6 +6692,24 @@ impl Render for ProjectPanel { .id("project-panel-blank-area") .block_mouse_except_scroll() .flex_grow() + .on_scroll_wheel({ + let scroll_handle = self.scroll_handle.clone(); + let entity_id = cx.entity().entity_id(); + move |event, window, cx| { + let state = scroll_handle.0.borrow(); + let base_handle = &state.base_handle; + let current_offset = base_handle.offset(); + let max_offset = base_handle.max_offset(); + let delta = event.delta.pixel_delta(window.line_height()); + let new_offset = (current_offset + delta) + .clamp(&max_offset.neg(), &Point::default()); + + if new_offset != current_offset { + base_handle.set_offset(new_offset); + cx.notify(entity_id); + } + } + }) .when( self.drag_target_entry.as_ref().is_some_and( |entry| match entry { diff --git a/crates/terminal_view/src/terminal_scrollbar.rs b/crates/terminal_view/src/terminal_scrollbar.rs index 82ca0b4097dad1be899879b0241aed50d8e60bfa..16dc580e877310b79501ca469b0351935dbb46f7 100644 --- a/crates/terminal_view/src/terminal_scrollbar.rs +++ b/crates/terminal_view/src/terminal_scrollbar.rs @@ -3,7 +3,7 @@ use std::{ rc::Rc, }; -use gpui::{Bounds, Point, Size, size}; +use gpui::{Bounds, Point, point, size}; use terminal::Terminal; use ui::{Pixels, ScrollableHandle, px}; @@ -46,9 +46,9 @@ impl TerminalScrollHandle { } impl ScrollableHandle for TerminalScrollHandle { - fn max_offset(&self) -> Size { + fn max_offset(&self) -> Point { let state = self.state.borrow(); - size( + point( Pixels::ZERO, state.total_lines.saturating_sub(state.viewport_lines) as f32 * state.line_height, ) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 8e8e89be9c0580a7820685b5690a996dfd2dade0..21d6aa46d0f90a0d48e267e935b00d9f263a30c5 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -9,8 +9,8 @@ use gpui::{ Along, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Negate, - ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, + LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, + Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful, StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration, UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, @@ -258,7 +258,7 @@ impl UniformListDecoration for ScrollbarStateWrapper { _cx: &mut App, ) -> gpui::AnyElement { ScrollbarElement { - origin: scroll_offset.negate(), + origin: -scroll_offset, state: self.0.clone(), } .into_any() @@ -911,7 +911,7 @@ impl ThumbState { } impl ScrollableHandle for UniformListScrollHandle { - fn max_offset(&self) -> Size { + fn max_offset(&self) -> Point { self.0.borrow().base_handle.max_offset() } @@ -929,7 +929,7 @@ impl ScrollableHandle for UniformListScrollHandle { } impl ScrollableHandle for ListState { - fn max_offset(&self) -> Size { + fn max_offset(&self) -> Point { self.max_offset_for_scrollbar() } @@ -955,7 +955,7 @@ impl ScrollableHandle for ListState { } impl ScrollableHandle for ScrollHandle { - fn max_offset(&self) -> Size { + fn max_offset(&self) -> Point { self.max_offset() } @@ -973,7 +973,7 @@ impl ScrollableHandle for ScrollHandle { } pub trait ScrollableHandle: 'static + Any + Sized + Clone { - fn max_offset(&self) -> Size; + fn max_offset(&self) -> Point; fn set_offset(&self, point: Point); fn offset(&self) -> Point; fn viewport(&self) -> Bounds; @@ -984,7 +984,7 @@ pub trait ScrollableHandle: 'static + Any + Sized + Clone { self.max_offset().along(axis) > Pixels::ZERO } fn content_size(&self) -> Size { - self.viewport().size + self.max_offset() + self.viewport().size + self.max_offset().into() } } @@ -1006,7 +1006,7 @@ impl ScrollbarLayout { fn compute_click_offset( &self, event_position: Point, - max_offset: Size, + max_offset: Point, event_type: ScrollbarMouseEvent, ) -> Pixels { let Self { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a39be125a5784b8c9d995bb750b9d7ff57a67191..81283427e83afb820b113250545d90f787030e25 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3450,7 +3450,7 @@ impl Pane { cx, ) .children(pinned_tabs.len().ne(&0).then(|| { - let max_scroll = self.tab_bar_scroll_handle.max_offset().width; + let max_scroll = self.tab_bar_scroll_handle.max_offset().x; // We need to check both because offset returns delta values even when the scroll handle is not scrollable let is_scrolled = self.tab_bar_scroll_handle.offset().x < px(0.); // Avoid flickering when max_offset is very small (< 2px). @@ -7974,7 +7974,7 @@ mod tests { let scroll_handle = pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone()); assert!( - scroll_handle.max_offset().width > px(0.), + scroll_handle.max_offset().x > px(0.), "Test requires tab overflow to verify scrolling. Increase tab count or reduce window width." ); From 4af77fb33d29fac4aa36c6b9e5a9248d9951f2ee Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 4 Mar 2026 19:45:52 +0100 Subject: [PATCH 55/74] docs: Remove outdated reference to simple-completion-language-server (#50732) Closes #46811 Release Notes: - N/A --- docs/src/snippets.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/docs/src/snippets.md b/docs/src/snippets.md index 72cbec7b20ff694304a58a70cd9b142a60fc58a2..9f6b6c880be9edcace23f0e3fd0a02263549776a 100644 --- a/docs/src/snippets.md +++ b/docs/src/snippets.md @@ -42,24 +42,4 @@ To create JSX snippets you have to use `javascript.json` snippets file, instead ## Known Limitations - Only the first prefix is used when a list of prefixes is passed in. -- Currently only the `json` snippet file format is supported, even though the `simple-completion-language-server` supports both `json` and `toml` file formats. - -## See also - -The `feature_paths` option in `simple-completion-language-server` is disabled by default. - -If you want to enable it you can add the following to your `settings.json`: - -```json [settings] -{ - "lsp": { - "snippet-completion-server": { - "settings": { - "feature_paths": true - } - } - } -} -``` - -For more configuration information, see the [`simple-completion-language-server` instructions](https://github.com/zed-industries/simple-completion-language-server/tree/main). +- Currently only the `json` snippet file format is supported. From 5c91ebf1fe9f8716a945a669b2e9ebeb83cb6fbe Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:54:23 +0100 Subject: [PATCH 56/74] git: Move diff num stat calculation to repository snapshot layer (#50645) Follow up on: https://github.com/zed-industries/zed/pull/49519 This PR reworks how Zed calculates diff num stats by moving the calculation to the `RepositorySnapshot` layer, instead of the `GitPanel`. This has a couple of benefits: 1. Snapshot recalculations are already set up to recompute on file system changes and only update the affected files. This means that diff stats don't need to manage their own subscription or states anymore like they did in the original PR. 2. We're able to further separate the data layer from the UI. Before, the git panel owned all the subscriptions and tasks that refreshed the diff stat, now the repository does, which is more inline with the code base. 3. Integration tests are cleaner because `FakeRepository` can handle all the data and calculations of diff stat and make it accessible to more tests in the codebase. Because a lot of tests wouldn't initialize the git panel when they used the git repository. 4. This made implementing remote/collab support for this feature streamline. Remote clients wouldn't get the same buffer events as local clients, so they wouldn't know that the diff stat state has been updated and invalidate their data. 5. File system changes that happened outside of Zed now trigger the diff stat refresh because we're using the `RepositorySnapshot`. I added some integration tests as well to make sure collab support is working this time. Finally, adding the initial diff calculation to `compute_snapshot` didn't affect performance for me when checking against chromium's diff with HEAD~1000. So this should be a safe change to make. I decided to add diff stats on the status entry struct because it made updating changed paths and the collab database much simpler than having two separate SumTrees. Also whenever the UI got a file's status it would check its diff stat as well, so this change makes that code more streamlined as well. Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing. - [x] Done a self-review taking into account security and performance aspects. Release Notes: - N/A --- Cargo.lock | 1 + .../20221109000000_test_schema.sql | 2 + .../migrations/20251208000000_test_schema.sql | 2 + crates/collab/src/db.rs | 2 + crates/collab/src/db/queries/projects.rs | 149 +----------- crates/collab/src/db/queries/rooms.rs | 2 +- .../db/tables/project_repository_statuses.rs | 2 + crates/collab/tests/integration/git_tests.rs | 223 +++++++++++++++++- crates/fs/src/fake_git_repo.rs | 199 +++++++--------- crates/git/src/repository.rs | 63 ++--- crates/git/src/status.rs | 52 ++-- crates/git_ui/src/git_panel.rs | 153 ++++-------- crates/lsp/Cargo.toml | 4 +- crates/lsp/src/lsp.rs | 10 +- crates/project/src/git_store.rs | 196 +++++++-------- .../tests/integration/project_tests.rs | 39 ++- crates/proto/proto/git.proto | 25 +- crates/proto/proto/zed.proto | 5 +- crates/proto/src/proto.rs | 4 - .../remote_server/src/remote_editing_tests.rs | 124 ---------- 20 files changed, 562 insertions(+), 695 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1b0a39869a44af1295235214836d446c509c360..c4ec49e50c3aa75e5e470414da470301e6f77e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10025,6 +10025,7 @@ dependencies = [ "ctor", "futures 0.3.31", "gpui", + "gpui_util", "log", "lsp-types", "parking_lot", diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 71e39fb595656e0dcdc53d97705b87a216ceb0f3..3e4b5c2ce211f68ef7e12895b542db5e6e3ea47c 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -122,6 +122,8 @@ CREATE TABLE "project_repository_statuses" ( "status_kind" INT4 NOT NULL, "first_status" INT4 NULL, "second_status" INT4 NULL, + "lines_added" INT4 NULL, + "lines_deleted" INT4 NULL, "scan_id" INT8 NOT NULL, "is_deleted" BOOL NOT NULL, PRIMARY KEY (project_id, repository_id, repo_path) diff --git a/crates/collab/migrations/20251208000000_test_schema.sql b/crates/collab/migrations/20251208000000_test_schema.sql index 493be3823e25a433d4a6a27a21c508f218dc68d1..0f4e4f2d2e3925ea1e4d2b964c5e4f159f393b4f 100644 --- a/crates/collab/migrations/20251208000000_test_schema.sql +++ b/crates/collab/migrations/20251208000000_test_schema.sql @@ -315,6 +315,8 @@ CREATE TABLE public.project_repository_statuses ( status_kind integer NOT NULL, first_status integer, second_status integer, + lines_added integer, + lines_deleted integer, scan_id bigint NOT NULL, is_deleted boolean NOT NULL ); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 57fb0df86495dc2013e7cd780c2e62e57298bd11..d8803c253f5feef8ef5e040f3ea112abcc688f52 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -732,6 +732,8 @@ fn db_status_to_proto( status: Some(proto::GitFileStatus { variant: Some(variant), }), + diff_stat_added: entry.lines_added.map(|v| v as u32), + diff_stat_deleted: entry.lines_deleted.map(|v| v as u32), }) } diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index fa3f99e1483e8a5d8410378493556b189eff78f1..24cf639a715aa9b88da80375b389debaea0c4295 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -334,147 +334,6 @@ impl Database { .await?; } - // Backward-compatibility for old Zed clients. - // - // Remove this block when Zed 1.80 stable has been out for a week. - { - if !update.updated_repositories.is_empty() { - project_repository::Entity::insert_many( - update.updated_repositories.iter().map(|repository| { - project_repository::ActiveModel { - project_id: ActiveValue::set(project_id), - legacy_worktree_id: ActiveValue::set(Some(worktree_id)), - id: ActiveValue::set(repository.repository_id as i64), - scan_id: ActiveValue::set(update.scan_id as i64), - is_deleted: ActiveValue::set(false), - branch_summary: ActiveValue::Set( - repository - .branch_summary - .as_ref() - .map(|summary| serde_json::to_string(summary).unwrap()), - ), - current_merge_conflicts: ActiveValue::Set(Some( - serde_json::to_string(&repository.current_merge_conflicts) - .unwrap(), - )), - // Old clients do not use abs path, entry ids, head_commit_details, or merge_message. - abs_path: ActiveValue::set(String::new()), - entry_ids: ActiveValue::set("[]".into()), - head_commit_details: ActiveValue::set(None), - merge_message: ActiveValue::set(None), - remote_upstream_url: ActiveValue::set(None), - remote_origin_url: ActiveValue::set(None), - } - }), - ) - .on_conflict( - OnConflict::columns([ - project_repository::Column::ProjectId, - project_repository::Column::Id, - ]) - .update_columns([ - project_repository::Column::ScanId, - project_repository::Column::BranchSummary, - project_repository::Column::CurrentMergeConflicts, - ]) - .to_owned(), - ) - .exec(&*tx) - .await?; - - let has_any_statuses = update - .updated_repositories - .iter() - .any(|repository| !repository.updated_statuses.is_empty()); - - if has_any_statuses { - project_repository_statuses::Entity::insert_many( - update.updated_repositories.iter().flat_map( - |repository: &proto::RepositoryEntry| { - repository.updated_statuses.iter().map(|status_entry| { - let (repo_path, status_kind, first_status, second_status) = - proto_status_to_db(status_entry.clone()); - project_repository_statuses::ActiveModel { - project_id: ActiveValue::set(project_id), - repository_id: ActiveValue::set( - repository.repository_id as i64, - ), - scan_id: ActiveValue::set(update.scan_id as i64), - is_deleted: ActiveValue::set(false), - repo_path: ActiveValue::set(repo_path), - status: ActiveValue::set(0), - status_kind: ActiveValue::set(status_kind), - first_status: ActiveValue::set(first_status), - second_status: ActiveValue::set(second_status), - } - }) - }, - ), - ) - .on_conflict( - OnConflict::columns([ - project_repository_statuses::Column::ProjectId, - project_repository_statuses::Column::RepositoryId, - project_repository_statuses::Column::RepoPath, - ]) - .update_columns([ - project_repository_statuses::Column::ScanId, - project_repository_statuses::Column::StatusKind, - project_repository_statuses::Column::FirstStatus, - project_repository_statuses::Column::SecondStatus, - ]) - .to_owned(), - ) - .exec(&*tx) - .await?; - } - - for repo in &update.updated_repositories { - if !repo.removed_statuses.is_empty() { - project_repository_statuses::Entity::update_many() - .filter( - project_repository_statuses::Column::ProjectId - .eq(project_id) - .and( - project_repository_statuses::Column::RepositoryId - .eq(repo.repository_id), - ) - .and( - project_repository_statuses::Column::RepoPath - .is_in(repo.removed_statuses.iter()), - ), - ) - .set(project_repository_statuses::ActiveModel { - is_deleted: ActiveValue::Set(true), - scan_id: ActiveValue::Set(update.scan_id as i64), - ..Default::default() - }) - .exec(&*tx) - .await?; - } - } - } - - if !update.removed_repositories.is_empty() { - project_repository::Entity::update_many() - .filter( - project_repository::Column::ProjectId - .eq(project_id) - .and(project_repository::Column::LegacyWorktreeId.eq(worktree_id)) - .and(project_repository::Column::Id.is_in( - update.removed_repositories.iter().map(|id| *id as i64), - )), - ) - .set(project_repository::ActiveModel { - is_deleted: ActiveValue::Set(true), - scan_id: ActiveValue::Set(update.scan_id as i64), - ..Default::default() - }) - .exec(&*tx) - .await?; - } - } - let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; Ok(connection_ids) }) @@ -552,6 +411,12 @@ impl Database { status_kind: ActiveValue::set(status_kind), first_status: ActiveValue::set(first_status), second_status: ActiveValue::set(second_status), + lines_added: ActiveValue::set( + status_entry.diff_stat_added.map(|v| v as i32), + ), + lines_deleted: ActiveValue::set( + status_entry.diff_stat_deleted.map(|v| v as i32), + ), } }), ) @@ -566,6 +431,8 @@ impl Database { project_repository_statuses::Column::StatusKind, project_repository_statuses::Column::FirstStatus, project_repository_statuses::Column::SecondStatus, + project_repository_statuses::Column::LinesAdded, + project_repository_statuses::Column::LinesDeleted, ]) .to_owned(), ) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 7c007a570a0cb25c5302495d7342882eec0e1942..b4cbd83167b227542d8de1022b7e2cf49f5a7645 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -738,7 +738,7 @@ impl Database { while let Some(db_status) = db_statuses.next().await { let db_status: project_repository_statuses::Model = db_status?; if db_status.is_deleted { - removed_statuses.push(db_status.repo_path); + removed_statuses.push(db_status.repo_path.clone()); } else { updated_statuses.push(db_status_to_proto(db_status)?); } diff --git a/crates/collab/src/db/tables/project_repository_statuses.rs b/crates/collab/src/db/tables/project_repository_statuses.rs index 7bb903d45085467a3285a58f8afdd7a29339731a..8160d8a03c2a3b4dd0db7675489eeafcef020a9a 100644 --- a/crates/collab/src/db/tables/project_repository_statuses.rs +++ b/crates/collab/src/db/tables/project_repository_statuses.rs @@ -17,6 +17,8 @@ pub struct Model { pub first_status: Option, /// For unmerged entries, this is the `second_head` status. For tracked entries, this is the `worktree_status`. pub second_status: Option, + pub lines_added: Option, + pub lines_deleted: Option, pub scan_id: i64, pub is_deleted: bool, } diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 6e50e41bade5f5dfdf124f5a6d659e81fc2ce0f6..dccc99a07769e66a3eb318a8201d8e14a29ef4f2 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -1,16 +1,40 @@ use std::path::{Path, PathBuf}; use call::ActiveCall; -use git::status::{FileStatus, StatusCode, TrackedStatus}; -use git_ui::project_diff::ProjectDiff; +use collections::HashMap; +use git::{ + repository::RepoPath, + status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, +}; +use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, VisualTestContext}; use project::ProjectPath; use serde_json::json; + use util::{path, rel_path::rel_path}; use workspace::{MultiWorkspace, Workspace}; use crate::TestServer; +fn collect_diff_stats( + panel: &gpui::Entity, + cx: &C, +) -> HashMap { + panel.read_with(cx, |panel, cx| { + let Some(repo) = panel.active_repository() else { + return HashMap::default(); + }; + let snapshot = repo.read(cx).snapshot(); + let mut stats = HashMap::default(); + for entry in snapshot.statuses_by_path.iter() { + if let Some(diff_stat) = entry.diff_stat { + stats.insert(entry.repo_path.clone(), diff_stat); + } + } + stats + }) +} + #[gpui::test] async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.background_executor.clone()).await; @@ -279,3 +303,198 @@ async fn test_remote_git_worktrees( ); assert_eq!(bugfix_worktree.sha.as_ref(), "fake-sha"); } + +#[gpui::test] +async fn test_diff_stat_sync_between_host_and_downstream_client( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + let mut server = TestServer::start(cx_a.background_executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)]) + .await; + + let fs = client_a.fs(); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "line1\nline2\nline3\n", + "new_file.rs": "added1\nadded2\n", + }, + "README.md": "# project 1", + } + }), + ) + .await; + + let dot_git = Path::new(path!("/code/project1/.git")); + fs.set_head_for_repo( + dot_git, + &[ + ("src/lib.rs", "line1\nold_line2\n".into()), + ("src/deleted.rs", "was_here\n".into()), + ], + "deadbeef", + ); + fs.set_index_for_repo( + dot_git, + &[ + ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()), + ("src/staged_only.rs", "x\ny\n".into()), + ("src/new_file.rs", "added1\nadded2\n".into()), + ("README.md", "# project 1".into()), + ], + ); + + let (project_a, worktree_id) = client_a + .build_local_project(path!("/code/project1"), cx_a) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + let _project_c = client_c.join_remote_project(project_id, cx_c).await; + cx_a.run_until_parked(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + + let panel_a = workspace_a.update_in(cx_a, GitPanel::new_test); + workspace_a.update_in(cx_a, |workspace, window, cx| { + workspace.add_panel(panel_a.clone(), window, cx); + }); + + let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test); + workspace_b.update_in(cx_b, |workspace, window, cx| { + workspace.add_panel(panel_b.clone(), window, cx); + }); + + cx_a.run_until_parked(); + + let stats_a = collect_diff_stats(&panel_a, cx_a); + let stats_b = collect_diff_stats(&panel_b, cx_b); + + let mut expected: HashMap = HashMap::default(); + expected.insert( + RepoPath::new("src/lib.rs").unwrap(), + DiffStat { + added: 3, + deleted: 2, + }, + ); + expected.insert( + RepoPath::new("src/deleted.rs").unwrap(), + DiffStat { + added: 0, + deleted: 1, + }, + ); + expected.insert( + RepoPath::new("src/new_file.rs").unwrap(), + DiffStat { + added: 2, + deleted: 0, + }, + ); + expected.insert( + RepoPath::new("README.md").unwrap(), + DiffStat { + added: 1, + deleted: 0, + }, + ); + assert_eq!(stats_a, expected, "host diff stats should match expected"); + assert_eq!(stats_a, stats_b, "host and remote should agree"); + + let buffer_a = project_a + .update(cx_a, |p, cx| { + p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + + let _buffer_b = project_b + .update(cx_b, |p, cx| { + p.open_buffer((worktree_id, rel_path("src/lib.rs")), cx) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + + buffer_a.update(cx_a, |buf, cx| { + buf.edit([(buf.len()..buf.len(), "line4\n")], None, cx); + }); + project_a + .update(cx_a, |project, cx| { + project.save_buffer(buffer_a.clone(), cx) + }) + .await + .unwrap(); + cx_a.run_until_parked(); + + let stats_a = collect_diff_stats(&panel_a, cx_a); + let stats_b = collect_diff_stats(&panel_b, cx_b); + + let mut expected_after_edit = expected.clone(); + expected_after_edit.insert( + RepoPath::new("src/lib.rs").unwrap(), + DiffStat { + added: 4, + deleted: 2, + }, + ); + assert_eq!( + stats_a, expected_after_edit, + "host diff stats should reflect the edit" + ); + assert_eq!( + stats_b, expected_after_edit, + "remote diff stats should reflect the host's edit" + ); + + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |call, cx| call.hang_up(cx)) + .await + .unwrap(); + cx_a.run_until_parked(); + + let user_id_b = client_b.current_user_id(cx_b).to_proto(); + active_call_a + .update(cx_a, |call, cx| call.invite(user_id_b, None, cx)) + .await + .unwrap(); + cx_b.run_until_parked(); + let active_call_b = cx_b.read(ActiveCall::global); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + cx_a.run_until_parked(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + cx_a.run_until_parked(); + + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let panel_b = workspace_b.update_in(cx_b, GitPanel::new_test); + workspace_b.update_in(cx_b, |workspace, window, cx| { + workspace.add_panel(panel_b.clone(), window, cx); + }); + cx_b.run_until_parked(); + + let stats_b = collect_diff_stats(&panel_b, cx_b); + assert_eq!( + stats_b, expected_after_edit, + "remote diff stats should be restored from the database after rejoining the call" + ); +} diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 06ebea9157f97a0323297cd3ae142c4b306fe4ef..85489b6057cd8214ee512fb477428c93cdb32219 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -795,8 +795,8 @@ impl GitRepository for FakeGitRepository { fn diff_stat( &self, - diff_type: git::repository::DiffType, - ) -> BoxFuture<'_, Result>> { + path_prefixes: &[RepoPath], + ) -> BoxFuture<'_, Result> { fn count_lines(s: &str) -> u32 { if s.is_empty() { 0 @@ -805,122 +805,95 @@ impl GitRepository for FakeGitRepository { } } - match diff_type { - git::repository::DiffType::HeadToIndex => self - .with_state_async(false, |state| { - let mut result = HashMap::default(); - let all_paths: HashSet<&RepoPath> = state - .head_contents - .keys() - .chain(state.index_contents.keys()) - .collect(); - for path in all_paths { - let head = state.head_contents.get(path); - let index = state.index_contents.get(path); - match (head, index) { - (Some(old), Some(new)) if old != new => { - result.insert( - path.clone(), - git::status::DiffStat { - added: count_lines(new), - deleted: count_lines(old), - }, - ); - } - (Some(old), None) => { - result.insert( - path.clone(), - git::status::DiffStat { - added: 0, - deleted: count_lines(old), - }, - ); - } - (None, Some(new)) => { - result.insert( - path.clone(), - git::status::DiffStat { - added: count_lines(new), - deleted: 0, - }, - ); - } - _ => {} - } - } - Ok(result) - }) - .boxed(), - git::repository::DiffType::HeadToWorktree => { - let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); - let worktree_files: HashMap = self + fn matches_prefixes(path: &RepoPath, prefixes: &[RepoPath]) -> bool { + if prefixes.is_empty() { + return true; + } + prefixes.iter().any(|prefix| { + let prefix_str = prefix.as_unix_str(); + if prefix_str == "." { + return true; + } + path == prefix || path.starts_with(&prefix) + }) + } + + let path_prefixes = path_prefixes.to_vec(); + + let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); + let worktree_files: HashMap = self + .fs + .files() + .iter() + .filter_map(|path| { + let repo_path = path.strip_prefix(&workdir_path).ok()?; + if repo_path.starts_with(".git") { + return None; + } + let content = self .fs - .files() - .iter() - .filter_map(|path| { - let repo_path = path.strip_prefix(&workdir_path).ok()?; - if repo_path.starts_with(".git") { - return None; - } - let content = self - .fs - .read_file_sync(path) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok())?; - let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; - Some((RepoPath::from_rel_path(&repo_path), content)) - }) - .collect(); + .read_file_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok())?; + let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; + Some((RepoPath::from_rel_path(&repo_path), content)) + }) + .collect(); - self.with_state_async(false, move |state| { - let mut result = HashMap::default(); - let all_paths: HashSet<&RepoPath> = state - .head_contents + self.with_state_async(false, move |state| { + let mut entries = Vec::new(); + let all_paths: HashSet<&RepoPath> = state + .head_contents + .keys() + .chain( + worktree_files .keys() - .chain(worktree_files.keys()) - .collect(); - for path in all_paths { - let head = state.head_contents.get(path); - let worktree = worktree_files.get(path); - match (head, worktree) { - (Some(old), Some(new)) if old != new => { - result.insert( - path.clone(), - git::status::DiffStat { - added: count_lines(new), - deleted: count_lines(old), - }, - ); - } - (Some(old), None) => { - result.insert( - path.clone(), - git::status::DiffStat { - added: 0, - deleted: count_lines(old), - }, - ); - } - (None, Some(new)) => { - result.insert( - path.clone(), - git::status::DiffStat { - added: count_lines(new), - deleted: 0, - }, - ); - } - _ => {} - } + .filter(|p| state.index_contents.contains_key(*p)), + ) + .collect(); + for path in all_paths { + if !matches_prefixes(path, &path_prefixes) { + continue; + } + let head = state.head_contents.get(path); + let worktree = worktree_files.get(path); + match (head, worktree) { + (Some(old), Some(new)) if old != new => { + entries.push(( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: count_lines(old), + }, + )); } - Ok(result) - }) - .boxed() - } - git::repository::DiffType::MergeBase { .. } => { - future::ready(Ok(HashMap::default())).boxed() + (Some(old), None) => { + entries.push(( + path.clone(), + git::status::DiffStat { + added: 0, + deleted: count_lines(old), + }, + )); + } + (None, Some(new)) => { + entries.push(( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: 0, + }, + )); + } + _ => {} + } } - } + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(git::status::GitDiffStat { + entries: entries.into(), + }) + }) + .boxed() } fn checkpoint(&self) -> BoxFuture<'static, Result> { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index f5a856325cc80071f2c8ef500e7b07aa24035f59..c36c70935522836eeea4a83a889109dc807604c8 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -924,8 +924,8 @@ pub trait GitRepository: Send + Sync { fn diff_stat( &self, - diff: DiffType, - ) -> BoxFuture<'_, Result>>; + path_prefixes: &[RepoPath], + ) -> BoxFuture<'_, Result>; /// Creates a checkpoint for the repository. fn checkpoint(&self) -> BoxFuture<'static, Result>; @@ -1997,42 +1997,30 @@ impl GitRepository for RealGitRepository { fn diff_stat( &self, - diff: DiffType, - ) -> BoxFuture<'_, Result>> { + path_prefixes: &[RepoPath], + ) -> BoxFuture<'_, Result> { + let path_prefixes = path_prefixes.to_vec(); let git_binary = self.git_binary(); + self.executor .spawn(async move { - let git = git_binary?; - let output = match diff { - DiffType::HeadToIndex => { - git.build_command(["diff", "--numstat", "--staged"]) - .output() - .await? - } - DiffType::HeadToWorktree => { - git.build_command(["diff", "--numstat"]).output().await? - } - DiffType::MergeBase { base_ref } => { - git.build_command([ - "diff", - "--numstat", - "--merge-base", - base_ref.as_ref(), - "HEAD", - ]) - .output() - .await? - } - }; - - anyhow::ensure!( - output.status.success(), - "Failed to run git diff --numstat:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - Ok(crate::status::parse_numstat(&String::from_utf8_lossy( - &output.stdout, - ))) + let git_binary = git_binary?; + let mut args: Vec = vec![ + "diff".into(), + "--numstat".into(), + "--no-renames".into(), + "HEAD".into(), + ]; + if !path_prefixes.is_empty() { + args.push("--".into()); + args.extend( + path_prefixes + .iter() + .map(|p| p.as_std_path().to_string_lossy().into_owned()), + ); + } + let output = git_binary.run(&args).await?; + Ok(crate::status::parse_numstat(&output)) }) .boxed() } @@ -2942,11 +2930,6 @@ fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { OsString::from("--no-renames"), OsString::from("-z"), ]; - args.extend( - path_prefixes - .iter() - .map(|path_prefix| path_prefix.as_std_path().into()), - ); args.extend(path_prefixes.iter().map(|path_prefix| { if path_prefix.is_empty() { Path::new(".").into() diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index b20919e7ecf4748d0035a003ed5eadebae752dd7..e8b5caec505f7bf65cb4f5cd7d789207ccd8784f 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -586,13 +586,18 @@ pub struct DiffStat { pub deleted: u32, } +#[derive(Clone, Debug)] +pub struct GitDiffStat { + pub entries: Arc<[(RepoPath, DiffStat)]>, +} + /// Parses the output of `git diff --numstat` where output looks like: /// /// ```text /// 24 12 dir/file.txt /// ``` -pub fn parse_numstat(output: &str) -> HashMap { - let mut stats = HashMap::default(); +pub fn parse_numstat(output: &str) -> GitDiffStat { + let mut entries = Vec::new(); for line in output.lines() { let line = line.trim(); if line.is_empty() { @@ -613,10 +618,14 @@ pub fn parse_numstat(output: &str) -> HashMap { let Ok(path) = RepoPath::new(path_str) else { continue; }; - let stat = DiffStat { added, deleted }; - stats.insert(path, stat); + entries.push((path, DiffStat { added, deleted })); + } + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.dedup_by(|(a, _), (b, _)| a == b); + + GitDiffStat { + entries: entries.into(), } - stats } #[cfg(test)] @@ -629,20 +638,25 @@ mod tests { use super::{DiffStat, parse_numstat}; + fn lookup<'a>(entries: &'a [(RepoPath, DiffStat)], path: &str) -> Option<&'a DiffStat> { + let path = RepoPath::new(path).unwrap(); + entries.iter().find(|(p, _)| p == &path).map(|(_, s)| s) + } + #[test] fn test_parse_numstat_normal() { let input = "10\t5\tsrc/main.rs\n3\t1\tREADME.md\n"; let result = parse_numstat(input); - assert_eq!(result.len(), 2); + assert_eq!(result.entries.len(), 2); assert_eq!( - result.get(&RepoPath::new("src/main.rs").unwrap()), + lookup(&result.entries, "src/main.rs"), Some(&DiffStat { added: 10, deleted: 5 }) ); assert_eq!( - result.get(&RepoPath::new("README.md").unwrap()), + lookup(&result.entries, "README.md"), Some(&DiffStat { added: 3, deleted: 1 @@ -655,10 +669,10 @@ mod tests { // git diff --numstat outputs "-\t-\tpath" for binary files let input = "-\t-\timage.png\n5\t2\tsrc/lib.rs\n"; let result = parse_numstat(input); - assert_eq!(result.len(), 1); - assert!(!result.contains_key(&RepoPath::new("image.png").unwrap())); + assert_eq!(result.entries.len(), 1); + assert!(lookup(&result.entries, "image.png").is_none()); assert_eq!( - result.get(&RepoPath::new("src/lib.rs").unwrap()), + lookup(&result.entries, "src/lib.rs"), Some(&DiffStat { added: 5, deleted: 2 @@ -668,18 +682,18 @@ mod tests { #[test] fn test_parse_numstat_empty_input() { - assert!(parse_numstat("").is_empty()); - assert!(parse_numstat("\n\n").is_empty()); - assert!(parse_numstat(" \n \n").is_empty()); + assert!(parse_numstat("").entries.is_empty()); + assert!(parse_numstat("\n\n").entries.is_empty()); + assert!(parse_numstat(" \n \n").entries.is_empty()); } #[test] fn test_parse_numstat_malformed_lines_skipped() { let input = "not_a_number\t5\tfile.rs\n10\t5\tvalid.rs\n"; let result = parse_numstat(input); - assert_eq!(result.len(), 1); + assert_eq!(result.entries.len(), 1); assert_eq!( - result.get(&RepoPath::new("valid.rs").unwrap()), + lookup(&result.entries, "valid.rs"), Some(&DiffStat { added: 10, deleted: 5 @@ -692,9 +706,9 @@ mod tests { // Lines with fewer than 3 tab-separated fields are skipped let input = "10\t5\n7\t3\tok.rs\n"; let result = parse_numstat(input); - assert_eq!(result.len(), 1); + assert_eq!(result.entries.len(), 1); assert_eq!( - result.get(&RepoPath::new("ok.rs").unwrap()), + lookup(&result.entries, "ok.rs"), Some(&DiffStat { added: 7, deleted: 3 @@ -707,7 +721,7 @@ mod tests { let input = "0\t0\tunchanged_but_present.rs\n"; let result = parse_numstat(input); assert_eq!( - result.get(&RepoPath::new("unchanged_but_present.rs").unwrap()), + lookup(&result.entries, "unchanged_but_present.rs"), Some(&DiffStat { added: 0, deleted: 0 diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 1fabc387247e3f0889749463e3aabd89ef0bff42..61d94b68a118525bd9b67217a929ce7462696dc7 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -41,7 +41,7 @@ use gpui::{ WeakEntity, actions, anchored, deferred, point, size, uniform_list, }; use itertools::Itertools; -use language::{Buffer, BufferEvent, File}; +use language::{Buffer, File}; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, }; @@ -51,7 +51,6 @@ use notifications::status_toast::{StatusToast, ToastIcon}; use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button}; use project::{ Fs, Project, ProjectPath, - buffer_store::BufferStoreEvent, git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; @@ -533,6 +532,7 @@ pub struct GitStatusEntry { pub(crate) repo_path: RepoPath, pub(crate) status: FileStatus, pub(crate) staging: StageStatus, + pub(crate) diff_stat: Option, } impl GitStatusEntry { @@ -653,8 +653,7 @@ pub struct GitPanel { local_committer_task: Option>, bulk_staging: Option, stash_entries: GitStash, - diff_stats: HashMap, - diff_stats_task: Task<()>, + _settings_subscription: Subscription, } @@ -723,18 +722,14 @@ impl GitPanel { if tree_view != was_tree_view { this.view_mode = GitPanelViewMode::from_settings(cx); } + + let mut update_entries = false; if sort_by_path != was_sort_by_path || tree_view != was_tree_view { this.bulk_staging.take(); - this.update_visible_entries(window, cx); + update_entries = true; } - if diff_stats != was_diff_stats { - if diff_stats { - this.fetch_diff_stats(cx); - } else { - this.diff_stats.clear(); - this.diff_stats_task = Task::ready(()); - cx.notify(); - } + if (diff_stats != was_diff_stats) || update_entries { + this.update_visible_entries(window, cx); } was_sort_by_path = sort_by_path; was_tree_view = tree_view; @@ -791,33 +786,6 @@ impl GitPanel { ) .detach(); - let buffer_store = project.read(cx).buffer_store().clone(); - - for buffer in project.read(cx).opened_buffers(cx) { - cx.subscribe(&buffer, |this, _buffer, event, cx| { - if matches!(event, BufferEvent::Saved) { - if GitPanelSettings::get_global(cx).diff_stats { - this.fetch_diff_stats(cx); - } - } - }) - .detach(); - } - - cx.subscribe(&buffer_store, |_this, _store, event, cx| { - if let BufferStoreEvent::BufferAdded(buffer) = event { - cx.subscribe(buffer, |this, _buffer, event, cx| { - if matches!(event, BufferEvent::Saved) { - if GitPanelSettings::get_global(cx).diff_stats { - this.fetch_diff_stats(cx); - } - } - }) - .detach(); - } - }) - .detach(); - let mut this = Self { active_repository, commit_editor, @@ -858,8 +826,6 @@ impl GitPanel { entry_count: 0, bulk_staging: None, stash_entries: Default::default(), - diff_stats: HashMap::default(), - diff_stats_task: Task::ready(()), _settings_subscription, }; @@ -3575,6 +3541,7 @@ impl GitPanel { repo_path: entry.repo_path.clone(), status: entry.status, staging, + diff_stat: entry.diff_stat, }; if staging.has_staged() { @@ -3611,6 +3578,7 @@ impl GitPanel { repo_path: ops.repo_path.clone(), status: status.status, staging: StageStatus::Staged, + diff_stat: status.diff_stat, }); } } @@ -3743,60 +3711,9 @@ impl GitPanel { editor.set_placeholder_text(&placeholder_text, window, cx) }); - if GitPanelSettings::get_global(cx).diff_stats { - self.fetch_diff_stats(cx); - } - cx.notify(); } - fn fetch_diff_stats(&mut self, cx: &mut Context) { - let Some(repo) = self.active_repository.clone() else { - self.diff_stats.clear(); - return; - }; - - let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx)); - let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx)); - - self.diff_stats_task = cx.spawn(async move |this, cx| { - let (unstaged_result, staged_result) = - futures::future::join(unstaged_rx, staged_rx).await; - - let mut combined = match unstaged_result { - Ok(Ok(stats)) => stats, - Ok(Err(err)) => { - log::warn!("Failed to fetch unstaged diff stats: {err:?}"); - HashMap::default() - } - Err(_) => HashMap::default(), - }; - - let staged = match staged_result { - Ok(Ok(stats)) => Some(stats), - Ok(Err(err)) => { - log::warn!("Failed to fetch staged diff stats: {err:?}"); - None - } - Err(_) => None, - }; - - if let Some(staged) = staged { - for (path, stat) in staged { - let entry = combined.entry(path).or_default(); - entry.added += stat.added; - entry.deleted += stat.deleted; - } - } - - this.update(cx, |this, cx| { - this.diff_stats = combined; - cx.notify(); - }) - .ok(); - }); - } - fn header_state(&self, header_type: Section) -> ToggleState { let (staged_count, count) = match header_type { Section::New => (self.new_staged_count, self.new_count), @@ -5227,17 +5144,14 @@ impl GitPanel { .active(|s| s.bg(active_bg)) .child(name_row) .when(GitPanelSettings::get_global(cx).diff_stats, |el| { - el.when_some( - self.diff_stats.get(&entry.repo_path).copied(), - move |this, stat| { - let id = format!("diff-stat-{}", id_for_diff_stat); - this.child(ui::DiffStat::new( - id, - stat.added as usize, - stat.deleted as usize, - )) - }, - ) + el.when_some(entry.diff_stat, move |this, stat| { + let id = format!("diff-stat-{}", id_for_diff_stat); + this.child(ui::DiffStat::new( + id, + stat.added as usize, + stat.deleted as usize, + )) + }) }) .child( div() @@ -5629,6 +5543,21 @@ impl GitPanel { } } +#[cfg(any(test, feature = "test-support"))] +impl GitPanel { + pub fn new_test( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + Self::new(workspace, window, cx) + } + + pub fn active_repository(&self) -> Option<&Entity> { + self.active_repository.as_ref() + } +} + impl Render for GitPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let project = self.project.read(cx); @@ -6606,11 +6535,19 @@ mod tests { repo_path: repo_path("crates/gpui/gpui.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), }), GitListEntry::Status(GitStatusEntry { repo_path: repo_path("crates/util/util.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), },), ], ); @@ -6631,11 +6568,19 @@ mod tests { repo_path: repo_path("crates/gpui/gpui.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), }), GitListEntry::Status(GitStatusEntry { repo_path: repo_path("crates/util/util.rs"), status: StatusCode::Modified.worktree(), staging: StageStatus::Unstaged, + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), },), ], ); diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index 9533ddb600b18213de4d6e50599c62aa182b9b8a..2c48575a648a9eba12b16ce8edb2cf959d7cc8b3 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -13,12 +13,13 @@ path = "src/lsp.rs" doctest = false [features] -test-support = ["async-pipe"] +test-support = ["async-pipe", "gpui_util"] [dependencies] anyhow.workspace = true async-pipe = { workspace = true, optional = true } collections.workspace = true +gpui_util = { workspace = true, optional = true } futures.workspace = true gpui.workspace = true log.workspace = true @@ -34,6 +35,7 @@ release_channel.workspace = true [dev-dependencies] async-pipe.workspace = true +gpui_util.workspace = true ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } semver.workspace = true diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index e552c21d701cefa8aa1f4b6e14e826892e3b25b6..2e2318065292ffdc2ac39b577afc7a264d36473d 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1970,10 +1970,14 @@ impl FakeLanguageServer { let responded_tx = responded_tx.clone(); let executor = cx.background_executor().clone(); async move { + let _guard = gpui_util::defer({ + let responded_tx = responded_tx.clone(); + move || { + responded_tx.unbounded_send(()).ok(); + } + }); executor.simulate_random_delay().await; - let result = result.await; - responded_tx.unbounded_send(()).ok(); - result + result.await } }) .detach(); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index b03c7d69ab05daf94254a9d47cb2ae23da3043d1..fdafea73fd0ca797616cc58fc9e4b6a3c2101224 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -24,7 +24,7 @@ use futures::{ mpsc, oneshot::{self, Canceled}, }, - future::{self, Shared}, + future::{self, BoxFuture, Shared}, stream::FuturesOrdered, }; use git::{ @@ -39,8 +39,8 @@ use git::{ }, stash::{GitStash, StashEntry}, status::{ - DiffTreeType, FileStatus, GitSummary, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, - UnmergedStatus, UnmergedStatusCode, + self, DiffStat, DiffTreeType, FileStatus, GitSummary, StatusCode, TrackedStatus, TreeDiff, + TreeDiffStatus, UnmergedStatus, UnmergedStatusCode, }, }; use gpui::{ @@ -195,6 +195,7 @@ pub struct GitStoreCheckpoint { pub struct StatusEntry { pub repo_path: RepoPath, pub status: FileStatus, + pub diff_stat: Option, } impl StatusEntry { @@ -216,6 +217,8 @@ impl StatusEntry { repo_path: self.repo_path.to_proto(), simple_status, status: Some(status_to_proto(self.status)), + diff_stat_added: self.diff_stat.map(|ds| ds.added), + diff_stat_deleted: self.diff_stat.map(|ds| ds.deleted), } } } @@ -226,7 +229,15 @@ impl TryFrom for StatusEntry { fn try_from(value: proto::StatusEntry) -> Result { let repo_path = RepoPath::from_proto(&value.repo_path).context("invalid repo path")?; let status = status_from_proto(value.simple_status, value.status)?; - Ok(Self { repo_path, status }) + let diff_stat = match (value.diff_stat_added, value.diff_stat_deleted) { + (Some(added), Some(deleted)) => Some(DiffStat { added, deleted }), + _ => None, + }; + Ok(Self { + repo_path, + status, + diff_stat, + }) } } @@ -555,7 +566,6 @@ impl GitStore { client.add_entity_request_handler(Self::handle_askpass); client.add_entity_request_handler(Self::handle_check_for_pushed_commits); client.add_entity_request_handler(Self::handle_git_diff); - client.add_entity_request_handler(Self::handle_git_diff_stat); client.add_entity_request_handler(Self::handle_tree_diff); client.add_entity_request_handler(Self::handle_get_blob_content); client.add_entity_request_handler(Self::handle_open_unstaged_diff); @@ -2761,45 +2771,6 @@ impl GitStore { Ok(proto::GitDiffResponse { diff }) } - async fn handle_git_diff_stat( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); - let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; - let diff_type = match envelope.payload.diff_type() { - proto::git_diff_stat::DiffType::HeadToIndex => DiffType::HeadToIndex, - proto::git_diff_stat::DiffType::HeadToWorktree => DiffType::HeadToWorktree, - proto::git_diff_stat::DiffType::MergeBase => { - let base_ref = envelope - .payload - .merge_base_ref - .ok_or_else(|| anyhow!("merge_base_ref is required for MergeBase diff type"))?; - DiffType::MergeBase { - base_ref: base_ref.into(), - } - } - }; - - let stats = repository_handle - .update(&mut cx, |repository_handle, cx| { - repository_handle.diff_stat(diff_type, cx) - }) - .await??; - - let entries = stats - .into_iter() - .map(|(path, stat)| proto::GitDiffStatEntry { - path: path.to_proto(), - added: stat.added, - deleted: stat.deleted, - }) - .collect(); - - Ok(proto::GitDiffStatResponse { entries }) - } - async fn handle_tree_diff( this: Entity, request: TypedEnvelope, @@ -3623,7 +3594,9 @@ impl RepositorySnapshot { current_new_entry = new_statuses.next(); } Ordering::Equal => { - if new_entry.status != old_entry.status { + if new_entry.status != old_entry.status + || new_entry.diff_stat != old_entry.diff_stat + { updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); @@ -3693,6 +3666,12 @@ impl RepositorySnapshot { .cloned() } + pub fn diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.statuses_by_path + .get(&PathKey(path.as_ref().clone()), ()) + .and_then(|entry| entry.diff_stat) + } + pub fn abs_path_to_repo_path(&self, abs_path: &Path) -> Option { Self::abs_path_to_repo_path_inner(&self.work_directory_abs_path, abs_path, self.path_style) } @@ -4193,6 +4172,10 @@ impl Repository { self.snapshot.status() } + pub fn diff_stat_for_path(&self, path: &RepoPath) -> Option { + self.snapshot.diff_stat_for_path(path) + } + pub fn cached_stash(&self) -> GitStash { self.snapshot.stash_entries.clone() } @@ -5884,63 +5867,6 @@ impl Repository { }) } - /// Fetches per-line diff statistics (additions/deletions) via `git diff --numstat`. - pub fn diff_stat( - &mut self, - diff_type: DiffType, - _cx: &App, - ) -> oneshot::Receiver< - Result>, - > { - let id = self.id; - self.send_job(None, move |repo, _cx| async move { - match repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.diff_stat(diff_type).await - } - RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let (proto_diff_type, merge_base_ref) = match &diff_type { - DiffType::HeadToIndex => { - (proto::git_diff_stat::DiffType::HeadToIndex.into(), None) - } - DiffType::HeadToWorktree => { - (proto::git_diff_stat::DiffType::HeadToWorktree.into(), None) - } - DiffType::MergeBase { base_ref } => ( - proto::git_diff_stat::DiffType::MergeBase.into(), - Some(base_ref.to_string()), - ), - }; - let response = client - .request(proto::GitDiffStat { - project_id: project_id.0, - repository_id: id.to_proto(), - diff_type: proto_diff_type, - merge_base_ref, - }) - .await?; - - let stats = response - .entries - .into_iter() - .filter_map(|entry| { - let path = RepoPath::from_proto(&entry.path).log_err()?; - Some(( - path, - git::status::DiffStat { - added: entry.added, - deleted: entry.deleted, - }, - )) - }) - .collect(); - - Ok(stats) - } - } - }) - } - pub fn create_branch( &mut self, branch_name: String, @@ -6165,6 +6091,7 @@ impl Repository { cx.emit(RepositoryEvent::StatusesChanged); } self.snapshot.statuses_by_path.edit(edits, ()); + if update.is_last_update { self.snapshot.scan_id = update.scan_id; } @@ -6479,22 +6406,43 @@ impl Repository { return Ok(()); } + let has_head = prev_snapshot.head_commit.is_some(); + let stash_entries = backend.stash_entries().await?; let changed_path_statuses = cx .background_spawn(async move { let mut changed_paths = changed_paths.into_iter().flatten().collect::>(); - let statuses = backend - .status(&changed_paths.iter().cloned().collect::>()) - .await?; + let changed_paths_vec = changed_paths.iter().cloned().collect::>(); + + let status_task = backend.status(&changed_paths_vec); + let diff_stat_future = if has_head { + backend.diff_stat(&changed_paths_vec) + } else { + future::ready(Ok(status::GitDiffStat { + entries: Arc::default(), + })) + .boxed() + }; + + let (statuses, diff_stats) = + futures::future::try_join(status_task, diff_stat_future).await?; + + let diff_stats: HashMap = + HashMap::from_iter(diff_stats.entries.into_iter().cloned()); + let mut changed_path_statuses = Vec::new(); let prev_statuses = prev_snapshot.statuses_by_path.clone(); let mut cursor = prev_statuses.cursor::(()); for (repo_path, status) in &*statuses.entries { + let current_diff_stat = diff_stats.get(repo_path).copied(); + changed_paths.remove(repo_path); if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left) - && cursor.item().is_some_and(|entry| entry.status == *status) + && cursor.item().is_some_and(|entry| { + entry.status == *status && entry.diff_stat == current_diff_stat + }) { continue; } @@ -6502,6 +6450,7 @@ impl Repository { changed_path_statuses.push(Edit::Insert(StatusEntry { repo_path: repo_path.clone(), status: *status, + diff_stat: current_diff_stat, })); } let mut cursor = prev_statuses.cursor::(()); @@ -6859,11 +6808,31 @@ async fn compute_snapshot( let mut events = Vec::new(); let branches = backend.branches().await?; let branch = branches.into_iter().find(|branch| branch.is_head); - let statuses = backend - .status(&[RepoPath::from_rel_path( + + // Useful when branch is None in detached head state + let head_commit = match backend.head_sha().await { + Some(head_sha) => backend.show(head_sha).await.log_err(), + None => None, + }; + + let diff_stat_future: BoxFuture<'_, Result> = if head_commit.is_some() { + backend.diff_stat(&[]) + } else { + future::ready(Ok(status::GitDiffStat { + entries: Arc::default(), + })) + .boxed() + }; + let (statuses, diff_stats) = futures::future::try_join( + backend.status(&[RepoPath::from_rel_path( &RelPath::new(".".as_ref(), PathStyle::local()).unwrap(), - )]) - .await?; + )]), + diff_stat_future, + ) + .await?; + + let diff_stat_map: HashMap<&RepoPath, DiffStat> = + diff_stats.entries.iter().map(|(p, s)| (p, *s)).collect(); let stash_entries = backend.stash_entries().await?; let mut conflicted_paths = Vec::new(); let statuses_by_path = SumTree::from_iter( @@ -6874,6 +6843,7 @@ async fn compute_snapshot( StatusEntry { repo_path: repo_path.clone(), status: *status, + diff_stat: diff_stat_map.get(repo_path).copied(), } }), (), @@ -6886,12 +6856,6 @@ async fn compute_snapshot( events.push(RepositoryEvent::StatusesChanged) } - // Useful when branch is None in detached head state - let head_commit = match backend.head_sha().await { - Some(head_sha) => backend.show(head_sha).await.log_err(), - None => None, - }; - if branch != prev_snapshot.branch || head_commit != prev_snapshot.head_commit { events.push(RepositoryEvent::BranchChanged); } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index d597377910a2a837e456ac4384b06c333887dfb3..d86b969e61ed173ee314cde6f584f2dbab6859f9 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -31,7 +31,7 @@ use futures::{StreamExt, future}; use git::{ GitHostingProviderRegistry, repository::{RepoPath, repo_path}, - status::{FileStatus, StatusCode, TrackedStatus}, + status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git2::RepositoryInitOptions; use gpui::{ @@ -9253,14 +9253,23 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Modified.worktree(), + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, + diff_stat: None, }, StatusEntry { repo_path: repo_path("d.txt"), status: StatusCode::Deleted.worktree(), + diff_stat: Some(DiffStat { + added: 0, + deleted: 1, + }), }, ] ); @@ -9282,18 +9291,31 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Modified.worktree(), + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, + diff_stat: None, }, StatusEntry { repo_path: repo_path("c.txt"), status: StatusCode::Modified.worktree(), + diff_stat: Some(DiffStat { + added: 1, + deleted: 1, + }), }, StatusEntry { repo_path: repo_path("d.txt"), status: StatusCode::Deleted.worktree(), + diff_stat: Some(DiffStat { + added: 0, + deleted: 1, + }), }, ] ); @@ -9327,6 +9349,10 @@ async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { [StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Deleted.worktree(), + diff_stat: Some(DiffStat { + added: 0, + deleted: 1, + }), }] ); }); @@ -9391,6 +9417,7 @@ async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) { worktree_status: StatusCode::Added } .into(), + diff_stat: None, }] ) }); @@ -9593,6 +9620,10 @@ async fn test_repository_pending_ops_staging( worktree_status: StatusCode::Unmodified } .into(), + diff_stat: Some(DiffStat { + added: 1, + deleted: 0, + }), }] ); }); @@ -9699,6 +9730,10 @@ async fn test_repository_pending_ops_long_running_staging( worktree_status: StatusCode::Unmodified } .into(), + diff_stat: Some(DiffStat { + added: 1, + deleted: 0, + }), }] ); }); @@ -9823,10 +9858,12 @@ async fn test_repository_pending_ops_stage_all( StatusEntry { repo_path: repo_path("a.txt"), status: FileStatus::Untracked, + diff_stat: None, }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, + diff_stat: None, }, ] ); diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 6cb3acfcd878c8f970c4e99789939424a3835709..736abcdaa49f62d72582750a8a28ea785baee282 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -229,29 +229,6 @@ message GitDiffResponse { string diff = 1; } -message GitDiffStat { - uint64 project_id = 1; - uint64 repository_id = 2; - DiffType diff_type = 3; - optional string merge_base_ref = 4; - - enum DiffType { - HEAD_TO_WORKTREE = 0; - HEAD_TO_INDEX = 1; - MERGE_BASE = 2; - } -} - -message GitDiffStatResponse { - repeated GitDiffStatEntry entries = 1; -} - -message GitDiffStatEntry { - string path = 1; - uint32 added = 2; - uint32 deleted = 3; -} - message GitInit { uint64 project_id = 1; string abs_path = 2; @@ -360,6 +337,8 @@ message StatusEntry { // Can be removed once collab's min version is >=0.171.0. GitStatus simple_status = 2; GitFileStatus status = 3; + optional uint32 diff_stat_added = 4; + optional uint32 diff_stat_deleted = 5; } message StashEntry { diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index d6139f5342d153221d13917e26565a4c0eb5a707..c129b6eff26404b66b38439c29f0b83289b37172 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -474,9 +474,7 @@ message Envelope { SpawnKernel spawn_kernel = 426; SpawnKernelResponse spawn_kernel_response = 427; - KillKernel kill_kernel = 428; - GitDiffStat git_diff_stat = 429; - GitDiffStatResponse git_diff_stat_response = 430; // current max + KillKernel kill_kernel = 428; // current max } reserved 87 to 88; @@ -501,6 +499,7 @@ message Envelope { reserved 280 to 281; reserved 332 to 333; reserved 394 to 396; + reserved 429 to 430; } message Hello { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3d30551557000c305a82b328828b566c9d78f75e..dd0a77beb29345021563b21bafd261d02b87e1ab 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -322,8 +322,6 @@ messages!( (CheckForPushedCommitsResponse, Background), (GitDiff, Background), (GitDiffResponse, Background), - (GitDiffStat, Background), - (GitDiffStatResponse, Background), (GitInit, Background), (GetDebugAdapterBinary, Background), (DebugAdapterBinary, Background), @@ -541,7 +539,6 @@ request_messages!( (GitRenameBranch, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), - (GitDiffStat, GitDiffStatResponse), (GitInit, Ack), (ToggleBreakpoint, Ack), (GetDebugAdapterBinary, DebugAdapterBinary), @@ -730,7 +727,6 @@ entity_messages!( GitRemoveRemote, CheckForPushedCommits, GitDiff, - GitDiffStat, GitInit, BreakpointsForFile, ToggleBreakpoint, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 9b9fe9948ace530d7e55d2843952ca5c9efb3749..7f9953c8a4e746d9586b663330badb38149cfb64 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -6,7 +6,6 @@ use agent::{AgentTool, ReadFileTool, ReadFileToolInput, ToolCallEventStream, Too use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; -use git::repository::DiffType; use language_model::LanguageModelToolResultContent; use extension::ExtensionHostProxy; @@ -1917,129 +1916,6 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA assert_eq!(server_branch.name(), "totally-new-branch"); } -#[gpui::test] -async fn test_remote_git_diff_stat(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { - let fs = FakeFs::new(server_cx.executor()); - fs.insert_tree( - path!("/code"), - json!({ - "project1": { - ".git": {}, - "src": { - "lib.rs": "line1\nline2\nline3\n", - "new_file.rs": "added1\nadded2\n", - }, - "README.md": "# project 1", - }, - }), - ) - .await; - - let dot_git = Path::new(path!("/code/project1/.git")); - - // HEAD: lib.rs (2 lines), deleted.rs (1 line) - fs.set_head_for_repo( - dot_git, - &[ - ("src/lib.rs", "line1\nold_line2\n".into()), - ("src/deleted.rs", "was_here\n".into()), - ], - "deadbeef", - ); - // Index: lib.rs modified (4 lines), staged_only.rs new (2 lines) - fs.set_index_for_repo( - dot_git, - &[ - ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()), - ("src/staged_only.rs", "x\ny\n".into()), - ], - ); - - let (project, _headless) = init_test(&fs, cx, server_cx).await; - let (_worktree, _) = project - .update(cx, |project, cx| { - project.find_or_create_worktree(path!("/code/project1"), true, cx) - }) - .await - .unwrap(); - cx.run_until_parked(); - - let repo_path = |s: &str| git::repository::RepoPath::new(s).unwrap(); - - let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap()); - - // --- HeadToWorktree --- - let stats = cx - .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx))) - .await - .unwrap() - .unwrap(); - - // src/lib.rs: worktree 3 lines vs HEAD 2 lines - let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs"); - assert_eq!((stat.added, stat.deleted), (3, 2)); - - // src/new_file.rs: only in worktree (2 lines) - let stat = stats - .get(&repo_path("src/new_file.rs")) - .expect("src/new_file.rs"); - assert_eq!((stat.added, stat.deleted), (2, 0)); - - // src/deleted.rs: only in HEAD (1 line) - let stat = stats - .get(&repo_path("src/deleted.rs")) - .expect("src/deleted.rs"); - assert_eq!((stat.added, stat.deleted), (0, 1)); - - // README.md: only in worktree (1 line) - let stat = stats.get(&repo_path("README.md")).expect("README.md"); - assert_eq!((stat.added, stat.deleted), (1, 0)); - - // --- HeadToIndex --- - let stats = cx - .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx))) - .await - .unwrap() - .unwrap(); - - // src/lib.rs: index 4 lines vs HEAD 2 lines - let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs"); - assert_eq!((stat.added, stat.deleted), (4, 2)); - - // src/staged_only.rs: only in index (2 lines) - let stat = stats - .get(&repo_path("src/staged_only.rs")) - .expect("src/staged_only.rs"); - assert_eq!((stat.added, stat.deleted), (2, 0)); - - // src/deleted.rs: in HEAD but not in index - let stat = stats - .get(&repo_path("src/deleted.rs")) - .expect("src/deleted.rs"); - assert_eq!((stat.added, stat.deleted), (0, 1)); - - // --- MergeBase (not implemented in FakeGitRepository) --- - let stats = cx - .update(|cx| { - repository.update(cx, |repo, cx| { - repo.diff_stat( - DiffType::MergeBase { - base_ref: "main".into(), - }, - cx, - ) - }) - }) - .await - .unwrap() - .unwrap(); - - assert!( - stats.is_empty(), - "MergeBase diff_stat should return empty from FakeGitRepository" - ); -} - #[gpui::test] async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); From 0fc5bc2e89389681cede09780fac1d5fa5155f07 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 4 Mar 2026 12:55:20 -0700 Subject: [PATCH 57/74] debugger: Reverse Python repr escaping (#50554) Closes #37168 Authored-By: @ngauder Release Notes: - debugger: Unescape Python strings Co-authored-by: Nikolas Gauder --- crates/project/src/debugger/session.rs | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index 2430d6c1024c61bb9af984c914df9c308c4cb64f..a6c3f52b17a4a6cf241aa49329f3f14f0b5cefbc 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -2645,10 +2645,40 @@ impl Session { self.fetch( command, move |this, variables, cx| { - let Some(variables) = variables.log_err() else { + let Some(mut variables) = variables.log_err() else { return; }; + if this.adapter.0.as_ref() == "Debugpy" { + for variable in variables.iter_mut() { + if variable.type_ == Some("str".into()) { + // reverse Python repr() escaping + let mut unescaped = String::with_capacity(variable.value.len()); + let mut chars = variable.value.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + unescaped.push(c); + } else { + match chars.next() { + Some('\\') => unescaped.push('\\'), + Some('n') => unescaped.push('\n'), + Some('t') => unescaped.push('\t'), + Some('r') => unescaped.push('\r'), + Some('\'') => unescaped.push('\''), + Some('"') => unescaped.push('"'), + Some(c) => { + unescaped.push('\\'); + unescaped.push(c); + } + None => {} + } + } + } + variable.value = unescaped; + } + } + } + this.active_snapshot .variables .insert(variables_reference, variables); From 9316c4aa555d520c91aed490f20f4235797d3504 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 4 Mar 2026 13:04:48 -0700 Subject: [PATCH 58/74] Fix crash metrics ID (#50728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change the crash handler uploaded crashes before sign-in had happened. Now we get the metrics_id correctly. This allows for us to tie crashes reported on Github to users who have opted into telemetry (users who have opted into crash reporting but not telemetry will not have a metrics_id). Release Notes: - Fixed crash reporter metadata collection --------- Co-authored-by: Miguel Raz Guzmán Macedo --- Cargo.lock | 3 +- crates/crashes/Cargo.toml | 3 +- crates/crashes/src/crashes.rs | 172 ++++++++++++++++++++-------------- crates/zed/Cargo.toml | 5 +- crates/zed/src/main.rs | 21 ++++- crates/zed/src/reliability.rs | 21 +++-- crates/zed/src/zed.rs | 11 +-- 7 files changed, 142 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4ec49e50c3aa75e5e470414da470301e6f77e04..d9c5c69e2d11d9e8f6d5cadd35ec342ebe202e57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4126,13 +4126,13 @@ dependencies = [ name = "crashes" version = "0.1.0" dependencies = [ - "bincode", "cfg-if", "crash-handler", "futures 0.3.31", "log", "mach2 0.5.0", "minidumper", + "parking_lot", "paths", "release_channel", "serde", @@ -21735,7 +21735,6 @@ dependencies = [ "audio", "auto_update", "auto_update_ui", - "bincode", "breadcrumbs", "call", "channel", diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 5e451853a925d86ffcc1491a5c95af1f94e6ed05..2c13dc83c5a88c3504da6f8be48c1d75c8e43652 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -6,13 +6,12 @@ edition.workspace = true license = "GPL-3.0-or-later" [dependencies] -bincode.workspace = true cfg-if.workspace = true crash-handler.workspace = true futures.workspace = true log.workspace = true minidumper.workspace = true - +parking_lot.workspace = true paths.workspace = true release_channel.workspace = true smol.workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index a1a43dbb88198b7afd4b89141f7578c0a5bc25ce..0c848d759cd444f3eb6e2a9838d3005254a25b19 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -2,12 +2,14 @@ use crash_handler::{CrashEventResult, CrashHandler}; use futures::future::BoxFuture; use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; +use parking_lot::Mutex; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; use std::mem; #[cfg(not(target_os = "windows"))] use smol::process::Command; +use system_specs::GpuSpecs; #[cfg(target_os = "macos")] use std::sync::atomic::AtomicU32; @@ -27,12 +29,14 @@ use std::{ }; // set once the crash handler has initialized and the client has connected to it -pub static CRASH_HANDLER: OnceLock> = OnceLock::new(); +static CRASH_HANDLER: OnceLock> = OnceLock::new(); // set when the first minidump request is made to avoid generating duplicate crash reports pub static REQUESTED_MINIDUMP: AtomicBool = AtomicBool::new(false); const CRASH_HANDLER_PING_TIMEOUT: Duration = Duration::from_secs(60); const CRASH_HANDLER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +static PENDING_CRASH_SERVER_MESSAGES: Mutex> = Mutex::new(Vec::new()); + #[cfg(target_os = "macos")] static PANIC_THREAD_ID: AtomicU32 = AtomicU32::new(0); @@ -118,6 +122,7 @@ async fn connect_and_keepalive(crash_init: InitCrashHandler, handler: CrashHandl spawn_crash_handler_windows(&exe, &socket_name); info!("spawning crash handler process"); + send_crash_server_message(CrashServerMessage::Init(crash_init)); let mut elapsed = Duration::ZERO; let retry_frequency = Duration::from_millis(100); @@ -134,10 +139,6 @@ async fn connect_and_keepalive(crash_init: InitCrashHandler, handler: CrashHandl smol::Timer::after(retry_frequency).await; } let client = maybe_client.unwrap(); - client - .send_message(1, serde_json::to_vec(&crash_init).unwrap()) - .unwrap(); - let client = Arc::new(client); #[cfg(target_os = "linux")] @@ -146,6 +147,10 @@ async fn connect_and_keepalive(crash_init: InitCrashHandler, handler: CrashHandl // Publishing the client to the OnceLock makes it visible to the signal // handler callback installed earlier. CRASH_HANDLER.set(client.clone()).ok(); + let messages: Vec<_> = mem::take(PENDING_CRASH_SERVER_MESSAGES.lock().as_mut()); + for message in messages.into_iter() { + send_crash_server_message(message); + } // mem::forget so that the drop is not called mem::forget(handler); info!("crash handler registered"); @@ -177,9 +182,10 @@ unsafe fn suspend_all_other_threads() { } pub struct CrashServer { - initialization_params: OnceLock, - panic_info: OnceLock, - active_gpu: OnceLock, + initialization_params: Mutex>, + panic_info: Mutex>, + active_gpu: Mutex>, + user_info: Mutex>, has_connection: Arc, } @@ -190,6 +196,7 @@ pub struct CrashInfo { pub minidump_error: Option, pub gpus: Vec, pub active_gpu: Option, + pub user_info: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -207,15 +214,55 @@ pub struct CrashPanic { pub span: String, } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct UserInfo { + pub metrics_id: Option, + pub is_staff: Option, +} + +fn send_crash_server_message(message: CrashServerMessage) { + let Some(crash_server) = CRASH_HANDLER.get() else { + PENDING_CRASH_SERVER_MESSAGES.lock().push(message); + return; + }; + let data = match serde_json::to_vec(&message) { + Ok(data) => data, + Err(err) => { + log::warn!("Failed to serialize crash server message: {:?}", err); + return; + } + }; + + if let Err(err) = crash_server.send_message(0, data) { + log::warn!("Failed to send data to crash server {:?}", err); + } +} + +pub fn set_gpu_info(specs: GpuSpecs) { + send_crash_server_message(CrashServerMessage::GPUInfo(specs)); +} + +pub fn set_user_info(info: UserInfo) { + send_crash_server_message(CrashServerMessage::UserInfo(info)); +} + +#[derive(Serialize, Deserialize, Debug)] +enum CrashServerMessage { + Init(InitCrashHandler), + Panic(CrashPanic), + GPUInfo(GpuSpecs), + UserInfo(UserInfo), +} + impl minidumper::ServerHandler for CrashServer { fn create_minidump_file(&self) -> Result<(File, PathBuf), io::Error> { - let err_message = "Missing initialization data"; let dump_path = paths::logs_dir() .join( &self .initialization_params - .get() - .expect(err_message) + .lock() + .as_ref() + .expect("Missing initialization data") .session_id, ) .with_extension("dmp"); @@ -255,13 +302,14 @@ impl minidumper::ServerHandler for CrashServer { let crash_info = CrashInfo { init: self .initialization_params - .get() - .expect("not initialized") - .clone(), - panic: self.panic_info.get().cloned(), + .lock() + .clone() + .expect("not initialized"), + panic: self.panic_info.lock().clone(), minidump_error, - active_gpu: self.active_gpu.get().cloned(), + active_gpu: self.active_gpu.lock().clone(), gpus, + user_info: self.user_info.lock().clone(), }; let crash_data_path = paths::logs_dir() @@ -273,30 +321,21 @@ impl minidumper::ServerHandler for CrashServer { LoopAction::Exit } - fn on_message(&self, kind: u32, buffer: Vec) { - match kind { - 1 => { - let init_data = - serde_json::from_slice::(&buffer).expect("invalid init data"); - self.initialization_params - .set(init_data) - .expect("already initialized"); + fn on_message(&self, _: u32, buffer: Vec) { + let message: CrashServerMessage = + serde_json::from_slice(&buffer).expect("invalid init data"); + match message { + CrashServerMessage::Init(init_data) => { + self.initialization_params.lock().replace(init_data); } - 2 => { - let panic_data = - serde_json::from_slice::(&buffer).expect("invalid panic data"); - self.panic_info.set(panic_data).expect("already panicked"); + CrashServerMessage::Panic(crash_panic) => { + self.panic_info.lock().replace(crash_panic); } - 3 => { - let gpu_specs: system_specs::GpuSpecs = - bincode::deserialize(&buffer).expect("gpu specs"); - // we ignore the case where it was already set because this message is sent - // on each new window. in theory all zed windows should be using the same - // GPU so this is fine. - self.active_gpu.set(gpu_specs).ok(); + CrashServerMessage::GPUInfo(gpu_specs) => { + self.active_gpu.lock().replace(gpu_specs); } - _ => { - panic!("invalid message kind"); + CrashServerMessage::UserInfo(user_info) => { + self.user_info.lock().replace(user_info); } } } @@ -326,37 +365,33 @@ pub fn panic_hook(info: &PanicHookInfo) { // if it's still not there just write panic info and no minidump let retry_frequency = Duration::from_millis(100); for _ in 0..5 { - if let Some(client) = CRASH_HANDLER.get() { - let location = info - .location() - .map_or_else(|| "".to_owned(), |location| location.to_string()); - log::error!("thread '{thread_name}' panicked at {location}:\n{message}..."); - client - .send_message( - 2, - serde_json::to_vec(&CrashPanic { message, span }).unwrap(), - ) - .ok(); - log::error!("triggering a crash to generate a minidump..."); - - #[cfg(target_os = "macos")] - PANIC_THREAD_ID.store( - unsafe { mach2::mach_init::mach_thread_self() }, - Ordering::SeqCst, - ); - - cfg_if::cfg_if! { - if #[cfg(target_os = "windows")] { - // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499- - CrashHandler.simulate_exception(Some(234)); // (MORE_DATA_AVAILABLE) - break; - } else { - std::process::abort(); - } - } + if CRASH_HANDLER.get().is_some() { + break; } thread::sleep(retry_frequency); } + let location = info + .location() + .map_or_else(|| "".to_owned(), |location| location.to_string()); + log::error!("thread '{thread_name}' panicked at {location}:\n{message}..."); + + send_crash_server_message(CrashServerMessage::Panic(CrashPanic { message, span })); + log::error!("triggering a crash to generate a minidump..."); + + #[cfg(target_os = "macos")] + PANIC_THREAD_ID.store( + unsafe { mach2::mach_init::mach_thread_self() }, + Ordering::SeqCst, + ); + + cfg_if::cfg_if! { + if #[cfg(target_os = "windows")] { + // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499- + CrashHandler.simulate_exception(Some(234)); // (MORE_DATA_AVAILABLE) + } else { + std::process::abort(); + } + } } #[cfg(target_os = "windows")] @@ -436,10 +471,11 @@ pub fn crash_server(socket: &Path) { server .run( Box::new(CrashServer { - initialization_params: OnceLock::new(), - panic_info: OnceLock::new(), + initialization_params: Mutex::default(), + panic_info: Mutex::default(), + user_info: Mutex::default(), has_connection, - active_gpu: OnceLock::new(), + active_gpu: Mutex::default(), }), &shutdown, Some(CRASH_HANDLER_PING_TIMEOUT), diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 3d9e433d73dac7d79fc008c79b3ab2db5863a8db..6ea308db5a32cf82e48439c477c8bb81f02ab777 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -17,7 +17,6 @@ test-support = [ "gpui/test-support", "gpui_platform/screen-capture", "dep:image", - "dep:semver", "workspace/test-support", "project/test-support", "editor/test-support", @@ -32,7 +31,6 @@ visual-tests = [ "gpui_platform/screen-capture", "gpui_platform/test-support", "dep:image", - "dep:semver", "dep:tempfile", "dep:action_log", "dep:agent_servers", @@ -76,7 +74,6 @@ assets.workspace = true audio.workspace = true auto_update.workspace = true auto_update_ui.workspace = true -bincode.workspace = true breadcrumbs.workspace = true call.workspace = true chrono.workspace = true @@ -122,7 +119,7 @@ system_specs.workspace = true gpui.workspace = true gpui_platform = {workspace = true, features=["screen-capture", "font-kit", "wayland", "x11"]} image = { workspace = true, optional = true } -semver = { workspace = true, optional = true } +semver.workspace = true tempfile = { workspace = true, optional = true } clock = { workspace = true, optional = true } acp_thread.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index a3379a6017b7e3b7c26e2a98346e4926e90e0999..0d50339f6c9d42ffa653e5c7565ae6e22441bdca 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -335,7 +335,13 @@ fn main() { crashes::init( InitCrashHandler { session_id, - zed_version: app_version.to_string(), + // strip the build and channel information from the version string, we send them separately + zed_version: semver::Version::new( + app_version.major, + app_version.minor, + app_version.patch, + ) + .to_string(), binary: "zed".to_string(), release_channel: release_channel::RELEASE_CHANNEL_NAME.clone(), commit_sha: app_commit_sha @@ -573,6 +579,19 @@ fn main() { session.id().to_owned(), cx, ); + cx.subscribe(&user_store, { + let telemetry = telemetry.clone(); + move |_, evt: &client::user::Event, _| match evt { + client::user::Event::PrivateUserInfoUpdated => { + crashes::set_user_info(crashes::UserInfo { + metrics_id: telemetry.metrics_id().map(|s| s.to_string()), + is_staff: telemetry.is_staff(), + }); + } + _ => {} + } + }) + .detach(); // We should rename these in the future to `first app open`, `first app open for release channel`, and `app open` if let (Some(system_id), Some(installation_id)) = (&system_id, &installation_id) { diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index d8dc1c4f8fe412b5e8eeb6b09e482a9ed243aaa3..2f284027929b19e5b0d5ac084267cf5548cda667 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -288,16 +288,23 @@ async fn upload_minidump( form = form.text("minidump_error", minidump_error); } - if let Some(id) = client.telemetry().metrics_id() { - form = form.text("sentry[user][id]", id.to_string()); + if let Some(is_staff) = &metadata + .user_info + .as_ref() + .and_then(|user_info| user_info.is_staff) + { form = form.text( "sentry[user][is_staff]", - if client.telemetry().is_staff().unwrap_or_default() { - "true" - } else { - "false" - }, + if *is_staff { "true" } else { "false" }, ); + } + + if let Some(metrics_id) = metadata + .user_info + .as_ref() + .and_then(|user_info| user_info.metrics_id.as_ref()) + { + form = form.text("sentry[user][id]", metrics_id.clone()); } else if let Some(id) = client.telemetry().installation_id() { form = form.text("sentry[user][id]", format!("installation-{}", id)) } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 20629785c7172241f49a0e7a69f9dcc1953f6a95..aeb740c5ec05f5382e3b93527bb2191cb44f9d51 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -422,16 +422,7 @@ pub fn initialize_workspace( if let Some(specs) = window.gpu_specs() { log::info!("Using GPU: {:?}", specs); show_software_emulation_warning_if_needed(specs.clone(), window, cx); - if let Some((crash_server, message)) = crashes::CRASH_HANDLER - .get() - .zip(bincode::serialize(&specs).ok()) - && let Err(err) = crash_server.send_message(3, message) - { - log::warn!( - "Failed to store active gpu info for crash reporting: {}", - err - ); - } + crashes::set_gpu_info(specs); } let edit_prediction_menu_handle = PopoverMenuHandle::default(); From 4d42d3a6b043fe57e6935dc722e1b8a3bc8dcbdd Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 4 Mar 2026 15:10:13 -0500 Subject: [PATCH 59/74] docs: Remove Preview callouts for stable release (#50736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR removes Preview callouts from documentation for features that are now in Stable. ## Files Updated • docs/src/collaboration/overview.md • docs/src/debugger.md • docs/src/configuring-languages.md • docs/src/troubleshooting.md • docs/src/outline-panel.md • docs/src/getting-started.md • docs/src/tasks.md • docs/src/ai/edit-prediction.md • docs/src/ai/llm-providers.md ## What This Does Removes callouts like: ```markdown > **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. ``` And: ```markdown > **Changed in Preview (v0.XXX).** See [release notes](/releases#0.XXX). ``` These features are now in Stable, so the callouts are no longer needed. Release Notes: - N/A --- docs/src/ai/edit-prediction.md | 2 -- docs/src/ai/llm-providers.md | 6 ------ docs/src/collaboration/overview.md | 2 -- docs/src/configuring-languages.md | 2 -- docs/src/debugger.md | 2 -- docs/src/getting-started.md | 2 -- docs/src/outline-panel.md | 2 -- docs/src/tasks.md | 2 -- docs/src/troubleshooting.md | 2 -- 9 files changed, 22 deletions(-) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 973dc9546a8b81ad58fc996102ff25aed2d241a9..92fde3eddd3be0a2dbfb1b6d37065b58cf2ad411 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -406,8 +406,6 @@ After adding your API key, Codestral will appear in the provider dropdown in the ### Self-Hosted OpenAI-compatible servers -> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. - You can use any self-hosted server that implements the OpenAI completion API format. This works with vLLM, llama.cpp server, LocalAI, and other compatible servers. #### Configuration diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index a4a6274af10d1aea20ed27160704136d9f0eb586..24501ab2d356b8dc4098808ed8e9193cf6e171c6 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -152,8 +152,6 @@ For the most up-to-date supported regions and models, refer to the [Supported Mo #### Extended Context Window {#bedrock-extended-context} -> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. - Anthropic models on Bedrock support a 1M token extended context window through the `anthropic_beta` API parameter. To enable this feature, set `"allow_extended_context": true` in your Bedrock configuration: ```json [settings] @@ -173,8 +171,6 @@ Zed enables extended context for supported models (Claude Sonnet 4.5 and Claude #### Image Support {#bedrock-image-support} -> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. - Bedrock models that support vision (Claude 3 and later, Amazon Nova Pro and Lite, Meta Llama 3.2 Vision models, Mistral Pixtral) can receive images in conversations and tool results. ### Anthropic {#anthropic} @@ -630,8 +626,6 @@ The OpenRouter API key will be saved in your keychain. Zed will also use the `OPENROUTER_API_KEY` environment variable if it's defined. -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - When using OpenRouter as your assistant provider, you must explicitly select a model in your settings. OpenRouter no longer provides a default model selection. Configure your preferred OpenRouter model in `settings.json`: diff --git a/docs/src/collaboration/overview.md b/docs/src/collaboration/overview.md index 97efdae088d1692ad5840e23c13bc50d4ecb75c7..1022ec683bf5eefab55b9aff939c568098fdda30 100644 --- a/docs/src/collaboration/overview.md +++ b/docs/src/collaboration/overview.md @@ -24,8 +24,6 @@ See the [Data and Privacy FAQs](https://zed.dev/faq#data-and-privacy) for more d ### Selecting Audio Devices -> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. - You can select specific input and output audio devices instead of using system defaults. To configure audio devices: 1. Open {#kb zed::OpenSettings} diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index 91775c3df137e38eb0b6b7b333b49d269b2f3a7c..485d843fd480177376cf4e5e990fc495e2bb60a7 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -165,8 +165,6 @@ Not all languages in Zed support toolchain discovery and selection, but for thos ### Configuring Language Servers -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - When configuring language servers in your `settings.json`, autocomplete suggestions include all available LSP adapters recognized by Zed, not only those currently active for loaded languages. This helps you discover and configure language servers before opening files that use them. Many language servers accept custom configuration options. You can set these in the `lsp` section of your `settings.json`: diff --git a/docs/src/debugger.md b/docs/src/debugger.md index c659c1410b38166cf11da0af728e18f8c9282054..bf05de0f6ccccff4e95fd622bab7130d655a1167 100644 --- a/docs/src/debugger.md +++ b/docs/src/debugger.md @@ -165,8 +165,6 @@ The debug adapter will then stop whenever an exception of a given kind occurs. W ## Working with Split Panes -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - When debugging with multiple split panes open, Zed shows the active debug line in one pane and preserves your layout in others. If you have the same file open in multiple panes, the debugger picks a pane where the file is already the active tab—it won't switch tabs in panes where the file is inactive. Once the debugger picks a pane, it continues using that pane for subsequent breakpoints during the session. If you drag the tab with the active debug line to a different split, the debugger tracks the move and uses the new pane. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index af6a41c26a6f70f073b2d7e45267871962bb1697..a87e1bea0f4c3eacaa330b34874283a0b61b5eb9 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -13,8 +13,6 @@ This guide covers the essential commands, environment setup, and navigation basi ### Welcome Page -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - When you open Zed without a folder, you see the welcome page in the main editor area. The welcome page offers quick actions to open a folder, clone a repository, or view documentation. Once you open a folder or file, the welcome page disappears. If you split the editor into multiple panes, the welcome page appears only in the center pane when empty—other panes show a standard empty state. To reopen the welcome page, close all items in the center pane or use the command palette to search for "Welcome". diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index 1bacc3cacf4f556c9c3a06e59d6f3fac9b8c74b0..7b31725bf2cec844881e0c5b0b41aac864e28fc9 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -7,8 +7,6 @@ description: Navigate code structure with Zed's outline panel. View symbols, jum In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` (`outline panel: toggle focus` via the command palette), or by clicking the `Outline Panel` button in the status bar. -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - When viewing a "singleton" buffer (i.e., a single file on a tab), the outline panel works similarly to that of the outline modal-it displays the outline of the current buffer's symbols. Each symbol entry shows its type prefix (such as "struct", "fn", "mod", "impl") along with the symbol name, helping you quickly identify what kind of symbol you're looking at. Clicking on an entry allows you to jump to the associated section in the file. The outline view will also automatically scroll to the section associated with the current cursor position within the file. ![Using the outline panel in a singleton buffer](https://zed.dev/img/outline-panel/singleton.png) diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 0fa659eb2cc58fe63536e721475b0093e0650618..482ca7b4d5779a4861756332ce2c0f25eaad4ad4 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -225,8 +225,6 @@ This could be useful for launching a terminal application that you want to use i ## VS Code Task Format -> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. - When importing VS Code tasks from `.vscode/tasks.json`, you can omit the `label` field. Zed automatically generates labels based on the task type: - **npm tasks**: `npm: