mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
481
desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
Normal file
481
desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 ? `` : "";
|
||||
})
|
||||
.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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export type ProjectRepoFile = {
|
||||
kind: string;
|
||||
size: number | null;
|
||||
previewContent: string | null;
|
||||
lastChangedAt: number | null;
|
||||
};
|
||||
|
||||
export type ProjectRepoSnapshot = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user