From c0e41f4b0dc61ae4cd80ca1c6198085b8daaf558 Mon Sep 17 00:00:00 2001 From: CI Test Date: Wed, 19 Aug 2026 12:39:55 +0800 Subject: [PATCH 1/2] fix(markdown): refresh previews after external changes --- README.md | 2 +- README_EN.md | 2 +- src/client/EditorHost.tsx | 121 +++++++++++++++++++++- src/client/TextEditor.tsx | 9 +- src/client/api.ts | 10 +- src/client/builtins/tabs.tsx | 3 +- src/client/locales.ts | 4 + src/index.ts | 26 ++++- tests/markdown-refresh.spec.tsx | 174 ++++++++++++++++++++++++++++++++ 9 files changed, 336 insertions(+), 15 deletions(-) create mode 100644 tests/markdown-refresh.spec.tsx diff --git a/README.md b/README.md index 5261f83..8d11694 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ pnpm watch # tsdown --watch ## ⚠️ 已知限制 -- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截 +- Git 无 push/pull/fetch;Markdown 预览通过 1 秒版本轮询自动刷新(编辑中不覆盖未保存内容),其他文件仍需手动刷新;暂未使用 fs.watch 推送;工具行内文件打开按钮不可拦截 - 终端 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 176a788..e295115 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; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted +- 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 - 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 984a1d7..4054692 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -25,9 +25,9 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { createElement } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { api, mediaUrl, type SessionScope } from './api.ts' +import { api, mediaUrl, type FsFileVersion, 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,6 +48,8 @@ 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 @@ -92,11 +94,23 @@ 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 } = props + const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible = true } = 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. */ + 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 @@ -153,8 +167,10 @@ export function EditorHost(props: { // its state and registers its commands (both null/absent for viewers // without a toolbar — image, pdf, binary download). const [toolbar, setToolbar] = useState(null) + const toolbarRef = useRef(null) const controlsRef = useRef(null) const onToolbarState = useCallback((next: EditorToolbarState) => { + toolbarRef.current = next setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next) }, []) const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => { @@ -189,9 +205,19 @@ 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. + toolbarRef.current = null setToolbar(null) // The seeded home tab (no path) never loads a viewer — the empty-state // hint renders until the user picks a file. @@ -209,6 +235,8 @@ export function EditorHost(props: { setLoad({ status: 'binary' }) return case 'render': + pendingVersionRef.current = undefined + setExternalChanged(false) setLoad({ status: 'ready', viewer: action.viewer, @@ -230,6 +258,7 @@ 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', @@ -247,7 +276,76 @@ export function EditorHost(props: { } apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf)) return () => { cancelled = true; controller.abort() } - }, [scope.sessionId, scope.cwd, path, ctx, showEmpty]) + }, [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' + ? window.confirm(t('refreshUnsavedConfirm')) + : false + if (!confirmed) return + } + pendingVersionRef.current = undefined + setExternalChanged(false) + setReloadTick(value => value + 1) + } const treeOpen = treeOpenOf(tab) /** Persist the panel flag on the tab (survives reloads with the layout). */ @@ -256,6 +354,7 @@ export function EditorHost(props: { : toolbar.saveState === 'saving' ? t('loading') : toolbar.saveState === 'saved' ? t('saved') : toolbar.saveState === 'failed' ? t('saveFailed') : '' + const markdownFile = !showEmpty && ctx.betterSidebar?.matchFileViewer(path)?.id === 'markdown' // Split mode: the path-less window IS the standalone explorer — the tree // panel fills the whole tab (search + FileTree, full form), no editor @@ -315,6 +414,20 @@ export function EditorHost(props: { {saveLabel !== '' && ( {saveLabel} )} + {markdownFile && ( + + )} + {externalChanged && ( + {t('fileChanged')} + )} )} - {externalChanged && ( - {t('fileChanged')} - )}