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 //! File explorer of the repository detail view: the file tree column and the
//! content column (README / file preview), backed by persistent //! 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::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::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard; use gpui_component::input::{Input, InputState};
use gpui_component::list::ListItem; use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState}; 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 gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use super::RepoDetailView; 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. /// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.; const TREE_WIDTH: f32 = 240.;
@@ -46,16 +47,17 @@ pub(super) struct MarkdownView {
pub(super) state: Entity<TextViewState>, pub(super) state: Entity<TextViewState>,
} }
/// A code file loaded into a persistent [`TextViewState`], rendered as a /// A code file loaded into a persistent [`InputState`], rendered as a
/// fenced code block so the markdown parser syntax-highlights it. /// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search.
/// ///
/// Same persistence rationale as [`MarkdownView`]: the state lives as long /// 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 /// 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 { pub(super) struct CodeView {
/// Source path, relative to the worktree root. /// Source path, relative to the worktree root.
pub(super) path: SharedString, pub(super) path: SharedString,
pub(super) state: Entity<TextViewState>, pub(super) state: Entity<InputState>,
} }
/// Spinner shown while a document is being loaded/parsed. /// Spinner shown while a document is being loaded/parsed.
@@ -298,22 +300,31 @@ impl RepoDetailView {
.into_any_element() .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 /// The state is created in code editor mode so the Input renders it as
/// TextView renders it as a syntax-highlighted code block. Like /// a syntax-highlighted, read-only editor. Like [`set_markdown`], the
/// [`set_markdown`], the state is created empty and fed via `push_str` /// state lives as long as this view, so re-viewing the same file does
/// so parsing happens on a background task instead of blocking the main /// not re-parse it; the tree-sitter parse runs on a background task
/// thread. /// inside the editor instead of blocking the main thread.
pub(super) fn set_code(&mut self, path: SharedString, text: &str, cx: &mut Context<Self>) { pub(super) fn set_code(
let source = fenced_code(text, code_language(path.as_ref())); &mut self,
let state = cx.new(|cx| TextViewState::markdown("", cx)); path: SharedString,
state.update(cx, |state, cx| state.push_str(&source, cx)); 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 }); self.code = Some(CodeView { path, state });
} }
/// The persistent code TextView for `path`, or a spinner while the file /// The persistent code editor for `path`, or a spinner while the file is
/// is being loaded/parsed. /// being loaded/parsed.
fn code_element(&mut self, path: &str, _cx: &mut Context<Self>) -> AnyElement { fn code_element(&mut self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else { let Some(code) = &self.code else {
return preview_spinner(); return preview_spinner();
@@ -322,29 +333,12 @@ impl RepoDetailView {
return preview_spinner(); return preview_spinner();
} }
TextView::new(&code.state) // Disabled: the preview is read-only. Selection, copy and the
.selectable(true) // built-in search (Cmd/Ctrl+F) still work; editing is blocked by the
.scrollable(true) // component's disabled state.
.p_4() Input::new(&code.state)
.code_block_actions(|code_block, _window, cx| { .disabled(true)
let lang = code_block.lang().unwrap_or_default(); .h_full()
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() .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. /// Whether a file path has a markdown extension.
pub(super) fn is_markdown_path(path: &str) -> bool { pub(super) fn is_markdown_path(path: &str) -> bool {
Path::new(path) Path::new(path)
@@ -200,28 +174,6 @@ mod tests {
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt"); 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] #[test]
fn code_language_maps_extensions_and_names() { fn code_language_maps_extensions_and_names() {
assert_eq!(code_language("src/main.rs"), Some("rust")); 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). /// 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()); self.selected_file = Some(path.into());
if self.files.contains_key(path) || self.loading_files.contains(path) { if self.files.contains_key(path) || self.loading_files.contains(path) {
@@ -316,7 +316,7 @@ impl RepoDetailView {
self.load_commit(&path, cx); self.load_commit(&path, cx);
let generation = self.ref_generation; 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 path_for_read = path.clone();
let content = cx let content = cx
.background_spawn(async move { .background_spawn(async move {
@@ -338,7 +338,7 @@ impl RepoDetailView {
}) })
.await; .await;
this.update(cx, |this, cx| { this.update_in(cx, |this, window, cx| {
// The worktree was switched while this file was reading; // The worktree was switched while this file was reading;
// the result belongs to the previous branch. // the result belongs to the previous branch.
if generation != this.ref_generation { if generation != this.ref_generation {
@@ -358,7 +358,7 @@ impl RepoDetailView {
let same = this.code.as_ref().map(|code| code.path.as_str()) let same = this.code.as_ref().map(|code| code.path.as_str())
== Some(path.as_str()); == Some(path.as_str());
if !same { if !same {
this.set_code(path.clone().into(), text, cx); this.set_code(path.clone().into(), text, window, cx);
} }
} }
} }