singularity-forge/web/components/sf/file-content-viewer.tsx
Mikael Hugo 2d34d3a386 fix(web): resolve ESLint regressions from eslint-config-next upgrade
- Escape unescaped entities (react/no-unescaped-entities) in step-remote,
  step-welcome, projects-view, settings-panels
- Add targeted eslint-disable-next-line for react-hooks/set-state-in-effect
  on established async-fetch and prop-sync patterns in useEffect bodies:
  chat-mode, file-content-viewer, files-view, step-dev-root, projects-view,
  settings-panels, update-banner, visualizer-view, carousel, use-mobile
- Add targeted eslint-disable-next-line for react-hooks/purity on Date.now()
  display timestamps in streaming chat messages (chat-mode)
- Remove now-unused eslint-disable directives (projects-view, settings-panels)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:18:58 +02:00

992 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Loader2, Save, X } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CodeEditor } from "@/components/sf/code-editor";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEditorFontSize } from "@/lib/use-editor-font-size";
import { cn } from "@/lib/utils";
/* ── Language detection ── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
jsonc: "jsonc",
md: "markdown",
mdx: "mdx",
css: "css",
scss: "scss",
less: "less",
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "fish",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
php: "php",
sql: "sql",
graphql: "graphql",
gql: "graphql",
dockerfile: "dockerfile",
makefile: "makefile",
lua: "lua",
vim: "viml",
r: "r",
tex: "latex",
diff: "diff",
ini: "ini",
conf: "ini",
env: "dotenv",
};
const SPECIAL_FILENAMES: Record<string, string> = {
Dockerfile: "dockerfile",
Makefile: "makefile",
Containerfile: "dockerfile",
Justfile: "makefile",
Rakefile: "ruby",
Gemfile: "ruby",
".env": "dotenv",
".env.local": "dotenv",
".env.example": "dotenv",
".eslintrc": "json",
".prettierrc": "json",
"tsconfig.json": "jsonc",
"jsconfig.json": "jsonc",
};
function detectLanguage(filepath: string): string | null {
const filename = filepath.split("/").pop() ?? "";
// Check special filenames first
if (SPECIAL_FILENAMES[filename]) return SPECIAL_FILENAMES[filename];
const ext = filename.includes(".")
? filename.split(".").pop()?.toLowerCase()
: null;
if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext];
return null;
}
function isMarkdown(filepath: string): boolean {
const ext = filepath.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
/* ── Shiki singleton ── */
type ShikiHighlighter = {
codeToTokens: (
code: string,
options: { lang: string; theme: string },
) => {
tokens: Array<
Array<{
color?: string;
content: string;
fontStyle?: number;
offset?: number;
}>
>;
bg?: string;
fg?: string;
};
};
let highlighterPromise: Promise<ShikiHighlighter> | null = null;
async function getHighlighter(): Promise<ShikiHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki")
.then((mod) =>
mod.createHighlighter({
themes: ["github-dark-default", "github-light-default"],
langs: [
"typescript",
"tsx",
"javascript",
"jsx",
"json",
"jsonc",
"markdown",
"mdx",
"css",
"scss",
"less",
"html",
"xml",
"yaml",
"toml",
"bash",
"python",
"ruby",
"rust",
"go",
"java",
"kotlin",
"swift",
"c",
"cpp",
"csharp",
"php",
"sql",
"graphql",
"dockerfile",
"makefile",
"lua",
"diff",
"ini",
"dotenv",
],
}) as Promise<ShikiHighlighter>,
)
.catch((err) => {
// Reset so the next call retries instead of returning a rejected promise forever
highlighterPromise = null;
throw err;
}) as Promise<ShikiHighlighter>;
}
return highlighterPromise!;
}
function HighlightedCode({
code,
highlighter,
lang,
theme,
className,
}: {
code: string;
highlighter: ShikiHighlighter;
lang: string;
theme: string;
className?: string;
}) {
const highlighted = highlighter.codeToTokens(code, { lang, theme });
return (
<pre
className={className}
style={{
backgroundColor: highlighted.bg,
color: highlighted.fg,
}}
>
<code>
{highlighted.tokens.map((line, lineNumber) => (
<span
key={`line-${lineNumber}-${line.map((token) => token.content).join("")}`}
>
{line.map((token, tokenIndex) => (
<span
key={`${token.content}-${token.offset ?? tokenIndex}`}
style={{
color: token.color,
fontStyle:
token.fontStyle === 1 || token.fontStyle === 3
? "italic"
: undefined,
fontWeight:
token.fontStyle === 2 || token.fontStyle === 3
? "bold"
: undefined,
}}
>
{token.content}
</span>
))}
{lineNumber < highlighted.tokens.length - 1 ? "\n" : null}
</span>
))}
</code>
</pre>
);
}
/* ── Code viewer (syntax highlighted) ── */
function CodeViewer({
content,
filepath,
shikiTheme = "github-dark-default",
}: {
content: string;
filepath: string;
shikiTheme?: string;
}) {
const [highlighted, setHighlighted] = useState<React.ReactNode | null>(null);
const [ready, setReady] = useState(false);
const lang = detectLanguage(filepath);
useEffect(() => {
let cancelled = false;
if (!lang) {
const readyTimer = window.setTimeout(() => {
setReady(true);
}, 0);
return () => window.clearTimeout(readyTimer);
}
getHighlighter()
.then((highlighter) => {
if (cancelled) return;
try {
setHighlighted(
<HighlightedCode
code={content}
highlighter={highlighter}
lang={lang}
theme={shikiTheme}
className="file-viewer-code overflow-x-auto text-sm leading-relaxed"
/>,
);
} catch {
// Language not loaded or unsupported — fall back to plain
setHighlighted(null);
}
setReady(true);
})
.catch(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [content, lang, shikiTheme]);
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Highlighting
</div>
);
}
if (highlighted) {
return highlighted;
}
// Fallback: plain text with line numbers
return <PlainViewer content={content} />;
}
/* ── Plain text viewer with line numbers ── */
function PlainViewer({ content }: { content: string }) {
const lines = useMemo(() => content.split("\n"), [content]);
const gutterWidth = String(lines.length).length;
return (
<div className="overflow-x-auto text-sm leading-relaxed font-mono">
<table className="border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-accent/20">
<td
className="select-none pr-4 text-right text-muted-foreground align-top"
style={{ minWidth: `${gutterWidth + 1}ch` }}
>
{i + 1}
</td>
<td className="whitespace-pre text-muted-foreground">
{line || " "}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ── Markdown viewer ── */
function MarkdownViewer({
content,
shikiTheme = "github-dark-default",
}: {
content: string;
filepath: string;
shikiTheme?: string;
}) {
const [rendered, setRendered] = useState<React.ReactNode | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
// Dynamic import to keep the main bundle lean
Promise.all([
import("react-markdown"),
import("remark-gfm"),
getHighlighter(),
])
.then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
if (cancelled) return;
const ReactMarkdown = ReactMarkdownMod.default;
const remarkGfm = remarkGfmMod.default;
setRendered(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const codeStr = String(children).replace(/\n$/, "");
if (match) {
try {
return (
<HighlightedCode
code={codeStr}
highlighter={highlighter}
lang={match[1]}
theme={shikiTheme}
className="file-viewer-code my-3 rounded-md overflow-x-auto text-sm"
/>
);
} catch {
// Fall through to default rendering
}
}
// Inline code or unknown language
const isInline = !className && !String(children).includes("\n");
if (isInline) {
return (
<code
className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono"
{...props}
>
{children}
</code>
);
}
return (
<pre className="my-3 overflow-x-auto rounded-md bg-[#0d1117] p-4 text-sm">
<code>{children}</code>
</pre>
);
},
pre({ children }) {
// Unwrap <pre> since code blocks handle their own wrapper
return <>{children}</>;
},
table({ children }) {
return (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
);
},
th({ children }) {
return (
<th className="border border-border bg-muted/50 px-3 py-2 text-left font-medium">
{children}
</th>
);
},
td({ children }) {
return (
<td className="border border-border px-3 py-2">{children}</td>
);
},
a({ href, children }) {
return (
<a
href={href}
className="text-info hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
img({ src, alt }) {
return (
<span className="my-2 block rounded border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
🖼 {alt || (typeof src === "string" ? src : "") || "image"}
</span>
);
},
}}
>
{content}
</ReactMarkdown>,
);
setReady(true);
})
.catch(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [content, shikiTheme]);
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Rendering
</div>
);
}
if (!rendered) {
return <PlainViewer content={content} />;
}
return <div className="markdown-body">{rendered}</div>;
}
/* ── Inline diff viewer — shows before/after with red/green line highlights ── */
function computeDiffLines(
before: string,
after: string,
): Array<{
type: "add" | "remove" | "context";
lineNum: number | null;
text: string;
}> {
const oldLines = before.split("\n");
const newLines = after.split("\n");
const result: Array<{
type: "add" | "remove" | "context";
lineNum: number | null;
text: string;
}> = [];
// Simple LCS-based diff for inline display
const n = oldLines.length;
const m = newLines.length;
// For files that are too large, fall back to showing just additions/removals
if (n + m > 5000) {
oldLines.forEach((l, i) =>
result.push({ type: "remove", lineNum: i + 1, text: l }),
);
newLines.forEach((l, i) =>
result.push({ type: "add", lineNum: i + 1, text: l }),
);
return result;
}
// Build edit script using O(ND) algorithm (simplified Myers)
const max = n + m;
const v = new Int32Array(2 * max + 1);
const trace: Int32Array[] = [];
outer: for (let d = 0; d <= max; d++) {
const vCopy = new Int32Array(v);
trace.push(vCopy);
for (let k = -d; k <= d; k += 2) {
let x: number;
if (k === -d || (k !== d && v[k - 1 + max] < v[k + 1 + max])) {
x = v[k + 1 + max];
} else {
x = v[k - 1 + max] + 1;
}
let y = x - k;
while (x < n && y < m && oldLines[x] === newLines[y]) {
x++;
y++;
}
v[k + max] = x;
if (x >= n && y >= m) break outer;
}
}
// Backtrack to produce diff
type Edit = {
type: "add" | "remove" | "context";
oldIdx: number;
newIdx: number;
};
const edits: Edit[] = [];
let x = n,
y = m;
for (let d = trace.length - 1; d >= 0; d--) {
const vPrev = trace[d];
const k = x - y;
let prevK: number;
if (k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max])) {
prevK = k + 1;
} else {
prevK = k - 1;
}
const prevX = vPrev[prevK + max];
const prevY = prevX - prevK;
// Diag moves = context lines
while (x > prevX && y > prevY) {
x--;
y--;
edits.push({ type: "context", oldIdx: x, newIdx: y });
}
if (d > 0) {
if (x === prevX) {
// Insert
y--;
edits.push({ type: "add", oldIdx: x, newIdx: y });
} else {
// Delete
x--;
edits.push({ type: "remove", oldIdx: x, newIdx: y });
}
}
}
edits.reverse();
// Convert to output lines, showing only changed regions with ±3 lines of context
const CONTEXT = 3;
const important = new Set<number>();
edits.forEach((e, i) => {
if (e.type !== "context") {
for (
let j = Math.max(0, i - CONTEXT);
j <= Math.min(edits.length - 1, i + CONTEXT);
j++
) {
important.add(j);
}
}
});
let lastIncluded = -1;
for (let i = 0; i < edits.length; i++) {
if (!important.has(i)) continue;
if (lastIncluded >= 0 && i - lastIncluded > 1) {
result.push({ type: "context", lineNum: null, text: "···" });
}
const e = edits[i];
if (e.type === "context") {
result.push({
type: "context",
lineNum: e.newIdx + 1,
text: newLines[e.newIdx],
});
} else if (e.type === "remove") {
result.push({
type: "remove",
lineNum: e.oldIdx + 1,
text: oldLines[e.oldIdx],
});
} else {
result.push({
type: "add",
lineNum: e.newIdx + 1,
text: newLines[e.newIdx],
});
}
lastIncluded = i;
}
return result;
}
function InlineDiffViewer({
before,
after,
}: {
before: string;
after: string;
onDismiss?: () => void;
}) {
const lines = useMemo(() => computeDiffLines(before, after), [before, after]);
return (
<div className="flex-1 overflow-y-auto font-mono text-sm leading-relaxed">
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr
key={i}
className={cn(
line.type === "add" && "bg-emerald-500/10",
line.type === "remove" && "bg-red-500/10",
)}
>
<td className="select-none w-[1ch] pl-2 pr-1 text-center align-top">
{line.type === "add" ? (
<span className="text-emerald-400/80">+</span>
) : line.type === "remove" ? (
<span className="text-red-400/80"></span>
) : null}
</td>
<td
className={cn(
"select-none pr-3 text-right align-top min-w-[3ch]",
line.type === "add"
? "text-emerald-400/40"
: line.type === "remove"
? "text-red-400/40"
: "text-muted-foreground/50",
)}
>
{line.lineNum ?? ""}
</td>
<td
className={cn(
"whitespace-pre pr-4",
line.type === "add" && "text-emerald-300",
line.type === "remove" &&
"text-red-300 line-through decoration-red-400/30",
line.type === "context" &&
line.text === "···" &&
"text-muted-foreground/50 text-center italic",
line.type === "context" &&
line.text !== "···" &&
"text-muted-foreground",
)}
>
{line.text || " "}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ── Read-only content renderer (shared between standalone and tab modes) ── */
function ReadOnlyContent({
content,
filepath,
fontSize,
shikiTheme,
}: {
content: string;
filepath: string;
fontSize?: number;
shikiTheme?: string;
}) {
return (
<div style={fontSize ? { fontSize } : undefined}>
{isMarkdown(filepath) ? (
<MarkdownViewer
content={content}
filepath={filepath}
shikiTheme={shikiTheme}
/>
) : (
<CodeViewer
content={content}
filepath={filepath}
shikiTheme={shikiTheme}
/>
)}
</div>
);
}
/* ── Exported component ── */
interface FileContentViewerProps {
content: string;
filepath: string;
className?: string;
/** Required for editing — the root context for the file */
root?: "sf" | "project";
/** Required for editing — the relative path within the root */
path?: string;
/** Required for editing — called with new content when the user saves */
onSave?: (newContent: string) => Promise<void>;
/** When set, shows an inline diff overlay (before/after content) */
diff?: { before: string; after: string };
/** Called to dismiss the diff overlay */
onDismissDiff?: () => void;
/** When true, MD files default to Edit tab so the raw changes are visible */
agentOpened?: boolean;
}
export function FileContentViewer({
content,
filepath,
className,
root,
path,
onSave,
diff,
onDismissDiff,
agentOpened,
}: FileContentViewerProps) {
const canEdit =
root !== undefined && path !== undefined && onSave !== undefined;
// ── Dirty state tracking ──
const [editContent, setEditContent] = useState(content);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Reset edit content when the source content changes (e.g. after save + re-fetch)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local copy when prop changes
setEditContent(content);
}, [content]);
const isDirty = editContent !== content;
const [fontSize] = useEditorFontSize();
const { resolvedTheme } = useTheme();
const shikiTheme =
resolvedTheme === "light" ? "github-light-default" : "github-dark-default";
const language = detectLanguage(filepath);
const handleSave = useCallback(async () => {
if (!onSave || !isDirty || isSaving) return;
setIsSaving(true);
setSaveError(null);
try {
await onSave(editContent);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save");
} finally {
setIsSaving(false);
}
}, [onSave, isDirty, isSaving, editContent]);
// ── Ctrl+S / Cmd+S keyboard shortcut ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [handleSave]);
// ── Read-only mode (backward compatible) ──
if (!canEdit) {
return (
<div
className={cn("flex-1 overflow-y-auto p-4", className)}
style={{ fontSize }}
>
<ReadOnlyContent
content={content}
filepath={filepath}
fontSize={fontSize}
shikiTheme={shikiTheme}
/>
</div>
);
}
// ── Diff overlay mode: agent just edited this file ──
if (diff) {
return (
<div
className={cn(
"flex flex-1 flex-col overflow-hidden min-h-0",
className,
)}
>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">
{filepath}
</span>
<span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400 uppercase tracking-wide">
Changed
</span>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={onDismissDiff}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-3 w-3" />
Dismiss
</button>
</div>
</div>
<InlineDiffViewer
before={diff.before}
after={diff.after}
onDismiss={onDismissDiff}
/>
</div>
);
}
// ── Editable mode: markdown keeps View/Edit tabs ──
if (isMarkdown(filepath)) {
return (
<Tabs
key={agentOpened ? "agent-edit" : "normal"}
defaultValue={agentOpened ? "edit" : "view"}
className={cn(
"flex flex-1 flex-col overflow-hidden min-h-0",
className,
)}
>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate mr-2">
{filepath}
</span>
<TabsList className="h-7 bg-transparent p-0 ml-auto">
<TabsTrigger
value="view"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
View
</TabsTrigger>
<TabsTrigger
value="edit"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
Edit
</TabsTrigger>
</TabsList>
{/* Save button */}
<div className="flex items-center gap-2">
{saveError && (
<span
className="text-xs text-destructive max-w-[200px] truncate"
title={saveError}
>
{saveError}
</span>
)}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
<TabsContent
value="view"
className="flex-1 overflow-y-auto p-4 mt-0"
style={{ fontSize }}
>
<ReadOnlyContent
content={content}
filepath={filepath}
fontSize={fontSize}
shikiTheme={shikiTheme}
/>
</TabsContent>
<TabsContent
value="edit"
className="flex-1 overflow-hidden mt-0 min-h-0"
>
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="h-full border-0 rounded-none"
/>
</TabsContent>
</Tabs>
);
}
// ── Editable mode: non-markdown gets single CodeEditor view ──
return (
<div
className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}
>
{/* Header bar with filepath and save button */}
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">
{filepath}
</span>
<div className="ml-auto flex items-center gap-2">
{saveError && (
<span
className="text-xs text-destructive max-w-[200px] truncate"
title={saveError}
>
{saveError}
</span>
)}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
{/* CodeEditor fills remaining space */}
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="flex-1 min-h-0 border-0 rounded-none"
/>
</div>
);
}