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)
This commit is contained in:
Claude
2026-01-30 10:03:01 +00:00
parent 7790d1821c
commit 55fb95789b
3 changed files with 75 additions and 210 deletions

View File

@@ -1,7 +1,15 @@
import { useState } from "react";
import { FolderGit2, AlertCircle, FileQuestion, Binary } from "lucide-react";
import {
FolderGit2,
AlertCircle,
FileQuestion,
Binary,
Copy,
Check,
} from "lucide-react";
import { useGitTree } from "@/hooks/useGitTree";
import { useGitBlob } from "@/hooks/useGitBlob";
import { useCopy } from "@/hooks/useCopy";
import { FileTreeView } from "@/components/ui/FileTreeView";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
import { Skeleton } from "@/components/ui/skeleton/Skeleton";
@@ -71,6 +79,9 @@ export function RepositoryFilesSection({
? getExtension(selectedFile.name) || null
: null;
// Copy functionality for file content
const { copy, copied } = useCopy();
const handleFileSelect = (file: SelectedFile) => {
setSelectedFile(file);
};
@@ -170,11 +181,25 @@ export function RepositoryFilesSection({
) : 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">
<span className="text-xs font-mono text-muted-foreground truncate">
{selectedFile.path}
</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono text-muted-foreground truncate">
{selectedFile.path}
</span>
<button
type="button"
onClick={() => copy(fileContent)}
className="flex-shrink-0 p-1 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title="Copy file content"
>
{copied ? (
<Check className="size-3.5 text-success" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
{rawContent && (
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatSize(rawContent.length)}
</span>
)}

View File

@@ -1,7 +1,7 @@
import {
createHighlighterCore,
createCssVariablesTheme,
type HighlighterCore,
type ShikiTransformer,
} from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
@@ -12,146 +12,16 @@ const loadedLanguages = new Set<string>();
const failedLanguages = new Set<string>();
/**
* Transformer that cleans up Shiki output for our styling
* Keeps inline colors from the theme but removes backgrounds
* CSS Variables theme for Shiki
* This outputs CSS custom properties that we map to our theme variables.
* The actual colors are defined in shiki-theme.css using our theme system.
*/
const cleanupTransformer: ShikiTransformer = {
name: "cleanup-transformer",
pre(node) {
// Remove background color from pre, let CSS handle it
if (node.properties?.style) {
const style = node.properties.style as string;
node.properties.style = style.replace(/background-color:[^;]+;?/g, "");
}
},
code(node) {
// Remove background from code element, keep color
if (node.properties?.style) {
const style = node.properties.style as string;
node.properties.style = style.replace(/background-color:[^;]+;?/g, "");
}
},
};
/**
* Minimal theme - we'll override colors via CSS
* Using high-contrast colors as fallback if CSS fails
*/
const minimalTheme = {
name: "grimoire",
type: "dark" as const,
colors: {
"editor.background": "transparent",
"editor.foreground": "#e6edf3",
},
tokenColors: [
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#8b949e" },
},
{
scope: ["string", "string.quoted"],
settings: { foreground: "#a5d6ff" },
},
{
scope: [
"keyword",
"storage",
"storage.type",
"storage.modifier",
"keyword.operator",
"keyword.control",
],
settings: { foreground: "#f0f0f0" },
},
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#e6edf3" },
},
{
scope: [
"entity.name.class",
"entity.name.type",
"support.class",
"support.type",
],
settings: { foreground: "#f0f0f0" },
},
{
scope: [
"constant",
"constant.numeric",
"constant.language",
"constant.character",
],
settings: { foreground: "#79c0ff" },
},
{
scope: ["variable", "variable.parameter", "variable.other"],
settings: { foreground: "#e6edf3" },
},
{
scope: ["punctuation", "meta.brace"],
settings: { foreground: "#c9d1d9" },
},
{
scope: [
"variable.other.property",
"entity.other.attribute-name",
"support.type.property-name",
],
settings: { foreground: "#e6edf3" },
},
{
scope: ["entity.name.tag", "support.class.component"],
settings: { foreground: "#7ee787" },
},
{
scope: ["support.type.property-name.json"],
settings: { foreground: "#a5d6ff" },
},
{
scope: [
"markup.deleted",
"punctuation.definition.deleted",
"meta.diff.header.from-file",
],
settings: { foreground: "#ffa198" },
},
{
scope: [
"markup.inserted",
"punctuation.definition.inserted",
"meta.diff.header.to-file",
],
settings: { foreground: "#7ee787" },
},
{
scope: ["markup.changed", "meta.diff.range", "meta.diff.header"],
settings: { foreground: "#a5d6ff" },
},
{
scope: ["markup.heading", "entity.name.section"],
settings: { foreground: "#f0f0f0" },
},
{
scope: ["markup.bold"],
settings: { fontStyle: "bold" },
},
{
scope: ["markup.italic"],
settings: { fontStyle: "italic" },
},
{
scope: ["markup.underline.link"],
settings: { foreground: "#a5d6ff" },
},
{
scope: ["markup.inline.raw", "markup.raw"],
settings: { foreground: "#a5d6ff" },
},
],
};
const cssVarsTheme = createCssVariablesTheme({
name: "css-variables",
variablePrefix: "--shiki-",
variableDefaults: {},
fontStyle: true,
});
/**
* Language alias mapping (file extensions and common names to Shiki IDs)
@@ -308,7 +178,7 @@ export async function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [minimalTheme],
themes: [cssVarsTheme],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
@@ -371,8 +241,7 @@ export async function highlightCode(
return hl.codeToHtml(code, {
lang: effectiveLang,
theme: "grimoire",
transformers: [cleanupTransformer],
theme: "css-variables",
});
}

View File

@@ -1,11 +1,40 @@
/* ==========================================================================
Shiki Syntax Highlighting - CSS Variable Based Theming
Token colors are defined using CSS variables from the theme system,
Maps Shiki's CSS variables to our theme system variables,
allowing automatic adaptation to light/dark mode.
========================================================================== */
/* Base container styling */
/* ==========================================================================
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;
/* 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));
/* 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));
}
@@ -36,55 +65,6 @@
background: transparent !important;
}
/* ==========================================================================
Token Classes - Styled with CSS Variables
========================================================================== */
/* Base token - inherits foreground color */
.shiki-token {
color: hsl(var(--syntax-variable));
}
/* Comments */
.shiki-comment {
color: hsl(var(--syntax-comment));
}
/* Strings */
.shiki-string {
color: hsl(var(--syntax-string));
}
/* Keywords (if, else, return, function, etc.) */
.shiki-keyword {
color: hsl(var(--syntax-keyword));
}
/* Constants and numbers */
.shiki-constant {
color: hsl(var(--syntax-constant));
}
/* Punctuation */
.shiki-punctuation {
color: hsl(var(--syntax-punctuation));
}
/* Tags (HTML/JSX) */
.shiki-tag {
color: hsl(var(--syntax-tag));
}
/* Diff - deleted lines */
.shiki-deleted {
color: hsl(var(--diff-deleted));
}
/* Diff - inserted lines */
.shiki-inserted {
color: hsl(var(--diff-inserted));
}
/* ==========================================================================
Loading State
========================================================================== */
@@ -108,29 +88,20 @@
========================================================================== */
/* Diff-specific styling - block-level backgrounds for inserted/deleted */
.shiki-container .line:has(.shiki-deleted) {
.shiki-container .line:has([style*="--shiki-token-deleted"]) {
background: hsl(var(--diff-deleted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
.shiki-container .line:has(.shiki-inserted) {
.shiki-container .line:has([style*="--shiki-token-inserted"]) {
background: hsl(var(--diff-inserted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Hunk headers (@@ lines) */
.shiki-container .line:has([class*="meta"]) {
background: hsl(var(--diff-meta-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
}
/* ==========================================================================
Line Numbers (optional)
========================================================================== */