feat: out-of-box experience #2
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user