From e4b6286a63143f21ed3e825126afa9193d8b12a6 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 13 Mar 2026 08:48:35 +0100 Subject: [PATCH] file_finder: Show collab channels in file search (#51120) Release Notes: - N/A *or* Added/Fixed/Improved ... --- Cargo.lock | 3 + crates/channel/src/channel_store.rs | 4 + crates/collab_ui/src/collab_panel.rs | 11 +- crates/file_finder/Cargo.toml | 3 + crates/file_finder/src/file_finder.rs | 185 +++++++++++++++----- crates/file_finder/src/file_finder_tests.rs | 4 +- crates/workspace/src/workspace.rs | 9 + 7 files changed, 175 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6570398f5b22f2248a9cd59f84d2cf70080c3591..4e347d40f3f0e0f23f48770537e7df92d8bd862a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6242,6 +6242,8 @@ name = "file_finder" version = "0.1.0" dependencies = [ "anyhow", + "channel", + "client", "collections", "ctor", "editor", @@ -6255,6 +6257,7 @@ dependencies = [ "pretty_assertions", "project", "project_panel", + "remote_connection", "serde", "serde_json", "settings", diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a9357a765a75443e18efb1e6f31cdfab313ebcce..f8d28ac96d7c140141ac520b1c38a10c82dd75a9 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -156,6 +156,10 @@ impl ChannelStore { cx.global::().0.clone() } + pub fn try_global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } + pub fn new(client: Arc, user_store: Entity, cx: &mut Context) -> Self { let rpc_subscriptions = [ client.add_message_handler(cx.weak_entity(), Self::handle_update_channels), diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 0ec5d03a478ba42d438f57ae2f4fdea9f34d1b50..d0cac2e69f8d8c5b3fde588cc4ceee92d64962d7 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -36,8 +36,8 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare, - ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, OpenChannelNotesById, + ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -114,6 +114,13 @@ pub fn init(cx: &mut App) { }); } }); + workspace.register_action(|_, action: &OpenChannelNotesById, window, cx| { + let channel_id = client::ChannelId(action.channel_id); + let workspace = cx.entity(); + window.defer(cx, move |window, cx| { + ChannelView::open(channel_id, None, workspace, window, cx).detach_and_log_err(cx) + }); + }); // TODO: make it possible to bind this one to a held key for push to talk? // how to make "toggle_on_modifiers_press" contextual? workspace.register_action(|_, _: &Mute, _, cx| title_bar::collab::toggle_mute(cx)); diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 113bf68d34f778f8fba9fdc62b586c31e689a380..0876d95a7b044d2a4ce5bf8be964c4057725f827 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -14,6 +14,8 @@ doctest = false [dependencies] anyhow.workspace = true +channel.workspace = true +client.workspace = true collections.workspace = true editor.workspace = true file_icons.workspace = true @@ -45,3 +47,4 @@ serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true +remote_connection = { workspace = true, features = ["test-support"] } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a1e64964ff578ed263e9e89a610997423f33f7c0..cd0c4dbdb922c6d8251225c696b60e27eb5951cf 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -4,10 +4,12 @@ mod file_finder_tests; use futures::future::join_all; pub use open_path_prompt::OpenPathDelegate; +use channel::ChannelStore; +use client::ChannelId; use collections::HashMap; use editor::Editor; use file_icons::FileIcons; -use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; +use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity, @@ -45,8 +47,8 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings, - notifications::NotifyResultExt, pane, + ModalView, OpenChannelNotesById, OpenOptions, OpenVisible, SplitDirection, Workspace, + item::PreviewTabsSettings, notifications::NotifyResultExt, pane, }; use zed_actions::search::ToggleIncludeIgnored; @@ -321,7 +323,7 @@ impl FileFinder { if let Some(workspace) = delegate.workspace.upgrade() && let Some(m) = delegate.matches.get(delegate.selected_index()) { - let path = match &m { + let path = match m { Match::History { path, .. } => { let worktree_id = path.project.worktree_id; ProjectPath { @@ -334,6 +336,7 @@ impl FileFinder { path: m.0.path.clone(), }, Match::CreateNew(p) => p.clone(), + Match::Channel { .. } => return, }; let open_task = workspace.update(cx, move |workspace, cx| { workspace.split_path_preview(path, false, Some(split_direction), window, cx) @@ -392,6 +395,7 @@ pub struct FileFinderDelegate { file_finder: WeakEntity, workspace: WeakEntity, project: Entity, + channel_store: Option>, search_count: usize, latest_search_id: usize, latest_search_did_cancel: bool, @@ -450,13 +454,18 @@ struct Matches { matches: Vec, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +#[derive(Debug, Clone)] enum Match { History { path: FoundPath, panel_match: Option, }, Search(ProjectPanelOrdMatch), + Channel { + channel_id: ChannelId, + channel_name: SharedString, + string_match: StringMatch, + }, CreateNew(ProjectPath), } @@ -465,7 +474,7 @@ impl Match { match self { Match::History { path, .. } => Some(&path.project.path), Match::Search(panel_match) => Some(&panel_match.0.path), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -479,7 +488,7 @@ impl Match { .read(cx) .absolutize(&path_match.path), ), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } @@ -487,7 +496,7 @@ impl Match { match self { Match::History { panel_match, .. } => panel_match.as_ref(), Match::Search(panel_match) => Some(panel_match), - Match::CreateNew(_) => None, + Match::Channel { .. } | Match::CreateNew(_) => None, } } } @@ -628,7 +637,6 @@ impl Matches { (_, Match::CreateNew(_)) => return cmp::Ordering::Greater, _ => {} } - debug_assert!(a.panel_match().is_some() && b.panel_match().is_some()); match (&a, &b) { // bubble currently opened files to the top @@ -651,32 +659,35 @@ impl Matches { } } - let a_panel_match = match a.panel_match() { - Some(pm) => pm, - None => { - return if b.panel_match().is_some() { - cmp::Ordering::Less - } else { - cmp::Ordering::Equal - }; + // For file-vs-file matches, use the existing detailed comparison. + if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) { + let a_in_filename = Self::is_filename_match(a_panel); + let b_in_filename = Self::is_filename_match(b_panel); + + match (a_in_filename, b_in_filename) { + (true, false) => return cmp::Ordering::Greater, + (false, true) => return cmp::Ordering::Less, + _ => {} } - }; - let b_panel_match = match b.panel_match() { - Some(pm) => pm, - None => return cmp::Ordering::Greater, - }; + return a_panel.cmp(b_panel); + } - let a_in_filename = Self::is_filename_match(a_panel_match); - let b_in_filename = Self::is_filename_match(b_panel_match); + let a_score = Self::match_score(a); + let b_score = Self::match_score(b); + // When at least one side is a channel, compare by raw score. + a_score + .partial_cmp(&b_score) + .unwrap_or(cmp::Ordering::Equal) + } - match (a_in_filename, b_in_filename) { - (true, false) => return cmp::Ordering::Greater, - (false, true) => return cmp::Ordering::Less, - _ => {} // Both are filename matches or both are path matches + fn match_score(m: &Match) -> f64 { + match m { + Match::History { panel_match, .. } => panel_match.as_ref().map_or(0.0, |pm| pm.0.score), + Match::Search(pm) => pm.0.score, + Match::Channel { string_match, .. } => string_match.score, + Match::CreateNew(_) => 0.0, } - - a_panel_match.cmp(b_panel_match) } /// Determines if the match occurred within the filename rather than in the path @@ -833,10 +844,12 @@ impl FileFinderDelegate { cx: &mut Context, ) -> Self { Self::subscribe_to_updates(&project, window, cx); + let channel_store = ChannelStore::try_global(cx); Self { file_finder, workspace, project, + channel_store, search_count: 0, latest_search_id: 0, latest_search_did_cancel: false, @@ -971,6 +984,68 @@ impl FileFinderDelegate { path_style, ); + // Add channel matches + if let Some(channel_store) = &self.channel_store { + let channel_store = channel_store.read(cx); + let channels: Vec<_> = channel_store.channels().cloned().collect(); + if !channels.is_empty() { + let candidates = channels + .iter() + .enumerate() + .map(|(id, channel)| StringMatchCandidate::new(id, &channel.name)); + let channel_query = query.path_query(); + let query_lower = channel_query.to_lowercase(); + let mut channel_matches = Vec::new(); + for candidate in candidates { + let channel_name = candidate.string; + let name_lower = channel_name.to_lowercase(); + + let mut positions = Vec::new(); + let mut query_idx = 0; + for (name_idx, name_char) in name_lower.char_indices() { + if query_idx < query_lower.len() { + let query_char = + query_lower[query_idx..].chars().next().unwrap_or_default(); + if name_char == query_char { + positions.push(name_idx); + query_idx += query_char.len_utf8(); + } + } + } + + if query_idx == query_lower.len() { + let channel = &channels[candidate.id]; + let score = if name_lower == query_lower { + 1.0 + } else if name_lower.starts_with(&query_lower) { + 0.8 + } else { + 0.5 * (query_lower.len() as f64 / name_lower.len() as f64) + }; + channel_matches.push(Match::Channel { + channel_id: channel.id, + channel_name: channel.name.clone(), + string_match: StringMatch { + candidate_id: candidate.id, + score, + positions, + string: channel_name, + }, + }); + } + } + for channel_match in channel_matches { + match self + .matches + .position(&channel_match, self.currently_opened_path.as_ref()) + { + Ok(_duplicate) => {} + Err(ix) => self.matches.matches.insert(ix, channel_match), + } + } + } + } + let query_path = query.raw_query.as_str(); if let Ok(mut query_path) = RelPath::new(Path::new(query_path), path_style) { let available_worktree = self @@ -1095,6 +1170,16 @@ impl FileFinderDelegate { } } Match::Search(path_match) => self.labels_for_path_match(&path_match.0, path_style), + Match::Channel { + channel_name, + string_match, + .. + } => ( + channel_name.to_string(), + string_match.positions.clone(), + "Channel Notes".to_string(), + vec![], + ), Match::CreateNew(project_path) => ( format!("Create file: {}", project_path.path.display(path_style)), vec![], @@ -1479,6 +1564,16 @@ impl PickerDelegate for FileFinderDelegate { if let Some(m) = self.matches.get(self.selected_index()) && let Some(workspace) = self.workspace.upgrade() { + // Channel matches are handled separately since they dispatch an action + // rather than directly opening a file path. + if let Match::Channel { channel_id, .. } = m { + let channel_id = channel_id.0; + let finder = self.file_finder.clone(); + window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); + finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + return; + } + let open_task = workspace.update(cx, |workspace, cx| { let split_or_open = |workspace: &mut Workspace, @@ -1571,6 +1666,7 @@ impl PickerDelegate for FileFinderDelegate { window, cx, ), + Match::Channel { .. } => unreachable!("handled above"), } }); @@ -1627,7 +1723,7 @@ impl PickerDelegate for FileFinderDelegate { let path_match = self.matches.get(ix)?; - let history_icon = match &path_match { + let end_icon = match path_match { Match::History { .. } => Icon::new(IconName::HistoryRerun) .color(Color::Muted) .size(IconSize::Small) @@ -1636,6 +1732,10 @@ impl PickerDelegate for FileFinderDelegate { .flex_none() .size(IconSize::Small.rems()) .into_any_element(), + Match::Channel { .. } => v_flex() + .flex_none() + .size(IconSize::Small.rems()) + .into_any_element(), Match::CreateNew(_) => Icon::new(IconName::Plus) .color(Color::Muted) .size(IconSize::Small) @@ -1643,21 +1743,24 @@ impl PickerDelegate for FileFinderDelegate { }; let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx); - let file_icon = maybe!({ - if !settings.file_icons { - return None; - } - let abs_path = path_match.abs_path(&self.project, cx)?; - let file_name = abs_path.file_name()?; - let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; - Some(Icon::from_path(icon).color(Color::Muted)) - }); + let file_icon = match path_match { + Match::Channel { .. } => Some(Icon::new(IconName::Hash).color(Color::Muted)), + _ => maybe!({ + if !settings.file_icons { + return None; + } + let abs_path = path_match.abs_path(&self.project, cx)?; + let file_name = abs_path.file_name()?; + let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; + Some(Icon::from_path(icon).color(Color::Muted)) + }), + }; Some( ListItem::new(ix) .spacing(ListItemSpacing::Sparse) .start_slot::(file_icon) - .end_slot::(history_icon) + .end_slot::(end_icon) .inset(true) .toggle_state(selected) .child( diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index c81d13420b179cc7ce0d8afd2aee26673673f09e..da9fd4b87b045a6321a291cb7128a051d977815b 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3709,7 +3709,7 @@ impl SearchEntries { fn collect_search_matches(picker: &Picker) -> SearchEntries { let mut search_entries = SearchEntries::default(); for m in &picker.delegate.matches.matches { - match &m { + match m { Match::History { path: history_path, panel_match: path_match, @@ -3734,6 +3734,7 @@ fn collect_search_matches(picker: &Picker) -> SearchEntries search_entries.search_matches.push(path_match.0.clone()); } Match::CreateNew(_) => {} + Match::Channel { .. } => {} } } search_entries @@ -3768,6 +3769,7 @@ fn assert_match_at_position( Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()), Match::Search(path_match) => path_match.0.path.file_name(), Match::CreateNew(project_path) => project_path.path.file_name(), + Match::Channel { channel_name, .. } => Some(channel_name.as_str()), } .unwrap(); assert_eq!(match_file_name, expected_file_name); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 19d02e9a8a6742ba04bc52a68568cb2bf994608a..7696af97996a83db0aab05dc11d03f6ac0a77513 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -8330,6 +8330,15 @@ actions!( CopyRoomId, ] ); + +/// Opens the channel notes for a specific channel by its ID. +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[action(namespace = collab)] +#[serde(deny_unknown_fields)] +pub struct OpenChannelNotesById { + pub channel_id: u64, +} + actions!( zed, [