add code preview
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user