perf: lazy-load file content and fix oversized tile labels

- Server now returns metadata-only tree on initial load (no file content
  in the JSON payload); content is served on-demand via the new
  GET /api/repos/{key}/file?path=... endpoint
- Cache still stores full content; strip_content() runs in-memory before
  the response is sent
- Frontend fetches file content lazily in _fetchContent() when a tile
  enters the LOD view, preventing a massive upfront JSON download for
  large repos (e.g. claude code)
- computeColorRanges() is now deferred to first _showCode() call instead
  of running synchronously for every file during load()
- Cap label fontSize at 5 world units to prevent giant text on large tiles
This commit is contained in:
Fam Zheng
2026-04-07 10:37:31 +01:00
committed by Fam Zheng
parent 37d2b33f32
commit 398ae64ed9
3 changed files with 124 additions and 28 deletions

View File

@@ -2,7 +2,7 @@ mod cache;
mod scanner;
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
extract::{DefaultBodyLimit, Multipart, Path, Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
@@ -10,7 +10,7 @@ use axum::{
};
use cache::{Cache, RepoEntry};
use scanner::{scan_dir, FileNode};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::process::Command;
use std::sync::Arc;
@@ -27,6 +27,43 @@ struct GitRequest {
url: String,
}
#[derive(Deserialize)]
struct FileQuery {
path: String,
}
/// Response wrapper that includes the cache key alongside the (content-stripped) tree.
#[derive(Serialize)]
struct TreeResponse {
cache_key: String,
tree: FileNode,
}
/// Recursively strip file content so the initial response is metadata-only.
fn strip_content(node: &mut FileNode) {
node.content = None;
if let Some(children) = &mut node.children {
for child in children {
strip_content(child);
}
}
}
/// Walk the cached tree to find a single file's content by its relative path.
fn find_file_content(node: &FileNode, path: &str) -> Option<String> {
if node.content.is_some() && node.path == path {
return node.content.clone();
}
if let Some(children) = &node.children {
for child in children {
if let Some(content) = find_file_content(child, path) {
return Some(content);
}
}
}
None
}
fn count_leaves(node: &FileNode) -> usize {
match &node.children {
Some(children) => children.iter().map(count_leaves).sum(),
@@ -55,6 +92,7 @@ async fn main() {
.route("/api/scan-zip", post(scan_zip))
.route("/api/repos", get(list_repos))
.route("/api/repos/{key}", get(get_repo))
.route("/api/repos/{key}/file", get(get_file))
.layer(DefaultBodyLimit::max(100 * 1024 * 1024))
.with_state(state)
.fallback_service(ServeDir::new(frontend_dir).append_index_html_on_directories(true));
@@ -76,19 +114,38 @@ async fn list_repos(
async fn get_repo(
State(state): State<Arc<AppState>>,
Path(key): Path<String>,
) -> Result<Json<FileNode>, (StatusCode, String)> {
) -> Result<Json<TreeResponse>, (StatusCode, String)> {
state
.cache
.get(&key)
.and_then(|data| serde_json::from_str(&data).ok())
.map(Json)
.and_then(|data| serde_json::from_str::<FileNode>(&data).ok())
.map(|mut tree| {
strip_content(&mut tree);
Json(TreeResponse { cache_key: key, tree })
})
.ok_or((StatusCode::NOT_FOUND, "Repo not found in cache".to_string()))
}
async fn get_file(
State(state): State<Arc<AppState>>,
Path(key): Path<String>,
Query(q): Query<FileQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let data = state
.cache
.get(&key)
.ok_or((StatusCode::NOT_FOUND, "Repo not found in cache".to_string()))?;
let tree: FileNode = serde_json::from_str(&data)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let content = find_file_content(&tree, &q.path)
.ok_or((StatusCode::NOT_FOUND, format!("File not found: {}", q.path)))?;
Ok(Json(serde_json::json!({ "content": content })))
}
async fn scan_git(
State(state): State<Arc<AppState>>,
Json(req): Json<GitRequest>,
) -> Result<Json<FileNode>, (StatusCode, String)> {
) -> Result<Json<TreeResponse>, (StatusCode, String)> {
let url = req.url.trim().to_string();
if !url.starts_with("http://")
@@ -102,9 +159,10 @@ async fn scan_git(
let key = Cache::make_key(&format!("git:{url}"));
if let Some(cached) = state.cache.get(&key) {
info!("Cache hit for {url}");
let tree: FileNode =
let mut tree: FileNode =
serde_json::from_str(&cached).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(tree));
strip_content(&mut tree);
return Ok(Json(TreeResponse { cache_key: key, tree }));
}
// Clone into temp dir
@@ -138,18 +196,20 @@ async fn scan_git(
let file_count = count_leaves(&tree);
// Cache with full content, then strip for response
if let Ok(json_str) = serde_json::to_string(&tree) {
state.cache.set(&key, &json_str);
state.cache.record_repo(&key, &repo_name, &url, file_count);
}
Ok(Json(tree))
strip_content(&mut tree);
Ok(Json(TreeResponse { cache_key: key, tree }))
}
async fn scan_zip(
State(state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> Result<Json<FileNode>, (StatusCode, String)> {
) -> Result<Json<TreeResponse>, (StatusCode, String)> {
let field = multipart
.next_field()
.await
@@ -173,9 +233,10 @@ async fn scan_zip(
if let Some(cached) = state.cache.get(&key) {
info!("Cache hit for zip {file_name}");
let tree: FileNode =
let mut tree: FileNode =
serde_json::from_str(&cached).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(tree));
strip_content(&mut tree);
return Ok(Json(TreeResponse { cache_key: key, tree }));
}
let tmp = TempDir::new().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -235,10 +296,12 @@ async fn scan_zip(
let file_count = count_leaves(&tree);
// Cache with full content, then strip for response
if let Ok(json_str) = serde_json::to_string(&tree) {
state.cache.set(&key, &json_str);
state.cache.record_repo(&key, &zip_name, &format!("zip:{file_name}"), file_count);
}
Ok(Json(tree))
strip_content(&mut tree);
Ok(Json(TreeResponse { cache_key: key, tree }))
}