feat: overhaul chat artifact previews and file change experience (#937)
This commit is contained in:
228
src/components/file-preview/ArtifactPanel.tsx
Normal file
228
src/components/file-preview/ArtifactPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Right-side artifact panel — the WorkBuddy-style split-pane sidebar
|
||||
* shown next to the Chat conversation. Hosts three top-level tabs:
|
||||
*
|
||||
* - 变更 (changes): side-by-side diff for the focused file only (no
|
||||
* in-panel file list — open a file from the run’s file cards below
|
||||
* the graph, or “查看文件变更” picks the latest change).
|
||||
* - 预览 (preview): rendered preview of whichever file is currently
|
||||
* focused (Markdown → rendered, code → syntax-highlighted). Shares
|
||||
* `focusedFile` with the changes tab so switching tabs keeps
|
||||
* context.
|
||||
* - 工作空间 (browser): read-only workspace tree + file preview,
|
||||
* scoped to the current agent's `agent.workspace`.
|
||||
*
|
||||
* Open/close + tab + focused-file state lives in the
|
||||
* `useArtifactPanel` zustand store so any part of the page (file cards,
|
||||
* toolbar buttons, "查看文件变更 →" links) can drive it.
|
||||
*/
|
||||
import { useLayoutEffect, useMemo } from 'react';
|
||||
import { Eye, FileEdit, FolderTree, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GeneratedFile } from '@/lib/generated-files';
|
||||
import type { AgentSummary } from '@/types/agent';
|
||||
import { useArtifactPanel } from '@/stores/artifact-panel';
|
||||
import type { FilePreviewTarget } from './types';
|
||||
import { FilePreviewBody } from './FilePreviewBody';
|
||||
import { WorkspaceBrowserBody } from './WorkspaceBrowserBody';
|
||||
import { WORKSPACE_BROWSER_ENABLED } from './workspace-browser-config';
|
||||
|
||||
export interface ArtifactPanelProps {
|
||||
/** All files generated by the AI across the conversation. */
|
||||
files: GeneratedFile[];
|
||||
/** Currently selected agent (drives the workspace tab). */
|
||||
agent: AgentSummary | null;
|
||||
/** Used to mark "本轮新增" badges on the workspace tree. */
|
||||
runStartedAt?: number | null;
|
||||
/** Bumping this number triggers a workspace tree reload. */
|
||||
refreshSignal?: number;
|
||||
}
|
||||
|
||||
export function ArtifactPanel({ files, agent, runStartedAt, refreshSignal }: ArtifactPanelProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const tab = useArtifactPanel((s) => s.tab);
|
||||
const visibleTab = !WORKSPACE_BROWSER_ENABLED && tab === 'browser' ? 'changes' : tab;
|
||||
const setTab = useArtifactPanel((s) => s.setTab);
|
||||
const focusedFile = useArtifactPanel((s) => s.focusedFile);
|
||||
const setFocusedFile = useArtifactPanel((s) => s.setFocusedFile);
|
||||
const close = useArtifactPanel((s) => s.close);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/5 px-3 py-2 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<PanelTabButton
|
||||
icon={<FileEdit className="h-3.5 w-3.5" />}
|
||||
label={t('artifactPanel.tabs.changes', '变更')}
|
||||
active={visibleTab === 'changes'}
|
||||
onClick={() => setTab('changes')}
|
||||
/>
|
||||
<PanelTabButton
|
||||
icon={<Eye className="h-3.5 w-3.5" />}
|
||||
label={t('artifactPanel.tabs.preview', '预览')}
|
||||
active={visibleTab === 'preview'}
|
||||
onClick={() => setTab('preview')}
|
||||
/>
|
||||
{WORKSPACE_BROWSER_ENABLED && (
|
||||
<PanelTabButton
|
||||
icon={<FolderTree className="h-3.5 w-3.5" />}
|
||||
label={t('artifactPanel.tabs.browser', '工作空间')}
|
||||
active={visibleTab === 'browser'}
|
||||
onClick={() => setTab('browser')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={close}
|
||||
aria-label={t('filePreview.actions.close', '关闭')}
|
||||
>
|
||||
<X className="h-4 w-4 pointer-events-none" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{visibleTab === 'changes' && (
|
||||
<ChangesTab
|
||||
files={files}
|
||||
focusedFile={focusedFile}
|
||||
onFocus={(f) => setFocusedFile(f)}
|
||||
/>
|
||||
)}
|
||||
{visibleTab === 'preview' && <PreviewTab focusedFile={focusedFile} />}
|
||||
{WORKSPACE_BROWSER_ENABLED && visibleTab === 'browser' && (
|
||||
<WorkspaceBrowserBody
|
||||
agent={agent}
|
||||
runStartedAt={runStartedAt}
|
||||
refreshSignal={refreshSignal}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelTabButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PanelTabButton({ icon, label, active, onClick }: PanelTabButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'bg-foreground/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-black/5 hover:text-foreground dark:hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function generatedFileToTarget(file: GeneratedFile): FilePreviewTarget {
|
||||
return {
|
||||
filePath: file.filePath,
|
||||
fileName: file.fileName,
|
||||
ext: file.ext,
|
||||
mimeType: file.mimeType,
|
||||
contentType: file.contentType,
|
||||
action: file.action,
|
||||
fullContent: file.fullContent,
|
||||
baseline: file.baseline,
|
||||
edits: file.edits,
|
||||
};
|
||||
}
|
||||
|
||||
interface ChangesTabProps {
|
||||
files: GeneratedFile[];
|
||||
focusedFile: FilePreviewTarget | null;
|
||||
onFocus: (file: FilePreviewTarget) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width diff for the focused file. Which file is focused comes from
|
||||
* the run’s file cards or “查看文件变更 →” (auto-first); switching workspace
|
||||
* tabs does not bring back a sidebar list.
|
||||
*/
|
||||
function ChangesTab({ files, focusedFile, onFocus }: ChangesTabProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// De-dup files by path, keep the latest entry (highest lastSeenIndex).
|
||||
const uniqueFiles = useMemo(() => {
|
||||
const map = new Map<string, GeneratedFile>();
|
||||
for (const f of files) {
|
||||
const existing = map.get(f.filePath);
|
||||
if (!existing || f.lastSeenIndex >= existing.lastSeenIndex) map.set(f.filePath, f);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.lastSeenIndex - a.lastSeenIndex);
|
||||
}, [files]);
|
||||
|
||||
// Auto-select the first file when entering this tab without one in
|
||||
// focus (or when the focused file disappears, e.g. session reset).
|
||||
useLayoutEffect(() => {
|
||||
if (uniqueFiles.length === 0) return;
|
||||
const stillExists =
|
||||
focusedFile && uniqueFiles.some((f) => f.filePath === focusedFile.filePath);
|
||||
if (!stillExists) {
|
||||
onFocus(generatedFileToTarget(uniqueFiles[0]));
|
||||
}
|
||||
}, [focusedFile, uniqueFiles, onFocus]);
|
||||
|
||||
if (uniqueFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
{t('artifactPanel.changes.empty', '本会话尚无文件变更')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return focusedFile ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<FilePreviewBody key={focusedFile.filePath} file={focusedFile} compact mode="diff" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
{t('artifactPanel.changes.selectFileHint', '请点击对话中的文件卡片,或「查看文件变更」')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviewTabProps {
|
||||
focusedFile: FilePreviewTarget | null;
|
||||
}
|
||||
|
||||
function PreviewTab({ focusedFile }: PreviewTabProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
if (!focusedFile) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('artifactPanel.preview.emptyTitle', '尚未选择文件')}
|
||||
</p>
|
||||
<p className="max-w-md text-xs text-muted-foreground">
|
||||
{t(
|
||||
'artifactPanel.preview.emptyHint',
|
||||
'请先点击对话里的文件卡片打开侧栏并选中文件。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FilePreviewBody key={focusedFile.filePath} file={focusedFile} compact mode="preview" />;
|
||||
}
|
||||
|
||||
export default ArtifactPanel;
|
||||
547
src/components/file-preview/FilePreviewBody.tsx
Normal file
547
src/components/file-preview/FilePreviewBody.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Inline file preview body.
|
||||
*
|
||||
* Renders the icon header (file name / path / save / revert) and a
|
||||
* minimal tabbed view for a single file. Documents (Markdown) render
|
||||
* only their `preview` tab; code / other text files render `source`.
|
||||
* A `diff` tab is added when the file is editable AND there are
|
||||
* AI-applied edits to display. The metadata `info` tab has been
|
||||
* intentionally removed — path / size are visible in the header bar.
|
||||
*
|
||||
* The `mode` prop narrows the tab set for callers that want a single,
|
||||
* fixed view (e.g. the artifact panel's 预览 tab forces `preview`, and
|
||||
* the 变更 tab's right pane forces `diff`).
|
||||
*
|
||||
* Used by:
|
||||
* - `FilePreviewOverlay` for the Skills detail Sheet (read-only).
|
||||
* - `ArtifactPanel`'s ChangesTab and PreviewTab.
|
||||
*
|
||||
* All sandbox / read-only / large-file / binary edge cases are handled
|
||||
* here so callers only pass a `FilePreviewTarget` and a `readOnly` flag.
|
||||
*/
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FolderOpen, Save, ShieldAlert, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc, readTextFile, writeTextFile } from '@/lib/api-client';
|
||||
import type { FilePreviewTarget } from './types';
|
||||
import { supportsInlineDiff, supportsInlineDocumentPreview } from '@/lib/generated-files';
|
||||
import { FilePreviewIcon } from './file-card-utils';
|
||||
import { formatFileSize } from './format';
|
||||
import MarkdownPreview from './MarkdownPreview';
|
||||
import ImageViewer from './ImageViewer';
|
||||
|
||||
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
|
||||
const MonacoDiffViewerLazy = lazy(() => import('./MonacoDiffViewer'));
|
||||
|
||||
/**
|
||||
* Tab set for the body.
|
||||
*
|
||||
* - 'full' – default: preview / source / diff as appropriate.
|
||||
* - 'preview' – render-only; hides the diff tab and forces read-only.
|
||||
* - 'diff' – diff-only; the body collapses to a single diff view
|
||||
* with no tab strip, save/revert, or source toggle.
|
||||
*/
|
||||
export type FilePreviewBodyMode = 'full' | 'preview' | 'diff';
|
||||
|
||||
export interface FilePreviewBodyProps {
|
||||
file: FilePreviewTarget;
|
||||
readOnly?: boolean;
|
||||
/** Compact mode reduces padding/font for use inside the side panel. */
|
||||
compact?: boolean;
|
||||
/** Optional slot rendered to the LEFT of the header info (e.g. back button). */
|
||||
leadingHeader?: React.ReactNode;
|
||||
/** Optional slot rendered to the RIGHT of the header (extra actions). */
|
||||
trailingHeader?: React.ReactNode;
|
||||
/** Limit the visible tabs. Default: 'full'. */
|
||||
mode?: FilePreviewBodyMode;
|
||||
/** When true, hide the file header (name / path / actions). */
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; content: string; size?: number; readOnly: boolean }
|
||||
| { status: 'tooLarge'; size?: number }
|
||||
| { status: 'binary' }
|
||||
| { status: 'outsideSandbox' }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
type Tab = 'source' | 'preview' | 'diff';
|
||||
|
||||
function tabsForFile(file: FilePreviewTarget, mode: FilePreviewBodyMode): Tab[] {
|
||||
// Diff-only mode short-circuits. Callers (e.g. the 变更 tab's right
|
||||
// pane) want a pure git-style diff with no tab strip — but only for
|
||||
// formats where inline diff is actually supported.
|
||||
if (mode === 'diff') return supportsInlineDiff(file) ? ['diff'] : [];
|
||||
|
||||
const tabs: Tab[] = [];
|
||||
if (file.contentType === 'document') {
|
||||
if (!supportsInlineDocumentPreview(file.ext)) {
|
||||
return [];
|
||||
}
|
||||
// Markdown / plain-text style documents: rendered preview only.
|
||||
tabs.push('preview');
|
||||
} else if (file.contentType === 'snapshot') {
|
||||
tabs.push('preview');
|
||||
} else if (file.contentType === 'video' || file.contentType === 'audio') {
|
||||
tabs.push('preview');
|
||||
} else if (file.contentType === 'code') {
|
||||
tabs.push('source');
|
||||
} else {
|
||||
tabs.push('source');
|
||||
}
|
||||
// Diff tab appears in 'full' mode whenever we captured a Write/Edit
|
||||
// payload — read-only is fine, the diff is informational only.
|
||||
if (
|
||||
mode === 'full' &&
|
||||
supportsInlineDiff(file) &&
|
||||
(file.fullContent != null || (file.edits != null && file.edits.length > 0))
|
||||
) {
|
||||
tabs.push('diff');
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function pickInitialTab(tabs: Tab[], file: FilePreviewTarget): Tab {
|
||||
if (file.contentType === 'document' && tabs.includes('preview')) return 'preview';
|
||||
// For changes view (edited code), prefer the diff tab if present so
|
||||
// the user sees the change immediately on click.
|
||||
if (tabs.includes('diff') && file.contentType !== 'document') return 'diff';
|
||||
return tabs[0] ?? 'source';
|
||||
}
|
||||
|
||||
function normaliseEol(s: string): string {
|
||||
return s.replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
type DiffPair = {
|
||||
/** Left pane. `null` makes Monaco render the side as empty (new-file). */
|
||||
oldContent: string | null;
|
||||
/** Right pane. */
|
||||
newContent: string;
|
||||
/**
|
||||
* - `whole` – right pane is the full file (Write tool).
|
||||
* - `snippet` – left/right are the joined Edit op old/new strings.
|
||||
* - `unavailable` – the chat captured no payload to diff against.
|
||||
*/
|
||||
kind: 'whole' | 'snippet' | 'unavailable';
|
||||
};
|
||||
|
||||
/** Visual separator between MultiEdit hunks. */
|
||||
const SNIPPET_SEPARATOR = '\n\n';
|
||||
|
||||
/**
|
||||
* Build a diff pair purely from the captured tool payload — never reads
|
||||
* disk. Edit tools show the exact old → new snippet swap. Write-family
|
||||
* tools prefer the captured baseline when available, so modified files
|
||||
* render a true before/after diff instead of a misleading all-green view.
|
||||
*/
|
||||
function computeDiffPair(file: FilePreviewTarget): DiffPair {
|
||||
// Edit / StrReplace / MultiEdit — show the snippet swap directly.
|
||||
if (file.edits && file.edits.length > 0) {
|
||||
const lefts = file.edits.map((op) => normaliseEol(op.old ?? ''));
|
||||
const rights = file.edits.map((op) => normaliseEol(op.new ?? ''));
|
||||
const left = lefts.join(SNIPPET_SEPARATOR);
|
||||
const right = rights.join(SNIPPET_SEPARATOR);
|
||||
if (left || right) {
|
||||
return { oldContent: left, newContent: right, kind: 'snippet' };
|
||||
}
|
||||
}
|
||||
|
||||
if (file.fullContent != null) {
|
||||
if (file.baseline?.status === 'ok') {
|
||||
return {
|
||||
oldContent: normaliseEol(file.baseline.content),
|
||||
newContent: normaliseEol(file.fullContent),
|
||||
kind: 'whole',
|
||||
};
|
||||
}
|
||||
if (file.baseline?.status === 'missing') {
|
||||
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
|
||||
}
|
||||
if (file.baseline?.status === 'unavailable') {
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
if (file.action === 'created') {
|
||||
return { oldContent: null, newContent: normaliseEol(file.fullContent), kind: 'whole' };
|
||||
}
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
|
||||
return { oldContent: null, newContent: '', kind: 'unavailable' };
|
||||
}
|
||||
|
||||
export function FilePreviewBody({
|
||||
file,
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
leadingHeader,
|
||||
trailingHeader,
|
||||
mode = 'full',
|
||||
hideHeader = false,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [state, setState] = useState<LoadState>({ status: 'idle' });
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('source');
|
||||
const [size, setSize] = useState<number | undefined>(undefined);
|
||||
|
||||
// Preview / diff modes are read-only by definition — those views are
|
||||
// for inspecting content, not editing it.
|
||||
const enforcedReadOnly = readOnly || mode === 'preview' || mode === 'diff';
|
||||
const tabs = useMemo(() => tabsForFile(file, mode), [file, mode]);
|
||||
const unsupportedPreviewFormat = file.contentType === 'document' && !supportsInlineDocumentPreview(file.ext);
|
||||
const unsupportedDiffFormat = mode === 'diff' && !supportsInlineDiff(file);
|
||||
|
||||
useEffect(() => {
|
||||
setTab(pickInitialTab(tabs, file));
|
||||
|
||||
// Diff-only mode renders entirely from the captured tool payload —
|
||||
// no disk read needed, so we can mark the body "ready" immediately.
|
||||
if (mode === 'diff') {
|
||||
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unsupportedPreviewFormat) {
|
||||
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.contentType === 'snapshot' || file.contentType === 'video' || file.contentType === 'audio') {
|
||||
setState({ status: 'ready', content: '', readOnly: enforcedReadOnly });
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
readTextFile(file.filePath)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
if (res.error === 'tooLarge') {
|
||||
setState({ status: 'tooLarge', size: res.size });
|
||||
return;
|
||||
}
|
||||
if (res.error === 'binary') {
|
||||
setState({ status: 'binary' });
|
||||
return;
|
||||
}
|
||||
if (res.error === 'outsideSandbox') {
|
||||
setState({ status: 'outsideSandbox' });
|
||||
return;
|
||||
}
|
||||
setState({ status: 'error', message: String(res.error ?? 'unknown') });
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
status: 'ready',
|
||||
content: res.content ?? '',
|
||||
size: res.size,
|
||||
readOnly: enforcedReadOnly || !!res.readOnly,
|
||||
});
|
||||
setDraft(res.content ?? '');
|
||||
setSize(res.size);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, enforcedReadOnly, mode, tabs, unsupportedPreviewFormat]);
|
||||
|
||||
const effectiveReadOnly = state.status === 'ready' ? state.readOnly : true;
|
||||
const dirty =
|
||||
state.status === 'ready' && !state.readOnly && draft != null && draft !== state.content;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!dirty || draft == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await writeTextFile(file.filePath, draft);
|
||||
if (!res.ok) throw new Error(res.error ?? 'unknown');
|
||||
setState({ status: 'ready', content: draft, size, readOnly: false });
|
||||
toast.success(t('filePreview.toast.saved', '已保存到磁盘'));
|
||||
} catch (err) {
|
||||
const code = err instanceof Error ? err.message : String(err);
|
||||
const localized =
|
||||
code === 'outsideSandbox'
|
||||
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝写入')
|
||||
: code === 'readOnlyRoot'
|
||||
? t('filePreview.errors.readOnlyRoot', '该文件位于只读位置(如内置技能),无法修改')
|
||||
: t('filePreview.toast.saveFailed', { defaultValue: '保存失败:{{error}}', error: code });
|
||||
toast.error(localized);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [file, dirty, draft, size, t]);
|
||||
|
||||
const handleRevert = useCallback(() => {
|
||||
if (state.status !== 'ready') return;
|
||||
setDraft(state.content);
|
||||
}, [state]);
|
||||
|
||||
const handleOpenInFinder = useCallback(() => {
|
||||
invokeIpc('shell:showItemInFolder', file.filePath).catch(() => {
|
||||
toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示'));
|
||||
});
|
||||
}, [file, t]);
|
||||
|
||||
const renderUnsupportedFormat = () => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')}
|
||||
</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
'filePreview.errors.unsupportedFormatHint',
|
||||
'当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBody = () => {
|
||||
if (unsupportedPreviewFormat || unsupportedDiffFormat) {
|
||||
return renderUnsupportedFormat();
|
||||
}
|
||||
if (state.status === 'loading' || state.status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'tooLarge') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t('filePreview.errors.tooLarge', {
|
||||
defaultValue: '文件过大({{size}}),已禁用预览',
|
||||
size: formatFileSize(state.size ?? 0) || '> 2MB',
|
||||
})}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'binary') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<p>{t('filePreview.errors.binary', '二进制文件不支持文本预览')}</p>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'outsideSandbox') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-amber-500/10 text-amber-500">
|
||||
<ShieldAlert className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('filePreview.errors.outsideSandboxTitle', '此文件位于沙盒外')}
|
||||
</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
'filePreview.errors.outsideSandboxHint',
|
||||
'出于安全考虑,ClawX 仅允许预览 ~/.openclaw、应用资源以及内置技能目录中的文件。可在 Finder 中查看。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
const errMsg = state.message;
|
||||
const hint =
|
||||
errMsg === 'notFound'
|
||||
? t('filePreview.errors.notFound', '文件不存在')
|
||||
: t('filePreview.errors.loadFailed', { defaultValue: '加载失败:{{error}}', error: errMsg });
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<p>{hint}</p>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenInFinder}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={(next) => setTab(next as Tab)} className="flex h-full flex-col">
|
||||
{/* Hide the tab strip when there's only one tab — keeps the UI
|
||||
quiet for the common case (just preview / just source). */}
|
||||
{tabs.length > 1 && (
|
||||
<TabsList className="m-3 self-start">
|
||||
{tabs.map((id) => (
|
||||
<TabsTrigger key={id} value={id}>
|
||||
{id === 'source' && t('filePreview.tabs.source', '源码')}
|
||||
{id === 'preview' && t('filePreview.tabs.preview', '预览')}
|
||||
{id === 'diff' && t('filePreview.tabs.changes', '变更')}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 flex-1',
|
||||
tabs.length > 1 && 'border-t border-black/5 dark:border-white/10',
|
||||
)}
|
||||
>
|
||||
{tabs.includes('source') && (
|
||||
<TabsContent value="source" className="m-0 h-full">
|
||||
{file.contentType === 'snapshot' ? (
|
||||
<ImageViewer filePath={file.filePath} fileName={file.fileName} />
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoViewerLazy
|
||||
filePath={file.filePath}
|
||||
value={draft ?? ''}
|
||||
readOnly={effectiveReadOnly}
|
||||
onChange={effectiveReadOnly ? undefined : (next) => setDraft(next)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{tabs.includes('preview') && (
|
||||
<TabsContent value="preview" className="m-0 h-full overflow-auto">
|
||||
{file.contentType === 'snapshot' ? (
|
||||
<ImageViewer filePath={file.filePath} fileName={file.fileName} />
|
||||
) : file.contentType === 'document' ? (
|
||||
<MarkdownPreview source={draft ?? state.content} />
|
||||
) : (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
{t('filePreview.errors.noPreview', '该文件没有预览')}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{tabs.includes('diff') && (
|
||||
<TabsContent value="diff" className="m-0 h-full">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
const pair = computeDiffPair(file);
|
||||
if (pair.kind === 'unavailable') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{t(
|
||||
'filePreview.diff.unavailable',
|
||||
'本会话没有抓到这个文件的精确变更基线,无法生成 diff。',
|
||||
)}
|
||||
</p>
|
||||
<p className="max-w-md text-2xs text-muted-foreground/90">
|
||||
{t(
|
||||
'filePreview.diff.unavailableHint',
|
||||
'可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MonacoDiffViewerLazy
|
||||
filePath={file.filePath}
|
||||
original={pair.oldContent}
|
||||
modified={pair.newContent}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{!hideHeader && (
|
||||
<header
|
||||
className={
|
||||
compact
|
||||
? 'flex items-center justify-between gap-3 border-b border-black/5 px-4 py-2 dark:border-white/10'
|
||||
: 'flex items-center justify-between gap-3 border-b border-black/5 px-5 py-3 dark:border-white/10'
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{leadingHeader}
|
||||
<FilePreviewIcon
|
||||
contentType={file.contentType}
|
||||
mimeType={file.mimeType}
|
||||
ext={file.ext}
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold">{file.fileName}</h2>
|
||||
<p className="truncate text-2xs text-muted-foreground">{file.filePath}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!effectiveReadOnly && state.status === 'ready' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleRevert} disabled={!dirty || saving}>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
{t('filePreview.actions.revert', '撤销')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||
<Save className="mr-1 h-3.5 w-3.5" />
|
||||
{saving ? t('filePreview.actions.saving', '保存中...') : t('filePreview.actions.save', '保存')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{trailingHeader}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{renderBody()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreviewBody;
|
||||
32
src/components/file-preview/FilePreviewOverlay.tsx
Normal file
32
src/components/file-preview/FilePreviewOverlay.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Sheet-based wrapper around `FilePreviewBody`, used by the Skills page
|
||||
* (read-only) to preview SKILL.md and friends in a full-screen overlay.
|
||||
*
|
||||
* The Chat page uses the inline `ArtifactPanel` instead of this component.
|
||||
*/
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { FilePreviewBody } from './FilePreviewBody';
|
||||
import type { FilePreviewTarget } from './types';
|
||||
|
||||
export type { FilePreviewTarget } from './types';
|
||||
|
||||
export interface FilePreviewOverlayProps {
|
||||
file: FilePreviewTarget | null;
|
||||
readOnly?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilePreviewOverlay({ file, readOnly = false, onClose }: FilePreviewOverlayProps) {
|
||||
return (
|
||||
<Sheet open={!!file} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[70vw] max-w-[1100px] sm:max-w-[1100px] p-0 flex flex-col"
|
||||
>
|
||||
{file && <FilePreviewBody file={file} readOnly={readOnly} />}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilePreviewOverlay;
|
||||
75
src/components/file-preview/GeneratedFilesPanel.tsx
Normal file
75
src/components/file-preview/GeneratedFilesPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Inline panel showing files the AI wrote/edited in the current run.
|
||||
* Lives directly under the ExecutionGraphCard for each user trigger
|
||||
* (see Chat/index.tsx).
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computeLineStats, supportsInlineDiff, type GeneratedFile } from '@/lib/generated-files';
|
||||
|
||||
export interface GeneratedFilesPanelProps {
|
||||
files: GeneratedFile[];
|
||||
onOpen: (file: GeneratedFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GeneratedFilesPanel({ files, onOpen, className }: GeneratedFilesPanelProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
if (!files.length) return null;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="px-1">
|
||||
<p className="text-xs font-semibold text-foreground/75">
|
||||
{t('generatedFiles.title', { count: files.length, defaultValue: '文件变更({{count}} 个)' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((file) => {
|
||||
const lineStats = computeLineStats(file);
|
||||
const clickable = supportsInlineDiff(file);
|
||||
return (
|
||||
<button
|
||||
key={`${file.filePath}-${file.lastSeenIndex}`}
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={() => {
|
||||
if (clickable) onOpen(file);
|
||||
}}
|
||||
className={cn(
|
||||
'group inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border border-black/8 bg-black/[0.035] px-3.5 py-2 text-left transition-colors disabled:opacity-100',
|
||||
clickable && 'hover:border-black/12 hover:bg-black/[0.055] dark:hover:bg-white/[0.07]',
|
||||
!clickable && 'cursor-default',
|
||||
'dark:border-white/10 dark:bg-white/[0.04]',
|
||||
)}
|
||||
title={file.filePath}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-2 overflow-hidden whitespace-nowrap text-[13px] leading-none">
|
||||
<span className="shrink-0 font-medium text-foreground">{file.fileName}</span>
|
||||
<span className="truncate text-muted-foreground">{file.filePath}</span>
|
||||
</div>
|
||||
{lineStats && (
|
||||
<div className="flex shrink-0 items-center gap-1 whitespace-nowrap text-xs leading-none tabular-nums">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{lineStats.added}</span>
|
||||
<span className="text-rose-600 dark:text-rose-400">-{lineStats.removed}</span>
|
||||
</div>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 rounded-full border border-black/8 bg-black/[0.045] px-1.5 py-0.5 text-2xs text-foreground/70 dark:border-white/10 dark:bg-white/[0.06] dark:text-foreground/75"
|
||||
>
|
||||
{file.action === 'created'
|
||||
? t('generatedFiles.created', '新增')
|
||||
: t('generatedFiles.modified', '修改')}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneratedFilesPanel;
|
||||
58
src/components/file-preview/ImageViewer.tsx
Normal file
58
src/components/file-preview/ImageViewer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Read-only image viewer with fit-to-window + click-to-zoom toggle.
|
||||
*
|
||||
* Renders the image directly off the disk via `file://` so we don't need
|
||||
* to base64-encode it through IPC. clawx's renderer already loads via
|
||||
* file:// in production so the protocol is allowlisted.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ImageViewerProps {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function toFileUrl(path: string): string {
|
||||
const norm = path.replace(/\\/g, '/');
|
||||
if (norm.startsWith('file://')) return norm;
|
||||
if (norm.startsWith('/')) return `file://${norm}`;
|
||||
return `file:///${norm}`;
|
||||
}
|
||||
|
||||
export default function ImageViewer({ filePath, fileName, className }: ImageViewerProps) {
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex h-full w-full items-center justify-center bg-black/5 dark:bg-black/40', className)}>
|
||||
<div className="absolute right-3 top-3 z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full shadow-md"
|
||||
onClick={() => setZoomed((v) => !v)}
|
||||
title={zoomed ? '缩小' : '原始尺寸'}
|
||||
>
|
||||
{zoomed ? <ZoomOut className="h-4 w-4" /> : <ZoomIn className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full w-full overflow-auto p-6">
|
||||
<img
|
||||
src={toFileUrl(filePath)}
|
||||
alt={fileName}
|
||||
className={cn(
|
||||
'mx-auto select-none transition-transform',
|
||||
zoomed
|
||||
? 'max-w-none cursor-zoom-out'
|
||||
: 'max-h-full max-w-full object-contain cursor-zoom-in',
|
||||
)}
|
||||
onClick={() => setZoomed((v) => !v)}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/file-preview/MarkdownPreview.tsx
Normal file
121
src/components/file-preview/MarkdownPreview.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Rendered Markdown preview.
|
||||
*
|
||||
* Two important deltas from a vanilla ReactMarkdown setup:
|
||||
*
|
||||
* 1. `remark-frontmatter` is enabled with the `yaml`/`toml` flavours so a
|
||||
* file like `SKILL.md` doesn't render its `---\nname: …\n---` block as
|
||||
* a giant paragraph between two horizontal rules. The frontmatter is
|
||||
* extracted into a side-rendered metadata card above the body so users
|
||||
* can still see it without the noise.
|
||||
*
|
||||
* 2. We render heading / inline-code / fenced-code with explicit Tailwind
|
||||
* classes instead of relying solely on `.prose`, because the project
|
||||
* doesn't ship `@tailwindcss/typography` — only the small subset of
|
||||
* `.prose` rules from `globals.css` exists.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface MarkdownPreviewProps {
|
||||
source: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterSplit {
|
||||
body: string;
|
||||
yaml: string | null;
|
||||
}
|
||||
|
||||
function splitFrontmatter(source: string): FrontmatterSplit {
|
||||
if (!source.startsWith('---')) {
|
||||
return { body: source, yaml: null };
|
||||
}
|
||||
// Match opening fence + YAML body + closing fence on its own line.
|
||||
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) return { body: source, yaml: null };
|
||||
const yaml = match[1].trim();
|
||||
const body = source.slice(match[0].length);
|
||||
return { body, yaml: yaml.length > 0 ? yaml : null };
|
||||
}
|
||||
|
||||
export default function MarkdownPreview({ source, className }: MarkdownPreviewProps) {
|
||||
const { body, yaml } = useMemo(() => splitFrontmatter(source), [source]);
|
||||
|
||||
return (
|
||||
<div className={cn('prose max-w-none px-6 py-4 text-sm leading-relaxed', className)}>
|
||||
{yaml && (
|
||||
<pre className="mb-4 rounded-lg border border-black/5 bg-black/[.03] px-3 py-2 text-2xs leading-relaxed text-foreground/70 dark:border-white/10 dark:bg-white/5">
|
||||
<code className="font-mono">{yaml}</code>
|
||||
</pre>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkFrontmatter, ['yaml', 'toml']], remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
h1: ({ children, ...props }) => (
|
||||
<h1 className="mb-3 mt-4 text-2xl font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<h2 className="mb-2 mt-4 text-xl font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<h3 className="mb-2 mt-3 text-base font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<h4 className="mb-1.5 mt-3 text-sm font-semibold text-foreground first:mt-0" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ className: codeClass, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(codeClass || '');
|
||||
const isInline = !match && !codeClass;
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-black/5 px-1.5 py-0.5 font-mono text-[0.9em] text-foreground dark:bg-white/10"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className={cn('font-mono text-[0.9em]', codeClass)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="overflow-x-auto rounded-lg bg-black/5 p-3 text-xs leading-relaxed dark:bg-white/10">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/file-preview/MonacoDiffViewer.tsx
Normal file
86
src/components/file-preview/MonacoDiffViewer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Monaco split diff editor (VS Code–style). Replaces the custom table-based
|
||||
* `SplitDiffViewer`: same Monaco bundle as `MonacoViewer`, with syntax
|
||||
* highlighting, scroll sync, and virtualisation for large files.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { DiffEditor, languageForPath } from '@/lib/monaco/loader';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
|
||||
export interface MonacoDiffViewerProps {
|
||||
filePath: string;
|
||||
/** Left pane; `null`/`undefined` treated as empty (e.g. new file → all additions). */
|
||||
original: string | null | undefined;
|
||||
modified: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function resolveMonacoTheme(theme: string | undefined): string {
|
||||
if (theme === 'dark') return 'vs-dark';
|
||||
if (theme === 'light') return 'vs';
|
||||
const prefersDark =
|
||||
typeof window !== 'undefined'
|
||||
&& window.matchMedia
|
||||
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'vs-dark' : 'vs';
|
||||
}
|
||||
|
||||
function diffModelPath(filePath: string, side: 'orig' | 'mod'): string {
|
||||
const key = encodeURIComponent(filePath.replace(/\\/g, '/'));
|
||||
return `inmemory://clawx-diff/${side}/${key}`;
|
||||
}
|
||||
|
||||
export default function MonacoDiffViewer({
|
||||
filePath,
|
||||
original,
|
||||
modified,
|
||||
className,
|
||||
}: MonacoDiffViewerProps) {
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const language = useMemo(() => languageForPath(filePath), [filePath]);
|
||||
const monacoTheme = resolveMonacoTheme(theme);
|
||||
const originalPath = useMemo(() => diffModelPath(filePath, 'orig'), [filePath]);
|
||||
const modifiedPath = useMemo(() => diffModelPath(filePath, 'mod'), [filePath]);
|
||||
const left = original ?? '';
|
||||
|
||||
return (
|
||||
<div className={className ?? 'h-full w-full min-h-0'}>
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
original={left}
|
||||
modified={modified}
|
||||
originalModelPath={originalPath}
|
||||
modifiedModelPath={modifiedPath}
|
||||
theme={monacoTheme}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly: true,
|
||||
domReadOnly: true,
|
||||
originalEditable: false,
|
||||
renderSideBySide: true,
|
||||
useInlineViewWhenSpaceIsLimited: false,
|
||||
renderIndicators: true,
|
||||
renderOverviewRuler: true,
|
||||
renderMarginRevertIcon: false,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: 'on',
|
||||
diffWordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
guides: { indentation: false },
|
||||
stickyScroll: { enabled: false },
|
||||
padding: { top: 8, bottom: 8 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/file-preview/MonacoViewer.tsx
Normal file
76
src/components/file-preview/MonacoViewer.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Monaco-backed text/code viewer.
|
||||
*
|
||||
* Lazily loads Monaco on first render (the parent overlay wraps this in
|
||||
* `<Suspense>`), and resolves the language from the file extension so
|
||||
* highlighting works for the dozens of file types the editor ships with
|
||||
* out of the box.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { Editor, languageForPath } from '@/lib/monaco/loader';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
|
||||
export interface MonacoViewerProps {
|
||||
filePath: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function resolveMonacoTheme(theme: string | undefined): string {
|
||||
if (theme === 'dark') return 'vs-dark';
|
||||
if (theme === 'light') return 'vs';
|
||||
// 'system' — derive from media query at the moment of mount
|
||||
const prefersDark = typeof window !== 'undefined'
|
||||
&& window.matchMedia
|
||||
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'vs-dark' : 'vs';
|
||||
}
|
||||
|
||||
export default function MonacoViewer({
|
||||
filePath,
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
className,
|
||||
}: MonacoViewerProps) {
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const language = useMemo(() => languageForPath(filePath), [filePath]);
|
||||
const monacoTheme = resolveMonacoTheme(theme);
|
||||
|
||||
return (
|
||||
<div className={className ?? 'h-full w-full'}>
|
||||
<Editor
|
||||
height="100%"
|
||||
path={filePath}
|
||||
defaultLanguage={language}
|
||||
language={language}
|
||||
value={value}
|
||||
onChange={(next) => onChange?.(next ?? '')}
|
||||
theme={monacoTheme}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
readOnly,
|
||||
domReadOnly: readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
renderLineHighlight: readOnly ? 'none' : 'line',
|
||||
padding: { top: 12, bottom: 12 },
|
||||
stickyScroll: { enabled: false },
|
||||
guides: { indentation: false },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/components/file-preview/PanelResizeDivider.tsx
Normal file
107
src/components/file-preview/PanelResizeDivider.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Drag-to-resize handle that sits between the chat column and the
|
||||
* artifact panel on the Chat page.
|
||||
*
|
||||
* On `pointerdown` we capture the pointer, attach window-level
|
||||
* `pointermove` / `pointerup` listeners, and convert the cursor X (in
|
||||
* pixels) into a panel-width percentage relative to the supplied
|
||||
* `containerRef`. The new width is clamped via the store.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ARTIFACT_PANEL_MAX_WIDTH,
|
||||
ARTIFACT_PANEL_MIN_WIDTH,
|
||||
useArtifactPanel,
|
||||
} from '@/stores/artifact-panel';
|
||||
|
||||
export interface PanelResizeDividerProps {
|
||||
/** The flex container that holds chat-left + panel-right. */
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PanelResizeDivider({ containerRef, className }: PanelResizeDividerProps) {
|
||||
const setWidthPct = useArtifactPanel((s) => s.setWidthPct);
|
||||
// Store window listeners in refs so the up-handler can remove the
|
||||
// matching move-handler without a self-referential closure (which the
|
||||
// `react-hooks/immutability` rule flags).
|
||||
const moveHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
|
||||
const upHandlerRef = useRef<((e: PointerEvent) => void) | null>(null);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
if (moveHandlerRef.current) {
|
||||
window.removeEventListener('pointermove', moveHandlerRef.current);
|
||||
moveHandlerRef.current = null;
|
||||
}
|
||||
if (upHandlerRef.current) {
|
||||
window.removeEventListener('pointerup', upHandlerRef.current);
|
||||
upHandlerRef.current = null;
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// setPointerCapture can throw on some browsers; window listeners
|
||||
// below are sufficient on their own.
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const node = containerRef.current;
|
||||
if (!node) return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
// Right panel width = container right edge - cursor X.
|
||||
const rightWidth = rect.right - ev.clientX;
|
||||
const pct = (rightWidth / rect.width) * 100;
|
||||
setWidthPct(pct);
|
||||
};
|
||||
const onUp = () => stopDragging();
|
||||
|
||||
moveHandlerRef.current = onMove;
|
||||
upHandlerRef.current = onUp;
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
},
|
||||
[containerRef, setWidthPct, stopDragging],
|
||||
);
|
||||
|
||||
// Safety: if the divider unmounts mid-drag, clean up listeners.
|
||||
useEffect(() => stopDragging, [stopDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={ARTIFACT_PANEL_MIN_WIDTH}
|
||||
aria-valuemax={ARTIFACT_PANEL_MAX_WIDTH}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={cn(
|
||||
'group relative z-10 hidden w-1.5 shrink-0 cursor-col-resize select-none lg:block',
|
||||
className,
|
||||
)}
|
||||
title="拖动调整宽度"
|
||||
>
|
||||
{/* Hairline (visible all the time) */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-black/5 transition-colors group-hover:bg-primary/40 dark:bg-white/10"
|
||||
/>
|
||||
{/* Wider hover hit-state */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 rounded-full opacity-0 transition-opacity group-hover:bg-primary/40 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelResizeDivider;
|
||||
166
src/components/file-preview/SkillFileSections.tsx
Normal file
166
src/components/file-preview/SkillFileSections.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Skill detail dialog file sections.
|
||||
*
|
||||
* Loads `loadSkillFiles(baseDir)` and groups the result into
|
||||
* Docs / Scripts / Hooks / Assets cards (Other is hidden by default).
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
EMPTY_SKILL_GROUPS,
|
||||
isSkillFileGroupsEmpty,
|
||||
loadSkillFiles,
|
||||
type SkillFile,
|
||||
type SkillFileGroups,
|
||||
} from '@/lib/skill-files';
|
||||
import { FilePreviewIcon } from './file-card-utils';
|
||||
import { formatFileSize } from './format';
|
||||
|
||||
export interface SkillFileSectionsProps {
|
||||
baseDir: string;
|
||||
onOpen: (file: SkillFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillFileSections({ baseDir, onOpen, className }: SkillFileSectionsProps) {
|
||||
const { t } = useTranslation('skills');
|
||||
const [groups, setGroups] = useState<SkillFileGroups>(EMPTY_SKILL_GROUPS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- async fetch with loading/error flags */
|
||||
if (!baseDir) {
|
||||
setGroups(EMPTY_SKILL_GROUPS);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
loadSkillFiles(baseDir)
|
||||
.then((next) => {
|
||||
if (cancelled) return;
|
||||
setGroups(next);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseDir]);
|
||||
|
||||
if (!baseDir) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center py-6', className)}>
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-xs text-destructive', className)}>
|
||||
{t('detail.sections.scanFailed', { defaultValue: '扫描技能目录失败' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSkillFileGroupsEmpty(groups)) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-black/10 bg-black/5 px-4 py-3 text-xs text-muted-foreground dark:border-white/10 dark:bg-white/5', className)}>
|
||||
{t('detail.sections.empty', { defaultValue: '此技能未包含可预览的文件。' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-5', className)}>
|
||||
<SkillFileSection
|
||||
title={t('detail.sections.docs', { defaultValue: '文档' })}
|
||||
description={t('detail.sections.docsDesc', { defaultValue: 'SKILL.md 与说明文档' })}
|
||||
files={groups.docs}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
<SkillFileSection
|
||||
title={t('detail.sections.scripts', { defaultValue: '脚本' })}
|
||||
description={t('detail.sections.scriptsDesc', { defaultValue: '可执行的脚本与命令' })}
|
||||
files={groups.scripts}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
<SkillFileSection
|
||||
title={t('detail.sections.hooks', { defaultValue: 'Hooks' })}
|
||||
description={t('detail.sections.hooksDesc', { defaultValue: '注入到 OpenClaw 生命周期的钩子' })}
|
||||
files={groups.hooks}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
<SkillFileSection
|
||||
title={t('detail.sections.assets', { defaultValue: '资源' })}
|
||||
description={t('detail.sections.assetsDesc', { defaultValue: '模板、参考资料与静态资源' })}
|
||||
files={groups.assets}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkillFileSectionProps {
|
||||
title: string;
|
||||
description: string;
|
||||
files: SkillFile[];
|
||||
onOpen: (file: SkillFile) => void;
|
||||
}
|
||||
|
||||
function SkillFileSection({ title, description, files, onOpen }: SkillFileSectionProps) {
|
||||
if (!files.length) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h4 className="text-meta font-bold text-foreground/80">
|
||||
{title}
|
||||
<span className="ml-2 text-2xs font-medium text-muted-foreground">{files.length}</span>
|
||||
</h4>
|
||||
<p className="text-2xs text-muted-foreground/80 truncate">{description}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.filePath}
|
||||
type="button"
|
||||
onClick={() => onOpen(file)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl border border-black/5 bg-surface-input px-3 py-2 text-left transition-colors',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
'dark:border-white/5 dark:hover:bg-white/10',
|
||||
)}
|
||||
title={file.filePath}
|
||||
>
|
||||
<FilePreviewIcon
|
||||
contentType={file.contentType}
|
||||
mimeType={file.mimeType}
|
||||
ext={file.ext}
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-mono text-foreground">{file.relativePath || file.fileName}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">
|
||||
{formatFileSize(file.size) || file.ext.replace('.', '').toUpperCase() || ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillFileSections;
|
||||
516
src/components/file-preview/WorkspaceBrowserBody.tsx
Normal file
516
src/components/file-preview/WorkspaceBrowserBody.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* Inline workspace browser body — left tree + right preview.
|
||||
*
|
||||
* Strictly scoped to the current agent's `agent.workspace` directory.
|
||||
* Used by `ArtifactPanel`'s 浏览器 tab (split-pane on the chat page).
|
||||
*/
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, FolderOpen, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc, readTextFile } from '@/lib/api-client';
|
||||
import { supportsInlineDocumentPreview } from '@/lib/generated-files';
|
||||
import {
|
||||
collectInitialExpanded,
|
||||
findNode,
|
||||
loadWorkspaceTree,
|
||||
type WorkspaceTreeNode,
|
||||
} from '@/lib/workspace-tree';
|
||||
import type { AgentSummary } from '@/types/agent';
|
||||
import { FilePreviewIcon } from './file-card-utils';
|
||||
import { formatFileSize } from './format';
|
||||
import MarkdownPreview from './MarkdownPreview';
|
||||
import ImageViewer from './ImageViewer';
|
||||
|
||||
const MonacoViewerLazy = lazy(() => import('./MonacoViewer'));
|
||||
|
||||
export interface WorkspaceBrowserBodyProps {
|
||||
agent: AgentSummary | null;
|
||||
/** Used to mark "本轮新增" badges on the tree. */
|
||||
runStartedAt?: number | null;
|
||||
/** Bumping this number triggers a tree reload (e.g. after AI run idles). */
|
||||
refreshSignal?: number;
|
||||
/** Compact mode used inside the side panel (smaller fonts/paddings). */
|
||||
compact?: boolean;
|
||||
/** Left tree column width in px. */
|
||||
treeWidth?: number;
|
||||
/** Optional slot rendered in the toolbar (e.g. close button when used in a Sheet). */
|
||||
toolbarTrailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; root: WorkspaceTreeNode; truncated: boolean }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
type FileState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; content: string }
|
||||
| { status: 'tooLarge' }
|
||||
| { status: 'binary' }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export function WorkspaceBrowserBody({
|
||||
agent,
|
||||
runStartedAt,
|
||||
refreshSignal,
|
||||
compact = false,
|
||||
treeWidth,
|
||||
toolbarTrailing,
|
||||
}: WorkspaceBrowserBodyProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [state, setState] = useState<LoadState>({ status: 'idle' });
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [selectedRel, setSelectedRel] = useState<string | null>(null);
|
||||
const [fileState, setFileState] = useState<FileState>({ status: 'idle' });
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const workspace = agent?.workspace ?? '';
|
||||
|
||||
const reload = useCallback(() => setRefreshTick((v) => v + 1), []);
|
||||
|
||||
// Reset selection when the agent changes.
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- intentional reset on agent switch */
|
||||
setSelectedRel(null);
|
||||
setFileState({ status: 'idle' });
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [agent?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) return;
|
||||
let cancelled = false;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async tree fetch
|
||||
setState({ status: 'loading' });
|
||||
loadWorkspaceTree(workspace, {
|
||||
runStartedAt: runStartedAt ?? null,
|
||||
includeHidden: showHidden,
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (!res) {
|
||||
setState({ status: 'error', message: 'load' });
|
||||
return;
|
||||
}
|
||||
setState({ status: 'ready', root: res.root, truncated: res.truncated });
|
||||
setExpanded((prev) => (prev.size > 0 ? prev : collectInitialExpanded(res.root, 1)));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspace, runStartedAt, refreshTick, showHidden, refreshSignal]);
|
||||
|
||||
const selectedNode = useMemo(() => {
|
||||
if (!selectedRel || state.status !== 'ready') return null;
|
||||
return findNode(state.root, selectedRel);
|
||||
}, [selectedRel, state]);
|
||||
|
||||
useEffect(() => {
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- selection-driven loader */
|
||||
if (!selectedNode || selectedNode.isDir) {
|
||||
setFileState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const node = selectedNode;
|
||||
if (node.contentType === 'document' && !supportsInlineDocumentPreview(node.ext ?? '')) {
|
||||
setFileState({ status: 'unsupported' });
|
||||
return;
|
||||
}
|
||||
if (node.contentType === 'snapshot' || node.contentType === 'video' || node.contentType === 'audio') {
|
||||
setFileState({ status: 'ready', content: '' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setFileState({ status: 'loading' });
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
readTextFile(node.absPath)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
if (res.error === 'tooLarge') {
|
||||
setFileState({ status: 'tooLarge' });
|
||||
return;
|
||||
}
|
||||
if (res.error === 'binary') {
|
||||
setFileState({ status: 'binary' });
|
||||
return;
|
||||
}
|
||||
setFileState({ status: 'error', message: String(res.error ?? 'unknown') });
|
||||
return;
|
||||
}
|
||||
setFileState({ status: 'ready', content: res.content ?? '' });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setFileState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedNode]);
|
||||
|
||||
const handleOpenWorkspaceInFinder = useCallback(() => {
|
||||
if (!workspace) return;
|
||||
invokeIpc('shell:openPath', workspace).catch(() => {
|
||||
toast.error(t('filePreview.errors.openInFinderFailed', '无法在 Finder 中显示'));
|
||||
});
|
||||
}, [workspace, t]);
|
||||
|
||||
const toggleNode = useCallback((relPath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(relPath)) next.delete(relPath);
|
||||
else next.add(relPath);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderTree = () => {
|
||||
if (state.status === 'loading' || state.status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<div className="px-4 py-6 text-xs text-destructive">
|
||||
{state.message === 'outsideSandbox'
|
||||
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取')
|
||||
: t('workspace.empty', '工作空间为空或无法访问')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1 overflow-y-auto">
|
||||
<div className="px-3 py-2 text-2xs uppercase tracking-wide text-muted-foreground">
|
||||
{t('workspace.title', '工作空间')}
|
||||
{agent?.name ? <span className="ml-1 text-foreground/60">· {agent.name}</span> : null}
|
||||
</div>
|
||||
<FileTreeNodeList
|
||||
nodes={state.root.children ?? []}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
selectedRel={selectedRel}
|
||||
onToggle={toggleNode}
|
||||
onSelect={(rel) => setSelectedRel(rel)}
|
||||
/>
|
||||
{state.truncated && (
|
||||
<div className="mt-2 px-3 py-2 text-2xs text-muted-foreground/80">
|
||||
{t('workspace.truncated', '目录过大,已截断显示 5000 个节点')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (!selectedNode || selectedNode.isDir) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t('workspace.pickFile', '从左侧选择一个文件预览')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (selectedNode.contentType === 'snapshot') {
|
||||
return <ImageViewer filePath={selectedNode.absPath} fileName={selectedNode.name} />;
|
||||
}
|
||||
if (fileState.status === 'loading' || fileState.status === 'idle') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fileState.status === 'tooLarge') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<p>{t('filePreview.errors.tooLarge', '文件过大,已禁用预览')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fileState.status === 'binary') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<p>{t('filePreview.errors.binary', '二进制文件不支持文本预览')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fileState.status === 'error') {
|
||||
const errMsg = fileState.message;
|
||||
const hint = errMsg === 'outsideSandbox'
|
||||
? t('filePreview.errors.outsideSandbox', '路径越界,已拒绝读取')
|
||||
: errMsg === 'notFound'
|
||||
? t('filePreview.errors.notFound', '文件不存在')
|
||||
: errMsg;
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fileState.status === 'unsupported') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('filePreview.errors.unsupportedFormatTitle', '此文件格式暂不支持内置预览或变更')}
|
||||
</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
{t(
|
||||
'filePreview.errors.unsupportedFormatHint',
|
||||
'当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在 Finder 中打开该文件。',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => invokeIpc('shell:showItemInFolder', selectedNode.absPath)}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('filePreview.actions.openInFinder', '在 Finder 中显示')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNode.contentType === 'document') {
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<MarkdownPreview source={fileState.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoViewerLazy filePath={selectedNode.absPath} value={fileState.content} readOnly />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 border-b border-black/5 dark:border-white/10',
|
||||
compact ? 'px-3 py-1.5' : 'px-4 py-2',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-sm font-semibold">
|
||||
{t('workspace.title', '工作空间')}
|
||||
{agent?.name ? <span className="ml-2 font-normal text-foreground/70">· {agent.name}</span> : null}
|
||||
</h2>
|
||||
{workspace && !compact ? (
|
||||
<code className="hidden truncate rounded bg-black/5 px-2 py-0.5 text-2xs text-muted-foreground dark:bg-white/10 sm:inline">
|
||||
{workspace}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
title={t('workspace.actions.toggleHidden', '显示/隐藏隐藏文件')}
|
||||
>
|
||||
{showHidden
|
||||
? t('workspace.actions.hideHidden', '隐藏隐藏文件')
|
||||
: t('workspace.actions.showHidden', '显示隐藏文件')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={reload}
|
||||
disabled={state.status === 'loading'}
|
||||
title={t('workspace.actions.refresh', '刷新')}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', state.status === 'loading' && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleOpenWorkspaceInFinder}
|
||||
title={t('workspace.actions.openRootInFinder', '在 Finder 中显示根目录')}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 pointer-events-none" />
|
||||
</Button>
|
||||
{toolbarTrailing}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="grid min-h-0 flex-1"
|
||||
style={{ gridTemplateColumns: `${treeWidth ?? (compact ? 220 : 280)}px 1fr` }}
|
||||
>
|
||||
<aside className="min-h-0 overflow-hidden border-r border-black/5 dark:border-white/10">
|
||||
<div className="h-full overflow-y-auto py-2 text-sm">{renderTree()}</div>
|
||||
</aside>
|
||||
<section className="min-h-0 overflow-hidden">
|
||||
{selectedNode && !selectedNode.isDir && (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-black/5 px-4 py-1.5 text-xs text-muted-foreground dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FilePreviewIcon
|
||||
contentType={selectedNode.contentType}
|
||||
mimeType={selectedNode.mimeType}
|
||||
ext={selectedNode.ext}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate font-mono">{selectedNode.relPath || selectedNode.name}</span>
|
||||
{selectedNode.isFresh && (
|
||||
<Badge variant="default" className="ml-1 text-2xs px-1.5 py-0">
|
||||
{t('workspace.freshBadge', '本轮新增')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0">{formatFileSize(selectedNode.size ?? 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[calc(100%-2rem)] min-h-0">{renderBody()}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileTreeNodeListProps {
|
||||
nodes: WorkspaceTreeNode[];
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
selectedRel: string | null;
|
||||
onToggle: (relPath: string) => void;
|
||||
onSelect: (relPath: string) => void;
|
||||
}
|
||||
|
||||
function FileTreeNodeList({ nodes, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeListProps) {
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{nodes.map((node) => (
|
||||
<FileTreeNodeRow
|
||||
key={node.relPath || node.name}
|
||||
node={node}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
selectedRel={selectedRel}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileTreeNodeRowProps extends Omit<FileTreeNodeListProps, 'nodes'> {
|
||||
node: WorkspaceTreeNode;
|
||||
}
|
||||
|
||||
function FileTreeNodeRow({ node, depth, expanded, selectedRel, onToggle, onSelect }: FileTreeNodeRowProps) {
|
||||
const isOpen = node.isDir && expanded.has(node.relPath);
|
||||
const isSelected = selectedRel === node.relPath;
|
||||
const indent = 12 + depth * 14;
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(node.relPath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-xs transition-colors',
|
||||
'hover:bg-black/5 dark:hover:bg-white/10',
|
||||
)}
|
||||
style={{ paddingLeft: indent }}
|
||||
title={node.relPath || node.name}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="truncate font-medium">{node.name}</span>
|
||||
</button>
|
||||
{isOpen && node.children && node.children.length > 0 && (
|
||||
<FileTreeNodeList
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
selectedRel={selectedRel}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node.relPath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/10',
|
||||
)}
|
||||
style={{ paddingLeft: indent + 16 }}
|
||||
title={node.relPath || node.name}
|
||||
>
|
||||
<FilePreviewIcon
|
||||
contentType={node.contentType}
|
||||
mimeType={node.mimeType}
|
||||
ext={node.ext}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.isFresh && (
|
||||
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspaceBrowserBody;
|
||||
20
src/components/file-preview/build-preview-target.ts
Normal file
20
src/components/file-preview/build-preview-target.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Build a `FilePreviewTarget` from a raw filesystem path, applying
|
||||
* mime / content-type defaults. Lives outside `FilePreviewOverlay.tsx`
|
||||
* so importing the helper doesn't bring in the Sheet/Monaco component
|
||||
* graph (and so React Fast Refresh stays happy).
|
||||
*/
|
||||
import { classifyFileExt, extnameOf, getMimeTypeForExt } from '@/lib/generated-files';
|
||||
import type { FilePreviewTarget } from './FilePreviewOverlay';
|
||||
|
||||
export function buildPreviewTarget(filePath: string, fileName?: string): FilePreviewTarget {
|
||||
const ext = extnameOf(filePath);
|
||||
const name = fileName || (filePath.replace(/\\/g, '/').split('/').pop() ?? filePath);
|
||||
return {
|
||||
filePath,
|
||||
fileName: name,
|
||||
ext,
|
||||
mimeType: getMimeTypeForExt(ext),
|
||||
contentType: classifyFileExt(ext),
|
||||
};
|
||||
}
|
||||
39
src/components/file-preview/file-card-utils.tsx
Normal file
39
src/components/file-preview/file-card-utils.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared icon picker for file cards (chat generated files + skill detail
|
||||
* dialog) and the workspace tree, so all panels render with consistent
|
||||
* iconography.
|
||||
*/
|
||||
import { File, FileArchive, FileCode, FileText, Film, ImageIcon, Music } from 'lucide-react';
|
||||
import type { FileContentType } from '@/lib/generated-files';
|
||||
|
||||
export interface FilePreviewIconProps {
|
||||
contentType?: FileContentType;
|
||||
mimeType?: string;
|
||||
ext?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FilePreviewIcon({ contentType, mimeType, ext, className }: FilePreviewIconProps) {
|
||||
if (contentType === 'snapshot' || (mimeType && mimeType.startsWith('image/'))) {
|
||||
return <ImageIcon className={className} />;
|
||||
}
|
||||
if (contentType === 'video' || (mimeType && mimeType.startsWith('video/'))) {
|
||||
return <Film className={className} />;
|
||||
}
|
||||
if (contentType === 'audio' || (mimeType && mimeType.startsWith('audio/'))) {
|
||||
return <Music className={className} />;
|
||||
}
|
||||
if (contentType === 'document') {
|
||||
return <FileText className={className} />;
|
||||
}
|
||||
if (contentType === 'code') {
|
||||
return <FileCode className={className} />;
|
||||
}
|
||||
if (mimeType === 'application/json' || mimeType === 'application/xml' || (mimeType?.startsWith('text/'))) {
|
||||
return <FileText className={className} />;
|
||||
}
|
||||
if (ext && /\.(zip|tar|gz|7z|rar)$/i.test(ext)) {
|
||||
return <FileArchive className={className} />;
|
||||
}
|
||||
return <File className={className} />;
|
||||
}
|
||||
12
src/components/file-preview/format.ts
Normal file
12
src/components/file-preview/format.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Tiny formatting helpers shared across file preview components.
|
||||
* Kept in a non-`.tsx` module so React Fast Refresh doesn't bail on
|
||||
* components that import them.
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
43
src/components/file-preview/types.ts
Normal file
43
src/components/file-preview/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared types for the file preview pipeline.
|
||||
*
|
||||
* Lives outside `FilePreviewOverlay.tsx` so callers (chat panel, workspace
|
||||
* tree, skills page, …) can import the type without pulling in the Sheet /
|
||||
* Monaco component graph.
|
||||
*/
|
||||
import type { FileContentType, FileEditOp, GeneratedFileBaseline } from '@/lib/generated-files';
|
||||
|
||||
export interface FilePreviewTarget {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
ext: string;
|
||||
mimeType: string;
|
||||
contentType: FileContentType;
|
||||
/**
|
||||
* From chat extraction only. Drives the badge in the changes list and is
|
||||
* not used by the diff view itself (which derives "before/after" from
|
||||
* `fullContent` / `edits` directly, WorkBuddy-style).
|
||||
*/
|
||||
action?: 'created' | 'modified';
|
||||
/**
|
||||
* Full new content of the file when the tool payload provides it (Write
|
||||
* family).
|
||||
*/
|
||||
fullContent?: string;
|
||||
/**
|
||||
* Content of the file *before* the AI's write, captured from disk when
|
||||
* the tool_use was first detected in the stream.
|
||||
*
|
||||
* - `ok` → render a real before/after diff
|
||||
* - `missing` → render a new-file diff (empty left pane)
|
||||
* - `unavailable`→ avoid pretending the file was new; show diff unavailable
|
||||
*/
|
||||
baseline?: GeneratedFileBaseline;
|
||||
/**
|
||||
* Edit operations from Edit / StrReplace / MultiEdit. The diff view
|
||||
* renders these directly as a snippet diff (left = joined `op.old`,
|
||||
* right = joined `op.new`) — exactly what the AI changed, no disk
|
||||
* reads, no reverse-application.
|
||||
*/
|
||||
edits?: FileEditOp[];
|
||||
}
|
||||
7
src/components/file-preview/workspace-browser-config.ts
Normal file
7
src/components/file-preview/workspace-browser-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Temporary feature gate for the Chat workspace browser UI.
|
||||
*
|
||||
* Kept as a single constant so we can re-enable the entry points later
|
||||
* without ripping out the underlying implementation.
|
||||
*/
|
||||
export const WORKSPACE_BROWSER_ENABLED = false;
|
||||
Reference in New Issue
Block a user