Files
Agent-System-Frontend/src/components/PreviewPane.tsx
T
weichenandCursor 0f52634380 Hydrate surface list into snapshots before render
Why: GET .../surfaces returns SurfaceMeta without identity; writing it into
surfaces let PreviewPane crash on x.identity.surfaceId after refresh.
Evidence: npx vitest run (118 passed), including surfaceKey.test.ts
Impact: openConversation fetches SurfaceSnapshot via surfaceGet; find paths
use surfaceKey; Meta no longer enters the surfaces projection
Refs: fix/surface-list-hydrate

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 08:33:23 +00:00

133 lines
5.8 KiB
TypeScript

// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
import { useEffect, useState } from 'react'
import { Btn, FilePreview, Icon, SurfaceView, TabStrip } from '@ui'
import { api } from '../api'
import type { Artifact } from '../contracts'
import { humanSize } from '../messageparts'
import { previewTabsKey } from '../previewtabs'
import { store, useStore } from '../store'
import { surfaceKey } from '../surfaceKey'
export function PreviewPane() {
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
const artifacts = useStore(s => s.artifacts)
const expanded = useStore(s => s.ui.previewExpanded)
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
const surfaces = useStore(s => s.surfaces)
const surfaceSnap = active?.id.startsWith('surface:')
? surfaces.find(x => {
const id = surfaceKey(x)
return id != null && 'surface:' + id === active.id
})
: undefined
const art = active ? artifacts.find(a => a.id === active.id) : undefined
const textKind = !!art && isTextKind(art)
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
setEditing(false)
setDirty(false)
setContent(null)
if (!art || !isTextKind(art)) return
let cancelled = false
fetch(api.artifactContentUrl(art.id))
.then(r => r.text())
.then(text => { if (!cancelled) setContent(text) })
.catch(() => { if (!cancelled) setContent('(读取失败)') })
return () => { cancelled = true }
}, [art?.id])
if (!active) return null
if (active.id.startsWith('surface:')) {
return (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
</div>
<div className="preview-pane-body">
{surfaceSnap
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(surfaceKey(surfaceSnap) ?? '', id, {})} docBridge={{ onOpenDoc: docId => {
const art = artifacts.find(a => a.id === docId)
if (art) store.requestPreview(art)
} }} />
: <p className="surface-view-empty">页面快照不可用(可能已被移除或无权限)。</p>}
</div>
</div>
)
}
return (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
<Icon name="list" size={14} />
</button>
<span className="preview-pane-name" title={active.name}>{active.name}</span>
{art && (
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
)}
<span className="preview-pane-spacer" />
{art && (
<>
{dirty && <span className="preview-pane-dirty">未保存</span>}
{textKind && (
<>
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
await api.saveArtifactContent(art.id, content ?? '')
setDirty(false)
store.toast('已保存')
}}>保存</Btn>}
</>
)}
<button type="button" className="icon-btn" title={expanded ? '还原预览' : '展开预览'} aria-pressed={expanded} onClick={() => store.togglePreviewExpanded()}>
<Icon name={expanded ? 'minimize' : 'maximize'} size={14} />
</button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
<Icon name="download" size={14} />
</a>
</>
)}
</div>
<div className="preview-pane-body">
{editing && textKind ? (
<textarea className="search-input preview-pane-editor" value={content ?? ''}
onChange={e => { setContent(e.target.value); setDirty(true) }} />
) : art ? (
<FilePreview source={{
kind: 'artifact',
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
</div>
</div>
)
}
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}