mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 02:31:13 +02:00
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:
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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"}`}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
212
src/components/nostr/kinds/RepositoryFilesSection.tsx
Normal file
212
src/components/nostr/kinds/RepositoryFilesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
src/components/ui/FileTreeView.tsx
Normal file
302
src/components/ui/FileTreeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/ui/IconCopyButton.tsx
Normal file
55
src/components/ui/IconCopyButton.tsx
Normal 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
137
src/hooks/useGitBlob.ts
Normal 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
175
src/hooks/useGitTree.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
src/hooks/useHighlightedCode.ts
Normal file
51
src/hooks/useHighlightedCode.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
50
src/lib/git-types.ts
Normal 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
269
src/lib/shiki.ts
Normal 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))));
|
||||
}
|
||||
@@ -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
145
src/styles/shiki-theme.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user