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>
133 lines
5.8 KiB
TypeScript
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)
|
|
}
|