diff --git a/README.md b/README.md index 4e739c8..d9643e5 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,7 @@ pnpm watch # tsdown --watch ## ⚠️ 已知限制 -- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截 +- 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 cf78a79..d50ba98 100644 --- a/README_EN.md +++ b/README_EN.md @@ -422,7 +422,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 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/locales.ts b/src/client/locales.ts index 2efe5c2..8ea32de 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -93,6 +93,7 @@ export const zh = { mermaidZoomReset: '重置', mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭', refresh: '刷新', + refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?', save: '保存', saved: '已保存', unsaved: '未保存', @@ -430,6 +431,7 @@ export const en: Record = { mermaidZoomReset: 'Reset', mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close', refresh: 'Refresh', + refreshUnsavedConfirm: 'The file changed on disk. Refreshing will discard unsaved edits. Continue?', save: 'Save', saved: 'Saved', unsaved: 'Unsaved', diff --git a/src/index.ts b/src/index.ts index 72753de..6c1ce19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,7 +180,13 @@ async function readText(path: string, readLimit: number): Promise<{ const head = binary ? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64') : undefined - return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head } + return { + content: binary ? '' : slice.toString('utf8'), + truncated, + binary, + size, + head, + } } finally { await handle.close() } diff --git a/tests/markdown-manual-refresh.spec.tsx b/tests/markdown-manual-refresh.spec.tsx new file mode 100644 index 0000000..6da925b --- /dev/null +++ b/tests/markdown-manual-refresh.spec.tsx @@ -0,0 +1,171 @@ +/** + * 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' +import { createElement, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import type { Context } from '../src/context-types.ts' +import { EditorHost } from '../src/client/EditorHost.tsx' +import { api } from '../src/client/api.ts' +import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts' +import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts' + +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +function toolbar(dirty: boolean) { + return { + modes: true, + mode: 'preview' as const, + dirty, + editable: true, + saveState: 'idle' as const, + } +} + +let markDirty: (() => void) | undefined + +/** A tiny viewer that exposes the host toolbar and renders the loaded source. */ +function FakeMarkdownViewer(props: FileViewerProps) { + useEffect(() => { + props.onToolbarControls?.({ setMode: () => {}, save: () => {} }) + props.onToolbarState?.(toolbar(false)) + markDirty = () => { props.onToolbarState?.(toolbar(true)) } + return () => { + markDirty = undefined + props.onToolbarControls?.(null) + } + }, [props.onToolbarControls, props.onToolbarState]) + return createElement('div', { 'data-testid': 'markdown-content' }, props.content ?? '') +} + +function setup(): { + store: ReturnType + ctx: Context + tab: SidebarTab +} { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'editor', title: 'Editor', component: () => null }) + service.registerFileViewer({ + id: 'markdown', + exts: ['md'], + fetchStrategy: 'fsRead', + component: FakeMarkdownViewer, + }) + store.setSession('markdown-manual-refresh-session') + const home = allLeaves(store.getSnapshot().state!.splits) + .flatMap(leaf => leaf.tabs) + .find(candidate => candidate.type === 'editor')! + const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' } + const ctx = { betterSidebar: service } as unknown as Context + return { store, ctx, tab } +} + +function mount(ctx: Context, store: ReturnType, tab: SidebarTab) { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + act(() => { + root.render(createElement(EditorHost, { + ctx, + store, + scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' }, + tab, + expanded: [], + onToggleDir: () => {}, + onReferenceFile: () => {}, + })) + }) + return { + container, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +afterEach(() => { + markDirty = undefined + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('Markdown manual refresh', () => { + it('does not poll after an external edit and reloads only after clicking refresh', async () => { + vi.useFakeTimers() + let disk = 'before' + const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({ + kind: 'text', content: disk, truncated: false, + })) + 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 = 'after' + await act(async () => { + vi.advanceTimersByTime(3000) + await Promise.resolve() + await Promise.resolve() + }) + expect(read.mock.calls.length).toBe(initialReads) + expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before') + + const refresh = view.container.querySelector('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 { + view.unmount() + } + }) + + 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, truncated: false, + })) + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false) + const { store, ctx, tab } = setup() + const view = mount(ctx, store, tab) + try { + await flush() + expect(markDirty).toBeDefined() + act(() => { markDirty?.() }) + await flush() + const before = read.mock.calls.length + disk = 'external' + + const refresh = view.container.querySelector('button[aria-label="刷新"], button[aria-label="Refresh"]') + expect(refresh).not.toBeNull() + act(() => { refresh!.click() }) + 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() }) + await flush() + expect(read.mock.calls.length).toBeGreaterThan(before) + expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('external') + } finally { + view.unmount() + } + }) +})