From 7ea8eb3f2e26bc2eb15d1c504b8ebb7d325b3ed6 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 31 Aug 2026 12:04:06 +0000 Subject: [PATCH] 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 --- Cargo.lock | 3 + .../src/edit_prediction_context.rs | 33 ++++++---- crates/editor/src/edit_prediction.rs | 12 ++-- crates/editor/src/editor.rs | 2 +- crates/editor/src/editor_tests.rs | 41 ++++++++++++ crates/outline_panel/src/outline_panel.rs | 57 +++++++++------- crates/tabular_data_preview/Cargo.toml | 5 ++ crates/tabular_data_preview/src/parser.rs | 28 ++++---- .../src/tabular_data_preview.rs | 65 ++++++++++++++++--- 9 files changed, 184 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9087886dd6..386fac6f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18009,9 +18009,12 @@ dependencies = [ "gpui", "log", "picker", + "project", "proptest", + "serde_json", "text", "ui", + "util", "workspace", ] diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 3461e6d341..8bd01b47f5 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -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()); } diff --git a/crates/editor/src/edit_prediction.rs b/crates/editor/src/edit_prediction.rs index 12cf7c111e..1d2f5d6109 100644 --- a/crates/editor/src/edit_prediction.rs +++ b/crates/editor/src/edit_prediction.rs @@ -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", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e04d28073c..8b6cc7b424 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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())); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index faf68af6ff..b359aa9d58 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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, |_| {}); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index ac9a499fd2..b251aaab1d 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -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 { + 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, @@ -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 diff --git a/crates/tabular_data_preview/Cargo.toml b/crates/tabular_data_preview/Cargo.toml index b953b51023..a9d263b130 100644 --- a/crates/tabular_data_preview/Cargo.toml +++ b/crates/tabular_data_preview/Cargo.toml @@ -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 = [] diff --git a/crates/tabular_data_preview/src/parser.rs b/crates/tabular_data_preview/src/parser.rs index c6cda974bc..791d311f66 100644 --- a/crates/tabular_data_preview/src/parser.rs +++ b/crates/tabular_data_preview/src/parser.rs @@ -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, cx: &App) -> Option { + Self::from_extension(editor_file_extension(editor, cx)?) + } + pub(crate) fn from_extension(ext: &str) -> Option { - 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, 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" ); ',' }); diff --git a/crates/tabular_data_preview/src/tabular_data_preview.rs b/crates/tabular_data_preview/src/tabular_data_preview.rs index df84a119be..8ec5689b85 100644 --- a/crates/tabular_data_preview/src/tabular_data_preview.rs +++ b/crates/tabular_data_preview/src/tabular_data_preview.rs @@ -265,15 +265,7 @@ impl TabularDataPreviewPane { } pub fn is_tabular_data_file(editor: &Entity, 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); + }); + } +}