Files
fusion/packages/dashboard/app/components/FileEditor.tsx
gsxdsm 1e49494bac feat(i18n): full-sweep string migration — 5,930 keys across 5 locales (#1352)
Migration (multi-agent sweep over 216 files, 60 batches):
- Every user-visible dashboard + TUI string moved to t() with the exact
  English inline default (en rendering byte-identical)
- Catalogs merged from per-batch fragments: en/zh-CN/zh-TW/fr/es now
  carry ~5,930 keys each across common/app/errors/cli namespaces;
  CLI bundles regenerated (6 locales incl. ko)

Integration fixes:
- 18 type errors: reserved {{count}} interpolations renamed, malformed
  plural call, hand-rolled t-param types replaced with TFunction<"app">
- 23 lint errors: superseded label constants/helpers removed
- ExecutorStatusBar hook-order violation (keyboard-open early return
  moved below hooks)
- TUI tests wrapped in I18nextProvider (uninitialized fallback renders
  literal {{placeholders}}); dashboard vitest.setup boots a minimal en
  i18next instance for the same reason

Known WIP (next commits): ~457 residual strings across 50 batches,
Korean drafts for swept keys, and a dashboard test-suite pass that is
still being stabilized (~283 failures under investigation — fake-timer
waitFor interaction, likely stale node_modules vs merged lockfile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:06:53 -07:00

236 lines
9.7 KiB
TypeScript

import { useState, useCallback, useMemo, useRef, useId, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { FileEdit, Eye, ListOrdered, WrapText, ChevronDown, ChevronUp } from "lucide-react";
import { EditorView, lineNumbers } from "@codemirror/view";
import { EditorState, Compartment, type Extension } from "@codemirror/state";
import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { resolveCodeMirrorLanguage } from "../utils/codemirror-language";
interface FileEditorProps {
content: string;
onChange: (content: string) => void;
readOnly?: boolean;
filePath?: string;
showLineNumbers?: boolean;
onToggleLineNumbers?: () => void;
canToggleLineNumbers?: boolean;
toolbarExpanded?: boolean;
toolbarActionsId?: string;
}
function isMarkdownFile(filePath?: string): boolean {
if (!filePath) return false;
const lowerPath = filePath.toLowerCase();
return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown") || lowerPath.endsWith(".mdx");
}
function isDarkTheme(): boolean {
return document.documentElement.dataset.theme !== "light";
}
function buildThemeExtension(isDark: boolean): Extension[] {
return isDark ? [oneDark] : [syntaxHighlighting(defaultHighlightStyle)];
}
export function FileEditor({
content,
onChange,
readOnly,
filePath,
showLineNumbers = false,
onToggleLineNumbers,
canToggleLineNumbers = true,
toolbarExpanded,
toolbarActionsId: externalToolbarActionsId,
}: FileEditorProps) {
const { t } = useTranslation("app");
const [showPreview, setShowPreview] = useState(false);
const [wordWrap, setWordWrap] = useState(true);
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = toolbarExpanded !== undefined;
const expanded = isControlled ? toolbarExpanded : internalExpanded;
const editorHostRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
const syncingFromPropsRef = useRef(false);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const lineNumbersCompartmentRef = useRef(new Compartment());
const wordWrapCompartmentRef = useRef(new Compartment());
const readOnlyCompartmentRef = useRef(new Compartment());
const languageCompartmentRef = useRef(new Compartment());
const themeCompartmentRef = useRef(new Compartment());
const isMarkdown = isMarkdownFile(filePath);
const generatedToolbarActionsId = useId();
const toolbarActionsId = externalToolbarActionsId ?? generatedToolbarActionsId;
const [darkThemeActive, setDarkThemeActive] = useState(() => isDarkTheme());
const effectiveShowPreview = isMarkdown && (readOnly ? true : showPreview);
const shouldRenderLineNumbers = showLineNumbers && !readOnly && !effectiveShowPreview;
const shouldShowLineNumbersToggle = Boolean(onToggleLineNumbers) && canToggleLineNumbers && !readOnly && !effectiveShowPreview;
const hasToolbarActions = isMarkdown || !readOnly || shouldShowLineNumbersToggle;
const languageExtension = useMemo(() => resolveCodeMirrorLanguage(filePath), [filePath]);
const handleEditClick = useCallback(() => setShowPreview(false), []);
const handlePreviewClick = useCallback(() => setShowPreview(true), []);
const handleWordWrapToggle = useCallback(() => setWordWrap((prev) => !prev), []);
const handleToolbarActionsToggle = useCallback(() => {
if (!isControlled) {
setInternalExpanded((prev) => !prev);
}
}, [isControlled]);
useEffect(() => {
if (!editorHostRef.current || effectiveShowPreview) {
return;
}
const themeOverlay = EditorView.theme({
"&": { height: "100%", fontFamily: "var(--font-mono)", backgroundColor: "var(--bg)", color: "var(--text)" },
".cm-gutters": { backgroundColor: "var(--surface)", color: "var(--text-muted)", borderRight: "calc(var(--space-xs) * 0.25) solid var(--border)" },
"&.cm-focused": { outline: "none" },
});
const state = EditorState.create({
doc: content,
extensions: [
lineNumbersCompartmentRef.current.of(shouldRenderLineNumbers ? lineNumbers() : []),
wordWrapCompartmentRef.current.of(wordWrap ? EditorView.lineWrapping : []),
readOnlyCompartmentRef.current.of(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
languageCompartmentRef.current.of(languageExtension ?? []),
themeCompartmentRef.current.of(buildThemeExtension(darkThemeActive)),
themeOverlay,
EditorView.updateListener.of((update) => {
if (!update.docChanged || syncingFromPropsRef.current) return;
onChangeRef.current(update.state.doc.toString());
}),
],
});
const view = new EditorView({ state, parent: editorHostRef.current });
editorViewRef.current = view;
return () => {
editorViewRef.current = null;
view.destroy();
};
}, [effectiveShowPreview]);
useEffect(() => {
const observer = new MutationObserver(() => {
setDarkThemeActive(isDarkTheme());
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: lineNumbersCompartmentRef.current.reconfigure(shouldRenderLineNumbers ? lineNumbers() : []),
});
}, [shouldRenderLineNumbers]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: wordWrapCompartmentRef.current.reconfigure(wordWrap ? EditorView.lineWrapping : []),
});
}, [wordWrap]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: readOnlyCompartmentRef.current.reconfigure(readOnly ? [EditorState.readOnly.of(true), EditorView.editable.of(false)] : []),
});
}, [readOnly]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: languageCompartmentRef.current.reconfigure(languageExtension ?? []),
});
}, [languageExtension]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
view.dispatch({
effects: themeCompartmentRef.current.reconfigure(buildThemeExtension(darkThemeActive)),
});
}, [darkThemeActive]);
useEffect(() => {
const view = editorViewRef.current;
if (!view) return;
const currentContent = view.state.doc.toString();
if (currentContent === content) return;
syncingFromPropsRef.current = true;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
syncingFromPropsRef.current = false;
}, [content]);
return (
<div className="file-editor-container">
{hasToolbarActions && (expanded || !isControlled) ? (
<div className={`file-editor-toolbar ${expanded ? "file-editor-toolbar--expanded" : ""}`}>
{!isControlled && (
<button className="btn btn-sm btn-icon file-editor-toolbar-button" onClick={handleToolbarActionsToggle} aria-label={t("fileEditor.toggleOptions", "Toggle editor options")} title={t("fileEditor.toggleOptions", "Toggle editor options")} aria-expanded={expanded} aria-controls={toolbarActionsId}>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
<div className="file-editor-toolbar-actions" id={toolbarActionsId} hidden={!expanded}>
{isMarkdown ? (
<>
{!readOnly && (
<button className={`btn btn-sm file-editor-toolbar-button ${!effectiveShowPreview ? "btn-primary" : ""}`} onClick={handleEditClick} disabled={!effectiveShowPreview} aria-label={t("fileEditor.editMode", "Edit mode")}>
<FileEdit size={14} />
{t("fileEditor.edit", "Edit")}
</button>
)}
<button className={`btn btn-sm file-editor-toolbar-button ${effectiveShowPreview ? "btn-primary" : ""}`} onClick={handlePreviewClick} disabled={effectiveShowPreview} aria-label={t("fileEditor.previewMode", "Preview mode")}>
<Eye size={14} />
{t("fileEditor.preview", "Preview")}
</button>
</>
) : null}
{shouldShowLineNumbersToggle && (
<button className={`btn btn-sm file-editor-toolbar-button ${showLineNumbers ? "btn-primary" : ""}`} onClick={onToggleLineNumbers} aria-label={t("fileEditor.toggleLineNumbers", "Toggle line numbers")} aria-pressed={showLineNumbers} title={t("fileEditor.toggleLineNumbers", "Toggle line numbers")}>
<ListOrdered size={14} />
<span>{t("fileEditor.lineNumber", "Line #")}</span>
</button>
)}
{!readOnly && (
<button className={`btn btn-sm file-editor-toolbar-button ${wordWrap ? "btn-primary" : ""}`} onClick={handleWordWrapToggle} aria-label={t("fileEditor.toggleWordWrap", "Toggle word wrap")} title={t("fileEditor.toggleWordWrap", "Toggle word wrap")}>
<WrapText size={14} />
<span>{t("fileEditor.wrap", "Wrap")}</span>
</button>
)}
</div>
</div>
) : null}
{effectiveShowPreview ? (
<div className="file-editor-preview markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? t("fileEditor.editorFor", `Editor for ${filePath}`) : t("fileEditor.fileEditor", "File editor")} />
)}
</div>
);
}