add code editor

This commit is contained in:
2026-08-12 20:41:40 +07:00
parent 447888e2fb
commit 650afad6ba
3 changed files with 41 additions and 95 deletions
@@ -1,11 +1,12 @@
//! File explorer of the repository detail view: the file tree column and the
//! content column (README / file preview), backed by persistent
//! [`TextViewState`]s for markdown documents and code files.
//! [`TextViewState`]s for markdown documents and persistent [`InputState`]s
//! for code files.
use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, div, px};
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::input::{Input, InputState};
use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
@@ -13,7 +14,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::{code_language, fenced_code, is_markdown_path, placeholder};
use super::helpers::{code_language, is_markdown_path, placeholder};
/// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.;
@@ -46,16 +47,17 @@ 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.
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search.
///
/// 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.
/// parsing happens on a background task inside the editor.
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
pub(super) state: Entity<TextViewState>,
pub(super) state: Entity<InputState>,
}
/// Spinner shown while a document is being loaded/parsed.
@@ -298,22 +300,31 @@ impl RepoDetailView {
.into_any_element()
}
/// Load `text` into the persistent code TextView state for `path`.
/// Load `text` into the persistent code editor 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));
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor. Like [`set_markdown`], the
/// state lives as long as this view, so re-viewing the same file does
/// not re-parse it; the tree-sitter parse runs on a background task
/// inside the editor instead of blocking the main thread.
pub(super) fn set_code(
&mut self,
path: SharedString,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let language = code_language(path.as_ref()).unwrap_or("text");
let state = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
.default_value(text)
});
self.code = Some(CodeView { path, state });
}
/// The persistent code TextView for `path`, or a spinner while the file
/// is being loaded/parsed.
/// The persistent code editor 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();
@@ -322,29 +333,12 @@ impl RepoDetailView {
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"),
)
})
// Disabled: the preview is read-only. Selection, copy and the
// built-in search (Cmd/Ctrl+F) still work; editing is blocked by the
// component's disabled state.
Input::new(&code.state)
.disabled(true)
.h_full()
.into_any_element()
}
}
@@ -100,32 +100,6 @@ pub(super) fn code_language(path: &str) -> Option<&'static str> {
})
}
/// 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.
pub(super) fn is_markdown_path(path: &str) -> bool {
Path::new(path)
@@ -200,28 +174,6 @@ mod tests {
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"));
@@ -284,7 +284,7 @@ impl RepoDetailView {
}
/// Preview the file at `path` (relative to the worktree root).
fn open_file(&mut self, path: &str, _window: &mut Window, cx: &mut Context<Self>) {
fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
if self.files.contains_key(path) || self.loading_files.contains(path) {
@@ -316,7 +316,7 @@ impl RepoDetailView {
self.load_commit(&path, cx);
let generation = self.ref_generation;
let task = cx.spawn(async move |this, cx| {
let task = cx.spawn_in(window, async move |this, cx| {
let path_for_read = path.clone();
let content = cx
.background_spawn(async move {
@@ -338,7 +338,7 @@ impl RepoDetailView {
})
.await;
this.update(cx, |this, cx| {
this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading;
// the result belongs to the previous branch.
if generation != this.ref_generation {
@@ -358,7 +358,7 @@ impl RepoDetailView {
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.set_code(path.clone().into(), text, window, cx);
}
}
}