mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
Make Projects repository-aware across navigation, source management, work items, discussions, and contextual actions while consolidating detail views into consistent single-column workspaces. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
import {
|
|
BookOpen,
|
|
DownloadCloud,
|
|
ExternalLink,
|
|
Globe,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
|
|
import type { ProjectRepoFile } from "@/features/projects/hooks";
|
|
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
|
|
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
|
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
|
|
import {
|
|
baseName,
|
|
formatLastChangedAt,
|
|
languageForPath,
|
|
} from "./ProjectRepositoryPanel";
|
|
import {
|
|
type RepoSourceHeaderControls,
|
|
RepoSourceDropdown,
|
|
RepoSyncActionButton,
|
|
RepositoryBranchDropdown,
|
|
} from "./ProjectRepositorySource";
|
|
import { GitHubMark } from "./GitHubMark";
|
|
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
|
|
|
|
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();
|
|
}
|
|
|
|
export function ReadmePanel({
|
|
accessChannelId,
|
|
file,
|
|
gitDataState,
|
|
externalHost,
|
|
externalUrl,
|
|
hideHeader,
|
|
ownerAvatarUrl,
|
|
ownerIsAgent,
|
|
ownerName,
|
|
sourceControls,
|
|
unavailableReason,
|
|
}: {
|
|
/** `buzz-channel` binding of the repository, for access-restricted copy. */
|
|
accessChannelId?: string | null;
|
|
file: ProjectRepoFile | null;
|
|
gitDataState: "checking" | "available" | "empty" | "unavailable";
|
|
externalHost?: string;
|
|
externalUrl?: string | null;
|
|
/**
|
|
* Skip the header rows entirely — the workspace layout renders the source
|
|
* controls and last-changed timestamp itself.
|
|
*/
|
|
hideHeader?: boolean;
|
|
ownerAvatarUrl?: string | null;
|
|
ownerIsAgent?: boolean;
|
|
ownerName?: string;
|
|
unavailableReason?: ProjectRepoUnavailableReason;
|
|
/** Branch picker + remote/local toggle rendered in the panel header. */
|
|
sourceControls?: RepoSourceHeaderControls;
|
|
}) {
|
|
const externalOpenUrl = projectExternalRefUrl(
|
|
externalUrl,
|
|
sourceControls?.selectedTag ?? sourceControls?.branch,
|
|
);
|
|
// Two header rows, mirroring the files panel: controls on top, then the
|
|
// file identity row.
|
|
const header = hideHeader ? null : (
|
|
<>
|
|
{sourceControls ? (
|
|
<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}
|
|
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>
|
|
) : null}
|
|
<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="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
|
{file ? baseName(file.path) : "README"}
|
|
</span>
|
|
{file ? (
|
|
<span className="hidden shrink-0 text-2xs text-muted-foreground sm:block">
|
|
Last changed {formatLastChangedAt(file.lastChangedAt)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (gitDataState === "checking") {
|
|
return (
|
|
<section className="overflow-hidden">
|
|
{header}
|
|
<BuzzLoadingState label="Loading repository" />
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (gitDataState === "unavailable") {
|
|
if (!externalHost) {
|
|
return (
|
|
<section className="overflow-hidden">
|
|
<ProjectRepositoryUnavailableState
|
|
accessChannelId={accessChannelId}
|
|
onAskForAccess={sourceControls?.onAskForAccess}
|
|
onRetry={sourceControls?.onFetch}
|
|
ownerAvatarUrl={ownerAvatarUrl}
|
|
ownerIsAgent={ownerIsAgent}
|
|
ownerName={ownerName}
|
|
reason={unavailableReason}
|
|
retryPending={sourceControls?.fetchPending}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="overflow-hidden">
|
|
<div className="flex min-h-64 flex-col items-center justify-center p-8 text-center">
|
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
|
|
{externalHost === "github.com" ? (
|
|
<GitHubMark className="h-6 w-6" />
|
|
) : (
|
|
<Globe className="h-6 w-6" />
|
|
)}
|
|
</div>
|
|
<h3 className="text-base font-semibold text-foreground">
|
|
Code hosted on {externalHost}
|
|
</h3>
|
|
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
|
|
Clone this repository locally to explore its files, commits, and
|
|
contributors in Buzz.
|
|
</p>
|
|
{externalOpenUrl ? (
|
|
<a
|
|
className="mt-2 max-w-lg truncate font-mono text-xs text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
href={externalOpenUrl}
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
>
|
|
{externalOpenUrl}
|
|
</a>
|
|
) : null}
|
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
|
{sourceControls?.onCloneLocal ? (
|
|
<Button
|
|
disabled={sourceControls.clonePending}
|
|
onClick={sourceControls.onCloneLocal}
|
|
size="sm"
|
|
>
|
|
{sourceControls.clonePending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<DownloadCloud className="h-4 w-4" />
|
|
)}
|
|
{sourceControls.clonePending ? "Cloning…" : "Clone locally"}
|
|
</Button>
|
|
) : null}
|
|
{externalOpenUrl ? (
|
|
<Button asChild size="sm" variant="outline">
|
|
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
|
|
<ExternalLink className="h-4 w-4" />
|
|
Open on {externalHost}
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (!file?.previewContent) {
|
|
return (
|
|
<section className="overflow-hidden">
|
|
{header}
|
|
<div className="p-6 text-sm text-muted-foreground">
|
|
{gitDataState === "empty"
|
|
? "No files have been pushed to this repository yet."
|
|
: "Add a README to this repository to describe setup, usage, and project context."}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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">
|
|
{header}
|
|
<div className="p-4">
|
|
{isMarkdown ? (
|
|
<Markdown
|
|
className="text-sm"
|
|
content={readmeContent}
|
|
interactive={false}
|
|
/>
|
|
) : language ? (
|
|
<pre className="overflow-x-auto bg-muted/40 p-4">
|
|
<SyntaxHighlightedCode
|
|
className="text-xs leading-relaxed"
|
|
code={file.previewContent}
|
|
language={language}
|
|
/>
|
|
</pre>
|
|
) : (
|
|
<pre className="overflow-x-auto 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>
|
|
);
|
|
}
|