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