file_finder: Show collab channels in file search (#51120)

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Lukas Wirth authored and GitHub committed 2026-03-13 07:48:35 +00:00
1 parent 657098eeb4
commit 2cb8a77dbe
7 files changed
+176 -45

No files matched your search

Generated
+3
View File
@@ -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",
+4
View File
@@ -156,6 +156,10 @@ impl ChannelStore {
cx.global::<GlobalChannelStore>().0.clone()
}
pub fn try_global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalChannelStore>().map(|g| g.0.clone())
}
pub fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
let rpc_subscriptions = [
client.add_message_handler(cx.weak_entity(), Self::handle_update_channels),
+9 -2
View File
@@ -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));
+3
View File
@@ -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"] }
+145 -42
View File
@@ -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<FileFinder>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
channel_store: Option<Entity<ChannelStore>>,
search_count: usize,
latest_search_id: usize,
latest_search_did_cancel: bool,
@@ -450,13 +454,18 @@ struct Matches {
matches: Vec<Match>,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
#[derive(Debug, Clone)]
enum Match {
History {
path: FoundPath,
panel_match: Option<ProjectPanelOrdMatch>,
},
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,
};
let a_in_filename = Self::is_filename_match(a_panel_match);
let b_in_filename = Self::is_filename_match(b_panel_match);
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
return a_panel.cmp(b_panel);
}
a_panel_match.cmp(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)
}
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,
}
}
/// Determines if the match occurred within the filename rather than in the path
@@ -833,10 +844,12 @@ impl FileFinderDelegate {
cx: &mut Context<FileFinder>,
) -> 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::<Icon>(file_icon)
.end_slot::<AnyElement>(history_icon)
.end_slot::<AnyElement>(end_icon)
.inset(true)
.toggle_state(selected)
.child(
+3 -1
View File
@@ -3709,7 +3709,7 @@ impl SearchEntries {
fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> 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<FileFinderDelegate>) -> 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);
+9
View File
@@ -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,
[