diff --git a/packages/app/src/workspaces/files/artifact.test.ts b/packages/app/src/workspaces/files/artifact.test.ts index af6996e03622..695b50b1c211 100644 --- a/packages/app/src/workspaces/files/artifact.test.ts +++ b/packages/app/src/workspaces/files/artifact.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { fileContentFromBytes, MAX_MEDIA_BYTES } from "./artifact" +import { fileContentFromBytes, MAX_MEDIA_BYTES, sameFileContent } from "./artifact" import { approxBytes } from "./content-cache" describe("fileContentFromBytes", () => { @@ -45,3 +45,22 @@ describe("fileContentFromBytes", () => { expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 }) }) }) + +describe("sameFileContent", () => { + test("matches rereads of unchanged text and media", () => { + const text = new TextEncoder().encode("const a = 1") + expect(sameFileContent(fileContentFromBytes("a.ts", text), fileContentFromBytes("a.ts", text.slice()))).toBe(true) + + const png = new Uint8Array([137, 80, 78, 71]) + expect(sameFileContent(fileContentFromBytes("a.png", png), fileContentFromBytes("a.png", png.slice()))).toBe(true) + }) + + test("detects changed content", () => { + const before = fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1")) + expect(sameFileContent(before, fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 2")))).toBe(false) + expect(sameFileContent(undefined, before)).toBe(false) + + const png = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71])) + expect(sameFileContent(png, fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 72])))).toBe(false) + }) +}) diff --git a/packages/app/src/workspaces/files/artifact.ts b/packages/app/src/workspaces/files/artifact.ts index 2f769f0c98e5..f5398dd832f0 100644 --- a/packages/app/src/workspaces/files/artifact.ts +++ b/packages/app/src/workspaces/files/artifact.ts @@ -36,3 +36,18 @@ export function fileContentFromBytes(path: string, bytes: Uint8Array): FileConte return { type: "text", content: new TextDecoder().decode(bytes), mimeType } } + +const sameBytes = (a: Uint8Array | undefined, b: Uint8Array | undefined) => + a === b || (!!a && !!b && a.length === b.length && a.every((byte, index) => byte === b[index])) + +/** Whether a reread found what is already shown, so the viewer can keep its content. */ +export function sameFileContent(a: FileContent | undefined, b: FileContent) { + return ( + !!a && + a.type === b.type && + a.content === b.content && + a.size === b.size && + a.mimeType === b.mimeType && + sameBytes(a.bytes, b.bytes) + ) +} diff --git a/packages/app/src/workspaces/files/model.tsx b/packages/app/src/workspaces/files/model.tsx index 97ba50a6becc..f2af82773bdc 100644 --- a/packages/app/src/workspaces/files/model.tsx +++ b/packages/app/src/workspaces/files/model.tsx @@ -4,12 +4,13 @@ import { isFileNotFoundError } from "@opencode/client/promise" import { createSimpleContext } from "@opencode/ui/context" import { showToast } from "@/shell/notifications/toast" import { useParams } from "@solidjs/router" +import { debounce } from "@solid-primitives/scheduled" import { getDirectory, getFilename } from "@opencode/util/path" import { useWorkspaceLocation } from "@/workspaces/location" import { useLanguage } from "@/runtime/i18n/language" import { useExtensionAttachment } from "@/runtime/extension/host-apis" import { createPathHelpers } from "./path" -import { fileContentFromBytes } from "./artifact" +import { fileContentFromBytes, sameFileContent } from "./artifact" import { approxBytes, evictContentLru, @@ -31,6 +32,8 @@ import { type SelectedLineRange, } from "./types" +const REFRESH_MAX_BYTES = 8 * 1024 * 1024 + export type { FileSelection, SelectedLineRange, FileViewState, FileState } export { selectionFromLines } @@ -186,7 +189,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ return pending.promise } - setLoading(file) + // A reread keeps what the file shows, its content or the missing file, until the reply lands. + if (!current?.loaded && !current?.notFound) setLoading(file) // Files outside the workspace are read from their own directory, like markdown images. // The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage. @@ -204,6 +208,9 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ .then((data) => { if (scope() !== directory) return const content = fileContentFromBytes(file, data) + + if (store.file[file]?.loaded && sameFileContent(store.file[file]?.content, content)) return + setLoaded(file, content) touchFileContent(file, approxBytes(content)) evictContent(new Set([file])) @@ -280,6 +287,27 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ onCleanup(stop) }) + // Agent tools can change any open file; reread them once a burst of tool calls settles. Large media keeps what it + // loaded. + const refreshOpened = debounce(() => { + for (const file of extensions.files.opened()) { + const content = store.file[file]?.content + + if (content?.type === "binary" && (content.bytes?.byteLength ?? content.size ?? 0) > REFRESH_MAX_BYTES) continue + void load(file, { force: true, silent: true }) + } + }, 250) + + createEffect(() => { + const event = sdk().event + const stops = [event.on("session.tool.success", refreshOpened), event.on("session.tool.failed", refreshOpened)] + + onCleanup(() => { + refreshOpened.clear() + stops.forEach((stop) => stop()) + }) + }) + const get = (input: string) => { const file = path.normalize(input) const state = store.file[file] diff --git a/packages/gui-extensions/src/file/view.tsx b/packages/gui-extensions/src/file/view.tsx index 529c1db7eb63..9e7be1ffd621 100644 --- a/packages/gui-extensions/src/file/view.tsx +++ b/packages/gui-extensions/src/file/view.tsx @@ -16,6 +16,8 @@ import ArtifactView from "./artifact-view" import { useShared } from "./context" import { fileTabPath } from "./path" +const REFRESH_MS = 2000 + type FileSelection = { startLine: number; endLine: number; startChar: number; endChar: number } function selectionFromLines(range: LineRange): FileSelection { @@ -408,6 +410,32 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio }, ) + const [hidden, setHidden] = createSignal(document.visibilityState === "hidden") + + makeEventListener(document, "visibilitychange", () => setHidden(document.visibilityState === "hidden")) + + // The server does not watch workspace files, so the shown file rereads while the window is visible. Unchanged + // rereads keep the current content; binary files, which can be large, only reread on agent edits. + createKeyed( + () => { + const p = path() + + if (!p || hidden() || layout.state(key(), props.session) !== "visible") return + + if (current()?.content?.type === "binary") return + + return { path: p } + }, + (target) => { + const sync = () => void file.sync(target.path, { force: true, silent: true }) + const timer = setInterval(sync, REFRESH_MS) + + sync() + onCleanup(() => clearInterval(timer)) + }, + { equals: (previous, next) => previous?.path === next?.path }, + ) + const previous = { loaded: false, ready: false, active: false } createKeyed(