Detailed changes
@@ -23,8 +23,8 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
- token: ${{ steps.get-app-token.outputs.token }}
ref: ${{ inputs.branch }}
+ token: ${{ steps.get-app-token.outputs.token }}
- name: bump_patch_version::run_bump_patch_version::bump_patch_version
run: |
channel="$(cat crates/zed/RELEASE_CHANNEL)"
@@ -12,6 +12,9 @@ jobs:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')
name: Check formatting and Clippy lints
runs-on: namespace-profile-16x32-ubuntu-2204
+ env:
+ CC: clang
+ CXX: clang++
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -42,6 +45,9 @@ jobs:
- style
name: Run tests
runs-on: namespace-profile-16x32-ubuntu-2204
+ env:
+ CC: clang
+ CXX: clang++
steps:
- name: steps::checkout_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -4,12 +4,57 @@ name: extension_workflow_rollout
env:
CARGO_TERM_COLOR: always
on:
- workflow_dispatch: {}
+ workflow_dispatch:
+ inputs:
+ filter-repos:
+ description: Comma-separated list of repository names to rollout to. Leave empty for all repos.
+ type: string
+ default: ''
+ change-description:
+ description: Description for the changes to be expected with this rollout
+ type: string
+ default: ''
jobs:
fetch_extension_repos:
if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.ref == 'refs/heads/main'
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
+ - name: checkout_zed_repo
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ with:
+ clean: false
+ fetch-depth: 0
+ - id: prev-tag
+ name: extension_workflow_rollout::fetch_extension_repos::get_previous_tag_commit
+ run: |
+ PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "")
+ if [ -z "$PREV_COMMIT" ]; then
+ echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes."
+ exit 1
+ fi
+ echo "Found previous rollout at commit: $PREV_COMMIT"
+ echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
+ - id: calc-changes
+ name: extension_workflow_rollout::fetch_extension_repos::get_removed_files
+ run: |
+ for workflow_type in "ci" "shared"; do
+ if [ "$workflow_type" = "ci" ]; then
+ WORKFLOW_DIR="extensions/workflows"
+ else
+ WORKFLOW_DIR="extensions/workflows/shared"
+ fi
+
+ REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+ awk '/^D/ { print $2 } /^R/ { print $2 }' | \
+ xargs -I{} basename {} 2>/dev/null | \
+ tr '\n' ' ' || echo "")
+ REMOVED=$(echo "$REMOVED" | xargs)
+
+ echo "Removed files for $workflow_type: $REMOVED"
+ echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
+ done
+ env:
+ PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }}
- id: list-repos
name: extension_workflow_rollout::fetch_extension_repos::get_repositories
uses: actions/github-script@v7
@@ -21,16 +66,42 @@ jobs:
per_page: 100,
});
- const filteredRepos = repos
+ let filteredRepos = repos
.filter(repo => !repo.archived)
.map(repo => repo.name);
+ const filterInput = `${{ inputs.filter-repos }}`.trim();
+ if (filterInput.length > 0) {
+ const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
+ filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
+ console.log(`Filter applied. Matched ${filteredRepos.length} repos from ${allowedNames.length} requested.`);
+ }
+
console.log(`Found ${filteredRepos.length} extension repos`);
return filteredRepos;
result-encoding: json
+ - name: steps::cache_rust_dependencies_namespace
+ uses: namespacelabs/nscloud-cache-action@v1
+ with:
+ cache: rust
+ path: ~/.rustup
+ - name: extension_workflow_rollout::fetch_extension_repos::generate_workflow_files
+ run: |
+ cargo xtask workflows "$COMMIT_SHA"
+ env:
+ COMMIT_SHA: ${{ github.sha }}
+ - name: extension_workflow_rollout::fetch_extension_repos::upload_workflow_files
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
+ with:
+ name: extension-workflow-files
+ path: extensions/workflows/**/*.yml
+ if-no-files-found: error
outputs:
repos: ${{ steps.list-repos.outputs.result }}
- timeout-minutes: 5
+ prev_commit: ${{ steps.prev-tag.outputs.prev_commit }}
+ removed_ci: ${{ steps.calc-changes.outputs.removed_ci }}
+ removed_shared: ${{ steps.calc-changes.outputs.removed_shared }}
+ timeout-minutes: 10
rollout_workflows_to_extension:
needs:
- fetch_extension_repos
@@ -53,59 +124,28 @@ jobs:
permission-pull-requests: write
permission-contents: write
permission-workflows: write
- - name: checkout_zed_repo
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- with:
- clean: false
- fetch-depth: 0
- path: zed
- name: checkout_extension_repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
clean: false
- token: ${{ steps.generate-token.outputs.token }}
path: extension
repository: zed-extensions/${{ matrix.repo }}
- - id: prev-tag
- name: extension_workflow_rollout::rollout_workflows_to_extension::get_previous_tag_commit
- run: |
- PREV_COMMIT=$(git rev-parse "extension-workflows^{commit}" 2>/dev/null || echo "")
- if [ -z "$PREV_COMMIT" ]; then
- echo "::error::No previous rollout tag 'extension-workflows' found. Cannot determine file changes."
- exit 1
- fi
- echo "Found previous rollout at commit: $PREV_COMMIT"
- echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
- working-directory: zed
- - id: calc-changes
- name: extension_workflow_rollout::rollout_workflows_to_extension::get_removed_files
+ token: ${{ steps.generate-token.outputs.token }}
+ - name: extension_workflow_rollout::rollout_workflows_to_extension::download_workflow_files
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
+ with:
+ name: extension-workflow-files
+ path: workflow-files
+ - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files
run: |
+ mkdir -p extension/.github/workflows
+
if [ "$MATRIX_REPO" = "workflows" ]; then
- WORKFLOW_DIR="extensions/workflows"
+ REMOVED_FILES="$REMOVED_CI"
else
- WORKFLOW_DIR="extensions/workflows/shared"
+ REMOVED_FILES="$REMOVED_SHARED"
fi
- echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
-
- # Get deleted files (status D) and renamed files (status R - old name needs removal)
- # Using -M to detect renames, then extracting files that are gone from their original location
- REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
- awk '/^D/ { print $2 } /^R/ { print $2 }' | \
- xargs -I{} basename {} 2>/dev/null | \
- tr '\n' ' ' || echo "")
-
- REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
-
- echo "Files to remove: $REMOVED_FILES"
- echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
- env:
- PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }}
- MATRIX_REPO: ${{ matrix.repo }}
- working-directory: zed
- - name: extension_workflow_rollout::rollout_workflows_to_extension::sync_workflow_files
- run: |
- mkdir -p extension/.github/workflows
cd extension/.github/workflows
if [ -n "$REMOVED_FILES" ]; then
@@ -119,18 +159,18 @@ jobs:
cd - > /dev/null
if [ "$MATRIX_REPO" = "workflows" ]; then
- cp zed/extensions/workflows/*.yml extension/.github/workflows/
+ cp workflow-files/*.yml extension/.github/workflows/
else
- cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
+ cp workflow-files/shared/*.yml extension/.github/workflows/
fi
env:
- REMOVED_FILES: ${{ steps.calc-changes.outputs.removed_files }}
+ REMOVED_CI: ${{ needs.fetch_extension_repos.outputs.removed_ci }}
+ REMOVED_SHARED: ${{ needs.fetch_extension_repos.outputs.removed_shared }}
MATRIX_REPO: ${{ matrix.repo }}
- id: short-sha
name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha
run: |
- echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
- working-directory: zed
+ echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
- id: create-pr
name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request
uses: peter-evans/create-pull-request@v7
@@ -140,6 +180,8 @@ jobs:
body: |
This PR updates the CI workflow files from the main Zed repository
based on the commit zed-industries/zed@${{ github.sha }}
+
+ ${{ inputs.change-description }}
commit-message: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}`
branch: update-workflows
committer: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
@@ -151,16 +193,17 @@ jobs:
- name: extension_workflow_rollout::rollout_workflows_to_extension::enable_auto_merge
run: |
if [ -n "$PR_NUMBER" ]; then
- cd extension
gh pr merge "$PR_NUMBER" --auto --squash
fi
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
PR_NUMBER: ${{ steps.create-pr.outputs.pull-request-number }}
+ working-directory: extension
timeout-minutes: 10
create_rollout_tag:
needs:
- rollout_workflows_to_extension
+ if: inputs.filter-repos == ''
runs-on: namespace-profile-2x4-ubuntu-2404
steps:
- id: generate-token
@@ -227,9 +227,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
-version = "0.9.4"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475"
+checksum = "9c56a59cf6315e99f874d2c1f96c69d2da5ffe0087d211297fc4a41f849770a2"
dependencies = [
"agent-client-protocol-schema",
"anyhow",
@@ -244,16 +244,16 @@ dependencies = [
[[package]]
name = "agent-client-protocol-schema"
-version = "0.10.8"
+version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1"
+checksum = "e0497b9a95a404e35799904835c57c6f8c69b9d08ccfd3cb5b7d746425cd6789"
dependencies = [
"anyhow",
"derive_more",
"schemars",
"serde",
"serde_json",
- "strum 0.27.2",
+ "strum 0.28.0",
]
[[package]]
@@ -6212,7 +6212,6 @@ dependencies = [
name = "feature_flags"
version = "0.1.0"
dependencies = [
- "futures 0.3.31",
"gpui",
]
@@ -7152,7 +7151,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
- "strum_macros",
+ "strum_macros 0.27.2",
]
[[package]]
@@ -15808,33 +15807,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-[[package]]
-name = "sidebar"
-version = "0.1.0"
-dependencies = [
- "acp_thread",
- "agent",
- "agent-client-protocol",
- "agent_ui",
- "assistant_text_thread",
- "chrono",
- "editor",
- "feature_flags",
- "fs",
- "gpui",
- "language_model",
- "menu",
- "project",
- "recent_projects",
- "serde_json",
- "settings",
- "theme",
- "ui",
- "util",
- "workspace",
- "zed_actions",
-]
-
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -16572,7 +16544,16 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros 0.28.0",
]
[[package]]
@@ -16587,6 +16568,18 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -17661,7 +17654,6 @@ dependencies = [
"client",
"cloud_api_types",
"db",
- "feature_flags",
"git_ui",
"gpui",
"notifications",
@@ -21764,7 +21756,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.228.0"
+version = "0.229.0"
dependencies = [
"acp_thread",
"acp_tools",
@@ -21888,7 +21880,6 @@ dependencies = [
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
- "sidebar",
"smol",
"snippet_provider",
"snippets_ui",
@@ -173,7 +173,6 @@ members = [
"crates/settings_profile_selector",
"crates/settings_ui",
"crates/shell_command_parser",
- "crates/sidebar",
"crates/snippet",
"crates/snippet_provider",
"crates/snippets_ui",
@@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" }
scheduler = { path = "crates/scheduler" }
search = { path = "crates/search" }
session = { path = "crates/session" }
-sidebar = { path = "crates/sidebar" }
settings = { path = "crates/settings" }
settings_content = { path = "crates/settings_content" }
settings_json = { path = "crates/settings_json" }
@@ -475,7 +473,7 @@ ztracing_macro = { path = "crates/ztracing_macro" }
# External crates
#
-agent-client-protocol = { version = "=0.9.4", features = ["unstable"] }
+agent-client-protocol = { version = "=0.10.2", features = ["unstable"] }
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "9d9640d4" }
any_vec = "0.14"
@@ -513,7 +511,6 @@ aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] }
aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] }
backtrace = "0.3"
base64 = "0.22"
-bincode = "1.2.1"
bitflags = "2.6.0"
brotli = "8.0.2"
bytes = "1.0"
@@ -572,7 +569,6 @@ human_bytes = "0.4.1"
html5ever = "0.27.0"
http = "1.1"
http-body = "1.0"
-hyper = "0.14"
ignore = "0.4.22"
image = "0.25.1"
imara-diff = "0.1.8"
@@ -690,7 +686,6 @@ serde_json_lenient = { version = "0.2", features = [
"raw_value",
] }
serde_path_to_error = "0.1.17"
-serde_repr = "0.1"
serde_urlencoded = "0.7"
sha2 = "0.10"
shellexpand = "2.1.0"
@@ -721,7 +716,6 @@ time = { version = "0.3", features = [
] }
tiny_http = "0.8"
tokio = { version = "1" }
-tokio-tungstenite = { version = "0.26", features = ["__rustls-tls"] }
tokio-socks = { version = "0.5.2", default-features = false, features = [
"futures-io",
"tokio",
@@ -907,7 +901,6 @@ refineable = { codegen-units = 1 }
release_channel = { codegen-units = 1 }
reqwest_client = { codegen-units = 1 }
session = { codegen-units = 1 }
-sidebar = { codegen-units = 1 }
snippet = { codegen-units = 1 }
snippets_ui = { codegen-units = 1 }
story = { codegen-units = 1 }
@@ -14,8 +14,12 @@ ARG GITHUB_SHA
ENV GITHUB_SHA=$GITHUB_SHA
# Also add `cmake`, since we need it to build `wasmtime`.
+# clang is needed because `webrtc-sys` uses Clang-specific compiler flags.
RUN apt-get update; \
- apt-get install -y --no-install-recommends cmake
+ apt-get install -y --no-install-recommends cmake clang
+
+ENV CC=clang
+ENV CXX=clang++
RUN --mount=type=cache,target=./script/node_modules \
--mount=type=cache,target=/usr/local/cargo/registry \
@@ -920,8 +920,8 @@
},
// Whether to show the addition/deletion change count next to each file in the Git panel.
//
- // Default: false
- "diff_stats": false,
+ // Default: true
+ "diff_stats": true,
},
"message_editor": {
// Whether to automatically replace emoji shortcodes with emoji characters.
@@ -4027,7 +4027,7 @@ mod tests {
}
fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
- if self.auth_methods().iter().any(|m| m.id == method) {
+ if self.auth_methods().iter().any(|m| m.id() == &method) {
Task::ready(Ok(()))
} else {
Task::ready(Err(anyhow!("Invalid Auth Method")))
@@ -60,7 +60,11 @@ pub trait AgentConnection {
}
/// Close an existing session. Allows the agent to free the session from memory.
- fn close_session(&self, _session_id: &acp::SessionId, _cx: &mut App) -> Task<Result<()>> {
+ fn close_session(
+ self: Rc<Self>,
+ _session_id: &acp::SessionId,
+ _cx: &mut App,
+ ) -> Task<Result<()>> {
Task::ready(Err(anyhow::Error::msg("Closing sessions is not supported")))
}
@@ -1028,6 +1028,11 @@ impl ActionLog {
.collect()
}
+ /// Returns the total number of lines added and removed across all unreviewed buffers.
+ pub fn diff_stats(&self, cx: &App) -> DiffStats {
+ DiffStats::all_files(&self.changed_buffers(cx), cx)
+ }
+
/// Iterate over buffers changed since last read or edited by the model
pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a Entity<Buffer>> {
self.tracked_buffers
@@ -1044,6 +1049,46 @@ impl ActionLog {
}
}
+#[derive(Default, Debug, Clone, Copy)]
+pub struct DiffStats {
+ pub lines_added: u32,
+ pub lines_removed: u32,
+}
+
+impl DiffStats {
+ pub fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
+ let mut stats = DiffStats::default();
+ let diff_snapshot = diff.snapshot(cx);
+ let buffer_snapshot = buffer.snapshot();
+ let base_text = diff_snapshot.base_text();
+
+ for hunk in diff_snapshot.hunks(&buffer_snapshot) {
+ let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
+ stats.lines_added += added_rows;
+
+ let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
+ let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
+ let removed_rows = base_end.saturating_sub(base_start);
+ stats.lines_removed += removed_rows;
+ }
+
+ stats
+ }
+
+ pub fn all_files(
+ changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
+ cx: &App,
+ ) -> Self {
+ let mut total = DiffStats::default();
+ for (buffer, diff) in changed_buffers {
+ let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
+ total.lines_added += stats.lines_added;
+ total.lines_removed += stats.lines_removed;
+ }
+ total
+ }
+}
+
#[derive(Clone)]
pub struct ActionLogTelemetry {
pub agent_telemetry_id: SharedString,
@@ -37,7 +37,8 @@ use futures::channel::{mpsc, oneshot};
use futures::future::Shared;
use futures::{FutureExt as _, StreamExt as _, future};
use gpui::{
- App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
+ App, AppContext, AsyncApp, Context, Entity, EntityId, SharedString, Subscription, Task,
+ WeakEntity,
};
use language_model::{IconOrSvg, LanguageModel, LanguageModelProvider, LanguageModelRegistry};
use project::{Project, ProjectItem, ProjectPath, Worktree};
@@ -65,12 +66,22 @@ pub struct RulesLoadingError {
pub message: SharedString,
}
+struct ProjectState {
+ project: Entity<Project>,
+ project_context: Entity<ProjectContext>,
+ project_context_needs_refresh: watch::Sender<()>,
+ _maintain_project_context: Task<Result<()>>,
+ context_server_registry: Entity<ContextServerRegistry>,
+ _subscriptions: Vec<Subscription>,
+}
+
/// Holds both the internal Thread and the AcpThread for a session
struct Session {
/// The internal thread that processes messages
thread: Entity<Thread>,
/// The ACP thread that handles protocol communication
acp_thread: Entity<acp_thread::AcpThread>,
+ project_id: EntityId,
pending_save: Task<()>,
_subscriptions: Vec<Subscription>,
}
@@ -235,79 +246,47 @@ pub struct NativeAgent {
/// Session ID -> Session mapping
sessions: HashMap<acp::SessionId, Session>,
thread_store: Entity<ThreadStore>,
- /// Shared project context for all threads
- project_context: Entity<ProjectContext>,
- project_context_needs_refresh: watch::Sender<()>,
- _maintain_project_context: Task<Result<()>>,
- context_server_registry: Entity<ContextServerRegistry>,
+ /// Project-specific state keyed by project EntityId
+ projects: HashMap<EntityId, ProjectState>,
/// Shared templates for all threads
templates: Arc<Templates>,
/// Cached model information
models: LanguageModels,
- project: Entity<Project>,
prompt_store: Option<Entity<PromptStore>>,
fs: Arc<dyn Fs>,
_subscriptions: Vec<Subscription>,
}
impl NativeAgent {
- pub async fn new(
- project: Entity<Project>,
+ pub fn new(
thread_store: Entity<ThreadStore>,
templates: Arc<Templates>,
prompt_store: Option<Entity<PromptStore>>,
fs: Arc<dyn Fs>,
- cx: &mut AsyncApp,
- ) -> Result<Entity<NativeAgent>> {
+ cx: &mut App,
+ ) -> Entity<NativeAgent> {
log::debug!("Creating new NativeAgent");
- let project_context = cx
- .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))
- .await;
-
- Ok(cx.new(|cx| {
- let context_server_store = project.read(cx).context_server_store();
- let context_server_registry =
- cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
-
- let mut subscriptions = vec![
- cx.subscribe(&project, Self::handle_project_event),
- cx.subscribe(
- &LanguageModelRegistry::global(cx),
- Self::handle_models_updated_event,
- ),
- cx.subscribe(
- &context_server_store,
- Self::handle_context_server_store_updated,
- ),
- cx.subscribe(
- &context_server_registry,
- Self::handle_context_server_registry_event,
- ),
- ];
+ cx.new(|cx| {
+ let mut subscriptions = vec![cx.subscribe(
+ &LanguageModelRegistry::global(cx),
+ Self::handle_models_updated_event,
+ )];
if let Some(prompt_store) = prompt_store.as_ref() {
subscriptions.push(cx.subscribe(prompt_store, Self::handle_prompts_updated_event))
}
- let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
- watch::channel(());
Self {
sessions: HashMap::default(),
thread_store,
- project_context: cx.new(|_| project_context),
- project_context_needs_refresh: project_context_needs_refresh_tx,
- _maintain_project_context: cx.spawn(async move |this, cx| {
- Self::maintain_project_context(this, project_context_needs_refresh_rx, cx).await
- }),
- context_server_registry,
+ projects: HashMap::default(),
templates,
models: LanguageModels::new(cx),
- project,
prompt_store,
fs,
_subscriptions: subscriptions,
}
- }))
+ })
}
fn new_session(
@@ -315,10 +294,10 @@ impl NativeAgent {
project: Entity<Project>,
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
- // Create Thread
- // Fetch default model from registry settings
+ let project_id = self.get_or_create_project_state(&project, cx);
+ let project_state = &self.projects[&project_id];
+
let registry = LanguageModelRegistry::read_global(cx);
- // Log available models for debugging
let available_count = registry.available_models(cx).count();
log::debug!("Total available models: {}", available_count);
@@ -328,21 +307,22 @@ impl NativeAgent {
});
let thread = cx.new(|cx| {
Thread::new(
- project.clone(),
- self.project_context.clone(),
- self.context_server_registry.clone(),
+ project,
+ project_state.project_context.clone(),
+ project_state.context_server_registry.clone(),
self.templates.clone(),
default_model,
cx,
)
});
- self.register_session(thread, cx)
+ self.register_session(thread, project_id, cx)
}
fn register_session(
&mut self,
thread_handle: Entity<Thread>,
+ project_id: EntityId,
cx: &mut Context<Self>,
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
@@ -405,12 +385,13 @@ impl NativeAgent {
Session {
thread: thread_handle,
acp_thread: acp_thread.clone(),
+ project_id,
_subscriptions: subscriptions,
pending_save: Task::ready(()),
},
);
- self.update_available_commands(cx);
+ self.update_available_commands_for_project(project_id, cx);
acp_thread
}
@@ -419,19 +400,102 @@ impl NativeAgent {
&self.models
}
+ fn get_or_create_project_state(
+ &mut self,
+ project: &Entity<Project>,
+ cx: &mut Context<Self>,
+ ) -> EntityId {
+ let project_id = project.entity_id();
+ if self.projects.contains_key(&project_id) {
+ return project_id;
+ }
+
+ let project_context = cx.new(|_| ProjectContext::new(vec![], vec![]));
+ self.register_project_with_initial_context(project.clone(), project_context, cx);
+ if let Some(state) = self.projects.get_mut(&project_id) {
+ state.project_context_needs_refresh.send(()).ok();
+ }
+ project_id
+ }
+
+ fn register_project_with_initial_context(
+ &mut self,
+ project: Entity<Project>,
+ project_context: Entity<ProjectContext>,
+ cx: &mut Context<Self>,
+ ) {
+ let project_id = project.entity_id();
+
+ let context_server_store = project.read(cx).context_server_store();
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(context_server_store.clone(), cx));
+
+ let subscriptions = vec![
+ cx.subscribe(&project, Self::handle_project_event),
+ cx.subscribe(
+ &context_server_store,
+ Self::handle_context_server_store_updated,
+ ),
+ cx.subscribe(
+ &context_server_registry,
+ Self::handle_context_server_registry_event,
+ ),
+ ];
+
+ let (project_context_needs_refresh_tx, project_context_needs_refresh_rx) =
+ watch::channel(());
+
+ self.projects.insert(
+ project_id,
+ ProjectState {
+ project,
+ project_context,
+ project_context_needs_refresh: project_context_needs_refresh_tx,
+ _maintain_project_context: cx.spawn(async move |this, cx| {
+ Self::maintain_project_context(
+ this,
+ project_id,
+ project_context_needs_refresh_rx,
+ cx,
+ )
+ .await
+ }),
+ context_server_registry,
+ _subscriptions: subscriptions,
+ },
+ );
+ }
+
+ fn session_project_state(&self, session_id: &acp::SessionId) -> Option<&ProjectState> {
+ self.sessions
+ .get(session_id)
+ .and_then(|session| self.projects.get(&session.project_id))
+ }
+
async fn maintain_project_context(
this: WeakEntity<Self>,
+ project_id: EntityId,
mut needs_refresh: watch::Receiver<()>,
cx: &mut AsyncApp,
) -> Result<()> {
while needs_refresh.changed().await.is_ok() {
let project_context = this
.update(cx, |this, cx| {
- Self::build_project_context(&this.project, this.prompt_store.as_ref(), cx)
- })?
+ let state = this
+ .projects
+ .get(&project_id)
+ .context("project state not found")?;
+ anyhow::Ok(Self::build_project_context(
+ &state.project,
+ this.prompt_store.as_ref(),
+ cx,
+ ))
+ })??
.await;
this.update(cx, |this, cx| {
- this.project_context = cx.new(|_| project_context);
+ if let Some(state) = this.projects.get_mut(&project_id) {
+ state.project_context = cx.new(|_| project_context);
+ }
})?;
}
@@ -620,13 +684,17 @@ impl NativeAgent {
fn handle_project_event(
&mut self,
- _project: Entity<Project>,
+ project: Entity<Project>,
event: &project::Event,
_cx: &mut Context<Self>,
) {
+ let project_id = project.entity_id();
+ let Some(state) = self.projects.get_mut(&project_id) else {
+ return;
+ };
match event {
project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
- self.project_context_needs_refresh.send(()).ok();
+ state.project_context_needs_refresh.send(()).ok();
}
project::Event::WorktreeUpdatedEntries(_, items) => {
if items.iter().any(|(path, _, _)| {
@@ -634,7 +702,7 @@ impl NativeAgent {
.iter()
.any(|name| path.as_ref() == RelPath::unix(name).unwrap())
}) {
- self.project_context_needs_refresh.send(()).ok();
+ state.project_context_needs_refresh.send(()).ok();
}
}
_ => {}
@@ -647,7 +715,9 @@ impl NativeAgent {
_event: &prompt_store::PromptsUpdatedEvent,
_cx: &mut Context<Self>,
) {
- self.project_context_needs_refresh.send(()).ok();
+ for state in self.projects.values_mut() {
+ state.project_context_needs_refresh.send(()).ok();
+ }
}
fn handle_models_updated_event(
@@ -677,30 +747,52 @@ impl NativeAgent {
fn handle_context_server_store_updated(
&mut self,
- _store: Entity<project::context_server_store::ContextServerStore>,
+ store: Entity<project::context_server_store::ContextServerStore>,
_event: &project::context_server_store::ServerStatusChangedEvent,
cx: &mut Context<Self>,
) {
- self.update_available_commands(cx);
+ let project_id = self.projects.iter().find_map(|(id, state)| {
+ if *state.context_server_registry.read(cx).server_store() == store {
+ Some(*id)
+ } else {
+ None
+ }
+ });
+ if let Some(project_id) = project_id {
+ self.update_available_commands_for_project(project_id, cx);
+ }
}
fn handle_context_server_registry_event(
&mut self,
- _registry: Entity<ContextServerRegistry>,
+ registry: Entity<ContextServerRegistry>,
event: &ContextServerRegistryEvent,
cx: &mut Context<Self>,
) {
match event {
ContextServerRegistryEvent::ToolsChanged => {}
ContextServerRegistryEvent::PromptsChanged => {
- self.update_available_commands(cx);
+ let project_id = self.projects.iter().find_map(|(id, state)| {
+ if state.context_server_registry == registry {
+ Some(*id)
+ } else {
+ None
+ }
+ });
+ if let Some(project_id) = project_id {
+ self.update_available_commands_for_project(project_id, cx);
+ }
}
}
}
- fn update_available_commands(&self, cx: &mut Context<Self>) {
- let available_commands = self.build_available_commands(cx);
+ fn update_available_commands_for_project(&self, project_id: EntityId, cx: &mut Context<Self>) {
+ let available_commands =
+ Self::build_available_commands_for_project(self.projects.get(&project_id), cx);
for session in self.sessions.values() {
+ if session.project_id != project_id {
+ continue;
+ }
session.acp_thread.update(cx, |thread, cx| {
thread
.handle_session_update(
@@ -714,8 +806,14 @@ impl NativeAgent {
}
}
- fn build_available_commands(&self, cx: &App) -> Vec<acp::AvailableCommand> {
- let registry = self.context_server_registry.read(cx);
+ fn build_available_commands_for_project(
+ project_state: Option<&ProjectState>,
+ cx: &App,
+ ) -> Vec<acp::AvailableCommand> {
+ let Some(state) = project_state else {
+ return vec![];
+ };
+ let registry = state.context_server_registry.read(cx);
let mut prompt_name_counts: HashMap<&str, usize> = HashMap::default();
for context_server_prompt in registry.prompts() {
@@ -769,8 +867,10 @@ impl NativeAgent {
pub fn load_thread(
&mut self,
id: acp::SessionId,
+ project: Entity<Project>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Thread>>> {
+ let project_id = self.get_or_create_project_state(&project, cx);
let database_future = ThreadsDatabase::connect(cx);
cx.spawn(async move |this, cx| {
let database = database_future.await.map_err(|err| anyhow!(err))?;
@@ -780,41 +880,48 @@ impl NativeAgent {
.with_context(|| format!("no thread found with ID: {id:?}"))?;
this.update(cx, |this, cx| {
+ let project_state = this
+ .projects
+ .get(&project_id)
+ .context("project state not found")?;
let summarization_model = LanguageModelRegistry::read_global(cx)
.thread_summary_model()
.map(|c| c.model);
- cx.new(|cx| {
+ Ok(cx.new(|cx| {
let mut thread = Thread::from_db(
id.clone(),
db_thread,
- this.project.clone(),
- this.project_context.clone(),
- this.context_server_registry.clone(),
+ project_state.project.clone(),
+ project_state.project_context.clone(),
+ project_state.context_server_registry.clone(),
this.templates.clone(),
cx,
);
thread.set_summarization_model(summarization_model, cx);
thread
- })
- })
+ }))
+ })?
})
}
pub fn open_thread(
&mut self,
id: acp::SessionId,
+ project: Entity<Project>,
cx: &mut Context<Self>,
) -> Task<Result<Entity<AcpThread>>> {
if let Some(session) = self.sessions.get(&id) {
return Task::ready(Ok(session.acp_thread.clone()));
}
- let task = self.load_thread(id, cx);
+ let project_id = self.get_or_create_project_state(&project, cx);
+ let task = self.load_thread(id, project, cx);
cx.spawn(async move |this, cx| {
let thread = task.await?;
- let acp_thread =
- this.update(cx, |this, cx| this.register_session(thread.clone(), cx))?;
+ let acp_thread = this.update(cx, |this, cx| {
+ this.register_session(thread.clone(), project_id, cx)
+ })?;
let events = thread.update(cx, |thread, cx| thread.replay(cx));
cx.update(|cx| {
NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
@@ -827,9 +934,10 @@ impl NativeAgent {
pub fn thread_summary(
&mut self,
id: acp::SessionId,
+ project: Entity<Project>,
cx: &mut Context<Self>,
) -> Task<Result<SharedString>> {
- let thread = self.open_thread(id.clone(), cx);
+ let thread = self.open_thread(id.clone(), project, cx);
cx.spawn(async move |this, cx| {
let acp_thread = thread.await?;
let result = this
@@ -857,8 +965,13 @@ impl NativeAgent {
return;
};
+ let project_id = session.project_id;
+ let Some(state) = self.projects.get(&project_id) else {
+ return;
+ };
+
let folder_paths = PathList::new(
- &self
+ &state
.project
.read(cx)
.visible_worktrees(cx)
@@ -889,15 +1002,22 @@ impl NativeAgent {
fn send_mcp_prompt(
&self,
message_id: UserMessageId,
- session_id: agent_client_protocol::SessionId,
+ session_id: acp::SessionId,
prompt_name: String,
server_id: ContextServerId,
arguments: HashMap<String, String>,
original_content: Vec<acp::ContentBlock>,
cx: &mut Context<Self>,
) -> Task<Result<acp::PromptResponse>> {
- let server_store = self.context_server_registry.read(cx).server_store().clone();
- let path_style = self.project.read(cx).path_style(cx);
+ let Some(state) = self.session_project_state(&session_id) else {
+ return Task::ready(Err(anyhow!("Project state not found for session")));
+ };
+ let server_store = state
+ .context_server_registry
+ .read(cx)
+ .server_store()
+ .clone();
+ let path_style = state.project.read(cx).path_style(cx);
cx.spawn(async move |this, cx| {
let prompt =
@@ -996,8 +1116,14 @@ impl NativeAgentConnection {
.map(|session| session.thread.clone())
}
- pub fn load_thread(&self, id: acp::SessionId, cx: &mut App) -> Task<Result<Entity<Thread>>> {
- self.0.update(cx, |this, cx| this.load_thread(id, cx))
+ pub fn load_thread(
+ &self,
+ id: acp::SessionId,
+ project: Entity<Project>,
+ cx: &mut App,
+ ) -> Task<Result<Entity<Thread>>> {
+ self.0
+ .update(cx, |this, cx| this.load_thread(id, project, cx))
}
fn run_turn(
@@ -1279,22 +1405,34 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn load_session(
self: Rc<Self>,
session_id: acp::SessionId,
- _project: Entity<Project>,
+ project: Entity<Project>,
_cwd: &Path,
_title: Option<SharedString>,
cx: &mut App,
) -> Task<Result<Entity<acp_thread::AcpThread>>> {
self.0
- .update(cx, |agent, cx| agent.open_thread(session_id, cx))
+ .update(cx, |agent, cx| agent.open_thread(session_id, project, cx))
}
fn supports_close_session(&self) -> bool {
true
}
- fn close_session(&self, session_id: &acp::SessionId, cx: &mut App) -> Task<Result<()>> {
+ fn close_session(
+ self: Rc<Self>,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<()>> {
self.0.update(cx, |agent, _cx| {
+ let project_id = agent.sessions.get(session_id).map(|s| s.project_id);
agent.sessions.remove(session_id);
+
+ if let Some(project_id) = project_id {
+ let has_remaining = agent.sessions.values().any(|s| s.project_id == project_id);
+ if !has_remaining {
+ agent.projects.remove(&project_id);
+ }
+ }
});
Task::ready(Ok(()))
}
@@ -1325,8 +1463,12 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
log::info!("Received prompt request for session: {}", session_id);
log::debug!("Prompt blocks count: {}", params.prompt.len());
+ let Some(project_state) = self.0.read(cx).session_project_state(&session_id) else {
+ return Task::ready(Err(anyhow::anyhow!("Session not found")));
+ };
+
if let Some(parsed_command) = Command::parse(¶ms.prompt) {
- let registry = self.0.read(cx).context_server_registry.read(cx);
+ let registry = project_state.context_server_registry.read(cx);
let explicit_server_id = parsed_command
.explicit_server_id
@@ -1362,10 +1504,10 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
cx,
)
});
- };
+ }
};
- let path_style = self.0.read(cx).project.read(cx).path_style(cx);
+ let path_style = project_state.project.read(cx).path_style(cx);
self.run_turn(session_id, cx, move |thread, cx| {
let content: Vec<UserMessageContent> = params
@@ -1406,7 +1548,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
fn truncate(
&self,
- session_id: &agent_client_protocol::SessionId,
+ session_id: &acp::SessionId,
cx: &App,
) -> Option<Rc<dyn acp_thread::AgentSessionTruncate>> {
self.0.read_with(cx, |agent, _cx| {
@@ -1611,6 +1753,7 @@ impl NativeThreadEnvironment {
};
let parent_thread = parent_thread_entity.read(cx);
let current_depth = parent_thread.depth();
+ let parent_session_id = parent_thread.id().clone();
if current_depth >= MAX_SUBAGENT_DEPTH {
return Err(anyhow!(
@@ -1627,9 +1770,16 @@ impl NativeThreadEnvironment {
let session_id = subagent_thread.read(cx).id().clone();
- let acp_thread = self.agent.update(cx, |agent, cx| {
- agent.register_session(subagent_thread.clone(), cx)
- })?;
+ let acp_thread = self
+ .agent
+ .update(cx, |agent, cx| -> Result<Entity<AcpThread>> {
+ let project_id = agent
+ .sessions
+ .get(&parent_session_id)
+ .map(|s| s.project_id)
+ .context("parent session not found")?;
+ Ok(agent.register_session(subagent_thread.clone(), project_id, cx))
+ })??;
let depth = current_depth + 1;
@@ -1955,18 +2105,21 @@ mod internal_tests {
.await;
let project = Project::test(fs.clone(), [], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store,
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent =
+ cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
+
+ // Creating a session registers the project and triggers context building.
+ let connection = NativeAgentConnection(agent.clone());
+ let _acp_thread = cx
+ .update(|cx| Rc::new(connection).new_session(project.clone(), Path::new("/"), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
agent.read_with(cx, |agent, cx| {
- assert_eq!(agent.project_context.read(cx).worktrees, vec![])
+ let project_id = project.entity_id();
+ let state = agent.projects.get(&project_id).unwrap();
+ assert_eq!(state.project_context.read(cx).worktrees, vec![])
});
let worktree = project
@@ -1975,8 +2128,10 @@ mod internal_tests {
.unwrap();
cx.run_until_parked();
agent.read_with(cx, |agent, cx| {
+ let project_id = project.entity_id();
+ let state = agent.projects.get(&project_id).unwrap();
assert_eq!(
- agent.project_context.read(cx).worktrees,
+ state.project_context.read(cx).worktrees,
vec![WorktreeContext {
root_name: "a".into(),
abs_path: Path::new("/a").into(),
@@ -1989,12 +2144,14 @@ mod internal_tests {
fs.insert_file("/a/.rules", Vec::new()).await;
cx.run_until_parked();
agent.read_with(cx, |agent, cx| {
+ let project_id = project.entity_id();
+ let state = agent.projects.get(&project_id).unwrap();
let rules_entry = worktree
.read(cx)
.entry_for_path(rel_path(".rules"))
.unwrap();
assert_eq!(
- agent.project_context.read(cx).worktrees,
+ state.project_context.read(cx).worktrees,
vec![WorktreeContext {
root_name: "a".into(),
abs_path: Path::new("/a").into(),
@@ -2015,18 +2172,10 @@ mod internal_tests {
fs.insert_tree("/", json!({ "a": {} })).await;
let project = Project::test(fs.clone(), [], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let connection = NativeAgentConnection(
- NativeAgent::new(
- project.clone(),
- thread_store,
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap(),
- );
+ let connection =
+ NativeAgentConnection(cx.update(|cx| {
+ NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx)
+ }));
// Create a thread/session
let acp_thread = cx
@@ -2095,16 +2244,8 @@ mod internal_tests {
let thread_store = cx.new(|cx| ThreadStore::new(cx));
// Create the agent and connection
- let agent = NativeAgent::new(
- project.clone(),
- thread_store,
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent =
+ cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
let connection = NativeAgentConnection(agent.clone());
// Create a thread/session
@@ -2196,16 +2337,8 @@ mod internal_tests {
let project = Project::test(fs.clone(), [], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store,
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent =
+ cx.update(|cx| NativeAgent::new(thread_store, Templates::new(), None, fs.clone(), cx));
let connection = NativeAgentConnection(agent.clone());
let acp_thread = cx
@@ -2288,16 +2421,9 @@ mod internal_tests {
fs.insert_tree("/", json!({ "a": {} })).await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
// Register a thinking model.
@@ -2371,7 +2497,9 @@ mod internal_tests {
// Reload the thread and verify thinking_enabled is still true.
let reloaded_acp_thread = agent
- .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .update(cx, |agent, cx| {
+ agent.open_thread(session_id.clone(), project.clone(), cx)
+ })
.await
.unwrap();
let reloaded_thread = agent.read_with(cx, |agent, _| {
@@ -2394,16 +2522,9 @@ mod internal_tests {
fs.insert_tree("/", json!({ "a": {} })).await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
// Register a model where id() != name(), like real Anthropic models
@@ -2478,7 +2599,9 @@ mod internal_tests {
// Reload the thread and verify the model was preserved.
let reloaded_acp_thread = agent
- .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .update(cx, |agent, cx| {
+ agent.open_thread(session_id.clone(), project.clone(), cx)
+ })
.await
.unwrap();
let reloaded_thread = agent.read_with(cx, |agent, _| {
@@ -2513,16 +2636,9 @@ mod internal_tests {
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -2642,7 +2758,9 @@ mod internal_tests {
)]
);
let acp_thread = agent
- .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .update(cx, |agent, cx| {
+ agent.open_thread(session_id.clone(), project.clone(), cx)
+ })
.await
.unwrap();
acp_thread.read_with(cx, |thread, cx| {
@@ -35,11 +35,10 @@ impl AgentServer for NativeAgentServer {
fn connect(
&self,
- delegate: AgentServerDelegate,
+ _delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::debug!("NativeAgentServer::connect");
- let project = delegate.project().clone();
let fs = self.fs.clone();
let thread_store = self.thread_store.clone();
let prompt_store = PromptStore::global(cx);
@@ -49,9 +48,8 @@ impl AgentServer for NativeAgentServer {
let prompt_store = prompt_store.await?;
log::debug!("Creating native agent entity");
- let agent =
- NativeAgent::new(project, thread_store, templates, Some(prompt_store), fs, cx)
- .await?;
+ let agent = cx
+ .update(|cx| NativeAgent::new(thread_store, templates, Some(prompt_store), fs, cx));
// Create the connection wrapper
let connection = NativeAgentConnection(agent);
@@ -3181,16 +3181,8 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
let thread_store = cx.new(|cx| ThreadStore::new(cx));
// Create agent and connection
- let agent = NativeAgent::new(
- project.clone(),
- thread_store,
- templates.clone(),
- None,
- fake_fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx
+ .update(|cx| NativeAgent::new(thread_store, templates.clone(), None, fake_fs.clone(), cx));
let connection = NativeAgentConnection(agent.clone());
// Create a thread using new_thread
@@ -4388,16 +4380,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -4530,16 +4515,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -4685,16 +4663,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -4822,16 +4793,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -5201,16 +5165,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -5334,16 +5291,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -5515,16 +5465,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = NativeAgent::new(
- project.clone(),
- thread_store.clone(),
- Templates::new(),
- None,
- fs.clone(),
- &mut cx.to_async(),
- )
- .await
- .unwrap();
+ let agent = cx.update(|cx| {
+ NativeAgent::new(thread_store.clone(), Templates::new(), None, fs.clone(), cx)
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = cx
@@ -118,7 +118,7 @@ pub struct Edit {
pub new_text: String,
}
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
struct StreamingEditFileToolPartialInput {
#[serde(default)]
display_description: Option<String>,
@@ -132,7 +132,7 @@ struct StreamingEditFileToolPartialInput {
edits: Option<Vec<PartialEdit>>,
}
-#[derive(Default, Debug, Deserialize)]
+#[derive(Clone, Default, Debug, Deserialize)]
pub struct PartialEdit {
#[serde(default)]
pub old_text: Option<String>,
@@ -314,12 +314,19 @@ impl AgentTool for StreamingEditFileTool {
) -> Task<Result<Self::Output, Self::Output>> {
cx.spawn(async move |cx: &mut AsyncApp| {
let mut state: Option<EditSession> = None;
+ let mut last_partial: Option<StreamingEditFileToolPartialInput> = None;
loop {
futures::select! {
partial = input.recv_partial().fuse() => {
let Some(partial_value) = partial else { break };
if let Ok(parsed) = serde_json::from_value::<StreamingEditFileToolPartialInput>(partial_value) {
+ let path_complete = parsed.path.is_some()
+ && parsed.path.as_ref() == last_partial.as_ref().and_then(|p| p.path.as_ref());
+
+ last_partial = Some(parsed.clone());
+
if state.is_none()
+ && path_complete
&& let StreamingEditFileToolPartialInput {
path: Some(path),
display_description: Some(display_description),
@@ -1907,6 +1914,13 @@ mod tests {
let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
// Setup + single edit that stays in-progress (no second edit to prove completion)
+ sender.send_partial(json!({
+ "display_description": "Single edit",
+ "path": "root/file.txt",
+ "mode": "edit",
+ }));
+ cx.run_until_parked();
+
sender.send_partial(json!({
"display_description": "Single edit",
"path": "root/file.txt",
@@ -3475,6 +3489,12 @@ mod tests {
let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
// Transition to BufferResolved
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "path": "root/file.txt",
+ }));
+ cx.run_until_parked();
+
sender.send_partial(json!({
"display_description": "Overwrite file",
"path": "root/file.txt",
@@ -3550,8 +3570,9 @@ mod tests {
// Verify buffer still has old content (no content partial yet)
let buffer = project.update(cx, |project, cx| {
let path = project.find_project_path("root/file.txt", cx).unwrap();
- project.get_open_buffer(&path, cx).unwrap()
+ project.open_buffer(path, cx)
});
+ let buffer = buffer.await.unwrap();
assert_eq!(
buffer.read_with(cx, |b, _| b.text()),
"old line 1\nold line 2\nold line 3\n"
@@ -3735,6 +3756,106 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_streaming_edit_file_tool_fields_out_of_order_in_write_mode(
+ cx: &mut TestAppContext,
+ ) {
+ let (tool, _project, _action_log, _fs, _thread) =
+ setup_test(cx, json!({"file.txt": "old_content"})).await;
+ let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+ let (event_stream, _receiver) = ToolCallEventStream::test();
+ let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "write"
+ }));
+ cx.run_until_parked();
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "write",
+ "content": "new_content"
+ }));
+ cx.run_until_parked();
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "write",
+ "content": "new_content",
+ "path": "root"
+ }));
+ cx.run_until_parked();
+
+ // Send final.
+ sender.send_final(json!({
+ "display_description": "Overwrite file",
+ "mode": "write",
+ "content": "new_content",
+ "path": "root/file.txt"
+ }));
+
+ let result = task.await;
+ let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+ panic!("expected success");
+ };
+ assert_eq!(new_text, "new_content");
+ }
+
+ #[gpui::test]
+ async fn test_streaming_edit_file_tool_fields_out_of_order_in_edit_mode(
+ cx: &mut TestAppContext,
+ ) {
+ let (tool, _project, _action_log, _fs, _thread) =
+ setup_test(cx, json!({"file.txt": "old_content"})).await;
+ let (sender, input) = ToolInput::<StreamingEditFileToolInput>::test();
+ let (event_stream, _receiver) = ToolCallEventStream::test();
+ let task = cx.update(|cx| tool.clone().run(input, event_stream, cx));
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "edit"
+ }));
+ cx.run_until_parked();
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "edit",
+ "edits": [{"old_text": "old_content"}]
+ }));
+ cx.run_until_parked();
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "edit",
+ "edits": [{"old_text": "old_content", "new_text": "new_content"}]
+ }));
+ cx.run_until_parked();
+
+ sender.send_partial(json!({
+ "display_description": "Overwrite file",
+ "mode": "edit",
+ "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+ "path": "root"
+ }));
+ cx.run_until_parked();
+
+ // Send final.
+ sender.send_final(json!({
+ "display_description": "Overwrite file",
+ "mode": "edit",
+ "edits": [{"old_text": "old_content", "new_text": "new_content"}],
+ "path": "root/file.txt"
+ }));
+ cx.run_until_parked();
+
+ let result = task.await;
+ let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else {
+ panic!("expected success");
+ };
+ assert_eq!(new_text, "new_content");
+ }
+
async fn setup_test_with_fs(
cx: &mut TestAppContext,
fs: Arc<project::FakeFs>,
@@ -279,7 +279,7 @@ impl AcpConnection {
acp::InitializeRequest::new(acp::ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
- .fs(acp::FileSystemCapability::new()
+ .fs(acp::FileSystemCapabilities::new()
.read_text_file(true)
.write_text_file(true))
.terminal(true)
@@ -331,11 +331,11 @@ impl AcpConnection {
"env": command.env.clone().unwrap_or_default(),
});
let meta = acp::Meta::from_iter([("terminal-auth".to_string(), value)]);
- vec![
- acp::AuthMethod::new("spawn-gemini-cli", "Login")
+ vec![acp::AuthMethod::Agent(
+ acp::AuthMethodAgent::new("spawn-gemini-cli", "Login")
.description("Login with your Google or Vertex AI account")
.meta(meta),
- ]
+ )]
} else {
response.auth_methods
};
@@ -744,6 +744,31 @@ impl AgentConnection for AcpConnection {
})
}
+ fn supports_close_session(&self) -> bool {
+ self.agent_capabilities.session_capabilities.close.is_some()
+ }
+
+ fn close_session(
+ self: Rc<Self>,
+ session_id: &acp::SessionId,
+ cx: &mut App,
+ ) -> Task<Result<()>> {
+ if !self.agent_capabilities.session_capabilities.close.is_none() {
+ return Task::ready(Err(anyhow!(LoadError::Other(
+ "Closing sessions is not supported by this agent.".into()
+ ))));
+ }
+
+ let conn = self.connection.clone();
+ let session_id = session_id.clone();
+ cx.foreground_executor().spawn(async move {
+ conn.close_session(acp::CloseSessionRequest::new(session_id.clone()))
+ .await?;
+ self.sessions.borrow_mut().remove(&session_id);
+ Ok(())
+ })
+ }
+
fn auth_methods(&self) -> &[acp::AuthMethod] {
&self.auth_methods
}
@@ -1373,10 +1398,10 @@ impl acp::Client for ClientDelegate {
Ok(acp::CreateTerminalResponse::new(terminal_id))
}
- async fn kill_terminal_command(
+ async fn kill_terminal(
&self,
- args: acp::KillTerminalCommandRequest,
- ) -> Result<acp::KillTerminalCommandResponse, acp::Error> {
+ args: acp::KillTerminalRequest,
+ ) -> Result<acp::KillTerminalResponse, acp::Error> {
self.session_thread(&args.session_id)?
.update(&mut self.cx.clone(), |thread, cx| {
thread.kill_terminal(args.terminal_id, cx)
@@ -14,7 +14,6 @@ use project::agent_server_store::AgentServerStore;
use acp_thread::AgentConnection;
use anyhow::Result;
use gpui::{App, AppContext, Entity, SharedString, Task};
-use project::Project;
use settings::SettingsStore;
use std::{any::Any, rc::Rc, sync::Arc};
@@ -22,29 +21,19 @@ pub use acp::AcpConnection;
pub struct AgentServerDelegate {
store: Entity<AgentServerStore>,
- project: Entity<Project>,
- status_tx: Option<watch::Sender<SharedString>>,
new_version_available: Option<watch::Sender<Option<String>>>,
}
impl AgentServerDelegate {
pub fn new(
store: Entity<AgentServerStore>,
- project: Entity<Project>,
- status_tx: Option<watch::Sender<SharedString>>,
new_version_tx: Option<watch::Sender<Option<String>>>,
) -> Self {
Self {
store,
- project,
- status_tx,
new_version_available: new_version_tx,
}
}
-
- pub fn project(&self) -> &Entity<Project> {
- &self.project
- }
}
pub trait AgentServer: Send {
@@ -364,7 +364,6 @@ impl AgentServer for CustomAgentServer {
})?;
anyhow::Ok(agent.get_command(
extra_env,
- delegate.status_tx,
delegate.new_version_available,
&mut cx.to_async(),
))
@@ -431,7 +431,7 @@ pub async fn new_test_thread(
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
let store = project.read_with(cx, |project, _| project.agent_server_store().clone());
- let delegate = AgentServerDelegate::new(store, project.clone(), None, None);
+ let delegate = AgentServerDelegate::new(store, None);
let connection = cx.update(|cx| server.connect(delegate, cx)).await.unwrap();
@@ -132,7 +132,6 @@ languages = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
-
semver.workspace = true
reqwest_client.workspace = true
@@ -0,0 +1,163 @@
+use std::rc::Rc;
+
+use acp_thread::{AgentConnection, LoadError};
+use agent_servers::{AgentServer, AgentServerDelegate};
+use anyhow::Result;
+use collections::HashMap;
+use futures::{FutureExt, future::Shared};
+use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+use project::{AgentServerStore, AgentServersUpdated, Project};
+use watch::Receiver;
+
+use crate::ExternalAgent;
+use project::ExternalAgentServerName;
+
+pub enum ConnectionEntry {
+ Connecting {
+ connect_task: Shared<Task<Result<Rc<dyn AgentConnection>, LoadError>>>,
+ },
+ Connected {
+ connection: Rc<dyn AgentConnection>,
+ },
+ Error {
+ error: LoadError,
+ },
+}
+
+impl ConnectionEntry {
+ pub fn wait_for_connection(&self) -> Shared<Task<Result<Rc<dyn AgentConnection>, LoadError>>> {
+ match self {
+ ConnectionEntry::Connecting { connect_task } => connect_task.clone(),
+ ConnectionEntry::Connected { connection } => {
+ Task::ready(Ok(connection.clone())).shared()
+ }
+ ConnectionEntry::Error { error } => Task::ready(Err(error.clone())).shared(),
+ }
+ }
+}
+
+pub enum ConnectionEntryEvent {
+ NewVersionAvailable(SharedString),
+}
+
+impl EventEmitter<ConnectionEntryEvent> for ConnectionEntry {}
+
+pub struct AgentConnectionStore {
+ project: Entity<Project>,
+ entries: HashMap<ExternalAgent, Entity<ConnectionEntry>>,
+ _subscriptions: Vec<Subscription>,
+}
+
+impl AgentConnectionStore {
+ pub fn new(project: Entity<Project>, cx: &mut Context<Self>) -> Self {
+ let agent_server_store = project.read(cx).agent_server_store().clone();
+ let subscription = cx.subscribe(&agent_server_store, Self::handle_agent_servers_updated);
+ Self {
+ project,
+ entries: HashMap::default(),
+ _subscriptions: vec![subscription],
+ }
+ }
+
+ pub fn request_connection(
+ &mut self,
+ key: ExternalAgent,
+ server: Rc<dyn AgentServer>,
+ cx: &mut Context<Self>,
+ ) -> Entity<ConnectionEntry> {
+ self.entries.get(&key).cloned().unwrap_or_else(|| {
+ let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx);
+ let connect_task = connect_task.shared();
+
+ let entry = cx.new(|_cx| ConnectionEntry::Connecting {
+ connect_task: connect_task.clone(),
+ });
+
+ self.entries.insert(key.clone(), entry.clone());
+
+ cx.spawn({
+ let key = key.clone();
+ let entry = entry.clone();
+ async move |this, cx| match connect_task.await {
+ Ok(connection) => {
+ entry.update(cx, |entry, cx| {
+ if let ConnectionEntry::Connecting { .. } = entry {
+ *entry = ConnectionEntry::Connected { connection };
+ cx.notify();
+ }
+ });
+ }
+ Err(error) => {
+ entry.update(cx, |entry, cx| {
+ if let ConnectionEntry::Connecting { .. } = entry {
+ *entry = ConnectionEntry::Error { error };
+ cx.notify();
+ }
+ });
+ this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+ }
+ }
+ })
+ .detach();
+
+ cx.spawn({
+ let entry = entry.clone();
+ async move |this, cx| {
+ while let Ok(version) = new_version_rx.recv().await {
+ if let Some(version) = version {
+ entry.update(cx, |_entry, cx| {
+ cx.emit(ConnectionEntryEvent::NewVersionAvailable(
+ version.clone().into(),
+ ));
+ });
+ this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+ }
+ }
+ }
+ })
+ .detach();
+
+ entry
+ })
+ }
+
+ fn handle_agent_servers_updated(
+ &mut self,
+ store: Entity<AgentServerStore>,
+ _: &AgentServersUpdated,
+ cx: &mut Context<Self>,
+ ) {
+ let store = store.read(cx);
+ self.entries.retain(|key, _| match key {
+ ExternalAgent::NativeAgent => true,
+ ExternalAgent::Custom { name } => store
+ .external_agents
+ .contains_key(&ExternalAgentServerName(name.clone())),
+ });
+ cx.notify();
+ }
+
+ fn start_connection(
+ &self,
+ server: Rc<dyn AgentServer>,
+ cx: &mut Context<Self>,
+ ) -> (
+ Receiver<Option<String>>,
+ Task<Result<Rc<dyn AgentConnection>, LoadError>>,
+ ) {
+ let (new_version_tx, new_version_rx) = watch::channel::<Option<String>>(None);
+
+ let agent_server_store = self.project.read(cx).agent_server_store().clone();
+ let delegate = AgentServerDelegate::new(agent_server_store, Some(new_version_tx));
+
+ let connect_task = server.connect(delegate, cx);
+ let connect_task = cx.spawn(async move |_this, _cx| match connect_task.await {
+ Ok(connection) => Ok(connection),
+ Err(err) => match err.downcast::<LoadError>() {
+ Ok(load_error) => Err(load_error),
+ Err(err) => Err(LoadError::Other(SharedString::from(err.to_string()))),
+ },
+ });
+ (new_version_rx, connect_task)
+ }
+}
@@ -30,6 +30,7 @@ use zed_actions::agent::{
};
use crate::ManageProfiles;
+use crate::agent_connection_store::AgentConnectionStore;
use crate::ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal};
use crate::{
AddContextServer, AgentDiffPane, ConnectionView, CopyThreadToClipboard, Follow,
@@ -47,7 +48,7 @@ use crate::{
NewNativeAgentThreadFromSummary,
};
use crate::{
- ExpandMessageEditor, ThreadHistory, ThreadHistoryEvent,
+ ExpandMessageEditor, ThreadHistory, ThreadHistoryView, ThreadHistoryViewEvent,
text_thread_history::{TextThreadHistory, TextThreadHistoryEvent},
};
use agent_settings::AgentSettings;
@@ -64,9 +65,10 @@ 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,
- Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
+ Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem,
+ Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle,
+ Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
+ deferred, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{ConfigurationError, LanguageModelRegistry};
@@ -78,15 +80,17 @@ use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
use theme::ThemeSettings;
use ui::{
- Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding,
- PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
+ Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator,
+ KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
utils::WithRemSize,
};
use util::ResultExt as _;
use workspace::{
- CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
- WorkspaceId,
+ CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
+ MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom,
+ ToolbarItemView, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
+ multi_workspace_enabled,
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -98,6 +102,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
const DEFAULT_THREAD_TITLE: &str = "New Thread";
+#[derive(Default)]
+struct SidebarsByWindow(
+ collections::HashMap<gpui::WindowId, gpui::WeakEntity<crate::sidebar::Sidebar>>,
+);
+
+impl gpui::Global for SidebarsByWindow {}
+
+pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool {
+ if !multi_workspace_enabled(cx) {
+ return false;
+ }
+ let window_id = window.window_handle().window_id();
+ cx.try_global::<SidebarsByWindow>()
+ .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade())
+ .is_some_and(|sidebar| sidebar.read(cx).is_open())
+}
+
+fn find_or_create_sidebar_for_window(
+ window: &mut Window,
+ cx: &mut App,
+) -> Option<Entity<crate::sidebar::Sidebar>> {
+ let window_id = window.window_handle().window_id();
+ let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
+
+ if !cx.has_global::<SidebarsByWindow>() {
+ cx.set_global(SidebarsByWindow::default());
+ }
+
+ cx.global_mut::<SidebarsByWindow>()
+ .0
+ .retain(|_, weak| weak.upgrade().is_some());
+
+ let existing = cx
+ .global::<SidebarsByWindow>()
+ .0
+ .get(&window_id)
+ .and_then(|weak| weak.upgrade());
+
+ if let Some(sidebar) = existing {
+ return Some(sidebar);
+ }
+
+ let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx));
+ cx.global_mut::<SidebarsByWindow>()
+ .0
+ .insert(window_id, sidebar.downgrade());
+ Some(sidebar)
+}
+
fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
let key = i64::from(workspace_id).to_string();
@@ -423,6 +476,30 @@ pub fn init(cx: &mut App) {
panel.set_start_thread_in(action, cx);
});
}
+ })
+ .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
+ if !multi_workspace_enabled(cx) {
+ return;
+ }
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+ sidebar.update(cx, |sidebar, cx| {
+ sidebar.toggle(window, cx);
+ });
+ }
+ }
+ })
+ .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| {
+ if !multi_workspace_enabled(cx) {
+ return;
+ }
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ if let Some(sidebar) = panel.read(cx).sidebar.clone() {
+ sidebar.update(cx, |sidebar, cx| {
+ sidebar.focus_or_unfocus(workspace, window, cx);
+ });
+ }
+ }
});
},
)
@@ -786,10 +863,12 @@ pub struct AgentPanel {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
acp_history: Entity<ThreadHistory>,
+ acp_history_view: Entity<ThreadHistoryView>,
text_thread_history: Entity<TextThreadHistory>,
thread_store: Entity<ThreadStore>,
text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
+ connection_store: Entity<AgentConnectionStore>,
context_server_registry: Entity<ContextServerRegistry>,
configuration: Option<Entity<AgentConfiguration>>,
configuration_subscription: Option<Subscription>,
@@ -818,6 +897,7 @@ pub struct AgentPanel {
last_configuration_error_telemetry: Option<String>,
on_boarding_upsell_dismissed: AtomicBool,
_active_view_observation: Option<Subscription>,
+ pub(crate) sidebar: Option<Entity<crate::sidebar::Sidebar>>,
}
impl AgentPanel {
@@ -989,19 +1069,19 @@ impl AgentPanel {
let client = workspace.client().clone();
let workspace_id = workspace.database_id();
let workspace = workspace.weak_handle();
-
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
let thread_store = ThreadStore::global(cx);
- let acp_history = cx.new(|cx| ThreadHistory::new(None, window, cx));
+ let acp_history = cx.new(|cx| ThreadHistory::new(None, cx));
+ let acp_history_view = cx.new(|cx| ThreadHistoryView::new(acp_history.clone(), window, cx));
let text_thread_history =
cx.new(|cx| TextThreadHistory::new(text_thread_store.clone(), window, cx));
cx.subscribe_in(
- &acp_history,
+ &acp_history_view,
window,
|this, _, event, window, cx| match event {
- ThreadHistoryEvent::Open(thread) => {
+ ThreadHistoryViewEvent::Open(thread) => {
this.load_agent_thread(
thread.session_id.clone(),
thread.cwd.clone(),
@@ -1116,6 +1196,7 @@ impl AgentPanel {
language_registry,
text_thread_store,
prompt_store,
+ connection_store: cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)),
configuration: None,
configuration_subscription: None,
focus_handle: cx.focus_handle(),
@@ -1134,6 +1215,7 @@ impl AgentPanel {
pending_serialization: None,
onboarding,
acp_history,
+ acp_history_view,
text_thread_history,
thread_store,
selected_agent: AgentType::default(),
@@ -1146,10 +1228,17 @@ impl AgentPanel {
last_configuration_error_telemetry: None,
on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
_active_view_observation: None,
+ sidebar: None,
};
// Initial sync of agent servers from extensions
panel.sync_agent_servers_from_extensions(cx);
+
+ cx.defer_in(window, move |this, window, cx| {
+ this.sidebar = find_or_create_sidebar_for_window(window, cx);
+ cx.notify();
+ });
+
panel
}
@@ -2395,7 +2484,7 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let selected_agent = AgentType::from(ext_agent);
+ let selected_agent = AgentType::from(ext_agent.clone());
if self.selected_agent != selected_agent {
self.selected_agent = selected_agent;
self.serialize(cx);
@@ -2406,9 +2495,13 @@ impl AgentPanel {
.is_some()
.then(|| self.thread_store.clone());
+ let connection_store = self.connection_store.clone();
+
let server_view = cx.new(|cx| {
crate::ConnectionView::new(
server,
+ connection_store,
+ ext_agent,
resume_session_id,
cwd,
title,
@@ -2956,7 +3049,7 @@ impl Focusable for AgentPanel {
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::AgentThreads => self.acp_history_view.focus_handle(cx),
HistoryKind::TextThreads => self.text_thread_history.focus_handle(cx),
},
ActiveView::TextThread {
@@ -3519,9 +3612,109 @@ impl AgentPanel {
})
}
+ fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> {
+ if !multi_workspace_enabled(cx) {
+ return None;
+ }
+ let sidebar = self.sidebar.as_ref()?;
+ let is_open = sidebar.read(cx).is_open();
+ let width = sidebar.read(cx).width(cx);
+ let view: AnyView = sidebar.clone().into();
+ Some((view, width, is_open))
+ }
+
+ fn render_sidebar_toggle(&self, cx: &Context<Self>) -> Option<AnyElement> {
+ if !multi_workspace_enabled(cx) {
+ return None;
+ }
+ let sidebar = self.sidebar.as_ref()?;
+ let sidebar_read = sidebar.read(cx);
+ if sidebar_read.is_open() {
+ return None;
+ }
+ let has_notifications = sidebar_read.has_notifications(cx);
+
+ Some(
+ IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
+ .icon_size(IconSize::Small)
+ .when(has_notifications, |button| {
+ button
+ .indicator(Indicator::dot().color(Color::Accent))
+ .indicator_border_color(Some(cx.theme().colors().tab_bar_background))
+ })
+ .tooltip(move |_, cx| {
+ Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+ })
+ .into_any_element(),
+ )
+ }
+
+ fn render_sidebar(&self, cx: &Context<Self>) -> Option<AnyElement> {
+ let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?;
+ if !is_open {
+ return None;
+ }
+
+ let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
+ let sidebar = self.sidebar.as_ref()?.downgrade();
+
+ let resize_handle = deferred(
+ div()
+ .id("sidebar-resize-handle")
+ .absolute()
+ .when(docked_right, |this| {
+ this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+ })
+ .when(!docked_right, |this| {
+ this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+ })
+ .top(px(0.))
+ .h_full()
+ .w(SIDEBAR_RESIZE_HANDLE_SIZE)
+ .cursor_col_resize()
+ .on_drag(DraggedSidebar, |dragged, _, _, cx| {
+ cx.stop_propagation();
+ cx.new(|_| dragged.clone())
+ })
+ .on_mouse_down(MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation();
+ })
+ .on_mouse_up(MouseButton::Left, move |event, _, cx| {
+ if event.click_count == 2 {
+ sidebar
+ .update(cx, |sidebar, cx| {
+ sidebar.set_width(None, cx);
+ })
+ .ok();
+ cx.stop_propagation();
+ }
+ })
+ .occlude(),
+ );
+
+ Some(
+ div()
+ .id("sidebar-container")
+ .relative()
+ .h_full()
+ .w(sidebar_width)
+ .flex_shrink_0()
+ .when(docked_right, |this| this.border_l_1())
+ .when(!docked_right, |this| this.border_r_1())
+ .border_color(cx.theme().colors().border)
+ .child(sidebar_view)
+ .child(resize_handle)
+ .into_any_element(),
+ )
+ }
+
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let focus_handle = self.focus_handle(cx);
+ let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
let (selected_agent_custom_icon, selected_agent_label) =
if let AgentType::Custom { name, .. } = &self.selected_agent {
@@ -3984,6 +4177,9 @@ impl AgentPanel {
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
+ .when(!docked_right, |this| {
+ this.children(self.render_sidebar_toggle(cx))
+ })
.child(agent_selector_menu)
.child(self.render_start_thread_in_selector(cx)),
)
@@ -4000,7 +4196,10 @@ impl AgentPanel {
cx,
))
})
- .child(self.render_panel_options_menu(window, cx)),
+ .child(self.render_panel_options_menu(window, cx))
+ .when(docked_right, |this| {
+ this.children(self.render_sidebar_toggle(cx))
+ }),
)
.into_any_element()
} else {
@@ -4038,6 +4237,9 @@ impl AgentPanel {
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
+ .when(!docked_right, |this| {
+ this.children(self.render_sidebar_toggle(cx))
+ })
.child(match &self.active_view {
ActiveView::History { .. } | ActiveView::Configuration => {
self.render_toolbar_back_button(cx).into_any_element()
@@ -4060,7 +4262,10 @@ impl AgentPanel {
cx,
))
})
- .child(self.render_panel_options_menu(window, cx)),
+ .child(self.render_panel_options_menu(window, cx))
+ .when(docked_right, |this| {
+ this.children(self.render_sidebar_toggle(cx))
+ }),
)
.into_any_element()
}
@@ -4561,7 +4766,7 @@ impl Render for AgentPanel {
.child(server_view.clone())
.child(self.render_drag_target(cx)),
ActiveView::History { kind } => match kind {
- HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
+ HistoryKind::AgentThreads => parent.child(self.acp_history_view.clone()),
HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
},
ActiveView::TextThread {
@@ -4600,14 +4805,44 @@ impl Render for AgentPanel {
})
.children(self.render_trial_end_upsell(window, cx));
+ let sidebar = self.render_sidebar(cx);
+ let has_sidebar = sidebar.is_some();
+ let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
+
+ let panel = h_flex()
+ .size_full()
+ .when(has_sidebar, |this| {
+ this.on_drag_move(cx.listener(
+ move |this, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
+ if let Some(sidebar) = &this.sidebar {
+ let width = if docked_right {
+ e.bounds.right() - e.event.position.x
+ } else {
+ e.event.position.x
+ };
+ sidebar.update(cx, |sidebar, cx| {
+ sidebar.set_width(Some(width), cx);
+ });
+ }
+ },
+ ))
+ })
+ .map(|this| {
+ if docked_right {
+ this.child(content).children(sidebar)
+ } else {
+ this.children(sidebar).child(content)
+ }
+ });
+
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
.size_full()
- .child(content)
+ .child(panel)
.into_any()
}
- _ => content.into_any(),
+ _ => panel.into_any(),
}
}
}
@@ -1,4 +1,5 @@
mod agent_configuration;
+pub(crate) mod agent_connection_store;
mod agent_diff;
mod agent_model_selector;
mod agent_panel;
@@ -22,6 +23,7 @@ mod mode_selector;
mod model_selector;
mod model_selector_popover;
mod profile_selector;
+pub mod sidebar;
mod slash_command;
mod slash_command_picker;
mod terminal_codegen;
@@ -31,6 +33,7 @@ pub mod test_support;
mod text_thread_editor;
mod text_thread_history;
mod thread_history;
+mod thread_history_view;
mod ui;
use std::rc::Rc;
@@ -72,7 +75,8 @@ pub(crate) use mode_selector::ModeSelector;
pub(crate) use model_selector::ModelSelector;
pub(crate) use model_selector_popover::ModelSelectorPopover;
pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor};
-pub(crate) use thread_history::*;
+pub(crate) use thread_history::ThreadHistory;
+pub(crate) use thread_history_view::*;
use zed_actions;
actions!(
@@ -212,7 +216,7 @@ pub struct NewNativeAgentThreadFromSummary {
}
// TODO unify this with AgentType
-#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExternalAgent {
NativeAgent,
@@ -64,6 +64,7 @@ pub(crate) enum PromptContextType {
Thread,
Rules,
Diagnostics,
+ BranchDiff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -102,6 +103,7 @@ impl TryFrom<&str> for PromptContextType {
"thread" => Ok(Self::Thread),
"rule" => Ok(Self::Rules),
"diagnostics" => Ok(Self::Diagnostics),
+ "diff" => Ok(Self::BranchDiff),
_ => Err(format!("Invalid context picker mode: {}", value)),
}
}
@@ -116,6 +118,7 @@ impl PromptContextType {
Self::Thread => "thread",
Self::Rules => "rule",
Self::Diagnostics => "diagnostics",
+ Self::BranchDiff => "branch diff",
}
}
@@ -127,6 +130,7 @@ impl PromptContextType {
Self::Thread => "Threads",
Self::Rules => "Rules",
Self::Diagnostics => "Diagnostics",
+ Self::BranchDiff => "Branch Diff",
}
}
@@ -138,6 +142,7 @@ impl PromptContextType {
Self::Thread => IconName::Thread,
Self::Rules => IconName::Reader,
Self::Diagnostics => IconName::Warning,
+ Self::BranchDiff => IconName::GitBranch,
}
}
}
@@ -150,6 +155,12 @@ pub(crate) enum Match {
Fetch(SharedString),
Rules(RulesContextEntry),
Entry(EntryMatch),
+ BranchDiff(BranchDiffMatch),
+}
+
+#[derive(Debug, Clone)]
+pub struct BranchDiffMatch {
+ pub base_ref: SharedString,
}
impl Match {
@@ -162,6 +173,7 @@ impl Match {
Match::Symbol(_) => 1.,
Match::Rules(_) => 1.,
Match::Fetch(_) => 1.,
+ Match::BranchDiff(_) => 1.,
}
}
}
@@ -781,6 +793,47 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
}
}
+ fn build_branch_diff_completion(
+ base_ref: SharedString,
+ source_range: Range<Anchor>,
+ source: Arc<T>,
+ editor: WeakEntity<Editor>,
+ mention_set: WeakEntity<MentionSet>,
+ workspace: Entity<Workspace>,
+ cx: &mut App,
+ ) -> Completion {
+ let uri = MentionUri::GitDiff {
+ base_ref: base_ref.to_string(),
+ };
+ let crease_text: SharedString = format!("Branch Diff (vs {})", base_ref).into();
+ let display_text = format!("@{}", crease_text);
+ let new_text = format!("[{}]({}) ", display_text, uri.to_uri());
+ let new_text_len = new_text.len();
+ let icon_path = uri.icon_path(cx);
+
+ Completion {
+ replace_range: source_range.clone(),
+ new_text,
+ label: CodeLabel::plain(crease_text.to_string(), None),
+ documentation: None,
+ source: project::CompletionSource::Custom,
+ icon_path: Some(icon_path),
+ match_start: None,
+ snippet_deduplication_key: None,
+ insert_text_mode: None,
+ confirm: Some(confirm_completion_callback(
+ crease_text,
+ source_range.start,
+ new_text_len - 1,
+ uri,
+ source,
+ editor,
+ mention_set,
+ workspace,
+ )),
+ }
+ }
+
fn search_slash_commands(&self, query: String, cx: &mut App) -> Task<Vec<AvailableCommand>> {
let commands = self.source.available_commands(cx);
if commands.is_empty() {
@@ -812,6 +865,27 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
})
}
+ fn fetch_branch_diff_match(
+ &self,
+ workspace: &Entity<Workspace>,
+ cx: &mut App,
+ ) -> Option<Task<Option<BranchDiffMatch>>> {
+ let project = workspace.read(cx).project().clone();
+ let repo = project.read(cx).active_repository(cx)?;
+
+ let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
+
+ Some(cx.spawn(async move |_cx| {
+ let base_ref = default_branch_receiver
+ .await
+ .ok()
+ .and_then(|r| r.ok())
+ .flatten()?;
+
+ Some(BranchDiffMatch { base_ref })
+ }))
+ }
+
fn search_mentions(
&self,
mode: Option<PromptContextType>,
@@ -892,6 +966,8 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
Some(PromptContextType::Diagnostics) => Task::ready(Vec::new()),
+ Some(PromptContextType::BranchDiff) => Task::ready(Vec::new()),
+
None if query.is_empty() => {
let recent_task = self.recent_context_picker_entries(&workspace, cx);
let entries = self
@@ -905,9 +981,25 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
})
.collect::<Vec<_>>();
+ let branch_diff_task = if self
+ .source
+ .supports_context(PromptContextType::BranchDiff, cx)
+ {
+ self.fetch_branch_diff_match(&workspace, cx)
+ } else {
+ None
+ };
+
cx.spawn(async move |_cx| {
let mut matches = recent_task.await;
matches.extend(entries);
+
+ if let Some(branch_diff_task) = branch_diff_task {
+ if let Some(branch_diff_match) = branch_diff_task.await {
+ matches.push(Match::BranchDiff(branch_diff_match));
+ }
+ }
+
matches
})
}
@@ -924,7 +1016,16 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
.map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
.collect::<Vec<_>>();
- cx.background_spawn(async move {
+ let branch_diff_task = if self
+ .source
+ .supports_context(PromptContextType::BranchDiff, cx)
+ {
+ self.fetch_branch_diff_match(&workspace, cx)
+ } else {
+ None
+ };
+
+ cx.spawn(async move |cx| {
let mut matches = search_files_task
.await
.into_iter()
@@ -949,6 +1050,26 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
})
}));
+ if let Some(branch_diff_task) = branch_diff_task {
+ let branch_diff_keyword = PromptContextType::BranchDiff.keyword();
+ let branch_diff_matches = fuzzy::match_strings(
+ &[StringMatchCandidate::new(0, branch_diff_keyword)],
+ &query,
+ false,
+ true,
+ 1,
+ &Arc::new(AtomicBool::default()),
+ cx.background_executor().clone(),
+ )
+ .await;
+
+ if !branch_diff_matches.is_empty() {
+ if let Some(branch_diff_match) = branch_diff_task.await {
+ matches.push(Match::BranchDiff(branch_diff_match));
+ }
+ }
+ }
+
matches.sort_by(|a, b| {
b.score()
.partial_cmp(&a.score())
@@ -1364,6 +1485,17 @@ impl<T: PromptCompletionProviderDelegate> CompletionProvider for PromptCompletio
cx,
)
}
+ Match::BranchDiff(branch_diff) => {
+ Some(Self::build_branch_diff_completion(
+ branch_diff.base_ref,
+ source_range.clone(),
+ source.clone(),
+ editor.clone(),
+ mention_set.clone(),
+ workspace.clone(),
+ cx,
+ ))
+ }
})
.collect::<Vec<_>>()
});
@@ -5,10 +5,12 @@ use acp_thread::{
UserMessageId,
};
use acp_thread::{AgentConnection, Plan};
-use action_log::{ActionLog, ActionLogTelemetry};
+use action_log::{ActionLog, ActionLogTelemetry, DiffStats};
use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore};
use agent_client_protocol::{self as acp, PromptCapabilities};
-use agent_servers::{AgentServer, AgentServerDelegate};
+use agent_servers::AgentServer;
+#[cfg(test)]
+use agent_servers::AgentServerDelegate;
use agent_settings::{AgentProfileId, AgentSettings};
use anyhow::{Result, anyhow};
use arrayvec::ArrayVec;
@@ -44,7 +46,7 @@ use std::sync::Arc;
use std::time::Instant;
use std::{collections::BTreeMap, rc::Rc, time::Duration};
use terminal_view::terminal_panel::TerminalPanel;
-use text::{Anchor, ToPoint as _};
+use text::Anchor;
use theme::AgentFontSize;
use ui::{
Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton,
@@ -65,6 +67,7 @@ use super::entry_view_state::EntryViewState;
use super::thread_history::ThreadHistory;
use crate::ModeSelector;
use crate::ModelSelectorPopover;
+use crate::agent_connection_store::{AgentConnectionStore, ConnectionEntryEvent};
use crate::agent_diff::AgentDiff;
use crate::entry_view_state::{EntryViewEvent, ViewEvent};
use crate::message_editor::{MessageEditor, MessageEditorEvent};
@@ -73,10 +76,10 @@ use crate::ui::{AgentNotification, AgentNotificationEvent};
use crate::{
AgentDiffPane, AgentInitialContent, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall,
ClearMessageQueue, CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort,
- EditFirstQueuedMessage, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAddContextMenu,
- OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SendImmediately,
- SendNextQueuedMessage, ToggleFastMode, ToggleProfileSelector, ToggleThinkingEffortMenu,
- ToggleThinkingMode, UndoLastReject,
+ EditFirstQueuedMessage, ExpandMessageEditor, ExternalAgent, Follow, KeepAll, NewThread,
+ OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+ RemoveFirstQueuedMessage, SendImmediately, SendNextQueuedMessage, ToggleFastMode,
+ ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode, UndoLastReject,
};
const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);
@@ -303,6 +306,8 @@ impl EventEmitter<AcpServerViewEvent> for ConnectionView {}
pub struct ConnectionView {
agent: Rc<dyn AgentServer>,
+ connection_store: Entity<AgentConnectionStore>,
+ connection_key: ExternalAgent,
agent_server_store: Entity<AgentServerStore>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
@@ -414,6 +419,7 @@ pub struct ConnectedServerState {
threads: HashMap<acp::SessionId, Entity<ThreadView>>,
connection: Rc<dyn AgentConnection>,
conversation: Entity<Conversation>,
+ _connection_entry_subscription: Subscription,
}
enum AuthState {
@@ -434,9 +440,7 @@ impl AuthState {
struct LoadingView {
session_id: Option<acp::SessionId>,
- title: SharedString,
_load_task: Task<()>,
- _update_title_task: Task<anyhow::Result<()>>,
}
impl ConnectedServerState {
@@ -459,7 +463,7 @@ impl ConnectedServerState {
let tasks = self
.threads
.keys()
- .map(|id| self.connection.close_session(id, cx));
+ .map(|id| self.connection.clone().close_session(id, cx));
let task = futures::future::join_all(tasks);
cx.background_spawn(async move {
task.await;
@@ -470,6 +474,8 @@ impl ConnectedServerState {
impl ConnectionView {
pub fn new(
agent: Rc<dyn AgentServer>,
+ connection_store: Entity<AgentConnectionStore>,
+ connection_key: ExternalAgent,
resume_session_id: Option<acp::SessionId>,
cwd: Option<PathBuf>,
title: Option<SharedString>,
@@ -509,6 +515,8 @@ impl ConnectionView {
Self {
agent: agent.clone(),
+ connection_store: connection_store.clone(),
+ connection_key: connection_key.clone(),
agent_server_store,
workspace,
project: project.clone(),
@@ -516,6 +524,8 @@ impl ConnectionView {
prompt_store,
server_state: Self::initial_state(
agent.clone(),
+ connection_store,
+ connection_key,
resume_session_id,
cwd,
title,
@@ -558,6 +568,8 @@ impl ConnectionView {
let state = Self::initial_state(
self.agent.clone(),
+ self.connection_store.clone(),
+ self.connection_key.clone(),
resume_session_id,
cwd,
title,
@@ -584,6 +596,8 @@ impl ConnectionView {
fn initial_state(
agent: Rc<dyn AgentServer>,
+ connection_store: Entity<AgentConnectionStore>,
+ connection_key: ExternalAgent,
resume_session_id: Option<acp::SessionId>,
cwd: Option<PathBuf>,
title: Option<SharedString>,
@@ -640,29 +654,31 @@ impl ConnectionView {
.or_else(|| worktree_roots.first().cloned())
.unwrap_or_else(|| paths::home_dir().as_path().into());
- let (status_tx, mut status_rx) = watch::channel("Loading…".into());
- let (new_version_available_tx, mut new_version_available_rx) = watch::channel(None);
- let delegate = AgentServerDelegate::new(
- project.read(cx).agent_server_store().clone(),
- project.clone(),
- Some(status_tx),
- Some(new_version_available_tx),
- );
+ let connection_entry = connection_store.update(cx, |store, cx| {
+ store.request_connection(connection_key, agent.clone(), cx)
+ });
+
+ let connection_entry_subscription =
+ cx.subscribe(&connection_entry, |this, _entry, event, cx| match event {
+ ConnectionEntryEvent::NewVersionAvailable(version) => {
+ if let Some(thread) = this.active_thread() {
+ thread.update(cx, |thread, cx| {
+ thread.new_server_version_available = Some(version.clone());
+ cx.notify();
+ });
+ }
+ }
+ });
+
+ let connect_result = connection_entry.read(cx).wait_for_connection();
- let connect_task = agent.connect(delegate, cx);
let load_session_id = resume_session_id.clone();
let load_task = cx.spawn_in(window, async move |this, cx| {
- let connection = match connect_task.await {
+ let connection = match connect_result.await {
Ok(connection) => connection,
Err(err) => {
this.update_in(cx, |this, window, cx| {
- if err.downcast_ref::<LoadError>().is_some() {
- this.handle_load_error(load_session_id.clone(), err, window, cx);
- } else if let Some(active) = this.active_thread() {
- active.update(cx, |active, cx| active.handle_thread_error(err, cx));
- } else {
- this.handle_load_error(load_session_id.clone(), err, window, cx);
- }
+ this.handle_load_error(load_session_id.clone(), err, window, cx);
cx.notify();
})
.log_err();
@@ -776,52 +792,27 @@ impl ConnectionView {
active_id: Some(id.clone()),
threads: HashMap::from_iter([(id, current)]),
conversation,
+ _connection_entry_subscription: connection_entry_subscription,
}),
cx,
);
}
Err(err) => {
- this.handle_load_error(load_session_id.clone(), err, window, cx);
+ this.handle_load_error(
+ load_session_id.clone(),
+ LoadError::Other(err.to_string().into()),
+ window,
+ cx,
+ );
}
};
})
.log_err();
});
- cx.spawn(async move |this, cx| {
- while let Ok(new_version) = new_version_available_rx.recv().await {
- if let Some(new_version) = new_version {
- this.update(cx, |this, cx| {
- if let Some(thread) = this.active_thread() {
- thread.update(cx, |thread, _cx| {
- thread.new_server_version_available = Some(new_version.into());
- });
- }
- cx.notify();
- })
- .ok();
- }
- }
- })
- .detach();
-
- let loading_view = cx.new(|cx| {
- let update_title_task = cx.spawn(async move |this, cx| {
- loop {
- let status = status_rx.recv().await?;
- this.update(cx, |this: &mut LoadingView, cx| {
- this.title = status;
- cx.notify();
- })?;
- }
- });
-
- LoadingView {
- session_id: resume_session_id,
- title: "Loading…".into(),
- _load_task: load_task,
- _update_title_task: update_title_task,
- }
+ let loading_view = cx.new(|_cx| LoadingView {
+ session_id: resume_session_id,
+ _load_task: load_task,
});
ServerState::Loading(loading_view)
@@ -1099,6 +1090,7 @@ impl ConnectionView {
threads: HashMap::default(),
connection,
conversation: cx.new(|_cx| Conversation::default()),
+ _connection_entry_subscription: Subscription::new(|| {}),
}),
cx,
);
@@ -1111,7 +1103,7 @@ impl ConnectionView {
fn handle_load_error(
&mut self,
session_id: Option<acp::SessionId>,
- err: anyhow::Error,
+ err: LoadError,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1125,15 +1117,10 @@ impl ConnectionView {
self.focus_handle.focus(window, cx)
}
}
- let load_error = if let Some(load_err) = err.downcast_ref::<LoadError>() {
- load_err.clone()
- } else {
- LoadError::Other(format!("{:#}", err).into())
- };
- self.emit_load_error_telemetry(&load_error);
+ self.emit_load_error_telemetry(&err);
self.set_server_state(
ServerState::LoadError {
- error: load_error,
+ error: err,
session_id,
},
cx,
@@ -1172,10 +1159,10 @@ impl ConnectionView {
&self.workspace
}
- pub fn title(&self, cx: &App) -> SharedString {
+ pub fn title(&self, _cx: &App) -> SharedString {
match &self.server_state {
ServerState::Connected(_) => "New Thread".into(),
- ServerState::Loading(loading_view) => loading_view.read(cx).title.clone(),
+ ServerState::Loading(_) => "Loading…".into(),
ServerState::LoadError { error, .. } => match error {
LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
LoadError::FailedToInstall(_) => {
@@ -1444,7 +1431,7 @@ impl ConnectionView {
.connection()
.auth_methods()
.iter()
- .any(|method| method.id.0.as_ref() == "claude-login")
+ .any(|method| method.id().0.as_ref() == "claude-login")
{
available_commands.push(acp::AvailableCommand::new("login", "Authenticate"));
available_commands.push(acp::AvailableCommand::new("logout", "Authenticate"));
@@ -1508,10 +1495,15 @@ impl ConnectionView {
let agent_telemetry_id = connection.telemetry_id();
// Check for the experimental "terminal-auth" _meta field
- let auth_method = connection.auth_methods().iter().find(|m| m.id == method);
+ let auth_method = connection.auth_methods().iter().find(|m| m.id() == &method);
if let Some(terminal_auth) = auth_method
- .and_then(|a| a.meta.as_ref())
+ .and_then(|a| match a {
+ acp::AuthMethod::EnvVar(env_var) => env_var.meta.as_ref(),
+ acp::AuthMethod::Terminal(terminal) => terminal.meta.as_ref(),
+ acp::AuthMethod::Agent(agent) => agent.meta.as_ref(),
+ _ => None,
+ })
.and_then(|m| m.get("terminal-auth"))
{
// Extract terminal auth details from meta
@@ -1895,7 +1887,7 @@ impl ConnectionView {
.enumerate()
.rev()
.map(|(ix, method)| {
- let (method_id, name) = (method.id.0.clone(), method.name.clone());
+ let (method_id, name) = (method.id().0.clone(), method.name().to_string());
let agent_telemetry_id = connection.telemetry_id();
Button::new(method_id.clone(), name)
@@ -1907,8 +1899,8 @@ impl ConnectionView {
this.style(ButtonStyle::Outlined)
}
})
- .when_some(method.description.clone(), |this, description| {
- this.tooltip(Tooltip::text(description))
+ .when_some(method.description(), |this, description| {
+ this.tooltip(Tooltip::text(description.to_string()))
})
.on_click({
cx.listener(move |this, _, window, cx| {
@@ -2353,7 +2345,7 @@ impl ConnectionView {
}
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
- multi_workspace.read(cx).is_sidebar_open()
+ crate::agent_panel::sidebar_is_open(window, cx)
|| self.agent_panel_visible(&multi_workspace, cx)
} else {
self.workspace
@@ -2909,12 +2901,18 @@ pub(crate) mod tests {
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
// Create history without an initial session list - it will be set after connection
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::default_response()),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
None,
None,
None,
@@ -3009,12 +3007,18 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::new(ResumeOnlyAgentConnection)),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
Some(SessionId::new("resume-session")),
None,
None,
@@ -3062,12 +3066,18 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let _thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::new(connection)),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
Some(SessionId::new("session-1")),
Some(PathBuf::from("/project/subdir")),
None,
@@ -3113,12 +3123,18 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let _thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::new(connection)),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
Some(SessionId::new("session-1")),
Some(PathBuf::from("/some/other/path")),
None,
@@ -3164,12 +3180,18 @@ pub(crate) mod tests {
let captured_cwd = connection.captured_cwd.clone();
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let _thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::new(connection)),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
Some(SessionId::new("session-1")),
Some(PathBuf::from("/project/../outside")),
None,
@@ -3476,13 +3498,19 @@ pub(crate) mod tests {
// Set up thread view in workspace 1
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project1.clone(), cx)));
let agent = StubAgentServer::default_response();
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(agent),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
None,
None,
None,
@@ -3690,12 +3718,18 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(agent),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
None,
None,
None,
@@ -3753,8 +3787,16 @@ pub(crate) mod tests {
}
impl Render for ThreadViewItem {
- fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
- self.0.clone().into_any_element()
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ // Render the title editor in the element tree too. In the real app
+ // it is part of the agent panel
+ let title_editor = self
+ .0
+ .read(cx)
+ .active_thread()
+ .map(|t| t.read(cx).title_editor.clone());
+
+ v_flex().children(title_editor).child(self.0.clone())
}
}
@@ -4037,7 +4079,10 @@ pub(crate) mod tests {
fn new() -> Self {
Self {
authenticated: Arc::new(Mutex::new(false)),
- auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"),
+ auth_method: acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
+ Self::AUTH_METHOD_ID,
+ "Test Login",
+ )),
}
}
}
@@ -4090,7 +4135,7 @@ pub(crate) mod tests {
method_id: acp::AuthMethodId,
_cx: &mut App,
) -> Task<gpui::Result<()>> {
- if method_id == self.auth_method.id {
+ if &method_id == self.auth_method.id() {
*self.authenticated.lock() = true;
Task::ready(Ok(()))
} else {
@@ -4409,13 +4454,19 @@ pub(crate) mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
- let history = cx.update(|window, cx| cx.new(|cx| ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| ThreadHistory::new(None, cx)));
+ let connection_store =
+ cx.update(|_window, cx| cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)));
let connection = Rc::new(StubAgentConnection::new());
let thread_view = cx.update(|window, cx| {
cx.new(|cx| {
ConnectionView::new(
Rc::new(StubAgentServer::new(connection.as_ref().clone())),
+ connection_store,
+ ExternalAgent::Custom {
+ name: "Test".into(),
+ },
None,
None,
None,
@@ -6025,6 +6076,7 @@ pub(crate) mod tests {
init_test(cx);
let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
+ add_to_workspace(thread_view.clone(), cx);
let active = active_thread(&thread_view, cx);
let title_editor = cx.read(|cx| active.read(cx).title_editor.clone());
@@ -6034,9 +6086,12 @@ pub(crate) mod tests {
assert!(!editor.read_only(cx));
});
- title_editor.update_in(cx, |editor, window, cx| {
- editor.set_text("My Custom Title", window, cx);
- });
+ cx.focus(&thread_view);
+ cx.focus(&title_editor);
+
+ cx.dispatch_action(editor::actions::DeleteLine);
+ cx.simulate_input("My Custom Title");
+
cx.run_until_parked();
title_editor.read_with(cx, |editor, cx| {
@@ -156,43 +156,6 @@ impl ThreadFeedbackState {
}
}
-#[derive(Default, Clone, Copy)]
-struct DiffStats {
- lines_added: u32,
- lines_removed: u32,
-}
-
-impl DiffStats {
- fn single_file(buffer: &Buffer, diff: &BufferDiff, cx: &App) -> Self {
- let mut stats = DiffStats::default();
- let diff_snapshot = diff.snapshot(cx);
- let buffer_snapshot = buffer.snapshot();
- let base_text = diff_snapshot.base_text();
-
- for hunk in diff_snapshot.hunks(&buffer_snapshot) {
- let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
- stats.lines_added += added_rows;
-
- let base_start = hunk.diff_base_byte_range.start.to_point(base_text).row;
- let base_end = hunk.diff_base_byte_range.end.to_point(base_text).row;
- let removed_rows = base_end.saturating_sub(base_start);
- stats.lines_removed += removed_rows;
- }
-
- stats
- }
-
- fn all_files(changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>, cx: &App) -> Self {
- let mut total = DiffStats::default();
- for (buffer, diff) in changed_buffers {
- let stats = DiffStats::single_file(buffer.read(cx), diff.read(cx), cx);
- total.lines_added += stats.lines_added;
- total.lines_removed += stats.lines_removed;
- }
- total
- }
-}
-
pub enum AcpThreadViewEvent {
FirstSendRequested { content: Vec<acp::ContentBlock> },
}
@@ -1464,6 +1427,13 @@ impl ThreadView {
match event {
EditorEvent::BufferEdited => {
+ // We only want to set the title if the user has actively edited
+ // it. If the title editor is not focused, we programmatically
+ // changed the text, so we don't want to set the title again.
+ if !title_editor.read(cx).is_focused(window) {
+ return;
+ }
+
let new_title = title_editor.read(cx).text(cx);
thread.update(cx, |thread, cx| {
thread
@@ -7439,7 +7409,7 @@ impl ThreadView {
// TODO: Add keyboard navigation.
let is_hovered =
self.hovered_recent_history_item == Some(index);
- crate::thread_history::HistoryEntryElement::new(
+ crate::thread_history_view::HistoryEntryElement::new(
entry,
self.server_view.clone(),
)
@@ -508,8 +508,7 @@ mod tests {
});
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let view_state = cx.new(|_cx| {
EntryViewState::new(
@@ -2155,7 +2155,7 @@ pub mod test {
});
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history = cx.new(|cx| crate::ThreadHistory::new(None, window, cx));
+ let history = cx.new(|cx| crate::ThreadHistory::new(None, cx));
// Add editor to workspace
workspace.update(cx, |workspace, cx| {
@@ -147,10 +147,12 @@ impl MentionSet {
include_errors,
include_warnings,
} => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
+ MentionUri::GitDiff { base_ref } => {
+ self.confirm_mention_for_git_diff(base_ref.into(), cx)
+ }
MentionUri::PastedImage
| MentionUri::Selection { .. }
| MentionUri::TerminalSelection { .. }
- | MentionUri::GitDiff { .. }
| MentionUri::MergeConflict { .. } => {
Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
}
@@ -298,9 +300,8 @@ impl MentionSet {
debug_panic!("unexpected terminal URI");
Task::ready(Err(anyhow!("unexpected terminal URI")))
}
- MentionUri::GitDiff { .. } => {
- debug_panic!("unexpected git diff URI");
- Task::ready(Err(anyhow!("unexpected git diff URI")))
+ MentionUri::GitDiff { base_ref } => {
+ self.confirm_mention_for_git_diff(base_ref.into(), cx)
}
MentionUri::MergeConflict { .. } => {
debug_panic!("unexpected merge conflict URI");
@@ -553,19 +554,17 @@ impl MentionSet {
project.read(cx).fs().clone(),
thread_store,
));
- let delegate = AgentServerDelegate::new(
- project.read(cx).agent_server_store().clone(),
- project.clone(),
- None,
- None,
- );
+ let delegate =
+ AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None);
let connection = server.connect(delegate, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;
let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
let summary = agent
.0
- .update(cx, |agent, cx| agent.thread_summary(id, cx))
+ .update(cx, |agent, cx| {
+ agent.thread_summary(id, project.clone(), cx)
+ })
.await?;
Ok(Mention::Text {
content: summary.to_string(),
@@ -604,6 +603,42 @@ impl MentionSet {
})
})
}
+
+ fn confirm_mention_for_git_diff(
+ &self,
+ base_ref: SharedString,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<Mention>> {
+ let Some(project) = self.project.upgrade() else {
+ return Task::ready(Err(anyhow!("project not found")));
+ };
+
+ let Some(repo) = project.read(cx).active_repository(cx) else {
+ return Task::ready(Err(anyhow!("no active repository")));
+ };
+
+ let diff_receiver = repo.update(cx, |repo, cx| {
+ repo.diff(
+ git::repository::DiffType::MergeBase { base_ref: base_ref },
+ cx,
+ )
+ });
+
+ cx.spawn(async move |_, _| {
+ let diff_text = diff_receiver.await??;
+ if diff_text.is_empty() {
+ Ok(Mention::Text {
+ content: "No changes found in branch diff.".into(),
+ tracked_buffers: Vec::new(),
+ })
+ } else {
+ Ok(Mention::Text {
+ content: diff_text,
+ tracked_buffers: Vec::new(),
+ })
+ }
+ })
+ }
}
#[cfg(test)]
@@ -80,6 +80,7 @@ impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
PromptContextType::Diagnostics,
PromptContextType::Fetch,
PromptContextType::Rules,
+ PromptContextType::BranchDiff,
]);
}
supported
@@ -1707,8 +1708,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -1821,8 +1821,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let workspace_handle = workspace.downgrade();
let message_editor = workspace.update_in(cx, |_, window, cx| {
cx.new(|cx| {
@@ -1977,8 +1976,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let available_commands = Rc::new(RefCell::new(vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
@@ -2212,8 +2210,7 @@ mod tests {
}
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
@@ -2708,8 +2705,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2809,8 +2805,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let session_id = acp::SessionId::new("thread-123");
let title = Some("Previous Conversation".into());
@@ -2885,8 +2880,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2942,8 +2936,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -2997,8 +2990,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3053,8 +3045,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3118,8 +3109,7 @@ mod tests {
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3278,8 +3268,7 @@ mod tests {
});
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
// Create a new `MessageEditor`. The `EditorMode::full()` has to be used
// to ensure we have a fixed viewport, so we can eventually actually
@@ -3399,8 +3388,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3482,8 +3470,7 @@ mod tests {
let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
@@ -3567,8 +3554,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -3720,8 +3706,7 @@ mod tests {
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
- let history =
- cx.update(|window, cx| cx.new(|cx| crate::ThreadHistory::new(None, window, cx)));
+ let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx)));
let message_editor = cx.update(|window, cx| {
cx.new(|cx| {
@@ -1,34 +1,33 @@
+use crate::{AgentPanel, AgentPanelEvent, NewThread};
use acp_thread::ThreadStatus;
+use action_log::DiffStats;
use agent::ThreadStore;
use agent_client_protocol as acp;
-use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
+use agent_settings::AgentSettings;
use chrono::Utc;
+use db::kvp::KEY_VALUE_STORE;
use editor::{Editor, EditorElement, EditorStyle};
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
- AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
+ Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState,
Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
relative, rems,
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::Event as ProjectEvent;
-use recent_projects::RecentProjects;
use settings::Settings;
use std::collections::{HashMap, HashSet};
use std::mem;
use theme::{ActiveTheme, ThemeSettings};
-use ui::utils::TRAFFIC_LIGHT_PADDING;
use ui::{
- AgentThreadStatus, ButtonStyle, GradientFade, HighlightedLabel, IconButtonShape, KeyBinding,
- ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar,
- prelude::*,
+ AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
+ Tooltip, WithScrollbar, prelude::*,
};
+use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
- FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
- SidebarEvent, ToggleWorkspaceSidebar, Workspace,
+ MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
};
-use zed_actions::OpenRecent;
use zed_actions::editor::{MoveDown, MoveUp};
actions!(
@@ -45,6 +44,27 @@ const DEFAULT_WIDTH: Pixels = px(320.0);
const MIN_WIDTH: Pixels = px(200.0);
const MAX_WIDTH: Pixels = px(800.0);
const DEFAULT_THREADS_SHOWN: usize = 5;
+const SIDEBAR_STATE_KEY: &str = "sidebar_state";
+
+fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
+ KEY_VALUE_STORE
+ .scoped(SIDEBAR_STATE_KEY)
+ .read(&multi_workspace_id.to_string())
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str::<bool>(&json).ok())
+ .unwrap_or(false)
+}
+
+async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
+ if let Ok(json) = serde_json::to_string(&is_open) {
+ KEY_VALUE_STORE
+ .scoped(SIDEBAR_STATE_KEY)
+ .write(multi_workspace_id.to_string(), json)
+ .await
+ .log_err();
+ }
+}
#[derive(Clone, Debug)]
struct ActiveThreadInfo {
@@ -54,6 +74,7 @@ struct ActiveThreadInfo {
icon: IconName,
icon_from_external_svg: Option<SharedString>,
is_background: bool,
+ diff_stats: DiffStats,
}
impl From<&ActiveThreadInfo> for acp_thread::AgentSessionInfo {
@@ -79,6 +100,7 @@ struct ThreadEntry {
is_live: bool,
is_background: bool,
highlight_positions: Vec<usize>,
+ diff_stats: DiffStats,
}
#[derive(Clone)]
@@ -174,6 +196,8 @@ fn workspace_path_list_and_label(
pub struct Sidebar {
multi_workspace: WeakEntity<MultiWorkspace>,
+ persistence_key: Option<u64>,
+ is_open: bool,
width: Pixels,
focus_handle: FocusHandle,
filter_editor: Entity<Editor>,
@@ -187,11 +211,8 @@ pub struct Sidebar {
active_entry_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
- recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
}
-impl EventEmitter<SidebarEvent> for Sidebar {}
-
impl Sidebar {
pub fn new(
multi_workspace: Entity<MultiWorkspace>,
@@ -213,7 +234,6 @@ impl Sidebar {
window,
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
- this.focused_thread = None;
this.update_entries(cx);
}
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -271,8 +291,15 @@ impl Sidebar {
this.update_entries(cx);
});
+ let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
+ let is_open = persistence_key
+ .map(read_sidebar_open_state)
+ .unwrap_or(false);
+
Self {
multi_workspace: multi_workspace.downgrade(),
+ persistence_key,
+ is_open,
width: DEFAULT_WIDTH,
focus_handle,
filter_editor,
@@ -283,7 +310,6 @@ impl Sidebar {
active_entry_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
- recent_projects_popover_handle: PopoverMenuHandle::default(),
}
}
@@ -335,31 +361,10 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
- |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
- AgentPanelEvent::ActiveViewChanged => {
- match agent_panel.read(cx).active_connection_view() {
- Some(thread) => {
- if let Some(session_id) = thread.read(cx).parent_id(cx) {
- this.focused_thread = Some(session_id);
- }
- }
- None => {
- this.focused_thread = None;
- }
- }
- this.update_entries(cx);
- }
- AgentPanelEvent::ThreadFocused => {
- let new_focused = agent_panel
- .read(cx)
- .active_connection_view()
- .and_then(|thread| thread.read(cx).parent_id(cx));
- if new_focused.is_some() && new_focused != this.focused_thread {
- this.focused_thread = new_focused;
- this.update_entries(cx);
- }
- }
- AgentPanelEvent::BackgroundThreadChanged => {
+ |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+ AgentPanelEvent::ActiveViewChanged
+ | AgentPanelEvent::ThreadFocused
+ | AgentPanelEvent::BackgroundThreadChanged => {
this.update_entries(cx);
}
},
@@ -400,6 +405,8 @@ impl Sidebar {
}
};
+ let diff_stats = thread.action_log().read(cx).diff_stats(cx);
+
ActiveThreadInfo {
session_id,
title,
@@ -407,6 +414,7 @@ impl Sidebar {
icon,
icon_from_external_svg,
is_background,
+ diff_stats,
}
})
.collect()
@@ -420,6 +428,12 @@ impl Sidebar {
let workspaces = mw.workspaces().to_vec();
let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
+ self.focused_thread = active_workspace
+ .as_ref()
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .and_then(|panel| panel.read(cx).active_connection_view().cloned())
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+
let thread_store = ThreadStore::try_global(cx);
let query = self.filter_editor.read(cx).text(cx);
@@ -464,6 +478,7 @@ impl Sidebar {
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
});
}
}
@@ -489,6 +504,7 @@ impl Sidebar {
thread.icon_from_external_svg = info.icon_from_external_svg.clone();
thread.is_live = true;
thread.is_background = info.is_background;
+ thread.diff_stats = info.diff_stats;
}
}
@@ -658,7 +674,7 @@ impl Sidebar {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
- if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
+ if !multi_workspace_enabled(cx) {
return;
}
@@ -795,17 +811,6 @@ impl Sidebar {
.into_any_element()
};
- let color = cx.theme().colors();
- let base_bg = if is_active_workspace {
- color.ghost_element_selected
- } else {
- color.panel_background
- };
- let gradient_overlay =
- GradientFade::new(base_bg, color.element_hover, color.element_active)
- .width(px(48.0))
- .group_name(group_name.clone());
-
ListItem::new(id)
.group_name(group_name)
.toggle_state(is_active_workspace)
@@ -822,9 +827,9 @@ impl Sidebar {
.size(IconSize::Small)
.color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.6))),
)
- .child(label)
- .child(gradient_overlay),
+ .child(label),
)
+ .end_hover_gradient_overlay(true)
.end_hover_slot(
h_flex()
.when(workspace_count > 1, |this| {
@@ -897,8 +902,6 @@ impl Sidebar {
return;
};
- self.focused_thread = None;
-
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), cx);
});
@@ -1176,6 +1179,12 @@ impl Sidebar {
.highlight_positions(thread.highlight_positions.to_vec())
.status(thread.status)
.notified(has_notification)
+ .when(thread.diff_stats.lines_added > 0, |this| {
+ this.added(thread.diff_stats.lines_added as usize)
+ })
+ .when(thread.diff_stats.lines_removed > 0, |this| {
+ this.removed(thread.diff_stats.lines_removed as usize)
+ })
.selected(self.focused_thread.as_ref() == Some(&session_info.session_id))
.focused(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
@@ -1185,48 +1194,6 @@ impl Sidebar {
.into_any_element()
}
- fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
- let workspace = self
- .multi_workspace
- .upgrade()
- .map(|mw| mw.read(cx).workspace().downgrade());
-
- let focus_handle = workspace
- .as_ref()
- .and_then(|ws| ws.upgrade())
- .map(|w| w.read(cx).focus_handle(cx))
- .unwrap_or_else(|| cx.focus_handle());
-
- let popover_handle = self.recent_projects_popover_handle.clone();
-
- PopoverMenu::new("sidebar-recent-projects-menu")
- .with_handle(popover_handle)
- .menu(move |window, cx| {
- workspace.as_ref().map(|ws| {
- RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
- })
- })
- .trigger_with_tooltip(
- IconButton::new("open-project", IconName::OpenFolder)
- .icon_size(IconSize::Small)
- .selected_style(ButtonStyle::Tinted(TintColor::Accent)),
- |_window, cx| {
- Tooltip::for_action(
- "Recent Projects",
- &OpenRecent {
- create_new_window: false,
- },
- cx,
- )
- },
- )
- .anchor(gpui::Corner::TopLeft)
- .offset(gpui::Point {
- x: px(0.0),
- y: px(2.0),
- })
- }
-
fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
@@ -1355,26 +1322,66 @@ impl Sidebar {
}
}
-impl WorkspaceSidebar for Sidebar {
- fn width(&self, _cx: &App) -> Pixels {
- self.width
+impl Sidebar {
+ pub fn is_open(&self) -> bool {
+ self.is_open
}
- fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
- self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+ pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
+ if self.is_open == open {
+ return;
+ }
+ self.is_open = open;
cx.notify();
+ if let Some(key) = self.persistence_key {
+ let is_open = self.is_open;
+ cx.background_spawn(async move {
+ save_sidebar_open_state(key, is_open).await;
+ })
+ .detach();
+ }
}
- fn has_notifications(&self, _cx: &App) -> bool {
- !self.contents.notified_threads.is_empty()
+ pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let new_state = !self.is_open;
+ self.set_open(new_state, cx);
+ if new_state {
+ cx.focus_self(window);
+ }
+ }
+
+ pub fn focus_or_unfocus(
+ &mut self,
+ workspace: &mut Workspace,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if self.is_open {
+ let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
+ if sidebar_is_focused {
+ let active_pane = workspace.active_pane().clone();
+ let pane_focus = active_pane.read(cx).focus_handle(cx);
+ window.focus(&pane_focus, cx);
+ } else {
+ cx.focus_self(window);
+ }
+ } else {
+ self.set_open(true, cx);
+ cx.focus_self(window);
+ }
+ }
+
+ pub fn width(&self, _cx: &App) -> Pixels {
+ self.width
}
- fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
- self.recent_projects_popover_handle.toggle(window, cx);
+ pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+ self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
+ cx.notify();
}
- fn is_recent_projects_popover_deployed(&self) -> bool {
- self.recent_projects_popover_handle.is_deployed()
+ pub fn has_notifications(&self, _cx: &App) -> bool {
+ !self.contents.notified_threads.is_empty()
}
}
@@ -1386,18 +1393,9 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let titlebar_height = ui::utils::platform_title_bar_height(window);
let ui_font = theme::setup_ui_font(window, cx);
- let is_focused = self.focus_handle.is_focused(window)
- || self.filter_editor.focus_handle(cx).is_focused(window);
let has_query = self.has_filter_query(cx);
- let focus_tooltip_label = if is_focused {
- "Focus Workspace"
- } else {
- "Focus Sidebar"
- };
-
v_flex()
.id("workspace-sidebar")
.key_context("WorkspaceSidebar")
@@ -1413,69 +1411,26 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::cancel))
.font(ui_font)
- .h_full()
- .w(self.width)
+ .size_full()
.bg(cx.theme().colors().surface_background)
- .border_r_1()
- .border_color(cx.theme().colors().border)
- .child(
- h_flex()
- .flex_none()
- .h(titlebar_height)
- .w_full()
- .mt_px()
- .pb_px()
- .pr_1()
- .when_else(
- cfg!(target_os = "macos") && !window.is_fullscreen(),
- |this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
- |this| this.pl_2(),
- )
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child({
- let focus_handle_toggle = self.focus_handle.clone();
- let focus_handle_focus = self.focus_handle.clone();
- IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::element(move |_, cx| {
- v_flex()
- .gap_1()
- .child(
- h_flex()
- .gap_2()
- .justify_between()
- .child(Label::new("Close Sidebar"))
- .child(KeyBinding::for_action_in(
- &ToggleWorkspaceSidebar,
- &focus_handle_toggle,
- cx,
- )),
- )
- .child(
- h_flex()
- .pt_1()
- .gap_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .justify_between()
- .child(Label::new(focus_tooltip_label))
- .child(KeyBinding::for_action_in(
- &FocusWorkspaceSidebar,
- &focus_handle_focus,
- cx,
- )),
- )
- .into_any_element()
- }))
- .on_click(cx.listener(|_this, _, _window, cx| {
- cx.emit(SidebarEvent::Close);
- }))
- })
- .child(self.render_recent_projects_button(cx)),
- )
- .child(
+ .child({
+ let docked_right =
+ AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
+ let render_close_button = || {
+ IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen)
+ .icon_size(IconSize::Small)
+ .tooltip(move |_, cx| {
+ Tooltip::for_action(
+ "Close Threads Sidebar",
+ &ToggleWorkspaceSidebar,
+ cx,
+ )
+ })
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+ })
+ };
+
h_flex()
.flex_none()
.px_2p5()
@@ -1483,6 +1438,7 @@ impl Render for Sidebar {
.gap_2()
.border_b_1()
.border_color(cx.theme().colors().border)
+ .when(!docked_right, |this| this.child(render_close_button()))
.child(
Icon::new(IconName::MagnifyingGlass)
.size(IconSize::Small)
@@ -1499,8 +1455,9 @@ impl Render for Sidebar {
this.update_entries(cx);
})),
)
- }),
- )
+ })
+ .when(docked_right, |this| this.child(render_close_button()))
+ })
.child(
v_flex()
.flex_1()
@@ -1521,26 +1478,24 @@ impl Render for Sidebar {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
use acp_thread::StubAgentConnection;
use agent::ThreadStore;
- use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
use assistant_text_thread::TextThreadStore;
use chrono::DateTime;
use feature_flags::FeatureFlagAppExt as _;
use fs::FakeFs;
use gpui::TestAppContext;
- use settings::SettingsStore;
use std::sync::Arc;
use util::path_list::PathList;
fn init_test(cx: &mut TestAppContext) {
+ crate::test_support::init_test(cx);
cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- theme::init(theme::LoadThemes::JustBase, cx);
- editor::init(cx);
cx.update_flags(false, vec!["agent-v2".into()]);
ThreadStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ prompt_store::init(cx);
});
}
@@ -1581,14 +1536,33 @@ mod tests {
multi_workspace: &Entity<MultiWorkspace>,
cx: &mut gpui::VisualTestContext,
) -> Entity<Sidebar> {
- let multi_workspace = multi_workspace.clone();
- let sidebar =
- cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.register_sidebar(sidebar.clone(), window, cx);
+ let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
+ sidebar
+ }
+
+ fn setup_sidebar_with_agent_panel(
+ multi_workspace: &Entity<MultiWorkspace>,
+ cx: &mut gpui::VisualTestContext,
+ ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+ let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+ let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
+ let panel = add_agent_panel(&workspace, &project, cx);
+ workspace.update_in(cx, |workspace, window, cx| {
+ workspace.right_dock().update(cx, |dock, cx| {
+ if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
+ dock.activate_panel(panel_ix, window, cx);
+ }
+ dock.set_open(true, window, cx);
+ });
});
cx.run_until_parked();
- sidebar
+ let sidebar = panel.read_with(cx, |panel, _cx| {
+ panel
+ .sidebar
+ .clone()
+ .expect("AgentPanel should have created a sidebar")
+ });
+ (sidebar, panel)
}
async fn save_n_test_threads(
@@ -1635,16 +1609,10 @@ mod tests {
cx.run_until_parked();
}
- fn open_and_focus_sidebar(
- sidebar: &Entity<Sidebar>,
- multi_workspace: &Entity<MultiWorkspace>,
- cx: &mut gpui::VisualTestContext,
- ) {
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.toggle_sidebar(window, cx);
- });
+ fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
cx.run_until_parked();
- sidebar.update_in(cx, |_, window, cx| {
+ sidebar.update_in(cx, |sidebar, window, cx| {
+ sidebar.set_open(true, cx);
cx.focus_self(window);
});
cx.run_until_parked();
@@ -1898,7 +1866,7 @@ mod tests {
assert!(entries.iter().any(|e| e.contains("View More (12)")));
// Focus and navigate to View More, then confirm to expand by one batch
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
@@ -2033,6 +2001,7 @@ mod tests {
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
}),
// Active thread with Running status
ListEntry::Thread(ThreadEntry {
@@ -2051,6 +2020,7 @@ mod tests {
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
}),
// Active thread with Error status
ListEntry::Thread(ThreadEntry {
@@ -2069,6 +2039,7 @@ mod tests {
is_live: true,
is_background: false,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
}),
// Thread with WaitingForConfirmation status, not active
ListEntry::Thread(ThreadEntry {
@@ -2087,6 +2058,7 @@ mod tests {
is_live: false,
is_background: false,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
}),
// Background thread that completed (should show notification)
ListEntry::Thread(ThreadEntry {
@@ -2105,6 +2077,7 @@ mod tests {
is_live: true,
is_background: true,
highlight_positions: Vec::new(),
+ diff_stats: DiffStats::default(),
}),
// View More entry
ListEntry::ViewMore {
@@ -2181,7 +2154,7 @@ mod tests {
// Entries: [header, thread3, thread2, thread1]
// Focusing the sidebar does not set a selection; select_next/select_previous
// handle None gracefully by starting from the first or last entry.
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// First SelectNext from None starts at index 0
@@ -2230,7 +2203,7 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
// SelectLast jumps to the end
cx.dispatch_action(SelectLast);
@@ -2253,7 +2226,7 @@ mod tests {
// Open the sidebar so it's rendered, then focus it to trigger focus_in.
// focus_in no longer sets a default selection.
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// Manually set a selection, blur, then refocus — selection should be preserved
@@ -2285,6 +2258,9 @@ mod tests {
});
cx.run_until_parked();
+ // Add an agent panel to workspace 1 so the sidebar renders when it's active.
+ setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
save_n_test_threads(1, &path_list, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -2311,7 +2287,7 @@ mod tests {
);
// Focus the sidebar and manually select the header (index 0)
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@@ -2354,7 +2330,7 @@ mod tests {
assert!(entries.iter().any(|e| e.contains("View More (3)")));
// Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
@@ -2389,7 +2365,7 @@ mod tests {
);
// Focus sidebar and manually select the header (index 0). Press left to collapse.
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@@ -2429,7 +2405,7 @@ mod tests {
cx.run_until_parked();
// Focus sidebar (selection starts at None), then navigate down to the thread (child)
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@@ -2464,7 +2440,7 @@ mod tests {
);
// Focus sidebar — focus_in does not set a selection
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// First SelectNext from None starts at index 0 (header)
@@ -2497,7 +2473,7 @@ mod tests {
cx.run_until_parked();
// Focus sidebar (selection starts at None), navigate down to the thread (index 1)
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@@ -2517,24 +2493,6 @@ mod tests {
);
}
- async fn init_test_project_with_agent_panel(
- worktree_path: &str,
- cx: &mut TestAppContext,
- ) -> Entity<project::Project> {
- agent_ui::test_support::init_test(cx);
- cx.update(|cx| {
- cx.update_flags(false, vec!["agent-v2".into()]);
- ThreadStore::init_global(cx);
- language_model::LanguageModelRegistry::test(cx);
- });
-
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
- .await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
- project::Project::test(fs, [worktree_path.as_ref()], cx).await
- }
-
fn add_agent_panel(
workspace: &Entity<Workspace>,
project: &Entity<project::Project>,
@@ -2548,36 +2506,25 @@ mod tests {
})
}
- fn setup_sidebar_with_agent_panel(
- multi_workspace: &Entity<MultiWorkspace>,
- project: &Entity<project::Project>,
- cx: &mut gpui::VisualTestContext,
- ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
- let sidebar = setup_sidebar(multi_workspace, cx);
- let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
- let panel = add_agent_panel(&workspace, project, cx);
- (sidebar, panel)
- }
-
#[gpui::test]
async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
- let project = init_test_project_with_agent_panel("/my-project", cx).await;
+ let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+ let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
// Open thread A and keep it generating.
- let connection_a = StubAgentConnection::new();
- open_thread_with_connection(&panel, connection_a.clone(), cx);
+ let connection = StubAgentConnection::new();
+ open_thread_with_connection(&panel, connection.clone(), cx);
send_message(&panel, cx);
let session_id_a = active_session_id(&panel, cx);
save_thread_to_store(&session_id_a, &path_list, cx).await;
cx.update(|_, cx| {
- connection_a.send_update(
+ connection.send_update(
session_id_a.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
cx,
@@ -2586,11 +2533,10 @@ mod tests {
cx.run_until_parked();
// Open thread B (idle, default response) — thread A goes to background.
- let connection_b = StubAgentConnection::new();
- connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+ connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Done".into()),
)]);
- open_thread_with_connection(&panel, connection_b, cx);
+ open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let session_id_b = active_session_id(&panel, cx);
@@ -2608,10 +2554,10 @@ mod tests {
#[gpui::test]
async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
- let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+ let project_a = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) = cx
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
- let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
+ let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@@ -2815,7 +2761,7 @@ mod tests {
);
// User types a search query to filter down.
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
type_in_search(&sidebar, "alpha", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
@@ -3138,7 +3084,7 @@ mod tests {
// User focuses the sidebar and collapses the group using keyboard:
// manually select the header, then press CollapseSelectedEntry to collapse.
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@@ -3188,7 +3134,7 @@ mod tests {
}
cx.run_until_parked();
- open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
+ open_and_focus_sidebar(&sidebar, cx);
// User types "fix" — two threads match.
type_in_search(&sidebar, "fix", cx);
@@ -3365,10 +3311,10 @@ mod tests {
#[gpui::test]
async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
- let project = init_test_project_with_agent_panel("/my-project", cx).await;
+ let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+ let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
@@ -3413,10 +3359,10 @@ mod tests {
#[gpui::test]
async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
- let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+ let project_a = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) = cx
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
- let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
+ let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@@ -3445,7 +3391,8 @@ mod tests {
let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
// ── 1. Initial state: no focused thread ──────────────────────────────
- // Workspace B is active (just added), so its header is the active entry.
+ // Workspace B is active (just added) and has no thread, so its header
+ // is the active entry.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread, None,
@@ -3460,6 +3407,7 @@ mod tests {
);
});
+ // ── 2. Click thread in workspace A via sidebar ───────────────────────
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.activate_thread(
acp_thread::AgentSessionInfo {
@@ -3503,6 +3451,7 @@ mod tests {
);
});
+ // ── 3. Open thread in workspace B, then click it via sidebar ─────────
let connection_b = StubAgentConnection::new();
connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Thread B".into()),
@@ -3514,6 +3463,16 @@ mod tests {
save_thread_to_store(&session_id_b, &path_list_b, cx).await;
cx.run_until_parked();
+ // Opening a thread in a non-active workspace should NOT change
+ // focused_thread — it's derived from the active workspace.
+ sidebar.read_with(cx, |sidebar, _cx| {
+ assert_eq!(
+ sidebar.focused_thread.as_ref(),
+ Some(&session_id_a),
+ "Opening a thread in a non-active workspace should not affect focused_thread"
+ );
+ });
+
// Workspace A is currently active. Click a thread in workspace B,
// which also triggers a workspace switch.
sidebar.update_in(cx, |sidebar, window, cx| {
@@ -3548,25 +3507,30 @@ mod tests {
);
});
+ // ── 4. Switch workspace → focused_thread reflects new workspace ──────
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate_next_workspace(window, cx);
});
cx.run_until_parked();
+ // Workspace A is now active. Its agent panel still has session_id_a
+ // loaded, so focused_thread should reflect that.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
- sidebar.focused_thread, None,
- "External workspace switch should clear focused_thread"
+ sidebar.focused_thread.as_ref(),
+ Some(&session_id_a),
+ "Switching workspaces should derive focused_thread from the new active workspace"
);
let active_entry = sidebar
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
- matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
- "Active entry should be the workspace header after external switch"
+ matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
+ "Active entry should be workspace_a's active thread"
);
});
+ // ── 5. Opening a thread in a non-active workspace is ignored ──────────
let connection_b2 = StubAgentConnection::new();
connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("New thread".into()),
@@ -3577,69 +3541,48 @@ mod tests {
save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
cx.run_until_parked();
+ // Workspace A is still active, so focused_thread stays on session_id_a.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
- Some(&session_id_b2),
- "Opening a thread externally should set focused_thread"
- );
- });
-
- workspace_b.update_in(cx, |workspace, window, cx| {
- workspace.focus_handle(cx).focus(window, cx);
- });
- cx.run_until_parked();
-
- sidebar.read_with(cx, |sidebar, _cx| {
- assert_eq!(
- sidebar.focused_thread.as_ref(),
- Some(&session_id_b2),
- "Defocusing the sidebar should not clear focused_thread"
+ Some(&session_id_a),
+ "Opening a thread in a non-active workspace should not affect focused_thread"
);
});
+ // ── 6. Activating workspace B shows its active thread ────────────────
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.activate_workspace(&workspace_b, window, cx);
});
cx.run_until_parked();
+ // Workspace B is now active with session_id_b2 loaded.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
- sidebar.focused_thread, None,
- "Clicking a workspace header should clear focused_thread"
+ sidebar.focused_thread.as_ref(),
+ Some(&session_id_b2),
+ "Activating workspace_b should show workspace_b's active thread"
);
let active_entry = sidebar
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
- matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
- "Active entry should be the workspace header"
+ matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
+ "Active entry should be workspace_b's active thread"
);
});
- // ── 8. Focusing the agent panel thread restores focused_thread ────
- // Workspace B still has session_id_b2 loaded in the agent panel.
- // Clicking into the thread (simulated by focusing its view) should
- // set focused_thread via the ThreadFocused event.
- panel_b.update_in(cx, |panel, window, cx| {
- if let Some(thread_view) = panel.active_connection_view() {
- thread_view.read(cx).focus_handle(cx).focus(window, cx);
- }
+ // ── 7. Switching back to workspace A reflects its thread ─────────────
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate_next_workspace(window, cx);
});
cx.run_until_parked();
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
- Some(&session_id_b2),
- "Focusing the agent panel thread should set focused_thread"
- );
- let active_entry = sidebar
- .active_entry_index
- .and_then(|ix| sidebar.contents.entries.get(ix));
- assert!(
- matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
- "Active entry should be the focused thread"
+ Some(&session_id_a),
+ "Switching back to workspace_a should show its active thread"
);
});
}
@@ -1,118 +1,21 @@
-use crate::ConnectionView;
-use crate::{AgentPanel, RemoveHistory, RemoveSelectedThread};
use acp_thread::{AgentSessionInfo, AgentSessionList, AgentSessionListRequest, SessionListUpdate};
use agent_client_protocol as acp;
-use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
-use editor::{Editor, EditorEvent};
-use fuzzy::StringMatchCandidate;
-use gpui::{
- App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
- UniformListScrollHandle, WeakEntity, Window, uniform_list,
-};
-use std::{fmt::Display, ops::Range, rc::Rc};
-use text::Bias;
-use time::{OffsetDateTime, UtcOffset};
-use ui::{
- ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
- WithScrollbar, prelude::*,
-};
-
-const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
-
-fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
- entry
- .title
- .as_ref()
- .filter(|title| !title.is_empty())
- .unwrap_or(DEFAULT_TITLE)
-}
+use gpui::{App, Task};
+use std::rc::Rc;
+use ui::prelude::*;
pub struct ThreadHistory {
session_list: Option<Rc<dyn AgentSessionList>>,
sessions: Vec<AgentSessionInfo>,
- scroll_handle: UniformListScrollHandle,
- selected_index: usize,
- hovered_index: Option<usize>,
- search_editor: Entity<Editor>,
- search_query: SharedString,
- visible_items: Vec<ListItemType>,
- local_timezone: UtcOffset,
- confirming_delete_history: bool,
- _visible_items_task: Task<()>,
_refresh_task: Task<()>,
_watch_task: Option<Task<()>>,
- _subscriptions: Vec<gpui::Subscription>,
-}
-
-enum ListItemType {
- BucketSeparator(TimeBucket),
- Entry {
- entry: AgentSessionInfo,
- format: EntryTimeFormat,
- },
- SearchResult {
- entry: AgentSessionInfo,
- positions: Vec<usize>,
- },
-}
-
-impl ListItemType {
- fn history_entry(&self) -> Option<&AgentSessionInfo> {
- match self {
- ListItemType::Entry { entry, .. } => Some(entry),
- ListItemType::SearchResult { entry, .. } => Some(entry),
- _ => None,
- }
- }
}
-pub enum ThreadHistoryEvent {
- Open(AgentSessionInfo),
-}
-
-impl EventEmitter<ThreadHistoryEvent> for ThreadHistory {}
-
impl ThreadHistory {
- pub fn new(
- session_list: Option<Rc<dyn AgentSessionList>>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let search_editor = cx.new(|cx| {
- let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Search threads...", window, cx);
- editor
- });
-
- let search_editor_subscription =
- cx.subscribe(&search_editor, |this, search_editor, event, cx| {
- if let EditorEvent::BufferEdited = event {
- let query = search_editor.read(cx).text(cx);
- if this.search_query != query {
- this.search_query = query.into();
- this.update_visible_items(false, cx);
- }
- }
- });
-
- let scroll_handle = UniformListScrollHandle::default();
-
+ pub fn new(session_list: Option<Rc<dyn AgentSessionList>>, cx: &mut Context<Self>) -> Self {
let mut this = Self {
session_list: None,
sessions: Vec::new(),
- scroll_handle,
- selected_index: 0,
- hovered_index: None,
- visible_items: Default::default(),
- search_editor,
- local_timezone: UtcOffset::from_whole_seconds(
- chrono::Local::now().offset().local_minus_utc(),
- )
- .unwrap(),
- search_query: SharedString::default(),
- confirming_delete_history: false,
- _subscriptions: vec![search_editor_subscription],
- _visible_items_task: Task::ready(()),
_refresh_task: Task::ready(()),
_watch_task: None,
};
@@ -120,43 +23,6 @@ impl ThreadHistory {
this
}
- fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
- let entries = self.sessions.clone();
- let new_list_items = if self.search_query.is_empty() {
- self.add_list_separators(entries, cx)
- } else {
- self.filter_search_results(entries, cx)
- };
- let selected_history_entry = if preserve_selected_item {
- self.selected_history_entry().cloned()
- } else {
- None
- };
-
- self._visible_items_task = cx.spawn(async move |this, cx| {
- let new_visible_items = new_list_items.await;
- this.update(cx, |this, cx| {
- let new_selected_index = if let Some(history_entry) = selected_history_entry {
- new_visible_items
- .iter()
- .position(|visible_entry| {
- visible_entry
- .history_entry()
- .is_some_and(|entry| entry.session_id == history_entry.session_id)
- })
- .unwrap_or(0)
- } else {
- 0
- };
-
- this.visible_items = new_visible_items;
- this.set_selected_index(new_selected_index, Bias::Right, cx);
- cx.notify();
- })
- .ok();
- });
- }
-
pub fn set_session_list(
&mut self,
session_list: Option<Rc<dyn AgentSessionList>>,
@@ -170,9 +36,6 @@ impl ThreadHistory {
self.session_list = session_list;
self.sessions.clear();
- self.visible_items.clear();
- self.selected_index = 0;
- self._visible_items_task = Task::ready(());
self._refresh_task = Task::ready(());
let Some(session_list) = self.session_list.as_ref() else {
@@ -181,9 +44,8 @@ impl ThreadHistory {
return;
};
let Some(rx) = session_list.watch(cx) else {
- // No watch support - do a one-time refresh
self._watch_task = None;
- self.refresh_sessions(false, false, cx);
+ self.refresh_sessions(false, cx);
return;
};
session_list.notify_refresh();
@@ -191,7 +53,6 @@ impl ThreadHistory {
self._watch_task = Some(cx.spawn(async move |this, cx| {
while let Ok(first_update) = rx.recv().await {
let mut updates = vec![first_update];
- // Collect any additional updates that are already in the channel
while let Ok(update) = rx.try_recv() {
updates.push(update);
}
@@ -202,7 +63,7 @@ impl ThreadHistory {
.any(|u| matches!(u, SessionListUpdate::Refresh));
if needs_refresh {
- this.refresh_sessions(true, false, cx);
+ this.refresh_sessions(false, cx);
} else {
for update in updates {
if let SessionListUpdate::SessionInfo { session_id, update } = update {
@@ -217,7 +78,7 @@ impl ThreadHistory {
}
pub(crate) fn refresh_full_history(&mut self, cx: &mut Context<Self>) {
- self.refresh_sessions(true, true, cx);
+ self.refresh_sessions(true, cx);
}
fn apply_info_update(
@@ -258,23 +119,15 @@ impl ThreadHistory {
session.meta = Some(meta);
}
- self.update_visible_items(true, cx);
+ cx.notify();
}
- fn refresh_sessions(
- &mut self,
- preserve_selected_item: bool,
- load_all_pages: bool,
- cx: &mut Context<Self>,
- ) {
+ fn refresh_sessions(&mut self, load_all_pages: bool, cx: &mut Context<Self>) {
let Some(session_list) = self.session_list.clone() else {
- self.update_visible_items(preserve_selected_item, cx);
+ cx.notify();
return;
};
- // If a new refresh arrives while pagination is in progress, the previous
- // `_refresh_task` is cancelled. This is intentional (latest refresh wins),
- // but means sessions may be in a partial state until the new refresh completes.
self._refresh_task = cx.spawn(async move |this, cx| {
let mut cursor: Option<String> = None;
let mut is_first_page = true;
@@ -305,7 +158,7 @@ impl ThreadHistory {
} else {
this.sessions.extend(page_sessions);
}
- this.update_visible_items(preserve_selected_item, cx);
+ cx.notify();
})
.ok();
@@ -378,693 +231,11 @@ impl ThreadHistory {
}
}
- fn add_list_separators(
- &self,
- entries: Vec<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- cx.background_spawn(async move {
- let mut items = Vec::with_capacity(entries.len() + 1);
- let mut bucket = None;
- let today = Local::now().naive_local().date();
-
- for entry in entries.into_iter() {
- let entry_bucket = entry
- .updated_at
- .map(|timestamp| {
- let entry_date = timestamp.with_timezone(&Local).naive_local().date();
- TimeBucket::from_dates(today, entry_date)
- })
- .unwrap_or(TimeBucket::All);
-
- if Some(entry_bucket) != bucket {
- bucket = Some(entry_bucket);
- items.push(ListItemType::BucketSeparator(entry_bucket));
- }
-
- items.push(ListItemType::Entry {
- entry,
- format: entry_bucket.into(),
- });
- }
- items
- })
- }
-
- fn filter_search_results(
- &self,
- entries: Vec<AgentSessionInfo>,
- cx: &App,
- ) -> Task<Vec<ListItemType>> {
- let query = self.search_query.clone();
- cx.background_spawn({
- let executor = cx.background_executor().clone();
- async move {
- let mut candidates = Vec::with_capacity(entries.len());
-
- for (idx, entry) in entries.iter().enumerate() {
- candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
- }
-
- const MAX_MATCHES: usize = 100;
-
- let matches = fuzzy::match_strings(
- &candidates,
- &query,
- false,
- true,
- MAX_MATCHES,
- &Default::default(),
- executor,
- )
- .await;
-
- matches
- .into_iter()
- .map(|search_match| ListItemType::SearchResult {
- entry: entries[search_match.candidate_id].clone(),
- positions: search_match.positions,
- })
- .collect()
- }
- })
- }
-
- fn search_produced_no_matches(&self) -> bool {
- self.visible_items.is_empty() && !self.search_query.is_empty()
- }
-
- fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
- self.get_history_entry(self.selected_index)
- }
-
- fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
- self.visible_items.get(visible_items_ix)?.history_entry()
- }
-
- fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
- if self.visible_items.len() == 0 {
- self.selected_index = 0;
- return;
- }
- while matches!(
- self.visible_items.get(index),
- None | Some(ListItemType::BucketSeparator(..))
- ) {
- index = match bias {
- Bias::Left => {
- if index == 0 {
- self.visible_items.len() - 1
- } else {
- index - 1
- }
- }
- Bias::Right => {
- if index >= self.visible_items.len() - 1 {
- 0
- } else {
- index + 1
- }
- }
- };
- }
- self.selected_index = index;
- self.scroll_handle
- .scroll_to_item(index, ScrollStrategy::Top);
- cx.notify()
- }
-
- pub fn select_previous(
- &mut self,
- _: &menu::SelectPrevious,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_index == 0 {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- } else {
- self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
- }
- }
-
- pub fn select_next(
- &mut self,
- _: &menu::SelectNext,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_index == self.visible_items.len() - 1 {
- self.set_selected_index(0, Bias::Right, cx);
+ pub(crate) fn delete_sessions(&self, cx: &mut App) -> Task<anyhow::Result<()>> {
+ if let Some(session_list) = self.session_list.as_ref() {
+ session_list.delete_sessions(cx)
} else {
- self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
- }
- }
-
- fn select_first(
- &mut self,
- _: &menu::SelectFirst,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.set_selected_index(0, Bias::Right, cx);
- }
-
- fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
- self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
- }
-
- fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirm_entry(self.selected_index, cx);
- }
-
- fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(ix) else {
- return;
- };
- cx.emit(ThreadHistoryEvent::Open(entry.clone()));
- }
-
- fn remove_selected_thread(
- &mut self,
- _: &RemoveSelectedThread,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.remove_thread(self.selected_index, cx)
- }
-
- fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
- let Some(entry) = self.get_history_entry(visible_item_ix) else {
- return;
- };
- let Some(session_list) = self.session_list.as_ref() else {
- return;
- };
- if !session_list.supports_delete() {
- return;
- }
- let task = session_list.delete_session(&entry.session_id, cx);
- task.detach_and_log_err(cx);
- }
-
- fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- let Some(session_list) = self.session_list.as_ref() else {
- return;
- };
- if !session_list.supports_delete() {
- return;
- }
- session_list.delete_sessions(cx).detach_and_log_err(cx);
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = true;
- cx.notify();
- }
-
- fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
- self.confirming_delete_history = false;
- cx.notify();
- }
-
- fn render_list_items(
- &mut self,
- range: Range<usize>,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Vec<AnyElement> {
- self.visible_items
- .get(range.clone())
- .into_iter()
- .flatten()
- .enumerate()
- .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
- .collect()
- }
-
- fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
- match item {
- ListItemType::Entry { entry, format } => self
- .render_history_entry(entry, *format, ix, Vec::default(), cx)
- .into_any(),
- ListItemType::SearchResult { entry, positions } => self.render_history_entry(
- entry,
- EntryTimeFormat::DateAndTime,
- ix,
- positions.clone(),
- cx,
- ),
- ListItemType::BucketSeparator(bucket) => div()
- .px(DynamicSpacing::Base06.rems(cx))
- .pt_2()
- .pb_1()
- .child(
- Label::new(bucket.to_string())
- .size(LabelSize::XSmall)
- .color(Color::Muted),
- )
- .into_any_element(),
- }
- }
-
- fn render_history_entry(
- &self,
- entry: &AgentSessionInfo,
- format: EntryTimeFormat,
- ix: usize,
- highlight_positions: Vec<usize>,
- cx: &Context<Self>,
- ) -> AnyElement {
- let selected = ix == self.selected_index;
- let hovered = Some(ix) == self.hovered_index;
- let entry_time = entry.updated_at;
- let display_text = match (format, entry_time) {
- (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
- let now = Utc::now();
- let duration = now.signed_duration_since(entry_time);
- let days = duration.num_days();
-
- format!("{}d", days)
- }
- (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
- format.format_timestamp(entry_time.timestamp(), self.local_timezone)
- }
- (_, None) => "—".to_string(),
- };
-
- let title = thread_title(entry).clone();
- let full_date = entry_time
- .map(|time| {
- EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
- })
- .unwrap_or_else(|| "Unknown".to_string());
-
- h_flex()
- .w_full()
- .pb_1()
- .child(
- ListItem::new(ix)
- .rounded()
- .toggle_state(selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(
- HighlightedLabel::new(thread_title(entry), highlight_positions)
- .size(LabelSize::Small)
- .truncate(),
- )
- .child(
- Label::new(display_text)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .tooltip(move |_, cx| {
- Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
- })
- .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
- if *is_hovered {
- this.hovered_index = Some(ix);
- } else if this.hovered_index == Some(ix) {
- this.hovered_index = None;
- }
-
- cx.notify();
- }))
- .end_slot::<IconButton>(if hovered && self.supports_delete() {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click(cx.listener(move |this, _, _, cx| {
- this.remove_thread(ix, cx);
- cx.stop_propagation()
- })),
- )
- } else {
- None
- })
- .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
- )
- .into_any_element()
- }
-}
-
-impl Focusable for ThreadHistory {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.search_editor.focus_handle(cx)
- }
-}
-
-impl Render for ThreadHistory {
- fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let has_no_history = self.is_empty();
-
- v_flex()
- .key_context("ThreadHistory")
- .size_full()
- .bg(cx.theme().colors().panel_background)
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .on_action(cx.listener(Self::select_first))
- .on_action(cx.listener(Self::select_last))
- .on_action(cx.listener(Self::confirm))
- .on_action(cx.listener(Self::remove_selected_thread))
- .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
- this.remove_history(window, cx);
- }))
- .child(
- h_flex()
- .h(Tab::container_height(cx))
- .w_full()
- .py_1()
- .px_2()
- .gap_2()
- .justify_between()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .child(
- Icon::new(IconName::MagnifyingGlass)
- .color(Color::Muted)
- .size(IconSize::Small),
- )
- .child(self.search_editor.clone()),
- )
- .child({
- let view = v_flex()
- .id("list-container")
- .relative()
- .overflow_hidden()
- .flex_grow();
-
- if has_no_history {
- view.justify_center().items_center().child(
- Label::new("You don't have any past threads yet.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- } else if self.search_produced_no_matches() {
- view.justify_center()
- .items_center()
- .child(Label::new("No threads match your search.").size(LabelSize::Small))
- } else {
- view.child(
- uniform_list(
- "thread-history",
- self.visible_items.len(),
- cx.processor(|this, range: Range<usize>, window, cx| {
- this.render_list_items(range, window, cx)
- }),
- )
- .p_1()
- .pr_4()
- .track_scroll(&self.scroll_handle)
- .flex_grow(),
- )
- .vertical_scrollbar_for(&self.scroll_handle, window, cx)
- }
- })
- .when(!has_no_history && self.supports_delete(), |this| {
- this.child(
- h_flex()
- .p_2()
- .border_t_1()
- .border_color(cx.theme().colors().border_variant)
- .when(!self.confirming_delete_history, |this| {
- this.child(
- Button::new("delete_history", "Delete All History")
- .full_width()
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.prompt_delete_history(window, cx);
- })),
- )
- })
- .when(self.confirming_delete_history, |this| {
- this.w_full()
- .gap_2()
- .flex_wrap()
- .justify_between()
- .child(
- h_flex()
- .flex_wrap()
- .gap_1()
- .child(
- Label::new("Delete all threads?")
- .size(LabelSize::Small),
- )
- .child(
- Label::new("You won't be able to recover them later.")
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
- )
- .child(
- h_flex()
- .gap_1()
- .child(
- Button::new("cancel_delete", "Cancel")
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|this, _, window, cx| {
- this.cancel_delete_history(window, cx);
- })),
- )
- .child(
- Button::new("confirm_delete", "Delete")
- .style(ButtonStyle::Tinted(ui::TintColor::Error))
- .color(Color::Error)
- .label_size(LabelSize::Small)
- .on_click(cx.listener(|_, _, window, cx| {
- window.dispatch_action(
- Box::new(RemoveHistory),
- cx,
- );
- })),
- ),
- )
- }),
- )
- })
- }
-}
-
-#[derive(IntoElement)]
-pub struct HistoryEntryElement {
- entry: AgentSessionInfo,
- thread_view: WeakEntity<ConnectionView>,
- selected: bool,
- hovered: bool,
- supports_delete: bool,
- on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
-}
-
-impl HistoryEntryElement {
- pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
- Self {
- entry,
- thread_view,
- selected: false,
- hovered: false,
- supports_delete: false,
- on_hover: Box::new(|_, _, _| {}),
- }
- }
-
- pub fn supports_delete(mut self, supports_delete: bool) -> Self {
- self.supports_delete = supports_delete;
- self
- }
-
- pub fn hovered(mut self, hovered: bool) -> Self {
- self.hovered = hovered;
- self
- }
-
- pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
- self.on_hover = Box::new(on_hover);
- self
- }
-}
-
-impl RenderOnce for HistoryEntryElement {
- fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
- let id = ElementId::Name(self.entry.session_id.0.clone().into());
- let title = thread_title(&self.entry).clone();
- let formatted_time = self
- .entry
- .updated_at
- .map(|timestamp| {
- let now = chrono::Utc::now();
- let duration = now.signed_duration_since(timestamp);
-
- if duration.num_days() > 0 {
- format!("{}d", duration.num_days())
- } else if duration.num_hours() > 0 {
- format!("{}h ago", duration.num_hours())
- } else if duration.num_minutes() > 0 {
- format!("{}m ago", duration.num_minutes())
- } else {
- "Just now".to_string()
- }
- })
- .unwrap_or_else(|| "Unknown".to_string());
-
- ListItem::new(id)
- .rounded()
- .toggle_state(self.selected)
- .spacing(ListItemSpacing::Sparse)
- .start_slot(
- h_flex()
- .w_full()
- .gap_2()
- .justify_between()
- .child(Label::new(title).size(LabelSize::Small).truncate())
- .child(
- Label::new(formatted_time)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- ),
- )
- .on_hover(self.on_hover)
- .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
- Some(
- IconButton::new("delete", IconName::Trash)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(move |_window, cx| {
- Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
- })
- .on_click({
- let thread_view = self.thread_view.clone();
- let session_id = self.entry.session_id.clone();
-
- move |_event, _window, cx| {
- if let Some(thread_view) = thread_view.upgrade() {
- thread_view.update(cx, |thread_view, cx| {
- thread_view.delete_history_entry(&session_id, cx);
- });
- }
- }
- }),
- )
- } else {
- None
- })
- .on_click({
- let thread_view = self.thread_view.clone();
- let entry = self.entry;
-
- move |_event, window, cx| {
- if let Some(workspace) = thread_view
- .upgrade()
- .and_then(|view| view.read(cx).workspace().upgrade())
- {
- if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.load_agent_thread(
- entry.session_id.clone(),
- entry.cwd.clone(),
- entry.title.clone(),
- window,
- cx,
- );
- });
- }
- }
- }
- })
- }
-}
-
-#[derive(Clone, Copy)]
-pub enum EntryTimeFormat {
- DateAndTime,
- TimeOnly,
-}
-
-impl EntryTimeFormat {
- fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
- let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
-
- match self {
- EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
- timestamp,
- OffsetDateTime::now_utc(),
- timezone,
- time_format::TimestampFormat::EnhancedAbsolute,
- ),
- EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
- }
- }
-}
-
-impl From<TimeBucket> for EntryTimeFormat {
- fn from(bucket: TimeBucket) -> Self {
- match bucket {
- TimeBucket::Today => EntryTimeFormat::TimeOnly,
- TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
- TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
- TimeBucket::All => EntryTimeFormat::DateAndTime,
- }
- }
-}
-
-#[derive(PartialEq, Eq, Clone, Copy, Debug)]
-enum TimeBucket {
- Today,
- Yesterday,
- ThisWeek,
- PastWeek,
- All,
-}
-
-impl TimeBucket {
- fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
- if date == reference {
- return TimeBucket::Today;
- }
-
- if date == reference - TimeDelta::days(1) {
- return TimeBucket::Yesterday;
- }
-
- let week = date.iso_week();
-
- if reference.iso_week() == week {
- return TimeBucket::ThisWeek;
- }
-
- let last_week = (reference - TimeDelta::days(7)).iso_week();
-
- if week == last_week {
- return TimeBucket::PastWeek;
- }
-
- TimeBucket::All
- }
-}
-
-impl Display for TimeBucket {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- TimeBucket::Today => write!(f, "Today"),
- TimeBucket::Yesterday => write!(f, "Yesterday"),
- TimeBucket::ThisWeek => write!(f, "This Week"),
- TimeBucket::PastWeek => write!(f, "Past Week"),
- TimeBucket::All => write!(f, "All"),
+ Task::ready(Ok(()))
}
}
}
@@ -1073,7 +244,6 @@ impl Display for TimeBucket {
mod tests {
use super::*;
use acp_thread::AgentSessionListResponse;
- use chrono::NaiveDate;
use gpui::TestAppContext;
use std::{
any::Any,
@@ -1246,9 +416,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, _cx| {
@@ -1270,9 +438,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
session_list.clear_requested_cursors();
@@ -1307,9 +473,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1340,9 +504,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -1371,9 +533,7 @@ mod tests {
vec![test_session("session-2", "Second")],
));
- let (history, cx) = cx.add_window_view(|window, cx| {
- ThreadHistory::new(Some(session_list.clone()), window, cx)
- });
+ let history = cx.new(|cx| ThreadHistory::new(Some(session_list.clone()), cx));
cx.run_until_parked();
history.update(cx, |history, cx| history.refresh_full_history(cx));
@@ -0,0 +1,878 @@
+use crate::thread_history::ThreadHistory;
+use crate::{AgentPanel, ConnectionView, RemoveHistory, RemoveSelectedThread};
+use acp_thread::AgentSessionInfo;
+use chrono::{Datelike as _, Local, NaiveDate, TimeDelta, Utc};
+use editor::{Editor, EditorEvent};
+use fuzzy::StringMatchCandidate;
+use gpui::{
+ AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
+ UniformListScrollHandle, WeakEntity, Window, uniform_list,
+};
+use std::{fmt::Display, ops::Range};
+use text::Bias;
+use time::{OffsetDateTime, UtcOffset};
+use ui::{
+ ElementId, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip,
+ WithScrollbar, prelude::*,
+};
+
+const DEFAULT_TITLE: &SharedString = &SharedString::new_static("New Thread");
+
+pub(crate) fn thread_title(entry: &AgentSessionInfo) -> &SharedString {
+ entry
+ .title
+ .as_ref()
+ .filter(|title| !title.is_empty())
+ .unwrap_or(DEFAULT_TITLE)
+}
+
+pub struct ThreadHistoryView {
+ history: Entity<ThreadHistory>,
+ scroll_handle: UniformListScrollHandle,
+ selected_index: usize,
+ hovered_index: Option<usize>,
+ search_editor: Entity<Editor>,
+ search_query: SharedString,
+ visible_items: Vec<ListItemType>,
+ local_timezone: UtcOffset,
+ confirming_delete_history: bool,
+ _visible_items_task: Task<()>,
+ _subscriptions: Vec<gpui::Subscription>,
+}
+
+enum ListItemType {
+ BucketSeparator(TimeBucket),
+ Entry {
+ entry: AgentSessionInfo,
+ format: EntryTimeFormat,
+ },
+ SearchResult {
+ entry: AgentSessionInfo,
+ positions: Vec<usize>,
+ },
+}
+
+impl ListItemType {
+ fn history_entry(&self) -> Option<&AgentSessionInfo> {
+ match self {
+ ListItemType::Entry { entry, .. } => Some(entry),
+ ListItemType::SearchResult { entry, .. } => Some(entry),
+ _ => None,
+ }
+ }
+}
+
+pub enum ThreadHistoryViewEvent {
+ Open(AgentSessionInfo),
+}
+
+impl EventEmitter<ThreadHistoryViewEvent> for ThreadHistoryView {}
+
+impl ThreadHistoryView {
+ pub fn new(
+ history: Entity<ThreadHistory>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let search_editor = cx.new(|cx| {
+ let mut editor = Editor::single_line(window, cx);
+ editor.set_placeholder_text("Search threads...", window, cx);
+ editor
+ });
+
+ let search_editor_subscription =
+ cx.subscribe(&search_editor, |this, search_editor, event, cx| {
+ if let EditorEvent::BufferEdited = event {
+ let query = search_editor.read(cx).text(cx);
+ if this.search_query != query {
+ this.search_query = query.into();
+ this.update_visible_items(false, cx);
+ }
+ }
+ });
+
+ let history_subscription = cx.observe(&history, |this, _, cx| {
+ this.update_visible_items(true, cx);
+ });
+
+ let scroll_handle = UniformListScrollHandle::default();
+
+ let mut this = Self {
+ history,
+ scroll_handle,
+ selected_index: 0,
+ hovered_index: None,
+ visible_items: Default::default(),
+ search_editor,
+ local_timezone: UtcOffset::from_whole_seconds(
+ chrono::Local::now().offset().local_minus_utc(),
+ )
+ .unwrap(),
+ search_query: SharedString::default(),
+ confirming_delete_history: false,
+ _subscriptions: vec![search_editor_subscription, history_subscription],
+ _visible_items_task: Task::ready(()),
+ };
+ this.update_visible_items(false, cx);
+ this
+ }
+
+ fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context<Self>) {
+ let entries = self.history.read(cx).sessions().to_vec();
+ let new_list_items = if self.search_query.is_empty() {
+ self.add_list_separators(entries, cx)
+ } else {
+ self.filter_search_results(entries, cx)
+ };
+ let selected_history_entry = if preserve_selected_item {
+ self.selected_history_entry().cloned()
+ } else {
+ None
+ };
+
+ self._visible_items_task = cx.spawn(async move |this, cx| {
+ let new_visible_items = new_list_items.await;
+ this.update(cx, |this, cx| {
+ let new_selected_index = if let Some(history_entry) = selected_history_entry {
+ new_visible_items
+ .iter()
+ .position(|visible_entry| {
+ visible_entry
+ .history_entry()
+ .is_some_and(|entry| entry.session_id == history_entry.session_id)
+ })
+ .unwrap_or(0)
+ } else {
+ 0
+ };
+
+ this.visible_items = new_visible_items;
+ this.set_selected_index(new_selected_index, Bias::Right, cx);
+ cx.notify();
+ })
+ .ok();
+ });
+ }
+
+ fn add_list_separators(
+ &self,
+ entries: Vec<AgentSessionInfo>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ cx.background_spawn(async move {
+ let mut items = Vec::with_capacity(entries.len() + 1);
+ let mut bucket = None;
+ let today = Local::now().naive_local().date();
+
+ for entry in entries.into_iter() {
+ let entry_bucket = entry
+ .updated_at
+ .map(|timestamp| {
+ let entry_date = timestamp.with_timezone(&Local).naive_local().date();
+ TimeBucket::from_dates(today, entry_date)
+ })
+ .unwrap_or(TimeBucket::All);
+
+ if Some(entry_bucket) != bucket {
+ bucket = Some(entry_bucket);
+ items.push(ListItemType::BucketSeparator(entry_bucket));
+ }
+
+ items.push(ListItemType::Entry {
+ entry,
+ format: entry_bucket.into(),
+ });
+ }
+ items
+ })
+ }
+
+ fn filter_search_results(
+ &self,
+ entries: Vec<AgentSessionInfo>,
+ cx: &App,
+ ) -> Task<Vec<ListItemType>> {
+ let query = self.search_query.clone();
+ cx.background_spawn({
+ let executor = cx.background_executor().clone();
+ async move {
+ let mut candidates = Vec::with_capacity(entries.len());
+
+ for (idx, entry) in entries.iter().enumerate() {
+ candidates.push(StringMatchCandidate::new(idx, thread_title(entry)));
+ }
+
+ const MAX_MATCHES: usize = 100;
+
+ let matches = fuzzy::match_strings(
+ &candidates,
+ &query,
+ false,
+ true,
+ MAX_MATCHES,
+ &Default::default(),
+ executor,
+ )
+ .await;
+
+ matches
+ .into_iter()
+ .map(|search_match| ListItemType::SearchResult {
+ entry: entries[search_match.candidate_id].clone(),
+ positions: search_match.positions,
+ })
+ .collect()
+ }
+ })
+ }
+
+ fn search_produced_no_matches(&self) -> bool {
+ self.visible_items.is_empty() && !self.search_query.is_empty()
+ }
+
+ fn selected_history_entry(&self) -> Option<&AgentSessionInfo> {
+ self.get_history_entry(self.selected_index)
+ }
+
+ fn get_history_entry(&self, visible_items_ix: usize) -> Option<&AgentSessionInfo> {
+ self.visible_items.get(visible_items_ix)?.history_entry()
+ }
+
+ fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context<Self>) {
+ if self.visible_items.len() == 0 {
+ self.selected_index = 0;
+ return;
+ }
+ while matches!(
+ self.visible_items.get(index),
+ None | Some(ListItemType::BucketSeparator(..))
+ ) {
+ index = match bias {
+ Bias::Left => {
+ if index == 0 {
+ self.visible_items.len() - 1
+ } else {
+ index - 1
+ }
+ }
+ Bias::Right => {
+ if index >= self.visible_items.len() - 1 {
+ 0
+ } else {
+ index + 1
+ }
+ }
+ };
+ }
+ self.selected_index = index;
+ self.scroll_handle
+ .scroll_to_item(index, ScrollStrategy::Top);
+ cx.notify()
+ }
+
+ fn select_previous(
+ &mut self,
+ _: &menu::SelectPrevious,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if self.selected_index == 0 {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ } else {
+ self.set_selected_index(self.selected_index - 1, Bias::Left, cx);
+ }
+ }
+
+ fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
+ if self.selected_index == self.visible_items.len() - 1 {
+ self.set_selected_index(0, Bias::Right, cx);
+ } else {
+ self.set_selected_index(self.selected_index + 1, Bias::Right, cx);
+ }
+ }
+
+ fn select_first(
+ &mut self,
+ _: &menu::SelectFirst,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.set_selected_index(0, Bias::Right, cx);
+ }
+
+ fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx);
+ }
+
+ fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirm_entry(self.selected_index, cx);
+ }
+
+ fn confirm_entry(&mut self, ix: usize, cx: &mut Context<Self>) {
+ let Some(entry) = self.get_history_entry(ix) else {
+ return;
+ };
+ cx.emit(ThreadHistoryViewEvent::Open(entry.clone()));
+ }
+
+ fn remove_selected_thread(
+ &mut self,
+ _: &RemoveSelectedThread,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.remove_thread(self.selected_index, cx)
+ }
+
+ fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context<Self>) {
+ let Some(entry) = self.get_history_entry(visible_item_ix) else {
+ return;
+ };
+ if !self.history.read(cx).supports_delete() {
+ return;
+ }
+ let session_id = entry.session_id.clone();
+ self.history.update(cx, |history, cx| {
+ history
+ .delete_session(&session_id, cx)
+ .detach_and_log_err(cx);
+ });
+ }
+
+ fn remove_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ if !self.history.read(cx).supports_delete() {
+ return;
+ }
+ self.history.update(cx, |history, cx| {
+ history.delete_sessions(cx).detach_and_log_err(cx);
+ });
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirming_delete_history = true;
+ cx.notify();
+ }
+
+ fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ self.confirming_delete_history = false;
+ cx.notify();
+ }
+
+ fn render_list_items(
+ &mut self,
+ range: Range<usize>,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Vec<AnyElement> {
+ self.visible_items
+ .get(range.clone())
+ .into_iter()
+ .flatten()
+ .enumerate()
+ .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx))
+ .collect()
+ }
+
+ fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context<Self>) -> AnyElement {
+ match item {
+ ListItemType::Entry { entry, format } => self
+ .render_history_entry(entry, *format, ix, Vec::default(), cx)
+ .into_any(),
+ ListItemType::SearchResult { entry, positions } => self.render_history_entry(
+ entry,
+ EntryTimeFormat::DateAndTime,
+ ix,
+ positions.clone(),
+ cx,
+ ),
+ ListItemType::BucketSeparator(bucket) => div()
+ .px(DynamicSpacing::Base06.rems(cx))
+ .pt_2()
+ .pb_1()
+ .child(
+ Label::new(bucket.to_string())
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ }
+ }
+
+ fn render_history_entry(
+ &self,
+ entry: &AgentSessionInfo,
+ format: EntryTimeFormat,
+ ix: usize,
+ highlight_positions: Vec<usize>,
+ cx: &Context<Self>,
+ ) -> AnyElement {
+ let selected = ix == self.selected_index;
+ let hovered = Some(ix) == self.hovered_index;
+ let entry_time = entry.updated_at;
+ let display_text = match (format, entry_time) {
+ (EntryTimeFormat::DateAndTime, Some(entry_time)) => {
+ let now = Utc::now();
+ let duration = now.signed_duration_since(entry_time);
+ let days = duration.num_days();
+
+ format!("{}d", days)
+ }
+ (EntryTimeFormat::TimeOnly, Some(entry_time)) => {
+ format.format_timestamp(entry_time.timestamp(), self.local_timezone)
+ }
+ (_, None) => "—".to_string(),
+ };
+
+ let title = thread_title(entry).clone();
+ let full_date = entry_time
+ .map(|time| {
+ EntryTimeFormat::DateAndTime.format_timestamp(time.timestamp(), self.local_timezone)
+ })
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ let supports_delete = self.history.read(cx).supports_delete();
+
+ h_flex()
+ .w_full()
+ .pb_1()
+ .child(
+ ListItem::new(ix)
+ .rounded()
+ .toggle_state(selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(
+ HighlightedLabel::new(thread_title(entry), highlight_positions)
+ .size(LabelSize::Small)
+ .truncate(),
+ )
+ .child(
+ Label::new(display_text)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .tooltip(move |_, cx| {
+ Tooltip::with_meta(title.clone(), None, full_date.clone(), cx)
+ })
+ .on_hover(cx.listener(move |this, is_hovered, _window, cx| {
+ if *is_hovered {
+ this.hovered_index = Some(ix);
+ } else if this.hovered_index == Some(ix) {
+ this.hovered_index = None;
+ }
+
+ cx.notify();
+ }))
+ .end_slot::<IconButton>(if hovered && supports_delete {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+ })
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.remove_thread(ix, cx);
+ cx.stop_propagation()
+ })),
+ )
+ } else {
+ None
+ })
+ .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))),
+ )
+ .into_any_element()
+ }
+}
+
+impl Focusable for ThreadHistoryView {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ self.search_editor.focus_handle(cx)
+ }
+}
+
+impl Render for ThreadHistoryView {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let has_no_history = self.history.read(cx).is_empty();
+ let supports_delete = self.history.read(cx).supports_delete();
+
+ v_flex()
+ .key_context("ThreadHistory")
+ .size_full()
+ .bg(cx.theme().colors().panel_background)
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_first))
+ .on_action(cx.listener(Self::select_last))
+ .on_action(cx.listener(Self::confirm))
+ .on_action(cx.listener(Self::remove_selected_thread))
+ .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| {
+ this.remove_history(window, cx);
+ }))
+ .child(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .w_full()
+ .py_1()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Icon::new(IconName::MagnifyingGlass)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(self.search_editor.clone()),
+ )
+ .child({
+ let view = v_flex()
+ .id("list-container")
+ .relative()
+ .overflow_hidden()
+ .flex_grow();
+
+ if has_no_history {
+ view.justify_center().items_center().child(
+ Label::new("You don't have any past threads yet.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ } else if self.search_produced_no_matches() {
+ view.justify_center()
+ .items_center()
+ .child(Label::new("No threads match your search.").size(LabelSize::Small))
+ } else {
+ view.child(
+ uniform_list(
+ "thread-history",
+ self.visible_items.len(),
+ cx.processor(|this, range: Range<usize>, window, cx| {
+ this.render_list_items(range, window, cx)
+ }),
+ )
+ .p_1()
+ .pr_4()
+ .track_scroll(&self.scroll_handle)
+ .flex_grow(),
+ )
+ .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+ }
+ })
+ .when(!has_no_history && supports_delete, |this| {
+ this.child(
+ h_flex()
+ .p_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .when(!self.confirming_delete_history, |this| {
+ this.child(
+ Button::new("delete_history", "Delete All History")
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.prompt_delete_history(window, cx);
+ })),
+ )
+ })
+ .when(self.confirming_delete_history, |this| {
+ this.w_full()
+ .gap_2()
+ .flex_wrap()
+ .justify_between()
+ .child(
+ h_flex()
+ .flex_wrap()
+ .gap_1()
+ .child(
+ Label::new("Delete all threads?")
+ .size(LabelSize::Small),
+ )
+ .child(
+ Label::new("You won't be able to recover them later.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Button::new("cancel_delete", "Cancel")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.cancel_delete_history(window, cx);
+ })),
+ )
+ .child(
+ Button::new("confirm_delete", "Delete")
+ .style(ButtonStyle::Tinted(ui::TintColor::Error))
+ .color(Color::Error)
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|_, _, window, cx| {
+ window.dispatch_action(
+ Box::new(RemoveHistory),
+ cx,
+ );
+ })),
+ ),
+ )
+ }),
+ )
+ })
+ }
+}
+
+#[derive(IntoElement)]
+pub struct HistoryEntryElement {
+ entry: AgentSessionInfo,
+ thread_view: WeakEntity<ConnectionView>,
+ selected: bool,
+ hovered: bool,
+ supports_delete: bool,
+ on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+}
+
+impl HistoryEntryElement {
+ pub fn new(entry: AgentSessionInfo, thread_view: WeakEntity<ConnectionView>) -> Self {
+ Self {
+ entry,
+ thread_view,
+ selected: false,
+ hovered: false,
+ supports_delete: false,
+ on_hover: Box::new(|_, _, _| {}),
+ }
+ }
+
+ pub fn supports_delete(mut self, supports_delete: bool) -> Self {
+ self.supports_delete = supports_delete;
+ self
+ }
+
+ pub fn hovered(mut self, hovered: bool) -> Self {
+ self.hovered = hovered;
+ self
+ }
+
+ pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_hover = Box::new(on_hover);
+ self
+ }
+}
+
+impl RenderOnce for HistoryEntryElement {
+ fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+ let id = ElementId::Name(self.entry.session_id.0.clone().into());
+ let title = thread_title(&self.entry).clone();
+ let formatted_time = self
+ .entry
+ .updated_at
+ .map(|timestamp| {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(timestamp);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h ago", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m ago", duration.num_minutes())
+ } else {
+ "Just now".to_string()
+ }
+ })
+ .unwrap_or_else(|| "Unknown".to_string());
+
+ ListItem::new(id)
+ .rounded()
+ .toggle_state(self.selected)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(Label::new(title).size(LabelSize::Small).truncate())
+ .child(
+ Label::new(formatted_time)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ ),
+ )
+ .on_hover(self.on_hover)
+ .end_slot::<IconButton>(if (self.hovered || self.selected) && self.supports_delete {
+ Some(
+ IconButton::new("delete", IconName::Trash)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .tooltip(move |_window, cx| {
+ Tooltip::for_action("Delete", &RemoveSelectedThread, cx)
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let session_id = self.entry.session_id.clone();
+
+ move |_event, _window, cx| {
+ if let Some(thread_view) = thread_view.upgrade() {
+ thread_view.update(cx, |thread_view, cx| {
+ thread_view.delete_history_entry(&session_id, cx);
+ });
+ }
+ }
+ }),
+ )
+ } else {
+ None
+ })
+ .on_click({
+ let thread_view = self.thread_view.clone();
+ let entry = self.entry;
+
+ move |_event, window, cx| {
+ if let Some(workspace) = thread_view
+ .upgrade()
+ .and_then(|view| view.read(cx).workspace().upgrade())
+ {
+ if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.load_agent_thread(
+ entry.session_id.clone(),
+ entry.cwd.clone(),
+ entry.title.clone(),
+ window,
+ cx,
+ );
+ });
+ }
+ }
+ }
+ })
+ }
+}
+
+#[derive(Clone, Copy)]
+pub enum EntryTimeFormat {
+ DateAndTime,
+ TimeOnly,
+}
+
+impl EntryTimeFormat {
+ fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String {
+ let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
+
+ match self {
+ EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
+ timestamp,
+ OffsetDateTime::now_utc(),
+ timezone,
+ time_format::TimestampFormat::EnhancedAbsolute,
+ ),
+ EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)),
+ }
+ }
+}
+
+impl From<TimeBucket> for EntryTimeFormat {
+ fn from(bucket: TimeBucket) -> Self {
+ match bucket {
+ TimeBucket::Today => EntryTimeFormat::TimeOnly,
+ TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
+ TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
+ TimeBucket::All => EntryTimeFormat::DateAndTime,
+ }
+ }
+}
+
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum TimeBucket {
+ Today,
+ Yesterday,
+ ThisWeek,
+ PastWeek,
+ All,
+}
+
+impl TimeBucket {
+ fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
+ if date == reference {
+ return TimeBucket::Today;
+ }
+
+ if date == reference - TimeDelta::days(1) {
+ return TimeBucket::Yesterday;
+ }
+
+ let week = date.iso_week();
+
+ if reference.iso_week() == week {
+ return TimeBucket::ThisWeek;
+ }
+
+ let last_week = (reference - TimeDelta::days(7)).iso_week();
+
+ if week == last_week {
+ return TimeBucket::PastWeek;
+ }
+
+ TimeBucket::All
+ }
+}
+
+impl Display for TimeBucket {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TimeBucket::Today => write!(f, "Today"),
+ TimeBucket::Yesterday => write!(f, "Yesterday"),
+ TimeBucket::ThisWeek => write!(f, "This Week"),
+ TimeBucket::PastWeek => write!(f, "Past Week"),
+ TimeBucket::All => write!(f, "All"),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::NaiveDate;
+
+ #[test]
+ fn test_time_bucket_from_dates() {
+ let today = NaiveDate::from_ymd_opt(2025, 1, 15).unwrap();
+
+ assert_eq!(TimeBucket::from_dates(today, today), TimeBucket::Today);
+
+ let yesterday = NaiveDate::from_ymd_opt(2025, 1, 14).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, yesterday),
+ TimeBucket::Yesterday
+ );
+
+ let this_week = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, this_week),
+ TimeBucket::ThisWeek
+ );
+
+ let past_week = NaiveDate::from_ymd_opt(2025, 1, 7).unwrap();
+ assert_eq!(
+ TimeBucket::from_dates(today, past_week),
+ TimeBucket::PastWeek
+ );
+
+ let old = NaiveDate::from_ymd_opt(2024, 12, 1).unwrap();
+ assert_eq!(TimeBucket::from_dates(today, old), TimeBucket::All);
+ }
+}
@@ -1,3 +1,6 @@
+-- This file is auto-generated. Do not modify it by hand.
+-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository.
+
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
CREATE TABLE public.breakpoints (
@@ -315,10 +318,10 @@ 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
+ is_deleted boolean NOT NULL,
+ lines_added integer,
+ lines_deleted integer
);
CREATE TABLE public.projects (
@@ -706,6 +709,8 @@ CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name
CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops);
+CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops);
+
CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text));
CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id);
@@ -753,7 +758,7 @@ ALTER TABLE ONLY public.contacts
ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contributors
- ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
+ ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.extension_versions
ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id);
@@ -1779,6 +1779,7 @@ mod tests {
fn disk_state(&self) -> language::DiskState {
language::DiskState::Present {
mtime: ::fs::MTime::from_seconds_and_nanos(100, 42),
+ size: 0,
}
}
@@ -350,8 +350,34 @@ impl minidumper::ServerHandler for CrashServer {
}
}
+/// Rust's string-slicing panics embed the user's string content in the message,
+/// e.g. "byte index 4 is out of bounds of `a`". Strip that suffix so we
+/// don't upload arbitrary user text in crash reports.
+fn strip_user_string_from_panic(message: &str) -> String {
+ const STRING_PANIC_PREFIXES: &[&str] = &[
+ // Older rustc (pre-1.95):
+ "byte index ",
+ "begin <= end (",
+ // Newer rustc (1.95+):
+ // https://github.com/rust-lang/rust/pull/145024
+ "start byte index ",
+ "end byte index ",
+ "begin > end (",
+ ];
+
+ if (message.ends_with('`') || message.ends_with("`[...]"))
+ && STRING_PANIC_PREFIXES
+ .iter()
+ .any(|prefix| message.starts_with(prefix))
+ && let Some(open) = message.find('`')
+ {
+ return format!("{} `<redacted>`", &message[..open]);
+ }
+ message.to_owned()
+}
+
pub fn panic_hook(info: &PanicHookInfo) {
- let message = info.payload_as_str().unwrap_or("Box<Any>").to_owned();
+ let message = strip_user_string_from_panic(info.payload_as_str().unwrap_or("Box<Any>"));
let span = info
.location()
@@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence(
cx.run_until_parked();
let workspace_id = workspace
- .update(cx, |workspace, _window, cx| workspace.database_id(cx))
+ .update(cx, |workspace, _window, cx| {
+ workspace.active_workspace_database_id(cx)
+ })
.ok()
.flatten()
.expect("workspace id has to be some for this test to work properly");
@@ -145,7 +145,7 @@ impl Editor {
_: &Window,
cx: &mut Context<Self>,
) {
- if !self.mode().is_full() {
+ if !self.lsp_data_enabled() {
return;
}
let Some(project) = self.project.as_ref() else {
@@ -147,7 +147,7 @@ impl Editor {
for_buffer: Option<BufferId>,
cx: &mut Context<Self>,
) {
- if !self.mode().is_full() {
+ if !self.lsp_data_enabled() {
return;
}
let Some(project) = self.project.clone() else {
@@ -35,13 +35,13 @@ mod lsp_ext;
mod mouse_context_menu;
pub mod movement;
mod persistence;
+mod runnables;
mod rust_analyzer_ext;
pub mod scroll;
mod selections_collection;
pub mod semantic_tokens;
mod split;
pub mod split_editor_view;
-pub mod tasks;
#[cfg(test)]
mod code_completion_tests;
@@ -133,8 +133,8 @@ use language::{
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
IndentSize, Language, LanguageName, LanguageRegistry, LanguageScope, LocalFile, OffsetRangeExt,
- OutlineItem, Point, Runnable, Selection, SelectionGoal, TextObject, TransactionId,
- TreeSitterOptions, WordsQuery,
+ OutlineItem, Point, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
+ WordsQuery,
language_settings::{
self, LanguageSettings, LspInsertMode, RewrapBehavior, WordsCompletionMode,
all_language_settings, language_settings,
@@ -158,7 +158,7 @@ use project::{
BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent,
CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId,
InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project,
- ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind,
+ ProjectItem, ProjectPath, ProjectTransaction,
debugger::{
breakpoint_store::{
Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState,
@@ -200,7 +200,7 @@ use std::{
sync::Arc,
time::{Duration, Instant},
};
-use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables};
+use task::TaskVariables;
use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _};
use theme::{
AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme,
@@ -231,6 +231,7 @@ use crate::{
InlineValueCache,
inlay_hints::{LspInlayHintData, inlay_hint_settings},
},
+ runnables::{ResolvedTasks, RunnableData, RunnableTasks},
scroll::{ScrollOffset, ScrollPixelOffset},
selections_collection::resolve_selections_wrapping_blocks,
semantic_tokens::SemanticTokenState,
@@ -857,37 +858,6 @@ impl BufferSerialization {
}
}
-#[derive(Clone, Debug)]
-struct RunnableTasks {
- templates: Vec<(TaskSourceKind, TaskTemplate)>,
- offset: multi_buffer::Anchor,
- // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
- column: u32,
- // Values of all named captures, including those starting with '_'
- extra_variables: HashMap<String, String>,
- // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
- context_range: Range<BufferOffset>,
-}
-
-impl RunnableTasks {
- fn resolve<'a>(
- &'a self,
- cx: &'a task::TaskContext,
- ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
- self.templates.iter().filter_map(|(kind, template)| {
- template
- .resolve_task(&kind.to_id_base(), cx)
- .map(|task| (kind.clone(), task))
- })
- }
-}
-
-#[derive(Clone)]
-pub struct ResolvedTasks {
- templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
- position: Anchor,
-}
-
/// Addons allow storing per-editor state in other crates (e.g. Vim)
pub trait Addon: 'static {
fn extend_key_context(&self, _: &mut KeyContext, _: &App) {}
@@ -1295,8 +1265,7 @@ pub struct Editor {
last_bounds: Option<Bounds<Pixels>>,
last_position_map: Option<Rc<PositionMap>>,
expect_bounds_change: Option<Bounds<Pixels>>,
- tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>,
- tasks_update_task: Option<Task<()>>,
+ runnables: RunnableData,
breakpoint_store: Option<Entity<BreakpointStore>>,
gutter_breakpoint_indicator: (Option<PhantomBreakpointIndicator>, Option<Task<()>>),
pub(crate) gutter_diff_review_indicator: (Option<PhantomDiffReviewIndicator>, Option<Task<()>>),
@@ -2173,16 +2142,9 @@ impl Editor {
editor.registered_buffers.clear();
editor.register_visible_buffers(cx);
editor.invalidate_semantic_tokens(None);
+ editor.refresh_runnables(window, cx);
editor.update_lsp_data(None, window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx);
- if editor.tasks_update_task.is_none() {
- editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
- }
- }
- project::Event::LanguageServerAdded(..) => {
- if editor.tasks_update_task.is_none() {
- editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
- }
}
project::Event::SnippetEdit(id, snippet_edits) => {
// todo(lw): Non singletons
@@ -2210,6 +2172,7 @@ impl Editor {
let buffer_id = *buffer_id;
if editor.buffer().read(cx).buffer(buffer_id).is_some() {
editor.register_buffer(buffer_id, cx);
+ editor.refresh_runnables(window, cx);
editor.update_lsp_data(Some(buffer_id), window, cx);
editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
refresh_linked_ranges(editor, window, cx);
@@ -2288,7 +2251,7 @@ impl Editor {
&task_inventory,
window,
|editor, _, window, cx| {
- editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
+ editor.refresh_runnables(window, cx);
},
));
};
@@ -2529,7 +2492,6 @@ impl Editor {
}),
blame: None,
blame_subscription: None,
- tasks: BTreeMap::default(),
breakpoint_store,
gutter_breakpoint_indicator: (None, None),
@@ -2565,7 +2527,7 @@ impl Editor {
]
})
.unwrap_or_default(),
- tasks_update_task: None,
+ runnables: RunnableData::new(),
pull_diagnostics_task: Task::ready(()),
colors: None,
refresh_colors_task: Task::ready(()),
@@ -2632,7 +2594,6 @@ impl Editor {
cx.notify();
}));
}
- editor.tasks_update_task = Some(editor.refresh_runnables(window, cx));
editor._subscriptions.extend(project_subscriptions);
editor._subscriptions.push(cx.subscribe_in(
@@ -2668,6 +2629,7 @@ impl Editor {
);
if !editor.buffer().read(cx).is_singleton() {
editor.update_lsp_data(None, window, cx);
+ editor.refresh_runnables(window, cx);
}
})
.ok();
@@ -5791,18 +5753,11 @@ impl Editor {
let display_snapshot = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let multi_buffer = self.buffer().read(cx);
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
- let multi_buffer_visible_start = self
- .scroll_manager
- .native_anchor(&display_snapshot, cx)
- .anchor
- .to_point(&multi_buffer_snapshot);
- let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
- multi_buffer_visible_start
- + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
- Bias::Left,
- );
multi_buffer_snapshot
- .range_to_buffer_ranges(multi_buffer_visible_start..=multi_buffer_visible_end)
+ .range_to_buffer_ranges(
+ self.multi_buffer_visible_range(&display_snapshot, cx)
+ .to_inclusive(),
+ )
.into_iter()
.filter(|(_, excerpt_visible_range, _)| !excerpt_visible_range.is_empty())
.filter_map(|(buffer, excerpt_visible_range, excerpt_id)| {
@@ -6737,8 +6692,8 @@ impl Editor {
};
let buffer_id = buffer.read(cx).remote_id();
let tasks = self
- .tasks
- .get(&(buffer_id, buffer_row))
+ .runnables
+ .runnables((buffer_id, buffer_row))
.map(|t| Arc::new(t.to_owned()));
if !self.focus_handle.is_focused(window) {
@@ -7733,7 +7688,7 @@ impl Editor {
#[ztracing::instrument(skip_all)]
fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context<Editor>) {
- if !self.mode.is_full() {
+ if !self.lsp_data_enabled() {
return;
}
let cursor = self.selections.newest_anchor().head();
@@ -7789,24 +7744,13 @@ impl Editor {
self.debounced_selection_highlight_complete = false;
}
if on_buffer_edit || query_changed {
- let multi_buffer_visible_start = self
- .scroll_manager
- .native_anchor(&display_snapshot, cx)
- .anchor
- .to_point(&multi_buffer_snapshot);
- let multi_buffer_visible_end = multi_buffer_snapshot.clip_point(
- multi_buffer_visible_start
- + Point::new(self.visible_line_count().unwrap_or(0.).ceil() as u32, 0),
- Bias::Left,
- );
- let multi_buffer_visible_range = multi_buffer_visible_start..multi_buffer_visible_end;
self.quick_selection_highlight_task = Some((
query_range.clone(),
self.update_selection_occurrence_highlights(
snapshot.buffer.clone(),
query_text.clone(),
query_range.clone(),
- multi_buffer_visible_range,
+ self.multi_buffer_visible_range(&display_snapshot, cx),
false,
window,
cx,
@@ -7841,6 +7785,27 @@ impl Editor {
}
}
+ pub fn multi_buffer_visible_range(
+ &self,
+ display_snapshot: &DisplaySnapshot,
+ cx: &App,
+ ) -> Range<Point> {
+ let visible_start = self
+ .scroll_manager
+ .native_anchor(display_snapshot, cx)
+ .anchor
+ .to_point(display_snapshot.buffer_snapshot())
+ .to_display_point(display_snapshot);
+
+ let mut target_end = visible_start;
+ *target_end.row_mut() += self.visible_line_count().unwrap_or(0.).ceil() as u32;
+
+ visible_start.to_point(display_snapshot)
+ ..display_snapshot
+ .clip_point(target_end, Bias::Right)
+ .to_point(display_snapshot)
+ }
+
pub fn refresh_edit_prediction(
&mut self,
debounce: bool,
@@ -8809,19 +8774,6 @@ impl Editor {
Some(self.edit_prediction_provider.as_ref()?.provider.clone())
}
- fn clear_tasks(&mut self) {
- self.tasks.clear()
- }
-
- fn insert_tasks(&mut self, key: (BufferId, BufferRow), value: RunnableTasks) {
- if self.tasks.insert(key, value).is_some() {
- // This case should hopefully be rare, but just in case...
- log::error!(
- "multiple different run targets found on a single line, only the last target will be rendered"
- )
- }
- }
-
/// Get all display points of breakpoints that will be rendered within editor
///
/// This function is used to handle overlaps between breakpoints and Code action/runner symbol.
@@ -9199,156 +9151,6 @@ impl Editor {
})
}
- pub fn spawn_nearest_task(
- &mut self,
- action: &SpawnNearestTask,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let Some((workspace, _)) = self.workspace.clone() else {
- return;
- };
- let Some(project) = self.project.clone() else {
- return;
- };
-
- // Try to find a closest, enclosing node using tree-sitter that has a task
- let Some((buffer, buffer_row, tasks)) = self
- .find_enclosing_node_task(cx)
- // Or find the task that's closest in row-distance.
- .or_else(|| self.find_closest_task(cx))
- else {
- return;
- };
-
- let reveal_strategy = action.reveal;
- let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
- cx.spawn_in(window, async move |_, cx| {
- let context = task_context.await?;
- let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
-
- let resolved = &mut resolved_task.resolved;
- resolved.reveal = reveal_strategy;
-
- workspace
- .update_in(cx, |workspace, window, cx| {
- workspace.schedule_resolved_task(
- task_source_kind,
- resolved_task,
- false,
- window,
- cx,
- );
- })
- .ok()
- })
- .detach();
- }
-
- fn find_closest_task(
- &mut self,
- cx: &mut Context<Self>,
- ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
- let cursor_row = self
- .selections
- .newest_adjusted(&self.display_snapshot(cx))
- .head()
- .row;
-
- let ((buffer_id, row), tasks) = self
- .tasks
- .iter()
- .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
-
- let buffer = self.buffer.read(cx).buffer(*buffer_id)?;
- let tasks = Arc::new(tasks.to_owned());
- Some((buffer, *row, tasks))
- }
-
- fn find_enclosing_node_task(
- &mut self,
- cx: &mut Context<Self>,
- ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
- let snapshot = self.buffer.read(cx).snapshot(cx);
- let offset = self
- .selections
- .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
- .head();
- let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
- let offset = excerpt.map_offset_to_buffer(offset);
- let buffer_id = excerpt.buffer().remote_id();
-
- let layer = excerpt.buffer().syntax_layer_at(offset)?;
- let mut cursor = layer.node().walk();
-
- while cursor.goto_first_child_for_byte(offset.0).is_some() {
- if cursor.node().end_byte() == offset.0 {
- cursor.goto_next_sibling();
- }
- }
-
- // Ascend to the smallest ancestor that contains the range and has a task.
- loop {
- let node = cursor.node();
- let node_range = node.byte_range();
- let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
-
- // Check if this node contains our offset
- if node_range.start <= offset.0 && node_range.end >= offset.0 {
- // If it contains offset, check for task
- if let Some(tasks) = self.tasks.get(&(buffer_id, symbol_start_row)) {
- let buffer = self.buffer.read(cx).buffer(buffer_id)?;
- return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
- }
- }
-
- if !cursor.goto_parent() {
- break;
- }
- }
- None
- }
-
- fn render_run_indicator(
- &self,
- _style: &EditorStyle,
- is_active: bool,
- row: DisplayRow,
- breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
- cx: &mut Context<Self>,
- ) -> IconButton {
- let color = Color::Muted;
- let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
-
- IconButton::new(
- ("run_indicator", row.0 as usize),
- ui::IconName::PlayOutlined,
- )
- .shape(ui::IconButtonShape::Square)
- .icon_size(IconSize::XSmall)
- .icon_color(color)
- .toggle_state(is_active)
- .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
- let quick_launch = match e {
- ClickEvent::Keyboard(_) => true,
- ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
- };
-
- window.focus(&editor.focus_handle(cx), cx);
- editor.toggle_code_actions(
- &ToggleCodeActions {
- deployed_from: Some(CodeActionSource::RunMenu(row)),
- quick_launch,
- },
- window,
- cx,
- );
- }))
- .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
- editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
- }))
- }
-
pub fn context_menu_visible(&self) -> bool {
!self.edit_prediction_preview_is_active()
&& self
@@ -17153,236 +16955,6 @@ impl Editor {
});
}
- fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
- if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables {
- self.clear_tasks();
- return Task::ready(());
- }
- let project = self.project().map(Entity::downgrade);
- let task_sources = self.lsp_task_sources(cx);
- let multi_buffer = self.buffer.downgrade();
- cx.spawn_in(window, async move |editor, cx| {
- cx.background_executor().timer(UPDATE_DEBOUNCE).await;
- let Some(project) = project.and_then(|p| p.upgrade()) else {
- return;
- };
- let Ok(display_snapshot) = editor.update(cx, |this, cx| {
- this.display_map.update(cx, |map, cx| map.snapshot(cx))
- }) else {
- return;
- };
-
- let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
- if hide_runnables {
- return;
- }
- let new_rows =
- cx.background_spawn({
- let snapshot = display_snapshot.clone();
- async move {
- Self::fetch_runnable_ranges(&snapshot, Anchor::min()..Anchor::max())
- }
- })
- .await;
- let Ok(lsp_tasks) =
- cx.update(|_, cx| crate::lsp_tasks(project.clone(), &task_sources, None, cx))
- else {
- return;
- };
- let lsp_tasks = lsp_tasks.await;
-
- let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
- lsp_tasks
- .into_iter()
- .flat_map(|(kind, tasks)| {
- tasks.into_iter().filter_map(move |(location, task)| {
- Some((kind.clone(), location?, task))
- })
- })
- .fold(HashMap::default(), |mut acc, (kind, location, task)| {
- let buffer = location.target.buffer;
- let buffer_snapshot = buffer.read(cx).snapshot();
- let offset = display_snapshot.buffer_snapshot().excerpts().find_map(
- |(excerpt_id, snapshot, _)| {
- if snapshot.remote_id() == buffer_snapshot.remote_id() {
- display_snapshot
- .buffer_snapshot()
- .anchor_in_excerpt(excerpt_id, location.target.range.start)
- } else {
- None
- }
- },
- );
- if let Some(offset) = offset {
- let task_buffer_range =
- location.target.range.to_point(&buffer_snapshot);
- let context_buffer_range =
- task_buffer_range.to_offset(&buffer_snapshot);
- let context_range = BufferOffset(context_buffer_range.start)
- ..BufferOffset(context_buffer_range.end);
-
- acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
- .or_insert_with(|| RunnableTasks {
- templates: Vec::new(),
- offset,
- column: task_buffer_range.start.column,
- extra_variables: HashMap::default(),
- context_range,
- })
- .templates
- .push((kind, task.original_task().clone()));
- }
-
- acc
- })
- }) else {
- return;
- };
-
- let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
- buffer.language_settings(cx).tasks.prefer_lsp
- }) else {
- return;
- };
-
- let rows = Self::runnable_rows(
- project,
- display_snapshot,
- prefer_lsp && !lsp_tasks_by_rows.is_empty(),
- new_rows,
- cx.clone(),
- )
- .await;
- editor
- .update(cx, |editor, _| {
- editor.clear_tasks();
- for (key, mut value) in rows {
- if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&key) {
- value.templates.extend(lsp_tasks.templates);
- }
-
- editor.insert_tasks(key, value);
- }
- for (key, value) in lsp_tasks_by_rows {
- editor.insert_tasks(key, value);
- }
- })
- .ok();
- })
- }
- fn fetch_runnable_ranges(
- snapshot: &DisplaySnapshot,
- range: Range<Anchor>,
- ) -> Vec<(Range<MultiBufferOffset>, language::RunnableRange)> {
- snapshot.buffer_snapshot().runnable_ranges(range).collect()
- }
-
- fn runnable_rows(
- project: Entity<Project>,
- snapshot: DisplaySnapshot,
- prefer_lsp: bool,
- runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
- cx: AsyncWindowContext,
- ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
- cx.spawn(async move |cx| {
- let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
- for (run_range, mut runnable) in runnable_ranges {
- let Some(tasks) = cx
- .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
- .ok()
- else {
- continue;
- };
- let mut tasks = tasks.await;
-
- if prefer_lsp {
- tasks.retain(|(task_kind, _)| {
- !matches!(task_kind, TaskSourceKind::Language { .. })
- });
- }
- if tasks.is_empty() {
- continue;
- }
-
- let point = run_range.start.to_point(&snapshot.buffer_snapshot());
- let Some(row) = snapshot
- .buffer_snapshot()
- .buffer_line_for_row(MultiBufferRow(point.row))
- .map(|(_, range)| range.start.row)
- else {
- continue;
- };
-
- let context_range =
- BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
- runnable_rows.push((
- (runnable.buffer_id, row),
- RunnableTasks {
- templates: tasks,
- offset: snapshot.buffer_snapshot().anchor_before(run_range.start),
- context_range,
- column: point.column,
- extra_variables: runnable.extra_captures,
- },
- ));
- }
- runnable_rows
- })
- }
-
- fn templates_with_tags(
- project: &Entity<Project>,
- runnable: &mut Runnable,
- cx: &mut App,
- ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
- let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
- let (worktree_id, file) = project
- .buffer_for_id(runnable.buffer, cx)
- .and_then(|buffer| buffer.read(cx).file())
- .map(|file| (file.worktree_id(cx), file.clone()))
- .unzip();
-
- (
- project.task_store().read(cx).task_inventory().cloned(),
- worktree_id,
- file,
- )
- });
-
- let tags = mem::take(&mut runnable.tags);
- let language = runnable.language.clone();
- cx.spawn(async move |cx| {
- let mut templates_with_tags = Vec::new();
- if let Some(inventory) = inventory {
- for RunnableTag(tag) in tags {
- let new_tasks = inventory.update(cx, |inventory, cx| {
- inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
- });
- templates_with_tags.extend(new_tasks.await.into_iter().filter(
- move |(_, template)| {
- template.tags.iter().any(|source_tag| source_tag == &tag)
- },
- ));
- }
- }
- templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
-
- if let Some((leading_tag_source, _)) = templates_with_tags.first() {
- // Strongest source wins; if we have worktree tag binding, prefer that to
- // global and language bindings;
- // if we have a global binding, prefer that to language binding.
- let first_mismatch = templates_with_tags
- .iter()
- .position(|(tag_source, _)| tag_source != leading_tag_source);
- if let Some(index) = first_mismatch {
- templates_with_tags.truncate(index);
- }
- }
-
- templates_with_tags
- })
- }
-
pub fn move_to_enclosing_bracket(
&mut self,
_: &MoveToEnclosingBracket,
@@ -19607,7 +19179,7 @@ impl Editor {
}
pub fn diagnostics_enabled(&self) -> bool {
- self.diagnostics_enabled && self.mode.is_full()
+ self.diagnostics_enabled && self.lsp_data_enabled()
}
pub fn inline_diagnostics_enabled(&self) -> bool {
@@ -19771,10 +19343,7 @@ impl Editor {
// `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view,
// skip any LSP updates for it.
- if self.active_diagnostics == ActiveDiagnostic::All
- || !self.mode().is_full()
- || !self.diagnostics_enabled()
- {
+ if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() {
return None;
}
let pull_diagnostics_settings = ProjectSettings::get_global(cx)
@@ -24182,7 +23751,6 @@ impl Editor {
predecessor,
excerpts,
} => {
- self.tasks_update_task = Some(self.refresh_runnables(window, cx));
let buffer_id = buffer.read(cx).remote_id();
if self.buffer.read(cx).diff_for(buffer_id).is_none()
&& let Some(project) = &self.project
@@ -24200,6 +23768,7 @@ impl Editor {
.invalidate_buffer(&buffer.read(cx).remote_id());
self.update_lsp_data(Some(buffer_id), window, cx);
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
+ self.refresh_runnables(window, cx);
self.colorize_brackets(false, cx);
self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
cx.emit(EditorEvent::ExcerptsAdded {
@@ -24218,8 +23787,7 @@ impl Editor {
self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx);
for buffer_id in removed_buffer_ids {
self.registered_buffers.remove(buffer_id);
- self.tasks
- .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id);
+ self.clear_runnables(Some(*buffer_id));
self.semantic_token_state.invalidate_buffer(buffer_id);
self.display_map.update(cx, |display_map, cx| {
display_map.invalidate_semantic_highlights(*buffer_id);
@@ -24261,10 +23829,12 @@ impl Editor {
}
self.colorize_brackets(false, cx);
self.update_lsp_data(None, window, cx);
+ self.refresh_runnables(window, cx);
cx.emit(EditorEvent::ExcerptsExpanded { ids: ids.clone() })
}
multi_buffer::Event::Reparsed(buffer_id) => {
- self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+ self.clear_runnables(Some(*buffer_id));
+ self.refresh_runnables(window, cx);
self.refresh_selected_text_highlights(&self.display_snapshot(cx), true, window, cx);
self.colorize_brackets(true, cx);
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -24272,7 +23842,7 @@ impl Editor {
cx.emit(EditorEvent::Reparsed(*buffer_id));
}
multi_buffer::Event::DiffHunksToggled => {
- self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+ self.refresh_runnables(window, cx);
}
multi_buffer::Event::LanguageChanged(buffer_id, is_fresh_language) => {
if !is_fresh_language {
@@ -24408,7 +23978,7 @@ impl Editor {
.unwrap_or(DiagnosticSeverity::Hint);
self.set_max_diagnostics_severity(new_severity, cx);
}
- self.tasks_update_task = Some(self.refresh_runnables(window, cx));
+ self.refresh_runnables(window, cx);
self.update_edit_prediction_settings(cx);
self.refresh_edit_prediction(true, false, window, cx);
self.refresh_inline_values(cx);
@@ -25628,13 +25198,17 @@ impl Editor {
}
}
+ fn lsp_data_enabled(&self) -> bool {
+ self.enable_lsp_data && self.mode().is_full()
+ }
+
fn update_lsp_data(
&mut self,
for_buffer: Option<BufferId>,
window: &mut Window,
cx: &mut Context<'_, Self>,
) {
- if !self.enable_lsp_data {
+ if !self.lsp_data_enabled() {
return;
}
@@ -25648,7 +25222,7 @@ impl Editor {
}
fn register_visible_buffers(&mut self, cx: &mut Context<Self>) {
- if !self.mode().is_full() {
+ if !self.lsp_data_enabled() {
return;
}
for (_, (visible_buffer, _, _)) in self.visible_excerpts(true, cx) {
@@ -25657,7 +25231,7 @@ impl Editor {
}
fn register_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
- if !self.mode().is_full() {
+ if !self.lsp_data_enabled() {
return;
}
@@ -5,6 +5,7 @@ use crate::{
edit_prediction_tests::FakeEditPredictionDelegate,
element::StickyHeader,
linked_editing_ranges::LinkedEditingRanges,
+ runnables::RunnableTasks,
scroll::scroll_amount::ScrollAmount,
test::{
assert_text_with_selections, build_editor, editor_content_with_blocks,
@@ -24403,20 +24404,24 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) {
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
- editor.tasks.insert(
- (buffer.read(cx).remote_id(), 3),
+ editor.runnables.insert(
+ buffer.read(cx).remote_id(),
+ 3,
+ buffer.read(cx).version(),
RunnableTasks {
- templates: vec![],
+ templates: Vec::new(),
offset: snapshot.anchor_before(MultiBufferOffset(43)),
column: 0,
extra_variables: HashMap::default(),
context_range: BufferOffset(43)..BufferOffset(85),
},
);
- editor.tasks.insert(
- (buffer.read(cx).remote_id(), 8),
+ editor.runnables.insert(
+ buffer.read(cx).remote_id(),
+ 8,
+ buffer.read(cx).version(),
RunnableTasks {
- templates: vec![],
+ templates: Vec::new(),
offset: snapshot.anchor_before(MultiBufferOffset(86)),
column: 0,
extra_variables: HashMap::default(),
@@ -3275,9 +3275,9 @@ impl EditorElement {
snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right);
editor
- .tasks
- .iter()
- .filter_map(|(_, tasks)| {
+ .runnables
+ .all_runnables()
+ .filter_map(|tasks| {
let multibuffer_point = tasks.offset.to_point(&snapshot.buffer_snapshot());
if multibuffer_point < offset_range_start
|| multibuffer_point > offset_range_end
@@ -13,7 +13,7 @@ impl Editor {
_window: &Window,
cx: &mut Context<Self>,
) {
- if !self.mode().is_full() || !self.use_document_folding_ranges {
+ if !self.lsp_data_enabled() || !self.use_document_folding_ranges {
return;
}
let Some(project) = self.project.clone() else {
@@ -292,7 +292,7 @@ impl Editor {
reason: InlayHintRefreshReason,
cx: &mut Context<Self>,
) {
- if !self.mode().is_full() || self.inlay_hints.is_none() {
+ if !self.lsp_data_enabled() || self.inlay_hints.is_none() {
return;
}
let Some(semantics_provider) = self.semantics_provider() else {
@@ -50,7 +50,7 @@ pub(super) fn refresh_linked_ranges(
window: &mut Window,
cx: &mut Context<Editor>,
) -> Option<()> {
- if !editor.mode().is_full() || editor.pending_rename.is_some() {
+ if !editor.lsp_data_enabled() || editor.pending_rename.is_some() {
return None;
}
let project = editor.project()?.downgrade();
@@ -0,0 +1,915 @@
+use std::{collections::BTreeMap, mem, ops::Range, sync::Arc};
+
+use clock::Global;
+use collections::HashMap;
+use gpui::{
+ App, AppContext as _, AsyncWindowContext, ClickEvent, Context, Entity, Focusable as _,
+ MouseButton, Task, Window,
+};
+use language::{Buffer, BufferRow, Runnable};
+use lsp::LanguageServerName;
+use multi_buffer::{
+ Anchor, BufferOffset, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToPoint as _,
+};
+use project::{
+ Location, Project, TaskSourceKind,
+ debugger::breakpoint_store::{Breakpoint, BreakpointSessionState},
+ project_settings::ProjectSettings,
+};
+use settings::Settings as _;
+use smallvec::SmallVec;
+use task::{ResolvedTask, RunnableTag, TaskContext, TaskTemplate, TaskVariables, VariableName};
+use text::{BufferId, OffsetRangeExt as _, ToOffset as _, ToPoint as _};
+use ui::{Clickable as _, Color, IconButton, IconSize, Toggleable as _};
+
+use crate::{
+ CodeActionSource, Editor, EditorSettings, EditorStyle, RangeToAnchorExt, SpawnNearestTask,
+ ToggleCodeActions, UPDATE_DEBOUNCE, display_map::DisplayRow,
+};
+
+#[derive(Debug)]
+pub(super) struct RunnableData {
+ runnables: HashMap<BufferId, (Global, BTreeMap<BufferRow, RunnableTasks>)>,
+ runnables_update_task: Task<()>,
+}
+
+impl RunnableData {
+ pub fn new() -> Self {
+ Self {
+ runnables: HashMap::default(),
+ runnables_update_task: Task::ready(()),
+ }
+ }
+
+ pub fn runnables(
+ &self,
+ (buffer_id, buffer_row): (BufferId, BufferRow),
+ ) -> Option<&RunnableTasks> {
+ self.runnables.get(&buffer_id)?.1.get(&buffer_row)
+ }
+
+ pub fn all_runnables(&self) -> impl Iterator<Item = &RunnableTasks> {
+ self.runnables
+ .values()
+ .flat_map(|(_, tasks)| tasks.values())
+ }
+
+ pub fn has_cached(&self, buffer_id: BufferId, version: &Global) -> bool {
+ self.runnables
+ .get(&buffer_id)
+ .is_some_and(|(cached_version, _)| !version.changed_since(cached_version))
+ }
+
+ #[cfg(test)]
+ pub fn insert(
+ &mut self,
+ buffer_id: BufferId,
+ buffer_row: BufferRow,
+ version: Global,
+ tasks: RunnableTasks,
+ ) {
+ self.runnables
+ .entry(buffer_id)
+ .or_insert_with(|| (version, BTreeMap::default()))
+ .1
+ .insert(buffer_row, tasks);
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct RunnableTasks {
+ pub templates: Vec<(TaskSourceKind, TaskTemplate)>,
+ pub offset: multi_buffer::Anchor,
+ // We need the column at which the task context evaluation should take place (when we're spawning it via gutter).
+ pub column: u32,
+ // Values of all named captures, including those starting with '_'
+ pub extra_variables: HashMap<String, String>,
+ // Full range of the tagged region. We use it to determine which `extra_variables` to grab for context resolution in e.g. a modal.
+ pub context_range: Range<BufferOffset>,
+}
+
+impl RunnableTasks {
+ pub fn resolve<'a>(
+ &'a self,
+ cx: &'a task::TaskContext,
+ ) -> impl Iterator<Item = (TaskSourceKind, ResolvedTask)> + 'a {
+ self.templates.iter().filter_map(|(kind, template)| {
+ template
+ .resolve_task(&kind.to_id_base(), cx)
+ .map(|task| (kind.clone(), task))
+ })
+ }
+}
+
+#[derive(Clone)]
+pub struct ResolvedTasks {
+ pub templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>,
+ pub position: Anchor,
+}
+
+impl Editor {
+ pub fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if !self.mode().is_full()
+ || !EditorSettings::get_global(cx).gutter.runnables
+ || !self.enable_runnables
+ {
+ self.clear_runnables(None);
+ return;
+ }
+ if let Some(buffer) = self.buffer().read(cx).as_singleton() {
+ if self
+ .runnables
+ .has_cached(buffer.read(cx).remote_id(), &buffer.read(cx).version())
+ {
+ return;
+ }
+ }
+
+ let project = self.project().map(Entity::downgrade);
+ let lsp_task_sources = self.lsp_task_sources(true, true, cx);
+ let multi_buffer = self.buffer.downgrade();
+ self.runnables.runnables_update_task = cx.spawn_in(window, async move |editor, cx| {
+ cx.background_executor().timer(UPDATE_DEBOUNCE).await;
+ let Some(project) = project.and_then(|p| p.upgrade()) else {
+ return;
+ };
+
+ let hide_runnables = project.update(cx, |project, _| project.is_via_collab());
+ if hide_runnables {
+ return;
+ }
+ let lsp_tasks = if lsp_task_sources.is_empty() {
+ Vec::new()
+ } else {
+ let Ok(lsp_tasks) = cx
+ .update(|_, cx| crate::lsp_tasks(project.clone(), &lsp_task_sources, None, cx))
+ else {
+ return;
+ };
+ lsp_tasks.await
+ };
+ let new_rows = {
+ let Some((multi_buffer_snapshot, multi_buffer_query_range)) = editor
+ .update(cx, |editor, cx| {
+ let multi_buffer = editor.buffer().read(cx);
+ if multi_buffer.is_singleton() {
+ Some((multi_buffer.snapshot(cx), Anchor::min()..Anchor::max()))
+ } else {
+ let display_snapshot =
+ editor.display_map.update(cx, |map, cx| map.snapshot(cx));
+ let multi_buffer_query_range =
+ editor.multi_buffer_visible_range(&display_snapshot, cx);
+ let multi_buffer_snapshot = display_snapshot.buffer();
+ Some((
+ multi_buffer_snapshot.clone(),
+ multi_buffer_query_range.to_anchors(&multi_buffer_snapshot),
+ ))
+ }
+ })
+ .ok()
+ .flatten()
+ else {
+ return;
+ };
+ cx.background_spawn({
+ async move {
+ multi_buffer_snapshot
+ .runnable_ranges(multi_buffer_query_range)
+ .collect()
+ }
+ })
+ .await
+ };
+
+ let Ok(multi_buffer_snapshot) =
+ editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx))
+ else {
+ return;
+ };
+ let Ok(mut lsp_tasks_by_rows) = cx.update(|_, cx| {
+ lsp_tasks
+ .into_iter()
+ .flat_map(|(kind, tasks)| {
+ tasks.into_iter().filter_map(move |(location, task)| {
+ Some((kind.clone(), location?, task))
+ })
+ })
+ .fold(HashMap::default(), |mut acc, (kind, location, task)| {
+ let buffer = location.target.buffer;
+ let buffer_snapshot = buffer.read(cx).snapshot();
+ let offset = multi_buffer_snapshot.excerpts().find_map(
+ |(excerpt_id, snapshot, _)| {
+ if snapshot.remote_id() == buffer_snapshot.remote_id() {
+ multi_buffer_snapshot
+ .anchor_in_excerpt(excerpt_id, location.target.range.start)
+ } else {
+ None
+ }
+ },
+ );
+ if let Some(offset) = offset {
+ let task_buffer_range =
+ location.target.range.to_point(&buffer_snapshot);
+ let context_buffer_range =
+ task_buffer_range.to_offset(&buffer_snapshot);
+ let context_range = BufferOffset(context_buffer_range.start)
+ ..BufferOffset(context_buffer_range.end);
+
+ acc.entry((buffer_snapshot.remote_id(), task_buffer_range.start.row))
+ .or_insert_with(|| RunnableTasks {
+ templates: Vec::new(),
+ offset,
+ column: task_buffer_range.start.column,
+ extra_variables: HashMap::default(),
+ context_range,
+ })
+ .templates
+ .push((kind, task.original_task().clone()));
+ }
+
+ acc
+ })
+ }) else {
+ return;
+ };
+
+ let Ok(prefer_lsp) = multi_buffer.update(cx, |buffer, cx| {
+ buffer.language_settings(cx).tasks.prefer_lsp
+ }) else {
+ return;
+ };
+
+ let rows = Self::runnable_rows(
+ project,
+ multi_buffer_snapshot,
+ prefer_lsp && !lsp_tasks_by_rows.is_empty(),
+ new_rows,
+ cx.clone(),
+ )
+ .await;
+ editor
+ .update(cx, |editor, cx| {
+ for ((buffer_id, row), mut new_tasks) in rows {
+ let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+ continue;
+ };
+
+ if let Some(lsp_tasks) = lsp_tasks_by_rows.remove(&(buffer_id, row)) {
+ new_tasks.templates.extend(lsp_tasks.templates);
+ }
+ editor.insert_runnables(
+ buffer_id,
+ buffer.read(cx).version(),
+ row,
+ new_tasks,
+ );
+ }
+ for ((buffer_id, row), new_tasks) in lsp_tasks_by_rows {
+ let Some(buffer) = editor.buffer().read(cx).buffer(buffer_id) else {
+ continue;
+ };
+ editor.insert_runnables(
+ buffer_id,
+ buffer.read(cx).version(),
+ row,
+ new_tasks,
+ );
+ }
+ })
+ .ok();
+ });
+ }
+
+ pub fn spawn_nearest_task(
+ &mut self,
+ action: &SpawnNearestTask,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some((workspace, _)) = self.workspace.clone() else {
+ return;
+ };
+ let Some(project) = self.project.clone() else {
+ return;
+ };
+
+ // Try to find a closest, enclosing node using tree-sitter that has a task
+ let Some((buffer, buffer_row, tasks)) = self
+ .find_enclosing_node_task(cx)
+ // Or find the task that's closest in row-distance.
+ .or_else(|| self.find_closest_task(cx))
+ else {
+ return;
+ };
+
+ let reveal_strategy = action.reveal;
+ let task_context = Self::build_tasks_context(&project, &buffer, buffer_row, &tasks, cx);
+ cx.spawn_in(window, async move |_, cx| {
+ let context = task_context.await?;
+ let (task_source_kind, mut resolved_task) = tasks.resolve(&context).next()?;
+
+ let resolved = &mut resolved_task.resolved;
+ resolved.reveal = reveal_strategy;
+
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.schedule_resolved_task(
+ task_source_kind,
+ resolved_task,
+ false,
+ window,
+ cx,
+ );
+ })
+ .ok()
+ })
+ .detach();
+ }
+
+ pub fn clear_runnables(&mut self, for_buffer: Option<BufferId>) {
+ if let Some(buffer_id) = for_buffer {
+ self.runnables.runnables.remove(&buffer_id);
+ } else {
+ self.runnables.runnables.clear();
+ }
+ self.runnables.runnables_update_task = Task::ready(());
+ }
+
+ pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
+ let Some(project) = self.project.clone() else {
+ return Task::ready(None);
+ };
+ let (selection, buffer, editor_snapshot) = {
+ let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
+ let Some((buffer, _)) = self
+ .buffer()
+ .read(cx)
+ .point_to_buffer_offset(selection.start, cx)
+ else {
+ return Task::ready(None);
+ };
+ let snapshot = self.snapshot(window, cx);
+ (selection, buffer, snapshot)
+ };
+ let selection_range = selection.range();
+ let start = editor_snapshot
+ .display_snapshot
+ .buffer_snapshot()
+ .anchor_after(selection_range.start)
+ .text_anchor;
+ let end = editor_snapshot
+ .display_snapshot
+ .buffer_snapshot()
+ .anchor_after(selection_range.end)
+ .text_anchor;
+ let location = Location {
+ buffer,
+ range: start..end,
+ };
+ let captured_variables = {
+ let mut variables = TaskVariables::default();
+ let buffer = location.buffer.read(cx);
+ let buffer_id = buffer.remote_id();
+ let snapshot = buffer.snapshot();
+ let starting_point = location.range.start.to_point(&snapshot);
+ let starting_offset = starting_point.to_offset(&snapshot);
+ for (_, tasks) in self
+ .runnables
+ .runnables
+ .get(&buffer_id)
+ .into_iter()
+ .flat_map(|(_, tasks)| tasks.range(0..starting_point.row + 1))
+ {
+ if !tasks
+ .context_range
+ .contains(&crate::BufferOffset(starting_offset))
+ {
+ continue;
+ }
+ for (capture_name, value) in tasks.extra_variables.iter() {
+ variables.insert(
+ VariableName::Custom(capture_name.to_owned().into()),
+ value.clone(),
+ );
+ }
+ }
+ variables
+ };
+
+ project.update(cx, |project, cx| {
+ project.task_store().update(cx, |task_store, cx| {
+ task_store.task_context_for_location(captured_variables, location, cx)
+ })
+ })
+ }
+
+ pub fn lsp_task_sources(
+ &self,
+ visible_only: bool,
+ skip_cached: bool,
+ cx: &mut Context<Self>,
+ ) -> HashMap<LanguageServerName, Vec<BufferId>> {
+ if !self.lsp_data_enabled() {
+ return HashMap::default();
+ }
+ let buffers = if visible_only {
+ self.visible_excerpts(true, cx)
+ .into_values()
+ .map(|(buffer, _, _)| buffer)
+ .collect()
+ } else {
+ self.buffer().read(cx).all_buffers()
+ };
+
+ let lsp_settings = &ProjectSettings::get_global(cx).lsp;
+
+ buffers
+ .into_iter()
+ .filter_map(|buffer| {
+ let lsp_tasks_source = buffer
+ .read(cx)
+ .language()?
+ .context_provider()?
+ .lsp_task_source()?;
+ if lsp_settings
+ .get(&lsp_tasks_source)
+ .is_none_or(|s| s.enable_lsp_tasks)
+ {
+ let buffer_id = buffer.read(cx).remote_id();
+ if skip_cached
+ && self
+ .runnables
+ .has_cached(buffer_id, &buffer.read(cx).version())
+ {
+ None
+ } else {
+ Some((lsp_tasks_source, buffer_id))
+ }
+ } else {
+ None
+ }
+ })
+ .fold(
+ HashMap::default(),
+ |mut acc, (lsp_task_source, buffer_id)| {
+ acc.entry(lsp_task_source)
+ .or_insert_with(Vec::new)
+ .push(buffer_id);
+ acc
+ },
+ )
+ }
+
+ pub fn find_enclosing_node_task(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+ let snapshot = self.buffer.read(cx).snapshot(cx);
+ let offset = self
+ .selections
+ .newest::<MultiBufferOffset>(&self.display_snapshot(cx))
+ .head();
+ let mut excerpt = snapshot.excerpt_containing(offset..offset)?;
+ let offset = excerpt.map_offset_to_buffer(offset);
+ let buffer_id = excerpt.buffer().remote_id();
+
+ let layer = excerpt.buffer().syntax_layer_at(offset)?;
+ let mut cursor = layer.node().walk();
+
+ while cursor.goto_first_child_for_byte(offset.0).is_some() {
+ if cursor.node().end_byte() == offset.0 {
+ cursor.goto_next_sibling();
+ }
+ }
+
+ // Ascend to the smallest ancestor that contains the range and has a task.
+ loop {
+ let node = cursor.node();
+ let node_range = node.byte_range();
+ let symbol_start_row = excerpt.buffer().offset_to_point(node.start_byte()).row;
+
+ // Check if this node contains our offset
+ if node_range.start <= offset.0 && node_range.end >= offset.0 {
+ // If it contains offset, check for task
+ if let Some(tasks) = self
+ .runnables
+ .runnables
+ .get(&buffer_id)
+ .and_then(|(_, tasks)| tasks.get(&symbol_start_row))
+ {
+ let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+ return Some((buffer, symbol_start_row, Arc::new(tasks.to_owned())));
+ }
+ }
+
+ if !cursor.goto_parent() {
+ break;
+ }
+ }
+ None
+ }
+
+ pub fn render_run_indicator(
+ &self,
+ _style: &EditorStyle,
+ is_active: bool,
+ row: DisplayRow,
+ breakpoint: Option<(Anchor, Breakpoint, Option<BreakpointSessionState>)>,
+ cx: &mut Context<Self>,
+ ) -> IconButton {
+ let color = Color::Muted;
+ let position = breakpoint.as_ref().map(|(anchor, _, _)| *anchor);
+
+ IconButton::new(
+ ("run_indicator", row.0 as usize),
+ ui::IconName::PlayOutlined,
+ )
+ .shape(ui::IconButtonShape::Square)
+ .icon_size(IconSize::XSmall)
+ .icon_color(color)
+ .toggle_state(is_active)
+ .on_click(cx.listener(move |editor, e: &ClickEvent, window, cx| {
+ let quick_launch = match e {
+ ClickEvent::Keyboard(_) => true,
+ ClickEvent::Mouse(e) => e.down.button == MouseButton::Left,
+ };
+
+ window.focus(&editor.focus_handle(cx), cx);
+ editor.toggle_code_actions(
+ &ToggleCodeActions {
+ deployed_from: Some(CodeActionSource::RunMenu(row)),
+ quick_launch,
+ },
+ window,
+ cx,
+ );
+ }))
+ .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| {
+ editor.set_breakpoint_context_menu(row, position, event.position(), window, cx);
+ }))
+ }
+
+ fn insert_runnables(
+ &mut self,
+ buffer: BufferId,
+ version: Global,
+ row: BufferRow,
+ new_tasks: RunnableTasks,
+ ) {
+ let (old_version, tasks) = self.runnables.runnables.entry(buffer).or_default();
+ if !old_version.changed_since(&version) {
+ *old_version = version;
+ tasks.insert(row, new_tasks);
+ }
+ }
+
+ fn runnable_rows(
+ project: Entity<Project>,
+ snapshot: MultiBufferSnapshot,
+ prefer_lsp: bool,
+ runnable_ranges: Vec<(Range<MultiBufferOffset>, language::RunnableRange)>,
+ cx: AsyncWindowContext,
+ ) -> Task<Vec<((BufferId, BufferRow), RunnableTasks)>> {
+ cx.spawn(async move |cx| {
+ let mut runnable_rows = Vec::with_capacity(runnable_ranges.len());
+ for (run_range, mut runnable) in runnable_ranges {
+ let Some(tasks) = cx
+ .update(|_, cx| Self::templates_with_tags(&project, &mut runnable.runnable, cx))
+ .ok()
+ else {
+ continue;
+ };
+ let mut tasks = tasks.await;
+
+ if prefer_lsp {
+ tasks.retain(|(task_kind, _)| {
+ !matches!(task_kind, TaskSourceKind::Language { .. })
+ });
+ }
+ if tasks.is_empty() {
+ continue;
+ }
+
+ let point = run_range.start.to_point(&snapshot);
+ let Some(row) = snapshot
+ .buffer_line_for_row(MultiBufferRow(point.row))
+ .map(|(_, range)| range.start.row)
+ else {
+ continue;
+ };
+
+ let context_range =
+ BufferOffset(runnable.full_range.start)..BufferOffset(runnable.full_range.end);
+ runnable_rows.push((
+ (runnable.buffer_id, row),
+ RunnableTasks {
+ templates: tasks,
+ offset: snapshot.anchor_before(run_range.start),
+ context_range,
+ column: point.column,
+ extra_variables: runnable.extra_captures,
+ },
+ ));
+ }
+ runnable_rows
+ })
+ }
+
+ fn templates_with_tags(
+ project: &Entity<Project>,
+ runnable: &mut Runnable,
+ cx: &mut App,
+ ) -> Task<Vec<(TaskSourceKind, TaskTemplate)>> {
+ let (inventory, worktree_id, file) = project.read_with(cx, |project, cx| {
+ let (worktree_id, file) = project
+ .buffer_for_id(runnable.buffer, cx)
+ .and_then(|buffer| buffer.read(cx).file())
+ .map(|file| (file.worktree_id(cx), file.clone()))
+ .unzip();
+
+ (
+ project.task_store().read(cx).task_inventory().cloned(),
+ worktree_id,
+ file,
+ )
+ });
+
+ let tags = mem::take(&mut runnable.tags);
+ let language = runnable.language.clone();
+ cx.spawn(async move |cx| {
+ let mut templates_with_tags = Vec::new();
+ if let Some(inventory) = inventory {
+ for RunnableTag(tag) in tags {
+ let new_tasks = inventory.update(cx, |inventory, cx| {
+ inventory.list_tasks(file.clone(), Some(language.clone()), worktree_id, cx)
+ });
+ templates_with_tags.extend(new_tasks.await.into_iter().filter(
+ move |(_, template)| {
+ template.tags.iter().any(|source_tag| source_tag == &tag)
+ },
+ ));
+ }
+ }
+ templates_with_tags.sort_by_key(|(kind, _)| kind.to_owned());
+
+ if let Some((leading_tag_source, _)) = templates_with_tags.first() {
+ // Strongest source wins; if we have worktree tag binding, prefer that to
+ // global and language bindings;
+ // if we have a global binding, prefer that to language binding.
+ let first_mismatch = templates_with_tags
+ .iter()
+ .position(|(tag_source, _)| tag_source != leading_tag_source);
+ if let Some(index) = first_mismatch {
+ templates_with_tags.truncate(index);
+ }
+ }
+
+ templates_with_tags
+ })
+ }
+
+ fn find_closest_task(
+ &mut self,
+ cx: &mut Context<Self>,
+ ) -> Option<(Entity<Buffer>, u32, Arc<RunnableTasks>)> {
+ let cursor_row = self
+ .selections
+ .newest_adjusted(&self.display_snapshot(cx))
+ .head()
+ .row;
+
+ let ((buffer_id, row), tasks) = self
+ .runnables
+ .runnables
+ .iter()
+ .flat_map(|(buffer_id, (_, tasks))| {
+ tasks.iter().map(|(row, tasks)| ((*buffer_id, *row), tasks))
+ })
+ .min_by_key(|((_, row), _)| cursor_row.abs_diff(*row))?;
+
+ let buffer = self.buffer.read(cx).buffer(buffer_id)?;
+ let tasks = Arc::new(tasks.to_owned());
+ Some((buffer, row, tasks))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{sync::Arc, time::Duration};
+
+ use gpui::{AppContext as _, Task, TestAppContext};
+ use indoc::indoc;
+ use language::ContextProvider;
+ use languages::rust_lang;
+ use multi_buffer::{MultiBuffer, PathKey};
+ use project::{FakeFs, Project};
+ use serde_json::json;
+ use task::{TaskTemplate, TaskTemplates};
+ use text::Point;
+ use util::path;
+
+ use crate::{
+ Editor, UPDATE_DEBOUNCE, editor_tests::init_test, scroll::scroll_amount::ScrollAmount,
+ };
+
+ struct TestRustContextProvider;
+
+ impl ContextProvider for TestRustContextProvider {
+ fn associated_tasks(
+ &self,
+ _: Option<Arc<dyn language::File>>,
+ _: &gpui::App,
+ ) -> Task<Option<TaskTemplates>> {
+ Task::ready(Some(TaskTemplates(vec![
+ TaskTemplate {
+ label: "Run main".into(),
+ command: "cargo".into(),
+ args: vec!["run".into()],
+ tags: vec!["rust-main".into()],
+ ..TaskTemplate::default()
+ },
+ TaskTemplate {
+ label: "Run test".into(),
+ command: "cargo".into(),
+ args: vec!["test".into()],
+ tags: vec!["rust-test".into()],
+ ..TaskTemplate::default()
+ },
+ ])))
+ }
+ }
+
+ fn rust_lang_with_task_context() -> Arc<language::Language> {
+ Arc::new(
+ Arc::try_unwrap(rust_lang())
+ .unwrap()
+ .with_context_provider(Some(Arc::new(TestRustContextProvider))),
+ )
+ }
+
+ fn collect_runnable_labels(
+ editor: &Editor,
+ ) -> Vec<(text::BufferId, language::BufferRow, Vec<String>)> {
+ let mut result = editor
+ .runnables
+ .runnables
+ .iter()
+ .flat_map(|(buffer_id, (_, tasks))| {
+ tasks.iter().map(move |(row, runnable_tasks)| {
+ let mut labels: Vec<String> = runnable_tasks
+ .templates
+ .iter()
+ .map(|(_, template)| template.label.clone())
+ .collect();
+ labels.sort();
+ (*buffer_id, *row, labels)
+ })
+ })
+ .collect::<Vec<_>>();
+ result.sort_by_key(|(id, row, _)| (*id, *row));
+ result
+ }
+
+ #[gpui::test]
+ async fn test_multi_buffer_runnables_on_scroll(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let padding_lines = 50;
+ let mut first_rs = String::from("fn main() {\n println!(\"hello\");\n}\n");
+ for _ in 0..padding_lines {
+ first_rs.push_str("//\n");
+ }
+ let test_one_row = 3 + padding_lines as u32 + 1;
+ first_rs.push_str("#[test]\nfn test_one() {\n assert!(true);\n}\n");
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "first.rs": first_rs,
+ "second.rs": indoc! {"
+ #[test]
+ fn test_two() {
+ assert!(true);
+ }
+
+ #[test]
+ fn test_three() {
+ assert!(true);
+ }
+ "},
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(rust_lang_with_task_context());
+
+ let buffer_1 = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/project/first.rs"), cx)
+ })
+ .await
+ .unwrap();
+ let buffer_2 = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/project/second.rs"), cx)
+ })
+ .await
+ .unwrap();
+
+ let buffer_1_id = buffer_1.read_with(cx, |buffer, _| buffer.remote_id());
+ let buffer_2_id = buffer_2.read_with(cx, |buffer, _| buffer.remote_id());
+
+ let multi_buffer = cx.new(|cx| {
+ let mut multi_buffer = MultiBuffer::new(language::Capability::ReadWrite);
+ let end = buffer_1.read(cx).max_point();
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(0),
+ buffer_1.clone(),
+ [Point::new(0, 0)..end],
+ 0,
+ cx,
+ );
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(1),
+ buffer_2.clone(),
+ [Point::new(0, 0)..Point::new(8, 1)],
+ 0,
+ cx,
+ );
+ multi_buffer
+ });
+
+ let editor = cx.add_window(|window, cx| {
+ Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx)
+ });
+ cx.executor().advance_clock(Duration::from_millis(500));
+ cx.executor().run_until_parked();
+
+ // Clear stale data from startup events, then refresh.
+ // first.rs is long enough that second.rs is below the ~47-line viewport.
+ editor
+ .update(cx, |editor, window, cx| {
+ editor.clear_runnables(None);
+ editor.refresh_runnables(window, cx);
+ })
+ .unwrap();
+ cx.executor().advance_clock(UPDATE_DEBOUNCE);
+ cx.executor().run_until_parked();
+ assert_eq!(
+ editor
+ .update(cx, |editor, _, _| collect_runnable_labels(editor))
+ .unwrap(),
+ vec![(buffer_1_id, 0, vec!["Run main".to_string()])],
+ "Only fn main from first.rs should be visible before scrolling"
+ );
+
+ // Scroll down to bring second.rs excerpts into view.
+ editor
+ .update(cx, |editor, window, cx| {
+ editor.scroll_screen(&ScrollAmount::Page(1.0), window, cx);
+ })
+ .unwrap();
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.executor().run_until_parked();
+
+ let after_scroll = editor
+ .update(cx, |editor, _, _| collect_runnable_labels(editor))
+ .unwrap();
+ assert_eq!(
+ after_scroll,
+ vec![
+ (buffer_1_id, 0, vec!["Run main".to_string()]),
+ (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+ (buffer_2_id, 1, vec!["Run test".to_string()]),
+ (buffer_2_id, 6, vec!["Run test".to_string()]),
+ ],
+ "Tree-sitter should detect both #[test] fns in second.rs after scroll"
+ );
+
+ // Edit second.rs to invalidate its cache; first.rs data should persist.
+ buffer_2.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "// added comment\n")], None, cx);
+ });
+ editor
+ .update(cx, |editor, window, cx| {
+ editor.scroll_screen(&ScrollAmount::Page(-1.0), window, cx);
+ })
+ .unwrap();
+ cx.executor().advance_clock(Duration::from_millis(200));
+ cx.executor().run_until_parked();
+
+ assert_eq!(
+ editor
+ .update(cx, |editor, _, _| collect_runnable_labels(editor))
+ .unwrap(),
+ vec![
+ (buffer_1_id, 0, vec!["Run main".to_string()]),
+ (buffer_1_id, test_one_row, vec!["Run test".to_string()]),
+ ],
+ "first.rs runnables should survive an edit to second.rs"
+ );
+ }
+}
@@ -119,7 +119,7 @@ impl Editor {
for_server: Option<RefreshForServer>,
cx: &mut Context<Self>,
) {
- if !self.mode().is_full() || !self.semantic_token_state.enabled() {
+ if !self.lsp_data_enabled() || !self.semantic_token_state.enabled() {
self.invalidate_semantic_tokens(None);
self.display_map.update(cx, |display_map, _| {
match Arc::get_mut(&mut display_map.semantic_token_highlights) {
@@ -446,6 +446,9 @@ impl SplittableEditor {
let mut editor =
Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx);
editor.set_expand_all_diff_hunks(cx);
+ editor.disable_runnables();
+ editor.disable_diagnostics(cx);
+ editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
editor
});
// TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs
@@ -1,110 +0,0 @@
-use crate::Editor;
-
-use collections::HashMap;
-use gpui::{App, Task, Window};
-use lsp::LanguageServerName;
-use project::{Location, project_settings::ProjectSettings};
-use settings::Settings as _;
-use task::{TaskContext, TaskVariables, VariableName};
-use text::{BufferId, ToOffset, ToPoint};
-
-impl Editor {
- pub fn task_context(&self, window: &mut Window, cx: &mut App) -> Task<Option<TaskContext>> {
- let Some(project) = self.project.clone() else {
- return Task::ready(None);
- };
- let (selection, buffer, editor_snapshot) = {
- let selection = self.selections.newest_adjusted(&self.display_snapshot(cx));
- let Some((buffer, _)) = self
- .buffer()
- .read(cx)
- .point_to_buffer_offset(selection.start, cx)
- else {
- return Task::ready(None);
- };
- let snapshot = self.snapshot(window, cx);
- (selection, buffer, snapshot)
- };
- let selection_range = selection.range();
- let start = editor_snapshot
- .display_snapshot
- .buffer_snapshot()
- .anchor_after(selection_range.start)
- .text_anchor;
- let end = editor_snapshot
- .display_snapshot
- .buffer_snapshot()
- .anchor_after(selection_range.end)
- .text_anchor;
- let location = Location {
- buffer,
- range: start..end,
- };
- let captured_variables = {
- let mut variables = TaskVariables::default();
- let buffer = location.buffer.read(cx);
- let buffer_id = buffer.remote_id();
- let snapshot = buffer.snapshot();
- let starting_point = location.range.start.to_point(&snapshot);
- let starting_offset = starting_point.to_offset(&snapshot);
- for (_, tasks) in self
- .tasks
- .range((buffer_id, 0)..(buffer_id, starting_point.row + 1))
- {
- if !tasks
- .context_range
- .contains(&crate::BufferOffset(starting_offset))
- {
- continue;
- }
- for (capture_name, value) in tasks.extra_variables.iter() {
- variables.insert(
- VariableName::Custom(capture_name.to_owned().into()),
- value.clone(),
- );
- }
- }
- variables
- };
-
- project.update(cx, |project, cx| {
- project.task_store().update(cx, |task_store, cx| {
- task_store.task_context_for_location(captured_variables, location, cx)
- })
- })
- }
-
- pub fn lsp_task_sources(&self, cx: &App) -> HashMap<LanguageServerName, Vec<BufferId>> {
- let lsp_settings = &ProjectSettings::get_global(cx).lsp;
-
- self.buffer()
- .read(cx)
- .all_buffers()
- .into_iter()
- .filter_map(|buffer| {
- let lsp_tasks_source = buffer
- .read(cx)
- .language()?
- .context_provider()?
- .lsp_task_source()?;
- if lsp_settings
- .get(&lsp_tasks_source)
- .is_none_or(|s| s.enable_lsp_tasks)
- {
- let buffer_id = buffer.read(cx).remote_id();
- Some((lsp_tasks_source, buffer_id))
- } else {
- None
- }
- })
- .fold(
- HashMap::default(),
- |mut acc, (lsp_task_source, buffer_id)| {
- acc.entry(lsp_task_source)
- .or_insert_with(Vec::new)
- .push(buffer_id);
- acc
- },
- )
- }
-}
@@ -357,20 +357,16 @@ async fn run_agent(
Err(e) => return (Err(e), None),
};
- let thread_store = cx.new(|cx| ThreadStore::new(cx));
- let agent = match NativeAgent::new(
- project.clone(),
- thread_store,
- Templates::new(),
- None,
- app_state.fs.clone(),
- cx,
- )
- .await
- {
- Ok(a) => a,
- Err(e) => return (Err(e).context("creating agent"), None),
- };
+ let agent = cx.update(|cx| {
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ NativeAgent::new(
+ thread_store,
+ Templates::new(),
+ None,
+ app_state.fs.clone(),
+ cx,
+ )
+ });
let connection = Rc::new(NativeAgentConnection(agent.clone()));
let acp_thread = match cx
@@ -12,5 +12,4 @@ workspace = true
path = "src/feature_flags.rs"
[dependencies]
-futures.workspace = true
gpui.workspace = true
@@ -3,12 +3,8 @@ mod flags;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::LazyLock;
-use std::time::Duration;
-use std::{future::Future, pin::Pin, task::Poll};
-use futures::channel::oneshot;
-use futures::{FutureExt, select_biased};
-use gpui::{App, Context, Global, Subscription, Task, Window};
+use gpui::{App, Context, Global, Subscription, Window};
pub use flags::*;
@@ -122,11 +118,6 @@ pub struct OnFlagsReady {
}
pub trait FeatureFlagAppExt {
- fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag;
-
- /// Waits for the specified feature flag to resolve, up to the given timeout.
- fn wait_for_flag_or_timeout<T: FeatureFlag>(&mut self, timeout: Duration) -> Task<bool>;
-
fn update_flags(&mut self, staff: bool, flags: Vec<String>);
fn set_staff(&mut self, staff: bool);
fn has_flag<T: FeatureFlag>(&self) -> bool;
@@ -192,54 +183,4 @@ impl FeatureFlagAppExt for App {
callback(feature_flags.has_flag::<T>(), cx);
})
}
-
- fn wait_for_flag<T: FeatureFlag>(&mut self) -> WaitForFlag {
- let (tx, rx) = oneshot::channel::<bool>();
- let mut tx = Some(tx);
- let subscription: Option<Subscription>;
-
- match self.try_global::<FeatureFlags>() {
- Some(feature_flags) => {
- subscription = None;
- tx.take().unwrap().send(feature_flags.has_flag::<T>()).ok();
- }
- None => {
- subscription = Some(self.observe_global::<FeatureFlags>(move |cx| {
- let feature_flags = cx.global::<FeatureFlags>();
- if let Some(tx) = tx.take() {
- tx.send(feature_flags.has_flag::<T>()).ok();
- }
- }));
- }
- }
-
- WaitForFlag(rx, subscription)
- }
-
- fn wait_for_flag_or_timeout<T: FeatureFlag>(&mut self, timeout: Duration) -> Task<bool> {
- let wait_for_flag = self.wait_for_flag::<T>();
-
- self.spawn(async move |cx| {
- let mut wait_for_flag = wait_for_flag.fuse();
- let mut timeout = FutureExt::fuse(cx.background_executor().timer(timeout));
-
- select_biased! {
- is_enabled = wait_for_flag => is_enabled,
- _ = timeout => false,
- }
- })
- }
-}
-
-pub struct WaitForFlag(oneshot::Receiver<bool>, Option<Subscription>);
-
-impl Future for WaitForFlag {
- type Output = bool;
-
- fn poll(mut self: Pin<&mut Self>, cx: &mut core::task::Context<'_>) -> Poll<Self::Output> {
- self.0.poll_unpin(cx).map(|result| {
- self.1.take();
- result.unwrap_or(false)
- })
- }
}
@@ -15,7 +15,7 @@ use gpui::{
px, uniform_list,
};
use language::line_diff;
-use menu::{Cancel, SelectNext, SelectPrevious};
+use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::{
Project,
git_store::{
@@ -1171,22 +1171,35 @@ impl GitGraph {
cx.notify();
}
- fn select_prev(&mut self, _: &SelectPrevious, _window: &mut Window, cx: &mut Context<Self>) {
+ fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
+ self.select_entry(0, cx);
+ }
+
+ fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
if let Some(selected_entry_idx) = &self.selected_entry_idx {
self.select_entry(selected_entry_idx.saturating_sub(1), cx);
} else {
- self.select_entry(0, cx);
+ self.select_first(&SelectFirst, window, cx);
}
}
fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
if let Some(selected_entry_idx) = &self.selected_entry_idx {
- self.select_entry(selected_entry_idx.saturating_add(1), cx);
+ self.select_entry(
+ selected_entry_idx
+ .saturating_add(1)
+ .min(self.graph_data.commits.len().saturating_sub(1)),
+ cx,
+ );
} else {
self.select_prev(&SelectPrevious, window, cx);
}
}
+ fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
+ self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx);
+ }
+
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
self.open_selected_commit_view(window, cx);
}
@@ -2260,8 +2273,10 @@ impl Render for GitGraph {
this.open_selected_commit_view(window, cx);
}))
.on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(Self::select_first))
.on_action(cx.listener(Self::select_prev))
.on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::confirm))
.child(content)
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
@@ -15,7 +15,7 @@ use project::{
git_store::{GitStoreEvent, RepositoryEvent},
};
use settings::Settings;
-use std::{ops::Range, sync::Arc};
+use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
use ui::{ActiveTheme, Divider, Element as _, Styled, Window, prelude::*};
use util::{ResultExt as _, debug_panic, maybe};
use workspace::{
@@ -534,7 +534,9 @@ pub(crate) fn register_conflict_notification(
) {
let git_store = workspace.project().read(cx).git_store().clone();
- cx.subscribe(&git_store, |workspace, _git_store, event, cx| {
+ let last_shown_paths: Rc<RefCell<HashSet<String>>> = Rc::new(RefCell::new(HashSet::default()));
+
+ cx.subscribe(&git_store, move |workspace, _git_store, event, cx| {
let conflicts_changed = matches!(
event,
GitStoreEvent::ConflictsUpdated
@@ -546,10 +548,15 @@ pub(crate) fn register_conflict_notification(
let paths = collect_conflicted_file_paths(workspace, cx);
let notification_id = merge_conflict_notification_id();
+ let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
if paths.is_empty() {
+ last_shown_paths.borrow_mut().clear();
workspace.dismiss_notification(¬ification_id, cx);
- } else {
+ } else if *last_shown_paths.borrow() != current_paths_set {
+ // Only show the notification if the set of conflicted paths has changed.
+ // This prevents re-showing after the user dismisses it while working on the same conflicts.
+ *last_shown_paths.borrow_mut() = current_paths_set;
let file_count = paths.len();
workspace.show_notification(notification_id, cx, |cx| {
cx.new(|cx| {
@@ -560,7 +567,7 @@ pub(crate) fn register_conflict_notification(
};
MessageNotification::new(message, cx)
- .primary_message("Resolve Conflicts with Agent")
+ .primary_message("Resolve with Agent")
.primary_icon(IconName::ZedAssistant)
.primary_icon_color(Color::Muted)
.primary_on_click({
@@ -435,7 +435,7 @@ pub enum DiskState {
/// File created in Zed that has not been saved.
New,
/// File present on the filesystem.
- Present { mtime: MTime },
+ Present { mtime: MTime, size: u64 },
/// Deleted file that was previously present.
Deleted,
/// An old version of a file that was previously present
@@ -448,7 +448,17 @@ impl DiskState {
pub fn mtime(self) -> Option<MTime> {
match self {
DiskState::New => None,
- DiskState::Present { mtime } => Some(mtime),
+ DiskState::Present { mtime, .. } => Some(mtime),
+ DiskState::Deleted => None,
+ DiskState::Historic { .. } => None,
+ }
+ }
+
+ /// Returns the file's size on disk in bytes.
+ pub fn size(self) -> Option<u64> {
+ match self {
+ DiskState::New => None,
+ DiskState::Present { size, .. } => Some(size),
DiskState::Deleted => None,
DiskState::Historic { .. } => None,
}
@@ -2377,7 +2387,7 @@ impl Buffer {
};
match file.disk_state() {
DiskState::New => false,
- DiskState::Present { mtime } => match self.saved_mtime {
+ DiskState::Present { mtime, .. } => match self.saved_mtime {
Some(saved_mtime) => {
mtime.bad_is_greater_than(saved_mtime) && self.has_unsaved_edits()
}
@@ -2859,9 +2869,19 @@ impl Buffer {
self.reparse(cx, true);
cx.emit(BufferEvent::Edited { is_local });
- if was_dirty != self.is_dirty() {
+ let is_dirty = self.is_dirty();
+ if was_dirty != is_dirty {
cx.emit(BufferEvent::DirtyChanged);
}
+ if was_dirty && !is_dirty {
+ if let Some(file) = self.file.as_ref() {
+ if matches!(file.disk_state(), DiskState::Present { .. })
+ && file.disk_state().mtime() != self.saved_mtime
+ {
+ cx.emit(BufferEvent::ReloadNeeded);
+ }
+ }
+ }
cx.notify();
}
@@ -7,7 +7,7 @@ path_suffixes = [
"NOTES_EDITMSG",
"EDIT_DESCRIPTION",
]
-line_comments = ["#"]
+line_comments = ["# "]
brackets = [
{ start = "(", end = ")", close = true, newline = false },
{ start = "`", end = "`", close = true, newline = false },
@@ -2,7 +2,7 @@ name = "Go Mod"
code_fence_block_name = "go.mod"
grammar = "gomod"
path_suffixes = ["mod"]
-line_comments = ["//"]
+line_comments = ["// "]
autoclose_before = ")"
brackets = [
{ start = "(", end = ")", close = true, newline = true}
@@ -2,7 +2,7 @@ name = "Go Work"
code_fence_block_name = "gowork"
grammar = "gowork"
path_suffixes = ["work"]
-line_comments = ["//"]
+line_comments = ["// "]
autoclose_before = ")"
brackets = [
{ start = "(", end = ")", close = true, newline = true}
@@ -1846,6 +1846,17 @@ impl LspInstaller for PyLspAdapter {
) -> Option<LanguageServerBinary> {
if let Some(pylsp_bin) = delegate.which(Self::SERVER_NAME.as_ref()).await {
let env = delegate.shell_env().await;
+ delegate
+ .try_exec(LanguageServerBinary {
+ path: pylsp_bin.clone(),
+ arguments: vec!["--version".into()],
+ env: Some(env.clone()),
+ })
+ .await
+ .inspect_err(|err| {
+ log::warn!("failed to validate user-installed pylsp at {pylsp_bin:?}: {err:#}")
+ })
+ .ok()?;
Some(LanguageServerBinary {
path: pylsp_bin,
env: Some(env),
@@ -1854,7 +1865,21 @@ impl LspInstaller for PyLspAdapter {
} else {
let toolchain = toolchain?;
let pylsp_path = Path::new(toolchain.path.as_ref()).parent()?.join("pylsp");
- pylsp_path.exists().then(|| LanguageServerBinary {
+ if !pylsp_path.exists() {
+ return None;
+ }
+ delegate
+ .try_exec(LanguageServerBinary {
+ path: toolchain.path.to_string().into(),
+ arguments: vec![pylsp_path.clone().into(), "--version".into()],
+ env: None,
+ })
+ .await
+ .inspect_err(|err| {
+ log::warn!("failed to validate toolchain pylsp at {pylsp_path:?}: {err:#}")
+ })
+ .ok()?;
+ Some(LanguageServerBinary {
path: toolchain.path.to_string().into(),
arguments: vec![pylsp_path.into()],
env: None,
@@ -258,7 +258,7 @@ impl AudioStack {
apm: Arc<Mutex<apm::AudioProcessingModule>>,
mixer: Arc<Mutex<audio_mixer::AudioMixer>>,
sample_rate: u32,
- num_channels: u32,
+ _num_channels: u32,
output_audio_device: Option<DeviceId>,
) -> Result<()> {
// Prevent App Nap from throttling audio playback on macOS.
@@ -270,6 +270,7 @@ impl AudioStack {
let mut device_change_listener = DeviceChangeListener::new(false)?;
let (output_device, output_config) =
crate::default_device(false, output_audio_device.as_ref())?;
+ info!("Output config: {output_config:?}");
let (end_on_drop_tx, end_on_drop_rx) = std::sync::mpsc::channel::<()>();
let mixer = mixer.clone();
let apm = apm.clone();
@@ -300,7 +301,12 @@ impl AudioStack {
let sampled = resampler.remix_and_resample(
mixed,
sample_rate / 100,
- num_channels,
+ // We need to assume output number of channels as otherwise we will
+ // crash in process_reverse_stream otherwise as livekit's audio resampler
+ // does not seem to support non-matching channel counts.
+ // NOTE: you can verify this by debug printing buf.len() after this stage.
+ // For 2->4 channel upmix, we should see buf.len=1920, buf we get only 960.
+ output_config.channels() as u32,
sample_rate,
output_config.channels() as u32,
output_config.sample_rate(),
@@ -31,8 +31,6 @@ pub struct PlatformTitleBar {
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
system_window_tabs: Entity<SystemWindowTabs>,
- workspace_sidebar_open: bool,
- sidebar_has_notifications: bool,
}
impl PlatformTitleBar {
@@ -46,8 +44,6 @@ impl PlatformTitleBar {
children: SmallVec::new(),
should_move: false,
system_window_tabs,
- workspace_sidebar_open: false,
- sidebar_has_notifications: false,
}
}
@@ -74,28 +70,6 @@ impl PlatformTitleBar {
SystemWindowTabs::init(cx);
}
- pub fn is_workspace_sidebar_open(&self) -> bool {
- self.workspace_sidebar_open
- }
-
- pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
- self.workspace_sidebar_open = open;
- cx.notify();
- }
-
- pub fn sidebar_has_notifications(&self) -> bool {
- self.sidebar_has_notifications
- }
-
- pub fn set_sidebar_has_notifications(
- &mut self,
- has_notifications: bool,
- cx: &mut Context<Self>,
- ) {
- self.sidebar_has_notifications = has_notifications;
- cx.notify();
- }
-
pub fn is_multi_workspace_enabled(cx: &App) -> bool {
cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
}
@@ -110,9 +84,6 @@ impl Render for PlatformTitleBar {
let close_action = Box::new(workspace::CloseWindow);
let children = mem::take(&mut self.children);
- let is_multiworkspace_sidebar_open =
- PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
-
let title_bar = h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
@@ -161,9 +132,7 @@ impl Render for PlatformTitleBar {
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
- } else if self.platform_style == PlatformStyle::Mac
- && !is_multiworkspace_sidebar_open
- {
+ } else if self.platform_style == PlatformStyle::Mac {
this.pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
@@ -175,10 +144,9 @@ impl Render for PlatformTitleBar {
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
- .when(
- !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
- |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
- )
+ .when(!(tiling.top || tiling.left), |el| {
+ el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
+ })
// this border is to avoid a transparent gap in the rounded corners
.mt(px(-1.))
.mb(px(-1.))
@@ -100,7 +100,6 @@ pub trait ExternalAgentServer {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- status_tx: Option<watch::Sender<SharedString>>,
new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>>;
@@ -243,7 +242,6 @@ impl AgentServerStore {
project_id: *project_id,
upstream_client: upstream_client.clone(),
name: agent_server_name.clone(),
- status_tx: None,
new_version_available_tx: None,
})
as Box<dyn ExternalAgentServer>,
@@ -347,7 +345,6 @@ impl AgentServerStore {
pub fn init_remote(session: &AnyProtoClient) {
session.add_entity_message_handler(Self::handle_external_agents_updated);
- session.add_entity_message_handler(Self::handle_loading_status_updated);
session.add_entity_message_handler(Self::handle_new_version_available);
}
@@ -695,57 +692,38 @@ impl AgentServerStore {
.get_mut(&*envelope.payload.name)
.map(|entry| entry.server.as_mut())
.with_context(|| format!("agent `{}` not found", envelope.payload.name))?;
- let (status_tx, new_version_available_tx) = downstream_client
- .clone()
- .map(|(project_id, downstream_client)| {
- let (status_tx, mut status_rx) = watch::channel(SharedString::from(""));
- let (new_version_available_tx, mut new_version_available_rx) =
- watch::channel(None);
- cx.spawn({
- let downstream_client = downstream_client.clone();
- let name = envelope.payload.name.clone();
- async move |_, _| {
- while let Some(status) = status_rx.recv().await.ok() {
- downstream_client.send(
- proto::ExternalAgentLoadingStatusUpdated {
- project_id,
- name: name.clone(),
- status: status.to_string(),
- },
- )?;
+ let new_version_available_tx =
+ downstream_client
+ .clone()
+ .map(|(project_id, downstream_client)| {
+ let (new_version_available_tx, mut new_version_available_rx) =
+ watch::channel(None);
+ cx.spawn({
+ let name = envelope.payload.name.clone();
+ async move |_, _| {
+ if let Some(version) =
+ new_version_available_rx.recv().await.ok().flatten()
+ {
+ downstream_client.send(
+ proto::NewExternalAgentVersionAvailable {
+ project_id,
+ name: name.clone(),
+ version,
+ },
+ )?;
+ }
+ anyhow::Ok(())
}
- anyhow::Ok(())
- }
- })
- .detach_and_log_err(cx);
- cx.spawn({
- let name = envelope.payload.name.clone();
- async move |_, _| {
- if let Some(version) =
- new_version_available_rx.recv().await.ok().flatten()
- {
- downstream_client.send(
- proto::NewExternalAgentVersionAvailable {
- project_id,
- name: name.clone(),
- version,
- },
- )?;
- }
- anyhow::Ok(())
- }
- })
- .detach_and_log_err(cx);
- (status_tx, new_version_available_tx)
- })
- .unzip();
+ })
+ .detach_and_log_err(cx);
+ new_version_available_tx
+ });
let mut extra_env = HashMap::default();
if no_browser {
extra_env.insert("NO_BROWSER".to_owned(), "1".to_owned());
}
anyhow::Ok(agent.get_command(
extra_env,
- status_tx,
new_version_available_tx,
&mut cx.to_async(),
))
@@ -782,13 +760,11 @@ impl AgentServerStore {
};
let mut previous_entries = std::mem::take(&mut this.external_agents);
- let mut status_txs = HashMap::default();
let mut new_version_available_txs = HashMap::default();
let mut metadata = HashMap::default();
for (name, mut entry) in previous_entries.drain() {
if let Some(agent) = entry.server.downcast_mut::<RemoteExternalAgentServer>() {
- status_txs.insert(name.clone(), agent.status_tx.take());
new_version_available_txs
.insert(name.clone(), agent.new_version_available_tx.take());
}
@@ -820,7 +796,6 @@ impl AgentServerStore {
project_id: *project_id,
upstream_client: upstream_client.clone(),
name: agent_name.clone(),
- status_tx: status_txs.remove(&agent_name).flatten(),
new_version_available_tx: new_version_available_txs
.remove(&agent_name)
.flatten(),
@@ -884,22 +859,6 @@ impl AgentServerStore {
})
}
- async fn handle_loading_status_updated(
- this: Entity<Self>,
- envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
- mut cx: AsyncApp,
- ) -> Result<()> {
- this.update(&mut cx, |this, _| {
- if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name)
- && let Some(agent) = agent.server.downcast_mut::<RemoteExternalAgentServer>()
- && let Some(status_tx) = &mut agent.status_tx
- {
- status_tx.send(envelope.payload.status.into()).ok();
- }
- });
- Ok(())
- }
-
async fn handle_new_version_available(
this: Entity<Self>,
envelope: TypedEnvelope<proto::NewExternalAgentVersionAvailable>,
@@ -936,7 +895,6 @@ struct RemoteExternalAgentServer {
project_id: u64,
upstream_client: Entity<RemoteClient>,
name: ExternalAgentServerName,
- status_tx: Option<watch::Sender<SharedString>>,
new_version_available_tx: Option<watch::Sender<Option<String>>>,
}
@@ -944,14 +902,12 @@ impl ExternalAgentServer for RemoteExternalAgentServer {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- status_tx: Option<watch::Sender<SharedString>>,
new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
let project_id = self.project_id;
let name = self.name.to_string();
let upstream_client = self.upstream_client.downgrade();
- self.status_tx = status_tx;
self.new_version_available_tx = new_version_available_tx;
cx.spawn(async move |cx| {
let mut response = upstream_client
@@ -1005,7 +961,6 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -1205,7 +1160,6 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -1386,7 +1340,6 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -1453,7 +1406,6 @@ impl ExternalAgentServer for LocalCustomAgent {
fn get_command(
&mut self,
extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -527,7 +527,10 @@ impl LocalBufferStore {
let new_file = if let Some(entry) = snapshot_entry {
File {
disk_state: match entry.mtime {
- Some(mtime) => DiskState::Present { mtime },
+ Some(mtime) => DiskState::Present {
+ mtime,
+ size: entry.size,
+ },
None => old_file.disk_state,
},
is_local: true,
@@ -808,7 +808,10 @@ impl LocalImageStore {
let new_file = if let Some(entry) = snapshot_entry {
worktree::File {
disk_state: match entry.mtime {
- Some(mtime) => DiskState::Present { mtime },
+ Some(mtime) => DiskState::Present {
+ mtime,
+ size: entry.size,
+ },
None => old_file.disk_state,
},
is_local: true,
@@ -10,7 +10,6 @@ impl ExternalAgentServer for NoopExternalAgent {
fn get_command(
&mut self,
_extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
_cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -26,7 +26,6 @@ impl ExternalAgentServer for NoopExternalAgent {
fn get_command(
&mut self,
_extra_env: HashMap<String, String>,
- _status_tx: Option<watch::Sender<SharedString>>,
_new_version_available_tx: Option<watch::Sender<Option<String>>>,
_cx: &mut AsyncApp,
) -> Task<Result<AgentServerCommand>> {
@@ -5687,6 +5687,75 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
cx.update(|cx| assert!(buffer3.read(cx).is_dirty()));
}
+#[gpui::test]
+async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({
+ "file.txt": "version 1",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+ let buffer = project
+ .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx))
+ .await
+ .unwrap();
+
+ buffer.read_with(cx, |buffer, _| {
+ assert_eq!(buffer.text(), "version 1");
+ assert!(!buffer.is_dirty());
+ });
+
+ // User makes an edit, making the buffer dirty.
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(0..0, "user edit: ")], None, cx);
+ });
+
+ buffer.read_with(cx, |buffer, _| {
+ assert!(buffer.is_dirty());
+ assert_eq!(buffer.text(), "user edit: version 1");
+ });
+
+ // External tool writes new content while buffer is dirty.
+ // file_updated() updates the File but suppresses ReloadNeeded.
+ fs.save(
+ path!("/dir/file.txt").as_ref(),
+ &"version 2 from external tool".into(),
+ Default::default(),
+ )
+ .await
+ .unwrap();
+ cx.executor().run_until_parked();
+
+ buffer.read_with(cx, |buffer, _| {
+ assert!(buffer.has_conflict());
+ assert_eq!(buffer.text(), "user edit: version 1");
+ });
+
+ // User undoes their edit. Buffer becomes clean, but disk has different
+ // content. did_edit() detects the dirty->clean transition and checks if
+ // disk changed while dirty. Since mtime differs from saved_mtime, it
+ // emits ReloadNeeded.
+ buffer.update(cx, |buffer, cx| {
+ buffer.undo(cx);
+ });
+ cx.executor().run_until_parked();
+
+ buffer.read_with(cx, |buffer, _| {
+ assert_eq!(
+ buffer.text(),
+ "version 2 from external tool",
+ "buffer should reload from disk after undo makes it clean"
+ );
+ assert!(!buffer.is_dirty());
+ });
+}
+
#[gpui::test]
async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -222,7 +222,7 @@ message ExternalExtensionAgentsUpdated {
message ExternalAgentLoadingStatusUpdated {
uint64 project_id = 1;
string name = 2;
- string status = 3;
+ reserved 3;
}
message NewExternalAgentVersionAvailable {
@@ -1241,8 +1241,8 @@ impl PickerDelegate for RecentProjectsDelegate {
let focus_handle = self.focus_handle.clone();
let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
let open_folder_section = matches!(
- self.filtered_entries.get(self.selected_index)?,
- ProjectPickerEntry::OpenFolder { .. }
+ self.filtered_entries.get(self.selected_index),
+ Some(ProjectPickerEntry::OpenFolder { .. })
);
if popover_style {
@@ -2028,7 +2028,6 @@ async fn test_remote_external_agent_server(
.get_command(
HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]),
None,
- None,
&mut cx.to_async(),
)
})
@@ -622,7 +622,7 @@ pub struct GitPanelSettingsContent {
/// Whether to show the addition/deletion change count next to each file in the Git panel.
///
- /// Default: false
+ /// Default: true
pub diff_stats: Option<bool>,
}
@@ -1,50 +0,0 @@
-[package]
-name = "sidebar"
-version = "0.1.0"
-edition.workspace = true
-publish.workspace = true
-license = "GPL-3.0-or-later"
-
-[lints]
-workspace = true
-
-[lib]
-path = "src/sidebar.rs"
-
-[features]
-default = []
-
-[dependencies]
-acp_thread.workspace = true
-agent.workspace = true
-agent-client-protocol.workspace = true
-agent_ui.workspace = true
-chrono.workspace = true
-editor.workspace = true
-feature_flags.workspace = true
-fs.workspace = true
-gpui.workspace = true
-menu.workspace = true
-project.workspace = true
-recent_projects.workspace = true
-settings.workspace = true
-theme.workspace = true
-ui.workspace = true
-util.workspace = true
-workspace.workspace = true
-zed_actions.workspace = true
-
-[dev-dependencies]
-acp_thread = { workspace = true, features = ["test-support"] }
-agent = { workspace = true, features = ["test-support"] }
-agent_ui = { workspace = true, features = ["test-support"] }
-assistant_text_thread = { workspace = true, features = ["test-support"] }
-editor.workspace = true
-language_model = { workspace = true, features = ["test-support"] }
-serde_json.workspace = true
-feature_flags.workspace = true
-fs = { workspace = true, features = ["test-support"] }
-gpui = { workspace = true, features = ["test-support"] }
-project = { workspace = true, features = ["test-support"] }
-settings = { workspace = true, features = ["test-support"] }
-workspace = { workspace = true, features = ["test-support"] }
@@ -1 +0,0 @@
-../../LICENSE-GPL
@@ -18,7 +18,7 @@ pub struct Connection {
unsafe impl Send for Connection {}
impl Connection {
- pub(crate) fn open(uri: &str, persistent: bool) -> Result<Self> {
+ fn open_with_flags(uri: &str, persistent: bool, flags: i32) -> Result<Self> {
let mut connection = Self {
sqlite3: ptr::null_mut(),
persistent,
@@ -26,7 +26,6 @@ impl Connection {
_sqlite: PhantomData,
};
- let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE;
unsafe {
sqlite3_open_v2(
CString::new(uri)?.as_ptr(),
@@ -44,6 +43,14 @@ impl Connection {
Ok(connection)
}
+ pub(crate) fn open(uri: &str, persistent: bool) -> Result<Self> {
+ Self::open_with_flags(
+ uri,
+ persistent,
+ SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE,
+ )
+ }
+
/// Attempts to open the database at uri. If it fails, a shared memory db will be opened
/// instead.
pub fn open_file(uri: &str) -> Self {
@@ -51,13 +58,17 @@ impl Connection {
}
pub fn open_memory(uri: Option<&str>) -> Self {
- let in_memory_path = if let Some(uri) = uri {
- format!("file:{}?mode=memory&cache=shared", uri)
+ if let Some(uri) = uri {
+ let in_memory_path = format!("file:{}?mode=memory&cache=shared", uri);
+ return Self::open_with_flags(
+ &in_memory_path,
+ false,
+ SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_READWRITE | SQLITE_OPEN_URI,
+ )
+ .expect("Could not create fallback in memory db");
} else {
- ":memory:".to_string()
- };
-
- Self::open(&in_memory_path, false).expect("Could not create fallback in memory db")
+ Self::open(":memory:", false).expect("Could not create fallback in memory db")
+ }
}
pub fn persistent(&self) -> bool {
@@ -265,9 +276,50 @@ impl Drop for Connection {
mod test {
use anyhow::Result;
use indoc::indoc;
+ use std::{
+ fs,
+ sync::atomic::{AtomicUsize, Ordering},
+ };
use crate::connection::Connection;
+ static NEXT_NAMED_MEMORY_DB_ID: AtomicUsize = AtomicUsize::new(0);
+
+ fn unique_named_memory_db(prefix: &str) -> String {
+ format!(
+ "{prefix}_{}_{}",
+ std::process::id(),
+ NEXT_NAMED_MEMORY_DB_ID.fetch_add(1, Ordering::Relaxed)
+ )
+ }
+
+ fn literal_named_memory_paths(name: &str) -> [String; 3] {
+ let main = format!("file:{name}?mode=memory&cache=shared");
+ [main.clone(), format!("{main}-wal"), format!("{main}-shm")]
+ }
+
+ struct NamedMemoryPathGuard {
+ paths: [String; 3],
+ }
+
+ impl NamedMemoryPathGuard {
+ fn new(name: &str) -> Self {
+ let paths = literal_named_memory_paths(name);
+ for path in &paths {
+ let _ = fs::remove_file(path);
+ }
+ Self { paths }
+ }
+ }
+
+ impl Drop for NamedMemoryPathGuard {
+ fn drop(&mut self) {
+ for path in &self.paths {
+ let _ = fs::remove_file(path);
+ }
+ }
+ }
+
#[test]
fn string_round_trips() -> Result<()> {
let connection = Connection::open_memory(Some("string_round_trips"));
@@ -382,6 +434,41 @@ mod test {
assert_eq!(read_blobs, vec![blob]);
}
+ #[test]
+ fn named_memory_connections_do_not_create_literal_backing_files() {
+ let name = unique_named_memory_db("named_memory_connections_do_not_create_backing_files");
+ let guard = NamedMemoryPathGuard::new(&name);
+
+ let connection1 = Connection::open_memory(Some(&name));
+ connection1
+ .exec(indoc! {"
+ CREATE TABLE shared (
+ value INTEGER
+ )"})
+ .unwrap()()
+ .unwrap();
+ connection1
+ .exec("INSERT INTO shared (value) VALUES (7)")
+ .unwrap()()
+ .unwrap();
+
+ let connection2 = Connection::open_memory(Some(&name));
+ assert_eq!(
+ connection2
+ .select_row::<i64>("SELECT value FROM shared")
+ .unwrap()()
+ .unwrap(),
+ Some(7)
+ );
+
+ for path in &guard.paths {
+ assert!(
+ fs::metadata(path).is_err(),
+ "named in-memory database unexpectedly created backing file {path}"
+ );
+ }
+ }
+
#[test]
fn multi_step_statement_works() {
let connection = Connection::open_memory(Some("multi_step_statement_works"));
@@ -7,12 +7,15 @@ use std::{
ops::Deref,
sync::{Arc, LazyLock},
thread,
+ time::Duration,
};
use thread_local::ThreadLocal;
use crate::{connection::Connection, domain::Migrator, util::UnboundedSyncSender};
const MIGRATION_RETRIES: usize = 10;
+const CONNECTION_INITIALIZE_RETRIES: usize = 50;
+const CONNECTION_INITIALIZE_RETRY_DELAY: Duration = Duration::from_millis(1);
type QueuedWrite = Box<dyn 'static + Send + FnOnce()>;
type WriteQueue = Box<dyn 'static + Send + Sync + Fn(QueuedWrite)>;
@@ -197,21 +200,54 @@ impl ThreadSafeConnection {
Self::open_shared_memory(uri)
};
+ if let Some(initialize_query) = connection_initialize_query {
+ let mut last_error = None;
+ let initialized = (0..CONNECTION_INITIALIZE_RETRIES).any(|attempt| {
+ match connection
+ .exec(initialize_query)
+ .and_then(|mut statement| statement())
+ {
+ Ok(()) => true,
+ Err(err)
+ if is_schema_lock_error(&err)
+ && attempt + 1 < CONNECTION_INITIALIZE_RETRIES =>
+ {
+ last_error = Some(err);
+ thread::sleep(CONNECTION_INITIALIZE_RETRY_DELAY);
+ false
+ }
+ Err(err) => {
+ panic!(
+ "Initialize query failed to execute: {}\n\nCaused by:\n{err:#}",
+ initialize_query
+ )
+ }
+ }
+ });
+
+ if !initialized {
+ let err = last_error
+ .expect("connection initialization retries should record the last error");
+ panic!(
+ "Initialize query failed to execute after retries: {}\n\nCaused by:\n{err:#}",
+ initialize_query
+ );
+ }
+ }
+
// Disallow writes on the connection. The only writes allowed for thread safe connections
// are from the background thread that can serialize them.
*connection.write.get_mut() = false;
- if let Some(initialize_query) = connection_initialize_query {
- connection.exec(initialize_query).unwrap_or_else(|_| {
- panic!("Initialize query failed to execute: {}", initialize_query)
- })()
- .unwrap()
- }
-
connection
}
}
+fn is_schema_lock_error(err: &anyhow::Error) -> bool {
+ let message = format!("{err:#}");
+ message.contains("database schema is locked") || message.contains("database is locked")
+}
+
impl ThreadSafeConnection {
/// Special constructor for ThreadSafeConnection which disallows db initialization and migrations.
/// This allows construction to be infallible and not write to the db.
@@ -282,7 +318,7 @@ mod test {
use indoc::indoc;
use std::ops::Deref;
- use std::thread;
+ use std::{thread, time::Duration};
use crate::{domain::Domain, thread_safe_connection::ThreadSafeConnection};
@@ -318,38 +354,21 @@ mod test {
}
#[test]
- #[should_panic]
- fn wild_zed_lost_failure() {
- enum TestWorkspace {}
- impl Domain for TestWorkspace {
- const NAME: &str = "workspace";
-
- const MIGRATIONS: &[&str] = &["
- CREATE TABLE workspaces(
- workspace_id INTEGER PRIMARY KEY,
- dock_visible INTEGER, -- Boolean
- dock_anchor TEXT, -- Enum: 'Bottom' / 'Right' / 'Expanded'
- dock_pane INTEGER, -- NULL indicates that we don't have a dock pane yet
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
- FOREIGN KEY(dock_pane) REFERENCES panes(pane_id),
- FOREIGN KEY(active_pane) REFERENCES panes(pane_id)
- ) STRICT;
-
- CREATE TABLE panes(
- pane_id INTEGER PRIMARY KEY,
- workspace_id INTEGER NOT NULL,
- active INTEGER NOT NULL, -- Boolean
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ON UPDATE CASCADE
- ) STRICT;
- "];
- }
-
- let builder =
- ThreadSafeConnection::builder::<TestWorkspace>("wild_zed_lost_failure", false)
- .with_connection_initialize_query("PRAGMA FOREIGN_KEYS=true");
-
- smol::block_on(builder.build()).unwrap();
+ fn connection_initialize_query_retries_transient_schema_lock() {
+ let name = "connection_initialize_query_retries_transient_schema_lock";
+ let locking_connection = crate::connection::Connection::open_memory(Some(name));
+ locking_connection.exec("BEGIN IMMEDIATE").unwrap()().unwrap();
+ locking_connection
+ .exec("CREATE TABLE test(col TEXT)")
+ .unwrap()()
+ .unwrap();
+
+ let releaser = thread::spawn(move || {
+ thread::sleep(Duration::from_millis(10));
+ locking_connection.exec("ROLLBACK").unwrap()().unwrap();
+ });
+
+ ThreadSafeConnection::create_connection(false, name, Some("PRAGMA FOREIGN_KEYS=true"));
+ releaser.join().unwrap();
}
}
@@ -316,7 +316,9 @@ pub fn task_contexts(
let lsp_task_sources = active_editor
.as_ref()
- .map(|active_editor| active_editor.update(cx, |editor, cx| editor.lsp_task_sources(cx)))
+ .map(|active_editor| {
+ active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx))
+ })
.unwrap_or_default();
let latest_selection = active_editor.as_ref().map(|active_editor| {
@@ -38,7 +38,6 @@ chrono.workspace = true
client.workspace = true
cloud_api_types.workspace = true
db.workspace = true
-feature_flags.workspace = true
git_ui.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
notifications.workspace = true
@@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore, zed_urls};
use cloud_api_types::Plan;
-use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
};
use onboarding_banner::OnboardingBanner;
-use project::{
- DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
-};
+use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
use remote::RemoteConnectionOptions;
use settings::Settings;
use settings::WorktreeId;
@@ -47,8 +44,7 @@ use ui::{
use update_version::UpdateVersion;
use util::ResultExt;
use workspace::{
- MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
- notifications::NotifyResultExt,
+ MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
};
use zed_actions::OpenRemote;
@@ -174,7 +170,6 @@ impl Render for TitleBar {
let mut render_project_items = title_bar_settings.show_branch_name
|| title_bar_settings.show_project_items;
title_bar
- .children(self.render_workspace_sidebar_toggle(window, cx))
.when_some(
self.application_menu.clone().filter(|_| !show_menus),
|title_bar, menu| {
@@ -357,7 +352,6 @@ impl TitleBar {
// Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
{
- let platform_titlebar = platform_titlebar.clone();
let window_handle = window.window_handle();
cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
@@ -370,26 +364,8 @@ impl TitleBar {
return;
};
- let is_open = multi_workspace.read(cx).is_sidebar_open();
- let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
- platform_titlebar.update(cx, |titlebar, cx| {
- titlebar.set_workspace_sidebar_open(is_open, cx);
- titlebar.set_sidebar_has_notifications(has_notifications, cx);
- });
-
- let platform_titlebar = platform_titlebar.clone();
- let subscription = cx.observe(&multi_workspace, move |mw, cx| {
- let is_open = mw.read(cx).is_sidebar_open();
- let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
- platform_titlebar.update(cx, |titlebar, cx| {
- titlebar.set_workspace_sidebar_open(is_open, cx);
- titlebar.set_sidebar_has_notifications(has_notifications, cx);
- });
- });
-
if let Some(this) = this.upgrade() {
this.update(cx, |this, _| {
- this._subscriptions.push(subscription);
this.multi_workspace = Some(multi_workspace.downgrade());
});
}
@@ -686,46 +662,7 @@ impl TitleBar {
)
}
- fn render_workspace_sidebar_toggle(
- &self,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<AnyElement> {
- if !cx.has_flag::<AgentV2FeatureFlag>() || DisableAiSettings::get_global(cx).disable_ai {
- return None;
- }
-
- let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
-
- if is_sidebar_open {
- return None;
- }
-
- let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
-
- Some(
- IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
- .icon_size(IconSize::Small)
- .when(has_notifications, |button| {
- button
- .indicator(Indicator::dot().color(Color::Accent))
- .indicator_border_color(Some(cx.theme().colors().title_bar_background))
- })
- .tooltip(move |_, cx| {
- Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
- })
- .on_click(|_, window, cx| {
- window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
- })
- .into_any_element(),
- )
- }
-
- pub fn render_project_name(
- &self,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
+ pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let workspace = self.workspace.clone();
let name = self.effective_active_worktree(cx).map(|worktree| {
@@ -741,19 +678,6 @@ impl TitleBar {
"Open Recent Project".to_string()
};
- let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
-
- if is_sidebar_open {
- return self
- .render_project_name_with_sidebar_popover(
- window,
- display_name,
- is_project_selected,
- cx,
- )
- .into_any_element();
- }
-
let focus_handle = workspace
.upgrade()
.map(|w| w.read(cx).focus_handle(cx))
@@ -793,49 +717,6 @@ impl TitleBar {
.into_any_element()
}
- fn render_project_name_with_sidebar_popover(
- &self,
- _window: &Window,
- display_name: String,
- is_project_selected: bool,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
- let multi_workspace = self.multi_workspace.clone();
-
- let is_popover_deployed = multi_workspace
- .as_ref()
- .and_then(|mw| mw.upgrade())
- .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx))
- .unwrap_or(false);
-
- Button::new("project_name_trigger", display_name)
- .label_size(LabelSize::Small)
- .when(self.worktree_count(cx) > 1, |this| {
- this.icon(IconName::ChevronDown)
- .icon_color(Color::Muted)
- .icon_size(IconSize::XSmall)
- })
- .toggle_state(is_popover_deployed)
- .selected_style(ButtonStyle::Tinted(TintColor::Accent))
- .when(!is_project_selected, |s| s.color(Color::Muted))
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Recent Projects",
- &zed_actions::OpenRecent {
- create_new_window: false,
- },
- cx,
- )
- })
- .on_click(move |_, window, cx| {
- if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) {
- mw.update(cx, |mw, cx| {
- mw.toggle_recent_projects_popover(window, cx);
- });
- }
- })
- }
-
pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let effective_worktree = self.effective_active_worktree(cx)?;
let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;
@@ -227,6 +227,12 @@ impl RenderOnce for ThreadItem {
.gradient_stop(0.8)
.group_name("thread-item");
+ let has_diff_stats = self.added.is_some() || self.removed.is_some();
+ let added_count = self.added.unwrap_or(0);
+ let removed_count = self.removed.unwrap_or(0);
+ let diff_stat_id = self.id.clone();
+ let has_worktree = self.worktree.is_some();
+
v_flex()
.id(self.id.clone())
.group("thread-item")
@@ -235,7 +241,7 @@ impl RenderOnce for ThreadItem {
.cursor_pointer()
.w_full()
.map(|this| {
- if self.worktree.is_some() {
+ if has_worktree || has_diff_stats {
this.p_2()
} else {
this.px_2().py_1()
@@ -300,35 +306,24 @@ impl RenderOnce for ThreadItem {
.gap_1p5()
.child(icon_container()) // Icon Spacing
.child(worktree_label)
- // TODO: Uncomment the elements below when we're ready to expose this data
- // .child(dot_separator())
- // .child(
- // Label::new(self.timestamp)
- // .size(LabelSize::Small)
- // .color(Color::Muted),
- // )
- // .child(
- // Label::new("•")
- // .size(LabelSize::Small)
- // .color(Color::Muted)
- // .alpha(0.5),
- // )
- // .when(has_no_changes, |this| {
- // this.child(
- // Label::new("No Changes")
- // .size(LabelSize::Small)
- // .color(Color::Muted),
- // )
- // })
- .when(self.added.is_some() || self.removed.is_some(), |this| {
+ .when(has_diff_stats, |this| {
this.child(DiffStat::new(
- self.id,
- self.added.unwrap_or(0),
- self.removed.unwrap_or(0),
+ diff_stat_id.clone(),
+ added_count,
+ removed_count,
))
}),
)
})
+ .when(!has_worktree && has_diff_stats, |this| {
+ this.child(
+ h_flex()
+ .min_w_0()
+ .gap_1p5()
+ .child(icon_container()) // Icon Spacing
+ .child(DiffStat::new(diff_stat_id, added_count, removed_count)),
+ )
+ })
.when_some(self.on_click, |this, on_click| this.on_click(on_click))
}
}
@@ -31,6 +31,9 @@ pub struct ListItem {
/// A slot for content that appears on hover after the children
/// It will obscure the `end_slot` when visible.
end_hover_slot: Option<AnyElement>,
+ /// When true, renders a gradient fade overlay before the `end_hover_slot`
+ /// to smoothly truncate overflowing content.
+ end_hover_gradient_overlay: bool,
toggle: Option<bool>,
inset: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -60,6 +63,7 @@ impl ListItem {
start_slot: None,
end_slot: None,
end_hover_slot: None,
+ end_hover_gradient_overlay: false,
toggle: None,
inset: false,
on_click: None,
@@ -166,6 +170,11 @@ impl ListItem {
self
}
+ pub fn end_hover_gradient_overlay(mut self, show: bool) -> Self {
+ self.end_hover_gradient_overlay = show;
+ self
+ }
+
pub fn outlined(mut self) -> Self {
self.outlined = true;
self
@@ -362,7 +371,9 @@ impl RenderOnce for ListItem {
.right(DynamicSpacing::Base06.rems(cx))
.top_0()
.visible_on_hover("list_item")
- .child(end_hover_gradient_overlay)
+ .when(self.end_hover_gradient_overlay, |this| {
+ this.child(end_hover_gradient_overlay)
+ })
.child(end_hover_slot),
)
}),
@@ -1,9 +1,8 @@
use anyhow::Result;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
- AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
- ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
- actions, deferred, px,
+ App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
+ Subscription, Task, Tiling, Window, WindowId, actions, px,
};
use project::{DisableAiSettings, Project};
use settings::Settings;
@@ -12,11 +11,12 @@ use std::path::PathBuf;
use ui::prelude::*;
use util::ResultExt;
-const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
use crate::{
CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
+ persistence::model::MultiWorkspaceId,
};
actions!(
@@ -41,31 +41,6 @@ pub enum MultiWorkspaceEvent {
WorkspaceRemoved(EntityId),
}
-pub enum SidebarEvent {
- Open,
- Close,
-}
-
-pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
- fn width(&self, cx: &App) -> Pixels;
- fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
- fn has_notifications(&self, cx: &App) -> bool;
- fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
- fn is_recent_projects_popover_deployed(&self) -> bool;
-}
-
-pub trait SidebarHandle: 'static + Send + Sync {
- fn width(&self, cx: &App) -> Pixels;
- fn set_width(&self, width: Option<Pixels>, cx: &mut App);
- fn focus_handle(&self, cx: &App) -> FocusHandle;
- fn focus(&self, window: &mut Window, cx: &mut App);
- fn has_notifications(&self, cx: &App) -> bool;
- fn to_any(&self) -> AnyView;
- fn entity_id(&self) -> EntityId;
- fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
- fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool;
-}
-
#[derive(Clone)]
pub struct DraggedSidebar;
@@ -75,54 +50,11 @@ impl Render for DraggedSidebar {
}
}
-impl<T: Sidebar> SidebarHandle for Entity<T> {
- fn width(&self, cx: &App) -> Pixels {
- self.read(cx).width(cx)
- }
-
- fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
- self.update(cx, |this, cx| this.set_width(width, cx))
- }
-
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.read(cx).focus_handle(cx)
- }
-
- fn focus(&self, window: &mut Window, cx: &mut App) {
- let handle = self.read(cx).focus_handle(cx);
- window.focus(&handle, cx);
- }
-
- fn has_notifications(&self, cx: &App) -> bool {
- self.read(cx).has_notifications(cx)
- }
-
- fn to_any(&self) -> AnyView {
- self.clone().into()
- }
-
- fn entity_id(&self) -> EntityId {
- Entity::entity_id(self)
- }
-
- fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
- self.update(cx, |this, cx| {
- this.toggle_recent_projects_popover(window, cx);
- });
- }
-
- fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
- self.read(cx).is_recent_projects_popover_deployed()
- }
-}
-
pub struct MultiWorkspace {
window_id: WindowId,
workspaces: Vec<Entity<Workspace>>,
+ database_id: Option<MultiWorkspaceId>,
active_workspace_index: usize,
- sidebar: Option<Box<dyn SidebarHandle>>,
- sidebar_open: bool,
- _sidebar_subscription: Option<Subscription>,
pending_removal_tasks: Vec<Task<()>>,
_serialize_task: Option<Task<()>>,
_create_task: Option<Task<()>>,
@@ -131,6 +63,10 @@ pub struct MultiWorkspace {
impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
+pub fn multi_workspace_enabled(cx: &App) -> bool {
+ cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
+}
+
impl MultiWorkspace {
pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
@@ -145,142 +81,17 @@ impl MultiWorkspace {
}
});
let quit_subscription = cx.on_app_quit(Self::app_will_quit);
- let settings_subscription =
- cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
- if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
- this.close_sidebar(window, cx);
- }
- });
Self::subscribe_to_workspace(&workspace, cx);
Self {
window_id: window.window_handle().window_id(),
+ database_id: None,
workspaces: vec![workspace],
active_workspace_index: 0,
- sidebar: None,
- sidebar_open: false,
- _sidebar_subscription: None,
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_create_task: None,
- _subscriptions: vec![
- release_subscription,
- quit_subscription,
- settings_subscription,
- ],
- }
- }
-
- pub fn register_sidebar<T: Sidebar>(
- &mut self,
- sidebar: Entity<T>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let subscription =
- cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
- SidebarEvent::Open => this.toggle_sidebar(window, cx),
- SidebarEvent::Close => {
- this.close_sidebar(window, cx);
- }
- });
- self.sidebar = Some(Box::new(sidebar));
- self._sidebar_subscription = Some(subscription);
- }
-
- pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
- self.sidebar.as_deref()
- }
-
- pub fn sidebar_open(&self) -> bool {
- self.sidebar_open && self.sidebar.is_some()
- }
-
- pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
- self.sidebar
- .as_ref()
- .map_or(false, |s| s.has_notifications(cx))
- }
-
- pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
- if let Some(sidebar) = &self.sidebar {
- sidebar.toggle_recent_projects_popover(window, cx);
- }
- }
-
- pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
- self.sidebar
- .as_ref()
- .map_or(false, |s| s.is_recent_projects_popover_deployed(cx))
- }
-
- pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
- cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
- }
-
- pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !self.multi_workspace_enabled(cx) {
- return;
- }
-
- if self.sidebar_open {
- self.close_sidebar(window, cx);
- } else {
- self.open_sidebar(cx);
- if let Some(sidebar) = &self.sidebar {
- sidebar.focus(window, cx);
- }
- }
- }
-
- pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !self.multi_workspace_enabled(cx) {
- return;
- }
-
- if self.sidebar_open {
- let sidebar_is_focused = self
- .sidebar
- .as_ref()
- .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
-
- if sidebar_is_focused {
- let pane = self.workspace().read(cx).active_pane().clone();
- let pane_focus = pane.read(cx).focus_handle(cx);
- window.focus(&pane_focus, cx);
- } else if let Some(sidebar) = &self.sidebar {
- sidebar.focus(window, cx);
- }
- } else {
- self.open_sidebar(cx);
- if let Some(sidebar) = &self.sidebar {
- sidebar.focus(window, cx);
- }
- }
- }
-
- pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
- self.sidebar_open = true;
- for workspace in &self.workspaces {
- workspace.update(cx, |workspace, cx| {
- workspace.set_workspace_sidebar_open(true, cx);
- });
- }
- self.serialize(cx);
- cx.notify();
- }
-
- fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- self.sidebar_open = false;
- for workspace in &self.workspaces {
- workspace.update(cx, |workspace, cx| {
- workspace.set_workspace_sidebar_open(false, cx);
- });
+ _subscriptions: vec![release_subscription, quit_subscription],
}
- let pane = self.workspace().read(cx).active_pane().clone();
- let pane_focus = pane.read(cx).focus_handle(cx);
- window.focus(&pane_focus, cx);
- self.serialize(cx);
- cx.notify();
}
pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
@@ -318,10 +129,6 @@ impl MultiWorkspace {
.detach();
}
- pub fn is_sidebar_open(&self) -> bool {
- self.sidebar_open
- }
-
pub fn workspace(&self) -> &Entity<Workspace> {
&self.workspaces[self.active_workspace_index]
}
@@ -335,7 +142,7 @@ impl MultiWorkspace {
}
pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
- if !self.multi_workspace_enabled(cx) {
+ if !multi_workspace_enabled(cx) {
self.workspaces[0] = workspace;
self.active_workspace_index = 0;
cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
@@ -371,11 +178,6 @@ impl MultiWorkspace {
if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
index
} else {
- if self.sidebar_open {
- workspace.update(cx, |workspace, cx| {
- workspace.set_workspace_sidebar_open(true, cx);
- });
- }
Self::subscribe_to_workspace(&workspace, cx);
self.workspaces.push(workspace.clone());
cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
@@ -384,6 +186,14 @@ impl MultiWorkspace {
}
}
+ pub fn database_id(&self) -> Option<MultiWorkspaceId> {
+ self.database_id
+ }
+
+ pub fn set_database_id(&mut self, id: Option<MultiWorkspaceId>) {
+ self.database_id = id;
+ }
+
pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
debug_assert!(
index < self.workspaces.len(),
@@ -421,7 +231,6 @@ impl MultiWorkspace {
let window_id = self.window_id;
let state = crate::persistence::model::MultiWorkspaceState {
active_workspace_id: self.workspace().read(cx).database_id(),
- sidebar_open: self.sidebar_open,
};
self._serialize_task = Some(cx.background_spawn(async move {
crate::persistence::write_multi_workspace_state(window_id, state).await;
@@ -540,7 +349,7 @@ impl MultiWorkspace {
self.workspace().read(cx).items_of_type::<T>(cx)
}
- pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+ pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
self.workspace().read(cx).database_id()
}
@@ -583,7 +392,7 @@ impl MultiWorkspace {
}
pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !self.multi_workspace_enabled(cx) {
+ if !multi_workspace_enabled(cx) {
return;
}
let app_state = self.workspace().read(cx).app_state().clone();
@@ -692,7 +501,7 @@ impl MultiWorkspace {
) -> Task<Result<()>> {
let workspace = self.workspace().clone();
- if self.multi_workspace_enabled(cx) {
+ if multi_workspace_enabled(cx) {
workspace.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(true, paths, window, cx)
})
@@ -719,57 +528,6 @@ impl MultiWorkspace {
impl Render for MultiWorkspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let multi_workspace_enabled = self.multi_workspace_enabled(cx);
-
- let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
- self.sidebar.as_ref().map(|sidebar_handle| {
- let weak = cx.weak_entity();
-
- let sidebar_width = sidebar_handle.width(cx);
- let resize_handle = deferred(
- div()
- .id("sidebar-resize-handle")
- .absolute()
- .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
- .top(px(0.))
- .h_full()
- .w(SIDEBAR_RESIZE_HANDLE_SIZE)
- .cursor_col_resize()
- .on_drag(DraggedSidebar, |dragged, _, _, cx| {
- cx.stop_propagation();
- cx.new(|_| dragged.clone())
- })
- .on_mouse_down(MouseButton::Left, |_, _, cx| {
- cx.stop_propagation();
- })
- .on_mouse_up(MouseButton::Left, move |event, _, cx| {
- if event.click_count == 2 {
- weak.update(cx, |this, cx| {
- if let Some(sidebar) = this.sidebar.as_mut() {
- sidebar.set_width(None, cx);
- }
- })
- .ok();
- cx.stop_propagation();
- }
- })
- .occlude(),
- );
-
- div()
- .id("sidebar-container")
- .relative()
- .h_full()
- .w(sidebar_width)
- .flex_shrink_0()
- .child(sidebar_handle.to_any())
- .child(resize_handle)
- .into_any_element()
- })
- } else {
- None
- };
-
let ui_font = theme::setup_ui_font(window, cx);
let text_color = cx.theme().colors().text;
@@ -799,32 +557,6 @@ impl Render for MultiWorkspace {
this.activate_previous_workspace(window, cx);
},
))
- .when(self.multi_workspace_enabled(cx), |this| {
- this.on_action(cx.listener(
- |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
- this.toggle_sidebar(window, cx);
- },
- ))
- .on_action(cx.listener(
- |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
- this.focus_sidebar(window, cx);
- },
- ))
- })
- .when(
- self.sidebar_open() && self.multi_workspace_enabled(cx),
- |this| {
- this.on_drag_move(cx.listener(
- |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
- if let Some(sidebar) = &this.sidebar {
- let new_width = e.event.position.x;
- sidebar.set_width(Some(new_width), cx);
- }
- },
- ))
- .children(sidebar)
- },
- )
.child(
div()
.flex()
@@ -837,98 +569,9 @@ impl Render for MultiWorkspace {
window,
cx,
Tiling {
- left: multi_workspace_enabled && self.sidebar_open,
+ left: false,
..Tiling::default()
},
)
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fs::FakeFs;
- use gpui::TestAppContext;
- use settings::SettingsStore;
-
- fn init_test(cx: &mut TestAppContext) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- theme::init(theme::LoadThemes::JustBase, cx);
- DisableAiSettings::register(cx);
- cx.update_flags(false, vec!["agent-v2".into()]);
- });
- }
-
- #[gpui::test]
- async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
-
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(mw.multi_workspace_enabled(cx));
- });
-
- multi_workspace.update_in(cx, |mw, _window, cx| {
- mw.open_sidebar(cx);
- assert!(mw.is_sidebar_open());
- });
-
- cx.update(|_window, cx| {
- DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
- });
- cx.run_until_parked();
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(
- !mw.is_sidebar_open(),
- "Sidebar should be closed when disable_ai is true"
- );
- assert!(
- !mw.multi_workspace_enabled(cx),
- "Multi-workspace should be disabled when disable_ai is true"
- );
- });
-
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.toggle_sidebar(window, cx);
- });
- multi_workspace.read_with(cx, |mw, _cx| {
- assert!(
- !mw.is_sidebar_open(),
- "Sidebar should remain closed when toggled with disable_ai true"
- );
- });
-
- cx.update(|_window, cx| {
- DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
- });
- cx.run_until_parked();
-
- multi_workspace.read_with(cx, |mw, cx| {
- assert!(
- mw.multi_workspace_enabled(cx),
- "Multi-workspace should be enabled after re-enabling AI"
- );
- assert!(
- !mw.is_sidebar_open(),
- "Sidebar should still be closed after re-enabling AI (not auto-opened)"
- );
- });
-
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.toggle_sidebar(window, cx);
- });
- multi_workspace.read_with(cx, |mw, _cx| {
- assert!(
- mw.is_sidebar_open(),
- "Sidebar should open when toggled after re-enabling AI"
- );
- });
- }
-}
@@ -657,15 +657,17 @@ impl RenderOnce for NotificationFrame {
IconButton::new(close_id, close_icon)
.tooltip(move |_window, cx| {
if suppress {
- Tooltip::for_action(
- "Suppress.\nClose with click.",
- &SuppressNotification,
+ Tooltip::with_meta(
+ "Suppress",
+ Some(&SuppressNotification),
+ "Click to Close",
cx,
)
} else if show_suppress_button {
- Tooltip::for_action(
- "Close.\nSuppress with shift-click.",
- &menu::Cancel,
+ Tooltip::with_meta(
+ "Close",
+ Some(&menu::Cancel),
+ "Shift-click to Suppress",
cx,
)
} else {
@@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces(
.map(read_multi_workspace_state)
.unwrap_or_default();
model::SerializedMultiWorkspace {
+ id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
workspaces: group,
state,
}
@@ -3877,7 +3878,6 @@ mod tests {
window_10,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
- sidebar_open: true,
},
)
.await;
@@ -3886,7 +3886,6 @@ mod tests {
window_20,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
- sidebar_open: false,
},
)
.await;
@@ -3924,23 +3923,20 @@ mod tests {
// Should produce 3 groups: window 10, window 20, and the orphan.
assert_eq!(results.len(), 3);
- // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
+ // Window 10 group: 2 workspaces, active_workspace_id = 2.
let group_10 = &results[0];
assert_eq!(group_10.workspaces.len(), 2);
assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
- assert_eq!(group_10.state.sidebar_open, true);
- // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
+ // Window 20 group: 1 workspace, active_workspace_id = 3.
let group_20 = &results[1];
assert_eq!(group_20.workspaces.len(), 1);
assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
- assert_eq!(group_20.state.sidebar_open, false);
// Orphan group: no window_id, so state is default.
let group_none = &results[2];
assert_eq!(group_none.workspaces.len(), 1);
assert_eq!(group_none.state.active_workspace_id, None);
- assert_eq!(group_none.state.sidebar_open, false);
}
#[gpui::test]
@@ -63,18 +63,19 @@ pub struct SessionWorkspace {
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
- pub sidebar_open: bool,
}
-/// The serialized state of a single MultiWorkspace window from a previous session:
-/// all workspaces that shared the window, which one was active, and whether the
-/// sidebar was open.
+/// The serialized state of a single MultiWorkspace window from a previous session.
#[derive(Debug, Clone)]
pub struct SerializedMultiWorkspace {
+ pub id: Option<MultiWorkspaceId>,
pub workspaces: Vec<SessionWorkspace>,
pub state: MultiWorkspaceState,
}
+#[derive(Debug, Clone, Copy)]
+pub struct MultiWorkspaceId(pub u64);
+
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
@@ -34,7 +34,6 @@ pub struct StatusBar {
right_items: Vec<Box<dyn StatusItemViewHandle>>,
active_pane: Entity<Pane>,
_observe_active_pane: Subscription,
- workspace_sidebar_open: bool,
}
impl Render for StatusBar {
@@ -52,10 +51,9 @@ impl Render for StatusBar {
.when(!(tiling.bottom || tiling.right), |el| {
el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
- .when(
- !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
- |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
- )
+ .when(!(tiling.bottom || tiling.left), |el| {
+ el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
+ })
// This border is to avoid a transparent gap in the rounded corners
.mb(px(-1.))
.border_b(px(1.0))
@@ -91,17 +89,11 @@ impl StatusBar {
_observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
this.update_active_pane_item(window, cx)
}),
- workspace_sidebar_open: false,
};
this.update_active_pane_item(window, cx);
this
}
- pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
- self.workspace_sidebar_open = open;
- cx.notify();
- }
-
pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
where
T: 'static + StatusItemView,
@@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame;
pub use dock::Panel;
pub use multi_workspace::{
DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
- NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent,
- SidebarHandle, ToggleWorkspaceSidebar,
+ NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow,
+ SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
};
pub use path_list::{PathList, SerializedPathList};
pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
pub use persistence::{
DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
model::{
- DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
- SessionWorkspace,
+ DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace,
+ SerializedWorkspaceLocation, SessionWorkspace,
},
read_serialized_multi_workspaces,
};
@@ -2154,12 +2154,6 @@ impl Workspace {
&self.status_bar
}
- pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) {
- self.status_bar.update(cx, |status_bar, cx| {
- status_bar.set_workspace_sidebar_open(open, cx);
- });
- }
-
pub fn status_bar_visible(&self, cx: &App) -> bool {
StatusBarSettings::get_global(cx).show
}
@@ -8184,7 +8178,11 @@ pub async fn restore_multiworkspace(
app_state: Arc<AppState>,
cx: &mut AsyncApp,
) -> anyhow::Result<MultiWorkspaceRestoreResult> {
- let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
+ let SerializedMultiWorkspace {
+ workspaces,
+ state,
+ id: window_id,
+ } = multi_workspace;
let mut group_iter = workspaces.into_iter();
let first = group_iter
.next()
@@ -8248,6 +8246,7 @@ pub async fn restore_multiworkspace(
if let Some(target_id) = state.active_workspace_id {
window_handle
.update(cx, |multi_workspace, window, cx| {
+ multi_workspace.set_database_id(window_id);
let target_index = multi_workspace
.workspaces()
.iter()
@@ -8269,14 +8268,6 @@ pub async fn restore_multiworkspace(
.ok();
}
- if state.sidebar_open {
- window_handle
- .update(cx, |multi_workspace, _, cx| {
- multi_workspace.open_sidebar(cx);
- })
- .ok();
- }
-
window_handle
.update(cx, |_, window, _cx| {
window.activate_window();
@@ -1322,6 +1322,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
+ size: metadata.len,
},
is_local: true,
is_private,
@@ -1378,6 +1379,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
+ size: metadata.len,
},
is_local: true,
is_private,
@@ -1575,6 +1577,7 @@ impl LocalWorktree {
path,
disk_state: DiskState::Present {
mtime: metadata.mtime,
+ size: metadata.len,
},
entry_id: None,
is_local: true,
@@ -3289,7 +3292,10 @@ impl File {
worktree,
path: entry.path.clone(),
disk_state: if let Some(mtime) = entry.mtime {
- DiskState::Present { mtime }
+ DiskState::Present {
+ mtime,
+ size: entry.size,
+ }
} else {
DiskState::New
},
@@ -3318,7 +3324,7 @@ impl File {
} else if proto.is_deleted {
DiskState::Deleted
} else if let Some(mtime) = proto.mtime.map(&Into::into) {
- DiskState::Present { mtime }
+ DiskState::Present { mtime, size: 0 }
} else {
DiskState::New
};
@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
-version = "0.228.0"
+version = "0.229.0"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"]
@@ -182,7 +182,6 @@ settings.workspace = true
settings_profile_selector.workspace = true
settings_ui.workspace = true
shellexpand.workspace = true
-sidebar.workspace = true
smol.workspace = true
snippet_provider.workspace = true
snippets_ui.workspace = true
@@ -103,8 +103,8 @@ use {
feature_flags::FeatureFlagAppExt as _,
git_ui::project_diff::ProjectDiff,
gpui::{
- App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext,
- WindowBounds, WindowHandle, WindowOptions, point, px, size,
+ Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString,
+ VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size,
},
image::RgbaImage,
project_panel::ProjectPanel,
@@ -2649,22 +2649,6 @@ fn run_multi_workspace_sidebar_visual_tests(
cx.run_until_parked();
- // Create the sidebar and register it on the MultiWorkspace
- let sidebar = multi_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")?;
-
- multi_workspace_window
- .update(cx, |multi_workspace, window, cx| {
- multi_workspace.register_sidebar(sidebar.clone(), window, cx);
- })
- .context("Failed to register sidebar")?;
-
- cx.run_until_parked();
-
// Save test threads to the ThreadStore for each workspace
let save_tasks = multi_workspace_window
.update(cx, |multi_workspace, _window, cx| {
@@ -2742,8 +2726,8 @@ fn run_multi_workspace_sidebar_visual_tests(
// Open the sidebar
multi_workspace_window
- .update(cx, |multi_workspace, window, cx| {
- multi_workspace.toggle_sidebar(window, cx);
+ .update(cx, |_multi_workspace, window, cx| {
+ window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.context("Failed to toggle sidebar")?;
@@ -3181,24 +3165,10 @@ edition = "2021"
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);
+ .update(cx, |_multi_workspace, window, cx| {
+ window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.context("Failed to toggle sidebar")?;
@@ -68,7 +68,6 @@ use settings::{
initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
update_settings_file,
};
-use sidebar::Sidebar;
use std::time::Duration;
use std::{
borrow::Cow,
@@ -163,21 +162,24 @@ pub fn init(cx: &mut App) {
cx.on_action(quit);
cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
- let flag = cx.wait_for_flag::<PanicFeatureFlag>();
- cx.spawn(async |cx| {
- if cx.update(|cx| ReleaseChannel::global(cx) == ReleaseChannel::Dev) || flag.await {
- cx.update(|cx| {
- cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
- .on_action(|_: &TestCrash, _| {
- unsafe extern "C" {
- fn puts(s: *const i8);
- }
- unsafe {
- puts(0xabad1d3a as *const i8);
- }
- });
- });
- };
+
+ cx.observe_flag::<PanicFeatureFlag, _>({
+ let mut added = false;
+ move |enabled, cx| {
+ if added || !enabled {
+ return;
+ }
+ added = true;
+ cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
+ .on_action(|_: &TestCrash, _| {
+ unsafe extern "C" {
+ fn puts(s: *const i8);
+ }
+ unsafe {
+ puts(0xabad1d3a as *const i8);
+ }
+ });
+ }
})
.detach();
cx.on_action(|_: &OpenLog, cx| {
@@ -386,20 +388,6 @@ pub fn initialize_workspace(
})
.unwrap_or(true)
});
-
- let window_handle = window.window_handle();
- let multi_workspace_handle = cx.entity();
- cx.defer(move |cx| {
- window_handle
- .update(cx, |_, window, cx| {
- let sidebar =
- cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
- multi_workspace_handle.update(cx, |multi_workspace, cx| {
- multi_workspace.register_sidebar(sidebar, window, cx);
- });
- })
- .ok();
- });
})
.detach();
@@ -368,7 +368,10 @@ mark.fade-out {
.searchbar-outer {
margin-inline-start: auto;
margin-inline-end: auto;
+ width: 100%;
max-width: var(--content-max-width);
+ box-sizing: border-box;
+ padding: 16px;
}
#searchbar {
@@ -394,21 +397,21 @@ mark.fade-out {
.searchresults-header {
font-weight: bold;
font-size: 1em;
- padding-block-start: 18px;
+ padding-block-start: 0;
padding-block-end: 0;
- padding-inline-start: 5px;
- padding-inline-end: 0;
color: var(--searchresults-header-fg);
}
ul#searchresults {
list-style: none;
padding-inline-start: 0;
+ margin-block-end: 0;
}
ul#searchresults li {
margin: 10px 0px;
padding: 2px;
border-radius: 2px;
+ scroll-margin-block-end: 10px;
}
ul#searchresults li.focus {
background-color: var(--searchresults-li-bg);
@@ -794,8 +797,7 @@ ul#searchresults span.teaser em {
max-height: 600px;
display: flex;
flex-direction: column;
- padding: 16px;
- overflow-y: auto;
+ overflow-y: hidden;
border-radius: 8px;
background: var(--popover-bg);
@@ -803,8 +805,11 @@ ul#searchresults span.teaser em {
box-shadow: var(--popover-shadow);
}
-.searchbar-outer {
- width: 100%;
+.searchresults-outer {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0px 22px 22px 22px;
}
#searchbar {
@@ -424,6 +424,31 @@
<script src="{{ path_to_root }}elasticlunr.min.js"></script>
<script src="{{ path_to_root }}mark.min.js"></script>
<script src="{{ path_to_root }}searcher.js"></script>
+
+ <script>
+ (function () {
+ // Check for focused search result and bring into the view
+ const ensureVisible = () => {
+ const focused = document.querySelector("#searchresults li.focus");
+
+ if (focused) {
+ focused.scrollIntoView({
+ block: "nearest",
+ inline: "nearest"
+ });
+ }
+ };
+
+ // 1. Listen for arrow key events
+ // 2. Wait for DOM to update
+ // 3. Call envsureVisible
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ requestAnimationFrame(ensureVisible);
+ }
+ });
+ })();
+ </script>
{{/if}}
<script src="{{ path_to_root }}clipboard.min.js"></script>
@@ -61,6 +61,25 @@ if (includesIssueUrl) {
);
}
+const MIGRATION_SCHEMA_FILES = [
+ "crates/collab/migrations/20251208000000_test_schema.sql",
+ "crates/collab/migrations.sqlite/20221109000000_test_schema.sql",
+];
+
+const modifiedSchemaFiles = danger.git.modified_files.filter((file) =>
+ MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)),
+);
+
+if (modifiedSchemaFiles.length > 0) {
+ warn(
+ [
+ "This PR modifies database schema files.",
+ "",
+ "If you are making database changes, a migration needs to be added in the Cloud repository.",
+ ].join("\n"),
+ );
+}
+
const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary.";
const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"];
@@ -29,38 +29,99 @@ mod runners;
mod steps;
mod vars;
+#[derive(Clone)]
+pub(crate) struct GitSha(String);
+
+impl AsRef<str> for GitSha {
+ fn as_ref(&self) -> &str {
+ &self.0
+ }
+}
+
+#[allow(
+ clippy::disallowed_methods,
+ reason = "This runs only in a CLI environment"
+)]
+fn parse_ref(value: &str) -> Result<GitSha, String> {
+ const GIT_SHA_LENGTH: usize = 40;
+ (value.len() == GIT_SHA_LENGTH)
+ .then_some(value)
+ .ok_or_else(|| {
+ format!(
+ "Git SHA has wrong length! \
+ Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.",
+ len = value.len()
+ )
+ })
+ .and_then(|value| {
+ let mut tmp = [0; 4];
+ value
+ .chars()
+ .all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value)
+ .ok_or_else(|| "Not a valid Git SHA".to_owned())
+ })
+ .and_then(|sha| {
+ std::process::Command::new("git")
+ .args([
+ "rev-parse",
+ "--quiet",
+ "--verify",
+ &format!("{sha}^{{commit}}")
+ ])
+ .output()
+ .map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())
+ .and_then(|output|
+ output
+ .status.success()
+ .then_some(sha)
+ .ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!")))
+ }).map(|sha| GitSha(sha.to_owned()))
+}
+
#[derive(Parser)]
-pub struct GenerateWorkflowArgs {}
+pub(crate) struct GenerateWorkflowArgs {
+ #[arg(value_parser = parse_ref)]
+ /// The Git SHA to use when invoking this
+ pub(crate) sha: Option<GitSha>,
+}
+
+enum WorkflowSource {
+ Contextless(fn() -> Workflow),
+ WithContext(fn(&GenerateWorkflowArgs) -> Workflow),
+}
struct WorkflowFile {
- source: fn() -> Workflow,
+ source: WorkflowSource,
r#type: WorkflowType,
}
impl WorkflowFile {
fn zed(f: fn() -> Workflow) -> WorkflowFile {
WorkflowFile {
- source: f,
+ source: WorkflowSource::Contextless(f),
r#type: WorkflowType::Zed,
}
}
- fn extension(f: fn() -> Workflow) -> WorkflowFile {
+ fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
WorkflowFile {
- source: f,
+ source: WorkflowSource::WithContext(f),
r#type: WorkflowType::ExtensionCi,
}
}
- fn extension_shared(f: fn() -> Workflow) -> WorkflowFile {
+ fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
WorkflowFile {
- source: f,
+ source: WorkflowSource::WithContext(f),
r#type: WorkflowType::ExtensionsShared,
}
}
- fn generate_file(&self) -> Result<()> {
- let workflow = (self.source)();
+ fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> {
+ let workflow = match &self.source {
+ WorkflowSource::Contextless(f) => f(),
+ WorkflowSource::WithContext(f) => f(workflow_args),
+ };
let workflow_folder = self.r#type.folder_path();
fs::create_dir_all(&workflow_folder).with_context(|| {
@@ -124,7 +185,7 @@ impl WorkflowType {
}
}
-pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
+pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
if !Path::new("crates/zed/").is_dir() {
anyhow::bail!("xtask workflows must be ran from the project root");
}
@@ -154,7 +215,7 @@ pub fn run_workflows(_: GenerateWorkflowArgs) -> Result<()> {
];
for workflow_file in workflows {
- workflow_file.generate_file()?;
+ workflow_file.generate_file(&args)?;
}
workflow_checks::validate(Default::default())
@@ -3,7 +3,7 @@ use indoc::indoc;
use crate::tasks::workflows::runners::{self, Platform};
use crate::tasks::workflows::steps::{
- self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named,
+ self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang,
};
use crate::tasks::workflows::vars;
@@ -23,7 +23,7 @@ pub(crate) fn deploy_collab() -> Workflow {
}
fn style() -> NamedJob {
- named::job(
+ named::job(use_clang(
dependant_job(&[])
.name("Check formatting and Clippy lints")
.with_repository_owner_guard()
@@ -34,7 +34,7 @@ fn style() -> NamedJob {
.map(steps::install_linux_dependencies)
.add_step(steps::cargo_fmt())
.add_step(steps::clippy(Platform::Linux)),
- )
+ ))
}
fn tests(deps: &[&NamedJob]) -> NamedJob {
@@ -42,7 +42,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob {
named::bash("cargo nextest run --package collab --no-fail-fast")
}
- named::job(
+ named::job(use_clang(
dependant_job(deps)
.name("Run tests")
.runs_on(runners::LINUX_XL)
@@ -65,7 +65,7 @@ fn tests(deps: &[&NamedJob]) -> NamedJob {
.add_step(steps::cargo_install_nextest())
.add_step(steps::clear_target_dir_if_large(Platform::Linux))
.add_step(run_collab_tests()),
- )
+ ))
}
fn publish(deps: &[&NamedJob]) -> NamedJob {
@@ -6,46 +6,72 @@ use indoc::indoc;
use serde_json::json;
use crate::tasks::workflows::steps::CheckoutStep;
+use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
+use crate::tasks::workflows::vars::JobOutput;
use crate::tasks::workflows::{
extension_bump::{RepositoryTarget, generate_token},
runners,
steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
- vars::{self, StepOutput},
+ vars::{self, StepOutput, WorkflowInput},
};
const ROLLOUT_TAG_NAME: &str = "extension-workflows";
+const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files";
pub(crate) fn extension_workflow_rollout() -> Workflow {
- let fetch_repos = fetch_extension_repos();
- let rollout_workflows = rollout_workflows_to_extension(&fetch_repos);
- let create_tag = create_rollout_tag(&rollout_workflows);
+ let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new()))
+ .description(
+ "Comma-separated list of repository names to rollout to. Leave empty for all repos.",
+ );
+ let extra_context_input = WorkflowInput::string("change-description", Some(String::new()))
+ .description("Description for the changes to be expected with this rollout");
+
+ let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input);
+ let rollout_workflows = rollout_workflows_to_extension(
+ &fetch_repos,
+ removed_ci,
+ removed_shared,
+ &extra_context_input,
+ );
+ let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
named::workflow()
- .on(Event::default().workflow_dispatch(WorkflowDispatch::default()))
+ .on(Event::default().workflow_dispatch(
+ WorkflowDispatch::default()
+ .add_input(filter_repos_input.name, filter_repos_input.input())
+ .add_input(extra_context_input.name, extra_context_input.input()),
+ ))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_job(fetch_repos.name, fetch_repos.job)
.add_job(rollout_workflows.name, rollout_workflows.job)
.add_job(create_tag.name, create_tag.job)
}
-fn fetch_extension_repos() -> NamedJob {
- fn get_repositories() -> (Step<Use>, StepOutput) {
+fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
+ fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
let step = named::uses("actions", "github-script", "v7")
.id("list-repos")
.add_with((
"script",
- indoc::indoc! {r#"
- const repos = await github.paginate(github.rest.repos.listForOrg, {
+ formatdoc! {r#"
+ const repos = await github.paginate(github.rest.repos.listForOrg, {{
org: 'zed-extensions',
type: 'public',
per_page: 100,
- });
+ }});
- const filteredRepos = repos
+ let filteredRepos = repos
.filter(repo => !repo.archived)
.map(repo => repo.name);
- console.log(`Found ${filteredRepos.length} extension repos`);
+ const filterInput = `{filter_repos_input}`.trim();
+ if (filterInput.length > 0) {{
+ const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
+ filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
+ console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`);
+ }}
+
+ console.log(`Found ${{filteredRepos.length}} extension repos`);
return filteredRepos;
"#},
))
@@ -56,36 +82,12 @@ fn fetch_extension_repos() -> NamedJob {
(step, filtered_repos)
}
- let (get_org_repositories, list_repos_output) = get_repositories();
-
- let job = Job::default()
- .cond(Expression::new(format!(
- "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
- )))
- .runs_on(runners::LINUX_SMALL)
- .timeout_minutes(5u32)
- .outputs([("repos".to_owned(), list_repos_output.to_string())])
- .add_step(get_org_repositories);
-
- named::job(job)
-}
-
-fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
fn checkout_zed_repo() -> CheckoutStep {
steps::checkout_repo()
.with_full_history()
- .with_path("zed")
.with_custom_name("checkout_zed_repo")
}
- fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
- steps::checkout_repo()
- .with_custom_name("checkout_extension_repo")
- .with_token(token)
- .with_repository("zed-extensions/${{ matrix.repo }}")
- .with_path("extension")
- }
-
fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
let step = named::bash(formatdoc! {r#"
PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
@@ -96,49 +98,126 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
echo "Found previous rollout at commit: $PREV_COMMIT"
echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
"#})
- .id("prev-tag")
- .working_directory("zed");
+ .id("prev-tag");
let step_output = StepOutput::new(&step, "prev_commit");
(step, step_output)
}
- fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput) {
- let step = named::bash(indoc::indoc! {r#"
- if [ "$MATRIX_REPO" = "workflows" ]; then
- WORKFLOW_DIR="extensions/workflows"
- else
- WORKFLOW_DIR="extensions/workflows/shared"
- fi
-
- echo "Calculating changes from $PREV_COMMIT to HEAD for $WORKFLOW_DIR"
+ fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput, StepOutput) {
+ let step = named::bash(indoc! {r#"
+ for workflow_type in "ci" "shared"; do
+ if [ "$workflow_type" = "ci" ]; then
+ WORKFLOW_DIR="extensions/workflows"
+ else
+ WORKFLOW_DIR="extensions/workflows/shared"
+ fi
+
+ REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
+ awk '/^D/ { print $2 } /^R/ { print $2 }' | \
+ xargs -I{} basename {} 2>/dev/null | \
+ tr '\n' ' ' || echo "")
+ REMOVED=$(echo "$REMOVED" | xargs)
+
+ echo "Removed files for $workflow_type: $REMOVED"
+ echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
+ done
+ "#})
+ .id("calc-changes")
+ .add_env(("PREV_COMMIT", prev_commit.to_string()));
- # Get deleted files (status D) and renamed files (status R - old name needs removal)
- # Using -M to detect renames, then extracting files that are gone from their original location
- REMOVED_FILES=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
- awk '/^D/ { print $2 } /^R/ { print $2 }' | \
- xargs -I{} basename {} 2>/dev/null | \
- tr '\n' ' ' || echo "")
+ let removed_ci = StepOutput::new(&step, "removed_ci");
+ let removed_shared = StepOutput::new(&step, "removed_shared");
- REMOVED_FILES=$(echo "$REMOVED_FILES" | xargs)
+ (step, removed_ci, removed_shared)
+ }
- echo "Files to remove: $REMOVED_FILES"
- echo "removed_files=$REMOVED_FILES" >> "$GITHUB_OUTPUT"
+ fn generate_workflow_files() -> Step<Run> {
+ named::bash(indoc! {r#"
+ cargo xtask workflows "$COMMIT_SHA"
"#})
- .id("calc-changes")
- .working_directory("zed")
- .add_env(("PREV_COMMIT", prev_commit.to_string()))
- .add_env(("MATRIX_REPO", "${{ matrix.repo }}"));
+ .add_env(("COMMIT_SHA", "${{ github.sha }}"))
+ }
- let removed_files = StepOutput::new(&step, "removed_files");
+ fn upload_workflow_files() -> Step<Use> {
+ named::uses(
+ "actions",
+ "upload-artifact",
+ "330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
+ )
+ .add_with(("name", WORKFLOW_ARTIFACT_NAME))
+ .add_with(("path", "extensions/workflows/**/*.yml"))
+ .add_with(("if-no-files-found", "error"))
+ }
- (step, removed_files)
+ let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
+ let (get_prev_tag, prev_commit) = get_previous_tag_commit();
+ let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
+
+ let job = Job::default()
+ .cond(Expression::new(format!(
+ "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
+ )))
+ .runs_on(runners::LINUX_SMALL)
+ .timeout_minutes(10u32)
+ .outputs([
+ ("repos".to_owned(), list_repos_output.to_string()),
+ ("prev_commit".to_owned(), prev_commit.to_string()),
+ ("removed_ci".to_owned(), removed_ci.to_string()),
+ ("removed_shared".to_owned(), removed_shared.to_string()),
+ ])
+ .add_step(checkout_zed_repo())
+ .add_step(get_prev_tag)
+ .add_step(calc_changes)
+ .add_step(get_org_repositories)
+ .add_step(cache_rust_dependencies_namespace())
+ .add_step(generate_workflow_files())
+ .add_step(upload_workflow_files());
+
+ let job = named::job(job);
+ let (removed_ci, removed_shared) = (
+ removed_ci.as_job_output(&job),
+ removed_shared.as_job_output(&job),
+ );
+
+ (job, removed_ci, removed_shared)
+}
+
+fn rollout_workflows_to_extension(
+ fetch_repos_job: &NamedJob,
+ removed_ci: JobOutput,
+ removed_shared: JobOutput,
+ extra_context_input: &WorkflowInput,
+) -> NamedJob {
+ fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
+ steps::checkout_repo()
+ .with_custom_name("checkout_extension_repo")
+ .with_token(token)
+ .with_repository("zed-extensions/${{ matrix.repo }}")
+ .with_path("extension")
+ }
+
+ fn download_workflow_files() -> Step<Use> {
+ named::uses(
+ "actions",
+ "download-artifact",
+ "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
+ )
+ .add_with(("name", WORKFLOW_ARTIFACT_NAME))
+ .add_with(("path", "workflow-files"))
}
- fn sync_workflow_files(removed_files: &StepOutput) -> Step<Run> {
- named::bash(indoc::indoc! {r#"
+ fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
+ named::bash(indoc! {r#"
mkdir -p extension/.github/workflows
+
+ if [ "$MATRIX_REPO" = "workflows" ]; then
+ REMOVED_FILES="$REMOVED_CI"
+ else
+ REMOVED_FILES="$REMOVED_SHARED"
+ fi
+
cd extension/.github/workflows
if [ -n "$REMOVED_FILES" ]; then
@@ -152,40 +231,46 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
cd - > /dev/null
if [ "$MATRIX_REPO" = "workflows" ]; then
- cp zed/extensions/workflows/*.yml extension/.github/workflows/
+ cp workflow-files/*.yml extension/.github/workflows/
else
- cp zed/extensions/workflows/shared/*.yml extension/.github/workflows/
+ cp workflow-files/shared/*.yml extension/.github/workflows/
fi
"#})
- .add_env(("REMOVED_FILES", removed_files.to_string()))
+ .add_env(("REMOVED_CI", removed_ci))
+ .add_env(("REMOVED_SHARED", removed_shared))
.add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
}
fn get_short_sha() -> (Step<Run>, StepOutput) {
- let step = named::bash(indoc::indoc! {r#"
- echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
+ let step = named::bash(indoc! {r#"
+ echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
"#})
- .id("short-sha")
- .working_directory("zed");
+ .id("short-sha");
let step_output = StepOutput::new(&step, "sha_short");
(step, step_output)
}
- fn create_pull_request(token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
+ fn create_pull_request(
+ token: &StepOutput,
+ short_sha: &StepOutput,
+ context_input: &WorkflowInput,
+ ) -> Step<Use> {
let title = format!("Update CI workflows to `{short_sha}`");
+ let body = formatdoc! {r#"
+ This PR updates the CI workflow files from the main Zed repository
+ based on the commit zed-industries/zed@${{{{ github.sha }}}}
+
+ {context_input}
+ "#,
+ };
+
named::uses("peter-evans", "create-pull-request", "v7")
.add_with(("path", "extension"))
.add_with(("title", title.clone()))
- .add_with((
- "body",
- indoc::indoc! {r#"
- This PR updates the CI workflow files from the main Zed repository
- based on the commit zed-industries/zed@${{ github.sha }}
- "#},
- ))
+ .add_with(("body", body))
.add_with(("commit-message", title))
.add_with(("branch", "update-workflows"))
.add_with((
@@ -204,12 +289,12 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
}
fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
- named::bash(indoc::indoc! {r#"
+ named::bash(indoc! {r#"
if [ -n "$PR_NUMBER" ]; then
- cd extension
gh pr merge "$PR_NUMBER" --auto --squash
fi
"#})
+ .working_directory("extension")
.add_env(("GH_TOKEN", token.to_string()))
.add_env((
"PR_NUMBER",
@@ -228,8 +313,6 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
]),
),
);
- let (get_prev_tag, prev_commit) = get_previous_tag_commit();
- let (calc_changes, removed_files) = get_removed_files(&prev_commit);
let (calculate_short_sha, short_sha) = get_short_sha();
let job = Job::default()
@@ -249,19 +332,17 @@ fn rollout_workflows_to_extension(fetch_repos_job: &NamedJob) -> NamedJob {
})),
)
.add_step(authenticate)
- .add_step(checkout_zed_repo())
.add_step(checkout_extension_repo(&token))
- .add_step(get_prev_tag)
- .add_step(calc_changes)
- .add_step(sync_workflow_files(&removed_files))
+ .add_step(download_workflow_files())
+ .add_step(sync_workflow_files(removed_ci, removed_shared))
.add_step(calculate_short_sha)
- .add_step(create_pull_request(&token, &short_sha))
+ .add_step(create_pull_request(&token, &short_sha, extra_context_input))
.add_step(enable_auto_merge(&token));
named::job(job)
}
-fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
+fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
steps::checkout_repo().with_full_history().with_token(token)
}
@@ -297,6 +378,10 @@ fn create_rollout_tag(rollout_job: &NamedJob) -> NamedJob {
let job = Job::default()
.needs([rollout_job.name.clone()])
+ .cond(Expression::new(format!(
+ "{filter_repos} == ''",
+ filter_repos = filter_repos_input.expr(),
+ )))
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(1u32)
.add_step(authenticate)
@@ -5,17 +5,18 @@ use gh_workflow::{
use indoc::indoc;
use crate::tasks::workflows::{
+ GenerateWorkflowArgs, GitSha,
extensions::WithAppSecrets,
runners,
steps::{CommonJobConditions, NamedJob, named},
vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token},
};
-pub(crate) fn bump_version() -> Workflow {
+pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow {
let (determine_bump_type, bump_type) = determine_bump_type();
let bump_type = bump_type.as_job_output(&determine_bump_type);
- let call_bump_version = call_bump_version(&determine_bump_type, bump_type);
+ let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type);
named::workflow()
.on(Event::default()
@@ -32,6 +33,7 @@ pub(crate) fn bump_version() -> Workflow {
}
pub(crate) fn call_bump_version(
+ target_ref: Option<&GitSha>,
depending_job: &NamedJob,
bump_type: JobOutput,
) -> NamedJob<UsesJob> {
@@ -51,7 +53,7 @@ pub(crate) fn call_bump_version(
"zed-industries",
"zed",
".github/workflows/extension_bump.yml",
- "main",
+ target_ref.map_or("main", AsRef::as_ref),
)
.add_need(depending_job.name.clone())
.with(
@@ -1,12 +1,13 @@
use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow};
use crate::tasks::workflows::{
+ GenerateWorkflowArgs, GitSha,
steps::{NamedJob, named},
vars::one_workflow_per_non_main_branch_and_token,
};
-pub(crate) fn run_tests() -> Workflow {
- let call_extension_tests = call_extension_tests();
+pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow {
+ let call_extension_tests = call_extension_tests(args.sha.as_ref());
named::workflow()
.on(Event::default()
.pull_request(PullRequest::default().add_branch("**"))
@@ -15,14 +16,14 @@ pub(crate) fn run_tests() -> Workflow {
.add_job(call_extension_tests.name, call_extension_tests.job)
}
-pub(crate) fn call_extension_tests() -> NamedJob<UsesJob> {
+pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob<UsesJob> {
let job = Job::default()
.permissions(Permissions::default().contents(Level::Read))
.uses(
"zed-industries",
"zed",
".github/workflows/extension_tests.yml",
- "main",
+ target_ref.map_or("main", AsRef::as_ref),
);
named::job(job)
@@ -131,22 +131,12 @@ impl From<CheckoutStep> for Step<Use> {
FetchDepth::Full => step.add_with(("fetch-depth", 0)),
FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
})
- .map(|step| match value.token {
- Some(token) => step.add_with(("token", token)),
- None => step,
- })
- .map(|step| match value.path {
- Some(path) => step.add_with(("path", path)),
- None => step,
- })
- .map(|step| match value.repository {
- Some(repository) => step.add_with(("repository", repository)),
- None => step,
- })
- .map(|step| match value.ref_ {
- Some(ref_) => step.add_with(("ref", ref_)),
- None => step,
+ .when_some(value.path, |step, path| step.add_with(("path", path)))
+ .when_some(value.repository, |step, repository| {
+ step.add_with(("repository", repository))
})
+ .when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
+ .when_some(value.token, |step, token| step.add_with(("token", token)))
}
}