Cargo.lock 🔗
@@ -15871,7 +15871,6 @@ dependencies = [
"agent_ui",
"anyhow",
"chrono",
- "collections",
"editor",
"feature_flags",
"fs",
Richard Feldman created
Cargo.lock | 1
assets/settings/default.json | 5
crates/agent_ui/src/agent_panel.rs | 4
crates/agent_ui/src/conversation_view.rs | 63
crates/agent_ui/src/conversation_view/thread_view.rs | 28
crates/agent_ui/src/threads_archive_view.rs | 56 +
crates/dev_container/src/devcontainer_json.rs | 54 +
crates/dev_container/src/devcontainer_manifest.rs | 77 +
crates/dev_container/src/docker.rs | 76 +
crates/gpui/src/elements/list.rs | 426 +++++++-
crates/gpui_wgpu/src/wgpu_atlas.rs | 82 +
crates/project/src/project.rs | 6
crates/remote/src/remote_client.rs | 31
crates/settings/src/vscode_import.rs | 1
crates/settings_content/src/workspace.rs | 10
crates/settings_ui/src/page_data.rs | 48
crates/sidebar/Cargo.toml | 1
crates/sidebar/src/project_group_builder.rs | 282 -----
crates/sidebar/src/sidebar.rs | 664 +++++++------
crates/sidebar/src/sidebar_tests.rs | 347 +++---
crates/workspace/src/dock.rs | 10
crates/workspace/src/focus_follows_mouse.rs | 71 +
crates/workspace/src/multi_workspace.rs | 20
crates/workspace/src/pane.rs | 13
crates/workspace/src/workspace.rs | 5
crates/workspace/src/workspace_settings.rs | 23
26 files changed, 1,450 insertions(+), 954 deletions(-)
@@ -15871,7 +15871,6 @@ dependencies = [
"agent_ui",
"anyhow",
"chrono",
- "collections",
"editor",
"feature_flags",
"fs",
@@ -225,6 +225,11 @@
// 3. Hide on both typing and cursor movement:
// "on_typing_and_movement"
"hide_mouse": "on_typing_and_movement",
+ // Determines whether the focused panel follows the mouse location.
+ "focus_follows_mouse": {
+ "enabled": false,
+ "debounce_ms": 250,
+ },
// Determines how snippets are sorted relative to other completion items.
//
// 1. Place snippets at the top of the completion list:
@@ -2076,6 +2076,10 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ if let Some(store) = ThreadMetadataStore::try_global(cx) {
+ store.update(cx, |store, cx| store.unarchive(&session_id, cx));
+ }
+
if let Some(conversation_view) = self.background_threads.remove(&session_id) {
self.set_active_view(
ActiveView::AgentThread { conversation_view },
@@ -831,6 +831,8 @@ impl ConversationView {
let count = thread.read(cx).entries().len();
let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0));
+ list_state.set_follow_mode(gpui::FollowMode::Tail);
+
entry_view_state.update(cx, |view_state, cx| {
for ix in 0..count {
view_state.sync_entry(ix, &thread, window, cx);
@@ -844,7 +846,7 @@ impl ConversationView {
if let Some(scroll_position) = thread.read(cx).ui_scroll_position() {
list_state.scroll_to(scroll_position);
} else {
- list_state.set_follow_tail(true);
+ list_state.scroll_to_end();
}
AgentDiff::set_active_thread(&self.workspace, thread.clone(), window, cx);
@@ -1243,15 +1245,15 @@ impl ConversationView {
if let Some(active) = self.thread_view(&thread_id) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
- notify_entry_changed(
- &entry_view_state,
- &list_state,
- index..index,
- index,
- thread,
- window,
- cx,
- );
+ entry_view_state.update(cx, |view_state, cx| {
+ view_state.sync_entry(index, thread, window, cx);
+ list_state.splice_focusable(
+ index..index,
+ [view_state
+ .entry(index)
+ .and_then(|entry| entry.focus_handle(cx))],
+ );
+ });
active.update(cx, |active, cx| {
active.sync_editor_mode_for_empty_state(cx);
});
@@ -1261,15 +1263,10 @@ impl ConversationView {
if let Some(active) = self.thread_view(&thread_id) {
let entry_view_state = active.read(cx).entry_view_state.clone();
let list_state = active.read(cx).list_state.clone();
- notify_entry_changed(
- &entry_view_state,
- &list_state,
- *index..*index + 1,
- *index,
- thread,
- window,
- cx,
- );
+ entry_view_state.update(cx, |view_state, cx| {
+ view_state.sync_entry(*index, thread, window, cx);
+ });
+ list_state.remeasure_items(*index..*index + 1);
active.update(cx, |active, cx| {
active.auto_expand_streaming_thought(cx);
});
@@ -1313,7 +1310,6 @@ impl ConversationView {
active.clear_auto_expand_tracking();
if active.list_state.is_following_tail() {
active.list_state.scroll_to_end();
- active.list_state.set_follow_tail(false);
}
}
active.sync_generating_indicator(cx);
@@ -1391,7 +1387,6 @@ impl ConversationView {
active.thread_retry_status.take();
if active.list_state.is_following_tail() {
active.list_state.scroll_to_end();
- active.list_state.set_follow_tail(false);
}
}
active.sync_generating_indicator(cx);
@@ -2608,32 +2603,6 @@ impl ConversationView {
}
}
-/// Syncs an entry's view state with the latest thread data and splices
-/// the list item so the list knows to re-measure it on the next paint.
-///
-/// Used by both `NewEntry` (splice range `index..index` to insert) and
-/// `EntryUpdated` (splice range `index..index+1` to replace), which is
-/// why the caller provides the splice range.
-fn notify_entry_changed(
- entry_view_state: &Entity<EntryViewState>,
- list_state: &ListState,
- splice_range: std::ops::Range<usize>,
- index: usize,
- thread: &Entity<AcpThread>,
- window: &mut Window,
- cx: &mut App,
-) {
- entry_view_state.update(cx, |view_state, cx| {
- view_state.sync_entry(index, thread, window, cx);
- list_state.splice_focusable(
- splice_range,
- [view_state
- .entry(index)
- .and_then(|entry| entry.focus_handle(cx))],
- );
- });
-}
-
fn loading_contents_spinner(size: IconSize) -> AnyElement {
Icon::new(IconName::LoadCircle)
.size(size)
@@ -541,24 +541,15 @@ impl ThreadView {
let thread_view = cx.entity().downgrade();
this.list_state
- .set_scroll_handler(move |event, _window, cx| {
+ .set_scroll_handler(move |_event, _window, cx| {
let list_state = list_state_for_scroll.clone();
let thread_view = thread_view.clone();
- let is_following_tail = event.is_following_tail;
// N.B. We must defer because the scroll handler is called while the
// ListState's RefCell is mutably borrowed. Reading logical_scroll_top()
// directly would panic from a double borrow.
cx.defer(move |cx| {
let scroll_top = list_state.logical_scroll_top();
let _ = thread_view.update(cx, |this, cx| {
- if !is_following_tail {
- let is_generating =
- matches!(this.thread.read(cx).status(), ThreadStatus::Generating);
-
- if list_state.is_at_bottom() && is_generating {
- list_state.set_follow_tail(true);
- }
- }
if let Some(thread) = this.as_native_thread(cx) {
thread.update(cx, |thread, _cx| {
thread.set_ui_scroll_position(Some(scroll_top));
@@ -1070,7 +1061,7 @@ impl ThreadView {
})?;
let _ = this.update(cx, |this, cx| {
- this.list_state.set_follow_tail(true);
+ this.list_state.scroll_to_end();
cx.notify();
});
@@ -4945,7 +4936,7 @@ impl ThreadView {
}
pub fn scroll_to_end(&mut self, cx: &mut Context<Self>) {
- self.list_state.set_follow_tail(true);
+ self.list_state.scroll_to_end();
cx.notify();
}
@@ -4967,7 +4958,6 @@ impl ThreadView {
}
pub(crate) fn scroll_to_top(&mut self, cx: &mut Context<Self>) {
- self.list_state.set_follow_tail(false);
self.list_state.scroll_to(ListOffset::default());
cx.notify();
}
@@ -4979,7 +4969,6 @@ impl ThreadView {
cx: &mut Context<Self>,
) {
let page_height = self.list_state.viewport_bounds().size.height;
- self.list_state.set_follow_tail(false);
self.list_state.scroll_by(-page_height * 0.9);
cx.notify();
}
@@ -4991,11 +4980,7 @@ impl ThreadView {
cx: &mut Context<Self>,
) {
let page_height = self.list_state.viewport_bounds().size.height;
- self.list_state.set_follow_tail(false);
self.list_state.scroll_by(page_height * 0.9);
- if self.list_state.is_at_bottom() {
- self.list_state.set_follow_tail(true);
- }
cx.notify();
}
@@ -5005,7 +4990,6 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.list_state.set_follow_tail(false);
self.list_state.scroll_by(-window.line_height() * 3.);
cx.notify();
}
@@ -5016,11 +5000,7 @@ impl ThreadView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.list_state.set_follow_tail(false);
self.list_state.scroll_by(window.line_height() * 3.);
- if self.list_state.is_at_bottom() {
- self.list_state.set_follow_tail(true);
- }
cx.notify();
}
@@ -5054,7 +5034,6 @@ impl ThreadView {
.rev()
.find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
{
- self.list_state.set_follow_tail(false);
self.list_state.scroll_to(ListOffset {
item_ix: target_ix,
offset_in_item: px(0.),
@@ -5074,7 +5053,6 @@ impl ThreadView {
if let Some(target_ix) = (current_ix + 1..entries.len())
.find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_))))
{
- self.list_state.set_follow_tail(false);
self.list_state.scroll_to(ListOffset {
item_ix: target_ix,
offset_in_item: px(0.),
@@ -1285,3 +1285,59 @@ impl PickerDelegate for ProjectPickerDelegate {
)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_fuzzy_match_positions_returns_byte_indices() {
+ // "🔥abc" — the fire emoji is 4 bytes, so 'a' starts at byte 4, 'b' at 5, 'c' at 6.
+ let text = "🔥abc";
+ let positions = fuzzy_match_positions("ab", text).expect("should match");
+ assert_eq!(positions, vec![4, 5]);
+
+ // Verify positions are valid char boundaries (this is the assertion that
+ // panicked before the fix).
+ for &pos in &positions {
+ assert!(
+ text.is_char_boundary(pos),
+ "position {pos} is not a valid UTF-8 boundary in {text:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_fuzzy_match_positions_ascii_still_works() {
+ let positions = fuzzy_match_positions("he", "hello").expect("should match");
+ assert_eq!(positions, vec![0, 1]);
+ }
+
+ #[test]
+ fn test_fuzzy_match_positions_case_insensitive() {
+ let positions = fuzzy_match_positions("HE", "hello").expect("should match");
+ assert_eq!(positions, vec![0, 1]);
+ }
+
+ #[test]
+ fn test_fuzzy_match_positions_no_match() {
+ assert!(fuzzy_match_positions("xyz", "hello").is_none());
+ }
+
+ #[test]
+ fn test_fuzzy_match_positions_multi_byte_interior() {
+ // "café" — 'é' is 2 bytes (0xC3 0xA9), so 'f' starts at byte 4, 'é' at byte 5.
+ let text = "café";
+ let positions = fuzzy_match_positions("fé", text).expect("should match");
+ // 'c'=0, 'a'=1, 'f'=2, 'é'=3..4 — wait, let's verify:
+ // Actually: c=1 byte, a=1 byte, f=1 byte, é=2 bytes
+ // So byte positions: c=0, a=1, f=2, é=3
+ assert_eq!(positions, vec![2, 3]);
+ for &pos in &positions {
+ assert!(
+ text.is_char_boundary(pos),
+ "position {pos} is not a valid UTF-8 boundary in {text:?}"
+ );
+ }
+ }
+}
@@ -72,7 +72,11 @@ impl Display for MountDefinition {
f,
"type={},source={},target={},consistency=cached",
self.mount_type.clone().unwrap_or_else(|| {
- if self.source.starts_with('/') {
+ if self.source.starts_with('/')
+ || self.source.starts_with("\\\\")
+ || self.source.get(1..3) == Some(":\\")
+ || self.source.get(1..3) == Some(":/")
+ {
"bind".to_string()
} else {
"volume".to_string()
@@ -1355,4 +1359,52 @@ mod test {
assert_eq!(devcontainer.build_type(), DevContainerBuildType::Dockerfile);
}
+
+ #[test]
+ fn mount_definition_should_use_bind_type_for_unix_absolute_paths() {
+ let mount = MountDefinition {
+ source: "/home/user/project".to_string(),
+ target: "/workspaces/project".to_string(),
+ mount_type: None,
+ };
+
+ let rendered = mount.to_string();
+
+ assert!(
+ rendered.starts_with("type=bind,"),
+ "Expected mount type 'bind' for Unix absolute path, but got: {rendered}"
+ );
+ }
+
+ #[test]
+ fn mount_definition_should_use_bind_type_for_windows_unc_paths() {
+ let mount = MountDefinition {
+ source: "\\\\server\\share\\project".to_string(),
+ target: "/workspaces/project".to_string(),
+ mount_type: None,
+ };
+
+ let rendered = mount.to_string();
+
+ assert!(
+ rendered.starts_with("type=bind,"),
+ "Expected mount type 'bind' for Windows UNC path, but got: {rendered}"
+ );
+ }
+
+ #[test]
+ fn mount_definition_should_use_bind_type_for_windows_absolute_paths() {
+ let mount = MountDefinition {
+ source: "C:\\Users\\mrg\\cli".to_string(),
+ target: "/workspaces/cli".to_string(),
+ mount_type: None,
+ };
+
+ let rendered = mount.to_string();
+
+ assert!(
+ rendered.starts_with("type=bind,"),
+ "Expected mount type 'bind' for Windows absolute path, but got: {rendered}"
+ );
+ }
}
@@ -20,7 +20,8 @@ use crate::{
},
docker::{
Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
- DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
+ DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
+ get_remote_dir_from_config,
},
features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref},
get_oci_token,
@@ -1137,18 +1138,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
// If the main service uses a different service's network bridge, append to that service's ports instead
if let Some(network_service_name) = network_mode_service {
if let Some(service) = service_declarations.get_mut(network_service_name) {
- service.ports.push(format!("{port}:{port}"));
+ service.ports.push(DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ });
} else {
service_declarations.insert(
network_service_name.to_string(),
DockerComposeService {
- ports: vec![format!("{port}:{port}")],
+ ports: vec![DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ }],
..Default::default()
},
);
}
} else {
- main_service.ports.push(format!("{port}:{port}"));
+ main_service.ports.push(DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ });
}
}
let other_service_ports: Vec<(&str, &str)> = forward_ports
@@ -1171,12 +1184,20 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
.collect();
for (service_name, port) in other_service_ports {
if let Some(service) = service_declarations.get_mut(service_name) {
- service.ports.push(format!("{port}:{port}"));
+ service.ports.push(DockerComposeServicePort {
+ target: port.to_string(),
+ published: port.to_string(),
+ ..Default::default()
+ });
} else {
service_declarations.insert(
service_name.to_string(),
DockerComposeService {
- ports: vec![format!("{port}:{port}")],
+ ports: vec![DockerComposeServicePort {
+ target: port.to_string(),
+ published: port.to_string(),
+ ..Default::default()
+ }],
..Default::default()
},
);
@@ -1186,18 +1207,30 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
if let Some(port) = &self.dev_container().app_port {
if let Some(network_service_name) = network_mode_service {
if let Some(service) = service_declarations.get_mut(network_service_name) {
- service.ports.push(format!("{port}:{port}"));
+ service.ports.push(DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ });
} else {
service_declarations.insert(
network_service_name.to_string(),
DockerComposeService {
- ports: vec![format!("{port}:{port}")],
+ ports: vec![DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ }],
..Default::default()
},
);
}
} else {
- main_service.ports.push(format!("{port}:{port}"));
+ main_service.ports.push(DockerComposeServicePort {
+ target: port.clone(),
+ published: port.clone(),
+ ..Default::default()
+ });
}
}
@@ -3278,6 +3311,8 @@ chmod +x ./install.sh
#[cfg(not(target_os = "windows"))]
#[gpui::test]
async fn test_spawns_devcontainer_with_docker_compose(cx: &mut TestAppContext) {
+ use crate::docker::DockerComposeServicePort;
+
cx.executor().allow_parking();
env_logger::try_init().ok();
let given_devcontainer_contents = r#"
@@ -3540,10 +3575,26 @@ ENV DOCKER_BUILDKIT=1
"db".to_string(),
DockerComposeService {
ports: vec![
- "8083:8083".to_string(),
- "5432:5432".to_string(),
- "1234:1234".to_string(),
- "8084:8084".to_string()
+ DockerComposeServicePort {
+ target: "8083".to_string(),
+ published: "8083".to_string(),
+ ..Default::default()
+ },
+ DockerComposeServicePort {
+ target: "5432".to_string(),
+ published: "5432".to_string(),
+ ..Default::default()
+ },
+ DockerComposeServicePort {
+ target: "1234".to_string(),
+ published: "1234".to_string(),
+ ..Default::default()
+ },
+ DockerComposeServicePort {
+ target: "8084".to_string(),
+ published: "8084".to_string(),
+ ..Default::default()
+ },
],
..Default::default()
},
@@ -86,6 +86,43 @@ pub(crate) struct DockerComposeServiceBuild {
pub(crate) additional_contexts: Option<HashMap<String, String>>,
}
+#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
+pub(crate) struct DockerComposeServicePort {
+ #[serde(deserialize_with = "deserialize_string_or_int")]
+ pub(crate) target: String,
+ #[serde(deserialize_with = "deserialize_string_or_int")]
+ pub(crate) published: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) mode: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) protocol: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) host_ip: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) app_protocol: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) name: Option<String>,
+}
+
+fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ use serde::Deserialize;
+
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum StringOrInt {
+ String(String),
+ Int(u32),
+ }
+
+ match StringOrInt::deserialize(deserializer)? {
+ StringOrInt::String(s) => Ok(s),
+ StringOrInt::Int(b) => Ok(b.to_string()),
+ }
+}
+
#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
pub(crate) struct DockerComposeService {
pub(crate) image: Option<String>,
@@ -109,7 +146,7 @@ pub(crate) struct DockerComposeService {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) env_file: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub(crate) ports: Vec<String>,
+ pub(crate) ports: Vec<DockerComposeServicePort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) network_mode: Option<String>,
}
@@ -491,8 +528,8 @@ mod test {
command_json::deserialize_json_output,
devcontainer_json::MountDefinition,
docker::{
- Docker, DockerComposeConfig, DockerComposeService, DockerComposeVolume, DockerInspect,
- DockerPs, get_remote_dir_from_config,
+ Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
+ DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
},
};
@@ -879,6 +916,22 @@ mod test {
"POSTGRES_PORT": "5432",
"POSTGRES_USER": "postgres"
},
+ "ports": [
+ {
+ "target": "5443",
+ "published": "5442"
+ },
+ {
+ "name": "custom port",
+ "protocol": "udp",
+ "host_ip": "127.0.0.1",
+ "app_protocol": "http",
+ "mode": "host",
+ "target": "8081",
+ "published": "8083"
+
+ }
+ ],
"image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
"network_mode": "service:db",
"volumes": [
@@ -943,6 +996,23 @@ mod test {
target: "/workspaces".to_string(),
}],
network_mode: Some("service:db".to_string()),
+
+ ports: vec![
+ DockerComposeServicePort {
+ target: "5443".to_string(),
+ published: "5442".to_string(),
+ ..Default::default()
+ },
+ DockerComposeServicePort {
+ target: "8081".to_string(),
+ published: "8083".to_string(),
+ mode: Some("host".to_string()),
+ protocol: Some("udp".to_string()),
+ host_ip: Some("127.0.0.1".to_string()),
+ app_protocol: Some("http".to_string()),
+ name: Some("custom port".to_string()),
+ },
+ ],
..Default::default()
},
),
@@ -72,7 +72,7 @@ struct StateInner {
scrollbar_drag_start_height: Option<Pixels>,
measuring_behavior: ListMeasuringBehavior,
pending_scroll: Option<PendingScrollFraction>,
- follow_tail: bool,
+ follow_state: FollowState,
}
/// Keeps track of a fractional scroll position within an item for restoration
@@ -84,6 +84,49 @@ struct PendingScrollFraction {
fraction: f32,
}
+/// Controls whether the list automatically follows new content at the end.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub enum FollowMode {
+ /// Normal scrolling — no automatic following.
+ #[default]
+ Normal,
+ /// The list should auto-scroll along with the tail, when scrolled to bottom.
+ Tail,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+enum FollowState {
+ #[default]
+ Normal,
+ Tail {
+ is_following: bool,
+ },
+}
+
+impl FollowState {
+ fn is_following(&self) -> bool {
+ matches!(self, FollowState::Tail { is_following: true })
+ }
+
+ fn has_stopped_following(&self) -> bool {
+ matches!(
+ self,
+ FollowState::Tail {
+ is_following: false
+ }
+ )
+ }
+
+ fn start_following(&mut self) {
+ if let FollowState::Tail {
+ is_following: false,
+ } = self
+ {
+ *self = FollowState::Tail { is_following: true };
+ }
+ }
+}
+
/// Whether the list is scrolling from top to bottom or bottom to top.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ListAlignment {
@@ -169,6 +212,7 @@ pub struct ListPrepaintState {
#[derive(Clone)]
enum ListItem {
Unmeasured {
+ size_hint: Option<Size<Pixels>>,
focus_handle: Option<FocusHandle>,
},
Measured {
@@ -186,9 +230,16 @@ impl ListItem {
}
}
+ fn size_hint(&self) -> Option<Size<Pixels>> {
+ match self {
+ ListItem::Measured { size, .. } => Some(*size),
+ ListItem::Unmeasured { size_hint, .. } => *size_hint,
+ }
+ }
+
fn focus_handle(&self) -> Option<FocusHandle> {
match self {
- ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
+ ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
focus_handle.clone()
}
}
@@ -196,7 +247,7 @@ impl ListItem {
fn contains_focused(&self, window: &Window, cx: &App) -> bool {
match self {
- ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => {
+ ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => {
focus_handle
.as_ref()
.is_some_and(|handle| handle.contains_focused(window, cx))
@@ -240,7 +291,7 @@ impl ListState {
scrollbar_drag_start_height: None,
measuring_behavior: ListMeasuringBehavior::default(),
pending_scroll: None,
- follow_tail: false,
+ follow_state: FollowState::default(),
})));
this.splice(0..0, item_count);
this
@@ -275,37 +326,63 @@ impl ListState {
/// Use this when item heights may have changed (e.g., font size changes)
/// but the number and identity of items remains the same.
pub fn remeasure(&self) {
- let state = &mut *self.0.borrow_mut();
+ let count = self.item_count();
+ self.remeasure_items(0..count);
+ }
- let new_items = state.items.iter().map(|item| ListItem::Unmeasured {
- focus_handle: item.focus_handle(),
- });
+ /// Mark items in `range` as needing remeasurement while preserving
+ /// the current scroll position. Unlike [`Self::splice`], this does
+ /// not change the number of items or blow away `logical_scroll_top`.
+ ///
+ /// Use this when an item's content has changed and its rendered
+ /// height may be different (e.g., streaming text, tool results
+ /// loading), but the item itself still exists at the same index.
+ pub fn remeasure_items(&self, range: Range<usize>) {
+ let state = &mut *self.0.borrow_mut();
- // If there's a `logical_scroll_top`, we need to keep track of it as a
- // `PendingScrollFraction`, so we can later preserve that scroll
- // position proportionally to the item, in case the item's height
- // changes.
+ // If the scroll-top item falls within the remeasured range,
+ // store a fractional offset so the layout can restore the
+ // proportional scroll position after the item is re-rendered
+ // at its new height.
if let Some(scroll_top) = state.logical_scroll_top {
- let mut cursor = state.items.cursor::<Count>(());
- cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
+ if range.contains(&scroll_top.item_ix) {
+ let mut cursor = state.items.cursor::<Count>(());
+ cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
- if let Some(item) = cursor.item() {
- if let Some(size) = item.size() {
- let fraction = if size.height.0 > 0.0 {
- (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
- } else {
- 0.0
- };
-
- state.pending_scroll = Some(PendingScrollFraction {
- item_ix: scroll_top.item_ix,
- fraction,
- });
+ if let Some(item) = cursor.item() {
+ if let Some(size) = item.size() {
+ let fraction = if size.height.0 > 0.0 {
+ (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+
+ state.pending_scroll = Some(PendingScrollFraction {
+ item_ix: scroll_top.item_ix,
+ fraction,
+ });
+ }
}
}
}
- state.items = SumTree::from_iter(new_items, ());
+ // Rebuild the tree, replacing items in the range with
+ // Unmeasured copies that keep their focus handles.
+ let new_items = {
+ let mut cursor = state.items.cursor::<Count>(());
+ let mut new_items = cursor.slice(&Count(range.start), Bias::Right);
+ let invalidated = cursor.slice(&Count(range.end), Bias::Right);
+ new_items.extend(
+ invalidated.iter().map(|item| ListItem::Unmeasured {
+ size_hint: item.size_hint(),
+ focus_handle: item.focus_handle(),
+ }),
+ (),
+ );
+ new_items.append(cursor.suffix(), ());
+ new_items
+ };
+ state.items = new_items;
state.measuring_behavior.reset();
}
@@ -339,7 +416,10 @@ impl ListState {
new_items.extend(
focus_handles.into_iter().map(|focus_handle| {
spliced_count += 1;
- ListItem::Unmeasured { focus_handle }
+ ListItem::Unmeasured {
+ size_hint: None,
+ focus_handle,
+ }
}),
(),
);
@@ -414,24 +494,37 @@ impl ListState {
});
}
- /// Set whether the list should automatically follow the tail (auto-scroll to the end).
- pub fn set_follow_tail(&self, follow: bool) {
- self.0.borrow_mut().follow_tail = follow;
- if follow {
- self.scroll_to_end();
+ /// Set the follow mode for the list. In `Tail` mode, the list
+ /// will auto-scroll to the end and re-engage after the user
+ /// scrolls back to the bottom. In `Normal` mode, no automatic
+ /// following occurs.
+ pub fn set_follow_mode(&self, mode: FollowMode) {
+ let state = &mut *self.0.borrow_mut();
+
+ match mode {
+ FollowMode::Normal => {
+ state.follow_state = FollowState::Normal;
+ }
+ FollowMode::Tail => {
+ state.follow_state = FollowState::Tail { is_following: true };
+ if matches!(mode, FollowMode::Tail) {
+ let item_count = state.items.summary().count;
+ state.logical_scroll_top = Some(ListOffset {
+ item_ix: item_count,
+ offset_in_item: px(0.),
+ });
+ }
+ }
}
}
- /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end).
+ /// Returns whether the list is currently actively following the
+ /// tail (snapping to the end on each layout).
pub fn is_following_tail(&self) -> bool {
- self.0.borrow().follow_tail
- }
-
- /// Returns whether the list is scrolled to the bottom (within 1px).
- pub fn is_at_bottom(&self) -> bool {
- let current_offset = self.scroll_px_offset_for_scrollbar().y.abs();
- let max_offset = self.max_offset_for_scrollbar().y;
- current_offset >= max_offset - px(1.0)
+ matches!(
+ self.0.borrow().follow_state,
+ FollowState::Tail { is_following: true }
+ )
}
/// Scroll the list to the given offset
@@ -599,6 +692,7 @@ impl StateInner {
if self.reset {
return;
}
+
let padding = self.last_padding.unwrap_or_default();
let scroll_max =
(self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
@@ -620,8 +714,10 @@ impl StateInner {
});
}
- if self.follow_tail && delta.y > px(0.) {
- self.follow_tail = false;
+ if let FollowState::Tail { is_following } = &mut self.follow_state {
+ if delta.y > px(0.) {
+ *is_following = false;
+ }
}
if let Some(handler) = self.scroll_handler.as_mut() {
@@ -631,7 +727,10 @@ impl StateInner {
visible_range,
count: self.items.summary().count,
is_scrolled: self.logical_scroll_top.is_some(),
- is_following_tail: self.follow_tail,
+ is_following_tail: matches!(
+ self.follow_state,
+ FollowState::Tail { is_following: true }
+ ),
},
window,
cx,
@@ -722,7 +821,7 @@ impl StateInner {
let mut max_item_width = px(0.);
let mut scroll_top = self.logical_scroll_top();
- if self.follow_tail {
+ if self.follow_state.is_following() {
scroll_top = ListOffset {
item_ix: self.items.summary().count,
offset_in_item: px(0.),
@@ -875,6 +974,18 @@ impl StateInner {
new_items.append(cursor.suffix(), ());
self.items = new_items;
+ // If follow_tail mode is on but the user scrolled away
+ // (is_following is false), check whether the current scroll
+ // position has returned to the bottom.
+ if self.follow_state.has_stopped_following() {
+ let padding = self.last_padding.unwrap_or_default();
+ let total_height = self.items.summary().height + padding.top + padding.bottom;
+ let scroll_offset = self.scroll_top(&scroll_top);
+ if scroll_offset + available_height >= total_height - px(1.0) {
+ self.follow_state.start_following();
+ }
+ }
+
// If none of the visible items are focused, check if an off-screen item is focused
// and include it to be rendered after the visible items so keyboard interaction continues
// to work for it.
@@ -1011,7 +1122,7 @@ impl StateInner {
content_height - self.scrollbar_drag_start_height.unwrap_or(content_height);
let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max);
- self.follow_tail = false;
+ self.follow_state = FollowState::Normal;
if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max {
self.logical_scroll_top = None;
@@ -1159,6 +1270,7 @@ impl Element for List {
{
let new_items = SumTree::from_iter(
state.items.iter().map(|item| ListItem::Unmeasured {
+ size_hint: None,
focus_handle: item.focus_handle(),
}),
(),
@@ -1245,11 +1357,18 @@ impl sum_tree::Item for ListItem {
fn summary(&self, _: ()) -> Self::Summary {
match self {
- ListItem::Unmeasured { focus_handle } => ListItemSummary {
+ ListItem::Unmeasured {
+ size_hint,
+ focus_handle,
+ } => ListItemSummary {
count: 1,
rendered_count: 0,
unrendered_count: 1,
- height: px(0.),
+ height: if let Some(size) = size_hint {
+ size.height
+ } else {
+ px(0.)
+ },
has_focus_handles: focus_handle.is_some(),
},
ListItem::Measured {
@@ -1319,8 +1438,8 @@ mod test {
use std::rc::Rc;
use crate::{
- self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled,
- TestAppContext, Window, div, list, point, px, size,
+ self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render,
+ Styled, TestAppContext, Window, div, list, point, px, size,
};
#[gpui::test]
@@ -1545,7 +1664,7 @@ mod test {
})
});
- state.set_follow_tail(true);
+ state.set_follow_mode(FollowMode::Tail);
// First paint — items are 50px, total 500px, viewport 200px.
// Follow-tail should anchor to the end.
@@ -1599,7 +1718,7 @@ mod test {
}
}
- state.set_follow_tail(true);
+ state.set_follow_mode(FollowMode::Tail);
// Paint with follow-tail — scroll anchored to the bottom.
cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| {
@@ -1641,7 +1760,7 @@ mod test {
let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
- state.set_follow_tail(true);
+ state.set_follow_mode(FollowMode::Tail);
// Paint with follow-tail — scroll anchored to the bottom.
cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
@@ -1709,7 +1828,7 @@ mod test {
// Enable follow-tail — this should immediately snap the scroll anchor
// to the end, like the user just sent a prompt.
- state.set_follow_tail(true);
+ state.set_follow_mode(FollowMode::Tail);
cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
view.into_any_element()
@@ -1764,4 +1883,201 @@ mod test {
-scroll_offset.y, max_offset.y,
);
}
+
+ /// When the user scrolls away from the bottom during follow_tail,
+ /// follow_tail suspends. If they scroll back to the bottom, the
+ /// next paint should re-engage follow_tail using fresh measurements.
+ #[gpui::test]
+ fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) {
+ let cx = cx.add_empty_window();
+
+ // 10 items × 50px = 500px total, 200px viewport.
+ let state = ListState::new(10, crate::ListAlignment::Top, px(0.));
+
+ struct TestView(ListState);
+ impl Render for TestView {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ list(self.0.clone(), |_, _, _| {
+ div().h(px(50.)).w_full().into_any()
+ })
+ .w_full()
+ .h_full()
+ }
+ }
+
+ let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+ state.set_follow_mode(FollowMode::Tail);
+
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(state.is_following_tail());
+
+ // Scroll up — follow_tail should suspend (not fully disengage).
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
+ ..Default::default()
+ });
+ assert!(!state.is_following_tail());
+
+ // Scroll back down to the bottom.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+ ..Default::default()
+ });
+
+ // After a paint, follow_tail should re-engage because the
+ // layout confirmed we're at the true bottom.
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(
+ state.is_following_tail(),
+ "follow_tail should re-engage after scrolling back to the bottom"
+ );
+ }
+
+ /// When an item is spliced to unmeasured (0px) while follow_tail
+ /// is suspended, the re-engagement check should still work correctly
+ #[gpui::test]
+ fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) {
+ let cx = cx.add_empty_window();
+
+ // 20 items × 50px = 1000px total, 200px viewport, 1000px
+ // overdraw so all items get measured during the follow_tail
+ // paint (matching realistic production settings).
+ let state = ListState::new(20, crate::ListAlignment::Top, px(1000.));
+
+ struct TestView(ListState);
+ impl Render for TestView {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ list(self.0.clone(), |_, _, _| {
+ div().h(px(50.)).w_full().into_any()
+ })
+ .w_full()
+ .h_full()
+ }
+ }
+
+ let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+ state.set_follow_mode(FollowMode::Tail);
+
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(state.is_following_tail());
+
+ // Scroll up a meaningful amount — suspends follow_tail.
+ // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px.
+ // Scrolling up 200px puts us at 600px, clearly not at bottom.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(200.))),
+ ..Default::default()
+ });
+ assert!(!state.is_following_tail());
+
+ // Invalidate the last item (simulates EntryUpdated calling
+ // remeasure_items). This makes items.summary().height
+ // temporarily wrong (0px for the invalidated item).
+ state.remeasure_items(19..20);
+
+ // Paint — layout re-measures the invalidated item with its true
+ // height. The re-engagement check uses these fresh measurements.
+ // Since we scrolled 200px up from the 800px max, we're at
+ // ~600px — NOT at the bottom, so follow_tail should NOT
+ // re-engage.
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(
+ !state.is_following_tail(),
+ "follow_tail should not falsely re-engage due to an unmeasured item \
+ reducing items.summary().height"
+ );
+ }
+
+ /// Calling `set_follow_mode(FollowState::Normal)` or dragging the scrollbar should
+ /// fully disengage follow_tail — clearing any suspended state so
+ /// follow_tail won’t auto-re-engage.
+ #[gpui::test]
+ fn test_follow_tail_suspended_state_cleared_by_explicit_actions(cx: &mut TestAppContext) {
+ let cx = cx.add_empty_window();
+
+ // 10 items × 50px = 500px total, 200px viewport.
+ let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all();
+
+ struct TestView(ListState);
+ impl Render for TestView {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+ list(self.0.clone(), |_, _, _| {
+ div().h(px(50.)).w_full().into_any()
+ })
+ .w_full()
+ .h_full()
+ }
+ }
+
+ let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone())));
+
+ state.set_follow_mode(FollowMode::Tail);
+ // --- Part 1: set_follow_mode(FollowState::Normal) clears suspended state ---
+
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+
+ // Scroll up — suspends follow_tail.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(50.))),
+ ..Default::default()
+ });
+ assert!(!state.is_following_tail());
+
+ // Scroll back to the bottom — should re-engage follow_tail.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+ ..Default::default()
+ });
+
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(
+ state.is_following_tail(),
+ "follow_tail should re-engage after scrolling back to the bottom"
+ );
+
+ // --- Part 2: scrollbar drag clears suspended state ---
+
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+
+ // Drag the scrollbar to the middle — should clear suspended state.
+ state.set_offset_from_scrollbar(point(px(0.), px(150.)));
+
+ // Scroll to the bottom.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(50.), px(100.)),
+ delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))),
+ ..Default::default()
+ });
+
+ // Paint — should NOT re-engage because the scrollbar drag
+ // cleared the suspended state.
+ cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
+ view.clone().into_any_element()
+ });
+ assert!(
+ !state.is_following_tail(),
+ "follow_tail should not re-engage after scrollbar drag cleared the suspended state"
+ );
+ }
}
@@ -115,6 +115,8 @@ impl PlatformAtlas for WgpuAtlas {
if let Some(mut texture) = texture_slot.take() {
texture.decrement_ref_count();
if texture.is_unreferenced() {
+ lock.pending_uploads
+ .retain(|upload| upload.id != texture.id);
lock.storage[id.kind]
.free_list
.push(texture.id.index as usize);
@@ -228,7 +230,9 @@ impl WgpuAtlasState {
fn flush_uploads(&mut self) {
for upload in self.pending_uploads.drain(..) {
- let texture = &self.storage[upload.id];
+ let Some(texture) = self.storage.get(upload.id) else {
+ continue;
+ };
let bytes_per_pixel = texture.bytes_per_pixel();
self.queue.write_texture(
@@ -286,6 +290,15 @@ impl ops::IndexMut<AtlasTextureKind> for WgpuAtlasStorage {
}
}
+impl WgpuAtlasStorage {
+ fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> {
+ self[id.kind]
+ .textures
+ .get(id.index as usize)
+ .and_then(|t| t.as_ref())
+ }
+}
+
impl ops::Index<AtlasTextureId> for WgpuAtlasStorage {
type Output = WgpuAtlasTexture;
fn index(&self, id: AtlasTextureId) -> &Self::Output {
@@ -341,3 +354,70 @@ impl WgpuAtlasTexture {
self.live_atlas_keys == 0
}
}
+
+#[cfg(all(test, not(target_family = "wasm")))]
+mod tests {
+ use super::*;
+ use gpui::{ImageId, RenderImageParams};
+ use pollster::block_on;
+ use std::sync::Arc;
+
+ fn test_device_and_queue() -> anyhow::Result<(Arc<wgpu::Device>, Arc<wgpu::Queue>)> {
+ block_on(async {
+ let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
+ backends: wgpu::Backends::all(),
+ flags: wgpu::InstanceFlags::default(),
+ backend_options: wgpu::BackendOptions::default(),
+ memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
+ display: None,
+ });
+ let adapter = instance
+ .request_adapter(&wgpu::RequestAdapterOptions {
+ power_preference: wgpu::PowerPreference::LowPower,
+ compatible_surface: None,
+ force_fallback_adapter: false,
+ })
+ .await
+ .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?;
+ let (device, queue) = adapter
+ .request_device(&wgpu::DeviceDescriptor {
+ label: Some("wgpu_atlas_test_device"),
+ required_features: wgpu::Features::empty(),
+ required_limits: wgpu::Limits::downlevel_defaults()
+ .using_resolution(adapter.limits())
+ .using_alignment(adapter.limits()),
+ memory_hints: wgpu::MemoryHints::MemoryUsage,
+ trace: wgpu::Trace::Off,
+ experimental_features: wgpu::ExperimentalFeatures::disabled(),
+ })
+ .await
+ .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?;
+ Ok((Arc::new(device), Arc::new(queue)))
+ })
+ }
+
+ #[test]
+ fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> {
+ let (device, queue) = test_device_and_queue()?;
+
+ let atlas = WgpuAtlas::new(device, queue);
+ let key = AtlasKey::Image(RenderImageParams {
+ image_id: ImageId(1),
+ frame_index: 0,
+ });
+ let size = Size {
+ width: DevicePixels(1),
+ height: DevicePixels(1),
+ };
+ let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255]))));
+
+ // Regression test: before the fix, this panicked in flush_uploads
+ atlas
+ .get_or_insert_with(&key, &mut build)?
+ .expect("tile should be created");
+ atlas.remove(&key);
+ atlas.before_frame();
+
+ Ok(())
+ }
+}
@@ -6076,11 +6076,7 @@ impl ProjectGroupKey {
/// Creates a new `ProjectGroupKey` with the given path list.
///
/// The path list should point to the git main worktree paths for a project.
- ///
- /// This should be used only in a few places to make sure we can ensure the
- /// main worktree path invariant. Namely, this should only be called from
- /// [`Workspace`].
- pub(crate) fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
+ pub fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
Self { paths, host }
}
@@ -1285,7 +1285,10 @@ pub enum RemoteConnectionOptions {
impl RemoteConnectionOptions {
pub fn display_name(&self) -> String {
match self {
- RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(),
+ RemoteConnectionOptions::Ssh(opts) => opts
+ .nickname
+ .clone()
+ .unwrap_or_else(|| opts.host.to_string()),
RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
RemoteConnectionOptions::Docker(opts) => {
if opts.use_podman {
@@ -1300,6 +1303,32 @@ impl RemoteConnectionOptions {
}
}
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_ssh_display_name_prefers_nickname() {
+ let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
+ host: "1.2.3.4".into(),
+ nickname: Some("My Cool Project".to_string()),
+ ..Default::default()
+ });
+
+ assert_eq!(options.display_name(), "My Cool Project");
+ }
+
+ #[test]
+ fn test_ssh_display_name_falls_back_to_host() {
+ let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
+ host: "1.2.3.4".into(),
+ ..Default::default()
+ });
+
+ assert_eq!(options.display_name(), "1.2.3.4");
+ }
+}
+
impl From<SshConnectionOptions> for RemoteConnectionOptions {
fn from(opts: SshConnectionOptions) -> Self {
RemoteConnectionOptions::Ssh(opts)
@@ -999,6 +999,7 @@ impl VsCodeSettings {
}
}),
zoomed_padding: None,
+ focus_follows_mouse: None,
}
}
@@ -122,6 +122,9 @@ pub struct WorkspaceSettingsContent {
/// What draws window decorations/titlebar, the client application (Zed) or display server
/// Default: client
pub window_decorations: Option<WindowDecorations>,
+ /// Whether the focused panel follows the mouse location
+ /// Default: false
+ pub focus_follows_mouse: Option<FocusFollowsMouse>,
}
#[with_fallible_options]
@@ -928,3 +931,10 @@ impl DocumentSymbols {
self == &Self::On
}
}
+
+#[with_fallible_options]
+#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
+pub struct FocusFollowsMouse {
+ pub enabled: Option<bool>,
+ pub debounce_ms: Option<u64>,
+}
@@ -4159,7 +4159,7 @@ fn window_and_layout_page() -> SettingsPage {
]
}
- fn layout_section() -> [SettingsPageItem; 4] {
+ fn layout_section() -> [SettingsPageItem; 6] {
[
SettingsPageItem::SectionHeader("Layout"),
SettingsPageItem::SettingItem(SettingItem {
@@ -4223,6 +4223,52 @@ fn window_and_layout_page() -> SettingsPage {
}),
metadata: None,
}),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Focus Follows Mouse",
+ description: "Whether to change focus to a pane when the mouse hovers over it.",
+ field: Box::new(SettingField {
+ json_path: Some("focus_follows_mouse.enabled"),
+ pick: |settings_content| {
+ settings_content
+ .workspace
+ .focus_follows_mouse
+ .as_ref()
+ .and_then(|s| s.enabled.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .workspace
+ .focus_follows_mouse
+ .get_or_insert_default()
+ .enabled = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
+ SettingsPageItem::SettingItem(SettingItem {
+ title: "Focus Follows Mouse Debounce ms",
+ description: "Amount of time to wait before changing focus.",
+ field: Box::new(SettingField {
+ json_path: Some("focus_follows_mouse.debounce_ms"),
+ pick: |settings_content| {
+ settings_content
+ .workspace
+ .focus_follows_mouse
+ .as_ref()
+ .and_then(|s| s.debounce_ms.as_ref())
+ },
+ write: |settings_content, value| {
+ settings_content
+ .workspace
+ .focus_follows_mouse
+ .get_or_insert_default()
+ .debounce_ms = value;
+ },
+ }),
+ metadata: None,
+ files: USER,
+ }),
]
}
@@ -23,7 +23,6 @@ agent_settings.workspace = true
agent_ui = { workspace = true, features = ["audio"] }
anyhow.workspace = true
chrono.workspace = true
-collections.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
@@ -1,282 +0,0 @@
-//! The sidebar groups threads by a canonical path list.
-//!
-//! Threads have a path list associated with them, but this is the absolute path
-//! of whatever worktrees they were associated with. In the sidebar, we want to
-//! group all threads by their main worktree, and then we add a worktree chip to
-//! the sidebar entry when that thread is in another worktree.
-//!
-//! This module is provides the functions and structures necessary to do this
-//! lookup and mapping.
-
-use collections::{HashMap, HashSet, vecmap::VecMap};
-use gpui::{App, Entity};
-use project::ProjectGroupKey;
-use std::{
- path::{Path, PathBuf},
- sync::Arc,
-};
-use workspace::{MultiWorkspace, PathList, Workspace};
-
-#[derive(Default)]
-pub struct ProjectGroup {
- pub workspaces: Vec<Entity<Workspace>>,
- /// Root paths of all open workspaces in this group. Used to skip
- /// redundant thread-store queries for linked worktrees that already
- /// have an open workspace.
- covered_paths: HashSet<Arc<Path>>,
-}
-
-impl ProjectGroup {
- fn add_workspace(&mut self, workspace: &Entity<Workspace>, cx: &App) {
- if !self.workspaces.contains(workspace) {
- self.workspaces.push(workspace.clone());
- }
- for path in workspace.read(cx).root_paths(cx) {
- self.covered_paths.insert(path);
- }
- }
-
- pub fn first_workspace(&self) -> &Entity<Workspace> {
- self.workspaces
- .first()
- .expect("groups always have at least one workspace")
- }
-
- pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
- self.workspaces
- .iter()
- .find(|ws| {
- !crate::root_repository_snapshots(ws, cx)
- .any(|snapshot| snapshot.is_linked_worktree())
- })
- .unwrap_or_else(|| self.first_workspace())
- }
-}
-
-pub struct ProjectGroupBuilder {
- /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
- directory_mappings: HashMap<PathBuf, PathBuf>,
- project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
-}
-
-impl ProjectGroupBuilder {
- fn new() -> Self {
- Self {
- directory_mappings: HashMap::default(),
- project_groups: VecMap::new(),
- }
- }
-
- pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self {
- let mut builder = Self::new();
- // First pass: collect all directory mappings from every workspace
- // so we know how to canonicalize any path (including linked
- // worktree paths discovered by the main repo's workspace).
- for workspace in mw.workspaces() {
- builder.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // Second pass: group each workspace using canonical paths derived
- // from the full set of mappings.
- for workspace in mw.workspaces() {
- let group_name = workspace.read(cx).project_group_key(cx);
- builder
- .project_group_entry(&group_name)
- .add_workspace(workspace, cx);
- }
- builder
- }
-
- fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
- self.project_groups.entry_ref(name).or_insert_default()
- }
-
- fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) {
- let old = self
- .directory_mappings
- .insert(PathBuf::from(work_directory), PathBuf::from(original_repo));
- if let Some(old) = old {
- debug_assert_eq!(
- &old, original_repo,
- "all worktrees should map to the same main worktree"
- );
- }
- }
-
- pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) {
- for repo in workspace.project().read(cx).repositories(cx).values() {
- let snapshot = repo.read(cx).snapshot();
-
- self.add_mapping(
- &snapshot.work_directory_abs_path,
- &snapshot.original_repo_abs_path,
- );
-
- for worktree in snapshot.linked_worktrees.iter() {
- self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path);
- }
- }
- }
-
- pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
- self.directory_mappings
- .get(path)
- .map(AsRef::as_ref)
- .unwrap_or(path)
- }
-
- /// Whether the given group should load threads for a linked worktree
- /// at `worktree_path`. Returns `false` if the worktree already has an
- /// open workspace in the group (its threads are loaded via the
- /// workspace loop) or if the worktree's canonical path list doesn't
- /// match `group_path_list`.
- pub fn group_owns_worktree(
- &self,
- group: &ProjectGroup,
- group_path_list: &PathList,
- worktree_path: &Path,
- ) -> bool {
- if group.covered_paths.contains(worktree_path) {
- return false;
- }
- let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path]));
- canonical == *group_path_list
- }
-
- /// Canonicalizes every path in a [`PathList`] using the builder's
- /// directory mappings.
- fn canonicalize_path_list(&self, path_list: &PathList) -> PathList {
- let paths: Vec<_> = path_list
- .paths()
- .iter()
- .map(|p| self.canonicalize_path(p).to_path_buf())
- .collect();
- PathList::new(&paths)
- }
-
- pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
- self.project_groups.iter()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::Arc;
-
- 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_settings::init(theme::LoadThemes::JustBase, cx);
- });
- }
-
- async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc<FakeFs> {
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- "/project",
- serde_json::json!({
- ".git": {
- "worktrees": {
- "feature-a": {
- "commondir": "../../",
- "HEAD": "ref: refs/heads/feature-a",
- },
- },
- },
- "src": {},
- }),
- )
- .await;
- fs.insert_tree(
- "/wt/feature-a",
- serde_json::json!({
- ".git": "gitdir: /project/.git/worktrees/feature-a",
- "src": {},
- }),
- )
- .await;
- fs.add_linked_worktree_for_repo(
- std::path::Path::new("/project/.git"),
- false,
- git::repository::Worktree {
- path: std::path::PathBuf::from("/wt/feature-a"),
- ref_name: Some("refs/heads/feature-a".into()),
- sha: "abc".into(),
- is_main: false,
- },
- )
- .await;
- fs
- }
-
- #[gpui::test]
- async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = create_fs_with_main_and_worktree(cx).await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
- project
- .update(cx, |project, cx| project.git_scans_complete(cx))
- .await;
-
- let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
- workspace::MultiWorkspace::test_new(project.clone(), window, cx)
- });
-
- multi_workspace.read_with(cx, |mw, cx| {
- let mut canonicalizer = ProjectGroupBuilder::new();
- for workspace in mw.workspaces() {
- canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // The main repo path should canonicalize to itself.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/project")),
- Path::new("/project"),
- );
-
- // An unknown path returns None.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/something/else")),
- Path::new("/something/else"),
- );
- });
- }
-
- #[gpui::test]
- async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = create_fs_with_main_and_worktree(cx).await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- // Open the worktree checkout as its own project.
- let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await;
- project
- .update(cx, |project, cx| project.git_scans_complete(cx))
- .await;
-
- let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
- workspace::MultiWorkspace::test_new(project.clone(), window, cx)
- });
-
- multi_workspace.read_with(cx, |mw, cx| {
- let mut canonicalizer = ProjectGroupBuilder::new();
- for workspace in mw.workspaces() {
- canonicalizer.add_workspace_mappings(workspace.read(cx), cx);
- }
-
- // The worktree checkout path should canonicalize to the main repo.
- assert_eq!(
- canonicalizer.canonicalize_path(Path::new("/wt/feature-a")),
- Path::new("/project"),
- );
- });
- }
-}
@@ -24,7 +24,9 @@ use gpui::{
use menu::{
Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious,
};
-use project::{AgentId, AgentRegistryStore, Event as ProjectEvent, linked_worktree_short_name};
+use project::{
+ AgentId, AgentRegistryStore, Event as ProjectEvent, ProjectGroupKey, linked_worktree_short_name,
+};
use recent_projects::sidebar_recent_projects::SidebarRecentProjects;
use remote::RemoteConnectionOptions;
use ui::utils::platform_title_bar_height;
@@ -55,10 +57,6 @@ use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher};
use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent};
-use crate::project_group_builder::ProjectGroupBuilder;
-
-mod project_group_builder;
-
#[cfg(test)]
mod sidebar_tests;
@@ -137,13 +135,7 @@ impl ActiveEntry {
(ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
thread.metadata.session_id == *session_id
}
- (
- ActiveEntry::Draft(workspace),
- ListEntry::NewThread {
- workspace: entry_workspace,
- ..
- },
- ) => workspace == entry_workspace,
+ (ActiveEntry::Draft(_workspace), ListEntry::DraftThread { .. }) => true,
_ => false,
}
}
@@ -210,9 +202,8 @@ impl ThreadEntry {
#[derive(Clone)]
enum ListEntry {
ProjectHeader {
- path_list: PathList,
+ key: ProjectGroupKey,
label: SharedString,
- workspace: Entity<Workspace>,
highlight_positions: Vec<usize>,
has_running_threads: bool,
waiting_thread_count: usize,
@@ -220,30 +211,25 @@ enum ListEntry {
},
Thread(ThreadEntry),
ViewMore {
- path_list: PathList,
+ key: ProjectGroupKey,
is_fully_expanded: bool,
},
+ /// The user's active draft thread. Shows a prefix of the currently-typed
+ /// prompt, or "Untitled Thread" if the prompt is empty.
+ DraftThread {
+ worktrees: Vec<WorktreeInfo>,
+ },
+ /// A convenience row for starting a new thread. Shown when a project group
+ /// has no threads, or when the active workspace contains linked worktrees
+ /// with no threads for that specific worktree set.
NewThread {
- path_list: PathList,
- workspace: Entity<Workspace>,
+ key: project::ProjectGroupKey,
worktrees: Vec<WorktreeInfo>,
},
}
#[cfg(test)]
impl ListEntry {
- fn workspace(&self) -> Option<Entity<Workspace>> {
- match self {
- ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
- ListEntry::Thread(thread_entry) => match &thread_entry.workspace {
- ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
- ThreadEntryWorkspace::Closed(_) => None,
- },
- ListEntry::ViewMore { .. } => None,
- ListEntry::NewThread { workspace, .. } => Some(workspace.clone()),
- }
- }
-
fn session_id(&self) -> Option<&acp::SessionId> {
match self {
ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
@@ -322,27 +308,32 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
/// Derives worktree display info from a thread's stored path list.
///
-/// For each path in the thread's `folder_paths` that canonicalizes to a
-/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`]
-/// with the short worktree name and full path.
+/// For each path in the thread's `folder_paths` that is not one of the
+/// group's main paths (i.e. it's a git linked worktree), produces a
+/// [`WorktreeInfo`] with the short worktree name and full path.
fn worktree_info_from_thread_paths(
folder_paths: &PathList,
- project_groups: &ProjectGroupBuilder,
+ group_key: &project::ProjectGroupKey,
) -> Vec<WorktreeInfo> {
+ let main_paths = group_key.path_list().paths();
folder_paths
.paths()
.iter()
.filter_map(|path| {
- let canonical = project_groups.canonicalize_path(path);
- if canonical != path.as_path() {
- Some(WorktreeInfo {
- name: linked_worktree_short_name(canonical, path).unwrap_or_default(),
- full_path: SharedString::from(path.display().to_string()),
- highlight_positions: Vec::new(),
- })
- } else {
- None
+ if main_paths.iter().any(|mp| mp.as_path() == path.as_path()) {
+ return None;
}
+ // Find the main path whose file name matches this linked
+ // worktree's file name, falling back to the first main path.
+ let main_path = main_paths
+ .iter()
+ .find(|mp| mp.file_name() == path.file_name())
+ .or(main_paths.first())?;
+ Some(WorktreeInfo {
+ name: linked_worktree_short_name(main_path, path).unwrap_or_default(),
+ full_path: SharedString::from(path.display().to_string()),
+ highlight_positions: Vec::new(),
+ })
})
.collect()
}
@@ -678,10 +669,41 @@ impl Sidebar {
result
}
+ /// Finds an open workspace whose project group key matches the given path list.
+ fn workspace_for_group(&self, path_list: &PathList, cx: &App) -> Option<Entity<Workspace>> {
+ let mw = self.multi_workspace.upgrade()?;
+ let mw = mw.read(cx);
+ mw.workspaces()
+ .iter()
+ .find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list)
+ .cloned()
+ }
+
+ /// Opens a new workspace for a group that has no open workspaces.
+ fn open_workspace_for_group(
+ &mut self,
+ path_list: &PathList,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(multi_workspace) = self.multi_workspace.upgrade() else {
+ return;
+ };
+
+ let paths: Vec<std::path::PathBuf> =
+ path_list.paths().iter().map(|p| p.to_path_buf()).collect();
+
+ multi_workspace
+ .update(cx, |mw, cx| {
+ mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
+ })
+ .detach_and_log_err(cx);
+ }
+
/// Rebuilds the sidebar contents from current workspace and thread state.
///
- /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git
- /// repository, then populates thread entries from the metadata store and
+ /// Iterates [`MultiWorkspace::project_group_keys`] to determine project
+ /// groups, then populates thread entries from the metadata store and
/// merges live thread info from active agent panels.
///
/// Aim for a single forward pass over workspaces and threads plus an
@@ -765,11 +787,6 @@ impl Sidebar {
let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
let mut project_header_indices: Vec<usize> = Vec::new();
- // Use ProjectGroupBuilder to canonically group workspaces by their
- // main git repository. This replaces the manual absorbed-workspace
- // detection that was here before.
- let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx);
-
let has_open_projects = workspaces
.iter()
.any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
@@ -786,38 +803,28 @@ impl Sidebar {
(icon, icon_from_external_svg)
};
- for (group_name, group) in project_groups.groups() {
- let path_list = group_name.path_list().clone();
+ for (group_key, group_workspaces) in mw.project_groups(cx) {
+ let path_list = group_key.path_list().clone();
if path_list.paths().is_empty() {
continue;
}
- let label = group_name.display_name();
+ let label = group_key.display_name();
let is_collapsed = self.collapsed_groups.contains(&path_list);
let should_load_threads = !is_collapsed || !query.is_empty();
let is_active = active_workspace
.as_ref()
- .is_some_and(|active| group.workspaces.contains(active));
-
- // Pick a representative workspace for the group: prefer the active
- // workspace if it belongs to this group, otherwise use the main
- // repo workspace (not a linked worktree).
- let representative_workspace = active_workspace
- .as_ref()
- .filter(|_| is_active)
- .unwrap_or_else(|| group.main_workspace(cx));
+ .is_some_and(|active| group_workspaces.contains(active));
// Collect live thread infos from all workspaces in this group.
- let live_infos: Vec<_> = group
- .workspaces
+ let live_infos: Vec<_> = group_workspaces
.iter()
.flat_map(|ws| all_thread_infos_for_workspace(ws, cx))
.collect();
let mut threads: Vec<ThreadEntry> = Vec::new();
- let mut threadless_workspaces: Vec<(Entity<Workspace>, Vec<WorktreeInfo>)> = Vec::new();
let mut has_running_threads = false;
let mut waiting_thread_count: usize = 0;
@@ -825,61 +832,88 @@ impl Sidebar {
let mut seen_session_ids: HashSet<acp::SessionId> = HashSet::new();
let thread_store = ThreadMetadataStore::global(cx);
- // Load threads from each workspace in the group.
- for workspace in &group.workspaces {
- let ws_path_list = workspace_path_list(workspace, cx);
- let mut workspace_rows = thread_store
- .read(cx)
- .entries_for_path(&ws_path_list)
- .cloned()
- .peekable();
- if workspace_rows.peek().is_none() {
- let worktrees =
- worktree_info_from_thread_paths(&ws_path_list, &project_groups);
- threadless_workspaces.push((workspace.clone(), worktrees));
+ // Build a lookup from workspace root paths to their workspace
+ // entity, used to assign ThreadEntryWorkspace::Open for threads
+ // whose folder_paths match an open workspace.
+ let workspace_by_path_list: HashMap<PathList, &Entity<Workspace>> =
+ group_workspaces
+ .iter()
+ .map(|ws| (workspace_path_list(ws, cx), ws))
+ .collect();
+
+ // Resolve a ThreadEntryWorkspace for a thread row. If any open
+ // workspace's root paths match the thread's folder_paths, use
+ // Open; otherwise use Closed.
+ let resolve_workspace = |row: &ThreadMetadata| -> ThreadEntryWorkspace {
+ workspace_by_path_list
+ .get(&row.folder_paths)
+ .map(|ws| ThreadEntryWorkspace::Open((*ws).clone()))
+ .unwrap_or_else(|| ThreadEntryWorkspace::Closed(row.folder_paths.clone()))
+ };
+
+ // Build a ThreadEntry from a metadata row.
+ let make_thread_entry = |row: ThreadMetadata,
+ workspace: ThreadEntryWorkspace|
+ -> ThreadEntry {
+ let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
+ let worktrees = worktree_info_from_thread_paths(&row.folder_paths, &group_key);
+ ThreadEntry {
+ metadata: row,
+ icon,
+ icon_from_external_svg,
+ status: AgentThreadStatus::default(),
+ workspace,
+ is_live: false,
+ is_background: false,
+ is_title_generating: false,
+ highlight_positions: Vec::new(),
+ worktrees,
+ diff_stats: DiffStats::default(),
}
- for row in workspace_rows {
- if !seen_session_ids.insert(row.session_id.clone()) {
- continue;
- }
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Open(workspace.clone()),
- is_live: false,
- is_background: false,
- is_title_generating: false,
- highlight_positions: Vec::new(),
- worktrees,
- diff_stats: DiffStats::default(),
- });
+ };
+
+ // === Main code path: one query per group via main_worktree_paths ===
+ // The main_worktree_paths column is set on all new threads and
+ // points to the group's canonical paths regardless of which
+ // linked worktree the thread was opened in.
+ for row in thread_store
+ .read(cx)
+ .entries_for_main_worktree_path(&path_list)
+ .cloned()
+ {
+ if !seen_session_ids.insert(row.session_id.clone()) {
+ continue;
}
+ let workspace = resolve_workspace(&row);
+ threads.push(make_thread_entry(row, workspace));
}
- // Load threads from linked git worktrees whose
- // canonical paths belong to this group.
- let linked_worktree_queries = group
- .workspaces
- .iter()
- .flat_map(|ws| root_repository_snapshots(ws, cx))
- .filter(|snapshot| !snapshot.is_linked_worktree())
- .flat_map(|snapshot| {
- snapshot
- .linked_worktrees()
- .iter()
- .filter(|wt| {
- project_groups.group_owns_worktree(group, &path_list, &wt.path)
- })
- .map(|wt| PathList::new(std::slice::from_ref(&wt.path)))
- .collect::<Vec<_>>()
- });
+ // Legacy threads did not have `main_worktree_paths` populated, so they
+ // must be queried by their `folder_paths`.
+
+ // Load any legacy threads for the main worktrees of this project group.
+ for row in thread_store.read(cx).entries_for_path(&path_list).cloned() {
+ if !seen_session_ids.insert(row.session_id.clone()) {
+ continue;
+ }
+ let workspace = resolve_workspace(&row);
+ threads.push(make_thread_entry(row, workspace));
+ }
- for worktree_path_list in linked_worktree_queries {
+ // Load any legacy threads for any single linked wortree of this project group.
+ let mut linked_worktree_paths = HashSet::new();
+ for workspace in &group_workspaces {
+ if workspace.read(cx).visible_worktrees(cx).count() != 1 {
+ continue;
+ }
+ for snapshot in root_repository_snapshots(workspace, cx) {
+ for linked_worktree in snapshot.linked_worktrees() {
+ linked_worktree_paths.insert(linked_worktree.path.clone());
+ }
+ }
+ }
+ for path in linked_worktree_paths {
+ let worktree_path_list = PathList::new(std::slice::from_ref(&path));
for row in thread_store
.read(cx)
.entries_for_path(&worktree_path_list)
@@ -888,67 +922,10 @@ impl Sidebar {
if !seen_session_ids.insert(row.session_id.clone()) {
continue;
}
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
- is_live: false,
- is_background: false,
- is_title_generating: false,
- highlight_positions: Vec::new(),
- worktrees,
- diff_stats: DiffStats::default(),
- });
- }
- }
-
- // Load threads from main worktrees when a workspace in this
- // group is itself a linked worktree checkout.
- let main_repo_queries: Vec<PathList> = group
- .workspaces
- .iter()
- .flat_map(|ws| root_repository_snapshots(ws, cx))
- .filter(|snapshot| snapshot.is_linked_worktree())
- .map(|snapshot| {
- PathList::new(std::slice::from_ref(&snapshot.original_repo_abs_path))
- })
- .collect();
-
- for main_repo_path_list in main_repo_queries {
- let folder_path_matches = thread_store
- .read(cx)
- .entries_for_path(&main_repo_path_list)
- .cloned();
- let main_worktree_path_matches = thread_store
- .read(cx)
- .entries_for_main_worktree_path(&main_repo_path_list)
- .cloned();
-
- for row in folder_path_matches.chain(main_worktree_path_matches) {
- if !seen_session_ids.insert(row.session_id.clone()) {
- continue;
- }
- let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id);
- let worktrees =
- worktree_info_from_thread_paths(&row.folder_paths, &project_groups);
- threads.push(ThreadEntry {
- metadata: row,
- icon,
- icon_from_external_svg,
- status: AgentThreadStatus::default(),
- workspace: ThreadEntryWorkspace::Closed(main_repo_path_list.clone()),
- is_live: false,
- is_background: false,
- is_title_generating: false,
- highlight_positions: Vec::new(),
- worktrees,
- diff_stats: DiffStats::default(),
- });
+ threads.push(make_thread_entry(
+ row,
+ ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
+ ));
}
}
@@ -1052,9 +1029,8 @@ impl Sidebar {
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
- path_list: path_list.clone(),
+ key: group_key.clone(),
label,
- workspace: representative_workspace.clone(),
highlight_positions: workspace_highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1066,15 +1042,13 @@ impl Sidebar {
entries.push(thread.into());
}
} else {
- let is_draft_for_workspace = is_active
- && matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
- && self.active_entry_workspace() == Some(representative_workspace);
+ let is_draft_for_group = is_active
+ && matches!(&self.active_entry, Some(ActiveEntry::Draft(ws)) if group_workspaces.contains(ws));
project_header_indices.push(entries.len());
entries.push(ListEntry::ProjectHeader {
- path_list: path_list.clone(),
+ key: group_key.clone(),
label,
- workspace: representative_workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads,
waiting_thread_count,
@@ -1085,25 +1059,61 @@ impl Sidebar {
continue;
}
- // Emit "New Thread" entries for threadless workspaces
- // and active drafts, right after the header.
- for (workspace, worktrees) in &threadless_workspaces {
- entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: workspace.clone(),
- worktrees: worktrees.clone(),
- });
+ // Emit a DraftThread entry when the active draft belongs to this group.
+ if is_draft_for_group {
+ if let Some(ActiveEntry::Draft(draft_ws)) = &self.active_entry {
+ let ws_path_list = workspace_path_list(draft_ws, cx);
+ let worktrees = worktree_info_from_thread_paths(&ws_path_list, &group_key);
+ entries.push(ListEntry::DraftThread { worktrees });
+ }
}
- if is_draft_for_workspace
- && !threadless_workspaces
- .iter()
- .any(|(ws, _)| ws == representative_workspace)
+
+ // Emit a NewThread entry when:
+ // 1. The group has zero threads (convenient affordance).
+ // 2. The active workspace has linked worktrees but no threads
+ // for the active workspace's specific set of worktrees.
+ let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
+ let active_ws_has_threadless_linked_worktrees = is_active
+ && !is_draft_for_group
+ && active_workspace.as_ref().is_some_and(|active_ws| {
+ let ws_path_list = workspace_path_list(active_ws, cx);
+ let has_linked_worktrees =
+ !worktree_info_from_thread_paths(&ws_path_list, &group_key).is_empty();
+ if !has_linked_worktrees {
+ return false;
+ }
+ let thread_store = ThreadMetadataStore::global(cx);
+ let has_threads_for_ws = thread_store
+ .read(cx)
+ .entries_for_path(&ws_path_list)
+ .next()
+ .is_some()
+ || thread_store
+ .read(cx)
+ .entries_for_main_worktree_path(&ws_path_list)
+ .next()
+ .is_some();
+ !has_threads_for_ws
+ });
+
+ if !is_draft_for_group
+ && (group_has_no_threads || active_ws_has_threadless_linked_worktrees)
{
- let ws_path_list = workspace_path_list(representative_workspace, cx);
- let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
+ let worktrees = if active_ws_has_threadless_linked_worktrees {
+ active_workspace
+ .as_ref()
+ .map(|ws| {
+ worktree_info_from_thread_paths(
+ &workspace_path_list(ws, cx),
+ &group_key,
+ )
+ })
+ .unwrap_or_default()
+ } else {
+ Vec::new()
+ };
entries.push(ListEntry::NewThread {
- path_list: path_list.clone(),
- workspace: representative_workspace.clone(),
+ key: group_key.clone(),
worktrees,
});
}
@@ -1149,7 +1159,7 @@ impl Sidebar {
if total > DEFAULT_THREADS_SHOWN {
entries.push(ListEntry::ViewMore {
- path_list: path_list.clone(),
+ key: group_key.clone(),
is_fully_expanded,
});
}
@@ -1237,9 +1247,8 @@ impl Sidebar {
let rendered = match entry {
ListEntry::ProjectHeader {
- path_list,
+ key,
label,
- workspace,
highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1247,9 +1256,8 @@ impl Sidebar {
} => self.render_project_header(
ix,
false,
- path_list,
+ key,
label,
- workspace,
highlight_positions,
*has_running_threads,
*waiting_thread_count,
@@ -1259,22 +1267,15 @@ impl Sidebar {
),
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
ListEntry::ViewMore {
- path_list,
+ key,
is_fully_expanded,
- } => self.render_view_more(ix, path_list, *is_fully_expanded, is_selected, cx),
- ListEntry::NewThread {
- path_list,
- workspace,
- worktrees,
- } => self.render_new_thread(
- ix,
- path_list,
- workspace,
- is_active,
- worktrees,
- is_selected,
- cx,
- ),
+ } => self.render_view_more(ix, key.path_list(), *is_fully_expanded, is_selected, cx),
+ ListEntry::DraftThread { worktrees, .. } => {
+ self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
+ }
+ ListEntry::NewThread { key, worktrees, .. } => {
+ self.render_new_thread(ix, key, worktrees, is_selected, cx)
+ }
};
if is_group_header_after_first {
@@ -1292,13 +1293,9 @@ impl Sidebar {
fn render_remote_project_icon(
&self,
ix: usize,
- workspace: &Entity<Workspace>,
- cx: &mut Context<Self>,
+ host: Option<&RemoteConnectionOptions>,
) -> Option<AnyElement> {
- let project = workspace.read(cx).project().read(cx);
- let remote_connection_options = project.remote_connection_options(cx)?;
-
- let remote_icon_per_type = match remote_connection_options {
+ let remote_icon_per_type = match host? {
RemoteConnectionOptions::Wsl(_) => IconName::Linux,
RemoteConnectionOptions::Docker(_) => IconName::Box,
_ => IconName::Server,
@@ -1321,9 +1318,8 @@ impl Sidebar {
&self,
ix: usize,
is_sticky: bool,
- path_list: &PathList,
+ key: &ProjectGroupKey,
label: &SharedString,
- workspace: &Entity<Workspace>,
highlight_positions: &[usize],
has_running_threads: bool,
waiting_thread_count: usize,
@@ -1331,6 +1327,9 @@ impl Sidebar {
is_focused: bool,
cx: &mut Context<Self>,
) -> AnyElement {
+ let path_list = key.path_list();
+ let host = key.host();
+
let id_prefix = if is_sticky { "sticky-" } else { "" };
let id = SharedString::from(format!("{id_prefix}project-header-{ix}"));
let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
@@ -1343,16 +1342,15 @@ impl Sidebar {
(IconName::ChevronDown, "Collapse Project")
};
- let has_new_thread_entry = self
- .contents
- .entries
- .get(ix + 1)
- .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
+ let has_new_thread_entry = self.contents.entries.get(ix + 1).is_some_and(|entry| {
+ matches!(
+ entry,
+ ListEntry::NewThread { .. } | ListEntry::DraftThread { .. }
+ )
+ });
let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
- let workspace_for_remove = workspace.clone();
- let workspace_for_menu = workspace.clone();
- let workspace_for_open = workspace.clone();
+ let workspace = self.workspace_for_group(path_list, cx);
let path_list_for_toggle = path_list.clone();
let path_list_for_collapse = path_list.clone();
@@ -1409,7 +1407,7 @@ impl Sidebar {
)
.child(label)
.when_some(
- self.render_remote_project_icon(ix, workspace, cx),
+ self.render_remote_project_icon(ix, host.as_ref()),
|this, icon| this.child(icon),
)
.when(is_collapsed, |this| {
@@ -1453,13 +1451,13 @@ impl Sidebar {
.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
- .child(self.render_project_header_menu(
- ix,
- id_prefix,
- &workspace_for_menu,
- &workspace_for_remove,
- cx,
- ))
+ .when_some(workspace, |this, workspace| {
+ this.child(
+ self.render_project_header_menu(
+ ix, id_prefix, &workspace, &workspace, cx,
+ ),
+ )
+ })
.when(view_more_expanded && !is_collapsed, |this| {
this.child(
IconButton::new(
@@ -1481,52 +1479,56 @@ impl Sidebar {
})),
)
})
- .when(show_new_thread_button, |this| {
- this.child(
- IconButton::new(
- SharedString::from(format!(
- "{id_prefix}project-header-new-thread-{ix}",
+ .when(
+ show_new_thread_button && workspace_for_new_thread.is_some(),
+ |this| {
+ let workspace_for_new_thread =
+ workspace_for_new_thread.clone().unwrap();
+ let path_list_for_new_thread = path_list_for_new_thread.clone();
+ this.child(
+ IconButton::new(
+ SharedString::from(format!(
+ "{id_prefix}project-header-new-thread-{ix}",
+ )),
+ IconName::Plus,
+ )
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("New Thread"))
+ .on_click(cx.listener(
+ move |this, _, window, cx| {
+ this.collapsed_groups.remove(&path_list_for_new_thread);
+ this.selection = None;
+ this.create_new_thread(
+ &workspace_for_new_thread,
+ window,
+ cx,
+ );
+ },
)),
- IconName::Plus,
)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("New Thread"))
- .on_click(cx.listener({
- let workspace_for_new_thread = workspace_for_new_thread.clone();
- let path_list_for_new_thread = path_list_for_new_thread.clone();
- move |this, _, window, cx| {
- // Uncollapse the group if collapsed so
- // the new-thread entry becomes visible.
- this.collapsed_groups.remove(&path_list_for_new_thread);
- this.selection = None;
- this.create_new_thread(&workspace_for_new_thread, window, cx);
- }
- })),
- )
- })
+ },
+ )
})
.when(!is_active, |this| {
+ let path_list_for_open = path_list.clone();
this.cursor_pointer()
.hover(|s| s.bg(hover_color))
- .tooltip(Tooltip::text("Activate Workspace"))
- .on_click(cx.listener({
- move |this, _, window, cx| {
- this.active_entry =
- Some(ActiveEntry::Draft(workspace_for_open.clone()));
+ .tooltip(Tooltip::text("Open Workspace"))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ if let Some(workspace) = this.workspace_for_group(&path_list_for_open, cx) {
+ this.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
if let Some(multi_workspace) = this.multi_workspace.upgrade() {
multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(
- workspace_for_open.clone(),
- window,
- cx,
- );
+ multi_workspace.activate(workspace.clone(), window, cx);
});
}
- if AgentPanel::is_visible(&workspace_for_open, cx) {
- workspace_for_open.update(cx, |workspace, cx| {
+ if AgentPanel::is_visible(&workspace, cx) {
+ workspace.update(cx, |workspace, cx| {
workspace.focus_panel::<AgentPanel>(window, cx);
});
}
+ } else {
+ this.open_workspace_for_group(&path_list_for_open, window, cx);
}
}))
})
@@ -1721,9 +1723,8 @@ impl Sidebar {
}
let ListEntry::ProjectHeader {
- path_list,
+ key,
label,
- workspace,
highlight_positions,
has_running_threads,
waiting_thread_count,
@@ -1739,9 +1740,8 @@ impl Sidebar {
let header_element = self.render_project_header(
header_idx,
true,
- &path_list,
+ key,
&label,
- workspace,
&highlight_positions,
*has_running_threads,
*waiting_thread_count,
@@ -1962,8 +1962,8 @@ impl Sidebar {
};
match entry {
- ListEntry::ProjectHeader { path_list, .. } => {
- let path_list = path_list.clone();
+ ListEntry::ProjectHeader { key, .. } => {
+ let path_list = key.path_list().clone();
self.toggle_collapse(&path_list, window, cx);
}
ListEntry::Thread(thread) => {
@@ -1984,11 +1984,11 @@ impl Sidebar {
}
}
ListEntry::ViewMore {
- path_list,
+ key,
is_fully_expanded,
..
} => {
- let path_list = path_list.clone();
+ let path_list = key.path_list().clone();
if *is_fully_expanded {
self.expanded_groups.remove(&path_list);
} else {
@@ -1998,9 +1998,16 @@ impl Sidebar {
self.serialize(cx);
self.update_entries(cx);
}
- ListEntry::NewThread { workspace, .. } => {
- let workspace = workspace.clone();
- self.create_new_thread(&workspace, window, cx);
+ ListEntry::DraftThread { .. } => {
+ // Already active — nothing to do.
+ }
+ ListEntry::NewThread { key, .. } => {
+ let path_list = key.path_list().clone();
+ if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
+ self.create_new_thread(&workspace, window, cx);
+ } else {
+ self.open_workspace_for_group(&path_list, window, cx);
+ }
}
}
}
@@ -2252,9 +2259,9 @@ impl Sidebar {
let Some(ix) = self.selection else { return };
match self.contents.entries.get(ix) {
- Some(ListEntry::ProjectHeader { path_list, .. }) => {
- if self.collapsed_groups.contains(path_list) {
- let path_list = path_list.clone();
+ Some(ListEntry::ProjectHeader { key, .. }) => {
+ if self.collapsed_groups.contains(key.path_list()) {
+ let path_list = key.path_list().clone();
self.collapsed_groups.remove(&path_list);
self.update_entries(cx);
} else if ix + 1 < self.contents.entries.len() {
@@ -2276,23 +2283,23 @@ impl Sidebar {
let Some(ix) = self.selection else { return };
match self.contents.entries.get(ix) {
- Some(ListEntry::ProjectHeader { path_list, .. }) => {
- if !self.collapsed_groups.contains(path_list) {
- let path_list = path_list.clone();
- self.collapsed_groups.insert(path_list);
+ Some(ListEntry::ProjectHeader { key, .. }) => {
+ if !self.collapsed_groups.contains(key.path_list()) {
+ self.collapsed_groups.insert(key.path_list().clone());
self.update_entries(cx);
}
}
Some(
- ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+ ListEntry::Thread(_)
+ | ListEntry::ViewMore { .. }
+ | ListEntry::NewThread { .. }
+ | ListEntry::DraftThread { .. },
) => {
for i in (0..ix).rev() {
- if let Some(ListEntry::ProjectHeader { path_list, .. }) =
- self.contents.entries.get(i)
+ if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i)
{
- let path_list = path_list.clone();
self.selection = Some(i);
- self.collapsed_groups.insert(path_list);
+ self.collapsed_groups.insert(key.path_list().clone());
self.update_entries(cx);
break;
}
@@ -2314,7 +2321,10 @@ impl Sidebar {
let header_ix = match self.contents.entries.get(ix) {
Some(ListEntry::ProjectHeader { .. }) => Some(ix),
Some(
- ListEntry::Thread(_) | ListEntry::ViewMore { .. } | ListEntry::NewThread { .. },
+ ListEntry::Thread(_)
+ | ListEntry::ViewMore { .. }
+ | ListEntry::NewThread { .. }
+ | ListEntry::DraftThread { .. },
) => (0..ix).rev().find(|&i| {
matches!(
self.contents.entries.get(i),
@@ -2325,15 +2335,14 @@ impl Sidebar {
};
if let Some(header_ix) = header_ix {
- if let Some(ListEntry::ProjectHeader { path_list, .. }) =
- self.contents.entries.get(header_ix)
+ if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
{
- let path_list = path_list.clone();
- if self.collapsed_groups.contains(&path_list) {
- self.collapsed_groups.remove(&path_list);
+ let path_list = key.path_list();
+ if self.collapsed_groups.contains(path_list) {
+ self.collapsed_groups.remove(path_list);
} else {
self.selection = Some(header_ix);
- self.collapsed_groups.insert(path_list);
+ self.collapsed_groups.insert(path_list.clone());
}
self.update_entries(cx);
}
@@ -2347,8 +2356,8 @@ impl Sidebar {
cx: &mut Context<Self>,
) {
for entry in &self.contents.entries {
- if let ListEntry::ProjectHeader { path_list, .. } = entry {
- self.collapsed_groups.insert(path_list.clone());
+ if let ListEntry::ProjectHeader { key, .. } = entry {
+ self.collapsed_groups.insert(key.path_list().clone());
}
}
self.update_entries(cx);
@@ -2413,17 +2422,18 @@ impl Sidebar {
});
// Find the workspace that owns this thread's project group by
- // walking backwards to the nearest ProjectHeader. We must use
- // *this* workspace (not the active workspace) because the user
- // might be archiving a thread in a non-active group.
+ // walking backwards to the nearest ProjectHeader and looking up
+ // an open workspace for that group's path_list.
let group_workspace = current_pos.and_then(|pos| {
- self.contents.entries[..pos]
- .iter()
- .rev()
- .find_map(|e| match e {
- ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()),
- _ => None,
- })
+ let path_list =
+ self.contents.entries[..pos]
+ .iter()
+ .rev()
+ .find_map(|e| match e {
+ ListEntry::ProjectHeader { key, .. } => Some(key.path_list()),
+ _ => None,
+ })?;
+ self.workspace_for_group(path_list, cx)
});
let next_thread = current_pos.and_then(|pos| {
@@ -2538,28 +2548,26 @@ impl Sidebar {
.insert(session_id.clone(), Utc::now());
}
- fn mru_threads_for_switcher(&self, _cx: &App) -> Vec<ThreadSwitcherEntry> {
+ fn mru_threads_for_switcher(&self, cx: &App) -> Vec<ThreadSwitcherEntry> {
let mut current_header_label: Option<SharedString> = None;
- let mut current_header_workspace: Option<Entity<Workspace>> = None;
+ let mut current_header_path_list: Option<PathList> = None;
let mut entries: Vec<ThreadSwitcherEntry> = self
.contents
.entries
.iter()
.filter_map(|entry| match entry {
- ListEntry::ProjectHeader {
- label, workspace, ..
- } => {
+ ListEntry::ProjectHeader { label, key, .. } => {
current_header_label = Some(label.clone());
- current_header_workspace = Some(workspace.clone());
+ current_header_path_list = Some(key.path_list().clone());
None
}
ListEntry::Thread(thread) => {
let workspace = match &thread.workspace {
- ThreadEntryWorkspace::Open(workspace) => workspace.clone(),
- ThreadEntryWorkspace::Closed(_) => {
- current_header_workspace.as_ref()?.clone()
- }
- };
+ ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()),
+ ThreadEntryWorkspace::Closed(_) => current_header_path_list
+ .as_ref()
+ .and_then(|pl| self.workspace_for_group(pl, cx)),
+ }?;
let notified = self
.contents
.is_thread_notified(&thread.metadata.session_id);
@@ -88,14 +88,18 @@ fn setup_sidebar(
sidebar
}
-async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) {
+async fn save_n_test_threads(
+ count: u32,
+ project: &Entity<project::Project>,
+ cx: &mut gpui::VisualTestContext,
+) {
for i in 0..count {
save_thread_metadata(
acp::SessionId::new(Arc::from(format!("thread-{}", i))),
format!("Thread {}", i + 1).into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
None,
- path_list.clone(),
+ project,
cx,
)
}
@@ -104,7 +108,7 @@ async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::Vi
async fn save_test_thread_metadata(
session_id: &acp::SessionId,
- path_list: PathList,
+ project: &Entity<project::Project>,
cx: &mut TestAppContext,
) {
save_thread_metadata(
@@ -112,7 +116,7 @@ async fn save_test_thread_metadata(
"Test".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ project,
cx,
)
}
@@ -120,7 +124,7 @@ async fn save_test_thread_metadata(
async fn save_named_thread_metadata(
session_id: &str,
title: &str,
- path_list: &PathList,
+ project: &Entity<project::Project>,
cx: &mut gpui::VisualTestContext,
) {
save_thread_metadata(
@@ -128,7 +132,7 @@ async fn save_named_thread_metadata(
SharedString::from(title.to_string()),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ project,
cx,
);
cx.run_until_parked();
@@ -139,21 +143,31 @@ fn save_thread_metadata(
title: SharedString,
updated_at: DateTime<Utc>,
created_at: Option<DateTime<Utc>>,
- path_list: PathList,
+ project: &Entity<project::Project>,
cx: &mut TestAppContext,
) {
- let metadata = ThreadMetadata {
- session_id,
- agent_id: agent::ZED_AGENT_ID.clone(),
- title,
- updated_at,
- created_at,
- folder_paths: path_list,
- main_worktree_paths: PathList::default(),
- archived: false,
- };
cx.update(|cx| {
- ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx))
+ let (folder_paths, main_worktree_paths) = {
+ let project_ref = project.read(cx);
+ let paths: Vec<Arc<Path>> = project_ref
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .collect();
+ let folder_paths = PathList::new(&paths);
+ let main_worktree_paths = project_ref.project_group_key(cx).path_list().clone();
+ (folder_paths, main_worktree_paths)
+ };
+ let metadata = ThreadMetadata {
+ session_id,
+ agent_id: agent::ZED_AGENT_ID.clone(),
+ title,
+ updated_at,
+ created_at,
+ folder_paths,
+ main_worktree_paths,
+ archived: false,
+ };
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx));
});
cx.run_until_parked();
}
@@ -193,11 +207,11 @@ fn visible_entries_as_strings(
match entry {
ListEntry::ProjectHeader {
label,
- path_list,
+ key,
highlight_positions: _,
..
} => {
- let icon = if sidebar.collapsed_groups.contains(path_list) {
+ let icon = if sidebar.collapsed_groups.contains(key.path_list()) {
">"
} else {
"v"
@@ -248,6 +262,22 @@ fn visible_entries_as_strings(
format!(" + View More{}", selected)
}
}
+ ListEntry::DraftThread { worktrees, .. } => {
+ let worktree = if worktrees.is_empty() {
+ String::new()
+ } else {
+ let mut seen = Vec::new();
+ let mut chips = Vec::new();
+ for wt in worktrees {
+ if !seen.contains(&wt.name) {
+ seen.push(wt.name.clone());
+ chips.push(format!("{{{}}}", wt.name));
+ }
+ }
+ format!(" {}", chips.join(", "))
+ };
+ format!(" [~ Draft{}]{}", worktree, selected)
+ }
ListEntry::NewThread { worktrees, .. } => {
let worktree = if worktrees.is_empty() {
String::new()
@@ -274,11 +304,14 @@ fn visible_entries_as_strings(
async fn test_serialization_round_trip(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
// Set a custom width, collapse the group, and expand "View More".
sidebar.update_in(cx, |sidebar, window, cx| {
@@ -437,17 +470,15 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Fix crash in project panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
@@ -456,7 +487,7 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
"Add inline diff view".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -478,18 +509,16 @@ async fn test_single_workspace_with_saved_threads(cx: &mut TestAppContext) {
async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
let project = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
// Single workspace with a thread
- let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-a1")),
"Thread A1".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -530,11 +559,10 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
async fn test_view_more_pagination(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(12, &path_list, cx).await;
+ save_n_test_threads(12, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -557,12 +585,15 @@ async fn test_view_more_pagination(cx: &mut TestAppContext) {
async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
// Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse
- save_n_test_threads(17, &path_list, cx).await;
+ save_n_test_threads(17, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -629,11 +660,14 @@ async fn test_view_more_batched_expansion(cx: &mut TestAppContext) {
async fn test_collapse_and_expand_group(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
+
+ let path_list = project.read_with(cx, |project, cx| {
+ project.project_group_key(cx).path_list().clone()
+ });
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -685,9 +719,8 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
s.contents.entries = vec![
// Expanded project header
ListEntry::ProjectHeader {
- path_list: expanded_path.clone(),
+ key: project::ProjectGroupKey::new(None, expanded_path.clone()),
label: "expanded-project".into(),
- workspace: workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
@@ -809,14 +842,13 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
}),
// View More entry
ListEntry::ViewMore {
- path_list: expanded_path.clone(),
+ key: project::ProjectGroupKey::new(None, expanded_path.clone()),
is_fully_expanded: false,
},
// Collapsed project header
ListEntry::ProjectHeader {
- path_list: collapsed_path.clone(),
+ key: project::ProjectGroupKey::new(None, collapsed_path.clone()),
label: "collapsed-project".into(),
- workspace: workspace.clone(),
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
@@ -872,11 +904,10 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -932,11 +963,10 @@ async fn test_keyboard_select_next_and_previous(cx: &mut TestAppContext) {
async fn test_keyboard_select_first_and_last(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(3, &path_list, cx).await;
+ save_n_test_threads(3, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -987,11 +1017,10 @@ async fn test_keyboard_focus_in_does_not_set_selection(cx: &mut TestAppContext)
async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1029,11 +1058,10 @@ async fn test_keyboard_confirm_on_project_header_toggles_collapse(cx: &mut TestA
async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(8, &path_list, cx).await;
+ save_n_test_threads(8, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1064,11 +1092,10 @@ async fn test_keyboard_confirm_on_view_more_expands(cx: &mut TestAppContext) {
async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1109,11 +1136,10 @@ async fn test_keyboard_expand_and_collapse_selected_entry(cx: &mut TestAppContex
async fn test_keyboard_collapse_from_child_selects_parent(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1177,11 +1203,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
- save_n_test_threads(1, &path_list, cx).await;
+ save_n_test_threads(1, &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -1254,15 +1279,13 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, 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 = StubAgentConnection::new();
open_thread_with_connection(&panel, connection.clone(), cx);
send_message(&panel, cx);
let session_id_a = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project, cx).await;
cx.update(|_, cx| {
connection.send_update(
@@ -1281,7 +1304,7 @@ async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
send_message(&panel, cx);
let session_id_b = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id_b, &project, cx).await;
cx.run_until_parked();
@@ -1300,15 +1323,13 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
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, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Open thread on workspace A and keep it generating.
let connection_a = StubAgentConnection::new();
open_thread_with_connection(&panel_a, connection_a.clone(), cx);
send_message(&panel_a, cx);
let session_id_a = active_session_id(&panel_a, cx);
- save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project_a, cx).await;
cx.update(|_, cx| {
connection_a.send_update(
@@ -1358,11 +1379,9 @@ fn type_in_search(sidebar: &Entity<Sidebar>, query: &str, cx: &mut gpui::VisualT
async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [
("t-1", "Fix crash in project panel", 3),
("t-2", "Add inline diff view", 2),
@@ -1373,7 +1392,7 @@ async fn test_search_narrows_visible_threads_to_matches(cx: &mut TestAppContext)
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
}
@@ -1411,17 +1430,15 @@ async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) {
// Search should match case-insensitively so they can still find it.
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Fix Crash In Project Panel".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1453,18 +1470,16 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
// to dismiss the filter and see the full list again.
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] {
save_thread_metadata(
acp::SessionId::new(Arc::from(id)),
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1502,11 +1517,9 @@ async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContex
async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) {
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, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
for (id, title, hour) in [
("a1", "Fix bug in sidebar", 2),
("a2", "Add tests for editor", 1),
@@ -1516,7 +1529,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_a.clone(),
+ &project_a,
cx,
)
}
@@ -1527,7 +1540,8 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
});
cx.run_until_parked();
- let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+ let project_b =
+ multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
for (id, title, hour) in [
("b1", "Refactor sidebar layout", 3),
@@ -1538,7 +1552,7 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_b.clone(),
+ &project_b,
cx,
)
}
@@ -1584,11 +1598,9 @@ async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppC
async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
let project_a = init_test_project("/alpha-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]);
-
for (id, title, hour) in [
("a1", "Fix bug in sidebar", 2),
("a2", "Add tests for editor", 1),
@@ -1598,7 +1610,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_a.clone(),
+ &project_a,
cx,
)
}
@@ -1609,7 +1621,8 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
});
cx.run_until_parked();
- let path_list_b = PathList::new::<std::path::PathBuf>(&[]);
+ let project_b =
+ multi_workspace.read_with(cx, |mw, cx| mw.workspaces()[1].read(cx).project().clone());
for (id, title, hour) in [
("b1", "Refactor sidebar layout", 3),
@@ -1620,7 +1633,7 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list_b.clone(),
+ &project_b,
cx,
)
}
@@ -1686,11 +1699,9 @@ async fn test_search_matches_workspace_name(cx: &mut TestAppContext) {
async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create 8 threads. The oldest one has a unique name and will be
// behind View More (only 5 shown by default).
for i in 0..8u32 {
@@ -1704,7 +1715,7 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1738,17 +1749,15 @@ async fn test_search_finds_threads_hidden_behind_view_more(cx: &mut TestAppConte
async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("thread-1")),
"Important thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1779,11 +1788,9 @@ async fn test_search_finds_threads_inside_collapsed_groups(cx: &mut TestAppConte
async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
for (id, title, hour) in [
("t-1", "Fix crash in panel", 3),
("t-2", "Fix lint warnings", 2),
@@ -1794,7 +1801,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
title.into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
)
}
@@ -1841,7 +1848,7 @@ async fn test_search_then_keyboard_navigate_and_confirm(cx: &mut TestAppContext)
async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
multi_workspace.update_in(cx, |mw, window, cx| {
@@ -1849,14 +1856,12 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
});
cx.run_until_parked();
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("hist-1")),
"Historical Thread".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
cx.run_until_parked();
@@ -1899,17 +1904,15 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
save_thread_metadata(
acp::SessionId::new(Arc::from("t-1")),
"Thread A".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
None,
- path_list.clone(),
+ &project,
cx,
);
@@ -1918,7 +1921,7 @@ async fn test_click_clears_selection_and_focus_in_restores_it(cx: &mut TestAppCo
"Thread B".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
None,
- path_list,
+ &project,
cx,
);
@@ -1966,8 +1969,6 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Hi there!".into()),
@@ -1976,7 +1977,7 @@ async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext)
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2014,8 +2015,6 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
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, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Save a thread so it appears in the list.
let connection_a = StubAgentConnection::new();
connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2024,7 +2023,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
open_thread_with_connection(&panel_a, connection_a, cx);
send_message(&panel_a, cx);
let session_id_a = active_session_id(&panel_a, cx);
- save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id_a, &project_a, cx).await;
// Add a second workspace with its own agent panel.
let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
@@ -2099,8 +2098,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
open_thread_with_connection(&panel_b, connection_b, cx);
send_message(&panel_b, cx);
let session_id_b = active_session_id(&panel_b, cx);
- let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]);
- save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
+ save_test_thread_metadata(&session_id_b, &project_b, cx).await;
cx.run_until_parked();
// Workspace A is currently active. Click a thread in workspace B,
@@ -2161,7 +2159,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
open_thread_with_connection(&panel_b, connection_b2, cx);
send_message(&panel_b, cx);
let session_id_b2 = active_session_id(&panel_b, cx);
- save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
+ save_test_thread_metadata(&session_id_b2, &project_b, cx).await;
cx.run_until_parked();
// Panel B is not the active workspace's panel (workspace A is
@@ -2243,8 +2241,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
-
// Start a thread and send a message so it has history.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2253,7 +2249,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
// Verify the thread appears in the sidebar.
@@ -2287,9 +2283,15 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
// The workspace path_list is now [project-a, project-b]. The active
// thread's metadata was re-saved with the new paths by the agent panel's
// project subscription, so it stays visible under the updated group.
+ // The old [project-a] group persists in the sidebar (empty) because
+ // project_group_keys is append-only.
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [project-a, project-b]", " Hello *",]
+ vec![
+ "v [project-a, project-b]", //
+ " Hello *",
+ "v [project-a]",
+ ]
);
// The "New Thread" button must still be clickable (not stuck in
@@ -2334,8 +2336,6 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create a non-empty thread (has messages).
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2345,7 +2345,7 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
send_message(&panel, cx);
let session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2365,8 +2365,8 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Hello *"],
- "After Cmd-N the sidebar should show a highlighted New Thread entry"
+ vec!["v [my-project]", " [~ Draft]", " Hello *"],
+ "After Cmd-N the sidebar should show a highlighted Draft entry"
);
sidebar.read_with(cx, |sidebar, _cx| {
@@ -2385,8 +2385,6 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
- let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
-
// Create a saved thread so the workspace has history.
let connection = StubAgentConnection::new();
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
@@ -2395,7 +2393,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
open_thread_with_connection(&panel, connection, cx);
send_message(&panel, cx);
let saved_session_id = active_session_id(&panel, cx);
- save_test_thread_metadata(&saved_session_id, path_list.clone(), cx).await;
+ save_test_thread_metadata(&saved_session_id, &project, cx).await;
cx.run_until_parked();
assert_eq!(
@@ -2412,8 +2410,7 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec!["v [my-project]", " [+ New Thread]", " Hello *"],
- "Draft with a server session should still show as [+ New Thread]"
+ vec!["v [my-project]", " [~ Draft]", " Hello *"],
);
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
@@ -2503,17 +2500,12 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
send_message(&worktree_panel, cx);
let session_id = active_session_id(&worktree_panel, cx);
- let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_test_thread_metadata(&session_id, wt_path_list, cx).await;
+ save_test_thread_metadata(&session_id, &worktree_project, cx).await;
cx.run_until_parked();
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Hello {wt-feature-a} *"
- ]
+ vec!["v [project]", " Hello {wt-feature-a} *"]
);
// Simulate Cmd-N in the worktree workspace.
@@ -2529,12 +2521,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project]",
- " [+ New Thread]",
- " [+ New Thread {wt-feature-a}]",
+ " [~ Draft {wt-feature-a}]",
" Hello {wt-feature-a} *"
],
"After Cmd-N in an absorbed worktree, the sidebar should show \
- a highlighted New Thread entry under the main repo header"
+ a highlighted Draft entry under the main repo header"
);
sidebar.read_with(cx, |sidebar, _cx| {
@@ -2586,14 +2577,17 @@ async fn test_search_matches_worktree_name(cx: &mut TestAppContext) {
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
+ let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+ worktree_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
- let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]);
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
- save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await;
- save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await;
+ save_named_thread_metadata("main-t", "Unrelated Thread", &project, cx).await;
+ save_named_thread_metadata("wt-t", "Fix Bug", &worktree_project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2615,13 +2609,17 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
+ let worktree_project = project::Project::test(fs.clone(), ["/wt/rosewood".as_ref()], cx).await;
+ worktree_project
+ .update(cx, |p, cx| p.git_scans_complete(cx))
+ .await;
+
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
// Save a thread against a worktree path that doesn't exist yet.
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]);
- save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await;
+ save_named_thread_metadata("wt-thread", "Worktree Thread", &worktree_project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2650,11 +2648,7 @@ async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) {
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [project]",
- " [+ New Thread]",
- " Worktree Thread {rosewood}",
- ]
+ vec!["v [project]", " Worktree Thread {rosewood}",]
);
}
@@ -2714,10 +2708,8 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
});
let sidebar = setup_sidebar(&multi_workspace, cx);
- let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]);
- save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
- save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await;
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
+ save_named_thread_metadata("thread-b", "Thread B", &project_b, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2748,7 +2740,6 @@ async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppC
visible_entries_as_strings(&sidebar, cx),
vec![
"v [project]",
- " [+ New Thread]",
" Thread A {wt-feature-a}",
" Thread B {wt-feature-b}",
]
@@ -2813,8 +2804,7 @@ async fn test_threadless_workspace_shows_new_thread_with_worktree_chip(cx: &mut
let sidebar = setup_sidebar(&multi_workspace, cx);
// Only save a thread for workspace A.
- let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await;
+ save_named_thread_metadata("thread-a", "Thread A", &project_a, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2894,11 +2884,7 @@ async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext
let sidebar = setup_sidebar(&multi_workspace, cx);
// Save a thread under the same paths as the workspace roots.
- let thread_paths = PathList::new(&[
- std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
- std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"),
- ]);
- save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await;
+ save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -2971,11 +2957,7 @@ async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext
let sidebar = setup_sidebar(&multi_workspace, cx);
// Thread with roots in both repos' "olivetti" worktrees.
- let thread_paths = PathList::new(&[
- std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"),
- std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"),
- ]);
- save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await;
+ save_named_thread_metadata("wt-thread", "Same Branch Thread", &project, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -3070,8 +3052,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
let session_id = active_session_id(&worktree_panel, cx);
// Save metadata so the sidebar knows about this thread.
- let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
- save_test_thread_metadata(&session_id, wt_paths, cx).await;
+ save_test_thread_metadata(&session_id, &worktree_project, cx).await;
// Keep the thread generating by sending a chunk without ending
// the turn.
@@ -3091,7 +3072,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
entries,
vec![
"v [project]",
- " [+ New Thread]",
+ " [~ Draft]",
" Hello {wt-feature-a} * (running)",
]
);
@@ -1,5 +1,6 @@
+use crate::focus_follows_mouse::FocusFollowsMouse as _;
use crate::persistence::model::DockData;
-use crate::{DraggedDock, Event, ModalLayer, Pane};
+use crate::{DraggedDock, Event, FocusFollowsMouse, ModalLayer, Pane, WorkspaceSettings};
use crate::{Workspace, status_bar::StatusItemView};
use anyhow::Context as _;
use client::proto;
@@ -12,7 +13,7 @@ use gpui::{
px,
};
use serde::{Deserialize, Serialize};
-use settings::SettingsStore;
+use settings::{Settings, SettingsStore};
use std::sync::Arc;
use ui::{
ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*,
@@ -252,6 +253,7 @@ pub struct Dock {
is_open: bool,
active_panel_index: Option<usize>,
focus_handle: FocusHandle,
+ focus_follows_mouse: FocusFollowsMouse,
pub(crate) serialized_dock: Option<DockData>,
zoom_layer_open: bool,
modal_layer: Entity<ModalLayer>,
@@ -376,6 +378,7 @@ impl Dock {
active_panel_index: None,
is_open: false,
focus_handle: focus_handle.clone(),
+ focus_follows_mouse: WorkspaceSettings::get_global(cx).focus_follows_mouse,
_subscriptions: [focus_subscription, zoom_subscription],
serialized_dock: None,
zoom_layer_open: false,
@@ -1086,8 +1089,10 @@ impl Render for Dock {
};
div()
+ .id("dock-panel")
.key_context(dispatch_context)
.track_focus(&self.focus_handle(cx))
+ .focus_follows_mouse(self.focus_follows_mouse, cx)
.flex()
.bg(cx.theme().colors().panel_background)
.border_color(cx.theme().colors().border)
@@ -1121,6 +1126,7 @@ impl Render for Dock {
})
} else {
div()
+ .id("dock-panel")
.key_context(dispatch_context)
.track_focus(&self.focus_handle(cx))
}
@@ -0,0 +1,71 @@
+use gpui::{
+ AnyWindowHandle, AppContext as _, Context, FocusHandle, Focusable, Global,
+ StatefulInteractiveElement, Task,
+};
+
+use crate::workspace_settings;
+
+#[derive(Default)]
+struct FfmState {
+ // The window and element to be focused
+ handles: Option<(AnyWindowHandle, FocusHandle)>,
+ // The debounced task which will do the focusing
+ _debounce_task: Option<Task<()>>,
+}
+
+impl Global for FfmState {}
+
+pub trait FocusFollowsMouse<E: Focusable>: StatefulInteractiveElement {
+ fn focus_follows_mouse(
+ self,
+ settings: workspace_settings::FocusFollowsMouse,
+ cx: &Context<E>,
+ ) -> Self {
+ if settings.enabled {
+ self.on_hover(cx.listener(move |this, enter, window, cx| {
+ if *enter {
+ let window_handle = window.window_handle();
+ let focus_handle = this.focus_handle(cx);
+
+ let state = cx.try_global::<FfmState>();
+
+ // Only replace the target if the new handle doesn't contain the existing one.
+ // This ensures that hovering over a parent (e.g., Dock) doesn't override
+ // a more specific child target (e.g., a Pane inside the Dock).
+ let should_replace = state
+ .and_then(|s| s.handles.as_ref())
+ .map(|(_, existing)| !focus_handle.contains(existing, window))
+ .unwrap_or(true);
+
+ if !should_replace {
+ return;
+ }
+
+ let debounce_task = cx.spawn(async move |_this, cx| {
+ cx.background_executor().timer(settings.debounce).await;
+
+ cx.update(|cx| {
+ let state = cx.default_global::<FfmState>();
+ let Some((window, focus)) = state.handles.take() else {
+ return;
+ };
+
+ let _ = cx.update_window(window, move |_view, window, cx| {
+ window.focus(&focus, cx);
+ });
+ });
+ });
+
+ cx.set_global(FfmState {
+ handles: Some((window_handle, focus_handle)),
+ _debounce_task: Some(debounce_task),
+ });
+ }
+ }))
+ } else {
+ self
+ }
+ }
+}
+
+impl<E: Focusable, T: StatefulInteractiveElement> FocusFollowsMouse<E> for T {}
@@ -478,6 +478,26 @@ impl MultiWorkspace {
self.project_group_keys.iter()
}
+ /// Returns the project groups, ordered by most recently added.
+ pub fn project_groups(
+ &self,
+ cx: &App,
+ ) -> impl Iterator<Item = (ProjectGroupKey, Vec<Entity<Workspace>>)> {
+ let mut groups = self
+ .project_group_keys
+ .iter()
+ .rev()
+ .map(|key| (key.clone(), Vec::new()))
+ .collect::<Vec<_>>();
+ for workspace in &self.workspaces {
+ let key = workspace.read(cx).project_group_key(cx);
+ if let Some((_, workspaces)) = groups.iter_mut().find(|(k, _)| k == &key) {
+ workspaces.push(workspace.clone());
+ }
+ }
+ groups.into_iter()
+ }
+
pub fn workspace(&self) -> &Entity<Workspace> {
&self.workspaces[self.active_workspace_index]
}
@@ -2,6 +2,7 @@ use crate::{
CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
WorkspaceItemBuilder, ZoomIn, ZoomOut,
+ focus_follows_mouse::FocusFollowsMouse as _,
invalid_item_view::InvalidItemView,
item::{
ActivateOnClose, ClosePosition, Item, ItemBufferKind, ItemHandle, ItemSettings,
@@ -11,7 +12,7 @@ use crate::{
move_item,
notifications::NotifyResultExt,
toolbar::Toolbar,
- workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
+ workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings},
};
use anyhow::Result;
use collections::{BTreeSet, HashMap, HashSet, VecDeque};
@@ -443,6 +444,7 @@ pub struct Pane {
pinned_tab_count: usize,
diagnostics: HashMap<ProjectPath, DiagnosticSeverity>,
zoom_out_on_close: bool,
+ focus_follows_mouse: FocusFollowsMouse,
diagnostic_summary_update: Task<()>,
/// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here.
pub project_item_restoration_data: HashMap<ProjectItemKind, Box<dyn Any + Send>>,
@@ -615,6 +617,7 @@ impl Pane {
pinned_tab_count: 0,
diagnostics: Default::default(),
zoom_out_on_close: true,
+ focus_follows_mouse: WorkspaceSettings::get_global(cx).focus_follows_mouse,
diagnostic_summary_update: Task::ready(()),
project_item_restoration_data: HashMap::default(),
welcome_page: None,
@@ -782,7 +785,6 @@ impl Pane {
fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let tab_bar_settings = TabBarSettings::get_global(cx);
- let new_max_tabs = WorkspaceSettings::get_global(cx).max_tabs;
if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() {
*display_nav_history_buttons = tab_bar_settings.show_nav_history_buttons;
@@ -795,6 +797,12 @@ impl Pane {
self.nav_history.0.lock().preview_item_id = None;
}
+ let workspace_settings = WorkspaceSettings::get_global(cx);
+
+ self.focus_follows_mouse = workspace_settings.focus_follows_mouse;
+
+ let new_max_tabs = workspace_settings.max_tabs;
+
if self.use_max_tabs && new_max_tabs != self.max_tabs {
self.max_tabs = new_max_tabs;
self.close_items_on_settings_change(window, cx);
@@ -4460,6 +4468,7 @@ impl Render for Pane {
placeholder.child(self.welcome_page.clone().unwrap())
}
}
+ .focus_follows_mouse(self.focus_follows_mouse, cx)
})
.child(
// drag target
@@ -19,6 +19,7 @@ mod security_modal;
pub mod shared_screen;
use db::smol::future::yield_now;
pub use shared_screen::SharedScreen;
+pub mod focus_follows_mouse;
mod status_bar;
pub mod tasks;
mod theme_preview;
@@ -147,8 +148,8 @@ use util::{
};
use uuid::Uuid;
pub use workspace_settings::{
- AutosaveSetting, BottomDockLayout, RestoreOnStartupBehavior, StatusBarSettings, TabBarSettings,
- WorkspaceSettings,
+ AutosaveSetting, BottomDockLayout, FocusFollowsMouse, RestoreOnStartupBehavior,
+ StatusBarSettings, TabBarSettings, WorkspaceSettings,
};
use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
@@ -1,4 +1,4 @@
-use std::num::NonZeroUsize;
+use std::{num::NonZeroUsize, time::Duration};
use crate::DockPosition;
use collections::HashMap;
@@ -35,6 +35,13 @@ pub struct WorkspaceSettings {
pub use_system_window_tabs: bool,
pub zoomed_padding: bool,
pub window_decorations: settings::WindowDecorations,
+ pub focus_follows_mouse: FocusFollowsMouse,
+}
+
+#[derive(Copy, Clone, Deserialize)]
+pub struct FocusFollowsMouse {
+ pub enabled: bool,
+ pub debounce: Duration,
}
#[derive(Copy, Clone, PartialEq, Debug, Default)]
@@ -113,6 +120,20 @@ impl Settings for WorkspaceSettings {
use_system_window_tabs: workspace.use_system_window_tabs.unwrap(),
zoomed_padding: workspace.zoomed_padding.unwrap(),
window_decorations: workspace.window_decorations.unwrap(),
+ focus_follows_mouse: FocusFollowsMouse {
+ enabled: workspace
+ .focus_follows_mouse
+ .unwrap()
+ .enabled
+ .unwrap_or(false),
+ debounce: Duration::from_millis(
+ workspace
+ .focus_follows_mouse
+ .unwrap()
+ .debounce_ms
+ .unwrap_or(250),
+ ),
+ },
}
}
}