- 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>
992 lines
23 KiB
TypeScript
992 lines
23 KiB
TypeScript
"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>
|
||
);
|
||
}
|