mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary - let the Projects page surface flow through repository, pull request, and issue list/grid layouts - remove opaque fills from project-detail content across Overview, Files, Commits, Issues, Pull Requests, and Contributors - preserve borders, hover feedback, and intentional nested fills for code, inputs, badges, and warnings ## Related Follow-up to #3416. ## Testing - `pnpm exec biome check` on the changed Projects UI and E2E files - `pnpm build:e2e` - focused Playwright smoke coverage for overview, subsection list/grid, and project-detail transparency (3 passed) - pre-commit checks passed - desktop pre-push checks passed; the unrelated integration hook remains blocked by a stale local checksum for migration 25 Signed-off-by: Thomas Petersen <thomasp@squareup.com>
912 lines
26 KiB
TypeScript
912 lines
26 KiB
TypeScript
import {
|
|
Braces,
|
|
ChevronRight,
|
|
CodeXml,
|
|
Database,
|
|
FileArchive,
|
|
FileAudio,
|
|
FileCode2,
|
|
FileCog,
|
|
FileDiff,
|
|
FileImage,
|
|
FileJson,
|
|
FileLock2,
|
|
FileSpreadsheet,
|
|
FileText,
|
|
FileType,
|
|
FileVideo,
|
|
FolderGit2,
|
|
Package,
|
|
Settings,
|
|
Terminal,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import type {
|
|
ProjectRepoFile,
|
|
ProjectRepoSnapshot,
|
|
} from "@/features/projects/hooks";
|
|
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
|
import { useUserSearchQuery } from "@/features/profile/hooks";
|
|
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
|
import type { UserSearchResult } from "@/shared/api/types";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
|
import { SyntaxHighlightedCode } from "@/shared/ui/markdown";
|
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|
import {
|
|
PROJECT_DETAIL_PANEL_CLASS,
|
|
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
|
|
} from "./projectPanelStyles";
|
|
import {
|
|
type RepoSourceHeaderControls,
|
|
RepoSourceDropdown,
|
|
RepoSyncActionButton,
|
|
RepositoryBranchDropdown,
|
|
} from "./ProjectRepositorySource";
|
|
|
|
function pluralize(count: number, singular: string) {
|
|
return `${count} ${singular}${count === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
export function formatLastChangedAt(timestamp: number | null) {
|
|
if (!timestamp) return "—";
|
|
return new Date(timestamp * 1_000).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
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 normalizeAuthorLookupValue(value: string | null | undefined) {
|
|
return value?.trim().toLowerCase() ?? "";
|
|
}
|
|
|
|
type CommitAuthorProfile = {
|
|
avatarUrl: string | null;
|
|
displayName: string | null;
|
|
isAgent?: boolean;
|
|
nip05Handle?: string | null;
|
|
ownerPubkey?: string | null;
|
|
};
|
|
|
|
function profileMatchesCommitAuthor(
|
|
commit: ProjectRepoFile["latestCommit"],
|
|
profile: CommitAuthorProfile,
|
|
pubkey?: string,
|
|
) {
|
|
if (!commit) return false;
|
|
|
|
const authorName = normalizeAuthorLookupValue(commit.authorName);
|
|
const authorEmail = normalizeAuthorLookupValue(commit.authorEmail);
|
|
if (!authorName && !authorEmail) return false;
|
|
|
|
const candidates = [
|
|
pubkey,
|
|
profile.displayName,
|
|
profile.nip05Handle,
|
|
profile.ownerPubkey,
|
|
].map(normalizeAuthorLookupValue);
|
|
|
|
return candidates.includes(authorName) || candidates.includes(authorEmail);
|
|
}
|
|
|
|
function profileForCommitAuthor(
|
|
commit: ProjectRepoFile["latestCommit"],
|
|
profiles: UserProfileLookup | undefined,
|
|
) {
|
|
if (!commit || !profiles) return null;
|
|
|
|
for (const [pubkey, profile] of Object.entries(profiles)) {
|
|
if (profileMatchesCommitAuthor(commit, profile, pubkey)) {
|
|
return profile;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function searchedProfileForCommitAuthor(
|
|
commit: ProjectRepoFile["latestCommit"],
|
|
users: UserSearchResult[] | undefined,
|
|
) {
|
|
if (!commit || !users?.length) return null;
|
|
|
|
return (
|
|
users.find((user) =>
|
|
profileMatchesCommitAuthor(commit, user, user.pubkey),
|
|
) ?? users[0]
|
|
);
|
|
}
|
|
|
|
function commitAuthorLabel(
|
|
commit: ProjectRepoFile["latestCommit"],
|
|
profile?: CommitAuthorProfile | null,
|
|
) {
|
|
if (!commit) return "";
|
|
|
|
return (
|
|
profile?.displayName?.trim() ||
|
|
profile?.nip05Handle?.trim() ||
|
|
commit.authorName
|
|
);
|
|
}
|
|
|
|
function RepositoryCommitCell({
|
|
commit,
|
|
profiles,
|
|
}: {
|
|
commit: ProjectRepoFile["latestCommit"];
|
|
profiles?: UserProfileLookup;
|
|
}) {
|
|
if (!commit) return <span className="text-muted-foreground">—</span>;
|
|
|
|
return (
|
|
<p className="truncate text-sm text-foreground">
|
|
{commit.subject}
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
· {commitAuthorLabel(commit, profileForCommitAuthor(commit, profiles))}
|
|
</span>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
export 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",
|
|
};
|
|
|
|
export 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 FileIconVisual = {
|
|
Icon: React.ComponentType<{ className?: string }>;
|
|
className: string;
|
|
containerClassName: string;
|
|
};
|
|
|
|
const CODE_EXTENSIONS = new Set([
|
|
"c",
|
|
"cc",
|
|
"cpp",
|
|
"cs",
|
|
"dart",
|
|
"go",
|
|
"java",
|
|
"js",
|
|
"jsx",
|
|
"kt",
|
|
"kts",
|
|
"mjs",
|
|
"mts",
|
|
"py",
|
|
"rb",
|
|
"rs",
|
|
"swift",
|
|
"ts",
|
|
"tsx",
|
|
"zig",
|
|
]);
|
|
const IMAGE_EXTENSIONS = new Set([
|
|
"gif",
|
|
"heic",
|
|
"jpeg",
|
|
"jpg",
|
|
"png",
|
|
"svg",
|
|
"webp",
|
|
]);
|
|
const ARCHIVE_EXTENSIONS = new Set([
|
|
"7z",
|
|
"bz2",
|
|
"gz",
|
|
"rar",
|
|
"tar",
|
|
"tgz",
|
|
"zip",
|
|
]);
|
|
const AUDIO_EXTENSIONS = new Set(["aac", "flac", "m4a", "mp3", "ogg", "wav"]);
|
|
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "webm"]);
|
|
const SPREADSHEET_EXTENSIONS = new Set(["csv", "ods", "tsv", "xls", "xlsx"]);
|
|
const TEXT_EXTENSIONS = new Set(["md", "mdx", "rst", "txt"]);
|
|
|
|
function extensionForPath(path: string) {
|
|
const name = baseName(path).toLowerCase();
|
|
if (!name.includes(".")) return "";
|
|
return name.split(".").pop() ?? "";
|
|
}
|
|
|
|
function fileIconVisual(entry: RepositoryFileEntry): FileIconVisual {
|
|
if (entry.type === "directory") {
|
|
return {
|
|
Icon: FolderGit2,
|
|
className: "fill-sky-500/25 text-sky-500",
|
|
containerClassName: "bg-sky-500/15",
|
|
};
|
|
}
|
|
|
|
const name = entry.name.toLowerCase();
|
|
const extension = extensionForPath(entry.name);
|
|
|
|
if (
|
|
name === "dockerfile" ||
|
|
name === "containerfile" ||
|
|
name === "package.json"
|
|
) {
|
|
return {
|
|
Icon: Package,
|
|
className: "fill-orange-500/20 text-orange-500",
|
|
containerClassName: "bg-orange-500/15",
|
|
};
|
|
}
|
|
|
|
if (name.includes("lock") || extension === "pem" || extension === "key") {
|
|
return {
|
|
Icon: FileLock2,
|
|
className: "fill-amber-500/20 text-amber-500",
|
|
containerClassName: "bg-amber-500/15",
|
|
};
|
|
}
|
|
|
|
if (extension === "json") {
|
|
return {
|
|
Icon: FileJson,
|
|
className: "fill-yellow-500/20 text-yellow-500",
|
|
containerClassName: "bg-yellow-500/15",
|
|
};
|
|
}
|
|
|
|
if (
|
|
["yaml", "yml", "toml", "ini", "conf", "config", "env"].includes(extension)
|
|
) {
|
|
return {
|
|
Icon: Settings,
|
|
className: "fill-zinc-500/20 text-zinc-500",
|
|
containerClassName: "bg-zinc-500/15",
|
|
};
|
|
}
|
|
|
|
if (["html", "xml"].includes(extension)) {
|
|
return {
|
|
Icon: CodeXml,
|
|
className: "fill-rose-500/20 text-rose-500",
|
|
containerClassName: "bg-rose-500/15",
|
|
};
|
|
}
|
|
|
|
if (extension === "css") {
|
|
return {
|
|
Icon: Braces,
|
|
className: "fill-violet-500/20 text-violet-500",
|
|
containerClassName: "bg-violet-500/15",
|
|
};
|
|
}
|
|
|
|
if (CODE_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileCode2,
|
|
className: "fill-blue-500/20 text-blue-500",
|
|
containerClassName: "bg-blue-500/15",
|
|
};
|
|
}
|
|
|
|
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileImage,
|
|
className: "fill-pink-500/20 text-pink-500",
|
|
containerClassName: "bg-pink-500/15",
|
|
};
|
|
}
|
|
|
|
if (ARCHIVE_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileArchive,
|
|
className: "fill-orange-500/20 text-orange-500",
|
|
containerClassName: "bg-orange-500/15",
|
|
};
|
|
}
|
|
|
|
if (AUDIO_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileAudio,
|
|
className: "fill-purple-500/20 text-purple-500",
|
|
containerClassName: "bg-purple-500/15",
|
|
};
|
|
}
|
|
|
|
if (VIDEO_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileVideo,
|
|
className: "fill-red-500/20 text-red-500",
|
|
containerClassName: "bg-red-500/15",
|
|
};
|
|
}
|
|
|
|
if (SPREADSHEET_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileSpreadsheet,
|
|
className: "fill-emerald-500/20 text-emerald-500",
|
|
containerClassName: "bg-emerald-500/15",
|
|
};
|
|
}
|
|
|
|
if (extension === "sql" || extension === "db" || extension === "sqlite") {
|
|
return {
|
|
Icon: Database,
|
|
className: "fill-cyan-500/20 text-cyan-500",
|
|
containerClassName: "bg-cyan-500/15",
|
|
};
|
|
}
|
|
|
|
if (["bash", "fish", "sh", "zsh"].includes(extension)) {
|
|
return {
|
|
Icon: Terminal,
|
|
className: "fill-lime-500/20 text-lime-500",
|
|
containerClassName: "bg-lime-500/15",
|
|
};
|
|
}
|
|
|
|
if (TEXT_EXTENSIONS.has(extension)) {
|
|
return {
|
|
Icon: FileText,
|
|
className: "fill-slate-500/20 text-slate-500",
|
|
containerClassName: "bg-slate-500/15",
|
|
};
|
|
}
|
|
|
|
if (extension === "pdf") {
|
|
return {
|
|
Icon: FileType,
|
|
className: "fill-red-500/20 text-red-500",
|
|
containerClassName: "bg-red-500/15",
|
|
};
|
|
}
|
|
|
|
return {
|
|
Icon: FileCog,
|
|
className: "fill-muted-foreground/20 text-muted-foreground",
|
|
containerClassName: "bg-muted/70",
|
|
};
|
|
}
|
|
|
|
function RepositoryEntryIcon({ entry }: { entry: RepositoryFileEntry }) {
|
|
const visual = fileIconVisual(entry);
|
|
const Icon = visual.Icon;
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"flex h-6 w-6 shrink-0 items-center justify-center",
|
|
visual.containerClassName,
|
|
)}
|
|
>
|
|
<Icon className={cn("h-4 w-4", visual.className)} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type RepositoryFileEntry = {
|
|
file?: ProjectRepoFile;
|
|
fileCount?: number;
|
|
lastChangedAt: number | null;
|
|
latestCommit: ProjectRepoFile["latestCommit"];
|
|
name: string;
|
|
path: string;
|
|
size: number | null;
|
|
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;
|
|
if ((file.lastChangedAt ?? 0) > (existing.lastChangedAt ?? 0)) {
|
|
existing.lastChangedAt = file.lastChangedAt;
|
|
existing.latestCommit = file.latestCommit;
|
|
}
|
|
} else {
|
|
directories.set(path, {
|
|
fileCount: 1,
|
|
lastChangedAt: file.lastChangedAt,
|
|
latestCommit: file.latestCommit,
|
|
name,
|
|
path,
|
|
size: null,
|
|
type: "directory",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
entries.push({
|
|
file,
|
|
lastChangedAt: file.lastChangedAt,
|
|
latestCommit: file.latestCommit,
|
|
name,
|
|
path: file.path,
|
|
size: file.size,
|
|
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);
|
|
});
|
|
}
|
|
|
|
function openRepositoryEntry(
|
|
entry: RepositoryFileEntry,
|
|
setCurrentPath: (path: string) => void,
|
|
setSelectedFile: (file: ProjectRepoFile) => void,
|
|
) {
|
|
if (entry.type === "directory") {
|
|
setCurrentPath(entry.path);
|
|
return;
|
|
}
|
|
|
|
if (entry.file) setSelectedFile(entry.file);
|
|
}
|
|
|
|
function handleRepositoryEntryKeyDown(
|
|
event: React.KeyboardEvent<HTMLTableRowElement>,
|
|
onOpen: () => void,
|
|
) {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
|
|
event.preventDefault();
|
|
onOpen();
|
|
}
|
|
|
|
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,
|
|
onOpenPath,
|
|
}: {
|
|
file: ProjectRepoFile;
|
|
onOpenPath: (path: string) => void;
|
|
}) {
|
|
const language = languageForPath(file.path);
|
|
const pathSegments = file.path.split("/").filter(Boolean);
|
|
const fileName = pathSegments[pathSegments.length - 1] ?? file.path;
|
|
const directorySegments = pathSegments.slice(0, -1);
|
|
|
|
return (
|
|
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
|
<div className="flex min-h-14 items-center gap-1 border-border/50 border-b bg-muted/20 px-3 py-3">
|
|
<BreadcrumbButton onClick={() => onOpenPath("")}>
|
|
Files
|
|
</BreadcrumbButton>
|
|
{directorySegments.map((segment, index) => {
|
|
const nextPath = directorySegments.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={() => onOpenPath(nextPath)}>
|
|
{segment}
|
|
</BreadcrumbButton>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
|
<FileDiff className="h-4 w-4 text-muted-foreground" />
|
|
<span className="min-w-0 flex-1 truncate px-1.5 py-1 font-mono text-xs text-foreground">
|
|
{fileName}
|
|
</span>
|
|
<div className="hidden shrink-0 items-center gap-3 text-2xs text-muted-foreground sm:flex">
|
|
<span>Last changed {formatLastChangedAt(file.lastChangedAt)}</span>
|
|
<span>{formatFileSize(file.size)}</span>
|
|
</div>
|
|
<span className="shrink-0 text-2xs text-muted-foreground sm:hidden">
|
|
{formatFileSize(file.size)}
|
|
</span>
|
|
</div>
|
|
<div className="border-border/50 border-b bg-muted/10 px-4 py-2 text-2xs text-muted-foreground sm:hidden">
|
|
Last changed {formatLastChangedAt(file.lastChangedAt)}
|
|
</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,
|
|
profiles,
|
|
fallbackAuthorPubkey,
|
|
sourceControls,
|
|
}: {
|
|
files: ProjectRepoFile[];
|
|
snapshot: ProjectRepoSnapshot | null | undefined;
|
|
isLoading: boolean;
|
|
error: unknown;
|
|
profiles?: UserProfileLookup;
|
|
fallbackAuthorPubkey?: string;
|
|
/** Branch picker + remote/local toggle rendered in the panel header. */
|
|
sourceControls?: RepoSourceHeaderControls;
|
|
}) {
|
|
const [currentPath, setCurrentPath] = React.useState("");
|
|
const [selectedFile, setSelectedFile] =
|
|
React.useState<ProjectRepoFile | null>(null);
|
|
const entries = React.useMemo(
|
|
() => repositoryEntries(files, currentPath),
|
|
[currentPath, files],
|
|
);
|
|
const visibleEntries = entries.slice(0, 200);
|
|
const latestCommit = snapshot?.latestCommit ?? null;
|
|
const knownLatestCommitProfile = React.useMemo(
|
|
() => profileForCommitAuthor(latestCommit, profiles),
|
|
[latestCommit, profiles],
|
|
);
|
|
const fallbackLatestCommitProfile = fallbackAuthorPubkey
|
|
? (profiles?.[normalizePubkey(fallbackAuthorPubkey)] ?? null)
|
|
: null;
|
|
const authorSearchQuery = useUserSearchQuery(
|
|
latestCommit?.authorEmail || latestCommit?.authorName || "",
|
|
{
|
|
enabled:
|
|
Boolean(latestCommit) &&
|
|
!knownLatestCommitProfile?.avatarUrl &&
|
|
!fallbackLatestCommitProfile?.avatarUrl,
|
|
limit: 3,
|
|
},
|
|
);
|
|
const searchedLatestCommitProfile = React.useMemo(
|
|
() => searchedProfileForCommitAuthor(latestCommit, authorSearchQuery.data),
|
|
[authorSearchQuery.data, latestCommit],
|
|
);
|
|
const latestCommitProfile =
|
|
knownLatestCommitProfile ??
|
|
searchedLatestCommitProfile ??
|
|
fallbackLatestCommitProfile;
|
|
const latestCommitAuthorLabel = commitAuthorLabel(
|
|
latestCommit,
|
|
latestCommitProfile,
|
|
);
|
|
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]);
|
|
|
|
// Loading/error/empty states keep the header controls visible — the
|
|
// remote/local toggle must stay reachable when one source fails to load.
|
|
const stateMessage = isLoading
|
|
? "Loading repository files…"
|
|
: error
|
|
? "Could not load the repository file tree."
|
|
: files.length === 0
|
|
? "No files have been pushed yet."
|
|
: null;
|
|
if (stateMessage) {
|
|
if (!sourceControls) {
|
|
return (
|
|
<div
|
|
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
|
|
data-project-detail-panel
|
|
>
|
|
{stateMessage}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
|
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
|
|
<RepoSourceDropdown controls={sourceControls} />
|
|
<RepositoryBranchDropdown
|
|
branch={sourceControls.branch}
|
|
branchOptions={sourceControls.branchOptions}
|
|
compact
|
|
createBranchDisabled={sourceControls.createBranchDisabled}
|
|
createBranchTitle={sourceControls.createBranchTitle}
|
|
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
|
|
deleteBranchTitle={sourceControls.deleteBranchTitle}
|
|
onBranchChange={sourceControls.onBranchChange}
|
|
onCreateBranch={sourceControls.onCreateBranch}
|
|
onDeleteBranch={sourceControls.onDeleteBranch}
|
|
onTagChange={sourceControls.onTagChange}
|
|
selectedTag={sourceControls.selectedTag}
|
|
tagOptions={sourceControls.tagOptions}
|
|
/>
|
|
<div className="ml-auto flex shrink-0 items-center">
|
|
<RepoSyncActionButton controls={sourceControls} />
|
|
</div>
|
|
</div>
|
|
<div className="p-4 text-sm text-muted-foreground">{stateMessage}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (selectedFile) {
|
|
return (
|
|
<FileContentPanel
|
|
file={selectedFile}
|
|
onOpenPath={(path) => {
|
|
setSelectedFile(null);
|
|
setCurrentPath(path);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
|
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
|
|
{sourceControls ? (
|
|
<>
|
|
<RepoSourceDropdown controls={sourceControls} />
|
|
<RepositoryBranchDropdown
|
|
branch={sourceControls.branch}
|
|
branchOptions={sourceControls.branchOptions}
|
|
compact
|
|
createBranchDisabled={sourceControls.createBranchDisabled}
|
|
createBranchTitle={sourceControls.createBranchTitle}
|
|
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
|
|
deleteBranchTitle={sourceControls.deleteBranchTitle}
|
|
onBranchChange={sourceControls.onBranchChange}
|
|
onCreateBranch={sourceControls.onCreateBranch}
|
|
onDeleteBranch={sourceControls.onDeleteBranch}
|
|
onTagChange={sourceControls.onTagChange}
|
|
selectedTag={sourceControls.selectedTag}
|
|
tagOptions={sourceControls.tagOptions}
|
|
/>
|
|
</>
|
|
) : (
|
|
<BreadcrumbButton onClick={() => setCurrentPath("")}>
|
|
Files
|
|
</BreadcrumbButton>
|
|
)}
|
|
{sourceControls && pathSegments.length > 0 ? (
|
|
<>
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
|
<BreadcrumbButton onClick={() => setCurrentPath("")}>
|
|
Files
|
|
</BreadcrumbButton>
|
|
</>
|
|
) : null}
|
|
{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>
|
|
);
|
|
})}
|
|
{sourceControls ? (
|
|
<div className="ml-auto flex shrink-0 items-center">
|
|
<RepoSyncActionButton controls={sourceControls} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full caption-bottom text-sm">
|
|
<thead>
|
|
<tr className="border-border/50 border-b bg-muted/20">
|
|
<th className="px-4 py-3 text-left font-normal" colSpan={3}>
|
|
{latestCommit ? (
|
|
<div className="flex min-w-0 items-center justify-between gap-3 text-sm">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<UserAvatar
|
|
accent={latestCommitProfile?.isAgent === true}
|
|
avatarUrl={latestCommitProfile?.avatarUrl ?? null}
|
|
displayName={latestCommitAuthorLabel}
|
|
size="sm"
|
|
/>
|
|
<p className="min-w-0 flex-1 truncate text-foreground">
|
|
<span className="font-semibold">
|
|
{latestCommitAuthorLabel}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
committed
|
|
</span>
|
|
<span className="font-medium">
|
|
{" "}
|
|
{latestCommit.subject}
|
|
</span>
|
|
<code
|
|
className="mx-1.5 rounded-md bg-background/60 px-1.5 py-0.5 text-2xs text-muted-foreground"
|
|
title={latestCommit.hash}
|
|
>
|
|
{latestCommit.shortHash}
|
|
</code>
|
|
<span className="text-muted-foreground">
|
|
· {pluralize(files.length, "file")}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<time
|
|
className="shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground"
|
|
dateTime={new Date(
|
|
latestCommit.timestamp * 1_000,
|
|
).toISOString()}
|
|
>
|
|
{relativeTime(latestCommit.timestamp)}
|
|
</time>
|
|
</div>
|
|
) : (
|
|
<p className="truncate text-sm font-medium text-foreground">
|
|
Repository files · {files.length} tracked files
|
|
</p>
|
|
)}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleEntries.map((entry, index) => {
|
|
const latestCommit = entry.latestCommit;
|
|
const rowIsLast = index === visibleEntries.length - 1;
|
|
const openEntry = () =>
|
|
openRepositoryEntry(entry, setCurrentPath, setSelectedFile);
|
|
|
|
return (
|
|
<tr
|
|
aria-label={`Open ${entry.type} ${entry.name}`}
|
|
className={cn(
|
|
"cursor-pointer transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-hidden",
|
|
!rowIsLast && "border-border/50 border-b",
|
|
)}
|
|
key={`${entry.type}:${entry.path}`}
|
|
onClick={openEntry}
|
|
onKeyDown={(event) =>
|
|
handleRepositoryEntryKeyDown(event, openEntry)
|
|
}
|
|
tabIndex={0}
|
|
>
|
|
<td className="min-w-52 p-3 align-middle">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<RepositoryEntryIcon entry={entry} />
|
|
<span className="truncate font-medium text-foreground">
|
|
{entry.name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="max-w-96 p-3 align-middle">
|
|
<RepositoryCommitCell
|
|
commit={latestCommit}
|
|
profiles={profiles}
|
|
/>
|
|
</td>
|
|
<td className="w-36 whitespace-nowrap p-3 text-right align-middle text-muted-foreground">
|
|
{latestCommit ? (
|
|
<time
|
|
dateTime={new Date(
|
|
latestCommit.timestamp * 1_000,
|
|
).toISOString()}
|
|
>
|
|
{relativeTime(latestCommit.timestamp)}
|
|
</time>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{entries.length > 200 ? (
|
|
<p className="border-border/50 border-t px-4 py-3 text-2xs text-muted-foreground">
|
|
Showing the first 200 entries in this folder. Open a folder to narrow
|
|
the list.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|