From 23c315ed824a3eddabeea50e80dd79319c6acd14 Mon Sep 17 00:00:00 2001 From: CI Test Date: Wed, 19 Aug 2026 22:05:55 +0800 Subject: [PATCH] fix(markdown): use manual preview refresh --- README.md | 2 +- README_EN.md | 2 +- src/client/EditorHost.tsx | 92 +------------------ src/client/api.ts | 10 +- src/client/builtins/tabs.tsx | 3 +- src/client/locales.ts | 2 - src/index.ts | 18 +--- ...c.tsx => markdown-manual-refresh.spec.tsx} | 49 +++++----- 8 files changed, 34 insertions(+), 144 deletions(-) rename tests/{markdown-refresh.spec.tsx => markdown-manual-refresh.spec.tsx} (76%) diff --git a/README.md b/README.md index 8d11694..a94d5c8 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ pnpm watch # tsdown --watch ## ⚠️ 已知限制 -- Git 无 push/pull/fetch;Markdown 预览通过 1 秒版本轮询自动刷新(编辑中不覆盖未保存内容),其他文件仍需手动刷新;暂未使用 fs.watch 推送;工具行内文件打开按钮不可拦截 +- Git 无 push/pull/fetch;Markdown 预览提供手动刷新按钮,刷新未保存编辑前会确认是否丢弃草稿;无文件 watcher/自动轮询;工具行内文件打开按钮不可拦截 - 终端 Tab 拖到另一分栏会重挂载(shell 重开) - Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底 - 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈 diff --git a/README_EN.md b/README_EN.md index e295115..b5c8c21 100644 --- a/README_EN.md +++ b/README_EN.md @@ -247,7 +247,7 @@ pnpm watch # tsdown --watch ## ⚠️ Known Limitations -- Git has no push/pull/fetch; Markdown previews auto-refresh through a 1-second version poll (without overwriting unsaved edits), other files still require manual refresh; no fs.watch push channel yet; tool inline file-open buttons cannot be intercepted +- Git has no push/pull/fetch; Markdown previews provide a manual refresh button with confirmation before discarding unsaved edits; no file watcher or automatic polling; tool inline file-open buttons cannot be intercepted - Dragging a terminal tab to another pane remounts it (shell restarts) - Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks - Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 4054692..ede447c 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -27,7 +27,7 @@ import { createElement } from 'react' import clsx from 'clsx' import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { api, mediaUrl, type FsFileVersion, type SessionScope } from './api.ts' +import { api, mediaUrl, type SessionScope } from './api.ts' import { BinaryDownload } from './binary-download.tsx' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' import { baseName } from './FileTree.tsx' @@ -48,8 +48,6 @@ type EditorLoad = /** The docked tree panel's width bounds (drag-resize clamps into them). */ const TREE_WIDTH_DEFAULT = 240 -/** Poll Markdown files once per second while their visible preview is open. */ -const MARKDOWN_POLL_MS = 1000 const TREE_WIDTH_MIN = 160 const TREE_WIDTH_MAX = 480 @@ -94,23 +92,13 @@ export function EditorHost(props: { expanded: string[] onToggleDir: (path: string) => void onReferenceFile: (path: string) => void - /** Whether this editor tab is currently visible in an open panel. */ - visible?: boolean }) { - const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible = true } = props + const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props const path = tab.path ?? '' const title = tab.title const [load, setLoad] = useState({ status: 'loading' }) - /** Bump to explicitly or automatically re-read the current file. */ + /** Bump to explicitly re-read the current file. */ const [reloadTick, setReloadTick] = useState(0) - /** True when the disk copy changed while the editor has unsaved edits. */ - const [externalChanged, setExternalChanged] = useState(false) - /** The last file version successfully loaded into the viewer. */ - const loadedVersionRef = useRef(undefined) - /** A changed version already noticed and scheduled for a reload. */ - const pendingVersionRef = useRef(undefined) - /** Prevent overlapping polling reads when the host is slow. */ - const pollingRef = useRef(false) // Reactive prefs read: flipping editorExplorer re-renders this tab with no // reload. The snapshot is the bare boolean so unrelated store churn never @@ -205,15 +193,6 @@ export function EditorHost(props: { if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth }) } - // A path/session switch starts a new observation baseline. A reloadTick does - // not clear this before the new read finishes, otherwise the polling loop - // could schedule the same reload repeatedly while the request is in flight. - useEffect(() => { - loadedVersionRef.current = undefined - pendingVersionRef.current = undefined - setExternalChanged(false) - }, [scope.sessionId, scope.cwd, path, showEmpty]) - useEffect(() => { // A (re)load or a path-less tab clears any hoisted toolbar state — the // fresh viewer re-registers its own. @@ -235,8 +214,6 @@ export function EditorHost(props: { setLoad({ status: 'binary' }) return case 'render': - pendingVersionRef.current = undefined - setExternalChanged(false) setLoad({ status: 'ready', viewer: action.viewer, @@ -258,7 +235,6 @@ export function EditorHost(props: { case 'fetchFsRead': api.fsRead(scope, path).then((result) => { if (cancelled) return - if (result.version !== undefined) loadedVersionRef.current = result.version // Binary reads carry the head bytes for the detect re-match. const outcome = planFsReadOutcome(action.viewer, { binary: result.kind === 'binary', @@ -278,63 +254,6 @@ export function EditorHost(props: { return () => { cancelled = true; controller.abort() } }, [scope.sessionId, scope.cwd, path, ctx, showEmpty, reloadTick]) - // Markdown preview freshness: read the current file once per second while - // this tab is visible. This intentionally uses the existing fs.stat route - // instead of relying on fs.watch, which is inconsistent on WSL/network - // filesystems. The manual button below remains available if polling fails. - useEffect(() => { - if (!visible || showEmpty || path === '') return - if (ctx.betterSidebar?.matchFileViewer(path)?.id !== 'markdown') return - let cancelled = false - const check = async (): Promise => { - if (cancelled || pollingRef.current || loadedVersionRef.current === undefined) return - pollingRef.current = true - try { - const result = await api.fsStat(scope, path) - if (cancelled) return - const loadedVersion = loadedVersionRef.current - if (loadedVersion === undefined) return - const changed = result.version.mtimeMs !== loadedVersion.mtimeMs || result.version.size !== loadedVersion.size - if (!changed) { - pendingVersionRef.current = undefined - if (toolbarRef.current?.dirty !== true) setExternalChanged(false) - return - } - const alreadyPending = pendingVersionRef.current !== undefined - && pendingVersionRef.current.mtimeMs === result.version.mtimeMs - && pendingVersionRef.current.size === result.version.size - pendingVersionRef.current = result.version - if (toolbarRef.current?.dirty === true) { - if (!alreadyPending) setExternalChanged(true) - return - } - if (!alreadyPending) { - setExternalChanged(false) - setReloadTick(value => value + 1) - } - } catch { - // The explicit refresh button is the fallback for transient poll errors. - } finally { - pollingRef.current = false - } - } - void check() - const timer = window.setInterval(() => { void check() }, MARKDOWN_POLL_MS) - return () => { - cancelled = true - window.clearInterval(timer) - } - }, [visible, showEmpty, path, scope.sessionId, scope.cwd, ctx, toolbar?.mode]) - - // If a disk change was detected while the editor was dirty, keep the draft - // in place. Once it becomes clean (for example after saving), follow the - // still-pending disk version exactly once. - useEffect(() => { - if (!externalChanged || toolbar?.dirty === true) return - setExternalChanged(false) - setReloadTick(value => value + 1) - }, [externalChanged, toolbar?.dirty]) - const refreshFile = (): void => { if (toolbarRef.current?.dirty === true) { const confirmed = typeof window.confirm === 'function' @@ -342,8 +261,6 @@ export function EditorHost(props: { : false if (!confirmed) return } - pendingVersionRef.current = undefined - setExternalChanged(false) setReloadTick(value => value + 1) } @@ -425,9 +342,6 @@ export function EditorHost(props: { )} - {externalChanged && ( - {t('fileChanged')} - )}