feat: proper file tree walker and blob viewer for code browser New XRPC endpoints (dev.panproto.node.*): - listTree: walks git tree at a ref+path, returns directory entries (files and dirs) with names, types, OIDs, and sizes - getBlob: returns raw file content at a ref+path from git mirror Code browser rewrite: - Tree page now shows actual directory listings with folder/file icons, sizes, and clickable navigation - Clicking refs/heads/main navigates into the branch's root tree - Clicking directories navigates deeper into the tree - Clicking files shows syntax-highlighted content with schema sidebar - No more "No refs found" or "invalid object ID" errors Also: parse all files in getProjectSchema (no 50-file limit), prioritize source code over config files in sort order, reduce tap parallelism to prevent post-reboot overloads.

Author: Aaron Steven White
Commit 1f39318fd10231a25a19c39fb2cd1b7f204000a7
Parent: dfd98e9089
Structural diff unavailable

These commits were pushed via plain git push, so no pre-parsed schemas are available. Install git-remote-cospan and re-push via panproto:// to see scope-level changes, breaking change detection, and semantic diffs.

brew install panproto/tap/git-remote-cospan
9 files changed +486 -200
@@ -6,38 +6,14 @@ import { createHighlighter } from 'shiki';
66 
77 // Map file extensions to Shiki language identifiers
88 const extensionToLang: Record<string, string> = {
9-	ts: 'typescript',
10-	tsx: 'tsx',
11-	js: 'javascript',
12-	jsx: 'jsx',
13-	rs: 'rust',
14-	py: 'python',
15-	go: 'go',
16-	json: 'json',
17-	yaml: 'yaml',
18-	yml: 'yaml',
19-	toml: 'toml',
20-	md: 'markdown',
21-	css: 'css',
22-	html: 'html',
23-	sql: 'sql',
24-	proto: 'proto',
25-	graphql: 'graphql',
26-	sh: 'bash',
27-	bash: 'bash',
28-	txt: 'text',
29-	xml: 'xml',
30-	svg: 'xml',
31-	c: 'c',
32-	cpp: 'cpp',
33-	h: 'c',
34-	hpp: 'cpp',
35-	java: 'java',
36-	kt: 'kotlin',
37-	swift: 'swift',
38-	rb: 'ruby',
39-	php: 'php',
40-	zig: 'zig'
9+	ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
10+	rs: 'rust', py: 'python', go: 'go', json: 'json',
11+	yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown',
12+	css: 'css', html: 'html', sql: 'sql', proto: 'proto',
13+	graphql: 'graphql', sh: 'bash', bash: 'bash', txt: 'text',
14+	xml: 'xml', svg: 'xml', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
15+	java: 'java', kt: 'kotlin', swift: 'swift', rb: 'ruby',
16+	php: 'php', zig: 'zig', svelte: 'svelte',
4117 };
4218 
4319 function detectLanguage(path: string): string {
@@ -45,158 +21,142 @@ function detectLanguage(path: string): string {
4521 	return extensionToLang[ext] ?? 'text';
4622 }
4723 
48-// Cached Shiki highlighter instance
4924 let highlighterPromise: ReturnType<typeof createHighlighter> | null = null;
50-
5125 function getHighlighter() {
5226 	if (!highlighterPromise) {
5327 		highlighterPromise = createHighlighter({
5428 			themes: ['github-dark'],
5529 			langs: [
56-				'typescript',
57-				'tsx',
58-				'javascript',
59-				'jsx',
60-				'rust',
61-				'python',
62-				'go',
63-				'json',
64-				'yaml',
65-				'toml',
66-				'markdown',
67-				'css',
68-				'html',
69-				'sql',
70-				'bash',
71-				'text',
72-				'xml',
73-				'c',
74-				'cpp',
75-				'java',
76-				'kotlin',
77-				'swift',
78-				'ruby',
79-				'php',
80-				'zig',
81-				'graphql',
82-				'proto',
83-				'svelte',
30+				'typescript', 'tsx', 'javascript', 'jsx', 'rust', 'python',
31+				'go', 'json', 'yaml', 'toml', 'markdown', 'css', 'html',
32+				'sql', 'bash', 'text', 'xml', 'c', 'cpp', 'java', 'kotlin',
33+				'swift', 'ruby', 'php', 'zig', 'graphql', 'proto', 'svelte',
8434 			]
8535 		});
8636 	}
8737 	return highlighterPromise;
8838 }
8939 
90-// Appview proxy response types
91-interface ProxyRef {
92-	ref: string;
93-	target: string;
94-}
95-
96-interface ProxyRefList {
97-	refs: ProxyRef[];
98-}
99-
100-interface ProxyObject {
101-	id: string;
102-	object: {
103-		type: string;
104-		protocol?: string;
105-		vertexCount?: number;
106-		edgeCount?: number;
107-		message?: string;
108-		author?: string;
109-		[key: string]: unknown;
110-	};
40+// XRPC response types
41+interface TreeEntry {
42+	name: string;
43+	type: 'file' | 'dir';
44+	oid: string;
45+	size?: number;
11146 }
11247 
113-// Frontend types
114-interface DisplayRef {
115-	name: string;
116-	target: string;
117-	type: 'branch' | 'tag';
48+interface ListTreeResponse {
49+	ref: string;
50+	commit: string;
51+	path: string;
52+	entries: TreeEntry[];
11853 }
11954 
120-interface TreePageData {
121-	repo: {
122-		did: string;
123-		name: string;
124-		protocol: string;
125-		starCount: number;
126-		openIssueCount: number;
127-		openMrCount: number;
128-		description: string | null;
129-		createdAt: string;
130-		updatedAt: string;
131-	};
55+interface GetBlobResponse {
13256 	path: string;
133-	mode: 'tree' | 'blob';
134-	refs?: DisplayRef[];
135-	object?: {
136-		code: string;
137-		language: string;
138-		highlightedHtml: string;
139-	};
140-	fileSchema?: FileSchemaResponse | null;
141-	error?: string;
57+	commit: string;
58+	binary: boolean;
59+	size: number;
60+	content: string | null;
14261 }
14362 
144-export const load: PageServerLoad = async ({ params }): Promise<TreePageData> => {
63+export const load: PageServerLoad = async ({ params }) => {
14564 	const repo = await getRepo({ did: params.did, name: params.repo });
14665 	const path = params.path || '';
14766 
148-	// If no path provided, show the refs tree via appview proxy
149-	if (!path) {
67+	// Determine if the path starts with a ref name (refs/heads/*, refs/tags/*)
68+	// and split it into ref + subpath
69+	let refName: string | undefined;
70+	let treePath = path;
71+
72+	if (path.startsWith('refs/heads/') || path.startsWith('refs/tags/')) {
73+		// Extract the ref name and any remaining path
74+		const parts = path.split('/');
75+		// refs/heads/main or refs/tags/v1.0
76+		refName = parts.slice(0, 3).join('/');
77+		treePath = parts.slice(3).join('/');
78+	}
79+
80+	// If no path (or just a ref with no subpath), show directory listing
81+	if (!treePath) {
15082 		try {
151-			const result = await xrpcQuery<ProxyRefList>(
152-				'dev.cospan.node.proxy.listRefs',
153-				{ did: params.did, repo: params.repo }
83+			const result = await xrpcQuery<ListTreeResponse>(
84+				'dev.panproto.node.proxy.listTree',
85+				{
86+					did: params.did,
87+					repo: params.repo,
88+					ref: refName,
89+					path: '',
90+				}
15491 			);
155-			const refs: DisplayRef[] = result.refs.map((r) => ({
156-				name: r.ref,
157-				target: r.target,
158-				type: r.ref.startsWith('refs/tags/') ? 'tag' as const : 'branch' as const,
159-			}));
160-			return { repo, path: '', mode: 'tree', refs };
92+			return {
93+				repo, path, mode: 'tree' as const,
94+				ref: refName ?? result.ref,
95+				entries: result.entries,
96+			};
16197 		} catch (e) {
16298 			return {
163-				repo,
164-				path: '',
165-				mode: 'tree',
166-				refs: [],
167-				error: `Could not fetch refs: ${e instanceof Error ? e.message : 'Unknown error'}`
99+				repo, path, mode: 'tree' as const,
100+				ref: refName,
101+				entries: [] as TreeEntry[],
102+				error: `Could not list tree: ${e instanceof Error ? e.message : 'Unknown error'}`,
168103 			};
169104 		}
170105 	}
171106 
172-	// Path provided: try to fetch the blob via appview proxy.
173-	// The proxy getObject endpoint returns structured metadata, but for
174-	// code display we need raw file content. Use the node directly for
175-	// blob fetching via the git mirror's listCommits to get the tree.
176-	// For now, try fetching from the node's git smart HTTP endpoint
177-	// which serves raw blobs.
107+	// Path provided: try to list it as a directory first, then as a blob
178108 	try {
179-		// Use the appview proxy to get the object
180-		const result = await xrpcQuery<ProxyObject>(
181-			'dev.cospan.node.proxy.getObject',
182-			{ did: params.did, repo: params.repo, id: path }
109+		const result = await xrpcQuery<ListTreeResponse>(
110+			'dev.panproto.node.proxy.listTree',
111+			{
112+				did: params.did,
113+				repo: params.repo,
114+				ref: refName,
115+				path: treePath,
116+			}
183117 		);
118+		return {
119+			repo, path, mode: 'tree' as const,
120+			ref: refName ?? result.ref,
121+			entries: result.entries,
122+		};
123+	} catch {
124+		// Not a directory, try as a file
125+	}
184126 
185-		// The proxy returns structured data, not raw file content.
186-		// We need to extract the content if it's a schema object,
187-		// or show metadata for other types.
188-		const language = detectLanguage(path);
189-		const code = JSON.stringify(result.object, null, 2);
127+	try {
128+		const blob = await xrpcQuery<GetBlobResponse>(
129+			'dev.panproto.node.proxy.getBlob',
130+			{
131+				did: params.did,
132+				repo: params.repo,
133+				ref: refName,
134+				path: treePath,
135+			}
136+		);
137+
138+		if (blob.binary || !blob.content) {
139+			return {
140+				repo, path, mode: 'blob' as const,
141+				ref: refName,
142+				object: {
143+					code: '(binary file)',
144+					language: 'text',
145+					highlightedHtml: '<pre><code>(binary file)</code></pre>',
146+				},
147+			};
148+		}
190149 
150+		const language = detectLanguage(treePath);
191151 		let highlightedHtml: string;
192152 		try {
193153 			const highlighter = await getHighlighter();
194-			highlightedHtml = highlighter.codeToHtml(code, {
195-				lang: 'json',
154+			highlightedHtml = highlighter.codeToHtml(blob.content, {
155+				lang: language,
196156 				theme: 'github-dark'
197157 			});
198158 		} catch {
199-			highlightedHtml = `<pre><code>${escapeHtml(code)}</code></pre>`;
159+			highlightedHtml = `<pre><code>${escapeHtml(blob.content)}</code></pre>`;
200160 		}
201161 
202162 		// Fetch file schema (best-effort)
@@ -205,48 +165,24 @@ export const load: PageServerLoad = async ({ params }): Promise<TreePageData> =>
205165 			fileSchema = await getFileSchema({
206166 				did: params.did,
207167 				repo: params.repo,
208-				commit: 'HEAD',
209-				path,
168+				commit: blob.commit,
169+				path: treePath,
210170 			});
211-		} catch {
212-			// Schema unavailable
213-		}
171+		} catch { /* schema unavailable */ }
214172 
215173 		return {
216-			repo,
217-			path,
218-			mode: 'blob',
219-			object: { code, language, highlightedHtml },
174+			repo, path, mode: 'blob' as const,
175+			ref: refName,
176+			object: { code: blob.content, language, highlightedHtml },
220177 			fileSchema,
221178 		};
222179 	} catch (e) {
223-		// Object not found or node unreachable
224-		try {
225-			const result = await xrpcQuery<ProxyRefList>(
226-				'dev.cospan.node.proxy.listRefs',
227-				{ did: params.did, repo: params.repo }
228-			);
229-			const refs: DisplayRef[] = result.refs.map((r) => ({
230-				name: r.ref,
231-				target: r.target,
232-				type: r.ref.startsWith('refs/tags/') ? 'tag' as const : 'branch' as const,
233-			}));
234-			return {
235-				repo,
236-				path,
237-				mode: 'tree',
238-				refs,
239-				error: `Could not fetch "${path}": ${e instanceof Error ? e.message : 'Unknown error'}`
240-			};
241-		} catch {
242-			return {
243-				repo,
244-				path,
245-				mode: 'tree',
246-				refs: [],
247-				error: 'Could not connect to node'
248-			};
249-		}
180+		return {
181+			repo, path, mode: 'tree' as const,
182+			ref: refName,
183+			entries: [] as TreeEntry[],
184+			error: `Could not fetch "${treePath}": ${e instanceof Error ? e.message : 'Unknown error'}`,
185+		};
250186 	}
251187 };
252188 
@@ -1,6 +1,5 @@
11 <script lang="ts">
22 	import { getContext } from 'svelte';
3-	import FileTree from '$lib/components/repo/FileTree.svelte';
43 	import CodeView from '$lib/components/repo/CodeView.svelte';
54 	import FileSchemaSidebar from '$lib/components/repo/FileSchemaSidebar.svelte';
65 	import BackLink from '$lib/components/shared/BackLink.svelte';
@@ -9,23 +8,33 @@
98 
109 	let basePath = $derived(`/${data.repo.did}/${data.repo.name}`);
1110 
11+	// Build the tree URL prefix, including the ref if present
12+	let treeBase = $derived(
13+		data.ref ? `${basePath}/tree/${data.ref}` : `${basePath}/tree`
14+	);
15+
1216 	const repoLayout = getContext<any>('repoLayout');
1317 	$effect(() => {
1418 		const crumbs: { label: string; href?: string }[] = [];
19+		crumbs.push({ label: 'Code', href: `${basePath}/tree` });
1520 
1621 		if (data.path) {
17-			const segments = data.path.split('/').filter(Boolean);
18-			let accumulated = '';
19-			for (let i = 0; i < segments.length; i++) {
20-				accumulated += (accumulated ? '/' : '') + segments[i];
21-				if (i < segments.length - 1) {
22-					crumbs.push({ label: segments[i], href: `${basePath}/tree/${accumulated}` });
23-				} else {
24-					crumbs.push({ label: segments[i] });
22+			// Strip the ref prefix from the path for breadcrumb display
23+			const displayPath = data.ref && data.path.startsWith(data.ref)
24+				? data.path.slice(data.ref.length + 1)
25+				: data.path;
26+			if (displayPath) {
27+				const segments = displayPath.split('/').filter(Boolean);
28+				let accumulated = '';
29+				for (let i = 0; i < segments.length; i++) {
30+					accumulated += (accumulated ? '/' : '') + segments[i];
31+					if (i < segments.length - 1) {
32+						crumbs.push({ label: segments[i], href: `${treeBase}/${accumulated}` });
33+					} else {
34+						crumbs.push({ label: segments[i] });
35+					}
2536 				}
2637 			}
27-		} else {
28-			crumbs.push({ label: 'Code' });
2938 		}
3039 
3140 		repoLayout?.setExtraCrumbs(crumbs);
@@ -61,9 +70,39 @@
6170 			</div>
6271 		{/if}
6372 	</div>
64-{:else}
65-	<FileTree refs={data.refs ?? []} {basePath} />
73+{:else if data.entries && data.entries.length > 0}
74+	<!-- Directory listing -->
75+	<div class="rounded-lg border border-border bg-surface-1">
76+		{#each data.entries as entry (entry.name)}
77+			<a
78+				href="{treeBase}/{data.path && !data.path.startsWith('refs/') ? data.path + '/' : ''}{entry.name}"
79+				class="flex items-center gap-3 border-b border-border/50 px-4 py-2.5 last:border-b-0 text-sm transition-colors hover:bg-surface-2/50"
80+			>
81+				{#if entry.type === 'dir'}
82+					<svg class="h-4 w-4 shrink-0 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
83+						<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
84+					</svg>
85+				{:else}
86+					<svg class="h-4 w-4 shrink-0 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
87+						<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
88+					</svg>
89+				{/if}
90+				<span class="font-mono text-xs text-text-primary">{entry.name}</span>
91+				{#if entry.type === 'file' && entry.size != null}
92+					<span class="ml-auto text-[10px] text-text-muted">
93+						{entry.size > 1024 ? `${(entry.size / 1024).toFixed(1)} KB` : `${entry.size} B`}
94+					</span>
95+				{/if}
96+			</a>
97+		{/each}
98+	</div>
99+{:else if !data.error}
100+	<div class="flex flex-col items-center gap-3 py-12 text-text-muted">
101+		<svg class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
102+			<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
103+		</svg>
104+		<p class="text-sm">This directory is empty.</p>
105+	</div>
66106 {/if}
67107 
68-<!-- Back to repo link -->
69108 <BackLink href={basePath} />
@@ -181,6 +181,14 @@ pub fn router(state: Arc<AppState>) -> Router {
181181             get(node_proxy::proxy_get_dependency_graph),
182182         )
183183         .route(
184+            "/xrpc/dev.panproto.node.proxy.listTree",
185+            get(node_proxy::proxy_list_tree),
186+        )
187+        .route(
188+            "/xrpc/dev.panproto.node.proxy.getBlob",
189+            get(node_proxy::proxy_get_blob),
190+        )
191+        .route(
184192             "/xrpc/dev.panproto.node.proxy.getImportStatus",
185193             get(node_proxy::proxy_get_import_status),
186194         )
@@ -298,6 +298,71 @@ pub async fn proxy_get_dependency_graph(
298298     Ok(Json(result))
299299 }
300300 
301+#[derive(Deserialize)]
302+#[serde(rename_all = "camelCase")]
303+pub struct ListTreeParams {
304+    pub did: String,
305+    pub repo: String,
306+    #[serde(rename = "ref")]
307+    pub ref_name: Option<String>,
308+    pub path: Option<String>,
309+}
310+
311+/// GET /xrpc/dev.cospan.node.proxy.listTree
312+pub async fn proxy_list_tree(
313+    State(state): State<Arc<AppState>>,
314+    Query(params): Query<ListTreeParams>,
315+) -> Result<Json<serde_json::Value>, AppError> {
316+    let mut extra: Vec<(&str, String)> = Vec::new();
317+    if let Some(ref r) = params.ref_name {
318+        extra.push(("ref", r.clone()));
319+    }
320+    if let Some(ref p) = params.path {
321+        extra.push(("path", p.clone()));
322+    }
323+    let result = node_proxy::proxy_get_json(
324+        &state,
325+        &params.did,
326+        &params.repo,
327+        "dev.panproto.node.listTree",
328+        &extra,
329+    )
330+    .await
331+    .map_err(AppError::Upstream)?;
332+    Ok(Json(result))
333+}
334+
335+#[derive(Deserialize)]
336+#[serde(rename_all = "camelCase")]
337+pub struct GetBlobParams {
338+    pub did: String,
339+    pub repo: String,
340+    #[serde(rename = "ref")]
341+    pub ref_name: Option<String>,
342+    pub path: String,
343+}
344+
345+/// GET /xrpc/dev.cospan.node.proxy.getBlob
346+pub async fn proxy_get_blob(
347+    State(state): State<Arc<AppState>>,
348+    Query(params): Query<GetBlobParams>,
349+) -> Result<Json<serde_json::Value>, AppError> {
350+    let mut extra: Vec<(&str, String)> = vec![("path", params.path.clone())];
351+    if let Some(ref r) = params.ref_name {
352+        extra.push(("ref", r.clone()));
353+    }
354+    let result = node_proxy::proxy_get_json(
355+        &state,
356+        &params.did,
357+        &params.repo,
358+        "dev.panproto.node.getBlob",
359+        &extra,
360+    )
361+    .await
362+    .map_err(AppError::Upstream)?;
363+    Ok(Json(result))
364+}
365+
301366 /// GET /xrpc/dev.panproto.node.proxy.getImportStatus
302367 pub async fn proxy_get_import_status(
303368     State(state): State<Arc<AppState>>,
@@ -0,0 +1,89 @@
1+//! `GET /xrpc/dev.cospan.node.getBlob`
2+//!
3+//! Returns the raw content of a file (blob) at a given path and ref
4+//! from the git mirror. This powers the code browser's file viewer.
5+
6+use std::sync::Arc;
7+
8+use axum::Json;
9+use axum::extract::{Query, State};
10+use serde::Deserialize;
11+use serde_json::{Value, json};
12+
13+use crate::error::NodeError;
14+use crate::state::NodeState;
15+
16+use super::list_commits::{resolve_default, resolve_ref};
17+
18+#[derive(Deserialize)]
19+#[serde(rename_all = "camelCase")]
20+pub struct Params {
21+    pub did: String,
22+    pub repo: String,
23+    #[serde(rename = "ref")]
24+    pub ref_name: Option<String>,
25+    pub path: String,
26+}
27+
28+pub async fn get_blob(
29+    State(state): State<Arc<NodeState>>,
30+    Query(params): Query<Params>,
31+) -> Result<Json<Value>, NodeError> {
32+    let store = state.store.lock().await;
33+    if !store.has_git_mirror(&params.did, &params.repo) {
34+        return Err(NodeError::RefNotFound(format!(
35+            "repo {}/{} not found",
36+            params.did, params.repo
37+        )));
38+    }
39+    let mirror = store
40+        .open_or_init_git_mirror(&params.did, &params.repo)
41+        .map_err(|e| NodeError::Internal(format!("open mirror: {e}")))?;
42+    drop(store);
43+
44+    let commit_oid = match params.ref_name.as_deref() {
45+        Some(name) => resolve_ref(&mirror, name)?,
46+        None => resolve_default(&mirror)?,
47+    };
48+
49+    let commit = mirror
50+        .find_commit(commit_oid)
51+        .map_err(|e| NodeError::Internal(format!("find commit: {e}")))?;
52+    let tree = commit
53+        .tree()
54+        .map_err(|e| NodeError::Internal(format!("commit tree: {e}")))?;
55+
56+    let entry = tree
57+        .get_path(std::path::Path::new(&params.path))
58+        .map_err(|_| {
59+            NodeError::ObjectNotFound(format!("file '{}' not found", params.path))
60+        })?;
61+
62+    let blob = mirror
63+        .find_blob(entry.id())
64+        .map_err(|_| {
65+            NodeError::ObjectNotFound(format!("'{}' is not a file", params.path))
66+        })?;
67+
68+    let content = blob.content();
69+    let is_binary = content.contains(&0u8);
70+
71+    if is_binary {
72+        Ok(Json(json!({
73+            "path": params.path,
74+            "commit": commit_oid.to_string(),
75+            "binary": true,
76+            "size": content.len(),
77+            "content": serde_json::Value::Null,
78+        })))
79+    } else {
80+        let text = String::from_utf8_lossy(content);
81+        Ok(Json(json!({
82+            "path": params.path,
83+            "commit": commit_oid.to_string(),
84+            "binary": false,
85+            "size": content.len(),
86+            "content": text,
87+        })))
88+    }
89+}
cospan · schematic version control on atproto built on AT Protocol