add repo detail view
This commit is contained in:
Generated
+4
@@ -7796,6 +7796,7 @@ dependencies = [
|
|||||||
"gix",
|
"gix",
|
||||||
"nostr",
|
"nostr",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7826,6 +7827,7 @@ dependencies = [
|
|||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"rustls",
|
"rustls",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
|
"signed_git",
|
||||||
"signed_nostr",
|
"signed_nostr",
|
||||||
"utils",
|
"utils",
|
||||||
]
|
]
|
||||||
@@ -10352,10 +10354,12 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||||||
name = "workspace"
|
name = "workspace"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"assets",
|
"assets",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
|
"signed_git",
|
||||||
"signed_state",
|
"signed_state",
|
||||||
"utils",
|
"utils",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ signed_core = { path = "../signed_core" }
|
|||||||
nostr.workspace = true
|
nostr.workspace = true
|
||||||
gix.workspace = true
|
gix.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -140,6 +140,94 @@ fn sanitize_path_component(id: &str) -> String {
|
|||||||
sanitized
|
sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Relative paths of all entries in the worktree (files and directories),
|
||||||
|
/// directories first, then alphabetically within each group. The `.git`
|
||||||
|
/// directory is skipped.
|
||||||
|
pub fn worktree_entries(repo: &gix::Repository) -> Result<Vec<PathBuf>> {
|
||||||
|
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||||
|
|
||||||
|
let mut entries: Vec<(PathBuf, bool)> = Vec::new();
|
||||||
|
collect_entries(workdir, workdir, &mut entries)?;
|
||||||
|
|
||||||
|
entries.sort_by(|(a, a_is_dir), (b, b_is_dir)| {
|
||||||
|
b_is_dir
|
||||||
|
.cmp(a_is_dir)
|
||||||
|
.then_with(|| a.as_os_str().cmp(b.as_os_str()))
|
||||||
|
});
|
||||||
|
Ok(entries.into_iter().map(|(path, _)| path).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from the worktree. Returns `Ok(None)` if the path is missing
|
||||||
|
/// or not a regular file.
|
||||||
|
pub fn worktree_read(repo: &gix::Repository, rel: &Path) -> Result<Option<Vec<u8>>> {
|
||||||
|
let workdir = repo.workdir().context("repository has no worktree")?;
|
||||||
|
let path = workdir.join(rel);
|
||||||
|
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => Ok(Some(bytes)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => Ok(None),
|
||||||
|
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the README file in the repository root (returned as a path relative
|
||||||
|
/// to the worktree). Case-insensitive; prefers `README.md`, then `.markdown`,
|
||||||
|
/// `.mdown`, `.mkdn`, then any other file whose name starts with `readme`.
|
||||||
|
pub fn find_readme(repo: &gix::Repository) -> Result<Option<PathBuf>> {
|
||||||
|
let Some(workdir) = repo.workdir() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(workdir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if name.to_ascii_lowercase().starts_with("readme") {
|
||||||
|
candidates.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort_by_key(|path| {
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.map(|e| e.to_string_lossy().to_ascii_lowercase());
|
||||||
|
match ext.as_deref() {
|
||||||
|
Some("md") => 0,
|
||||||
|
Some("markdown") => 1,
|
||||||
|
Some("mdown") => 2,
|
||||||
|
Some("mkdn") => 3,
|
||||||
|
Some(_) => 5,
|
||||||
|
None => 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|path| path.strip_prefix(workdir).ok().map(Path::to_path_buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_entries(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, bool)>) -> Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_name() == ".git" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_dir = entry.file_type()?.is_dir();
|
||||||
|
let path = entry.path();
|
||||||
|
let rel = path.strip_prefix(root)?.to_path_buf();
|
||||||
|
out.push((rel, is_dir));
|
||||||
|
|
||||||
|
if is_dir {
|
||||||
|
collect_entries(root, &path, out)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
@@ -181,4 +269,92 @@ mod tests {
|
|||||||
Some("_".into())
|
Some("_".into())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a throwaway non-bare repository with the given files (rel → bytes).
|
||||||
|
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let repo = gix::init(&dir).expect("init");
|
||||||
|
|
||||||
|
for (rel, bytes) in files {
|
||||||
|
let path = dir.path().join(rel);
|
||||||
|
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
|
||||||
|
std::fs::write(&path, bytes).expect("write");
|
||||||
|
}
|
||||||
|
|
||||||
|
(dir, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worktree_entries_lists_all_files_and_dirs() {
|
||||||
|
let (_dir, repo) = fixture(&[
|
||||||
|
("README.md", b"# Hi"),
|
||||||
|
("src/main.rs", b"fn main() {}"),
|
||||||
|
("src/lib.rs", b""),
|
||||||
|
("docs/guide.md", b"guide"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let entries = worktree_entries(&repo).expect("entries");
|
||||||
|
let entries: Vec<String> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![
|
||||||
|
"docs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"docs/guide.md",
|
||||||
|
"src/lib.rs",
|
||||||
|
"src/main.rs"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worktree_read_returns_bytes_or_none() {
|
||||||
|
let (_dir, repo) = fixture(&[("a.txt", b"hello"), ("sub/b.bin", b"\x00\x01")]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
worktree_read(&repo, Path::new("a.txt")).expect("read"),
|
||||||
|
Some(b"hello".to_vec())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
worktree_read(&repo, Path::new("sub/b.bin")).expect("read"),
|
||||||
|
Some(vec![0x00, 0x01])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
worktree_read(&repo, Path::new("missing.txt")).expect("read"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_readme_prefers_markdown() {
|
||||||
|
let (_dir, repo) = fixture(&[("readme.txt", b"txt"), ("README.md", b"md")]);
|
||||||
|
|
||||||
|
let readme = find_readme(&repo).expect("find");
|
||||||
|
assert_eq!(
|
||||||
|
readme.map(|p| p.to_string_lossy().into_owned()),
|
||||||
|
Some("README.md".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_readme_falls_back_to_any_readme() {
|
||||||
|
let (_dir, repo) = fixture(&[("README.rst", b"rst")]);
|
||||||
|
|
||||||
|
let readme = find_readme(&repo).expect("find");
|
||||||
|
assert_eq!(
|
||||||
|
readme.map(|p| p.to_string_lossy().into_owned()),
|
||||||
|
Some("README.rst".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_readme_returns_none_without_one() {
|
||||||
|
let (_dir, repo) = fixture(&[("main.rs", b"")]);
|
||||||
|
assert!(find_readme(&repo).expect("find").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
signed_git = { path = "../signed_git" }
|
||||||
signed_nostr = { path = "../signed_nostr" }
|
signed_nostr = { path = "../signed_nostr" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gpui::{App, Global};
|
||||||
|
use signed_git::GitCache;
|
||||||
|
|
||||||
|
struct GlobalGitStore(GitCache);
|
||||||
|
|
||||||
|
impl Global for GlobalGitStore {}
|
||||||
|
|
||||||
|
/// Global access to the on-disk git clone cache (grasp mirrors).
|
||||||
|
///
|
||||||
|
/// Installed at startup via [`GitStore::set_global`]; see also
|
||||||
|
/// [`signed_state::init`].
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitStore(GitCache);
|
||||||
|
|
||||||
|
impl GitStore {
|
||||||
|
/// Register the clone cache rooted at `root` as an app-wide global.
|
||||||
|
/// Replaces any previously installed store (see [`signed_state::init`], which
|
||||||
|
/// installs an empty one).
|
||||||
|
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||||
|
let store = Self::new(root);
|
||||||
|
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app-wide clone cache.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if [`GitStore::set_global`] was never called.
|
||||||
|
pub fn global(cx: &App) -> Self {
|
||||||
|
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self(GitCache::new(root.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Underlying clone cache.
|
||||||
|
pub fn cache(&self) -> &GitCache {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
mod backend;
|
mod backend;
|
||||||
|
mod git_store;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod repo;
|
mod repo;
|
||||||
mod repo_list;
|
mod repo_list;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use backend::{Backend, BackendEvent};
|
pub use backend::{Backend, BackendEvent};
|
||||||
|
pub use git_store::GitStore;
|
||||||
use gpui::{App, AppContext, Entity};
|
use gpui::{App, AppContext, Entity};
|
||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use utils::shorten_pubkey;
|
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repo_list::RepoListStore;
|
pub use repo_list::RepoListStore;
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
|
pub use utils::shorten_pubkey;
|
||||||
|
|
||||||
/// Initialize the backend and stores, and install them as globals. Call once
|
/// Initialize the backend and stores, and install them as globals. Call once
|
||||||
/// at startup, before opening any window that uses the stores.
|
/// at startup, before opening any window that uses the stores.
|
||||||
@@ -35,6 +37,10 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
|||||||
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
|
// The clone cache is only meaningful on native platforms; the wasm
|
||||||
|
// build registers an empty store so `GitStore::global` still works.
|
||||||
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
|
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,5 +54,7 @@ pub fn init(cx: &mut App) -> Entity<Backend> {
|
|||||||
|
|
||||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||||
|
|
||||||
|
GitStore::set_global(PathBuf::new(), cx);
|
||||||
|
|
||||||
entity
|
entity
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
assets = { path = "../assets" }
|
assets = { path = "../assets" }
|
||||||
signed_core = { path = "../signed_core" }
|
signed_core = { path = "../signed_core" }
|
||||||
|
signed_git = { path = "../signed_git" }
|
||||||
signed_state = { path = "../signed_state" }
|
signed_state = { path = "../signed_state" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
mod repo_detail;
|
||||||
mod repo_list;
|
mod repo_list;
|
||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
|
|
||||||
|
pub use repo_detail::RepoDetailView;
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
|
|||||||
@@ -0,0 +1,776 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Error;
|
||||||
|
use gpui::prelude::*;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
|
||||||
|
Task, WeakEntity, Window, div, px,
|
||||||
|
};
|
||||||
|
use gpui_component::avatar::Avatar;
|
||||||
|
use gpui_component::dock::{Panel, PanelEvent};
|
||||||
|
use gpui_component::list::ListItem;
|
||||||
|
use gpui_component::spinner::Spinner;
|
||||||
|
use gpui_component::text::{TextView, TextViewState};
|
||||||
|
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
|
||||||
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
|
use signed_core::Announcement;
|
||||||
|
use signed_state::{GitStore, ProfileStore, RepoStore};
|
||||||
|
use utils::relative_time;
|
||||||
|
|
||||||
|
/// Width of the file explorer column.
|
||||||
|
const TREE_WIDTH: f32 = 280.;
|
||||||
|
/// Files larger than this are not previewed.
|
||||||
|
const MAX_PREVIEW_BYTES: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Preview state of a browsed file.
|
||||||
|
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 here rather than created per render (as the stateless
|
||||||
|
/// `text::markdown` helper does), so it survives branch switches in the
|
||||||
|
/// content pane. GPUI's keyed element state is dropped as soon as the
|
||||||
|
/// element is absent for a single frame, which would otherwise re-parse the
|
||||||
|
/// whole document on the main thread every time the pane switches between
|
||||||
|
/// the README, a file preview, and the loading spinner.
|
||||||
|
struct MarkdownView {
|
||||||
|
/// Source path; `None` means the repository README.
|
||||||
|
path: Option<SharedString>,
|
||||||
|
state: Entity<TextViewState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
|
/// A clone/fetch is in flight.
|
||||||
|
loading: bool,
|
||||||
|
error: Option<SharedString>,
|
||||||
|
/// 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
tasks: Vec::new(),
|
||||||
|
loading: true,
|
||||||
|
error: None,
|
||||||
|
worktree: None,
|
||||||
|
md: None,
|
||||||
|
readme_name: None,
|
||||||
|
selected_file: None,
|
||||||
|
files: HashMap::new(),
|
||||||
|
loading_files: HashSet::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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load `text` into the persistent markdown TextView state.
|
||||||
|
///
|
||||||
|
/// The state is created empty and fed via `push_str`, which parses on a
|
||||||
|
/// background task: switching files never blocks the main thread, and
|
||||||
|
/// the state lives as long as this view, so re-viewing the same document
|
||||||
|
/// does not re-parse it.
|
||||||
|
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(&mut self, path: Option<&str>, _cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
let spinner = || {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Spinner::new().small())
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(md) = &self.md else {
|
||||||
|
return spinner();
|
||||||
|
};
|
||||||
|
let ready = match path {
|
||||||
|
Some(path) => md.path.as_deref() == Some(path),
|
||||||
|
None => md.path.is_none(),
|
||||||
|
};
|
||||||
|
if !ready {
|
||||||
|
return spinner();
|
||||||
|
}
|
||||||
|
|
||||||
|
TextView::new(&md.state).selectable(true).into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_row(label: SharedString, value: SharedString, cx: &App) -> impl IntoElement {
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_start()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w_24()
|
||||||
|
.flex_none()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.child(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stat(label: SharedString, count: usize, cx: &App) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.child(div().text_lg().font_semibold().child(count.to_string()))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 item = entry.item();
|
||||||
|
let id = item.id.clone();
|
||||||
|
let is_folder = entry.is_folder();
|
||||||
|
|
||||||
|
let icon = if is_folder {
|
||||||
|
if entry.is_expanded() {
|
||||||
|
IconName::FolderOpen
|
||||||
|
} else {
|
||||||
|
IconName::FolderClosed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconName::File
|
||||||
|
};
|
||||||
|
|
||||||
|
let view = view.clone();
|
||||||
|
|
||||||
|
ListItem::new(ix)
|
||||||
|
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||||
|
.selected(selected)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(Icon::new(icon).small())
|
||||||
|
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||||
|
)
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
// Folders expand/collapse via the tree itself.
|
||||||
|
if is_folder {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(view) = view.upgrade() {
|
||||||
|
view.update(cx, |this, cx| this.open_file(&id, window, cx));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 announcement = self
|
||||||
|
.store
|
||||||
|
.read(cx)
|
||||||
|
.announcement
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| self.initial.clone());
|
||||||
|
|
||||||
|
let store = self.store.read(cx);
|
||||||
|
let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner);
|
||||||
|
|
||||||
|
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 web = join_urls(&announcement.web);
|
||||||
|
let clone = join_urls(&announcement.clone);
|
||||||
|
let relays = announcement
|
||||||
|
.relays
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let hashtags = announcement.hashtags.join(", ");
|
||||||
|
|
||||||
|
let pane_title = self
|
||||||
|
.selected_file
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.readme_name.clone())
|
||||||
|
.unwrap_or_else(|| "Overview".into());
|
||||||
|
|
||||||
|
let tree_state = self.tree_state.clone();
|
||||||
|
let view = cx.entity().downgrade();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id("repo-detail")
|
||||||
|
.size_full()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.p_6()
|
||||||
|
.gap_4()
|
||||||
|
// Header
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
Avatar::new()
|
||||||
|
.name(owner.name())
|
||||||
|
.when_some(owner.picture(), |this, url| this.src(url))
|
||||||
|
.small(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(owner.name()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(div().text_2xl().font_semibold().child(name))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(description),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Activity summary
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_6()
|
||||||
|
.child(Self::stat("Issues".into(), store.issues.len(), cx))
|
||||||
|
.child(Self::stat("Patches".into(), store.patches.len(), cx))
|
||||||
|
.child(Self::stat(
|
||||||
|
"Pull requests".into(),
|
||||||
|
store.pull_requests.len(),
|
||||||
|
cx,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
// File explorer + README / file preview
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h(px(380.))
|
||||||
|
.flex_none()
|
||||||
|
.w_full()
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.render_tree_column(tree_state, view, cx))
|
||||||
|
.child(self.render_content_column(pane_title, cx)),
|
||||||
|
)
|
||||||
|
// Details
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(Self::meta_row(
|
||||||
|
"Created".into(),
|
||||||
|
relative_time(announcement.created_at).into(),
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
.when_some(announcement.euc.clone(), |this, euc| {
|
||||||
|
this.child(Self::meta_row("EUC".into(), euc.into(), cx))
|
||||||
|
})
|
||||||
|
.when(!web.is_empty(), |this| {
|
||||||
|
this.child(Self::meta_row("Web".into(), web.into(), cx))
|
||||||
|
})
|
||||||
|
.when(!clone.is_empty(), |this| {
|
||||||
|
this.child(Self::meta_row("Clone".into(), clone.into(), cx))
|
||||||
|
})
|
||||||
|
.when(!relays.is_empty(), |this| {
|
||||||
|
this.child(Self::meta_row("Relays".into(), relays.into(), cx))
|
||||||
|
})
|
||||||
|
.when(!hashtags.is_empty(), |this| {
|
||||||
|
this.child(Self::meta_row("Tags".into(), hashtags.into(), cx))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoDetailView {
|
||||||
|
/// Left column: the file tree.
|
||||||
|
fn render_tree_column(
|
||||||
|
&mut self,
|
||||||
|
tree_state: Entity<TreeState>,
|
||||||
|
view: WeakEntity<Self>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> impl IntoElement {
|
||||||
|
v_flex()
|
||||||
|
.w(px(TREE_WIDTH))
|
||||||
|
.flex_none()
|
||||||
|
.h_full()
|
||||||
|
.border_r(px(1.))
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_9()
|
||||||
|
.px_3()
|
||||||
|
.items_center()
|
||||||
|
.border_b(px(1.))
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(div().text_xs().font_semibold().child("Files")),
|
||||||
|
)
|
||||||
|
.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.
|
||||||
|
fn render_content_column(
|
||||||
|
&mut 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(text)) => {
|
||||||
|
if is_markdown_path(path.as_ref()) {
|
||||||
|
self.markdown_element(Some(path.as_ref()), cx)
|
||||||
|
} else {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.children(plain_lines(text, cx))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 => v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(Spinner::new().small())
|
||||||
|
.into_any_element(),
|
||||||
|
}
|
||||||
|
} else if self.readme_name.is_some() {
|
||||||
|
self.markdown_element(None, cx)
|
||||||
|
} else {
|
||||||
|
placeholder("No README found", cx)
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.h_full()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h_9()
|
||||||
|
.px_3()
|
||||||
|
.items_center()
|
||||||
|
.border_b(px(1.))
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_semibold()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(pane_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("repo-content-scroll")
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.p_4()
|
||||||
|
.child(body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
||||||
|
fn build_tree_items(entries: &[PathBuf]) -> Vec<TreeItem> {
|
||||||
|
let mut roots: Vec<TreeItem> = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let parts: Vec<String> = entry
|
||||||
|
.components()
|
||||||
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
insert_path(&mut roots, &parts, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert `parts` (path components) into the tree rooted at `items`.
|
||||||
|
/// `prefix` is the path of `items`' parent, used to build item ids.
|
||||||
|
fn insert_path(items: &mut Vec<TreeItem>, parts: &[String], prefix: &str) {
|
||||||
|
let Some((head, rest)) = parts.split_first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = if prefix.is_empty() {
|
||||||
|
head.clone()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/{head}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(existing) = items.iter_mut().find(|item| &*item.label == head.as_str()) {
|
||||||
|
insert_path(&mut existing.children, rest, &id);
|
||||||
|
} else {
|
||||||
|
let mut item = TreeItem::new(id.clone(), head.clone());
|
||||||
|
insert_path(&mut item.children, rest, &id);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render text with one element per line, preserving blank lines.
|
||||||
|
fn plain_lines(text: &str, cx: &App) -> Vec<AnyElement> {
|
||||||
|
text.lines()
|
||||||
|
.map(|line| {
|
||||||
|
// A space keeps empty lines from collapsing to zero height.
|
||||||
|
let text = if line.is_empty() { " " } else { line };
|
||||||
|
div()
|
||||||
|
.font_family(cx.theme().mono_font_family.clone())
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.child(text.to_string())
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a file path has a markdown extension.
|
||||||
|
fn is_markdown_path(path: &str) -> bool {
|
||||||
|
Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| {
|
||||||
|
matches!(
|
||||||
|
ext.to_ascii_lowercase().as_str(),
|
||||||
|
"md" | "markdown" | "mdown" | "mkdn"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.p_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().muted_foreground)
|
||||||
|
.child(message.to_string()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_urls<T: ToString>(urls: &[T]) -> String {
|
||||||
|
urls.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_nested_tree_from_flat_entries() {
|
||||||
|
let entries = vec![
|
||||||
|
PathBuf::from("src"),
|
||||||
|
PathBuf::from("src/lib.rs"),
|
||||||
|
PathBuf::from("README.md"),
|
||||||
|
PathBuf::from("docs/guide.md"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let items = build_tree_items(&entries);
|
||||||
|
|
||||||
|
// Input order is preserved (dirs-first, as produced by worktree_entries).
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert_eq!(items[0].label, "src");
|
||||||
|
assert_eq!(items[0].id, "src");
|
||||||
|
assert_eq!(items[0].children.len(), 1);
|
||||||
|
assert_eq!(items[0].children[0].label, "lib.rs");
|
||||||
|
assert_eq!(items[0].children[0].id, "src/lib.rs");
|
||||||
|
|
||||||
|
assert_eq!(items[1].label, "README.md");
|
||||||
|
assert_eq!(items[1].id, "README.md");
|
||||||
|
|
||||||
|
assert_eq!(items[2].label, "docs");
|
||||||
|
assert_eq!(items[2].children[0].label, "guide.md");
|
||||||
|
assert_eq!(items[2].children[0].id, "docs/guide.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_builder_handles_deep_nesting() {
|
||||||
|
let entries = vec![
|
||||||
|
PathBuf::from("a"),
|
||||||
|
PathBuf::from("a/b"),
|
||||||
|
PathBuf::from("a/b/c.txt"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let items = build_tree_items(&entries);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].children[0].id, "a/b");
|
||||||
|
assert_eq!(items[0].children[0].children[0].id, "a/b/c.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Pixels,
|
||||||
SharedString, Size, Subscription, Window, div, px, size,
|
Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_component::avatar::Avatar;
|
use gpui_component::avatar::Avatar;
|
||||||
use gpui_component::dock::{Panel, PanelEvent};
|
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
|
||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||||
@@ -15,11 +16,14 @@ use signed_core::Announcement;
|
|||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
|
use super::RepoDetailView;
|
||||||
|
|
||||||
const CARD_HEIGHT: f32 = 160.;
|
const CARD_HEIGHT: f32 = 160.;
|
||||||
|
|
||||||
/// Browse all announced repositories (works anonymously).
|
/// Browse all announced repositories (works anonymously).
|
||||||
pub struct RepoListView {
|
pub struct RepoListView {
|
||||||
store: Entity<RepoListStore>,
|
store: Entity<RepoListStore>,
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
@@ -27,7 +31,11 @@ pub struct RepoListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RepoListView {
|
impl RepoListView {
|
||||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(
|
||||||
|
dock_area: WeakEntity<DockArea>,
|
||||||
|
_window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
let store = cx.new(|cx| RepoListStore::new(None, cx));
|
let store = cx.new(|cx| RepoListStore::new(None, cx));
|
||||||
|
|
||||||
let subscription = cx.observe(&store, |this, store, cx| {
|
let subscription = cx.observe(&store, |this, store, cx| {
|
||||||
@@ -40,6 +48,7 @@ impl RepoListView {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
|
dock_area,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(vec![]),
|
item_sizes: Rc::new(vec![]),
|
||||||
@@ -70,11 +79,34 @@ impl RepoListView {
|
|||||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Open the repository in a new center tab when the card is clicked.
|
||||||
|
let dock_area = self.dock_area.clone();
|
||||||
|
let announcement = announcement.clone();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
|
.id(ElementId::from(format!(
|
||||||
|
"repo-card-{}",
|
||||||
|
announcement.addr()
|
||||||
|
)))
|
||||||
.px_4()
|
.px_4()
|
||||||
.w_full()
|
.w_full()
|
||||||
.border_b(px(1.))
|
.border_b(px(1.))
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
|
.cursor_pointer()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
let detail = cx.new(|cx| RepoDetailView::new(announcement.clone(), window, cx));
|
||||||
|
if let Some(dock_area) = dock_area.upgrade() {
|
||||||
|
dock_area.update(cx, |dock_area, cx| {
|
||||||
|
dock_area.add_panel(
|
||||||
|
Arc::new(detail),
|
||||||
|
DockPlacement::Center,
|
||||||
|
None,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h_12()
|
.h_12()
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ impl SidebarPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let panel = cx.new(|cx| RepoListView::new(window, cx));
|
let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx));
|
||||||
self.explore = Some(panel.downgrade());
|
self.explore = Some(panel.downgrade());
|
||||||
|
|
||||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ fn main() {
|
|||||||
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
std::fs::create_dir_all(paths::nostr_dir()).ok();
|
||||||
signed_state::init(paths::nostr_dir(), cx);
|
signed_state::init(paths::nostr_dir(), cx);
|
||||||
|
|
||||||
|
// Local git clone cache for browsing repository contents.
|
||||||
|
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||||
|
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
||||||
|
|
||||||
// Set up the window bounds
|
// Set up the window bounds
|
||||||
let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx);
|
let bounds = Bounds::centered(None, size(px(980.0), px(740.0)), cx);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user