Files
signed/crates/workspace/src/views/repo_detail/browser.rs
T

298 lines
10 KiB
Rust

use gpui::prelude::*;
use gpui::{AnyElement, Context, Entity, SharedString, WeakEntity, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Editor, EditorState};
use gpui_component::list::ListItem;
use gpui_component::spinner::Spinner;
use gpui_component::text::{TextView, TextViewState};
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use signed_ui::{placeholder, tree_row};
use super::RepoDetailView;
use super::helpers::{code_language, is_markdown_path};
/// Width of the file explorer column.
const TREE_WIDTH: f32 = 240.;
/// Files larger than this are not previewed.
pub(super) const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
/// Preview cache caps: at most this many files (or this many text bytes)
/// are kept in memory at once; the oldest previews are evicted beyond that.
pub(super) const MAX_PREVIEWED_FILES: usize = 32;
pub(super) const MAX_PREVIEW_CACHE_BYTES: usize = 8 * 1024 * 1024;
/// Preview state of a browsed file.
pub(super) enum FileContent {
/// Decodable text content.
Text(String),
/// Not valid UTF-8.
Binary,
/// Bigger than [`MAX_PREVIEW_BYTES`].
TooLarge,
/// Reading failed.
Failed(String),
}
/// A markdown document loaded into a persistent [`TextViewState`].
///
/// The state is owned by the view rather than created per render: GPUI
/// drops keyed element state after one absent frame, which would re-parse
/// the whole document on every pane switch (README / file / spinner).
pub(super) struct MarkdownView {
/// Source path; `None` means the repository README.
pub(super) path: Option<SharedString>,
pub(super) state: Entity<TextViewState>,
}
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search. Persistent for the same reason as [`MarkdownView`].
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
pub(super) state: Entity<EditorState>,
}
/// Spinner shown while a document is being loaded/parsed.
fn preview_spinner() -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
}
impl RepoDetailView {
/// One row of the file tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let view = view.clone();
let id = entry.item().id.clone();
tree_row(ix, entry, selected, move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_file(&id, window, cx));
}
})
}
/// Left column: the file tree.
pub(super) fn render_tree_column(
tree_state: Entity<TreeState>,
view: WeakEntity<Self>,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.p_2()
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(div().flex_1().min_h_0().child(tree(
&tree_state,
move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
},
)))
}
/// Right column: README, selected file preview, or status text.
pub(super) fn render_content_column(
&self,
pane_title: SharedString,
cx: &mut Context<Self>,
) -> impl IntoElement {
let loading = self.loading;
let error = self.error.clone();
let selected_file = self.selected_file.clone();
let body: AnyElement = if loading {
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(Spinner::new().small())
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Cloning repository..."),
)
.into_any_element()
} else if let Some(error) = error {
v_flex()
.size_full()
.items_center()
.justify_center()
.p_4()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(error),
)
.into_any_element()
} else if let Some(path) = selected_file {
match self.files.get(path.as_ref()) {
Some(FileContent::Text(_)) => {
if is_markdown_path(path.as_ref()) {
self.markdown_element(Some(path.as_ref()), cx)
} else {
self.code_element(path.as_ref(), cx)
}
}
Some(FileContent::Binary) => placeholder("Binary file — preview not supported", cx),
Some(FileContent::TooLarge) => placeholder("File is too large to preview", cx),
Some(FileContent::Failed(message)) => placeholder(message, cx),
None => preview_spinner(),
}
} else if self.readme_name.is_some() {
self.markdown_element(None, cx)
} else {
placeholder("No README found", cx)
};
// Latest commit for the current pane: the selected file, or the README
// while nothing is selected. Computed after the body above, which
// needs `&mut self`.
let commit = match &self.selected_file {
Some(path) => self.commits.get(path.as_ref()),
None => self
.readme_name
.as_ref()
.and_then(|name| self.commits.get(name.as_ref())),
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.bg(cx.theme().muted)
.border_b(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(
div()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(pane_title),
)
.when_some(commit, |this, commit| {
this.child(
h_flex()
.flex_1()
.gap_1()
.child(
Button::new("commit")
.xsmall()
.text()
.label(commit.id.clone()),
)
.child(
div()
.max_w(px(250.))
.text_xs()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
),
)
}),
)
.child(div().id("repo-content").flex_1().min_h_0().child(body))
}
/// Load `text` into the persistent markdown TextView state.
///
/// The state is created empty and fed via `push_str`, which parses on a
/// background task, so switching files never blocks the main thread.
pub(super) fn set_markdown(
&mut self,
path: Option<SharedString>,
text: &str,
cx: &mut Context<Self>,
) {
let state = cx.new(|cx| TextViewState::markdown("", cx));
state.update(cx, |state, cx| state.push_str(text, cx));
self.md = Some(MarkdownView { path, state });
}
/// The persistent markdown TextView for `path` (`None` = README), or a
/// spinner while the document is being loaded/parsed.
fn markdown_element(&self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
let Some(md) = &self.md else {
return preview_spinner();
};
let ready = match path {
Some(path) => md.path.as_deref() == Some(path),
None => md.path.is_none(),
};
if !ready {
return preview_spinner();
}
TextView::new(&md.state)
.selectable(true)
.scrollable(true)
.p_4()
.text_sm()
.into_any_element()
}
/// Load `text` into the persistent code editor state for `path`.
///
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
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| {
EditorState::new(window, cx)
.language(language)
.default_value(text)
.line_number(true)
.folding(true)
});
self.code = Some(CodeView { path, state });
}
/// The persistent code editor for `path`, or a spinner while the file is
/// being loaded/parsed.
fn code_element(&self, path: &str, _cx: &mut Context<Self>) -> AnyElement {
let Some(code) = &self.code else {
return preview_spinner();
};
if code.path.as_ref() != path {
return preview_spinner();
}
Editor::new(&code.state)
.readonly(true)
.bordered(false)
.rounded_none()
.h_full()
.text_sm()
.into_any_element()
}
}