Design pattern & component consistency governance across 120 files: ESLint governance (eslint.config.mjs): - Enforce ui/ components over native HTML controls (warn) - Ban Lucide imports (error), require Phosphor via ui/icon.tsx (warn) - Component file size limit 500 lines (warn, skip blanks+comments) - Patterns layer isolation (error: no hooks/lib imports) - Exclude 资料/ from lint scope Icon unification (ui/icon.tsx): - Unified barrel export with 86+ Phosphor icons - Decoupled types/index.ts from icon library (generic IconComponent type) - Separated command data (lib/constants/commands.ts) from icons (command-icons.ts) Color token system (globals.css): - Added semantic status tokens: success/warning/error/info with muted/foreground/border variants - Migrated 39 files from raw green/red/orange/amber/blue to semantic tokens - grep-based lint:colors script covers green/emerald/red/orange/yellow/amber/blue Component splits (all under 500 lines): - MessageInput 1659→444: MessageInputParts, SlashCommandPopover, 7 hooks, 2 libs - ChatView ~850→419: useStreamSubscription, useAssistantTrigger - ProviderManager 1081→406: provider-presets, PresetConnectDialog, useProviderModels - AppShell 527→470: useUpdateChecker - AssistantWorkspaceSection 908→499: WorkspaceTabPanels/ConfirmDialogs/StatusCards - ChatListPanel 677→484: SessionListItem, ProjectGroupHeader, chat-list-utils Other: - Migrated 80 native HTML controls to ui/ components across 38 files - Fixed 22 hook dependency / set-state-in-effect issues - Cleaned 7 dead code warnings - Rebuilt better-sqlite3 for ARM architecture - Added handover doc: docs/handover/ui-governance.md Lint status: 0 errors 0 warnings on touched files; typecheck pass; 374/374 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
8.3 KiB
TypeScript
289 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import { useTheme } from "next-themes";
|
|
import { X, Copy, Check, SpinnerGap } from "@/components/ui/icon";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
import { useThemeFamily } from "@/lib/theme/context";
|
|
import { resolveCodeTheme, resolveHljsStyle } from "@/lib/theme/code-themes";
|
|
import { Streamdown } from "streamdown";
|
|
import { cjk } from "@streamdown/cjk";
|
|
import { code } from "@streamdown/code";
|
|
import { math } from "@streamdown/math";
|
|
import { mermaid } from "@streamdown/mermaid";
|
|
import { usePanel } from "@/hooks/usePanel";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
import type { FilePreview as FilePreviewType } from "@/types";
|
|
|
|
const streamdownPlugins = { cjk, code, math, mermaid };
|
|
|
|
type ViewMode = "source" | "rendered";
|
|
|
|
interface DocPreviewProps {
|
|
filePath: string;
|
|
viewMode: ViewMode;
|
|
onViewModeChange: (mode: ViewMode) => void;
|
|
onClose: () => void;
|
|
width: number;
|
|
}
|
|
|
|
/** Extensions that support a rendered preview */
|
|
const RENDERABLE_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]);
|
|
|
|
function getExtension(filePath: string): string {
|
|
const dot = filePath.lastIndexOf(".");
|
|
return dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
|
|
}
|
|
|
|
function isRenderable(filePath: string): boolean {
|
|
return RENDERABLE_EXTENSIONS.has(getExtension(filePath));
|
|
}
|
|
|
|
function isHtml(filePath: string): boolean {
|
|
const ext = getExtension(filePath);
|
|
return ext === ".html" || ext === ".htm";
|
|
}
|
|
|
|
export function DocPreview({
|
|
filePath,
|
|
viewMode,
|
|
onViewModeChange,
|
|
onClose,
|
|
width,
|
|
}: DocPreviewProps) {
|
|
const { resolvedTheme } = useTheme();
|
|
const { workingDirectory } = usePanel();
|
|
const isDark = resolvedTheme === "dark";
|
|
const [preview, setPreview] = useState<FilePreviewType | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadPreview() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/files/preview?path=${encodeURIComponent(filePath)}&maxLines=500${workingDirectory ? `&baseDir=${encodeURIComponent(workingDirectory)}` : ''}`
|
|
);
|
|
if (!res.ok) {
|
|
const data = await res.json();
|
|
throw new Error(data.error || "Failed to load file");
|
|
}
|
|
const data = await res.json();
|
|
if (!cancelled) {
|
|
setPreview(data.preview);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : "Failed to load file");
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadPreview();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [filePath, workingDirectory]);
|
|
|
|
const handleCopyContent = async () => {
|
|
const text = preview?.content || filePath;
|
|
await navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
const fileName = filePath.split("/").pop() || filePath;
|
|
|
|
// Build breadcrumb — show last 3 segments
|
|
const breadcrumb = useMemo(() => {
|
|
const segments = filePath.split("/").filter(Boolean);
|
|
const display = segments.slice(-3);
|
|
const prefix = display.length < segments.length ? ".../" : "";
|
|
return prefix + display.join("/");
|
|
}, [filePath]);
|
|
|
|
const canRender = isRenderable(filePath);
|
|
|
|
return (
|
|
<div
|
|
className="hidden h-full shrink-0 flex-col overflow-hidden border-l border-border/40 bg-background lg:flex"
|
|
style={{ width }}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex h-12 mt-5 shrink-0 items-center gap-2 px-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium">{fileName}</p>
|
|
</div>
|
|
|
|
{canRender && (
|
|
<ViewModeToggle value={viewMode} onChange={onViewModeChange} />
|
|
)}
|
|
|
|
<Button variant="ghost" size="icon-sm" onClick={handleCopyContent}>
|
|
{copied ? (
|
|
<Check size={14} className="text-status-success-foreground" />
|
|
) : (
|
|
<Copy size={14} />
|
|
)}
|
|
<span className="sr-only">Copy content</span>
|
|
</Button>
|
|
|
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
|
<X size={14} />
|
|
<span className="sr-only">Close preview</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Breadcrumb + language — subtle, no border */}
|
|
<div className="flex shrink-0 items-center gap-2 px-3 pb-2">
|
|
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/60">
|
|
{breadcrumb}
|
|
</p>
|
|
{preview && (
|
|
<span className="shrink-0 text-[10px] text-muted-foreground/50">
|
|
{preview.language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-h-0 overflow-auto">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<SpinnerGap size={20} className="animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</div>
|
|
) : preview ? (
|
|
viewMode === "rendered" && canRender ? (
|
|
<RenderedView content={preview.content} filePath={filePath} />
|
|
) : (
|
|
<SourceView preview={preview} isDark={isDark} />
|
|
)
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Capsule toggle for Source / Preview view mode */
|
|
function ViewModeToggle({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: ViewMode;
|
|
onChange: (v: ViewMode) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex h-6 items-center rounded-full bg-muted p-0.5 text-[11px]">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`rounded-full px-2 py-0.5 font-medium h-auto ${
|
|
value === "source"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => onChange("source")}
|
|
>
|
|
Source
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`rounded-full px-2 py-0.5 font-medium h-auto ${
|
|
value === "rendered"
|
|
? "bg-background text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
onClick={() => onChange("rendered")}
|
|
>
|
|
Preview
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Resolve hljs style from the current theme family + mode. */
|
|
function useDocCodeTheme(isDark: boolean) {
|
|
const { family, families } = useThemeFamily();
|
|
const codeTheme = resolveCodeTheme(families, family);
|
|
return resolveHljsStyle(codeTheme, isDark);
|
|
}
|
|
|
|
/** Source code view using react-syntax-highlighter */
|
|
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
|
|
const hljsStyle = useDocCodeTheme(isDark);
|
|
return (
|
|
<div className="text-xs">
|
|
<SyntaxHighlighter
|
|
language={preview.language}
|
|
style={hljsStyle}
|
|
showLineNumbers
|
|
customStyle={{
|
|
margin: 0,
|
|
padding: "8px",
|
|
borderRadius: 0,
|
|
fontSize: "11px",
|
|
lineHeight: "1.5",
|
|
background: "transparent",
|
|
}}
|
|
lineNumberStyle={{
|
|
minWidth: "2.5em",
|
|
paddingRight: "8px",
|
|
color: "var(--muted-foreground)",
|
|
opacity: 0.5,
|
|
userSelect: "none",
|
|
}}
|
|
>
|
|
{preview.content}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Rendered view for markdown / HTML files */
|
|
function RenderedView({
|
|
content,
|
|
filePath,
|
|
}: {
|
|
content: string;
|
|
filePath: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (isHtml(filePath)) {
|
|
return (
|
|
<iframe
|
|
srcDoc={content}
|
|
sandbox=""
|
|
className="h-full w-full border-0"
|
|
title={t('docPreview.htmlPreview')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Markdown / MDX
|
|
return (
|
|
<div className="px-6 py-4 overflow-x-hidden break-words">
|
|
<Streamdown
|
|
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_ul]:pl-6 [&_ol]:pl-6"
|
|
plugins={streamdownPlugins}
|
|
>
|
|
{content}
|
|
</Streamdown>
|
|
</div>
|
|
);
|
|
}
|