update
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Render,
|
||||
SharedString, Task, Window, div,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants, DropdownButton};
|
||||
use gpui_component::dock::{Panel, PanelEvent};
|
||||
use gpui_component::menu::PopupMenuItem;
|
||||
use gpui_component::tree::TreeState;
|
||||
use gpui_component::{ActiveTheme, IconName, StyledExt, h_flex, v_flex};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{GitStore, RepoStore};
|
||||
|
||||
mod browser;
|
||||
mod helpers;
|
||||
|
||||
use browser::{FileContent, MAX_PREVIEW_BYTES, MarkdownView};
|
||||
use helpers::{build_tree_items, is_markdown_path};
|
||||
|
||||
/// Detail view of a repository: header, stats, a file explorer with README
|
||||
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
||||
pub struct RepoDetailView {
|
||||
/// Live per-repository store, refreshed from the local database.
|
||||
store: Entity<RepoStore>,
|
||||
/// Snapshot taken at open time, shown until the store's first refresh completes.
|
||||
initial: Announcement,
|
||||
/// File explorer state (worktree of the local clone).
|
||||
tree_state: Entity<TreeState>,
|
||||
/// Root of the local clone, for reading files on demand.
|
||||
worktree: Option<PathBuf>,
|
||||
/// Markdown document currently in the preview pane (README or a file).
|
||||
md: Option<MarkdownView>,
|
||||
readme_name: Option<SharedString>,
|
||||
/// Currently previewed file (relative path) and its contents.
|
||||
selected_file: Option<SharedString>,
|
||||
files: HashMap<String, FileContent>,
|
||||
/// Reads in flight, to avoid duplicate loads.
|
||||
loading_files: HashSet<String>,
|
||||
/// A clone/fetch is in flight.
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
focus_handle: FocusHandle,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
pub fn new(initial: Announcement, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
|
||||
let tree_state = cx.new(|cx| TreeState::new(cx));
|
||||
|
||||
// Defer loading the repository until the window is ready.
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load_repo(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
store,
|
||||
initial,
|
||||
tree_state,
|
||||
worktree: None,
|
||||
md: None,
|
||||
readme_name: None,
|
||||
selected_file: None,
|
||||
files: HashMap::new(),
|
||||
loading_files: HashSet::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone (or fetch) the repository and populate the file explorer.
|
||||
fn load_repo(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
cx.notify();
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.initial.addr();
|
||||
let clone_urls: Vec<String> = self.initial.clone.iter().map(ToString::to_string).collect();
|
||||
|
||||
let load = cx.background_spawn(async move {
|
||||
let repo = cache.ensure_clone(&addr, &clone_urls)?;
|
||||
let entries = signed_git::worktree_entries(&repo)?;
|
||||
let readme_path = signed_git::find_readme(&repo)?;
|
||||
let readme = match &readme_path {
|
||||
Some(path) => signed_git::worktree_read(&repo, path)?,
|
||||
None => None,
|
||||
};
|
||||
let worktree = repo.workdir().map(Path::to_path_buf);
|
||||
|
||||
Ok::<_, Error>((entries, readme_path, readme, worktree))
|
||||
});
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let result = load.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok((entries, readme_path, readme, Some(worktree))) => {
|
||||
this.worktree = Some(worktree);
|
||||
this.tree_state.update(cx, |state, cx| {
|
||||
state.set_items(build_tree_items(&entries), cx);
|
||||
});
|
||||
if let Some((path, bytes)) = readme_path.zip(readme) {
|
||||
this.readme_name = Some(path.to_string_lossy().into());
|
||||
if let Ok(text) = String::from_utf8(bytes) {
|
||||
this.set_markdown(None, &text, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((_, _, _, None)) => {
|
||||
this.error = Some("Repository has no worktree".into());
|
||||
}
|
||||
Err(error) => {
|
||||
this.error = Some(error.to_string().into());
|
||||
}
|
||||
}
|
||||
this.loading = false;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Preview the file at `path` (relative to the worktree root).
|
||||
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) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths come from our own tree walk, but never trust them: refuse
|
||||
// anything that could escape the worktree.
|
||||
let rel = Path::new(path);
|
||||
let unsafe_path = rel.is_absolute()
|
||||
|| rel.components().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
});
|
||||
let Some(worktree) = self.worktree.clone() else {
|
||||
return;
|
||||
};
|
||||
if unsafe_path {
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_files.insert(path.to_string());
|
||||
let path = path.to_string();
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
let path_for_read = path.clone();
|
||||
let content = cx
|
||||
.background_spawn(async move {
|
||||
let full = worktree.join(&path_for_read);
|
||||
let bytes = match std::fs::read(&full) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return Err(anyhow::anyhow!("{}", error)),
|
||||
};
|
||||
|
||||
let kind = if bytes.len() > MAX_PREVIEW_BYTES {
|
||||
FileContent::TooLarge
|
||||
} else {
|
||||
match String::from_utf8(bytes) {
|
||||
Ok(text) => FileContent::Text(text),
|
||||
Err(_) => FileContent::Binary,
|
||||
}
|
||||
};
|
||||
Ok::<_, Error>(kind)
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.loading_files.remove(&path);
|
||||
match content {
|
||||
Ok(kind) => {
|
||||
if let FileContent::Text(text) = &kind
|
||||
&& is_markdown_path(&path)
|
||||
{
|
||||
let same = this.md.as_ref().map(|md| md.path.as_deref())
|
||||
== Some(Some(path.as_str()));
|
||||
if !same {
|
||||
this.set_markdown(Some(path.clone().into()), text, cx);
|
||||
}
|
||||
}
|
||||
this.files.insert(path, kind);
|
||||
}
|
||||
Err(error) => {
|
||||
this.files
|
||||
.insert(path, FileContent::Failed(error.to_string()));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RepoDetailView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"repo_detail"
|
||||
}
|
||||
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let announcement = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.initial.clone());
|
||||
|
||||
announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RepoDetailView {}
|
||||
|
||||
impl Focusable for RepoDetailView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RepoDetailView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tree_state = self.tree_state.clone();
|
||||
let view = cx.entity().downgrade();
|
||||
|
||||
let announcement = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcement
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.initial.clone());
|
||||
|
||||
let name = announcement
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(announcement.id.clone()));
|
||||
|
||||
let description = announcement
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or(SharedString::from("No description"));
|
||||
|
||||
let pane_title = self
|
||||
.selected_file
|
||||
.clone()
|
||||
.or_else(|| self.readme_name.clone())
|
||||
.unwrap_or_else(|| "Overview".into());
|
||||
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
v_flex()
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_4()
|
||||
.pt_2()
|
||||
.pb_4()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_none()
|
||||
.child(div().font_semibold().child(name))
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.line_clamp(3)
|
||||
.child(description),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
DropdownButton::new("relays")
|
||||
.button(
|
||||
Button::new("relay-trigger")
|
||||
.label(format!("{} relays", relays.len()))
|
||||
.ghost(),
|
||||
)
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let mut menu = menu;
|
||||
if relays.is_empty() {
|
||||
return menu.item(
|
||||
PopupMenuItem::new("No relays").disabled(true),
|
||||
);
|
||||
}
|
||||
for relay in relays.iter() {
|
||||
let url = relay.to_string();
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new(url.clone()).on_click(
|
||||
move |_, _, cx| {
|
||||
cx.write_to_clipboard(
|
||||
ClipboardItem::new_string(url.clone()),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
menu
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("link")
|
||||
.icon(IconName::ExternalLink)
|
||||
.tooltip("Open in gitworkshop.dev")
|
||||
.secondary(),
|
||||
)
|
||||
.child(
|
||||
Button::new("clone")
|
||||
.icon(IconName::ArrowDown)
|
||||
.tooltip("Clone")
|
||||
.primary(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.child(self.render_tree_column(tree_state, view, cx))
|
||||
.child(self.render_content_column(pane_title, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user