Support CSV preview in project-less set-ups (#63350)

Closes https://github.com/zed-industries/zed/issues/63265

Release Notes:

- Fixed CSV preview not showing in project-less set-ups
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-08-31 12:04:06 +00:00
1 parent 75917d2f73
commit 7ea8eb3f2e
9 files changed
+184 -62

No files matched your search

Generated
+3
View File
@@ -18009,9 +18009,12 @@ dependencies = [
"gpui",
"log",
"picker",
"project",
"proptest",
"serde_json",
"text",
"ui",
"util",
"workspace",
]
@@ -212,23 +212,32 @@ impl RelatedExcerptStore {
position: Anchor,
cx: &mut AsyncApp,
) -> Result<()> {
let (project, snapshot, identifier_line_count) = this.read_with(cx, |this, cx| {
(
this.project.upgrade(),
buffer.read(cx).snapshot(),
this.identifier_line_count,
)
})?;
let (project, snapshot, file_extension, identifier_line_count) =
this.read_with(cx, |this, cx| {
let snapshot = buffer.read(cx).snapshot();
let file_extension = snapshot
.file()
.and_then(|file| {
Some(
Path::new(file.file_name(cx))
.extension()?
.to_string_lossy()
.into_owned(),
)
})
.unwrap_or_default();
(
this.project.upgrade(),
snapshot,
file_extension,
this.identifier_line_count,
)
})?;
let Some(project) = project else {
return Ok(());
};
let file = snapshot.file().cloned();
let file_extension = file
.as_ref()
.and_then(|file| file.path().extension())
.unwrap_or("")
.to_string();
if let Some(file) = &file {
log::debug!("retrieving_context buffer:{}", file.path().as_unix_str());
}
+8 -4
View File
@@ -1638,10 +1638,14 @@ impl Editor {
return;
};
let extension = buffer
.read(cx)
.file()
.and_then(|file| Some(file.path().extension()?.to_string()));
let extension = buffer.read(cx).file().and_then(|file| {
Some(
std::path::Path::new(file.file_name(cx))
.extension()?
.to_string_lossy()
.into_owned(),
)
});
let event_type = match accepted {
true => "Edit Prediction Accepted",
+1 -1
View File
@@ -8913,7 +8913,7 @@ impl Editor {
) {
if let Some(file_stem) = self.active_buffer(cx).and_then(|buffer| {
let file = buffer.read(cx).file()?;
file.path().file_stem()
Path::new(file.file_name(cx)).file_stem()?.to_str()
}) {
cx.write_to_clipboard(ClipboardItem::new_string(file_stem.to_string()));
}
+41
View File
@@ -10648,6 +10648,47 @@ async fn test_copy_file_location_from_multibuffer(cx: &mut TestAppContext) {
);
}
#[gpui::test]
async fn test_copy_file_name_for_external_file(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), json!({ "a.txt": "" })).await;
fs.insert_tree(path!("/elsewhere"), json!({ "external.csv": "a,b\n" }))
.await;
let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/elsewhere/external.csv"), cx)
})
.await
.unwrap();
let (editor, cx) = cx.add_window_view(|window, cx| {
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
});
editor.update_in(cx, |editor, window, cx| {
editor.copy_file_name(&CopyFileName, window, cx);
});
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("external.csv".to_string()),
"copy_file_name should work for a file outside the project"
);
editor.update_in(cx, |editor, window, cx| {
editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx);
});
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("external".to_string()),
"copy_file_name_without_extension should work for a file outside the project"
);
}
#[gpui::test]
async fn test_copy_file_location_across_buffers(cx: &mut TestAppContext) {
init_test(cx, |_| {});
+34 -23
View File
@@ -2357,7 +2357,7 @@ impl OutlinePanel {
let color =
entry_git_aware_label_color(entry.git_summary, entry.is_ignored, is_active);
let icon = if settings.file_icons {
FileIcons::get_icon(entry.path.as_std_path(), cx)
FileIcons::get_icon(Path::new(&name), cx)
.map(|icon_path| Icon::from_path(icon_path).color(color).into_any_element())
} else {
None
@@ -2415,15 +2415,15 @@ impl OutlinePanel {
let (icon, name) = match self.buffer_snapshot_for_id(external_file.buffer_id, cx) {
Some(buffer_snapshot) => match buffer_snapshot.file() {
Some(file) => {
let path = file.path();
let name = file.file_name(cx).to_string();
let icon = if settings.file_icons {
FileIcons::get_icon(path.as_std_path(), cx)
FileIcons::get_icon(Path::new(&name), cx)
} else {
None
}
.map(Icon::from_path)
.map(|icon| icon.color(color).into_any_element());
(icon, file_name(path.as_std_path()))
(icon, name)
}
None => (None, "Untitled".to_string()),
},
@@ -2722,6 +2722,26 @@ impl OutlinePanel {
}
}
fn fs_entry_label(&self, fs_entry: &FsEntry, cx: &App) -> Option<String> {
match fs_entry {
FsEntry::ExternalFile(external) => Some(
self.buffer_snapshot_for_id(external.buffer_id, cx)?
.file()?
.file_name(cx)
.to_string(),
),
FsEntry::Directory(FsEntryDirectory {
worktree_id, entry, ..
})
| FsEntry::File(FsEntryFile {
worktree_id, entry, ..
}) => match entry.path.file_name() {
Some(name) => Some(name.to_string()),
None => Some(self.entry_name(worktree_id, entry, cx)),
},
}
}
fn update_fs_entries(
&mut self,
active_editor: Entity<Editor>,
@@ -4128,10 +4148,13 @@ impl OutlinePanel {
let id = state.entries.len();
match &entry {
PanelEntry::Fs(fs_entry) => {
if let Some(file_name) = self
.relative_path(fs_entry, cx)
.and_then(|path| Some(path.file_name()?.to_string()))
{
let candidate_label = match fs_entry {
FsEntry::Directory(directory) => {
directory.entry.path.file_name().map(str::to_string)
}
_ => self.fs_entry_label(fs_entry, cx),
};
if let Some(file_name) = candidate_label {
state
.match_candidates
.push(StringMatchCandidate::new(id, &file_name));
@@ -4569,21 +4592,9 @@ impl OutlinePanel {
fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 {
let item_text_chars = match entry {
PanelEntry::Fs(FsEntry::ExternalFile(external)) => self
.buffer_snapshot_for_id(external.buffer_id, cx)
.and_then(|snapshot| Some(snapshot.file()?.path().file_name()?.len()))
.unwrap_or_default(),
PanelEntry::Fs(FsEntry::Directory(directory)) => directory
.entry
.path
.file_name()
.map(|name| name.len())
.unwrap_or_default(),
PanelEntry::Fs(FsEntry::File(file)) => file
.entry
.path
.file_name()
.map(|name| name.len())
PanelEntry::Fs(fs_entry) => self
.fs_entry_label(fs_entry, cx)
.map(|label| label.len())
.unwrap_or_default(),
PanelEntry::FoldedDirs(folded_dirs) => {
folded_dirs
+5
View File
@@ -19,7 +19,12 @@ log.workspace = true
text.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
proptest.workspace = true
serde_json.workspace = true
util.workspace = true
workspace = { workspace = true, features = ["test-support"] }
[features]
dev-tools = []
+15 -13
View File
@@ -6,7 +6,8 @@ use crate::{
types::{LineNumber, TableCell},
};
use editor::Editor;
use gpui::{AppContext, Context, Entity, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
use std::path::Path;
use std::time::{Duration, Instant};
use text::BufferSnapshot;
use ui::{SharedString, table_row::TableRow};
@@ -34,11 +35,14 @@ const TABULAR_FORMATS: &[(&str, TabularFormat)] = &[
];
impl TabularFormat {
pub(crate) fn from_editor(editor: &Entity<Editor>, cx: &App) -> Option<Self> {
Self::from_extension(editor_file_extension(editor, cx)?)
}
pub(crate) fn from_extension(ext: &str) -> Option<Self> {
let lower = ext.to_lowercase();
TABULAR_FORMATS
.iter()
.find(|(name, _)| *name == lower)
.find(|(name, _)| name.eq_ignore_ascii_case(ext))
.map(|(_, format)| *format)
}
@@ -52,6 +56,12 @@ impl TabularFormat {
}
}
pub(crate) fn editor_file_extension<'a>(editor: &Entity<Editor>, cx: &'a App) -> Option<&'a str> {
let buffer = editor.read(cx).buffer().read(cx).as_singleton()?;
let file = buffer.read(cx).file()?;
Path::new(file.file_name(cx)).extension()?.to_str()
}
impl TabularDataPreviewPane {
pub(crate) fn parse_from_active_editor(
&mut self,
@@ -99,22 +109,14 @@ impl TabularDataPreviewPane {
.as_singleton()
.map(|b| b.read(cx).text_snapshot());
let extension = editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.and_then(|file| file.path().extension().map(ToOwned::to_owned));
let extension = editor_file_extension(&editor, cx);
let delimiter = extension
.as_deref()
.and_then(TabularFormat::from_extension)
.map(TabularFormat::delimiter)
.unwrap_or_else(|| {
log::warn!(
"unrecognized tabular data extension {:?}, defaulting to comma delimiter",
extension
"unrecognized tabular data extension {extension:?}, defaulting to comma delimiter"
);
','
});
@@ -265,15 +265,7 @@ impl TabularDataPreviewPane {
}
pub fn is_tabular_data_file(editor: &Entity<Editor>, cx: &App) -> bool {
editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.and_then(|file| file.path().extension())
.and_then(parser::TabularFormat::from_extension)
.is_some()
parser::TabularFormat::from_editor(editor, cx).is_some()
}
}
@@ -369,3 +361,58 @@ impl ColumnWidths {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use serde_json::json;
use std::path::Path;
use util::path;
use workspace::AppState;
#[gpui::test]
async fn test_detects_tabular_files_outside_the_project(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({ "inside.csv": "a,b\n1,2\n", "inside.txt": "plain" }),
)
.await;
fs.insert_tree(
path!("/elsewhere"),
json!({ "outside.csv": "a,b\n1,2\n", "outside.txt": "plain" }),
)
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
for (abs_path, expected) in [
(path!("/project/inside.csv"), true),
(path!("/project/inside.txt"), false),
(path!("/elsewhere/outside.csv"), true),
(path!("/elsewhere/outside.txt"), false),
] {
let buffer = project
.update(cx, |project, cx| project.open_local_buffer(abs_path, cx))
.await
.unwrap();
let (editor, _) = cx.add_window_view(|window, cx| {
Editor::for_buffer(buffer, Some(project.clone()), window, cx)
});
let is_tabular =
cx.update(|cx| TabularDataPreviewPane::is_tabular_data_file(&editor, cx));
assert_eq!(is_tabular, expected, "{abs_path}");
}
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
AppState::test(cx);
editor::init(cx);
});
}
}