mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
## Summary - Makes Projects repository-aware across browsing, branch and tag selection, local and remote source management, work items, commits, and contextual repository actions. - Refines project details into consistent single-column reading surfaces with a resizable, section-aware context pod, while keeping project conversations available in the attached chat panel. - Adds persistent sidebar project navigation, direct entity links, repository discussion channels, contributor identity matching, and consistent loading and activity presentation. - Keeps branch-specific controls on code-oriented sections while Tasks, Reviews, and Channels remain repository-scoped, reducing misleading context and actions. Replacement for #5981 with an identical final tree flattened into one signed-off commit because the required DCO check suite remained stalled. ### Related issue N/A ### Testing - [x] Desktop pre-push checks, TypeScript typecheck, and unit tests - [x] E2E production build with `pnpm build:e2e` - [x] Focused Playwright smoke coverage for project workspace, task, review, commit, sidebar, and contextual-panel behavior - [ ] Attach final before/after screenshots after design review --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz> Co-authored-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz>
272 lines
8.6 KiB
TypeScript
272 lines
8.6 KiB
TypeScript
import * as React from "react";
|
|
import { Copy } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
getSingletonHighlighter,
|
|
type HighlighterGeneric,
|
|
type BundledLanguage,
|
|
type BundledTheme,
|
|
type ThemedToken,
|
|
} from "shiki";
|
|
|
|
import { useTheme } from "@/shared/theme/ThemeProvider";
|
|
import { resolveShikiThemeName } from "@/shared/theme/theme-loader";
|
|
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
|
|
import { getReactNodeText } from "./utils";
|
|
|
|
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
|
|
null;
|
|
let shikiInitPromise: Promise<void> | null = null;
|
|
const loadedLangs = new Set<string>();
|
|
const loadedThemes = new Set<string>();
|
|
const tokenCache = new Map<string, ThemedToken[][]>();
|
|
const MAX_CACHE_ENTRIES = 100;
|
|
const MAX_LOADED_LANGUAGES = 30;
|
|
const MAX_HIGHLIGHT_LINES = 150;
|
|
export const CODE_BLOCK_CLASS =
|
|
"code-block-lines block min-w-full whitespace-pre font-mono text-sm font-medium text-foreground";
|
|
const DIFF_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/;
|
|
const DIFF_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/;
|
|
|
|
function ensureHighlighter(): Promise<void> {
|
|
if (shikiHighlighter) return Promise.resolve();
|
|
if (!shikiInitPromise) {
|
|
shikiInitPromise = getSingletonHighlighter({
|
|
themes: [],
|
|
langs: [],
|
|
}).then((h) => {
|
|
shikiHighlighter = h;
|
|
});
|
|
}
|
|
return shikiInitPromise;
|
|
}
|
|
|
|
export function extractLanguage(className?: string): string {
|
|
if (typeof className !== "string") return "";
|
|
const match = className.match(/language-(\S+)/);
|
|
return match ? match[1] : "";
|
|
}
|
|
|
|
function stripDiffMarker(tokens: ThemedToken[], marker: RegExp): ThemedToken[] {
|
|
const last = tokens[tokens.length - 1];
|
|
if (!last) return tokens;
|
|
const stripped = last.content.replace(marker, "");
|
|
if (stripped === last.content) return tokens;
|
|
if (stripped === "") return tokens.slice(0, -1);
|
|
return [...tokens.slice(0, -1), { ...last, content: stripped }];
|
|
}
|
|
|
|
function getCodeBlockText(children: React.ReactNode) {
|
|
return getReactNodeText(children).replace(/\n$/, "");
|
|
}
|
|
|
|
export function MarkdownCodeBlock({
|
|
children,
|
|
language,
|
|
}: {
|
|
children?: React.ReactNode;
|
|
language?: string;
|
|
}) {
|
|
const [isCopying, setIsCopying] = React.useState(false);
|
|
const codeBlockRef = React.useRef<HTMLPreElement | null>(null);
|
|
const code = React.useMemo(() => getCodeBlockText(children), [children]);
|
|
useSmoothCorners(codeBlockRef);
|
|
|
|
const handleCopy = React.useCallback(
|
|
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setIsCopying(true);
|
|
|
|
try {
|
|
await copyCodeBlockToClipboard(code);
|
|
toast.success("Copied code to clipboard");
|
|
} catch (error) {
|
|
console.error("Failed to copy code block", error);
|
|
toast.error("Failed to copy code");
|
|
} finally {
|
|
setIsCopying(false);
|
|
}
|
|
},
|
|
[code],
|
|
);
|
|
|
|
return (
|
|
<div className="group relative" data-code-block="">
|
|
<pre
|
|
ref={codeBlockRef}
|
|
className="max-h-[400px] overflow-x-auto overflow-y-auto rounded-2xl border border-border/70 bg-muted/60 px-3 py-1.5 pr-12 shadow-xs"
|
|
style={{ borderRadius: "1rem" }}
|
|
>
|
|
{language && (
|
|
<div className="mb-1 text-xs text-muted-foreground/70">
|
|
{language}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</pre>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
aria-label="Copy code block"
|
|
className="absolute right-2 top-2 h-7 w-7 bg-background/80 text-muted-foreground opacity-0 shadow-xs ring-1 ring-border/60 backdrop-blur-sm transition-opacity hover:bg-background hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 disabled:opacity-60"
|
|
disabled={isCopying}
|
|
onClick={handleCopy}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
<span className="sr-only">Copy code block</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Copy code</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SyntaxHighlightedCode({
|
|
className,
|
|
code,
|
|
language,
|
|
...props
|
|
}: {
|
|
code: string;
|
|
language: string;
|
|
} & React.ComponentProps<"code">) {
|
|
const { themeName } = useTheme();
|
|
// Buzz aliases ("buzz" / "buzz-dark") are not bundled Shiki themes — resolve
|
|
// to the real bundle (github-light / github-dark) before touching Shiki, or
|
|
// it throws and code blocks fall back to plain text.
|
|
const shikiTheme = resolveShikiThemeName(themeName);
|
|
const [loadedKey, setLoadedKey] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadAssets() {
|
|
try {
|
|
await ensureHighlighter();
|
|
if (!shikiHighlighter || cancelled) return;
|
|
let loaded = false;
|
|
if (!loadedLangs.has(language)) {
|
|
if (loadedLangs.size >= MAX_LOADED_LANGUAGES) return;
|
|
try {
|
|
await shikiHighlighter.loadLanguage(language as BundledLanguage);
|
|
loadedLangs.add(language);
|
|
loaded = true;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
if (!loadedThemes.has(shikiTheme)) {
|
|
try {
|
|
await shikiHighlighter.loadTheme(shikiTheme as BundledTheme);
|
|
loadedThemes.add(shikiTheme);
|
|
loaded = true;
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
if (loaded && !cancelled) setLoadedKey((k) => k + 1);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (!loadedLangs.has(language) || !loadedThemes.has(shikiTheme)) {
|
|
loadAssets();
|
|
}
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [language, shikiTheme]);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: loadedKey intentionally triggers re-memoization after async asset loading
|
|
const tokens = React.useMemo(() => {
|
|
if (
|
|
!shikiHighlighter ||
|
|
!loadedLangs.has(language) ||
|
|
!loadedThemes.has(shikiTheme)
|
|
)
|
|
return null;
|
|
if ((code.match(/\n/g) || []).length > MAX_HIGHLIGHT_LINES) return null;
|
|
const cacheKey = `${language}:${shikiTheme}:${code}`;
|
|
const cached = tokenCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
try {
|
|
const result = shikiHighlighter.codeToTokens(code, {
|
|
lang: language as BundledLanguage,
|
|
theme: shikiTheme as BundledTheme,
|
|
});
|
|
if (tokenCache.size >= MAX_CACHE_ENTRIES) {
|
|
const firstKey = tokenCache.keys().next().value;
|
|
if (firstKey !== undefined) tokenCache.delete(firstKey);
|
|
}
|
|
tokenCache.set(cacheKey, result.tokens);
|
|
return result.tokens;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, [code, language, shikiTheme, loadedKey]);
|
|
|
|
const codeClassName = cn(CODE_BLOCK_CLASS, className);
|
|
|
|
if (!tokens) {
|
|
const lines = code.split("\n");
|
|
return (
|
|
<code {...props} className={codeClassName}>
|
|
{lines.map((line, i) => (
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional
|
|
<span key={i} data-line="">
|
|
{line}
|
|
</span>
|
|
))}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<code {...props} className={codeClassName}>
|
|
{tokens.map((line, lineIdx) => {
|
|
const lineText = line.map((t) => t.content).join("");
|
|
const isAdd = DIFF_ADD_RE.test(lineText);
|
|
const isRemove = DIFF_REMOVE_RE.test(lineText);
|
|
const diffClass = isAdd
|
|
? "code-line-diff-add"
|
|
: isRemove
|
|
? "code-line-diff-remove"
|
|
: undefined;
|
|
|
|
const renderedTokens =
|
|
isAdd || isRemove
|
|
? stripDiffMarker(line, isAdd ? DIFF_ADD_RE : DIFF_REMOVE_RE)
|
|
: line;
|
|
|
|
return (
|
|
<span
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
|
key={lineIdx}
|
|
data-line=""
|
|
className={diffClass}
|
|
>
|
|
{renderedTokens.map((token, tokenIdx) => (
|
|
<span
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: tokens are positional and never reordered
|
|
key={tokenIdx}
|
|
style={token.color ? { color: token.color } : undefined}
|
|
>
|
|
{token.content}
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
})}
|
|
</code>
|
|
);
|
|
}
|