Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/app/src/workspaces/files/artifact.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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)
})
})
15 changes: 15 additions & 0 deletions packages/app/src/workspaces/files/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}
32 changes: 30 additions & 2 deletions packages/app/src/workspaces/files/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,8 @@ import {
type SelectedLineRange,
} from "./types"

const REFRESH_MAX_BYTES = 8 * 1024 * 1024

export type { FileSelection, SelectedLineRange, FileViewState, FileState }

export { selectionFromLines }
Expand Down Expand Up @@ -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.
Expand All @@ -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]))
Expand Down Expand Up @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions packages/gui-extensions/src/file/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
Loading