Improve project repository browser

Co-authored-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-06-25 18:00:05 -04:00
parent 9ad0acf555
commit 2a2be6b1a5
7 changed files with 598 additions and 233 deletions

View File

@@ -23,6 +23,7 @@ pub struct ProjectRepoFileInfo {
pub kind: String,
pub size: Option<u64>,
pub preview_content: Option<String>,
pub last_changed_at: Option<i64>,
}
#[derive(Serialize)]
@@ -161,7 +162,29 @@ fn read_preview_content(
String::from_utf8(bytes).ok()
}
fn parse_ls_tree(repo_dir: &std::path::Path, output: &str) -> Vec<ProjectRepoFileInfo> {
fn parse_last_changed_at(output: &str) -> std::collections::HashMap<String, i64> {
let mut current_timestamp = None;
let mut result = std::collections::HashMap::new();
for line in output.lines().filter(|line| !line.trim().is_empty()) {
if let Ok(timestamp) = line.parse::<i64>() {
current_timestamp = Some(timestamp);
continue;
}
if let Some(timestamp) = current_timestamp {
result.entry(line.to_string()).or_insert(timestamp);
}
}
result
}
fn parse_ls_tree(
repo_dir: &std::path::Path,
output: &str,
last_changed_by_path: &std::collections::HashMap<String, i64>,
) -> Vec<ProjectRepoFileInfo> {
output
.lines()
.filter_map(|line| {
@@ -181,6 +204,7 @@ fn parse_ls_tree(repo_dir: &std::path::Path, output: &str) -> Vec<ProjectRepoFil
kind,
size,
preview_content,
last_changed_at: last_changed_by_path.get(path).copied(),
})
})
.take(250)
@@ -196,8 +220,22 @@ fn snapshot_from_repo(repo_dir: &std::path::Path, auth: &GitAuthConfig) -> Proje
.ok()
.and_then(|output| parse_latest_commit(&output));
let files = if latest_commit.is_some() {
let last_changed_by_path = run_git(
&[
"log",
"--format=%ct",
"--name-only",
"--diff-filter=ACMRT",
"--",
],
Some(repo_dir),
auth,
)
.map(|output| parse_last_changed_at(&output))
.unwrap_or_default();
run_git(&["ls-tree", "-r", "--long", "HEAD"], Some(repo_dir), auth)
.map(|output| parse_ls_tree(repo_dir, &output))
.map(|output| parse_ls_tree(repo_dir, &output, &last_changed_by_path))
.unwrap_or_default()
} else {
Vec::new()

View File

@@ -19,7 +19,6 @@ import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
type Project,
type ProjectRepoFile,
type ProjectRepoSnapshot,
useProjectIssuesQuery,
useProjectQuery,
@@ -37,6 +36,11 @@ import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
findReadmeFile,
ReadmePanel,
RepositoryFilesPanel,
} from "./ProjectRepositoryPanel";
function CloneUrlRow({ url }: { url: string }) {
const [copied, setCopied] = React.useState(false);
@@ -123,138 +127,6 @@ function projectPeople(project: Project, issues: ProjectIssue[]) {
];
}
function formatFileSize(size: number | null) {
if (size === null) return "—";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function baseName(path: string) {
return path.split("/").pop() || path;
}
function dirName(path: string) {
const index = path.lastIndexOf("/");
return index >= 0 ? path.slice(0, index) : "/";
}
function FileBrowser({
files,
selectedFile,
onSelectFile,
}: {
files: ProjectRepoFile[];
selectedFile: ProjectRepoFile | null;
onSelectFile: (file: ProjectRepoFile) => void;
}) {
if (files.length === 0) {
return (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
No files have been pushed yet.
</div>
);
}
return (
<div className="h-full overflow-y-auto py-2">
{files.slice(0, 200).map((file) => {
const isSelected = selectedFile?.path === file.path;
return (
<button
className={cn(
"flex w-full min-w-0 items-center gap-2 px-3 py-1.5 text-left font-mono text-xs transition-colors",
isSelected
? "bg-primary/10 text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
key={file.path}
onClick={() => onSelectFile(file)}
type="button"
>
<FileDiff className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{file.path}</span>
</button>
);
})}
</div>
);
}
function FilePreview({ file }: { file: ProjectRepoFile | null }) {
if (!file) {
return (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Select a file to inspect its path and contents.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-9 items-center gap-2 border-border/50 border-b bg-muted/20 px-3">
<FileDiff className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
{baseName(file.path)}
</span>
<span className="ml-auto shrink-0 text-2xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-background/60">
{file.previewContent ? (
<pre className="min-h-full overflow-x-auto p-4 font-mono text-xs leading-relaxed text-foreground">
<code>{file.previewContent}</code>
</pre>
) : (
<div className="space-y-3 p-4">
<div className="space-y-3 rounded-lg border border-border/50 bg-background/50 p-4">
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Path
</p>
<p className="mt-1 break-all font-mono text-sm text-foreground">
{file.path}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
File
</p>
<p className="mt-1 truncate text-sm text-foreground">
{baseName(file.path)}
</p>
</div>
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Folder
</p>
<p className="mt-1 truncate font-mono text-sm text-foreground">
{dirName(file.path)}
</p>
</div>
<div>
<p className="text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Size
</p>
<p className="mt-1 text-sm text-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
</div>
<p className="text-sm text-muted-foreground">
Preview unavailable for this file. Large and binary files only
show metadata.
</p>
</div>
)}
</div>
</div>
);
}
function LatestCommitPanel({
snapshot,
isLoading,
@@ -282,7 +154,7 @@ function LatestCommitPanel({
return (
<div className="space-y-3 p-4">
<div className="rounded-lg bg-muted/50 p-4">
<div className="rounded-lg bg-muted/70 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="line-clamp-2 text-sm font-medium text-foreground">
@@ -292,7 +164,7 @@ function LatestCommitPanel({
{latestCommit.authorName} · {compactDate(latestCommit.timestamp)}
</p>
</div>
<code className="shrink-0 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
<code className="shrink-0 rounded-md bg-background/55 px-2 py-1 text-xs text-muted-foreground">
{latestCommit.shortHash}
</code>
</div>
@@ -396,7 +268,7 @@ function IssuesPanel({
<div className="space-y-2 p-4">
{issues.slice(0, 10).map((issue) => (
<article
className="space-y-2 rounded-lg bg-muted/50 p-3"
className="space-y-2 rounded-lg bg-muted/70 p-3"
key={issue.id}
>
<div className="flex items-start justify-between gap-3">
@@ -410,7 +282,7 @@ function IssuesPanel({
</p>
) : null}
</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
<span className="shrink-0 rounded-full bg-background/55 px-2 py-0.5 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{issue.status}
</span>
</div>
@@ -451,98 +323,68 @@ function WorkspaceTabs({
issuesLoading: boolean;
}) {
const files = snapshot?.files ?? [];
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
const selectedFile =
files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
React.useEffect(() => {
if (files.length > 0 && !files.some((file) => file.path === selectedPath)) {
setSelectedPath(files[0].path);
}
}, [files, selectedPath]);
const readmeFile = React.useMemo(() => findReadmeFile(files), [files]);
return (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60 shadow-none">
<Tabs className="flex min-h-[32rem] flex-col" defaultValue="files">
<div className="flex items-center justify-between gap-3 border-border/50 border-b bg-muted/20 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<FolderGit2 className="h-4 w-4 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
Workspace
</span>
</div>
<TabsList className="h-8">
<TabsTrigger className="h-7 gap-1 px-2" value="files">
<FolderGit2 className="h-3.5 w-3.5" />
Files
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="activity">
<CircleDot className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="issues">
<ListTodo className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="branches">
<GitBranch className="h-3.5 w-3.5" />
Branches
</TabsTrigger>
</TabsList>
</div>
<Tabs className="space-y-3" defaultValue="files">
<TabsList className="h-8 w-fit justify-start">
<TabsTrigger className="h-7 gap-1 px-2" value="files">
<FolderGit2 className="h-3.5 w-3.5" />
Files
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="activity">
<CircleDot className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="issues">
<ListTodo className="h-3.5 w-3.5" />
Issues
</TabsTrigger>
<TabsTrigger className="h-7 gap-1 px-2" value="branches">
<GitBranch className="h-3.5 w-3.5" />
Branches
</TabsTrigger>
</TabsList>
<TabsContent className="m-0 min-h-0 flex-1" value="files">
<div className="grid h-[32rem] min-h-0 grid-cols-1 lg:grid-cols-[18rem_minmax(0,1fr)]">
<aside className="min-h-0 border-border/50 border-b bg-background/35 lg:border-r lg:border-b-0">
<div className="flex h-8 items-center justify-between border-border/50 border-b px-3">
<span className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
Explorer
</span>
<span className="text-2xs text-muted-foreground">
{files.length} files
</span>
</div>
{snapshotLoading ? (
<p className="p-3 text-sm text-muted-foreground">
Loading files…
</p>
) : snapshotError ? (
<p className="p-3 text-sm text-muted-foreground">
Could not load file tree.
</p>
) : (
<FileBrowser
files={files}
onSelectFile={(file) => setSelectedPath(file.path)}
selectedFile={selectedFile}
/>
)}
</aside>
<FilePreview file={selectedFile} />
</div>
</TabsContent>
<TabsContent className="m-0 space-y-4" value="files">
<RepositoryFilesPanel
error={snapshotError}
files={files}
isLoading={snapshotLoading}
snapshot={snapshot}
/>
<ReadmePanel file={readmeFile} />
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="activity">
<LatestCommitPanel
error={snapshotError}
isLoading={snapshotLoading}
snapshot={snapshot}
/>
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="activity"
>
<LatestCommitPanel
error={snapshotError}
isLoading={snapshotLoading}
snapshot={snapshot}
/>
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="issues">
<IssuesPanel isLoading={issuesLoading} issues={issues} />
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="issues"
>
<IssuesPanel isLoading={issuesLoading} issues={issues} />
</TabsContent>
<TabsContent className="m-0 min-h-0 flex-1" value="branches">
<BranchesPanel
isLoading={repoStateLoading}
project={project}
repoState={repoState}
/>
</TabsContent>
</Tabs>
</section>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="branches"
>
<BranchesPanel
isLoading={repoStateLoading}
project={project}
repoState={repoState}
/>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,481 @@
import {
ArrowLeft,
BookOpen,
ChevronRight,
FileDiff,
FolderGit2,
} from "lucide-react";
import * as React from "react";
import type {
ProjectRepoFile,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import { Button } from "@/shared/ui/button";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
function compactDate(createdAt: number) {
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function formatFileSize(size: number | null) {
if (size === null) return "—";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function baseName(path: string) {
return path.split("/").pop() || path;
}
const FILE_LANGUAGE_BY_EXTENSION: Record<string, string> = {
c: "c",
cc: "cpp",
cpp: "cpp",
cs: "csharp",
css: "css",
dart: "dart",
go: "go",
h: "c",
hpp: "cpp",
html: "html",
java: "java",
js: "javascript",
json: "json",
jsx: "jsx",
kt: "kotlin",
kts: "kotlin",
md: "markdown",
mjs: "javascript",
mts: "typescript",
py: "python",
rb: "ruby",
rs: "rust",
sh: "bash",
sql: "sql",
swift: "swift",
toml: "toml",
ts: "typescript",
tsx: "tsx",
yaml: "yaml",
yml: "yaml",
zig: "zig",
};
function languageForPath(path: string) {
const fileName = baseName(path).toLowerCase();
if (fileName === "dockerfile") return "dockerfile";
if (fileName === "makefile") return "make";
const extension = fileName.split(".").pop();
return extension ? FILE_LANGUAGE_BY_EXTENSION[extension] : undefined;
}
type RepositoryFileEntry = {
file?: ProjectRepoFile;
fileCount?: number;
lastChangedAt: number | null;
name: string;
path: string;
type: "directory" | "file";
};
function repositoryEntries(
files: ProjectRepoFile[],
currentPath: string,
): RepositoryFileEntry[] {
const directories = new Map<string, RepositoryFileEntry>();
const entries: RepositoryFileEntry[] = [];
const prefix = currentPath ? `${currentPath}/` : "";
for (const file of files) {
if (currentPath && !file.path.startsWith(prefix)) continue;
const relativePath = currentPath
? file.path.slice(prefix.length)
: file.path;
const [name, ...rest] = relativePath.split("/");
if (!name) continue;
if (rest.length > 0) {
const path = currentPath ? `${currentPath}/${name}` : name;
const existing = directories.get(path);
if (existing) {
existing.fileCount = (existing.fileCount ?? 0) + 1;
existing.lastChangedAt = Math.max(
existing.lastChangedAt ?? 0,
file.lastChangedAt ?? 0,
);
} else {
directories.set(path, {
fileCount: 1,
lastChangedAt: file.lastChangedAt,
name,
path,
type: "directory",
});
}
continue;
}
entries.push({
file,
lastChangedAt: file.lastChangedAt,
name,
path: file.path,
type: "file",
});
}
return [...directories.values(), ...entries].sort((left, right) => {
if (left.type !== right.type) return left.type === "directory" ? -1 : 1;
return left.name.localeCompare(right.name);
});
}
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
/^readme(?:\.(?:md|markdown|mdx|txt))?$/i.test(baseName(file.path)),
);
return readmes.find((file) => !file.path.includes("/")) ?? readmes[0] ?? null;
}
function decodeHtmlEntities(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function htmlInlineToMarkdown(value: string): string {
return decodeHtmlEntities(value)
.replace(/<br\s*\/?\s*>/gi, "\n")
.replace(/<img\b([^>]*)>/gi, (_match: string, attrs: string) => {
const src = attrs.match(/\bsrc=["']([^"']+)["']/i)?.[1];
const alt = attrs.match(/\balt=["']([^"']*)["']/i)?.[1] ?? "";
return src ? `![${alt}](${src})` : "";
})
.replace(
/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
(_match: string, href: string, label: string) =>
`[${htmlInlineToMarkdown(label).trim()}](${href})`,
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**")
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*")
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, "`$1`")
.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, "$1")
.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, "$1")
.replace(/<[^>]+>/g, "")
.trim();
}
function normalizeReadmeMarkdown(content: string) {
return content
.replace(
/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
(_match, depth: string, value: string) =>
`${"#".repeat(Number(depth))} ${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<p\b[^>]*>([\s\S]*?)<\/p>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<div\b[^>]*>([\s\S]*?)<\/div>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<center\b[^>]*>([\s\S]*?)<\/center>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function BreadcrumbButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
className="truncate rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={onClick}
type="button"
>
{children}
</button>
);
}
function FileContentPanel({
file,
onBack,
}: {
file: ProjectRepoFile;
onBack: () => void;
}) {
const language = languageForPath(file.path);
return (
<div className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex min-h-10 items-center gap-2 border-border/50 border-b bg-muted/20 px-4">
<Button className="h-7 px-2" onClick={onBack} size="sm" variant="ghost">
<ArrowLeft className="h-3.5 w-3.5" />
Back
</Button>
<FileDiff className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
{file.path}
</span>
<span className="shrink-0 text-2xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
{file.previewContent ? (
<pre className="max-h-[36rem] overflow-auto bg-background/60 p-4">
{language ? (
<SyntaxHighlightedCode
className="text-xs leading-relaxed"
code={file.previewContent}
language={language}
/>
) : (
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
{file.previewContent}
</code>
)}
</pre>
) : (
<div className="p-6 text-sm text-muted-foreground">
Preview unavailable for this file. Large and binary files only show
metadata.
</div>
)}
</div>
);
}
export function RepositoryFilesPanel({
files,
snapshot,
isLoading,
error,
}: {
files: ProjectRepoFile[];
snapshot: ProjectRepoSnapshot | null | undefined;
isLoading: boolean;
error: unknown;
}) {
const [currentPath, setCurrentPath] = React.useState("");
const [selectedFile, setSelectedFile] =
React.useState<ProjectRepoFile | null>(null);
const entries = React.useMemo(
() => repositoryEntries(files, currentPath),
[currentPath, files],
);
const latestCommit = snapshot?.latestCommit ?? null;
const pathSegments = currentPath ? currentPath.split("/") : [];
const filesKey = React.useMemo(
() => files.map((file) => file.path).join("\0"),
[files],
);
React.useEffect(() => {
if (!filesKey) return;
setCurrentPath("");
setSelectedFile(null);
}, [filesKey]);
if (isLoading) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
Loading repository files…
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
Could not load the repository file tree.
</div>
);
}
if (files.length === 0) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-6 text-center text-sm text-muted-foreground">
No files have been pushed yet.
</div>
);
}
if (selectedFile) {
return (
<FileContentPanel
file={selectedFile}
onBack={() => setSelectedFile(null)}
/>
);
}
return (
<div className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex flex-col gap-2 border-border/50 border-b bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{latestCommit?.subject ?? "Repository files"}
</p>
{latestCommit ? (
<p className="truncate text-xs text-muted-foreground">
{latestCommit.authorName} committed{" "}
{compactDate(latestCommit.timestamp)}
</p>
) : (
<p className="text-xs text-muted-foreground">
{files.length} tracked files
</p>
)}
</div>
{latestCommit ? (
<code className="w-fit shrink-0 rounded-md bg-background/60 px-2 py-1 text-xs text-muted-foreground">
{latestCommit.shortHash}
</code>
) : null}
</div>
<div className="flex min-h-10 min-w-0 items-center gap-1 border-border/50 border-b px-3">
<BreadcrumbButton onClick={() => setCurrentPath("")}>
Files
</BreadcrumbButton>
{pathSegments.map((segment, index) => {
const nextPath = pathSegments.slice(0, index + 1).join("/");
return (
<React.Fragment key={nextPath}>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<BreadcrumbButton onClick={() => setCurrentPath(nextPath)}>
{segment}
</BreadcrumbButton>
</React.Fragment>
);
})}
</div>
<div className="divide-y divide-border/50">
{currentPath ? (
<button
className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors hover:bg-muted/30"
onClick={() => {
const parent = currentPath.split("/").slice(0, -1).join("/");
setCurrentPath(parent);
}}
type="button"
>
<div className="flex min-w-0 items-center gap-2">
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-muted-foreground">
..
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
Parent
</span>
</button>
) : null}
{entries.slice(0, 200).map((entry) => {
const Icon = entry.type === "directory" ? FolderGit2 : FileDiff;
const meta =
entry.type === "directory"
? `${entry.fileCount ?? 0} files`
: formatFileSize(entry.file?.size ?? null);
return (
<button
className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors hover:bg-muted/30"
key={`${entry.type}:${entry.path}`}
onClick={() => {
if (entry.type === "directory") {
setCurrentPath(entry.path);
return;
}
if (entry.file) setSelectedFile(entry.file);
}}
type="button"
>
<div className="flex min-w-0 items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">
{entry.name}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{meta}
</span>
</button>
);
})}
</div>
</div>
);
}
export function ReadmePanel({ file }: { file: ProjectRepoFile | null }) {
if (!file?.previewContent) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-6 text-sm text-muted-foreground">
Add a README to this repository to describe setup, usage, and project
context.
</div>
);
}
const language = languageForPath(file.path);
const isMarkdown = /\.(?:md|markdown|mdx)$/i.test(file.path);
const readmeContent = isMarkdown
? normalizeReadmeMarkdown(file.previewContent)
: file.previewContent;
return (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex min-h-10 items-center gap-2 border-border/50 border-b bg-muted/20 px-4">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="truncate text-sm font-medium text-foreground">
{baseName(file.path)}
</span>
</div>
<div className="p-4">
{isMarkdown ? (
<Markdown
className="text-sm"
content={readmeContent}
interactive={false}
/>
) : language ? (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<SyntaxHighlightedCode
className="text-xs leading-relaxed"
code={file.previewContent}
language={language}
/>
</pre>
) : (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
{file.previewContent}
</code>
</pre>
)}
</div>
</section>
);
}

View File

@@ -556,7 +556,7 @@ function ProjectGridCard({
}) {
return (
<Card
className="group relative flex min-h-48 flex-col overflow-hidden border-border/60 bg-gradient-to-br from-card via-card to-muted/30 p-4 shadow-sm transition-colors duration-150 hover:border-primary/30 hover:shadow-md"
className="group relative flex min-h-48 flex-col overflow-hidden border-border/60 bg-gradient-to-br from-card via-card to-muted/30 p-4 shadow-none transition-colors duration-150 hover:border-primary/30"
data-testid={`project-card-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
@@ -602,9 +602,9 @@ function ProjectGridCard({
</MetadataItem>
</div>
<div className="mt-auto space-y-2 rounded-lg border border-border/40 bg-background/55 px-2.5 py-2 shadow-inner">
<div className="mt-auto space-y-2 rounded-lg bg-muted/70 px-2.5 py-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
<p className="truncate text-xs font-medium text-muted-foreground">
{getActivityLabel(summary)}
</p>
<div className="relative z-10 flex shrink-0 items-center gap-1">
@@ -646,7 +646,7 @@ function ProjectListRow({
}) {
return (
<Card
className="group relative overflow-hidden border-border/60 bg-gradient-to-r from-card via-card to-muted/20 p-3 shadow-sm transition-colors duration-150 hover:border-primary/30 hover:shadow-md"
className="group relative overflow-hidden border-border/60 bg-gradient-to-r from-card via-card to-muted/20 p-3 shadow-none transition-colors duration-150 hover:border-primary/30"
data-testid={`project-row-${project.dtag}`}
>
<ProjectCardButton onOpen={onOpen} project={project} />
@@ -685,8 +685,8 @@ function ProjectListRow({
</div>
</div>
<div className="relative z-10 flex min-w-0 items-center justify-start gap-2 lg:justify-end">
<p className="truncate text-xs text-muted-foreground">
<div className="relative z-10 flex min-w-0 items-center justify-start gap-2 rounded-lg bg-muted/70 px-2.5 py-2 lg:justify-end">
<p className="truncate text-xs font-medium text-muted-foreground">
{getActivityLabel(summary)}
</p>
<ProjectPeopleStack

View File

@@ -15,6 +15,7 @@ type RawProjectRepoFile = {
kind: string;
size: number | null;
preview_content: string | null;
last_changed_at: number | null;
};
type RawProjectRepoSnapshot = {
@@ -41,6 +42,7 @@ function fromRawProjectRepoSnapshot(
kind: file.kind,
size: file.size,
previewContent: file.preview_content,
lastChangedAt: file.last_changed_at,
})),
};
}

View File

@@ -172,6 +172,7 @@ export type ProjectRepoFile = {
kind: string;
size: number | null;
previewContent: string | null;
lastChangedAt: number | null;
};
export type ProjectRepoSnapshot = {

View File

@@ -1519,9 +1519,10 @@ function FileCard({
);
}
function SyntaxHighlightedCode({
export function SyntaxHighlightedCode({
code,
language,
className,
...props
}: {
code: string;
@@ -1597,7 +1598,7 @@ function SyntaxHighlightedCode({
}
}, [code, language, themeName, loadedKey]);
const codeClassName = CODE_BLOCK_CLASS;
const codeClassName = cn(CODE_BLOCK_CLASS, className);
if (!tokens) {
const lines = code.split("\n");