From ac11cc8fa5c4bfc9b768e31353d829e5b4f7e2b8 Mon Sep 17 00:00:00 2001 From: NGdev2 Date: Thu, 8 Oct 2026 01:42:33 +0200 Subject: [PATCH 1/2] Auto-refresh file preview when the file changes on disk Preview blocks now poll the displayed file's modtime every 2s (skipped while the window is hidden, and for directories) and reload when it changes. Unsaved edits are never overwritten, and a block's own save does not trigger a reload. Also fixes two cases where refresh showed stale content: - a block that had saved the file kept returning its cached saved content instead of the file on disk - PDF/image previews reused the same stream URL, so the browser kept serving the cached file (#1686) Co-Authored-By: Claude Opus 5.5 --- frontend/app/view/preview/preview-edit.tsx | 2 +- .../app/view/preview/preview-markdown.tsx | 3 +- frontend/app/view/preview/preview-model.tsx | 61 +++++++++++++++++++ .../app/view/preview/preview-streaming.tsx | 8 ++- frontend/app/view/preview/preview.tsx | 27 ++++++++ 5 files changed, 96 insertions(+), 5 deletions(-) diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx index 2961771fa3..70807c6270 100644 --- a/frontend/app/view/preview/preview-edit.tsx +++ b/frontend/app/view/preview/preview-edit.tsx @@ -64,7 +64,7 @@ function CodeEditPreview({ model }: SpecializedViewProps) { useEffect(() => { model.codeEditKeyDownHandler = codeEditKeyDownHandler; model.refreshCallback = () => { - globalStore.set(model.refreshVersion, (v) => v + 1); + model.reloadFileContent(); }; return () => { model.codeEditKeyDownHandler = null; diff --git a/frontend/app/view/preview/preview-markdown.tsx b/frontend/app/view/preview/preview-markdown.tsx index d47248d457..473948f45e 100644 --- a/frontend/app/view/preview/preview-markdown.tsx +++ b/frontend/app/view/preview/preview-markdown.tsx @@ -1,7 +1,6 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { globalStore } from "@/app/store/jotaiStore"; import { Markdown } from "@/element/markdown"; import { getOverrideConfigAtom } from "@/store/global"; import { useAtomValue } from "jotai"; @@ -11,7 +10,7 @@ import type { SpecializedViewProps } from "./preview"; function MarkdownPreview({ model }: SpecializedViewProps) { useEffect(() => { model.refreshCallback = () => { - globalStore.set(model.refreshVersion, (v) => v + 1); + model.reloadFileContent(); }; return () => { model.refreshCallback = null; diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 8315e48b2a..4f7c6f003b 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -166,6 +166,9 @@ export class PreviewModel implements ViewModel { refreshVersion: PrimitiveAtom; directorySearchActive: PrimitiveAtom; refreshCallback: () => void; + // path + modtime of the file as last seen on disk, used to auto-refresh on external changes + watchedFilePath: string = null; + watchedModTime: number = null; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; env: PreviewEnv; @@ -665,6 +668,8 @@ export class PreviewModel implements ViewModel { globalStore.set(this.fileContent, newFileContent); globalStore.set(this.newFileContent, null); console.log("saved file", filePath); + // our own write changes the modtime; record it so auto-refresh doesn't reload what we just saved + await this.syncWatchedModTime(); } catch (e) { const errorStatus: ErrorMsg = { status: "Save Failed", @@ -674,6 +679,62 @@ export class PreviewModel implements ViewModel { } } + async statWatchedFile(): Promise { + const filePath = await globalStore.get(this.statFilePath); + if (filePath == null) { + return null; + } + const fileInfo = await this.env.rpc.FileInfoCommand(TabRpcClient, { + info: { + path: await this.formatRemoteUri(filePath, globalStore.get), + }, + }); + if (fileInfo == null || fileInfo.notfound || fileInfo.isdir) { + return null; + } + return fileInfo; + } + + async syncWatchedModTime() { + try { + const fileInfo = await this.statWatchedFile(); + if (fileInfo != null && fileInfo.path === this.watchedFilePath) { + this.watchedModTime = fileInfo.modtime; + } + } catch (e) { + console.log("error syncing preview modtime", e); + } + } + + // Polled while a file is displayed. Reloads the content when the file's modtime on disk + // changes, unless there are unsaved edits (those must never be overwritten). + async checkForExternalChange() { + const fileInfo = await this.statWatchedFile(); + if (fileInfo == null) { + return; + } + if (fileInfo.path !== this.watchedFilePath) { + // first check for this file, start from the modtime the view was loaded with + const loadedInfo = await globalStore.get(this.statFile); + this.watchedFilePath = fileInfo.path; + this.watchedModTime = loadedInfo?.path === fileInfo.path ? loadedInfo.modtime : fileInfo.modtime; + } + if (fileInfo.modtime === this.watchedModTime) { + return; + } + if (globalStore.get(this.newFileContent) != null) { + return; + } + this.watchedModTime = fileInfo.modtime; + this.reloadFileContent(); + } + + reloadFileContent() { + // drop the cached saved content, otherwise it shadows the freshly read file + globalStore.set(this.fileContentSaved, null); + globalStore.set(this.refreshVersion, (v) => v + 1); + } + async handleFileRevert() { const fileContent = await globalStore.get(this.fileContent); this.monacoRef.current?.setValue(fileContent); diff --git a/frontend/app/view/preview/preview-streaming.tsx b/frontend/app/view/preview/preview-streaming.tsx index 57f7305064..9156651344 100644 --- a/frontend/app/view/preview/preview-streaming.tsx +++ b/frontend/app/view/preview/preview-streaming.tsx @@ -3,7 +3,6 @@ import { Button } from "@/app/element/button"; import { CenteredDiv } from "@/app/element/quickelems"; -import { globalStore } from "@/app/store/jotaiStore"; import { getWebServerEndpoint } from "@/util/endpoints"; import { formatRemoteUri } from "@/util/waveutil"; import { useAtomValue } from "jotai"; @@ -49,18 +48,23 @@ function StreamingImagePreview({ url }: { url: string }) { function StreamingPreview({ model }: SpecializedViewProps) { useEffect(() => { model.refreshCallback = () => { - globalStore.set(model.refreshVersion, (v) => v + 1); + model.reloadFileContent(); }; return () => { model.refreshCallback = null; }; }, []); const conn = useAtomValue(model.connection); + const refreshVersion = useAtomValue(model.refreshVersion); const fileInfo = useAtomValue(model.statFile); const filePath = fileInfo.path; const remotePath = formatRemoteUri(filePath, conn); const usp = new URLSearchParams(); usp.set("path", remotePath); + if (refreshVersion > 0) { + // cache-buster so the browser refetches the file after a refresh + usp.set("refresh", String(refreshVersion)); + } const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`; if (fileInfo.mimetype === "application/pdf") { return ( diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 87cf44678a..2dc98672b3 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -18,6 +18,9 @@ import type { PreviewModel } from "./preview-model"; import { StreamingPreview } from "./preview-streaming"; import type { PreviewEnv } from "./previewenv"; +// how often a displayed file is checked for changes on disk +const FileWatchIntervalMs = 2000; + export type SpecializedViewProps = { model: PreviewModel; parentRef: React.RefObject; @@ -120,6 +123,30 @@ function PreviewView({ setErrorMsg(null); }, [connection, fileInfo]); + const specializedViewLoadable = useAtomValue(model.loadableSpecializedView); + const specializedViewName = + specializedViewLoadable.state == "hasData" ? specializedViewLoadable.data?.specializedView : null; + const watchFile = specializedViewName != null && specializedViewName != "directory"; + useEffect(() => { + if (!watchFile || connStatus?.status != "connected") { + return; + } + let checking = false; + const intervalId = setInterval(() => { + if (checking || document.hidden) { + return; + } + checking = true; + model + .checkForExternalChange() + .catch((e) => console.log("error checking file for changes", e)) + .finally(() => { + checking = false; + }); + }, FileWatchIntervalMs); + return () => clearInterval(intervalId); + }, [model, watchFile, connStatus?.status]); + if (connStatus?.status != "connected") { return null; } From 7329944baec864784b3fa7169f897e75e7f39ee7 Mon Sep 17 00:00:00 2001 From: NGdev2 Date: Thu, 8 Oct 2026 14:32:21 +0200 Subject: [PATCH 2/2] Retry failed auto-reloads and re-stat the file on reload Addresses review feedback: - Only record the new modtime after the reload succeeds, so a failed read (e.g. file mid-write) is retried on the next poll, up to 3 times. - Re-run FileInfoCommand on reload (new fileInfoVersion atom), so a changed mimetype or size is picked up: the view switches type and the 10MB limit is applied to the current size. Co-Authored-By: Claude Opus 5.5 --- frontend/app/view/preview/preview-model.tsx | 32 ++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 4f7c6f003b..226576267f 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -33,6 +33,7 @@ const BOOKMARKS: { label: string; path: string }[] = [ const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB +const MaxAutoReloadRetries = 3; const textApplicationMimetypes = [ "application/sql", @@ -164,11 +165,14 @@ export class PreviewModel implements ViewModel { showHiddenFiles: PrimitiveAtom; refreshVersion: PrimitiveAtom; + fileInfoVersion: PrimitiveAtom; directorySearchActive: PrimitiveAtom; refreshCallback: () => void; // path + modtime of the file as last seen on disk, used to auto-refresh on external changes watchedFilePath: string = null; watchedModTime: number = null; + // failed auto-reloads of the current modtime; retried on the next poll up to MaxAutoReloadRetries + reloadFailures = 0; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; env: PreviewEnv; @@ -182,6 +186,7 @@ export class PreviewModel implements ViewModel { let showHiddenFiles = globalStore.get(this.env.getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; this.showHiddenFiles = atom(showHiddenFiles); this.refreshVersion = atom(0); + this.fileInfoVersion = atom(0); this.directorySearchActive = atom(false); this.previewTextRef = createRef(); this.openFileModal = atom(false); @@ -405,6 +410,7 @@ export class PreviewModel implements ViewModel { return get(this.blockAtom)?.meta?.connection; }); this.statFile = atom>(async (get) => { + get(this.fileInfoVersion); // re-stat when the file content is reloaded const fileName = get(this.metaFilePath); const path = await this.formatRemoteUri(fileName, get); if (fileName == null) { @@ -717,6 +723,7 @@ export class PreviewModel implements ViewModel { // first check for this file, start from the modtime the view was loaded with const loadedInfo = await globalStore.get(this.statFile); this.watchedFilePath = fileInfo.path; + this.reloadFailures = 0; this.watchedModTime = loadedInfo?.path === fileInfo.path ? loadedInfo.modtime : fileInfo.modtime; } if (fileInfo.modtime === this.watchedModTime) { @@ -725,13 +732,36 @@ export class PreviewModel implements ViewModel { if (globalStore.get(this.newFileContent) != null) { return; } - this.watchedModTime = fileInfo.modtime; this.reloadFileContent(); + if (!(await this.didReloadSucceed()) && ++this.reloadFailures < MaxAutoReloadRetries) { + // keep the old modtime so the next poll retries (e.g. the file was mid-write) + return; + } + this.reloadFailures = 0; + this.watchedModTime = fileInfo.modtime; + } + + async didReloadSucceed(): Promise { + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null) { + return false; + } + const specializedView = await globalStore.get(this.specializedView); + if (specializedView.errorStr != null) { + return false; + } + if (specializedView.specializedView == "streaming") { + // streamed files are loaded by the browser, nothing to check here + return true; + } + const fullFile = await globalStore.get(this.fullFile); + return fullFile != null; } reloadFileContent() { // drop the cached saved content, otherwise it shadows the freshly read file globalStore.set(this.fileContentSaved, null); + globalStore.set(this.fileInfoVersion, (v) => v + 1); globalStore.set(this.refreshVersion, (v) => v + 1); }