fix(markdown): use manual preview refresh
This commit is contained in:
@@ -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 内部跳转不进后退栈
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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<EditorLoad>({ 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<FsFileVersion | undefined>(undefined)
|
||||
/** A changed version already noticed and scheduled for a reload. */
|
||||
const pendingVersionRef = useRef<FsFileVersion | undefined>(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<void> => {
|
||||
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: {
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{externalChanged && (
|
||||
<span className={css.editorStatus}>{t('fileChanged')}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
|
||||
|
||||
+2
-8
@@ -58,15 +58,11 @@ export interface GitLogEntry {
|
||||
refs: string
|
||||
}
|
||||
|
||||
/** File version used by the Markdown freshness poll. */
|
||||
export interface FsFileVersion { mtimeMs: number; size: number }
|
||||
/** Text read result. */
|
||||
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean; version?: FsFileVersion }
|
||||
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
|
||||
/** Binary read result (no content; images load through the media route).
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string; version?: FsFileVersion }
|
||||
/** Lightweight file metadata response used to avoid polling full contents. */
|
||||
export interface FsStatResult { version: FsFileVersion }
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One jobs.output response: the output the MODEL has read so far for the
|
||||
@@ -143,8 +139,6 @@ export const api = {
|
||||
* side panel's search box); matches are cwd-relative '/'-separated paths. */
|
||||
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
|
||||
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
|
||||
fsStat: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<FsStatResult>('fs.stat', scopePayload(scope, { path }), signal),
|
||||
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
],
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile, visible }) => (
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<EditorHost
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
@@ -120,7 +120,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
expanded={expanded ?? []}
|
||||
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
|
||||
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
|
||||
visible={visible}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@ export const zh = {
|
||||
mermaidZoomReset: '重置',
|
||||
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
|
||||
refresh: '刷新',
|
||||
fileChanged: '文件已更新',
|
||||
refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
@@ -304,7 +303,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
mermaidZoomReset: 'Reset',
|
||||
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
|
||||
refresh: 'Refresh',
|
||||
fileChanged: 'File changed on disk',
|
||||
refreshUnsavedConfirm: 'The file changed on disk. Refreshing will discard unsaved edits. Continue?',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
|
||||
+3
-15
@@ -139,7 +139,6 @@ async function readText(path: string, readLimit: number): Promise<{
|
||||
truncated: boolean
|
||||
binary: boolean
|
||||
size: number
|
||||
mtimeMs: number
|
||||
head?: string
|
||||
}> {
|
||||
const info = await stat(path).catch((error: unknown) => {
|
||||
@@ -166,7 +165,6 @@ async function readText(path: string, readLimit: number): Promise<{
|
||||
truncated,
|
||||
binary,
|
||||
size,
|
||||
mtimeMs: info.mtimeMs,
|
||||
head,
|
||||
}
|
||||
} finally {
|
||||
@@ -239,24 +237,14 @@ function buildApi(
|
||||
const query = requireString(payload, 'query')
|
||||
return searchFiles(cwd, query)
|
||||
},
|
||||
'fs.stat': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const info = await stat(path).catch((error: unknown) => {
|
||||
throw new SidebarError('fs-error', `cannot stat "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||
})
|
||||
if (info.isDirectory()) throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
||||
return { version: { mtimeMs: info.mtimeMs, size: info.size } }
|
||||
},
|
||||
'fs.read': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
// Relative paths are git-derived (status/diff report repo-root-relative
|
||||
// names; the untracked diff view reads the file through this route).
|
||||
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
|
||||
const { content, truncated, binary, size, mtimeMs, head } = await readText(path, resolved.readLimit)
|
||||
const version = { mtimeMs, size }
|
||||
if (binary) return { kind: 'binary', size, truncated, head, version }
|
||||
return { kind: 'text', content, truncated, version }
|
||||
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated, head }
|
||||
return { kind: 'text', content, truncated }
|
||||
},
|
||||
'fs.write': async (payload) => {
|
||||
const { cwd } = cwdOf(payload)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Markdown freshness behavior: clean previews follow an external edit, while
|
||||
* dirty previews keep the user's draft until an explicit confirmed refresh.
|
||||
* Markdown refresh behavior: external edits are picked up only when the user
|
||||
* explicitly clicks the refresh button, and unsaved drafts stay protected.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -55,7 +55,7 @@ function setup(): {
|
||||
fetchStrategy: 'fsRead',
|
||||
component: FakeMarkdownViewer,
|
||||
})
|
||||
store.setSession('markdown-refresh-session')
|
||||
store.setSession('markdown-manual-refresh-session')
|
||||
const home = allLeaves(store.getSnapshot().state!.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.find(candidate => candidate.type === 'editor')!
|
||||
@@ -72,12 +72,11 @@ function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab:
|
||||
root.render(createElement(EditorHost, {
|
||||
ctx,
|
||||
store,
|
||||
scope: { sessionId: 'markdown-refresh-session', cwd: '/tmp' },
|
||||
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
|
||||
tab,
|
||||
expanded: [],
|
||||
onToggleDir: () => {},
|
||||
onReferenceFile: () => {},
|
||||
visible: true,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
@@ -102,27 +101,33 @@ afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Markdown external refresh', () => {
|
||||
it('reloads a clean visible preview after the file version changes', async () => {
|
||||
describe('Markdown manual refresh', () => {
|
||||
it('does not poll after an external edit and reloads only after clicking refresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
let disk = { content: 'before', version: { mtimeMs: 1, size: 6 } }
|
||||
let disk = 'before'
|
||||
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
|
||||
kind: 'text', content: disk.content, truncated: false, version: disk.version,
|
||||
kind: 'text', content: disk, truncated: false,
|
||||
}))
|
||||
const stat = vi.spyOn(api, 'fsStat').mockImplementation(async () => ({ version: disk.version }))
|
||||
const { store, ctx, tab } = setup()
|
||||
const view = mount(ctx, store, tab)
|
||||
try {
|
||||
await flush()
|
||||
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
|
||||
const initialReads = read.mock.calls.length
|
||||
disk = { content: 'after', version: { mtimeMs: 2, size: 5 } }
|
||||
|
||||
disk = 'after'
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
vi.advanceTimersByTime(3000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(stat).toHaveBeenCalled()
|
||||
expect(read.mock.calls.length).toBe(initialReads)
|
||||
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
|
||||
|
||||
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
|
||||
expect(refresh).not.toBeNull()
|
||||
act(() => { refresh!.click() })
|
||||
await flush()
|
||||
expect(read.mock.calls.length).toBeGreaterThan(initialReads)
|
||||
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('after')
|
||||
} finally {
|
||||
@@ -130,13 +135,11 @@ describe('Markdown external refresh', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps dirty content and requires confirmation before manual refresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
let disk = { content: 'before', version: { mtimeMs: 1, size: 6 } }
|
||||
it('protects dirty content until the user confirms manual refresh', async () => {
|
||||
let disk = 'before'
|
||||
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
|
||||
kind: 'text', content: disk.content, truncated: false, version: disk.version,
|
||||
kind: 'text', content: disk, truncated: false,
|
||||
}))
|
||||
vi.spyOn(api, 'fsStat').mockImplementation(async () => ({ version: disk.version }))
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { store, ctx, tab } = setup()
|
||||
const view = mount(ctx, store, tab)
|
||||
@@ -146,14 +149,7 @@ describe('Markdown external refresh', () => {
|
||||
act(() => { markDirty?.() })
|
||||
await flush()
|
||||
const before = read.mock.calls.length
|
||||
disk = { content: 'external', version: { mtimeMs: 2, size: 8 } }
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
|
||||
expect(view.container.textContent).toMatch(/文件已更新|File changed on disk/)
|
||||
disk = 'external'
|
||||
|
||||
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
|
||||
expect(refresh).not.toBeNull()
|
||||
@@ -161,6 +157,7 @@ describe('Markdown external refresh', () => {
|
||||
await flush()
|
||||
expect(confirm).toHaveBeenCalled()
|
||||
expect(read.mock.calls.length).toBe(before)
|
||||
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
|
||||
|
||||
confirm.mockReturnValue(true)
|
||||
act(() => { refresh!.click() })
|
||||
Reference in New Issue
Block a user