From aabccdf0992d2770b47d1ae55945ca2a4ff4d5bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 11 Aug 2026 10:56:39 +0700 Subject: [PATCH] add code preview --- Cargo.lock | 407 +++++++++++++++++- Cargo.toml | 4 +- .../src/views/repo_detail/browser.rs | 115 +++-- .../src/views/repo_detail/helpers.rs | 132 +++++- crates/workspace/src/views/repo_detail/mod.rs | 28 +- 5 files changed, 629 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b131831..3c06477 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1018,9 +1018,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -3522,6 +3522,41 @@ dependencies = [ "smallvec", "smol", "tracing", + "tree-sitter", + "tree-sitter-astro-next", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-kotlin-sg", + "tree-sitter-lua", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-php", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-svelte-next", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", "unicode-segmentation", "uuid", "windows 0.58.0", @@ -8035,6 +8070,12 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -8361,7 +8402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -8823,6 +8864,366 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.26.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c567a8e18ae93f20982c90370b16fd24023aeaf52f6052b96957ab253a0fec" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-astro-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-kotlin-sg" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06ec43ae3c12165d4ac08afe4e1f5fc6757ffe274fa7bd5af9007ef11ba4319" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-lua" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea992f4164d83f371ef1239ae178c4d4596c296c09055e9a48bb02a2760403af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-svelte-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/Cargo.toml b/Cargo.toml index 7b1755b..9126736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,9 @@ gpui_macos = { git = "https://github.com/zed-industries/zed" } gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } -gpui-component = { git = "https://github.com/longbridge/gpui-component" } +# `tree-sitter-languages` enables syntax highlighting for the TextView +# code preview (fenced code blocks are highlighted with tree-sitter). +gpui-component = { git = "https://github.com/longbridge/gpui-component", features = ["tree-sitter-languages"] } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index 3841e8a..1513b39 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -1,9 +1,10 @@ //! File explorer of the repository detail view: the file tree column and the -//! content column (README / file preview), backed by a persistent -//! [`TextViewState`] for markdown documents. +//! content column (README / file preview), backed by persistent +//! [`TextViewState`]s for markdown documents and code files. use gpui::prelude::*; use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px}; +use gpui_component::clipboard::Clipboard; use gpui_component::list::ListItem; use gpui_component::spinner::Spinner; use gpui_component::text::{TextView, TextViewState}; @@ -11,7 +12,7 @@ use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use super::RepoDetailView; -use super::helpers::{is_markdown_path, placeholder, plain_lines}; +use super::helpers::{code_language, fenced_code, is_markdown_path, placeholder}; /// Width of the file explorer column. const TREE_WIDTH: f32 = 240.; @@ -44,6 +45,28 @@ pub(super) struct MarkdownView { pub(super) state: Entity, } +/// A code file loaded into a persistent [`TextViewState`], rendered as a +/// fenced code block so the markdown parser syntax-highlights it. +/// +/// Same persistence rationale as [`MarkdownView`]: the state lives as long +/// as this view, so re-viewing the same file does not re-parse it, and +/// parsing happens on a background task. +pub(super) struct CodeView { + /// Source path, relative to the worktree root. + pub(super) path: SharedString, + pub(super) state: Entity, +} + +/// Spinner shown while a document is being loaded/parsed. +fn preview_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new().small()) + .into_any_element() +} + impl RepoDetailView { /// One row of the file tree: icon + name, indented by depth. fn render_tree_item( @@ -150,14 +173,11 @@ impl RepoDetailView { .into_any_element() } else if let Some(path) = selected_file { match self.files.get(path.as_ref()) { - Some(FileContent::Text(text)) => { + Some(FileContent::Text(_)) => { if is_markdown_path(path.as_ref()) { self.markdown_element(Some(path.as_ref()), cx) } else { - v_flex() - .size_full() - .children(plain_lines(text, cx)) - .into_any_element() + self.code_element(path.as_ref(), cx) } } Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx), @@ -197,15 +217,7 @@ impl RepoDetailView { .child(pane_title), ), ) - .child( - div() - .id("repo-content-scroll") - .flex_1() - .min_h_0() - .p_4() - .overflow_y_scroll() - .child(body), - ) + .child(div().id("repo-content").flex_1().min_h_0().child(body)) } /// Load `text` into the persistent markdown TextView state. @@ -228,26 +240,71 @@ impl RepoDetailView { /// The persistent markdown TextView for `path` (`None` = README), or a /// spinner while the document is being loaded/parsed. fn markdown_element(&mut self, path: Option<&str>, _cx: &mut Context) -> AnyElement { - let spinner = || { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new().small()) - .into_any_element() - }; - let Some(md) = &self.md else { - return spinner(); + return preview_spinner(); }; let ready = match path { Some(path) => md.path.as_deref() == Some(path), None => md.path.is_none(), }; if !ready { - return spinner(); + return preview_spinner(); } - TextView::new(&md.state).selectable(true).into_any_element() + TextView::new(&md.state) + .selectable(true) + .scrollable(true) + .p_4() + .into_any_element() + } + + /// Load `text` into the persistent code TextView state for `path`. + /// + /// The code is wrapped in a markdown fence (see [`fenced_code`]) so the + /// TextView renders it as a syntax-highlighted code block. Like + /// [`set_markdown`], the state is created empty and fed via `push_str` + /// so parsing happens on a background task instead of blocking the main + /// thread. + pub(super) fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context) { + let source = fenced_code(text, code_language(path.as_ref())); + let state = cx.new(|cx| TextViewState::markdown("", cx)); + state.update(cx, |state, cx| state.push_str(&source, cx)); + self.code = Some(CodeView { path, state }); + } + + /// The persistent code TextView for `path`, or a spinner while the file + /// is being loaded/parsed. + fn code_element(&mut self, path: &str, _cx: &mut Context) -> AnyElement { + let Some(code) = &self.code else { + return preview_spinner(); + }; + if code.path.as_ref() != path { + return preview_spinner(); + } + + TextView::new(&code.state) + .selectable(true) + .scrollable(true) + .p_4() + .code_block_actions(|code_block, _window, cx| { + let lang = code_block.lang().unwrap_or_default(); + h_flex() + .gap_2() + .items_center() + .when(!lang.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(lang.clone()), + ) + }) + .child( + Clipboard::new(format!("copy-code-{lang}")) + .value(code_block.code()) + .tooltip("Copy code"), + ) + }) + .into_any_element() } } diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index 0972756..ade4ac0 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -1,5 +1,5 @@ -//! Pure helpers for the repository detail view: file-tree building, plain -//! text rendering and small element builders. +//! Pure helpers for the repository detail view: file-tree building, code +//! preview helpers and small element builders. use std::path::{Path, PathBuf}; @@ -45,20 +45,85 @@ fn insert_path(items: &mut Vec, parts: &[String], prefix: &str) { } } -/// Render text with one element per line, preserving blank lines. -pub(super) fn plain_lines(text: &str, cx: &App) -> Vec { - text.lines() - .map(|line| { - // A space keeps empty lines from collapsing to zero height. - let text = if line.is_empty() { " " } else { line }; - div() - .font_family(cx.theme().mono_font_family.clone()) - .text_xs() - .text_color(cx.theme().foreground) - .child(text.to_string()) - .into_any_element() - }) - .collect() +/// The markdown fence language for a file path, or `None` for plain text. +/// +/// Names are chosen so `gpui_component`'s highlighter can resolve them +/// (`highlighter::Language::from_name` accepts short aliases such as `rs` +/// and `js`). +pub(super) fn code_language(path: &str) -> Option<&'static str> { + let name = Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + // Some common files are recognized by name rather than extension. + match name { + "Makefile" | "makefile" => return Some("make"), + "CMakeLists.txt" => return Some("cmake"), + _ => {} + } + + let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "rs" => "rust", + "toml" => "toml", + "json" | "jsonc" => "json", + "py" => "python", + "js" | "mjs" | "cjs" => "javascript", + "ts" | "mts" | "cts" => "typescript", + "tsx" | "jsx" => "tsx", + "go" => "go", + "c" | "h" => "c", + "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "java" => "java", + "kt" | "kts" | "ktm" => "kotlin", + "swift" => "swift", + "php" | "phtml" => "php", + "rb" => "ruby", + "sh" | "bash" | "zsh" => "bash", + "yml" | "yaml" => "yaml", + "css" | "scss" | "sass" => "css", + "html" | "htm" => "html", + "lua" => "lua", + "sql" => "sql", + "proto" | "protobuf" => "proto", + "cmake" => "cmake", + "zig" => "zig", + "ex" | "exs" => "elixir", + "graphql" | "gql" => "graphql", + "diff" | "patch" => "diff", + "svelte" => "svelte", + "astro" => "astro", + "scala" => "scala", + _ => return None, + }) +} + +/// Wrap `code` in a fenced markdown code block tagged with `lang`, so the +/// markdown [`TextViewState`] renders it as a syntax-highlighted code block. +/// +/// The fence is one backtick longer than the longest run of backticks in +/// `code`, so the content can never close the block early. +pub(super) fn fenced_code(code: &str, lang: Option<&str>) -> String { + // Split on non-backtick characters so the segments are runs of backticks. + let longest_run = code.split(|c| c != '`').map(str::len).max().unwrap_or(0); + let fence = "`".repeat((longest_run + 1).max(3)); + + let mut out = + String::with_capacity(code.len() + fence.len() * 2 + lang.map_or(1, |lang| lang.len() + 2)); + out.push_str(&fence); + if let Some(lang) = lang { + out.push(' '); + out.push_str(lang); + } + out.push('\n'); + out.push_str(code); + if !code.ends_with('\n') { + out.push('\n'); + } + out.push_str(&fence); + out } /// Whether a file path has a markdown extension. @@ -134,4 +199,39 @@ mod tests { assert_eq!(items[0].children[0].id, "a/b"); assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); } + + #[test] + fn fenced_code_wraps_in_triple_backticks() { + let out = fenced_code("fn main() {}\n", Some("rust")); + assert_eq!(out, "``` rust\nfn main() {}\n```"); + } + + #[test] + fn fenced_code_uses_longer_fence_than_content() { + let code = "let x = \"```\";\n`code`"; + let out = fenced_code(code, None); + // The longest run of backticks in `code` is 3, so the fence is 4. + assert!(out.starts_with("````\n")); + assert!(out.ends_with("````")); + assert!(out.contains(code)); + } + + #[test] + fn fenced_code_keeps_trailing_newline() { + assert_eq!(fenced_code("a\n", None), "```\na\n```"); + assert_eq!(fenced_code("a", None), "```\na\n```"); + } + + #[test] + fn code_language_maps_extensions_and_names() { + assert_eq!(code_language("src/main.rs"), Some("rust")); + assert_eq!(code_language("Cargo.toml"), Some("toml")); + assert_eq!(code_language("app.js"), Some("javascript")); + assert_eq!(code_language("index.tsx"), Some("tsx")); + assert_eq!(code_language("Makefile"), Some("make")); + assert_eq!(code_language("CMakeLists.txt"), Some("cmake")); + assert_eq!(code_language("data.csv"), None); + assert_eq!(code_language("LICENSE"), None); + assert_eq!(code_language("README.md"), None); + } } diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 0267fb0..73b300b 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -18,7 +18,7 @@ use signed_state::{GitStore, RepoStore}; mod browser; mod helpers; -use browser::{FileContent, MAX_PREVIEW_BYTES, MarkdownView}; +use browser::{CodeView, FileContent, MAX_PREVIEW_BYTES, MarkdownView}; use helpers::{build_tree_items, is_markdown_path}; /// Detail view of a repository: header, stats, a file explorer with README @@ -34,6 +34,8 @@ pub struct RepoDetailView { worktree: Option, /// Markdown document currently in the preview pane (README or a file). md: Option, + /// Code file currently in the preview pane. + code: Option, readme_name: Option, /// Currently previewed file (relative path) and its contents. selected_file: Option, @@ -63,6 +65,7 @@ impl RepoDetailView { tree_state, worktree: None, md: None, + code: None, readme_name: None, selected_file: None, files: HashMap::new(), @@ -150,9 +153,11 @@ impl RepoDetailView { Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }); + let Some(worktree) = self.worktree.clone() else { return; }; + if unsafe_path { return; } @@ -186,13 +191,19 @@ impl RepoDetailView { this.loading_files.remove(&path); match content { Ok(kind) => { - if let FileContent::Text(text) = &kind - && is_markdown_path(&path) - { - let same = this.md.as_ref().map(|md| md.path.as_deref()) - == Some(Some(path.as_str())); - if !same { - this.set_markdown(Some(path.clone().into()), text, cx); + if let FileContent::Text(text) = &kind { + if is_markdown_path(&path) { + let same = this.md.as_ref().map(|md| md.path.as_deref()) + == Some(Some(path.as_str())); + if !same { + this.set_markdown(Some(path.clone().into()), text, cx); + } + } else { + let same = this.code.as_ref().map(|code| code.path.as_str()) + == Some(path.as_str()); + if !same { + this.set_code(path.clone().into(), text, cx); + } } } this.files.insert(path, kind); @@ -280,6 +291,7 @@ impl Render for RepoDetailView { .pt_2() .pb_4() .w_full() + .items_start() .justify_between() .border_b_1() .border_color(cx.theme().border)