From 650afad6ba28d13bcdfba92cf883732a6bc60b7f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 12 Aug 2026 20:41:40 +0700 Subject: [PATCH] add code editor --- .../src/views/repo_detail/browser.rs | 80 +++++++++---------- .../src/views/repo_detail/helpers.rs | 48 ----------- crates/workspace/src/views/repo_detail/mod.rs | 8 +- 3 files changed, 41 insertions(+), 95 deletions(-) diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index f40e981..5429e67 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -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, } -/// 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, + pub(super) state: Entity, } /// 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) { - 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, + ) { + 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) -> 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() } } diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index ade4ac0..cf6b7f4 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -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")); diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 038e98a..0912548 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -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) { + fn open_file(&mut self, path: &str, window: &mut Window, cx: &mut Context) { 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); } } }