存量清理(首跑 141 errors / 21 warnings → 0 / 0): - 删除孤儿 disable:SideChatView / Sidebar / selection-popup 三处 exhaustive-deps 豁免与 chunk-artifact 的 no-new-func 豁免(多行理由注释把 disable-next-line 顶离了实报行;selection-popup 改为提取 popupOpen 标志真修复) - 死代码:未用导入(Sidebar×7、TextEditor、SideCardSection、config、fs-operations 及 6 个 spec)、sidechat-core 死累加器 total、chunk-loader.spec 死助手 registry、sidechat-seed-validation 死助手 toolResultMessage - 真实小修:agent-opens 三处 throw 补 cause;lazy-chunk any → ComponentType<P>(P extends object);service.ts claimed 初值改定值赋值;chunk-loader task / spec 内 etag / pendingLoad / snapshot 改 const;terminal-links 去掉字符类内多余转义;SubagentView catalogs 空兜底 useMemo 稳定标识;editor-host.spec 组件提出大写 FakeViewer(rules-of-hooks);pty-helpers.spec 内联 require 提升为静态导入;panel-host-css 注释里的零宽空格改为可见写法 - 依赖数组豁免(均带理由):granular scope 字段(EditorHost / PdfView / ChangesTab / GitLens×2)、job.status 粒度(SubagentView×2)、清理期读 ref、mount-only 注册等;ctx(挂载期恒定)直接入 deps(Sidebar×3)、TextEditor previewScroll 补 scope(幂等) - 刻意控制字符正则(conversation-draft / markdown-images)与宿主槽位 any 签名(context-types)以带理由豁免保留 接入: - Makefile:lint 目标(typecheck 旁),check 聚合补 lint 保持「对齐 CI」语义 - ci.yml ci / ci-windows 两 job 与 release.yml 在 Typecheck 后加 Lint 步骤(release 注释同步顺序)
114 lines
4.7 KiB
TypeScript
114 lines
4.7 KiB
TypeScript
/** Browser-native PDF preview with an always-available download fallback. */
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import clsx from 'clsx'
|
|
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
|
import { t } from './locales.ts'
|
|
import css from './sidebar.module.css'
|
|
|
|
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
|
const { scope, path, title } = props
|
|
const [load, setLoad] = useState<
|
|
| { status: 'loading' }
|
|
| { status: 'ready'; url: string }
|
|
| { status: 'error'; message: string }
|
|
>({ status: 'loading' })
|
|
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
|
const frameRef = useRef<HTMLIFrameElement>(null)
|
|
const shieldRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
let objectUrl: string | undefined
|
|
setLoad({ status: 'loading' })
|
|
void (async () => {
|
|
try {
|
|
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const bytes = await response.arrayBuffer()
|
|
if (controller.signal.aborted) return
|
|
// A direct iframe navigation may download when an old host process or
|
|
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
|
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
|
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
|
setLoad({ status: 'ready', url: objectUrl })
|
|
} catch (error) {
|
|
if (controller.signal.aborted) return
|
|
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
|
}
|
|
})()
|
|
return () => {
|
|
controller.abort()
|
|
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
|
}
|
|
// Granular scope fields: the scope object's identity churns, only its
|
|
// sessionId / cwd fields gate the fetch.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scope.sessionId, scope.cwd, path])
|
|
|
|
useEffect(() => {
|
|
const block = (): void => {
|
|
setInteractionBlocked(true)
|
|
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
|
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
|
}
|
|
const unblock = (): void => {
|
|
setInteractionBlocked(false)
|
|
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
|
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
|
}
|
|
const blockForResize = (event: PointerEvent): void => {
|
|
const target = event.target
|
|
if (target instanceof Element
|
|
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
|
block()
|
|
}
|
|
}
|
|
// An iframe is a separate browsing context and otherwise swallows the
|
|
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
|
// remove it from hit-testing while a tab drag or pane resize is active.
|
|
document.addEventListener('dragstart', block, true)
|
|
document.addEventListener('dragend', unblock, true)
|
|
document.addEventListener('drop', unblock, true)
|
|
window.addEventListener('pointerdown', blockForResize, true)
|
|
window.addEventListener('pointerup', unblock, true)
|
|
window.addEventListener('pointercancel', unblock, true)
|
|
window.addEventListener('blur', unblock)
|
|
return () => {
|
|
document.removeEventListener('dragstart', block, true)
|
|
document.removeEventListener('dragend', unblock, true)
|
|
document.removeEventListener('drop', unblock, true)
|
|
window.removeEventListener('pointerdown', blockForResize, true)
|
|
window.removeEventListener('pointerup', unblock, true)
|
|
window.removeEventListener('pointercancel', unblock, true)
|
|
window.removeEventListener('blur', unblock)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<div className={css.editorPdf}>
|
|
<div className={css.editorPdfToolbar}>
|
|
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
|
{t('downloadToView')}
|
|
</a>
|
|
</div>
|
|
<div className={css.editorPdfStage}>
|
|
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
|
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
|
{load.status === 'ready' && (
|
|
<iframe
|
|
ref={frameRef}
|
|
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
|
src={load.url}
|
|
title={title}
|
|
/>
|
|
)}
|
|
<div
|
|
ref={shieldRef}
|
|
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|