feat: repository tree visualization (#31)

* docs: add plan for repository tree visualization feature

Comprehensive plan covering:
- git-natural-api library analysis and API documentation
- useGitTree/useGitBlob hooks for fetching git data
- FileTreeView component using Radix Collapsible
- Shiki migration with on-demand language loading
- Multi-server fallback for redundant clone URLs
- Dexie caching for offline access

* docs: add comprehensive Shiki migration plan

Detailed plan for migrating from Prism.js to Shiki with lazy loading:
- Analysis of all 8 components using SyntaxHighlight
- Shiki service with singleton highlighter and on-demand language loading
- Custom Grimoire dark theme matching current Prism styles
- Language alias mapping for 50+ extensions
- React hook for async highlighting with loading states
- CSS theme preserving minimalistic dark aesthetics
- Migration steps with rollback plan

* feat: migrate syntax highlighting from Prism.js to Shiki

Replace Prism.js with Shiki for syntax highlighting with several key improvements:

- Lazy loading: Languages loaded on-demand via dynamic imports instead of bundling all upfront
- Broader language support: 200+ TextMate grammars vs 11 statically imported
- Singleton highlighter: Core languages (JS, TS, JSON, diff, bash) preloaded, others loaded on first use

New files:
- src/lib/shiki.ts: Shiki service with highlightCode(), normalizeLanguage(), language aliases
- src/hooks/useHighlightedCode.ts: React hook for async highlighting with loading states
- src/styles/shiki-theme.css: Grimoire dark theme matching previous minimalistic style

Updated components:
- SyntaxHighlight: Now uses Shiki with graceful loading/error states
- CodeSnippetRenderer/DetailRenderer: Simplified, removed manual language mapping
- MarkdownContent: Removed type casts, any language now supported

Removed:
- prismjs and @types/prismjs dependencies
- src/styles/prism-theme.css

* feat: add repository file tree visualization

Add file tree explorer to the Repository detail renderer (kind 30617)
using @fiatjaf/git-natural-api for fetching git trees via HTTP.

New files:
- src/lib/git-types.ts: TypeScript types for DirectoryTree, SelectedFile, etc.
- src/hooks/useGitTree.ts: Hook to fetch git repository tree from clone URLs
  - Tries multiple clone URLs in sequence
  - Uses getDirectoryTreeAt with filter capability when available
  - Falls back to shallowCloneRepositoryAt otherwise
- src/hooks/useGitBlob.ts: Hook to fetch individual file content by hash
  - Detects binary files
  - Returns both raw Uint8Array and decoded text
- src/components/ui/FileTreeView.tsx: Recursive tree view component
  - Collapsible directories with chevron icons
  - File icons based on extension (code, json, text, image, etc.)
  - Alphabetical sorting with directories first
- src/components/nostr/kinds/RepositoryFilesSection.tsx: Main integration
  - Side-by-side tree and file preview layout
  - Syntax-highlighted file content using existing SyntaxHighlight
  - Binary file detection with appropriate UI
  - Loading/error states

Modified:
- RepositoryDetailRenderer.tsx: Added RepositoryFilesSection below relays

Dependencies:
- Added @fiatjaf/git-natural-api from JSR

* fix: improve repository tree visualization UX

- Collapse directories by default in file tree
- Hide files section on tree loading error
- Add code-like skeleton loader with file header
- Fix syntax highlight size jump between loading/loaded states
- Replace purple accent with grayscale theme
- Preload Rust and Markdown languages for reliable highlighting

* refactor: improve git tree and syntax highlighting

- Remove shallow clone fallback from useGitTree, only use no-blobs fetch
  Servers without filter capability are skipped instead of downloading blobs
- Add light theme support for Shiki syntax highlighting
  Theme is automatically selected based on current color scheme

* fix: improve dark theme contrast for syntax highlighting

* fix: address code review issues

- useGitTree: use useStableArray for cloneUrls to fix dependency tracking
- useGitTree/useGitBlob: add isMounted checks to prevent state updates after unmount
- RepositoryFilesSection: remove unnecessary useMemo for language
- FileTreeView: use path instead of hash for React keys
- shiki: track failed languages to avoid repeated console warnings

* fix: improve dark theme contrast for syntax highlighting

- Add CSS variables for syntax highlighting instead of hardcoded colors
- Add --syntax-constant and --syntax-tag variables to light and dark themes
- Use high contrast colors for dark mode (bright green strings, purple keywords)
- Simplify Shiki transformer to output CSS classes instead of inline styles
- Remove unused parameters from transformer callback

* fix: restore syntax highlighting colors

Revert the CSS class-based approach which was failing to classify tokens.
Instead, keep Shiki's inline styles from the theme and only remove
backgrounds to let CSS handle those. The theme colors now provide
syntax highlighting directly.

* feat: add copy button and CSS variable-based syntax highlighting

- Add copy button next to file name in file viewer header (icon-only)
- Use Shiki's createCssVariablesTheme for proper theme integration
- Map Shiki CSS variables to our theme system variables
- Syntax highlighting now works correctly across all themes (light/dark)

* refactor: create IconCopyButton component and use CopyCheck consistently

- Add IconCopyButton component for reusable icon-only copy buttons
- Refactor RepositoryFilesSection to use IconCopyButton
- Replace Check with CopyCheck in ChatMessageContextMenu
- Replace Check with CopyCheck in BaseEventRenderer
- Use text-success instead of text-green-500 for consistency

* fix: add HTML, CSS, TOML to core languages and expand token mappings

- Add html, css, toml to CORE_LANGUAGES for eager loading
- Add variableDefaults to cssVarsTheme for proper initialization
- Expand shiki-theme.css with more token type mappings:
  - HTML/XML: tag, attribute, attr-value
  - CSS: selector, property
  - Additional: variable, operator, number, boolean, regex, etc.

* fix: improve diff line spacing with flex layout

- Use flex-col with gap-0 on code element for tight line packing
- Reduce line-height from 1.5 to 1.4 for tighter spacing
- Add .line display:block with min-height for consistent sizing
- Simplify diff background styling (remove negative margin hack)

* fix: improve code block line spacing and wrap long lines

- Increase line-height from 1.4 to 1.5 for better readability
- Use pre-wrap instead of pre to allow long line wrapping
- Add overflow-wrap: break-word to break long URLs/strings

* chore: remove planning docs

* chore: update @fiatjaf/git-natural-api to 0.2.3

* fix: make code blocks horizontally scrollable with full-width diff backgrounds

- Use white-space: pre for horizontal scrolling instead of wrapping
- Add width: fit-content and min-width: 100% to code element
- Ensure diff line backgrounds extend full width when scrolling

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-30 12:35:59 +01:00
committed by GitHub
parent 121fbb7654
commit 8f2f055566
31 changed files with 1935 additions and 6685 deletions

View File

@@ -1,45 +1,24 @@
import { useEffect, useRef } from "react";
import Prism from "prismjs";
// Core languages
import "prismjs/components/prism-diff";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-json";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-css";
import "prismjs/components/prism-python";
import "prismjs/components/prism-yaml";
import { useHighlightedCode } from "@/hooks/useHighlightedCode";
import { cn } from "@/lib/utils";
interface SyntaxHighlightProps {
code: string;
language:
| "diff"
| "javascript"
| "typescript"
| "jsx"
| "tsx"
| "bash"
| "shell"
| "json"
| "markdown"
| "css"
| "python"
| "yaml";
language?: string | null;
className?: string;
showLineNumbers?: boolean;
}
/**
* Syntax highlighting component using Prism.js
* Matches Grimoire's dark theme using CSS custom properties
* Syntax highlighting component using Shiki with lazy language loading
*
* Languages are loaded on-demand - the first render of a new language
* will show a brief loading state while the grammar is fetched.
*
* @example
* ```tsx
* <SyntaxHighlight code={patchContent} language="diff" />
* <SyntaxHighlight code={jsonStr} language="json" />
* <SyntaxHighlight code={snippet} language="python" />
* ```
*/
export function SyntaxHighlight({
@@ -48,26 +27,42 @@ export function SyntaxHighlight({
className = "",
showLineNumbers = false,
}: SyntaxHighlightProps) {
const codeRef = useRef<HTMLElement>(null);
const { html, loading, error } = useHighlightedCode(code, language);
// Normalize language aliases
const normalizedLanguage = language === "shell" ? "bash" : language;
// Use consistent wrapper structure for all states to avoid size jumps
const wrapperClasses = cn(
"shiki-container overflow-x-auto max-w-full [&_pre]:!bg-transparent [&_pre]:!m-0 [&_code]:text-xs [&_code]:font-mono",
showLineNumbers && "line-numbers",
className,
);
useEffect(() => {
// Check for browser environment (SSR safety)
if (typeof window === "undefined" || !codeRef.current) return;
// Loading state - show code without highlighting
if (loading) {
return (
<div className={cn(wrapperClasses, "shiki-loading")}>
<pre className="!bg-transparent !m-0">
<code className="text-foreground/70">{code}</code>
</pre>
</div>
);
}
// Highlight the code element
Prism.highlightElement(codeRef.current);
}, [code, normalizedLanguage]);
// Error state - fallback to plain code
if (error || !html) {
return (
<div className={wrapperClasses}>
<pre className="!bg-transparent !m-0">
<code>{code}</code>
</pre>
</div>
);
}
// Render highlighted HTML
return (
<pre
className={`language-${normalizedLanguage} ${showLineNumbers ? "line-numbers" : ""} overflow-x-auto max-w-full ${className}`.trim()}
>
<code ref={codeRef} className={`language-${normalizedLanguage}`}>
{code}
</code>
</pre>
<div
className={wrapperClasses}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -12,7 +12,7 @@ import {
} from "@/components/ui/context-menu";
import {
Copy,
Check,
CopyCheck,
FileJson,
ExternalLink,
Reply,
@@ -212,7 +212,7 @@ export function ChatMessageContextMenu({
</ContextMenuItem>
<ContextMenuItem onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
<CopyCheck className="size-4 mr-2 text-success" />
) : (
<Copy className="size-4 mr-2" />
)}

View File

@@ -135,7 +135,7 @@ function CodeBlock({
return (
<div className="relative my-4">
{language ? (
<SyntaxHighlight code={code} language={language as any} />
<SyntaxHighlight code={code} language={language} />
) : (
<pre
className={`bg-muted p-4 border border-border rounded overflow-x-auto max-w-full ${isSingleLine ? "" : "pr-12"}`}

View File

@@ -18,7 +18,7 @@ import {
import {
Menu,
Copy,
Check,
CopyCheck,
FileJson,
ExternalLink,
Zap,
@@ -267,7 +267,7 @@ export function EventMenu({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
<CopyCheck className="size-4 mr-2 text-success" />
) : (
<Copy className="size-4 mr-2" />
)}
@@ -433,7 +433,7 @@ export function EventContextMenu({
<ContextMenuSeparator />
<ContextMenuItem onClick={copyEventId}>
{copied ? (
<Check className="size-4 mr-2 text-green-500" />
<CopyCheck className="size-4 mr-2 text-success" />
) : (
<Copy className="size-4 mr-2" />
)}

View File

@@ -80,41 +80,6 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
}
};
// Normalize language to supported Prism languages
const normalizedLanguage = useMemo(() => {
if (!language) return null;
const lang = language.toLowerCase();
// Map common language names to Prism identifiers
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
shell: "bash",
yml: "yaml",
};
const mapped = languageMap[lang] || lang;
// Check if it's a supported language
const supported = [
"javascript",
"typescript",
"jsx",
"tsx",
"bash",
"json",
"markdown",
"css",
"python",
"yaml",
"diff",
];
return supported.includes(mapped) ? mapped : null;
}, [language]);
return (
<div className="flex flex-col gap-2 p-6">
{/* Header */}
@@ -195,29 +160,16 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
{/* Code Section */}
<div className="relative">
{normalizedLanguage ? (
<>
<SyntaxHighlight
code={event.content}
language={normalizedLanguage as any}
className="bg-muted p-4 pr-10 border border-border overflow-x-auto"
/>
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
</>
) : (
<pre className="text-xs font-mono bg-muted p-4 pr-10 border border-border overflow-x-auto">
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
<code>{event.content}</code>
</pre>
)}
<SyntaxHighlight
code={event.content}
language={language}
className="bg-muted p-4 pr-10 border border-border overflow-x-auto"
/>
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
</div>
</div>
);

View File

@@ -11,58 +11,6 @@ import {
import { Label } from "@/components/ui/label";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
// Map common language names to Prism-supported languages
function mapLanguage(
lang: string | null | undefined,
):
| "javascript"
| "typescript"
| "jsx"
| "tsx"
| "bash"
| "json"
| "markdown"
| "css"
| "python"
| "yaml"
| "diff" {
if (!lang) return "javascript";
const normalized = lang.toLowerCase();
// Direct matches
if (
[
"javascript",
"typescript",
"jsx",
"tsx",
"bash",
"json",
"markdown",
"css",
"python",
"yaml",
"diff",
].includes(normalized)
) {
return normalized as any;
}
// Common aliases
const aliases: Record<string, string> = {
js: "javascript",
ts: "typescript",
sh: "bash",
shell: "bash",
py: "python",
md: "markdown",
yml: "yaml",
};
return (aliases[normalized] as any) || "javascript";
}
/**
* Renderer for Kind 1337 - Code Snippet (NIP-C0)
* Displays code snippet name, language, description, and preview in feed
@@ -109,7 +57,7 @@ export function Kind1337Renderer({ event }: BaseEventProps) {
<div className="relative">
<SyntaxHighlight
code={previewCode}
language={mapLanguage(language)}
language={language}
className="overflow-x-auto bg-muted/30 p-3 border border-border"
/>
</div>

View File

@@ -3,6 +3,7 @@ import { Globe, Copy, Users, CopyCheck, Server } from "lucide-react";
import { UserName } from "../UserName";
import { RelayLink } from "../RelayLink";
import { useCopy } from "@/hooks/useCopy";
import { RepositoryFilesSection } from "./RepositoryFilesSection";
import type { NostrEvent } from "@/types/nostr";
import {
getRepositoryName,
@@ -123,6 +124,9 @@ export function RepositoryDetailRenderer({ event }: { event: NostrEvent }) {
</ul>
</section>
)}
{/* Files Section */}
{cloneUrls.length > 0 && <RepositoryFilesSection cloneUrls={cloneUrls} />}
</div>
);
}

View File

@@ -0,0 +1,212 @@
import { useState } from "react";
import { FolderGit2, AlertCircle, FileQuestion, Binary } from "lucide-react";
import { useGitTree } from "@/hooks/useGitTree";
import { useGitBlob } from "@/hooks/useGitBlob";
import { FileTreeView } from "@/components/ui/FileTreeView";
import { IconCopyButton } from "@/components/ui/IconCopyButton";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { Skeleton } from "@/components/ui/skeleton/Skeleton";
import { cn } from "@/lib/utils";
import type { SelectedFile } from "@/lib/git-types";
interface RepositoryFilesSectionProps {
cloneUrls: string[];
className?: string;
}
/**
* Get file extension from filename
*/
function getExtension(filename: string): string {
const parts = filename.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
}
/**
* Format file size for display
*/
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* Repository files section with tree view and file content preview
*
* Displays a collapsible file tree on the left and file content on the right.
* Files are fetched lazily when selected.
*/
export function RepositoryFilesSection({
cloneUrls,
className,
}: RepositoryFilesSectionProps) {
const [selectedFile, setSelectedFile] = useState<SelectedFile | null>(null);
// Fetch the repository tree
const {
tree,
loading: treeLoading,
error: treeError,
serverUrl,
} = useGitTree({
cloneUrls,
enabled: cloneUrls.length > 0,
});
// Fetch file content when a file is selected
const {
text: fileContent,
loading: contentLoading,
error: contentError,
isBinary,
content: rawContent,
} = useGitBlob({
serverUrl,
hash: selectedFile?.hash ?? null,
enabled: !!selectedFile && !!serverUrl,
});
// Get the language for syntax highlighting from the file extension
const language = selectedFile
? getExtension(selectedFile.name) || null
: null;
const handleFileSelect = (file: SelectedFile) => {
setSelectedFile(file);
};
// Don't render if no clone URLs
if (cloneUrls.length === 0) {
return null;
}
// Loading state
if (treeLoading) {
return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderGit2 className="size-5" />
Files
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</div>
</section>
);
}
// Error state - silently hide section
if (treeError) {
return null;
}
// No tree available
if (!tree) {
return null;
}
return (
<section className={cn("flex flex-col gap-4", className)}>
<h2 className="text-xl font-semibold flex items-center gap-2">
<FolderGit2 className="size-5" />
Files
{serverUrl && (
<span className="text-xs text-muted-foreground font-normal ml-2">
from {new URL(serverUrl).hostname}
</span>
)}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* File Tree */}
<div className="border border-border rounded p-2 max-h-96 overflow-auto bg-muted/20">
<FileTreeView
tree={tree}
onFileSelect={handleFileSelect}
selectedPath={selectedFile?.path}
/>
</div>
{/* File Content Preview */}
<div className="border border-border rounded max-h-96 overflow-auto bg-muted/20">
{selectedFile ? (
contentLoading ? (
<div className="relative">
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-3 py-1.5 border-b border-border/50 flex items-center justify-between">
<span className="text-xs font-mono text-muted-foreground truncate">
{selectedFile.path}
</span>
</div>
<div className="p-3 space-y-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-4/5" />
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
) : contentError ? (
<div className="flex items-start gap-3 p-4 text-sm">
<AlertCircle className="size-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex flex-col gap-1">
<p className="font-medium">Failed to load file</p>
<p className="text-muted-foreground text-xs">
{contentError.message}
</p>
</div>
</div>
) : isBinary ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<Binary className="size-12 mb-4 opacity-50" />
<p className="text-sm font-medium">Binary file</p>
<p className="text-xs mt-1">
{rawContent && formatSize(rawContent.length)}
</p>
</div>
) : fileContent ? (
<div className="relative">
<div className="sticky top-0 bg-muted/80 backdrop-blur-sm px-3 py-1.5 border-b border-border/50 flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-muted-foreground truncate">
{selectedFile.path}
</span>
<IconCopyButton
text={fileContent}
size="sm"
label="Copy file content"
/>
</div>
{rawContent && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatSize(rawContent.length)}
</span>
)}
</div>
<SyntaxHighlight
code={fileContent}
language={language}
className="p-3"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
<FileQuestion className="size-12 mb-4 opacity-50" />
<p className="text-sm">Empty file</p>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full min-h-48 p-8 text-muted-foreground">
<FileQuestion className="size-12 mb-4 opacity-30" />
<p className="text-sm">Select a file to view its contents</p>
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,302 @@
import { useState, useMemo } from "react";
import {
ChevronRight,
ChevronDown,
File,
FileCode,
FileJson,
FileText,
Folder,
FolderOpen,
Image,
FileArchive,
FileAudio,
FileVideo,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { DirectoryTree, SelectedFile } from "@/lib/git-types";
interface FileTreeViewProps {
tree: DirectoryTree;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
className?: string;
}
interface TreeNodeProps {
name: string;
hash: string;
path: string;
isDirectory: boolean;
content?: DirectoryTree | null;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
depth: number;
}
/**
* Get appropriate icon for a file based on extension
*/
function getFileIcon(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
// Code files
if (
[
"js",
"jsx",
"ts",
"tsx",
"py",
"rb",
"go",
"rs",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"php",
"swift",
"kt",
"scala",
"zig",
"lua",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"pl",
"r",
"ex",
"exs",
"erl",
"hs",
"ml",
"clj",
"vim",
"sol",
].includes(ext)
) {
return FileCode;
}
// JSON/Config
if (["json", "jsonc", "json5"].includes(ext)) {
return FileJson;
}
// Text/Docs
if (
[
"md",
"mdx",
"txt",
"rst",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"csv",
"log",
].includes(ext)
) {
return FileText;
}
// Images
if (
["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp"].includes(ext)
) {
return Image;
}
// Archives
if (["zip", "tar", "gz", "bz2", "7z", "rar", "xz"].includes(ext)) {
return FileArchive;
}
// Audio
if (["mp3", "wav", "ogg", "flac", "aac", "m4a"].includes(ext)) {
return FileAudio;
}
// Video
if (["mp4", "webm", "mkv", "avi", "mov", "wmv"].includes(ext)) {
return FileVideo;
}
return File;
}
/**
* Single tree node (file or directory)
*/
function TreeNode({
name,
hash,
path,
isDirectory,
content,
onFileSelect,
selectedPath,
depth,
}: TreeNodeProps) {
const [isOpen, setIsOpen] = useState(false);
const isSelected = selectedPath === path;
const handleClick = () => {
if (isDirectory) {
setIsOpen(!isOpen);
} else {
onFileSelect({ name, hash, path });
}
};
const Icon = isDirectory ? (isOpen ? FolderOpen : Folder) : getFileIcon(name);
return (
<div>
<button
onClick={handleClick}
className={cn(
"flex items-center gap-1.5 w-full text-left py-0.5 px-1 hover:bg-muted/50 text-sm",
isSelected && "bg-primary/20 text-primary",
isDirectory && "font-medium",
)}
style={{ paddingLeft: `${depth * 12 + 4}px` }}
>
{isDirectory ? (
isOpen ? (
<ChevronDown className="size-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="size-3 text-muted-foreground flex-shrink-0" />
)
) : (
<span className="size-3 flex-shrink-0" />
)}
<Icon
className={cn(
"size-4 flex-shrink-0",
isDirectory ? "text-yellow-500" : "text-muted-foreground",
)}
/>
<span className="truncate">{name}</span>
</button>
{isDirectory && isOpen && content && (
<TreeContents
tree={content}
basePath={path}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth + 1}
/>
)}
</div>
);
}
interface TreeContentsProps {
tree: DirectoryTree;
basePath: string;
onFileSelect: (file: SelectedFile) => void;
selectedPath?: string;
depth: number;
}
/**
* Render contents of a directory
*/
function TreeContents({
tree,
basePath,
onFileSelect,
selectedPath,
depth,
}: TreeContentsProps) {
// Sort: directories first, then alphabetically
const sortedEntries = useMemo(() => {
const dirs = [...tree.directories].sort((a, b) =>
a.name.localeCompare(b.name),
);
const files = [...tree.files].sort((a, b) => a.name.localeCompare(b.name));
return { dirs, files };
}, [tree]);
return (
<div>
{sortedEntries.dirs.map((dir) => {
const dirPath = basePath ? `${basePath}/${dir.name}` : dir.name;
return (
<TreeNode
key={dirPath}
name={dir.name}
hash={dir.hash}
path={dirPath}
isDirectory={true}
content={dir.content}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth}
/>
);
})}
{sortedEntries.files.map((file) => {
const filePath = basePath ? `${basePath}/${file.name}` : file.name;
return (
<TreeNode
key={filePath}
name={file.name}
hash={file.hash}
path={filePath}
isDirectory={false}
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={depth}
/>
);
})}
</div>
);
}
/**
* File tree view component for displaying git repository structure
*
* @example
* <FileTreeView
* tree={directoryTree}
* onFileSelect={(file) => console.log('Selected:', file.path)}
* selectedPath={selectedFile?.path}
* />
*/
export function FileTreeView({
tree,
onFileSelect,
selectedPath,
className,
}: FileTreeViewProps) {
return (
<div className={cn("font-mono text-xs", className)}>
<TreeContents
tree={tree}
basePath=""
onFileSelect={onFileSelect}
selectedPath={selectedPath}
depth={0}
/>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Copy, CopyCheck } from "lucide-react";
import { useCopy } from "@/hooks/useCopy";
import { cn } from "@/lib/utils";
type IconSize = "xs" | "sm" | "md" | "lg";
interface IconCopyButtonProps {
/** Text to copy to clipboard */
text: string;
/** Icon size preset */
size?: IconSize;
/** Additional class names for the button */
className?: string;
/** Tooltip/aria-label text */
label?: string;
}
const sizeClasses: Record<IconSize, string> = {
xs: "size-3",
sm: "size-3.5",
md: "size-4",
lg: "size-5 md:size-4",
};
/**
* Tiny icon-only copy button for inline use
* Uses Copy/CopyCheck icons with consistent styling
*/
export function IconCopyButton({
text,
size = "sm",
className,
label = "Copy",
}: IconCopyButtonProps) {
const { copy, copied } = useCopy();
return (
<button
type="button"
onClick={() => copy(text)}
className={cn(
"flex-shrink-0 p-1 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors",
className,
)}
title={label}
aria-label={label}
>
{copied ? (
<CopyCheck className={cn(sizeClasses[size], "text-success")} />
) : (
<Copy className={sizeClasses[size]} />
)}
</button>
);
}

137
src/hooks/useGitBlob.ts Normal file
View File

@@ -0,0 +1,137 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { getObject } from "@fiatjaf/git-natural-api";
interface UseGitBlobOptions {
/** Git server URL */
serverUrl: string | null;
/** Blob hash to fetch */
hash: string | null;
/** Whether to fetch immediately */
enabled?: boolean;
}
interface UseGitBlobResult {
/** The blob content as Uint8Array */
content: Uint8Array | null;
/** Content decoded as text (if decodable) */
text: string | null;
/** Loading state */
loading: boolean;
/** Error if fetch failed */
error: Error | null;
/** Whether the content appears to be binary */
isBinary: boolean;
/** Refetch the blob */
refetch: () => void;
}
/**
* Check if content appears to be binary (contains null bytes or non-text characters)
*/
function detectBinary(data: Uint8Array): boolean {
// Check first 8KB for binary indicators
const checkLength = Math.min(data.length, 8192);
for (let i = 0; i < checkLength; i++) {
const byte = data[i];
// Null byte is a strong indicator of binary
if (byte === 0) return true;
// Non-printable characters (except common whitespace) suggest binary
if (byte < 9 || (byte > 13 && byte < 32)) return true;
}
return false;
}
/**
* Hook to fetch a git blob (file content) by its hash
*
* @example
* const { content, text, loading } = useGitBlob({
* serverUrl: 'https://github.com/user/repo.git',
* hash: 'abc123...'
* })
*/
export function useGitBlob({
serverUrl,
hash,
enabled = true,
}: UseGitBlobOptions): UseGitBlobResult {
const [content, setContent] = useState<Uint8Array | null>(null);
const [text, setText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isBinary, setIsBinary] = useState(false);
// Track mounted state to prevent state updates after unmount
const isMountedRef = useRef(true);
const fetchBlob = useCallback(async () => {
if (!serverUrl || !hash) {
return;
}
setLoading(true);
setError(null);
setContent(null);
setText(null);
setIsBinary(false);
try {
const object = await getObject(serverUrl, hash);
if (!isMountedRef.current) return;
if (!object || !object.data) {
throw new Error("Empty or invalid blob");
}
const data = object.data;
setContent(data);
// Check if binary
const binary = detectBinary(data);
setIsBinary(binary);
// Try to decode as text if not binary
if (!binary) {
try {
const decoder = new TextDecoder("utf-8", { fatal: true });
setText(decoder.decode(data));
} catch {
// Decoding failed, treat as binary
setIsBinary(true);
}
}
} catch (e) {
if (!isMountedRef.current) return;
const err = e instanceof Error ? e : new Error(String(e));
console.warn(`[useGitBlob] Failed to fetch blob ${hash}:`, err.message);
setError(err);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [serverUrl, hash]);
useEffect(() => {
isMountedRef.current = true;
if (enabled && serverUrl && hash) {
fetchBlob();
}
return () => {
isMountedRef.current = false;
};
}, [enabled, serverUrl, hash, fetchBlob]);
return {
content,
text,
loading,
error,
isBinary,
refetch: fetchBlob,
};
}

175
src/hooks/useGitTree.ts Normal file
View File

@@ -0,0 +1,175 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
getInfoRefs,
getDirectoryTreeAt,
MissingCapability,
} from "@fiatjaf/git-natural-api";
import { useStableArray } from "@/hooks/useStable";
import type { DirectoryTree } from "@/lib/git-types";
interface UseGitTreeOptions {
/** Clone URLs to try in order */
cloneUrls: string[];
/** Branch, tag, or commit ref (defaults to HEAD) */
ref?: string;
/** Whether to fetch immediately */
enabled?: boolean;
}
interface UseGitTreeResult {
/** The directory tree if successfully fetched */
tree: DirectoryTree | null;
/** Loading state */
loading: boolean;
/** Error if fetch failed */
error: Error | null;
/** Which server URL succeeded */
serverUrl: string | null;
/** Refetch the tree */
refetch: () => void;
}
/**
* Hook to fetch a git repository tree from clone URLs
*
* Tries each clone URL in sequence until one succeeds.
* Uses the lightweight `getDirectoryTreeAt` which requires filter capability.
* Servers without filter support are skipped.
*
* @example
* const { tree, loading, error } = useGitTree({
* cloneUrls: ['https://github.com/user/repo.git'],
* ref: 'main'
* })
*/
export function useGitTree({
cloneUrls,
ref = "HEAD",
enabled = true,
}: UseGitTreeOptions): UseGitTreeResult {
const [tree, setTree] = useState<DirectoryTree | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [serverUrl, setServerUrl] = useState<string | null>(null);
// Stabilize cloneUrls to prevent unnecessary re-fetches
const stableCloneUrls = useStableArray(cloneUrls);
// Track mounted state to prevent state updates after unmount
const isMountedRef = useRef(true);
const fetchTree = useCallback(async () => {
if (stableCloneUrls.length === 0) {
setError(new Error("No clone URLs provided"));
return;
}
setLoading(true);
setError(null);
setTree(null);
setServerUrl(null);
const errors: Error[] = [];
for (const url of stableCloneUrls) {
// Check if still mounted before each iteration
if (!isMountedRef.current) return;
try {
// Get server info to check capabilities and resolve refs
const info = await getInfoRefs(url);
if (!isMountedRef.current) return;
// Only use servers that support filter capability (lightweight fetch)
// Skip servers that would require downloading all blobs
if (!info.capabilities.includes("filter")) {
console.warn(
`[useGitTree] Server ${url} doesn't support filter capability, skipping`,
);
errors.push(
new MissingCapability("filter", "Server doesn't support filter"),
);
continue;
}
// Resolve the ref to a commit hash
let resolvedRef = ref;
if (ref === "HEAD" && info.symrefs["HEAD"]) {
// HEAD points to a branch like "refs/heads/main"
const headBranch = info.symrefs["HEAD"];
if (info.refs[headBranch]) {
resolvedRef = info.refs[headBranch];
}
} else if (ref.startsWith("refs/") && info.refs[ref]) {
resolvedRef = info.refs[ref];
} else if (!ref.match(/^[0-9a-f]{40}$/i)) {
// Try common ref patterns
const possibleRefs = [`refs/heads/${ref}`, `refs/tags/${ref}`];
for (const possibleRef of possibleRefs) {
if (info.refs[possibleRef]) {
resolvedRef = info.refs[possibleRef];
break;
}
}
}
// Fetch the tree using lightweight filter (tree only, no blobs)
const fetchedTree = await getDirectoryTreeAt(url, resolvedRef);
if (!isMountedRef.current) return;
setTree(fetchedTree);
setServerUrl(url);
setLoading(false);
return;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
errors.push(err);
// Log specific error types for debugging
if (e instanceof MissingCapability) {
console.warn(
`[useGitTree] Server ${url} missing capability: ${e.capability}`,
);
} else {
console.warn(
`[useGitTree] Failed to fetch from ${url}:`,
err.message,
);
}
continue;
}
}
if (!isMountedRef.current) return;
// All URLs failed
const message =
errors.length === 1
? errors[0].message
: `All ${stableCloneUrls.length} servers failed`;
setError(new Error(message));
setLoading(false);
}, [stableCloneUrls, ref]);
useEffect(() => {
isMountedRef.current = true;
if (enabled && stableCloneUrls.length > 0) {
fetchTree();
}
return () => {
isMountedRef.current = false;
};
}, [enabled, fetchTree, stableCloneUrls]);
return {
tree,
loading,
error,
serverUrl,
refetch: fetchTree,
};
}

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { highlightCode } from "@/lib/shiki";
interface UseHighlightedCodeResult {
html: string | null;
loading: boolean;
error: Error | null;
}
/**
* Hook to highlight code asynchronously with lazy language loading
*
* @example
* const { html, loading } = useHighlightedCode(code, "typescript")
* if (loading) return <pre>{code}</pre>
* return <div dangerouslySetInnerHTML={{ __html: html }} />
*/
export function useHighlightedCode(
code: string,
language: string | null | undefined,
): UseHighlightedCodeResult {
const [html, setHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
highlightCode(code, language)
.then((result) => {
if (!cancelled) {
setHtml(result);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
return { html, loading, error };
}

View File

@@ -1,8 +1,8 @@
/* Tailwind CSS v4 - CSS-first configuration */
@import "tailwindcss";
/* Prism syntax highlighting theme */
@import "./styles/prism-theme.css";
/* Shiki syntax highlighting theme */
@import "./styles/shiki-theme.css";
/* ==========================================================================
Theme Configuration (@theme)
@@ -164,6 +164,8 @@
--syntax-function: 222.2 47.4% 11.2%;
--syntax-variable: 222.2 84% 4.9%;
--syntax-operator: 222.2 84% 20%;
--syntax-constant: 199 89% 48%;
--syntax-tag: 142 76% 36%;
/* Diff colors */
--diff-inserted: 142 60% 30%;
@@ -223,15 +225,17 @@
--tooltip: 217.2 32.6% 30%;
--tooltip-foreground: 210 40% 98%;
/* Syntax highlighting */
--syntax-comment: 215 20.2% 70%;
--syntax-punctuation: 210 40% 70%;
/* Syntax highlighting - high contrast for dark backgrounds */
--syntax-comment: 215 20% 55%;
--syntax-punctuation: 210 30% 75%;
--syntax-property: 210 40% 98%;
--syntax-string: 215 20.2% 70%;
--syntax-keyword: 210 40% 98%;
--syntax-string: 140 70% 65%;
--syntax-keyword: 270 100% 80%;
--syntax-function: 210 40% 98%;
--syntax-variable: 210 40% 98%;
--syntax-operator: 210 40% 98%;
--syntax-constant: 199 90% 70%;
--syntax-tag: 140 70% 65%;
/* Diff colors */
--diff-inserted: 134 60% 76%;

50
src/lib/git-types.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Types for git repository tree visualization
* Based on @fiatjaf/git-natural-api library
*/
/**
* Directory tree structure returned by git-natural-api
*/
export interface DirectoryTree {
directories: Array<{
name: string;
hash: string;
content: DirectoryTree | null;
}>;
files: Array<{
name: string;
hash: string;
content: Uint8Array | null;
}>;
}
/**
* Git server info/refs response
*/
export interface GitInfoRefs {
service: string | null;
refs: Record<string, string>;
capabilities: string[];
symrefs: Record<string, string>;
}
/**
* Flattened file entry for tree display
*/
export interface FileEntry {
name: string;
hash: string;
path: string;
isDirectory: boolean;
children?: FileEntry[];
}
/**
* Selected file in the tree
*/
export interface SelectedFile {
name: string;
hash: string;
path: string;
}

269
src/lib/shiki.ts Normal file
View File

@@ -0,0 +1,269 @@
import {
createHighlighterCore,
createCssVariablesTheme,
type HighlighterCore,
} from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
// Singleton highlighter instance
let highlighter: HighlighterCore | null = null;
let highlighterPromise: Promise<HighlighterCore> | null = null;
const loadedLanguages = new Set<string>();
const failedLanguages = new Set<string>();
/**
* CSS Variables theme for Shiki
* Maps TextMate scopes to CSS variable names that reference our theme system.
*/
const cssVarsTheme = createCssVariablesTheme({
name: "css-variables",
variablePrefix: "--shiki-",
variableDefaults: {
foreground: "var(--shiki-color-text)",
background: "var(--shiki-color-background)",
},
fontStyle: true,
});
/**
* Language alias mapping (file extensions and common names to Shiki IDs)
*/
const LANGUAGE_ALIASES: Record<string, string> = {
// JavaScript family
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
// Python
py: "python",
pyw: "python",
// Ruby
rb: "ruby",
// Rust
rs: "rust",
// Go
go: "go",
// Shell
sh: "bash",
bash: "bash",
shell: "bash",
zsh: "bash",
fish: "fish",
// Config/Data
yml: "yaml",
yaml: "yaml",
toml: "toml",
ini: "ini",
// JSON
json: "json",
jsonc: "jsonc",
json5: "json5",
// Markdown
md: "markdown",
mdx: "mdx",
// CSS
css: "css",
scss: "scss",
sass: "sass",
less: "less",
// HTML/XML
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
// SQL
sql: "sql",
// C family
c: "c",
h: "c",
cpp: "cpp",
"c++": "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
hxx: "cpp",
// C#
cs: "csharp",
csharp: "csharp",
// Java/JVM
java: "java",
kt: "kotlin",
kotlin: "kotlin",
scala: "scala",
groovy: "groovy",
// Apple
swift: "swift",
objc: "objective-c",
// PHP
php: "php",
// Lua
lua: "lua",
// Vim
vim: "viml",
// Docker
dockerfile: "dockerfile",
docker: "dockerfile",
// Make
makefile: "makefile",
make: "makefile",
// Diff/Patch
diff: "diff",
patch: "diff",
// Blockchain
sol: "solidity",
solidity: "solidity",
// Zig
zig: "zig",
// Functional
ex: "elixir",
exs: "elixir",
erl: "erlang",
hs: "haskell",
ml: "ocaml",
clj: "clojure",
cljs: "clojure",
// GraphQL
graphql: "graphql",
gql: "graphql",
// Protocol Buffers
proto: "protobuf",
// Nix
nix: "nix",
// Terraform
tf: "hcl",
hcl: "hcl",
// PowerShell
ps1: "powershell",
psm1: "powershell",
// R
r: "r",
// Perl
pl: "perl",
pm: "perl",
// LaTeX
tex: "latex",
latex: "latex",
// WASM
wat: "wasm",
wasm: "wasm",
};
/**
* Core languages to preload (most commonly used in Grimoire)
*/
const CORE_LANGUAGES = [
"javascript",
"typescript",
"json",
"html",
"css",
"diff",
"bash",
"rust",
"toml",
"markdown",
] as const;
/**
* Normalize language identifier to Shiki language ID
*/
export function normalizeLanguage(lang: string | null | undefined): string {
if (!lang) return "text";
const normalized = lang.toLowerCase().trim();
return LANGUAGE_ALIASES[normalized] || normalized;
}
/**
* Get or create the singleton highlighter instance
*/
export async function getHighlighter(): Promise<HighlighterCore> {
if (highlighter) return highlighter;
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [cssVarsTheme],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/html.mjs"),
import("shiki/langs/css.mjs"),
import("shiki/langs/diff.mjs"),
import("shiki/langs/bash.mjs"),
import("shiki/langs/rust.mjs"),
import("shiki/langs/toml.mjs"),
import("shiki/langs/markdown.mjs"),
],
engine: createOnigurumaEngine(import("shiki/wasm")),
}).then((hl) => {
highlighter = hl;
CORE_LANGUAGES.forEach((l) => loadedLanguages.add(l));
return hl;
});
}
return highlighterPromise;
}
/**
* Load a language on demand
*/
async function loadLanguage(lang: string): Promise<boolean> {
if (lang === "text" || loadedLanguages.has(lang)) return true;
if (failedLanguages.has(lang)) return false;
const hl = await getHighlighter();
try {
// Dynamic import for the language
const langModule = await import(`shiki/langs/${lang}.mjs`);
await hl.loadLanguage(langModule.default || langModule);
loadedLanguages.add(lang);
return true;
} catch {
// Language not available - track to avoid repeated warnings
failedLanguages.add(lang);
console.warn(
`[shiki] Language "${lang}" not available, falling back to plaintext`,
);
return false;
}
}
/**
* Highlight code with lazy language loading
* Returns HTML string with CSS classes for styling via CSS variables
*/
export async function highlightCode(
code: string,
language: string | null | undefined,
): Promise<string> {
const lang = normalizeLanguage(language);
const hl = await getHighlighter();
// Try to load the language if not already loaded
const loaded = await loadLanguage(lang);
const effectiveLang = loaded ? lang : "text";
return hl.codeToHtml(code, {
lang: effectiveLang,
theme: "css-variables",
});
}
/**
* Check if a language is loaded
*/
export function isLanguageLoaded(lang: string): boolean {
return loadedLanguages.has(normalizeLanguage(lang));
}
/**
* Preload languages (e.g., before rendering known content)
*/
export async function preloadLanguages(langs: string[]): Promise<void> {
await getHighlighter();
await Promise.all(langs.map((l) => loadLanguage(normalizeLanguage(l))));
}

View File

@@ -1,144 +0,0 @@
/* Grimoire Prism Theme - Uses CSS theme variables */
code[class*="language-"],
pre[class*="language-"] {
color: hsl(var(--foreground));
background: none;
text-shadow: none;
font-family: "Oxygen Mono", monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 4;
hyphens: none;
}
/* Diff-specific tokens */
/* Deleted lines (red) - subtle background, no strikethrough */
.token.deleted {
color: hsl(var(--diff-deleted));
background: hsl(var(--diff-deleted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Added lines (green) - subtle background */
.token.inserted {
color: hsl(var(--diff-inserted));
background: hsl(var(--diff-inserted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Hunk headers (@@ -1,5 +1,7 @@) - cyan/blue */
.token.diff.coord,
.token.coord {
color: hsl(var(--diff-meta));
background: hsl(var(--diff-meta-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
font-style: normal;
}
/* File headers (diff --git, ---, +++) */
.token.diff.range,
.token.prefix.unchanged,
.language-diff .token.unchanged {
color: hsl(var(--muted-foreground));
font-weight: normal;
}
/* Prefix characters (+/-) */
.language-diff .token.prefix {
font-weight: 700;
opacity: 0.7;
}
/* General syntax tokens */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: hsl(var(--syntax-comment));
}
.token.punctuation {
color: hsl(var(--syntax-punctuation));
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol {
color: hsl(var(--syntax-property));
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin {
color: hsl(var(--syntax-string));
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: hsl(var(--syntax-operator));
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: hsl(var(--syntax-keyword));
}
.token.function,
.token.class-name {
color: hsl(var(--syntax-function));
font-weight: bold;
}
.token.regex,
.token.important,
.token.variable {
color: hsl(var(--syntax-variable));
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line highlighting */
pre[class*="language-"] > code {
display: block;
}
/* Optional: Line numbers support */
.line-numbers .line-numbers-rows {
border-right: 1px solid hsl(var(--border));
}
.line-numbers-rows > span:before {
color: hsl(var(--muted-foreground));
}

145
src/styles/shiki-theme.css Normal file
View File

@@ -0,0 +1,145 @@
/* ==========================================================================
Shiki Syntax Highlighting - CSS Variable Based Theming
Maps Shiki's CSS variables to our theme system variables,
allowing automatic adaptation to light/dark mode.
========================================================================== */
/* ==========================================================================
Shiki CSS Variables -> Theme Variables Mapping
These are set by Shiki's css-variables theme and we map them to our theme
========================================================================== */
:root {
/* Default/fallback - uses foreground */
--shiki-color-text: hsl(var(--foreground));
--shiki-color-background: transparent;
/* Core token colors - mapped to our syntax theme variables */
--shiki-token-constant: hsl(var(--syntax-constant));
--shiki-token-string: hsl(var(--syntax-string));
--shiki-token-comment: hsl(var(--syntax-comment));
--shiki-token-keyword: hsl(var(--syntax-keyword));
--shiki-token-parameter: hsl(var(--syntax-variable));
--shiki-token-function: hsl(var(--syntax-function));
--shiki-token-string-expression: hsl(var(--syntax-string));
--shiki-token-punctuation: hsl(var(--syntax-punctuation));
--shiki-token-link: hsl(var(--syntax-string));
/* HTML/XML tokens */
--shiki-token-tag: hsl(var(--syntax-tag));
--shiki-token-attribute: hsl(var(--syntax-property));
--shiki-token-attr-value: hsl(var(--syntax-string));
/* CSS tokens */
--shiki-token-selector: hsl(var(--syntax-tag));
--shiki-token-property: hsl(var(--syntax-property));
/* Additional tokens */
--shiki-token-variable: hsl(var(--syntax-variable));
--shiki-token-operator: hsl(var(--syntax-operator));
--shiki-token-number: hsl(var(--syntax-constant));
--shiki-token-boolean: hsl(var(--syntax-constant));
--shiki-token-regex: hsl(var(--syntax-string));
--shiki-token-class: hsl(var(--syntax-keyword));
--shiki-token-interface: hsl(var(--syntax-keyword));
--shiki-token-namespace: hsl(var(--syntax-keyword));
--shiki-token-type: hsl(var(--syntax-keyword));
/* Diff colors */
--shiki-token-deleted: hsl(var(--diff-deleted));
--shiki-token-inserted: hsl(var(--diff-inserted));
}
/* ==========================================================================
Base container styling
========================================================================== */
.shiki-container {
color: hsl(var(--foreground));
}
.shiki-container pre {
background: transparent !important;
margin: 0;
padding: 0;
}
.shiki-container code {
font-family: "Oxygen Mono", monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
tab-size: 4;
color: hsl(var(--foreground));
/* Flex layout for consistent line spacing */
display: flex;
flex-direction: column;
gap: 0;
/* Ensure lines extend for proper diff backgrounds */
width: fit-content;
min-width: 100%;
}
/* Lines with proper spacing */
.shiki-container .line {
display: block;
min-height: 1.5em;
/* Ensure background extends full width for diff highlighting */
width: 100%;
}
/* Ensure shiki's inline styles don't conflict */
.shiki-container .shiki {
background: transparent !important;
}
.shiki-container .shiki code {
background: transparent !important;
}
/* ==========================================================================
Loading State
========================================================================== */
.shiki-loading {
animation: shiki-pulse 1.5s ease-in-out infinite;
}
@keyframes shiki-pulse {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 0.5;
}
}
/* ==========================================================================
Diff Block Backgrounds
========================================================================== */
/* Diff-specific styling - highlight inserted/deleted lines */
.shiki-container .line:has([style*="--shiki-token-deleted"]) {
background: hsl(var(--diff-deleted-bg));
}
.shiki-container .line:has([style*="--shiki-token-inserted"]) {
background: hsl(var(--diff-inserted-bg));
}
/* ==========================================================================
Line Numbers (optional)
========================================================================== */
.shiki-container.line-numbers .line::before {
content: attr(data-line);
display: inline-block;
width: 2rem;
margin-right: 1rem;
text-align: right;
color: hsl(var(--muted-foreground));
border-right: 1px solid hsl(var(--border));
padding-right: 0.5rem;
}