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..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,8 +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; @@ -179,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); @@ -402,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) { @@ -665,6 +674,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 +685,86 @@ 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.reloadFailures = 0; + this.watchedModTime = loadedInfo?.path === fileInfo.path ? loadedInfo.modtime : fileInfo.modtime; + } + if (fileInfo.modtime === this.watchedModTime) { + return; + } + if (globalStore.get(this.newFileContent) != null) { + return; + } + 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); + } + 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; }