6715fbd971
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
/**
|
|
* @file FileViewer.tsx
|
|
* @description Read-only file-content modal for the config explorer. Takes
|
|
* already-fetched data as props (path/data/error) rather than fetching
|
|
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
|
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
|
* (`MarkdownPreview`); Raw is the default.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useRef, useState } from "react";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
|
import type { CcFileResponse } from "../../lib/api";
|
|
import { CopyButton } from "./configPrimitives";
|
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
|
|
|
export function FileViewer({
|
|
state,
|
|
onClose,
|
|
}: {
|
|
state: { path: string; data: CcFileResponse | null; error: string | null };
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation("ccConfig");
|
|
const [preview, setPreview] = useState(false);
|
|
const canPreview = isMarkdown(state.path) && !!state.data;
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panelRef, onClose);
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="cc-file-viewer-title"
|
|
className="relative w-full max-w-4xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<FileText className="w-4 h-4 text-fg-muted" />
|
|
<span
|
|
id="cc-file-viewer-title"
|
|
className="font-mono text-[12px] text-fg-secondary truncate flex-1"
|
|
>
|
|
{state.path}
|
|
</span>
|
|
{canPreview && (
|
|
<button
|
|
type="button"
|
|
aria-pressed={preview}
|
|
onClick={() => setPreview((v) => !v)}
|
|
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
|
>
|
|
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
|
{preview ? t("common.raw") : t("common.preview")}
|
|
</button>
|
|
)}
|
|
<CopyButton value={state.path} />
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
|
aria-label={t("common.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="overflow-auto p-4">
|
|
{state.error ? (
|
|
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{state.error}
|
|
</div>
|
|
) : !state.data ? (
|
|
<div className="text-sm text-fg-muted">…</div>
|
|
) : preview && canPreview ? (
|
|
<>
|
|
<MarkdownPreview text={state.data.text} />
|
|
{state.data.truncated && (
|
|
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
|
{state.data.text}
|
|
{state.data.truncated && (
|
|
<span className="text-fg-muted italic">
|
|
{"\n\n"}
|
|
{t("common.truncated")}
|
|
</span>
|
|
)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|