feat: add Files tab with a tree-and-blob split pane
commit
3ffab91feat: add Files tab with a tree-and-blob split pane
The Files tab renders the repository tree beside a blob pane inside one card. With no client JavaScript, expanding a folder or opening a file is a link to /<repo>/files/<path>; the tree arrives already expanded along the selected path, and the pane shows the file’s path, line count, size, a copy button, and its syntax-highlighted source.
feat: add /<repo>/files split-pane route with expand-on-navigate tree
feat: show line/size meta and copy button in the Files blob pane
feat: add chevron icon and human-readable size helper
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/git-ents-server/src/web.rs
@@ -3,7 +3,10 @@
//! warm-gold palette that follows the system light/dark preference. The git
//! smart-HTTP gateway in [`crate::http`] delegates plain browser GETs here.
+use std::collections::HashSet;
+use std::future::Future;
use std::path::{Path, PathBuf};
+use std::pin::Pin;
use std::process::Stdio;
use arborium::{Config, Highlighter, HtmlFormat};
@@ -223,6 +226,32 @@
.crumbs .sep { color: var(--color-text-muted); opacity: .55; }
.crumbs .here { color: var(--color-text-muted); }
+.files { display: grid; grid-template-columns: 17rem minmax(0, 1fr); min-height: 30rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; }
+.tree-pane { background: var(--color-bg); border-right: 1px solid var(--color-border); overflow: auto; padding-bottom: .5rem; }
+.tree-head { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem .9rem; border-bottom: 1px solid var(--color-border); display: flex; align-items: center; gap: .4rem; }
+.tree-head .icon { width: 13px; height: 13px; }
+.tree-row { display: flex; align-items: center; gap: .3rem; font-family: var(--font-mono); font-size: .79rem; padding: 4px 8px; text-decoration: none; color: var(--color-text); white-space: nowrap; }
+.tree-row:hover { text-decoration: none; background: var(--color-code-bg); }
+.tree-row.sel { background: var(--color-accent-subtle); color: var(--color-accent); font-weight: 600; }
+.tree-row .chev { width: 12px; height: 12px; flex-shrink: 0; color: var(--color-text-muted); transition: transform .15s; }
+.tree-row .chev.open { transform: rotate(90deg); }
+.tree-row .ic-folder { display: inline-flex; color: var(--color-accent); }
+.tree-row.sel .ic-folder { color: var(--color-accent); }
+.tree-row .ic-file { display: inline-flex; color: var(--color-text-muted); }
+.tree-row span:last-child { overflow: hidden; text-overflow: ellipsis; }
+.blob-pane { display: flex; flex-direction: column; min-width: 0; }
+.blob-head { display: flex; align-items: center; gap: .5rem; background: var(--color-code-bg); border-bottom: 1px solid var(--color-border); padding: .5rem 1rem; font-family: var(--font-mono); font-size: .78rem; }
+.blob-head .meta { margin-left: auto; display: flex; align-items: center; gap: .8rem; color: var(--color-text-muted); }
+.blob-head .copy-btn { border: 1px solid var(--color-border); border-radius: 6px; padding: .1rem .55rem; background: var(--color-surface); }
+.blob-pane .blob { border: 0; border-radius: 0; box-shadow: none; margin: 0; overflow: auto; flex: 1; }
+.files-empty { margin: auto; padding: 3rem; text-align: center; color: var(--color-text-muted); }
+.files-empty .icon { width: 28px; height: 28px; opacity: .5; margin-bottom: .5rem; }
+
+@media (max-width: 700px) {
+ .files { grid-template-columns: 1fr; }
+ .tree-pane { border-right: 0; border-bottom: 1px solid var(--color-border); max-height: 16rem; }
+}
+
.blob { display: grid; grid-template-columns: auto minmax(0, 1fr); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; }
.blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 0; }
.blob-nums { text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding-left: 1rem; padding-right: 1rem; user-select: none; -webkit-user-select: none; }
@@ -361,6 +390,7 @@
let meta = gather_meta(repo, rel).await;
match rest.split_first() {
None => repo_page(repo, &meta, host).await.into_response(),
+ Some((&"files", sub)) => files_page(repo, &meta, sub).await,
Some((&"tree", sub)) => tree_page(repo, &meta, sub).await,
Some((&"blob", sub)) => blob_page(repo, &meta, sub).await,
Some((&"commit", &[sha])) => commit_page(repo, &meta, sha).await,
@@ -926,6 +956,204 @@
}
}
+/// One row of the Files tree pane.
+struct TreeRow {
+ name: String,
+ path: String,
+ is_dir: bool,
+ depth: usize,
+ expanded: bool,
+ selected: bool,
+}
+
+/// Walk the tree under `dir`, emitting a row per entry and recursing into any
+/// directory in `expanded`. Boxed because the recursion is `async`.
+fn collect_rows<'a>(
+ repo: &'a Path,
+ dir: &'a str,
+ depth: usize,
+ expanded: &'a HashSet<String>,
+ selected: &'a str,
+ out: &'a mut Vec<TreeRow>,
+) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
+ Box::pin(async move {
+ let spec = if dir.is_empty() {
+ "HEAD".to_owned()
+ } else {
+ format!("HEAD:{dir}")
+ };
+ for entry in list_tree(repo, &spec).await {
+ let path = if dir.is_empty() {
+ entry.name.clone()
+ } else {
+ format!("{dir}/{}", entry.name)
+ };
+ let is_expanded = entry.is_dir && expanded.contains(&path);
+ out.push(TreeRow {
+ name: entry.name.clone(),
+ path: path.clone(),
+ is_dir: entry.is_dir,
+ depth,
+ expanded: is_expanded,
+ selected: !entry.is_dir && path == selected,
+ });
+ if is_expanded {
+ collect_rows(
+ repo,
+ &path,
+ depth.saturating_add(1),
+ expanded,
+ selected,
+ out,
+ )
+ .await;
+ }
+ }
+ })
+}
+
+/// The Files tab: a tree pane beside a blob pane, both inside one card. With no
+/// client JavaScript, expanding a folder or opening a file is a link to
+/// `/<repo>/files/<path>`; the tree is rendered already expanded along the
+/// selected path.
+async fn files_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response {
+ let rel = &meta.rel;
+ let Some(selected) = browse_path(sub) else {
+ return not_found().into_response();
+ };
+
+ // Classify the selection so the right pane shows a file and the tree expands
+ // the correct ancestors.
+ let kind = if selected.is_empty() {
+ None
+ } else {
+ git_output(repo, &["cat-file", "-t", &format!("HEAD:{selected}")])
+ .await
+ .map(|s| s.trim().to_owned())
+ };
+ let selected_file = matches!(kind.as_deref(), Some("blob")).then(|| selected.clone());
+ let selected_dir = match kind.as_deref() {
+ Some("tree") => selected.clone(),
+ Some("blob") => selected
+ .rsplit_once('/')
+ .map_or(String::new(), |(d, _)| d.to_owned()),
+ _ if selected.is_empty() => String::new(),
+ _ => return not_found().into_response(),
+ };
+
+ // Expand every ancestor directory of the selection (and the selection itself
+ // when it is a directory).
+ let mut expanded = HashSet::new();
+ let mut acc = String::new();
+ for part in selected_dir.split('/').filter(|s| !s.is_empty()) {
+ if !acc.is_empty() {
+ acc.push('/');
+ }
+ acc.push_str(part);
+ expanded.insert(acc.clone());
+ }
+
+ let mut rows = Vec::new();
+ collect_rows(
+ repo,
+ "",
+ 0,
+ &expanded,
+ selected_file.as_deref().unwrap_or_default(),
+ &mut rows,
+ )
+ .await;
+
+ let right = match &selected_file {
+ Some(path) => blob_pane(repo, path).await,
+ None => html! {
+ div.files-empty {
+ (icon_file())
+ p { "Select a file to view its contents." }
+ }
+ },
+ };
+
+ let name = rel.rsplit('/').next().unwrap_or(rel);
+ repo_shell(
+ meta,
+ Tab::Files,
+ name,
+ html! {
+ div.files {
+ div.tree-pane {
+ div.tree-head { (icon_branch()) (meta.branch.as_deref().unwrap_or("HEAD")) }
+ @for row in &rows {
+ a.tree-row.sel[row.selected]
+ href={ "/" (rel) "/files/" (row.path) }
+ style={ "padding-left:" (row.depth.saturating_mul(15).saturating_add(8)) "px" }
+ {
+ @if row.is_dir {
+ span.chev.open[row.expanded] { (icon_chevron()) }
+ span.ic-folder { (icon_folder()) }
+ } @else {
+ span.chev {}
+ span.ic-file { (icon_file()) }
+ }
+ span { (row.name) }
+ }
+ }
+ }
+ div.blob-pane { (right) }
+ }
+ },
+ )
+ .into_response()
+}
+
+/// The right-hand pane of the Files view: a file's path, line/size meta, and its
+/// syntax-highlighted source (or a binary notice).
+async fn blob_pane(repo: &Path, path: &str) -> Markup {
+ let Some(bytes) = git_output_bytes(repo, &["cat-file", "-p", &format!("HEAD:{path}")]).await
+ else {
+ return html! { div.files-empty { "File not found." } };
+ };
+ let name = path.rsplit('/').next().unwrap_or(path);
+ html! {
+ div.blob-head {
+ span { (path) }
+ span.meta {
+ @if !is_binary(&bytes) {
+ span { (String::from_utf8_lossy(&bytes).lines().count()) " lines" }
+ }
+ span { (human_size(bytes.len())) }
+ button.copy-btn data-copy=(String::from_utf8_lossy(&bytes)) { "Copy" }
+ }
+ }
+ @if is_binary(&bytes) {
+ div.binary { "Binary file (" (human_size(bytes.len())) ") not shown." }
+ } @else {
+ (blob_body(name, &String::from_utf8_lossy(&bytes)))
+ }
+ }
+}
+
+/// A byte count rendered as a compact human-readable size.
+fn human_size(bytes: usize) -> String {
+ const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
+ #[expect(
+ clippy::cast_precision_loss,
+ reason = "an approximate display size; exactness past 2^52 bytes is irrelevant"
+ )]
+ let mut size = bytes as f64;
+ let mut unit = 0;
+ while size >= 1024.0 && unit < UNITS.len().saturating_sub(1) {
+ size /= 1024.0;
+ unit = unit.saturating_add(1);
+ }
+ let label = UNITS.get(unit).unwrap_or(&"B");
+ if unit == 0 {
+ format!("{bytes} {label}")
+ } else {
+ format!("{size:.1} {label}")
+ }
+}
+
/// A directory listing at `sub` within the repository.
async fn tree_page(repo: &Path, meta: &RepoMeta, sub: &[&str]) -> Response {
let rel = &meta.rel;
@@ -1304,6 +1532,12 @@
)
}
+fn icon_chevron() -> Markup {
+ svg(
+ "M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z",
+ )
+}
+
fn icon_branch() -> Markup {
svg(
"M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z",