add code preview

This commit is contained in:
2026-08-11 10:56:39 +07:00
parent 91a76a6f52
commit aabccdf099
5 changed files with 629 additions and 57 deletions
@@ -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<TextViewState>,
}
/// 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<TextViewState>,
}
/// 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<Self>) -> 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<Self>) {
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<Self>) -> 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()
}
}
+116 -16
View File
@@ -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<TreeItem>, parts: &[String], prefix: &str) {
}
}
/// Render text with one element per line, preserving blank lines.
pub(super) fn plain_lines(text: &str, cx: &App) -> Vec<AnyElement> {
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);
}
}
+20 -8
View File
@@ -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<PathBuf>,
/// Markdown document currently in the preview pane (README or a file).
md: Option<MarkdownView>,
/// Code file currently in the preview pane.
code: Option<CodeView>,
readme_name: Option<SharedString>,
/// Currently previewed file (relative path) and its contents.
selected_file: Option<SharedString>,
@@ -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)