diff --git a/docs/design.md b/docs/design.md index 6cf7e4689..790465389 100644 --- a/docs/design.md +++ b/docs/design.md @@ -64,6 +64,8 @@ The button is ` + + + ); +} diff --git a/packages/app/src/file-pane/editor/extensions.web.ts b/packages/app/src/file-pane/editor/extensions.web.ts new file mode 100644 index 000000000..47fc0ab3c --- /dev/null +++ b/packages/app/src/file-pane/editor/extensions.web.ts @@ -0,0 +1,87 @@ +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { + bracketMatching, + defaultHighlightStyle, + indentOnInput, + syntaxHighlighting, +} from "@codemirror/language"; +import { searchKeymap } from "@codemirror/search"; +import { + EditorView, + drawSelection, + highlightActiveLine, + keymap, + lineNumbers, +} from "@codemirror/view"; +import { createCodeMirrorHighlightStyle, type HighlightStyle } from "@getpaseo/highlight"; + +export interface EditorVisualTheme { + colorScheme: "light" | "dark"; + background: string; + foreground: string; + cursor: string; + foregroundMuted: string; + border: string; + selection: string; + monoFont: string; + codeFontSize: number; + syntax: Record; +} + +export function editorBaseExtensions(onSave: () => void) { + return [ + lineNumbers(), + history(), + drawSelection(), + indentOnInput(), + bracketMatching(), + highlightActiveLine(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([ + { key: "Mod-s", preventDefault: true, run: () => (onSave(), true) }, + indentWithTab, + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + ]), + ]; +} + +export function editorTheme(theme: EditorVisualTheme) { + return [ + EditorView.theme( + { + "&": { + height: "100%", + backgroundColor: theme.background, + color: theme.foreground, + fontFamily: theme.monoFont, + fontSize: `${theme.codeFontSize}px`, + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: theme.monoFont, + lineHeight: "1.45", + }, + ".cm-content": { caretColor: theme.foreground, padding: "16px 0" }, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor }, + ".cm-gutters": { + backgroundColor: theme.background, + color: theme.foregroundMuted, + borderRight: `1px solid ${theme.border}`, + }, + ".cm-activeLine": { backgroundColor: "transparent" }, + ".cm-activeLineGutter": { backgroundColor: "transparent", color: theme.foreground }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + backgroundColor: theme.selection, + }, + ".cm-selectionBackground, ::selection": { + backgroundColor: theme.selection, + }, + "&.cm-focused": { outline: "none" }, + }, + { dark: theme.colorScheme === "dark" }, + ), + syntaxHighlighting(createCodeMirrorHighlightStyle(theme.syntax)), + ]; +} diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts new file mode 100644 index 000000000..9f1655277 --- /dev/null +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "vitest"; +import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; +import { + FileEditorModel, + type FileEditorClock, + type FileEditorFile, + type FileEditorSession, +} from "./model"; + +class TestClock implements FileEditorClock { + private callback: (() => void) | null = null; + + setTimeout(callback: () => void): ReturnType { + this.callback = callback; + return 1 as unknown as ReturnType; + } + + clearTimeout(): void { + this.callback = null; + } + + fire(): void { + const callback = this.callback; + this.callback = null; + callback?.(); + } +} + +class FileSession implements FileEditorSession { + file: FileEditorFile; + writes: Array<{ content: string; expectedModifiedAt: string; expectedRevision?: string }> = []; + nextWrite: FileWriteResult | Error | null = null; + private pendingWrite: Promise | null = null; + private resolvePendingWrite: ((result: FileWriteResult) => void) | null = null; + + constructor(file: FileEditorFile) { + this.file = file; + } + + async read(): Promise { + return this.file; + } + + async write(input: { + content: string; + expectedModifiedAt: string; + expectedRevision?: string; + }): Promise { + this.writes.push(input); + if (this.pendingWrite) return this.pendingWrite; + if (this.nextWrite instanceof Error) throw this.nextWrite; + if (this.nextWrite) return this.nextWrite; + return { + status: "written", + modifiedAt: "2026-07-18T00:00:01.000Z", + size: input.content.length, + }; + } + + holdNextWrite(): void { + this.pendingWrite = new Promise((resolve) => { + this.resolvePendingWrite = resolve; + }); + } + + finishHeldWrite(result: FileWriteResult): void { + this.resolvePendingWrite?.(result); + this.pendingWrite = null; + this.resolvePendingWrite = null; + } +} + +function ready( + modifiedAt = "2026-07-18T00:00:00.000Z", + size = 3, +): Extract { + return { status: "ready", cwd: "/workspace", path: "file.ts", size, modifiedAt }; +} + +function makeModel() { + const file = { content: "one", version: ready() as Extract }; + const session = new FileSession(file); + const clock = new TestClock(); + return { model: new FileEditorModel({ file, session, clock }), session, clock }; +} + +describe("FileEditorModel", () => { + test("tracks whether the current buffer differs from persisted content", async () => { + const { model } = makeModel(); + + expect(model.getSnapshot().modified).toBe(false); + model.edit("two"); + expect(model.getSnapshot().modified).toBe(true); + model.edit("one"); + expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); + + model.edit("saved"); + await model.save(); + expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); + }); + + test("adopts a precise revision for otherwise unchanged initial metadata", () => { + const { model } = makeModel(); + + model.receiveFileVersion({ ...ready(), revision: "precise-revision" }); + + expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" }); + }); + + test("keeps a newer edit modified when an older save finishes", async () => { + const { model, session } = makeModel(); + session.holdNextWrite(); + model.edit("saving"); + + const save = model.save(); + model.edit("newer edit"); + session.finishHeldWrite({ + status: "written", + modifiedAt: "2026-07-18T00:00:01.000Z", + size: 6, + }); + await save; + + expect(model.getSnapshot()).toMatchObject({ + status: "dirty", + content: "newer edit", + modified: true, + }); + }); + + test("autosaves the latest edit after inactivity", async () => { + const { model, session, clock } = makeModel(); + + model.edit("two"); + model.edit("three"); + clock.fire(); + await Promise.resolve(); + + expect(session.writes).toEqual([ + { content: "three", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, + ]); + expect(model.getSnapshot().status).toBe("clean"); + }); + + test("reloads a clean editor when the disk version changes", async () => { + const { model, session } = makeModel(); + session.file = { + content: "external", + version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, + }; + + model.receiveFileVersion(session.file.version); + await Promise.resolve(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); + }); + + test("coalesces consecutive clean disk updates onto the latest reload", async () => { + const { model, session } = makeModel(); + const reads: Array<(file: FileEditorFile) => void> = []; + session.read = () => new Promise((resolve) => reads.push(resolve)); + const firstVersion = ready("2026-07-18T00:00:02.000Z", 5); + const latestVersion = ready("2026-07-18T00:00:03.000Z", 6); + + model.receiveFileVersion(firstVersion); + model.receiveFileVersion(latestVersion); + reads[0]?.({ content: "first", version: firstVersion }); + await Promise.resolve(); + reads[1]?.({ content: "latest", version: latestVersion }); + await Promise.resolve(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); + }); + + test("preserves a dirty buffer and overwrites against the newest disk revision", async () => { + const { model, session } = makeModel(); + model.edit("local"); + model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + + expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" }); + await model.overwrite(); + + expect(session.writes).toEqual([ + { content: "local", expectedModifiedAt: "2026-07-18T00:00:02.000Z" }, + ]); + expect(model.getSnapshot().status).toBe("clean"); + }); + + test("reload discards a conflicted local buffer for the disk contents", async () => { + const { model, session } = makeModel(); + model.edit("local"); + const diskVersion = ready("2026-07-18T00:00:02.000Z", 4) as Extract< + FileVersion, + { status: "ready" } + >; + session.file = { content: "disk", version: diskVersion }; + model.receiveFileVersion(diskVersion); + + await model.reload(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); + }); + + test("reports failed saves without losing the local buffer", async () => { + const { model, session } = makeModel(); + session.nextWrite = new Error("disk full"); + model.edit("important local work"); + + await model.save(); + + expect(model.getSnapshot()).toMatchObject({ + status: "error", + content: "important local work", + error: "disk full", + }); + }); + + test("a deletion conflicts with local changes and stops autosave", () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" }); + + clock.fire(); + + expect(model.getSnapshot().status).toBe("conflict"); + expect(session.writes).toEqual([]); + }); + + test("dispose cancels pending autosave", () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + + model.dispose(); + clock.fire(); + + expect(session.writes).toEqual([]); + }); + + test("suspends a pending autosave while close confirmation is active", async () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + + const resume = model.suspendAutosave(); + clock.fire(); + expect(session.writes).toEqual([]); + + resume(); + clock.fire(); + await Promise.resolve(); + expect(session.writes).toHaveLength(1); + }); +}); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts new file mode 100644 index 000000000..a2b081d7b --- /dev/null +++ b/packages/app/src/file-pane/editor/model.ts @@ -0,0 +1,302 @@ +import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; + +export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; + +export interface FileEditorSnapshot { + status: FileEditorStatus; + content: string; + modified: boolean; + version: FileVersion; + observedVersion: FileVersion; + error: string | null; +} + +export interface FileEditorFile { + content: string; + version: Extract; +} + +export interface FileEditorSession { + read(): Promise; + write(input: { + content: string; + expectedModifiedAt: string; + expectedRevision?: string; + }): Promise; +} + +export interface FileEditorClock { + setTimeout(callback: () => void, delayMs: number): ReturnType; + clearTimeout(handle: ReturnType): void; +} + +const systemClock: FileEditorClock = { + setTimeout(callback, delay) { + return globalThis.setTimeout(callback, delay); + }, + clearTimeout(handle) { + globalThis.clearTimeout(handle); + }, +}; + +export class FileEditorModel { + private readonly session: FileEditorSession; + private readonly clock: FileEditorClock; + private readonly listeners = new Set<() => void>(); + private snapshot: FileEditorSnapshot; + private autosave: ReturnType | null = null; + private saveSequence = 0; + private disposed = false; + private observedWhileSaving: FileVersion | null = null; + private persistedContent: string; + + constructor(input: { + file: FileEditorFile; + session: FileEditorSession; + clock?: FileEditorClock; + }) { + this.session = input.session; + this.clock = input.clock ?? systemClock; + this.persistedContent = input.file.content; + this.snapshot = { + status: "clean", + content: input.file.content, + modified: false, + version: input.file.version, + observedVersion: input.file.version, + error: null, + }; + } + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): FileEditorSnapshot => this.snapshot; + + edit(content: string): void { + if (this.disposed || content === this.snapshot.content) return; + const modified = content !== this.persistedContent; + let status: FileEditorStatus = modified ? "dirty" : "clean"; + if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") { + status = "conflict"; + } + this.setSnapshot({ ...this.snapshot, status, content, modified, error: null }); + if (status === "dirty") this.scheduleAutosave(); + else this.clearAutosave(); + } + + async save(): Promise { + if (this.disposed || (this.snapshot.status !== "dirty" && this.snapshot.status !== "error")) { + return; + } + if (this.snapshot.observedVersion.status !== "ready") { + this.enterConflict(this.snapshot.observedVersion); + return; + } + await this.performWrite(this.snapshot.observedVersion); + } + + receiveFileVersion(version: FileVersion): void { + if (this.disposed) return; + if (sameVersion(version, this.snapshot.observedVersion)) { + if ( + version.status === "ready" && + this.snapshot.observedVersion.status === "ready" && + version.revision && + !this.snapshot.observedVersion.revision + ) { + this.setSnapshot({ + ...this.snapshot, + version: + this.snapshot.version.status === "ready" + ? { ...this.snapshot.version, revision: version.revision } + : this.snapshot.version, + observedVersion: version, + }); + } + return; + } + this.setSnapshot({ ...this.snapshot, observedVersion: version }); + if (this.snapshot.status === "saving") { + this.observedWhileSaving = version; + return; + } + if (this.snapshot.status === "clean" || this.snapshot.status === "loading") { + void this.reloadFromDisk(version); + return; + } + this.enterConflict(version); + } + + async overwrite(): Promise { + if (this.disposed || this.snapshot.status !== "conflict") return; + if (this.snapshot.observedVersion.status !== "ready") return; + await this.performWrite(this.snapshot.observedVersion); + } + + async reload(): Promise { + if (this.disposed) return; + await this.reloadFromDisk(this.snapshot.observedVersion); + } + + dispose(): void { + this.disposed = true; + this.saveSequence += 1; + this.clearAutosave(); + this.listeners.clear(); + } + + suspendAutosave(): () => void { + const wasScheduled = this.autosave !== null; + this.clearAutosave(); + let resumed = false; + return () => { + if (resumed || this.disposed) return; + resumed = true; + if (wasScheduled && this.snapshot.status === "dirty") this.scheduleAutosave(); + }; + } + + private async performWrite( + expectedVersion: Extract, + ): Promise { + this.clearAutosave(); + const sequence = ++this.saveSequence; + const content = this.snapshot.content; + this.observedWhileSaving = null; + this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); + let result: FileWriteResult; + try { + result = await this.session.write({ + content, + expectedModifiedAt: expectedVersion.modifiedAt, + expectedRevision: expectedVersion.revision, + }); + } catch (error) { + if (this.disposed || sequence !== this.saveSequence) return; + this.setSnapshot({ + ...this.snapshot, + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + return; + } + if (this.disposed || sequence !== this.saveSequence) return; + if (result.status === "error") { + this.setSnapshot({ ...this.snapshot, status: "error", error: result.error }); + return; + } + if (result.status === "conflict") { + this.enterConflict(result.version); + return; + } + + const writtenVersion: FileVersion = { + status: "ready", + cwd: this.snapshot.version.cwd, + path: this.snapshot.version.path, + size: result.size, + modifiedAt: result.modifiedAt, + revision: result.revision, + }; + const pending = this.observedWhileSaving; + this.observedWhileSaving = null; + this.persistedContent = content; + if (pending && !sameVersion(pending, writtenVersion)) { + this.setSnapshot({ + ...this.snapshot, + status: "conflict", + modified: this.snapshot.content !== this.persistedContent, + version: writtenVersion, + observedVersion: pending, + error: null, + }); + return; + } + const modified = this.snapshot.content !== this.persistedContent; + this.setSnapshot({ + ...this.snapshot, + status: modified ? "dirty" : "clean", + modified, + version: writtenVersion, + observedVersion: writtenVersion, + error: null, + }); + if (modified) this.scheduleAutosave(); + } + + private async reloadFromDisk(version: FileVersion): Promise { + this.clearAutosave(); + if (version.status !== "ready") { + this.enterConflict(version); + return; + } + const sequence = ++this.saveSequence; + this.setSnapshot({ ...this.snapshot, status: "loading", error: null }); + try { + const file = await this.session.read(); + if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") { + return; + } + this.persistedContent = file.content; + this.setSnapshot({ + status: "clean", + content: file.content, + modified: false, + version: file.version, + observedVersion: file.version, + error: null, + }); + } catch (error) { + if (this.disposed || sequence !== this.saveSequence) return; + this.setSnapshot({ + ...this.snapshot, + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private enterConflict(version: FileVersion): void { + this.clearAutosave(); + this.setSnapshot({ + ...this.snapshot, + status: "conflict", + modified: this.snapshot.content !== this.persistedContent, + observedVersion: version, + error: version.status === "error" ? version.error : null, + }); + } + + private scheduleAutosave(): void { + this.clearAutosave(); + this.autosave = this.clock.setTimeout(() => { + this.autosave = null; + void this.save(); + }, 800); + } + + private clearAutosave(): void { + if (!this.autosave) return; + this.clock.clearTimeout(this.autosave); + this.autosave = null; + } + + private setSnapshot(snapshot: FileEditorSnapshot): void { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +function sameVersion(left: FileVersion, right: FileVersion): boolean { + if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path) + return false; + if (left.status === "ready" && right.status === "ready") { + if (left.revision && right.revision) return left.revision === right.revision; + return left.modifiedAt === right.modifiedAt && left.size === right.size; + } + if (left.status === "error" && right.status === "error") return left.error === right.error; + return true; +} diff --git a/packages/app/src/file-pane/editor/view.tsx b/packages/app/src/file-pane/editor/view.tsx new file mode 100644 index 000000000..d84d0c4e8 --- /dev/null +++ b/packages/app/src/file-pane/editor/view.tsx @@ -0,0 +1,33 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { HighlightStyle } from "@getpaseo/highlight"; +import type { FileEditorModel } from "./model"; + +export function FileEditorView(_props: { + model: FileEditorModel; + filename: string; + vimEnabled: boolean; + theme: { + background: string; + foreground: string; + foregroundMuted: string; + border: string; + selection: string; + monoFont: string; + codeFontSize: number; + syntax: Record; + }; + onCursorChange(position: { line: number; column: number }): void; + onVimModeChange(mode: string | null): void; +}) { + return ( + + Source editing is available on web and desktop. + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { flex: 1, alignItems: "center", justifyContent: "center" }, + text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx new file mode 100644 index 000000000..6bca1a275 --- /dev/null +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { getLanguageForFile } from "@getpaseo/highlight"; +import { getCM, vim } from "@replit/codemirror-vim"; +import type { FileEditorModel } from "./model"; +import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; + +interface FileEditorViewProps { + model: FileEditorModel; + filename: string; + vimEnabled: boolean; + theme: EditorVisualTheme; + onCursorChange(position: { line: number; column: number }): void; + onVimModeChange(mode: string | null): void; +} + +const languageCompartment = new Compartment(); +const themeCompartment = new Compartment(); +const vimCompartment = new Compartment(); + +export function FileEditorView({ + model, + filename, + vimEnabled, + theme, + onCursorChange, + onVimModeChange, +}: FileEditorViewProps) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + const initial = useRef({ filename, model, theme, vimEnabled, content: snapshot.content }); + const onCursorChangeRef = useRef(onCursorChange); + onCursorChangeRef.current = onCursorChange; + + useEffect(() => { + if (!hostRef.current) return; + const values = initial.current; + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: values.content, + extensions: [ + vimCompartment.of(values.vimEnabled ? vim() : []), + ...editorBaseExtensions(() => void values.model.save()), + languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), + themeCompartment.of(editorTheme(values.theme)), + EditorView.updateListener.of((update) => { + if ( + update.docChanged && + !update.transactions.some((tr) => tr.annotation(remoteUpdate)) + ) { + values.model.edit(update.state.doc.toString()); + } + if (update.selectionSet || update.docChanged) { + const head = update.state.selection.main.head; + const line = update.state.doc.lineAt(head); + onCursorChangeRef.current({ line: line.number, column: head - line.from + 1 }); + } + }), + ], + }), + }); + viewRef.current = view; + onCursorChangeRef.current({ line: 1, column: 1 }); + return () => { + view.destroy(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view || view.state.doc.toString() === snapshot.content) return; + const head = Math.min(view.state.selection.main.head, snapshot.content.length); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: snapshot.content }, + selection: { anchor: head }, + annotations: [remoteUpdate.of(true), Transaction.addToHistory.of(false)], + }); + }, [snapshot.content]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: languageCompartment.reconfigure(getLanguageForFile(filename)?.extension ?? []), + }); + }, [filename]); + + useEffect(() => { + viewRef.current?.dispatch({ effects: themeCompartment.reconfigure(editorTheme(theme)) }); + }, [theme]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + view.dispatch({ effects: vimCompartment.reconfigure(vimEnabled ? vim() : []) }); + if (!vimEnabled) { + onVimModeChange(null); + return; + } + const cm = getCM(view); + if (!cm) return; + function handleModeChange(event: { mode?: string }) { + onVimModeChange((event.mode ?? "normal").toUpperCase()); + } + cm.on("vim-mode-change", handleModeChange); + onVimModeChange("NORMAL"); + return () => cm.off("vim-mode-change", handleModeChange); + }, [onVimModeChange, vimEnabled]); + + return ( +
+ ); +} + +const remoteUpdate = Annotation.define(); +const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; diff --git a/packages/app/src/file-pane/live-file.ts b/packages/app/src/file-pane/live-file.ts new file mode 100644 index 000000000..c76d66afb --- /dev/null +++ b/packages/app/src/file-pane/live-file.ts @@ -0,0 +1,93 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import { useFetchQuery } from "@/data/query"; + +export function useLiveFile(input: { + client: DaemonClient | null; + serverId: string; + cwd: string | null; + path: string | null; + enabled: boolean; + liveUpdates: boolean; +}) { + const queryClient = useQueryClient(); + const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates); + const [version, setVersion] = useState(null); + const latestVersion = useRef(null); + const queryKey = useMemo( + () => ["workspaceFile", input.serverId, input.cwd, input.path] as const, + [input.cwd, input.path, input.serverId], + ); + + useEffect(() => { + latestVersion.current = null; + setVersion(null); + const { client, cwd, path } = input; + if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) { + setSubscriptionReady(!input.liveUpdates); + return; + } + let disposed = false; + let unsubscribe: (() => void) | null = null; + setSubscriptionReady(false); + void (async () => { + try { + const subscription = await client.subscribeFile({ cwd, path }, (next) => { + if (disposed) return; + latestVersion.current = next; + setVersion(next); + void queryClient.invalidateQueries({ queryKey }); + }); + if (disposed) { + subscription.unsubscribe(); + return; + } + unsubscribe = subscription.unsubscribe; + latestVersion.current = subscription.initial; + setVersion(subscription.initial); + setSubscriptionReady(true); + } catch { + if (!disposed) setSubscriptionReady(true); + } + })(); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, [ + input.client, + input.cwd, + input.enabled, + input.liveUpdates, + input.path, + queryClient, + queryKey, + input.serverId, + ]); + + const query = useFetchQuery({ + queryKey, + enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady, + queryFn: async (): Promise => { + if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable."); + return input.client.readFile(input.cwd, input.path); + }, + dataShape: "value", + staleTimeMs: 5_000, + }); + + useEffect(() => { + const observed = latestVersion.current; + if ( + query.data && + observed?.status === "ready" && + query.data.modifiedAt !== observed.modifiedAt + ) { + void queryClient.invalidateQueries({ queryKey }); + } + }, [query.data, queryClient, queryKey]); + + return { query, version }; +} diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/file-pane/pane.tsx similarity index 53% rename from packages/app/src/components/file-pane.tsx rename to packages/app/src/file-pane/pane.tsx index e78ac348b..ab00cbcdc 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,6 +1,13 @@ -import React, { useEffect, useMemo, useRef } from "react"; -import { useQuery } from "@tanstack/react-query"; -import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; import { ActivityIndicator, Image as RNImage, @@ -8,7 +15,7 @@ import { Text, View, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, UnistylesRuntime } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -29,6 +36,14 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; +import { isWeb } from "@/constants/platform"; +import { useAppSettings } from "@/hooks/use-settings"; +import { useLiveFile } from "./live-file"; +import { FilePanelBar } from "./bar"; +import { FileEditorModel, type FileEditorFile } from "./editor/model"; +import { FileEditorView } from "./editor/view"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; interface CodeLineProps { tokens: HighlightToken[]; @@ -45,6 +60,8 @@ interface FilePreviewBodyProps { imagePreviewUri: string | null; } +type TextExplorerFile = ExplorerFile & { kind: "text" }; + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -192,7 +209,7 @@ function FilePreviewBody({ location, imagePreviewUri, }: FilePreviewBodyProps) { - const { theme } = useUnistyles(); + const theme = UnistylesRuntime.getTheme(); const { t } = useTranslation(); const filePath = location.path; const isMarkdownFile = @@ -370,8 +387,18 @@ export function FilePane({ }) { const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); + const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview"); + const [resolvedPreview, setResolvedPreview] = useState<{ + key: string | null; + file: ExplorerFile | null; + imageAttachment: AttachmentMetadata | null; + }>({ key: null, file: null, imageAttachment: null }); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. + const supportsEditing = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true, + ); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); const readTarget = useMemo( @@ -390,53 +417,174 @@ export function FilePane({ // from another window after an external edit. The gate lives in isFileQueryEnabled. const isActive = useRetainedPanelActive(); const isAppVisible = useAppActivelyVisible(); - - const query = useQuery({ - queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null], - enabled: isFileQueryEnabled({ - hasReadTarget: Boolean(client && readTarget), - isTabActive: isActive, - isAppVisible, - }), - queryFn: async () => { - if (!client || !readTarget) { - return { - file: null as ExplorerFile | null, - error: t("workspace.terminal.hostDisconnected"), - }; - } - try { - const file = await client.readFile(readTarget.cwd, readTarget.path); - const preview = await createFilePanePreview(file); - return { - file: preview.file, - imageAttachment: preview.imageAttachment, - error: null, - }; - } catch (error) { - return { - file: null, - imageAttachment: null, - error: error instanceof Error ? error.message : t("panels.file.failedToLoad"), - }; - } - }, - staleTime: 5_000, - refetchOnMount: true, + const enabled = isFileQueryEnabled({ + hasReadTarget: Boolean(client && readTarget), + isTabActive: isActive, + isAppVisible, }); - const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null); + const { query, version } = useLiveFile({ + client, + serverId, + cwd: readTarget?.cwd ?? null, + path: readTarget?.path ?? null, + enabled, + liveUpdates: supportsEditing, + }); + + useEffect(() => { + let active = true; + const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; + void (async () => { + const nextPreview = await createFilePanePreview(query.data ?? null); + if (active) setResolvedPreview({ key, ...nextPreview }); + })(); + return () => { + active = false; + }; + }, [query.data, readTarget]); + + useEffect(() => setMarkdownMode("preview"), [readTarget?.path]); + + const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; + const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null; + const imagePreviewUri = useAttachmentPreviewUrl( + resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null, + ); + const isMarkdown = isMarkdownPreview(preview, location.path); + const editable = isEditableTextFile({ + preview, + supportsEditing, + }); + const lineCount = + preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; + const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad")); + + return ( + + ); +} + +function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean { + return preview?.kind === "text" && isRenderedMarkdownFile(path); +} + +function getFileErrorMessage(error: unknown, fallback: string): string | null { + if (!error) return null; + return error instanceof Error ? error.message : fallback; +} + +function isEditableTextFile(input: { + preview: ExplorerFile | null; + supportsEditing: boolean; +}): boolean { + return Boolean( + isWeb && + input.supportsEditing && + input.preview?.kind === "text" && + input.preview.size <= 1024 * 1024, + ); +} + +function FilePanePresentation({ + serverId, + client, + readTarget, + preview, + version, + filename, + markdownMode, + onMarkdownModeChange, + lineCount, + editable, + disconnectedMessage, + errorMessage, + isLoading, + isMobile, + location, + imagePreviewUri, +}: { + serverId: string; + client: DaemonClient | null; + readTarget: { cwd: string; path: string } | null; + preview: ExplorerFile | null; + version: FileVersion | null; + filename: string; + markdownMode?: "preview" | "source"; + onMarkdownModeChange?: (mode: "preview" | "source") => void; + lineCount?: number; + editable: boolean; + disconnectedMessage: string; + errorMessage: string | null; + isLoading: boolean; + isMobile: boolean; + location: WorkspaceFileLocation; + imagePreviewUri: string | null; +}) { + if (!client && readTarget) { + return ( + + + {disconnectedMessage} + + + ); + } + + if (editable && client && readTarget && preview?.kind === "text") { + return ( + + ); + } return ( - {query.data?.error ? ( + {preview ? ( + + ) : null} + {errorMessage ? ( - {query.data.error} + {errorMessage} ) : null} void; + isLoading: boolean; + isMobile: boolean; + location: WorkspaceFileLocation; +}) { + const { settings } = useAppSettings(); + const { t } = useTranslation(); + const [cursor, setCursor] = useState({ line: 1, column: 1 }); + const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); + const session = useMemo( + () => ({ + async read(): Promise { + const file = await client.readFile(cwd, path); + if (file.kind !== "text") throw new Error("File is no longer text."); + return { + content: new TextDecoder().decode(file.bytes), + version: { + status: "ready", + cwd, + path, + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }; + }, + write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { + return client.writeFile({ cwd, path, ...input }); + }, + }), + [client, cwd, path], + ); + const [model] = useState( + () => + new FileEditorModel({ + file: { + content: preview.content ?? "", + version: { + status: "ready", + cwd, + path, + size: preview.size, + modifiedAt: preview.modifiedAt, + }, + }, + session, + }), + ); + const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); + usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); + const theme = UnistylesRuntime.getTheme(); + const visualTheme = useMemo( + () => ({ + colorScheme: theme.colorScheme, + background: theme.colors.surface0, + foreground: theme.colors.foreground, + cursor: theme.colors.terminal.cursor, + foregroundMuted: theme.colors.foregroundMuted, + border: theme.colors.border, + selection: theme.colors.terminal.selectionBackground, + monoFont: theme.fontFamily.mono, + codeFontSize: theme.fontSize.code, + syntax: theme.colors.syntax, + }), + [ + theme.colors.border, + theme.colors.foreground, + theme.colors.foregroundMuted, + theme.colors.surface0, + theme.colors.syntax, + theme.colors.terminal.cursor, + theme.colors.terminal.selectionBackground, + theme.colorScheme, + theme.fontFamily.mono, + theme.fontSize.code, + ], + ); + + useEffect(() => () => model.dispose(), [model]); + useEffect(() => { + if (version) model.receiveFileVersion(version); + }, [model, version]); + + const handleReload = useCallback(() => { + void (async () => { + const confirmed = await confirmDialog({ + title: t("panels.file.editor.reloadTitle"), + message: t("panels.file.editor.reloadMessage"), + confirmLabel: t("panels.file.editor.reload"), + destructive: true, + }); + if (confirmed) void model.reload(); + })(); + }, [model, t]); + const handleOverwrite = useCallback(() => void model.overwrite(), [model]); + const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); + const renderedPreview = useMemo( + () => ({ + ...preview, + content: snapshot.content, + size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size, + modifiedAt: + snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt, + }), + [preview, snapshot.content, snapshot.version], + ); + const showSource = mode !== "preview"; + + return ( + + + {showSource ? ( + + ) : ( + + )} + + ); +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 9e171f5dd..c634c463d 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -191,6 +191,9 @@ export function useSettings( if (updates.toolCallDetailLevel !== undefined) { appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel; } + if (updates.vimKeybindings !== undefined) { + appUpdates.vimKeybindings = updates.vimKeybindings; + } const promises: Promise[] = []; if (Object.keys(appUpdates).length > 0) { promises.push(appSettings.updateSettings(appUpdates)); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 808665f3e..6afe34966 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -43,6 +43,7 @@ export interface AppSettings { workspaceTitleSource: WorkspaceTitleSource; autoExpandReasoning: boolean; toolCallDetailLevel: ToolCallDetailLevel; + vimKeybindings: boolean; } export interface Settings extends AppSettings { @@ -66,6 +67,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { workspaceTitleSource: "title", autoExpandReasoning: false, toolCallDetailLevel: "detailed", + vimKeybindings: false, }; export const DEFAULT_APP_SETTINGS: Settings = { @@ -233,6 +235,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial { if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) { result.syntaxTheme = stored.syntaxTheme; } + if (typeof stored.vimKeybindings === "boolean") { + result.vimKeybindings = stored.vimKeybindings; + } if ( typeof stored.workspaceTitleSource === "string" && VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource) diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 703221824..be585dbe0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -466,6 +466,7 @@ export const ar: TranslationResources = { }, tabs: { loading: "تحميل...", + modified: "تغييرات غير محفوظة", loadingAgentTitle: "جارٍ تحميل عنوان الوكيل", emptyPane: "لا توجد علامات تبويب في هذا الجزء.", fallback: { @@ -532,6 +533,13 @@ export const ar: TranslationResources = { failedToReloadAgent: "فشل في إعادة تحميل الوكيل", }, confirmations: { + unsavedTitle: "تغييرات غير محفوظة", + unsavedMessage: + "تحتوي علامة التبويب هذه على تغييرات غير محفوظة. سيؤدي إغلاقها إلى تجاهل المسودة.", + closeWithoutSaving: "إغلاق بدون حفظ", + closePaneTitle: "إغلاق اللوحة؟", + bulkUnsaved: + "تحتوي {{count}} علامة تبويب على تغييرات غير محفوظة. سيؤدي الإغلاق إلى تجاهل المسودات.", close: "يغلق", cancel: "يلغي", archive: "أرشيف", @@ -1445,6 +1453,25 @@ export const ar: TranslationResources = { binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة", failedToLoad: "فشل تحميل الملف", failedToLoadPreview: "فشل تحميل معاينة الملف", + editor: { + fileSize: "حجم الملف {{size}}", + lines: "{{count}} سطر", + editorStatus: "حالة المحرر {{status}}", + unsavedChanges: "تغييرات غير محفوظة", + saving: "جارٍ الحفظ...", + saveFailed: "فشل الحفظ", + changedOnDisk: "تغيّر على القرص", + vimMode: "وضع Vim ‏{{mode}}", + cursor: "السطر {{line}}، العمود {{column}}", + preview: "معاينة", + source: "المصدر", + unavailableTitle: "الملف غير متاح على القرص", + conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.", + overwrite: "استبدال", + reload: "إعادة تحميل", + reloadTitle: "إعادة التحميل من القرص؟", + reloadMessage: "ستفقد تغييراتك المحلية.", + }, }, diff: { changesLabel: "التغييرات", @@ -1540,12 +1567,18 @@ export const ar: TranslationResources = { sections: { general: "عام", appearance: "مظهر", + editor: "المحرر", shortcuts: "الاختصارات", integrations: "التكامل", permissions: "الأذونات", diagnostics: "التشخيص", about: "عن", }, + editor: { + title: "المحرر", + vimKeybindings: "اختصارات Vim", + vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.", + }, hostSections: { connections: "اتصالات", agents: "Agents", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index acc593ce4..6c348dd22 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -465,6 +465,7 @@ export const en = { }, tabs: { loading: "Loading...", + modified: "Unsaved changes", loadingAgentTitle: "Loading agent title", emptyPane: "No tabs in this pane.", fallback: { @@ -534,6 +535,12 @@ export const en = { close: "Close", cancel: "Cancel", archive: "Archive", + unsavedTitle: "Unsaved changes", + unsavedMessage: + "This tab has changes that have not been saved. Closing it will discard the draft.", + closeWithoutSaving: "Close without saving", + closePaneTitle: "Close pane?", + bulkUnsaved: "{{count}} tab(s) have unsaved changes. Closing will discard those drafts.", closeTerminalTitle: "Close terminal?", closeTerminalMessage: "Any running process in this terminal will be stopped immediately.", archiveRunningAgentTitle: "Archive running agent?", @@ -1457,6 +1464,25 @@ export const en = { binaryPreviewUnavailable: "Binary preview unavailable", failedToLoad: "Failed to load file", failedToLoadPreview: "Failed to load file preview", + editor: { + fileSize: "File size {{size}}", + lines: "{{count}} lines", + editorStatus: "Editor status {{status}}", + unsavedChanges: "Unsaved changes", + saving: "Saving...", + saveFailed: "Save failed", + changedOnDisk: "Changed on disk", + vimMode: "Vim mode {{mode}}", + cursor: "Line {{line}}, column {{column}}", + preview: "Preview", + source: "Source", + unavailableTitle: "File unavailable on disk", + conflictDescription: "The local buffer was preserved. Choose which version to keep.", + overwrite: "Overwrite", + reload: "Reload", + reloadTitle: "Reload from disk?", + reloadMessage: "Your local changes will be lost.", + }, }, diff: { changesLabel: "Changes", @@ -1552,12 +1578,18 @@ export const en = { sections: { general: "General", appearance: "Appearance", + editor: "Editor", shortcuts: "Shortcuts", integrations: "Integrations", permissions: "Permissions", diagnostics: "Diagnostics", about: "About", }, + editor: { + title: "Editor", + vimKeybindings: "Vim keybindings", + vimHint: "Applies to source files on web and desktop.", + }, hostSections: { connections: "Connections", agents: "Agents", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 73bdc0c7f..37a1daffb 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -470,6 +470,7 @@ export const es: TranslationResources = { }, tabs: { loading: "Cargando...", + modified: "Cambios sin guardar", loadingAgentTitle: "Título del agente de carga", emptyPane: "No hay pestañas en este panel.", fallback: { @@ -537,6 +538,13 @@ export const es: TranslationResources = { failedToReloadAgent: "No se pudo recargar el agente", }, confirmations: { + unsavedTitle: "Cambios sin guardar", + unsavedMessage: + "Esta pestaña tiene cambios sin guardar. Al cerrarla se descartará el borrador.", + closeWithoutSaving: "Cerrar sin guardar", + closePaneTitle: "¿Cerrar panel?", + bulkUnsaved: + "{{count}} pestaña(s) tienen cambios sin guardar. Al cerrar se descartarán esos borradores.", close: "Cerca", cancel: "Cancelar", archive: "Archivo", @@ -1488,6 +1496,25 @@ export const es: TranslationResources = { binaryPreviewUnavailable: "Vista previa binaria no disponible", failedToLoad: "No se pudo cargar el archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo", + editor: { + fileSize: "Tamaño {{size}}", + lines: "{{count}} líneas", + editorStatus: "Estado del editor: {{status}}", + unsavedChanges: "Cambios sin guardar", + saving: "Guardando...", + saveFailed: "Error al guardar", + changedOnDisk: "Cambiado en disco", + vimMode: "Modo Vim {{mode}}", + cursor: "Línea {{line}}, columna {{column}}", + preview: "Vista previa", + source: "Código fuente", + unavailableTitle: "Archivo no disponible en disco", + conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.", + overwrite: "Sobrescribir", + reload: "Recargar", + reloadTitle: "¿Recargar desde el disco?", + reloadMessage: "Se perderán tus cambios locales.", + }, }, diff: { changesLabel: "Cambios", @@ -1583,12 +1610,18 @@ export const es: TranslationResources = { sections: { general: "General", appearance: "Apariencia", + editor: "Editor", shortcuts: "Atajos", integrations: "Integraciones", permissions: "Permisos", diagnostics: "Diagnóstico", about: "Acerca de", }, + editor: { + title: "Editor", + vimKeybindings: "Atajos de Vim", + vimHint: "Se aplica a archivos fuente en web y escritorio.", + }, hostSections: { connections: "Conexiones", agents: "Agents", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c3b58ff75..5d25d31ad 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -470,6 +470,7 @@ export const fr: TranslationResources = { }, tabs: { loading: "Chargement...", + modified: "Modifications non enregistrées", loadingAgentTitle: "Titre d'agent de chargement", emptyPane: "Aucun onglet dans ce volet.", fallback: { @@ -537,6 +538,13 @@ export const fr: TranslationResources = { failedToReloadAgent: "Échec du rechargement de l'agent", }, confirmations: { + unsavedTitle: "Modifications non enregistrées", + unsavedMessage: + "Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.", + closeWithoutSaving: "Fermer sans enregistrer", + closePaneTitle: "Fermer le volet?", + bulkUnsaved: + "{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.", close: "Fermer", cancel: "Annuler", archive: "Archive", @@ -1491,6 +1499,25 @@ export const fr: TranslationResources = { binaryPreviewUnavailable: "Aperçu binaire indisponible", failedToLoad: "Échec du chargement du fichier", failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", + editor: { + fileSize: "Taille {{size}}", + lines: "{{count}} lignes", + editorStatus: "État de l’éditeur : {{status}}", + unsavedChanges: "Modifications non enregistrées", + saving: "Enregistrement...", + saveFailed: "Échec de l’enregistrement", + changedOnDisk: "Modifié sur le disque", + vimMode: "Mode Vim {{mode}}", + cursor: "Ligne {{line}}, colonne {{column}}", + preview: "Aperçu", + source: "Source", + unavailableTitle: "Fichier indisponible sur le disque", + conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", + overwrite: "Écraser", + reload: "Recharger", + reloadTitle: "Recharger depuis le disque ?", + reloadMessage: "Vos modifications locales seront perdues.", + }, }, diff: { changesLabel: "Modifications", @@ -1586,12 +1613,18 @@ export const fr: TranslationResources = { sections: { general: "Général", appearance: "Apparence", + editor: "Éditeur", shortcuts: "Raccourcis", integrations: "Intégrations", permissions: "Autorisations", diagnostics: "Diagnostic", about: "À propos", }, + editor: { + title: "Éditeur", + vimKeybindings: "Raccourcis Vim", + vimHint: "S’applique aux fichiers source sur le web et le bureau.", + }, hostSections: { connections: "Relations", agents: "Agents", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index b7528bf08..3be918ea0 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -470,6 +470,7 @@ export const ja: TranslationResources = { }, tabs: { loading: "読み込み中...", + modified: "未保存の変更", loadingAgentTitle: "エージェントタイトルを読み込み中", emptyPane: "このペインにタブがありません。", fallback: { @@ -537,6 +538,11 @@ export const ja: TranslationResources = { failedToReloadAgent: "エージェントの再読み込みに失敗しました", }, confirmations: { + unsavedTitle: "未保存の変更", + unsavedMessage: "このタブには未保存の変更があります。閉じると下書きが破棄されます。", + closeWithoutSaving: "保存せずに閉じる", + closePaneTitle: "ペインを閉じますか?", + bulkUnsaved: "{{count}} 個のタブに未保存の変更があります。閉じると下書きが破棄されます。", close: "閉じる", cancel: "キャンセル", archive: "アーカイブ", @@ -1463,6 +1469,25 @@ export const ja: TranslationResources = { binaryPreviewUnavailable: "バイナリプレビューが利用できません", failedToLoad: "ファイルの読み込みに失敗しました", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", + editor: { + fileSize: "ファイルサイズ {{size}}", + lines: "{{count}} 行", + editorStatus: "エディターの状態 {{status}}", + unsavedChanges: "未保存の変更", + saving: "保存中...", + saveFailed: "保存に失敗しました", + changedOnDisk: "ディスク上で変更されました", + vimMode: "Vim モード {{mode}}", + cursor: "{{line}} 行、{{column}} 列", + preview: "プレビュー", + source: "ソース", + unavailableTitle: "ディスク上のファイルを利用できません", + conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。", + overwrite: "上書き", + reload: "再読み込み", + reloadTitle: "ディスクから再読み込みしますか?", + reloadMessage: "ローカルの変更は失われます。", + }, }, diff: { changesLabel: "変更", @@ -1558,12 +1583,18 @@ export const ja: TranslationResources = { sections: { general: "一般", appearance: "外観", + editor: "エディター", shortcuts: "ショートカット", integrations: "連携", permissions: "権限", diagnostics: "診断", about: "アプリ情報", }, + editor: { + title: "エディター", + vimKeybindings: "Vim キーバインド", + vimHint: "Web とデスクトップのソースファイルに適用されます。", + }, hostSections: { connections: "接続", agents: "エージェント", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index d474e3019..f12ad621b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -470,6 +470,7 @@ export const ptBR: TranslationResources = { }, tabs: { loading: "Carregando...", + modified: "Alterações não salvas", loadingAgentTitle: "Carregando título do agente", emptyPane: "Nenhuma aba neste painel.", fallback: { @@ -536,6 +537,12 @@ export const ptBR: TranslationResources = { failedToReloadAgent: "Falha ao recarregar agente", }, confirmations: { + unsavedTitle: "Alterações não salvas", + unsavedMessage: "Esta aba tem alterações não salvas. Fechá-la descartará o rascunho.", + closeWithoutSaving: "Fechar sem salvar", + closePaneTitle: "Fechar painel?", + bulkUnsaved: + "{{count}} aba(s) têm alterações não salvas. Fechar descartará esses rascunhos.", close: "Fechar", cancel: "Cancelar", archive: "Arquivar", @@ -1475,6 +1482,25 @@ export const ptBR: TranslationResources = { binaryPreviewUnavailable: "Prévia binária indisponível", failedToLoad: "Falha ao carregar arquivo", failedToLoadPreview: "Falha ao carregar prévia do arquivo", + editor: { + fileSize: "Tamanho {{size}}", + lines: "{{count}} linhas", + editorStatus: "Status do editor: {{status}}", + unsavedChanges: "Alterações não salvas", + saving: "Salvando...", + saveFailed: "Falha ao salvar", + changedOnDisk: "Alterado no disco", + vimMode: "Modo Vim {{mode}}", + cursor: "Linha {{line}}, coluna {{column}}", + preview: "Prévia", + source: "Fonte", + unavailableTitle: "Arquivo indisponível no disco", + conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.", + overwrite: "Sobrescrever", + reload: "Recarregar", + reloadTitle: "Recarregar do disco?", + reloadMessage: "Suas alterações locais serão perdidas.", + }, }, diff: { changesLabel: "Alterações", @@ -1570,12 +1596,18 @@ export const ptBR: TranslationResources = { sections: { general: "Geral", appearance: "Aparência", + editor: "Editor", shortcuts: "Atalhos", integrations: "Integrações", permissions: "Permissões", diagnostics: "Diagnósticos", about: "Sobre", }, + editor: { + title: "Editor", + vimKeybindings: "Atalhos do Vim", + vimHint: "Aplica-se a arquivos-fonte na web e no desktop.", + }, hostSections: { connections: "Conexões", agents: "Agentes", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4ef336f8e..afb7bf2de 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -470,6 +470,7 @@ export const ru: TranslationResources = { }, tabs: { loading: "Загрузка...", + modified: "Несохранённые изменения", loadingAgentTitle: "Название агента загрузки", emptyPane: "На этой панели нет вкладок.", fallback: { @@ -536,6 +537,13 @@ export const ru: TranslationResources = { failedToReloadAgent: "Не удалось перезагрузить агент", }, confirmations: { + unsavedTitle: "Несохранённые изменения", + unsavedMessage: + "В этой вкладке есть несохранённые изменения. При закрытии черновик будет удалён.", + closeWithoutSaving: "Закрыть без сохранения", + closePaneTitle: "Закрыть панель?", + bulkUnsaved: + "В {{count}} вкладках есть несохранённые изменения. При закрытии черновики будут удалены.", close: "Закрывать", cancel: "Отмена", archive: "Архив", @@ -1480,6 +1488,25 @@ export const ru: TranslationResources = { binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.", failedToLoad: "Не удалось загрузить файл", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", + editor: { + fileSize: "Размер файла {{size}}", + lines: "Строк: {{count}}", + editorStatus: "Состояние редактора: {{status}}", + unsavedChanges: "Несохранённые изменения", + saving: "Сохранение...", + saveFailed: "Не удалось сохранить", + changedOnDisk: "Изменён на диске", + vimMode: "Режим Vim {{mode}}", + cursor: "Строка {{line}}, столбец {{column}}", + preview: "Просмотр", + source: "Исходник", + unavailableTitle: "Файл недоступен на диске", + conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.", + overwrite: "Перезаписать", + reload: "Перезагрузить", + reloadTitle: "Перезагрузить с диска?", + reloadMessage: "Локальные изменения будут потеряны.", + }, }, diff: { changesLabel: "Изменения", @@ -1575,12 +1602,18 @@ export const ru: TranslationResources = { sections: { general: "Общий", appearance: "Появление", + editor: "Редактор", shortcuts: "Ярлыки", integrations: "Интеграции", permissions: "Разрешения", diagnostics: "Диагностика", about: "О", }, + editor: { + title: "Редактор", + vimKeybindings: "Клавиши Vim", + vimHint: "Применяется к исходным файлам в веб- и настольной версии.", + }, hostSections: { connections: "Соединения", agents: "Agents", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 347d4585d..a3ca8dcf3 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -466,6 +466,7 @@ export const zhCN: TranslationResources = { }, tabs: { loading: "正在加载...", + modified: "未保存的更改", loadingAgentTitle: "正在加载 Agent 标题", emptyPane: "此窗格中没有标签。", fallback: { @@ -532,6 +533,11 @@ export const zhCN: TranslationResources = { failedToReloadAgent: "重新加载 Agent 失败", }, confirmations: { + unsavedTitle: "未保存的更改", + unsavedMessage: "此标签页有尚未保存的更改。关闭将丢弃草稿。", + closeWithoutSaving: "不保存并关闭", + closePaneTitle: "关闭面板?", + bulkUnsaved: "{{count}} 个标签页有未保存的更改。关闭将丢弃这些草稿。", close: "关闭", cancel: "取消", archive: "归档", @@ -1428,6 +1434,25 @@ export const zhCN: TranslationResources = { binaryPreviewUnavailable: "二进制预览不可用", failedToLoad: "加载文件失败", failedToLoadPreview: "加载文件预览失败", + editor: { + fileSize: "文件大小 {{size}}", + lines: "{{count}} 行", + editorStatus: "编辑器状态 {{status}}", + unsavedChanges: "未保存的更改", + saving: "正在保存...", + saveFailed: "保存失败", + changedOnDisk: "磁盘上的文件已更改", + vimMode: "Vim 模式 {{mode}}", + cursor: "第 {{line}} 行,第 {{column}} 列", + preview: "预览", + source: "源代码", + unavailableTitle: "磁盘上的文件不可用", + conflictDescription: "本地内容已保留。请选择要保留的版本。", + overwrite: "覆盖", + reload: "重新加载", + reloadTitle: "从磁盘重新加载?", + reloadMessage: "本地更改将丢失。", + }, }, diff: { changesLabel: "更改", @@ -1523,12 +1548,18 @@ export const zhCN: TranslationResources = { sections: { general: "通用", appearance: "外观", + editor: "编辑器", shortcuts: "快捷键", integrations: "集成", permissions: "权限", diagnostics: "诊断", about: "关于", }, + editor: { + title: "编辑器", + vimKeybindings: "Vim 键位", + vimHint: "适用于网页和桌面端的源文件。", + }, hostSections: { connections: "连接", agents: "Agents", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 990527a7b..edde7a42b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -326,6 +326,7 @@ function useAgentPanelDescriptor( return { label: label ?? "", subtitle: `${formatProviderLabel(provider)} agent`, + tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon, statusBucket: descriptorState.status diff --git a/packages/app/src/panels/browser-panel.tsx b/packages/app/src/panels/browser-panel.tsx index 82ac2da9c..0f6b4a437 100644 --- a/packages/app/src/panels/browser-panel.tsx +++ b/packages/app/src/panels/browser-panel.tsx @@ -42,10 +42,12 @@ function useBrowserPanelDescriptor(target: { const browser = useBrowserStore((state) => state.browsersById[target.browserId] ?? null); const url = browser?.url ?? "https://example.com"; const icon = createBrowserTabIcon(browser?.faviconUrl ?? null); + const label = getBrowserLabel({ title: browser?.title ?? "", url }); return { - label: getBrowserLabel({ title: browser?.title ?? "", url }), + label, subtitle: url, + tooltip: url || label, titleState: "ready", icon, statusBucket: browser?.isLoading ? "running" : null, diff --git a/packages/app/src/panels/commit-diff-panel.tsx b/packages/app/src/panels/commit-diff-panel.tsx index d34057e4d..8cd5ef679 100644 --- a/packages/app/src/panels/commit-diff-panel.tsx +++ b/packages/app/src/panels/commit-diff-panel.tsx @@ -132,6 +132,7 @@ function useCommitDiffPanelDescriptor( return { label: target.sha.slice(0, 7), subtitle: t("panels.diff.commitSubtitle"), + tooltip: target.sha, titleState: "ready", icon: ThemedGitCommitHorizontal, statusBucket: null, diff --git a/packages/app/src/panels/draft-panel-descriptor.ts b/packages/app/src/panels/draft-panel-descriptor.ts index 15be856af..645fd9ade 100644 --- a/packages/app/src/panels/draft-panel-descriptor.ts +++ b/packages/app/src/panels/draft-panel-descriptor.ts @@ -14,6 +14,7 @@ export function buildDraftPanelDescriptor(input: { return { label: creatingLabel, subtitle: i18n.t("panels.draft.creatingAgent"), + tooltip: creatingLabel, titleState: "ready", icon, statusBucket: "running", @@ -23,6 +24,7 @@ export function buildDraftPanelDescriptor(input: { return { label: newAgentLabel, subtitle: newAgentLabel, + tooltip: newAgentLabel, titleState: "ready", icon, statusBucket: null, diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index d457fd1b9..13d8e417f 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -1,11 +1,12 @@ import { Text, View } from "react-native"; -import { FileText } from "lucide-react-native"; +import { useMemo } from "react"; import invariant from "tiny-invariant"; import { useTranslation } from "react-i18next"; -import { FilePane } from "@/components/file-pane"; +import { FilePane } from "@/file-pane/pane"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelRegistration } from "@/panels/panel-registry"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { createMaterialFileIcon } from "@/components/material-file-icon"; const CENTERED_PADDED_STYLE = { flex: 1, @@ -16,11 +17,13 @@ const CENTERED_PADDED_STYLE = { function useFilePanelDescriptor(target: { kind: "file"; path: string }) { const fileName = target.path.split("/").findLast(Boolean) ?? target.path; + const icon = useMemo(() => createMaterialFileIcon(fileName), [fileName]); return { label: fileName, subtitle: target.path, + tooltip: target.path, titleState: "ready" as const, - icon: FileText, + icon, statusBucket: null, }; } diff --git a/packages/app/src/panels/panel-instance-attributes.test.ts b/packages/app/src/panels/panel-instance-attributes.test.ts new file mode 100644 index 000000000..6ff7f181e --- /dev/null +++ b/packages/app/src/panels/panel-instance-attributes.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { + getPanelInstanceAttributes, + setPanelInstanceAttributes, + subscribePanelInstanceAttributes, +} from "./panel-instance-attributes"; + +describe("panel instance attributes", () => { + test("keeps runtime attributes isolated by workspace and tab", () => { + const first = { serverId: "server", workspaceId: "one", tabId: "tab" }; + const second = { serverId: "server", workspaceId: "two", tabId: "tab" }; + + setPanelInstanceAttributes(first, { modified: true }); + + expect(getPanelInstanceAttributes(first)).toEqual({ modified: true }); + expect(getPanelInstanceAttributes(second)).toEqual({ modified: false }); + + setPanelInstanceAttributes(first, { modified: false }); + }); + + test("notifies subscribers only when attributes change", () => { + const identity = { serverId: "server", workspaceId: "workspace", tabId: "observed" }; + let notifications = 0; + const unsubscribe = subscribePanelInstanceAttributes(identity, () => { + notifications += 1; + }); + + setPanelInstanceAttributes(identity, { modified: true }); + setPanelInstanceAttributes(identity, { modified: true }); + setPanelInstanceAttributes(identity, { modified: false }); + + expect(notifications).toBe(2); + unsubscribe(); + }); +}); diff --git a/packages/app/src/panels/panel-instance-attributes.ts b/packages/app/src/panels/panel-instance-attributes.ts new file mode 100644 index 000000000..6ef49b0e4 --- /dev/null +++ b/packages/app/src/panels/panel-instance-attributes.ts @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { usePaneContext } from "@/panels/pane-context"; + +export interface PanelInstanceIdentity { + serverId: string; + workspaceId: string; + tabId: string; +} + +export interface PanelInstanceAttributes { + modified: boolean; + suspendPendingSave?: () => () => void; +} + +const DEFAULT_ATTRIBUTES: PanelInstanceAttributes = { modified: false }; +const attributesByPanel = new Map(); +const listenersByPanel = new Map void>>(); +const allListeners = new Set<() => void>(); +let attributesRevision = 0; + +export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string { + return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`; +} + +export function getPanelInstanceAttributes( + identity: PanelInstanceIdentity, +): PanelInstanceAttributes { + return attributesByPanel.get(buildPanelInstanceKey(identity)) ?? DEFAULT_ATTRIBUTES; +} + +export function setPanelInstanceAttributes( + identity: PanelInstanceIdentity, + attributes: PanelInstanceAttributes, +): void { + const key = buildPanelInstanceKey(identity); + const previous = attributesByPanel.get(key) ?? DEFAULT_ATTRIBUTES; + if ( + previous.modified === attributes.modified && + previous.suspendPendingSave === attributes.suspendPendingSave + ) { + return; + } + if (attributes.modified) attributesByPanel.set(key, attributes); + else attributesByPanel.delete(key); + attributesRevision += 1; + for (const listener of listenersByPanel.get(key) ?? []) listener(); + for (const listener of allListeners) listener(); +} + +export function useModifiedPanelTabIds(input: { + serverId: string; + workspaceId: string; + tabIds: string[]; +}): Set { + const revision = useSyncExternalStore( + useCallback((listener: () => void) => { + allListeners.add(listener); + return () => allListeners.delete(listener); + }, []), + () => attributesRevision, + () => attributesRevision, + ); + return useMemo(() => { + void revision; + return new Set( + input.tabIds.filter( + (tabId) => + getPanelInstanceAttributes({ + serverId: input.serverId, + workspaceId: input.workspaceId, + tabId, + }).modified, + ), + ); + }, [input.serverId, input.tabIds, input.workspaceId, revision]); +} + +export function subscribePanelInstanceAttributes( + identity: PanelInstanceIdentity, + listener: () => void, +): () => void { + const key = buildPanelInstanceKey(identity); + const listeners = listenersByPanel.get(key) ?? new Set<() => void>(); + listeners.add(listener); + listenersByPanel.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) listenersByPanel.delete(key); + }; +} + +export function usePanelInstanceAttributes({ + serverId, + workspaceId, + tabId, +}: PanelInstanceIdentity): PanelInstanceAttributes { + const subscribe = useCallback( + (listener: () => void) => + subscribePanelInstanceAttributes({ serverId, workspaceId, tabId }, listener), + [serverId, tabId, workspaceId], + ); + const getSnapshot = useCallback( + () => getPanelInstanceAttributes({ serverId, workspaceId, tabId }), + [serverId, tabId, workspaceId], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function usePublishPanelInstanceAttributes(attributes: PanelInstanceAttributes): void { + const { serverId, workspaceId, tabId } = usePaneContext(); + const modified = attributes.modified; + const suspendPendingSave = attributes.suspendPendingSave; + useEffect(() => { + const identity = { serverId, workspaceId, tabId }; + setPanelInstanceAttributes(identity, { modified, suspendPendingSave }); + return () => setPanelInstanceAttributes(identity, DEFAULT_ATTRIBUTES); + }, [modified, serverId, suspendPendingSave, tabId, workspaceId]); +} diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts index 3bea9167b..5fa16e5aa 100644 --- a/packages/app/src/panels/panel-registry.ts +++ b/packages/app/src/panels/panel-registry.ts @@ -10,6 +10,7 @@ export interface PanelIconProps { export interface PanelDescriptor { label: string; subtitle: string; + tooltip: string; titleState: "ready" | "loading"; icon: ComponentType; statusBucket: SidebarStateBucket | null; @@ -18,6 +19,7 @@ export interface PanelDescriptor { export interface PanelDescriptorContext { serverId: string; workspaceId: string; + tabId: string; } export interface PanelRegistration< @@ -29,10 +31,6 @@ export interface PanelRegistration< target: Extract, context: PanelDescriptorContext, ): PanelDescriptor; - confirmClose?( - target: Extract, - context: PanelDescriptorContext, - ): Promise; } const panelRegistry = new Map(); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 2bff0118c..d77fa3225 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -49,6 +49,7 @@ function useProviderSubagentDescriptor( return { label, subtitle: `${formatProviderLabel(provider)} subagent`, + tooltip: label, titleState: descriptor ? "ready" : "loading", icon: getProviderIcon(provider), statusBucket: descriptor diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 255cf35a2..3ca919a60 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -37,6 +37,7 @@ function useSetupPanelDescriptor( return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.completed"), + tooltip: t("workspace.setup.descriptor.completed"), titleState: "ready", icon: CheckCircle2, statusBucket: null, @@ -47,6 +48,7 @@ function useSetupPanelDescriptor( return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.failed"), + tooltip: t("workspace.setup.descriptor.failed"), titleState: "ready", icon: CircleAlert, statusBucket: null, @@ -56,6 +58,7 @@ function useSetupPanelDescriptor( return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.workspace"), + tooltip: t("workspace.setup.descriptor.workspace"), titleState: "ready", icon: SquareTerminal, statusBucket: snapshot?.status === "running" ? "running" : null, diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 8d406b66e..29f116a9a 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -62,12 +62,14 @@ function useTerminalPanelDescriptor( ); const terminal = terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; + const label = + trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? + t("workspace.tabs.fallback.terminal"); return { - label: - trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? - t("workspace.tabs.fallback.terminal"), + label, subtitle: t("workspace.tabs.fallback.terminal"), + tooltip: label, titleState: "ready", icon: Terminal, statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity), diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index dcbaf1fa3..04cbb3958 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -33,6 +33,7 @@ import { Plus, FolderGit2, SquareTerminal, + Code2, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -65,6 +66,7 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section"; +import { EditorSection } from "@/screens/settings/editor-section"; import { Button } from "@/components/ui/button"; import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; @@ -135,6 +137,7 @@ interface SidebarSectionItem { const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", labelKey: "settings.sections.general", icon: Settings }, { id: "appearance", labelKey: "settings.sections.appearance", icon: Palette }, + { id: "editor", labelKey: "settings.sections.editor", icon: Code2 }, { id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", @@ -1403,6 +1406,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti ); case "appearance": return ; + case "editor": + return ; case "shortcuts": return isDesktopApp ? : null; case "integrations": diff --git a/packages/app/src/screens/settings/editor-section.tsx b/packages/app/src/screens/settings/editor-section.tsx new file mode 100644 index 000000000..862ff607b --- /dev/null +++ b/packages/app/src/screens/settings/editor-section.tsx @@ -0,0 +1,33 @@ +import { Switch, Text, View } from "react-native"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppSettings } from "@/hooks/use-settings"; +import { SettingsSection } from "./settings-section"; +import { settingsStyles } from "@/styles/settings"; + +export function EditorSection() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const handleChange = useCallback( + (vimKeybindings: boolean) => void updateSettings({ vimKeybindings }), + [updateSettings], + ); + return ( + + + + + {t("settings.editor.vimKeybindings")} + {t("settings.editor.vimHint")} + + + + + + ); +} diff --git a/packages/app/src/screens/workspace/use-mounted-tab-set.test.ts b/packages/app/src/screens/workspace/use-mounted-tab-set.test.ts index 1b0a3df45..3b0c49e63 100644 --- a/packages/app/src/screens/workspace/use-mounted-tab-set.test.ts +++ b/packages/app/src/screens/workspace/use-mounted-tab-set.test.ts @@ -49,4 +49,23 @@ describe("useMountedTabSet", () => { rerender({ activeTabId: "third" }); expect(mountedIds(result)).toEqual(["third", "second"]); }); + + it("keeps retained panels mounted beyond the normal cap", () => { + const { result, rerender } = renderHook( + ({ activeTabId }) => + useMountedTabSet({ + activeTabId, + allTabIds: ["modified", "second", "third", "fourth"], + retainedTabIds: new Set(["modified"]), + cap: 2, + }), + { initialProps: { activeTabId: "modified" } }, + ); + + rerender({ activeTabId: "second" }); + rerender({ activeTabId: "third" }); + rerender({ activeTabId: "fourth" }); + + expect(mountedIds(result)).toEqual(["fourth", "modified"]); + }); }); diff --git a/packages/app/src/screens/workspace/use-mounted-tab-set.ts b/packages/app/src/screens/workspace/use-mounted-tab-set.ts index 60256b4a6..3c4d3cd39 100644 --- a/packages/app/src/screens/workspace/use-mounted-tab-set.ts +++ b/packages/app/src/screens/workspace/use-mounted-tab-set.ts @@ -4,6 +4,7 @@ interface UseMountedTabSetInput { activeTabId: string | null; allTabIds: string[]; cap: number; + retainedTabIds?: Set; } interface UseMountedTabSetResult { @@ -15,6 +16,7 @@ interface DeriveMountedTabLruInput { availableTabIds: Set; cap: number; previousLru: string[]; + retainedTabIds: Set; } function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] { @@ -25,7 +27,7 @@ function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] { } function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] { - const { activeTabId, availableTabIds, cap, previousLru } = input; + const { activeTabId, availableTabIds, cap, previousLru, retainedTabIds } = input; const maxSize = Math.max(1, cap); const next: string[] = []; @@ -33,6 +35,10 @@ function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] { next.push(activeTabId); } + for (const tabId of retainedTabIds) { + if (tabId !== activeTabId && availableTabIds.has(tabId)) next.push(tabId); + } + for (const tabId of previousLru) { if (next.length >= maxSize) break; if (tabId !== activeTabId && availableTabIds.has(tabId)) { @@ -57,8 +63,9 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet availableTabIds, cap, previousLru: committedLruRef.current, + retainedTabIds: input.retainedTabIds ?? new Set(), }), - [activeTabId, availableTabIds, cap], + [activeTabId, availableTabIds, cap, input.retainedTabIds], ); const mountedTabIds = useMemo(() => new Set(mountedTabLru), [mountedTabLru]); diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bee528e26..620e14fcf 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -553,12 +553,14 @@ function TabChip({ onCloseTab: (tabId: string) => Promise | void; dragHandleProps: DraggableListDragHandleProps | undefined; }) { + const { t } = useTranslation(); const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), ); const [hovered, setHovered] = useState(false); const isHighlighted = isActive || hovered || isCloseHovered; + const showTrailingAffordance = showCloseButton || presentation.modified; const closeButtonDragBlockers = isWeb ? ({ onPointerDown: (event: { stopPropagation?: () => void }) => { @@ -630,16 +632,19 @@ function TabChip({ [isFocused], ); const tabLabelSkeletonStyle = useMemo( - () => [styles.tabLabelSkeleton, showCloseButton && styles.tabLabelSkeletonWithCloseButton], - [showCloseButton], + () => [ + styles.tabLabelSkeleton, + showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton, + ], + [showTrailingAffordance], ); const tabLabelStyle = useMemo( () => [ styles.tabLabel, isHighlighted && styles.tabLabelActive, - showCloseButton && styles.tabLabelWithCloseButton, + showTrailingAffordance && styles.tabLabelWithCloseButton, ], - [isHighlighted, showCloseButton], + [isHighlighted, showTrailingAffordance], ); return ( @@ -672,7 +677,7 @@ function TabChip({ tabLabelStyle={tabLabelStyle} /> - {showCloseButton ? ( + {showTrailingAffordance ? ( - {({ hovered: closeHovered, pressed }) => - isClosingTab ? ( - { + const highlighted = closeHovered || pressed; + if (isClosingTab) { + return ( + + ); + } + if (highlighted || !presentation.modified) { + return ( + + ); + } + return ( + - ) : ( - - ) - } + ); + }} ) : null} - + {tab.target.kind === "agent" ? ( {tooltipLabel} @@ -1161,7 +1181,7 @@ function ResolvedDesktopTabChip({ const tooltipLabel = presentation.titleState === "loading" ? t("workspace.tabs.loadingAgentTitle") - : presentation.label; + : presentation.tooltip; return ( @@ -1331,6 +1351,12 @@ const styles = StyleSheet.create((theme) => ({ tabCloseButtonActive: { backgroundColor: theme.colors.surface3, }, + tabModifiedDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundMuted, + }, newTabActionButton: { width: 22, height: 22, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 828eef184..bfea0a7d3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -168,6 +168,10 @@ import { closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; import { resolveCloseAgentTabPolicy } from "@/subagents"; +import { + getPanelInstanceAttributes, + useModifiedPanelTabIds, +} from "@/panels/panel-instance-attributes"; import { findAdjacentPane } from "@/utils/split-navigation"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; @@ -2661,11 +2665,8 @@ function WorkspaceScreenContent({ [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t], ); - const handleCloseDraftOrFileTab = useCallback( - function handleCloseDraftOrFileTab(input: { - tabId: string; - target?: WorkspaceTabTarget | null; - }) { + const handleClosePassiveTab = useCallback( + function handleClosePassiveTab(input: { tabId: string; target?: WorkspaceTabTarget | null }) { setHoveredCloseTabKey((current) => (current === input.tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target }); @@ -2674,12 +2675,37 @@ function WorkspaceScreenContent({ [closeWorkspaceTabWithCleanup, persistenceKey], ); + const confirmDiscardModifiedTab = useCallback( + async (tabId: string): Promise => { + const attributes = getPanelInstanceAttributes({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId, + }); + if (!attributes.modified) return true; + const resumePendingSave = attributes.suspendPendingSave?.(); + const confirmed = await confirmDialog({ + title: t("workspace.tabs.confirmations.unsavedTitle"), + message: t("workspace.tabs.confirmations.unsavedMessage"), + confirmLabel: t("workspace.tabs.confirmations.closeWithoutSaving"), + cancelLabel: t("workspace.tabs.confirmations.cancel"), + destructive: true, + }); + if (!confirmed) resumePendingSave?.(); + return confirmed; + }, + [normalizedServerId, normalizedWorkspaceId, t], + ); + const handleCloseTabById = useCallback( async (tabId: string) => { const tab = allTabDescriptorsById.get(tabId); if (!tab) { return; } + if (!(await confirmDiscardModifiedTab(tabId))) { + return; + } if (tab.target.kind === "terminal") { await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId }); return; @@ -2688,9 +2714,15 @@ function WorkspaceScreenContent({ await handleCloseAgentTab({ tabId, agentId: tab.target.agentId }); return; } - handleCloseDraftOrFileTab({ tabId, target: tab.target }); + handleClosePassiveTab({ tabId, target: tab.target }); }, - [allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab], + [ + allTabDescriptorsById, + confirmDiscardModifiedTab, + handleCloseAgentTab, + handleClosePassiveTab, + handleCloseTerminalTab, + ], ); const handleCopyAgentId = useCallback( @@ -2834,9 +2866,21 @@ function WorkspaceScreenContent({ } const groups = classifyBulkClosableTabs(tabsToClose); + const modifiedCount = tabsToClose.filter( + (tab) => + getPanelInstanceAttributes({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: tab.tabId, + }).modified, + ).length; + const bulkMessage = buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels); const confirmed = await confirmDialog({ title, - message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels), + message: + modifiedCount > 0 + ? `${bulkMessage}\n\n${t("workspace.tabs.confirmations.bulkUnsaved", { count: modifiedCount })}` + : bulkMessage, confirmLabel: t("workspace.tabs.confirmations.close"), cancelLabel: t("workspace.tabs.confirmations.cancel"), destructive: true, @@ -2869,6 +2913,8 @@ function WorkspaceScreenContent({ client, closeTab, closeWorkspaceTabWithCleanup, + normalizedServerId, + normalizedWorkspaceId, persistenceKey, t, ], @@ -3055,12 +3101,15 @@ function WorkspaceScreenContent({ } if (action.id === "workspace.pane.close") { - for (const tabId of focusedPane.tabIds) { - closeWorkspaceTabWithCleanup({ - tabId, - target: allTabDescriptorsById.get(tabId)?.target ?? null, - }); - } + const tabsToClose = focusedPane.tabIds.flatMap((tabId) => { + const tab = allTabDescriptorsById.get(tabId); + return tab ? [tab] : []; + }); + void handleBulkCloseTabs({ + tabsToClose, + title: t("workspace.tabs.confirmations.closePaneTitle"), + logLabel: "from pane close", + }); return true; } @@ -3068,14 +3117,15 @@ function WorkspaceScreenContent({ }, [ allTabDescriptorsById, - closeWorkspaceTabWithCleanup, focusWorkspacePane, + handleBulkCloseTabs, handleCreateDraftSplit, moveWorkspaceTabToPane, persistenceKey, focusedPaneTabState.activeTabId, focusedPaneTabState.pane, toggleFocusMode, + t, workspaceLayout, ], ); @@ -3210,10 +3260,16 @@ function WorkspaceScreenContent({ [focusedPaneTabState.pane], ); const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]); + const modifiedFocusedPaneTabIds = useModifiedPanelTabIds({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabIds: focusedPaneTabIds, + }); const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs); const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({ activeTabId, allTabIds: focusedPaneTabIds, + retainedTabIds: modifiedFocusedPaneTabIds, cap: 3, }); const mountedFocusedPaneTabIds = useMemo( diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index a421c2b2c..dea7fe017 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -12,12 +12,15 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import type { Theme } from "@/styles/theme"; +import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes"; export interface WorkspaceTabPresentation { key: string; kind: WorkspaceTabDescriptor["kind"]; label: string; subtitle: string; + tooltip: string; + modified: boolean; titleState: "ready" | "loading"; icon: React.ComponentType<{ size: number; color: string }>; statusBucket: SidebarStateBucket | null; @@ -72,7 +75,9 @@ function WorkspaceTabPresentationResolverInner({ const descriptor = registration.useDescriptor(tab.target as never, { serverId, workspaceId, + tabId: tab.tabId, }); + const attributes = usePanelInstanceAttributes({ serverId, workspaceId, tabId: tab.tabId }); const presentation = useMemo( () => ({ @@ -80,6 +85,8 @@ function WorkspaceTabPresentationResolverInner({ kind: tab.kind, label: descriptor.label, subtitle: descriptor.subtitle, + tooltip: descriptor.tooltip, + modified: attributes.modified, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, @@ -87,11 +94,13 @@ function WorkspaceTabPresentationResolverInner({ [ descriptor.icon, descriptor.label, + descriptor.tooltip, descriptor.statusBucket, descriptor.subtitle, descriptor.titleState, tab.key, tab.kind, + attributes.modified, ], ); @@ -208,6 +217,9 @@ export function WorkspaceTabOptionRow({ + {presentation.modified ? ( + + ) : null} {selected ? ( @@ -303,6 +315,12 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + optionModifiedDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundMuted, + }, optionTrailingAccessorySlot: { alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 9dd38c19b..1aaa59436 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -41,8 +41,11 @@ export interface SubagentsTrackProps { const SUBAGENTS_LIST_MAX_HEIGHT = 200; function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { + const data = buildSubagentRowPresentationData(row); return { - ...buildSubagentRowPresentationData(row), + ...data, + tooltip: data.label, + modified: false, icon: getProviderIcon(row.provider), }; } diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 717bc8296..79f78c229 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -488,6 +488,7 @@ export function resolveKnownHostRoute(input: { export const SETTINGS_SECTION_SLUGS = [ "general", "appearance", + "editor", "shortcuts", "integrations", "permissions", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index c0ecc10c4..50e686fad 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -26,6 +26,8 @@ import type { FileDownloadTokenResponse, FileUploadResponse, FileExplorerResponse, + FileVersion, + FileWriteResult, FetchAgentTimelineResponseMessage, AgentForkContextResponseMessage, GitSetupOptions, @@ -416,6 +418,7 @@ export interface FileReadResult { path: string; kind: LegacyFileExplorerFilePayload["kind"]; modifiedAt: string; + revision?: string; } export interface FileUploadInput { fileName: string; @@ -875,6 +878,7 @@ interface BinaryFileTransferState extends PendingBinaryFileRead { { opcode: typeof FileTransferOpcode.FileBegin } >["metadata"]["encoding"]; modifiedAt: string; + revision?: string; chunks: Uint8Array[]; } @@ -995,6 +999,7 @@ function legacyExplorerFileToBytes(file: LegacyFileExplorerFilePayload): FileRea path: file.path, kind: file.kind, modifiedAt: file.modifiedAt, + revision: file.revision, }; } @@ -1098,6 +1103,10 @@ export class DaemonClient { } >(); private terminalDirectorySubscriptions = new Map(); + private fileSubscriptions = new Map< + string, + { cwd: string; path: string; onUpdate: (version: FileVersion) => void } + >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map(); private activeBinaryFileTransfers = new Map(); @@ -1378,6 +1387,7 @@ export class DaemonClient { this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); + this.fileSubscriptions.clear(); this.lastServerInfoMessage = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); @@ -2269,6 +2279,22 @@ export class DaemonClient { } } + private resubscribeFileSubscriptions(): void { + for (const [subscriptionId, subscription] of this.fileSubscriptions) { + void this.sendCorrelatedSessionRequest({ + message: { + type: "fs.file.subscribe.request", + cwd: subscription.cwd, + path: subscription.path, + subscriptionId, + }, + responseType: "fs.file.subscribe.response", + }) + .then((payload) => subscription.onUpdate(payload.initial)) + .catch(() => undefined); + } + } + // ============================================================================ // Agent Lifecycle // ============================================================================ @@ -4074,6 +4100,52 @@ export class DaemonClient { } } + async subscribeFile( + input: { cwd: string; path: string }, + onUpdate: (version: FileVersion) => void, + ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { + const subscriptionId = this.createRequestId(); + this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate }); + try { + const payload = await this.sendCorrelatedSessionRequest({ + message: { + type: "fs.file.subscribe.request", + cwd: input.cwd, + path: input.path, + subscriptionId, + }, + responseType: "fs.file.subscribe.response", + }); + return { + initial: payload.initial, + unsubscribe: () => { + if (!this.fileSubscriptions.delete(subscriptionId)) return; + void this.sendCorrelatedSessionRequest({ + message: { type: "fs.file.unsubscribe.request", subscriptionId }, + responseType: "fs.file.unsubscribe.response", + }).catch(() => undefined); + }, + }; + } catch (error) { + this.fileSubscriptions.delete(subscriptionId); + throw error; + } + } + + async writeFile(input: { + cwd: string; + path: string; + content: string; + expectedModifiedAt: string; + expectedRevision?: string; + }): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + message: { type: "fs.file.write.request", ...input }, + responseType: "fs.file.write.response", + }); + return payload.result; + } + async uploadFile(input: FileUploadInput): Promise { const bytes = asUint8Array(input.bytes); if (!bytes) { @@ -5320,6 +5392,7 @@ export class DaemonClient { size: frame.metadata.size, encoding: frame.metadata.encoding, modifiedAt: frame.metadata.modifiedAt, + revision: frame.metadata.revision, chunks: [], }); return; @@ -5344,6 +5417,7 @@ export class DaemonClient { path: transfer.path, kind: binaryFileKind(transfer.mime, transfer.encoding), modifiedAt: transfer.modifiedAt, + revision: transfer.revision, }); this.handleSessionMessage({ type: "file_explorer_response", @@ -5523,6 +5597,7 @@ export class DaemonClient { this.startLivenessHeartbeat(); this.resubscribeCheckoutDiffSubscriptions(); this.resubscribeTerminalDirectorySubscriptions(); + this.resubscribeFileSubscriptions(); this.flushPendingSendQueue(); this.resolveConnect(); } @@ -5533,6 +5608,12 @@ export class DaemonClient { this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId); } + if (consumerMessage.type === "fs.file.update") { + this.fileSubscriptions + .get(consumerMessage.payload.subscriptionId) + ?.onUpdate(consumerMessage.payload.version); + } + if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { diff --git a/packages/highlight/package.json b/packages/highlight/package.json index ed86dd816..1165d83a8 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -27,7 +27,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@codemirror/language": "^6.12.3", + "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index d229df79c..16dea4b6c 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { isLanguageSupported, getSupportedExtensions, getParserForFile } from "../parsers.js"; +import { + isLanguageSupported, + getSupportedExtensions, + getParserForFile, + getLanguageForFile, +} from "../parsers.js"; describe("isLanguageSupported", () => { it("returns true for supported file extensions", () => { @@ -60,6 +65,13 @@ describe("getSupportedExtensions", () => { }); describe("getParserForFile", () => { + it("projects the parser retained by the editor language registry", () => { + for (const extension of getSupportedExtensions()) { + const filename = `source.${extension}`; + expect(getParserForFile(filename)).toBe(getLanguageForFile(filename)?.parser); + } + }); + it("returns a parser for supported files", () => { expect(getParserForFile("test.js")).not.toBeNull(); expect(getParserForFile("test.py")).not.toBeNull(); diff --git a/packages/highlight/src/highlighter.ts b/packages/highlight/src/highlighter.ts index 269b7cd6a..3929a2948 100644 --- a/packages/highlight/src/highlighter.ts +++ b/packages/highlight/src/highlighter.ts @@ -1,50 +1,8 @@ -import { highlightTree, tagHighlighter, tags } from "@lezer/highlight"; +import { highlightTree } from "@lezer/highlight"; import type { HighlightStyle, HighlightToken } from "./types.js"; import { getParserForFile } from "./parsers.js"; -const highlighter = tagHighlighter([ - { tag: tags.keyword, class: "keyword" }, - { tag: tags.controlKeyword, class: "keyword" }, - { tag: tags.operatorKeyword, class: "keyword" }, - { tag: tags.definitionKeyword, class: "keyword" }, - { tag: tags.moduleKeyword, class: "keyword" }, - { tag: tags.comment, class: "comment" }, - { tag: tags.lineComment, class: "comment" }, - { tag: tags.blockComment, class: "comment" }, - { tag: tags.docComment, class: "comment" }, - { tag: tags.string, class: "string" }, - { tag: tags.special(tags.string), class: "string" }, - { tag: tags.number, class: "number" }, - { tag: tags.integer, class: "number" }, - { tag: tags.float, class: "number" }, - { tag: tags.bool, class: "literal" }, - { tag: tags.null, class: "literal" }, - { tag: tags.function(tags.variableName), class: "function" }, - { tag: tags.function(tags.propertyName), class: "function" }, - { tag: tags.definition(tags.variableName), class: "definition" }, - { tag: tags.definition(tags.propertyName), class: "definition" }, - { tag: tags.definition(tags.function(tags.variableName)), class: "definition" }, - { tag: tags.className, class: "class" }, - { tag: tags.definition(tags.className), class: "class" }, - { tag: tags.typeName, class: "type" }, - { tag: tags.tagName, class: "tag" }, - { tag: tags.attributeName, class: "attribute" }, - { tag: tags.attributeValue, class: "string" }, - { tag: tags.propertyName, class: "property" }, - { tag: tags.variableName, class: "variable" }, - { tag: tags.local(tags.variableName), class: "variable" }, - { tag: tags.special(tags.variableName), class: "variable" }, - { tag: tags.operator, class: "operator" }, - { tag: tags.punctuation, class: "punctuation" }, - { tag: tags.bracket, class: "punctuation" }, - { tag: tags.separator, class: "punctuation" }, - { tag: tags.regexp, class: "regexp" }, - { tag: tags.escape, class: "escape" }, - { tag: tags.meta, class: "meta" }, - { tag: tags.heading, class: "heading" }, - { tag: tags.link, class: "link" }, - { tag: tags.url, class: "link" }, -]); +import { staticSyntaxHighlighter } from "./syntax-roles.js"; export function highlightCode(code: string, filename: string): HighlightToken[][] { const parser = getParserForFile(filename); @@ -64,7 +22,7 @@ export function highlightCode(code: string, filename: string): HighlightToken[][ // Build a map of character positions to styles const styleMap: Array = Array.from({ length: code.length }, () => null); - highlightTree(tree, highlighter, (from, to, classes) => { + highlightTree(tree, staticSyntaxHighlighter, (from, to, classes) => { for (let i = from; i < to && i < styleMap.length; i++) { styleMap[i] = classes as HighlightStyle; } diff --git a/packages/highlight/src/index.ts b/packages/highlight/src/index.ts index ea5c4eb3c..3c004f97a 100644 --- a/packages/highlight/src/index.ts +++ b/packages/highlight/src/index.ts @@ -1,5 +1,11 @@ export type { HighlightStyle, HighlightToken } from "./types.js"; -export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js"; +export { + getLanguageForFile, + getParserForFile, + isLanguageSupported, + getSupportedExtensions, +} from "./parsers.js"; +export { createCodeMirrorHighlightStyle } from "./syntax-roles.js"; export { highlightCode, highlightLine } from "./highlighter.js"; export { darkHighlightColors, lightHighlightColors } from "./colors.js"; export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js"; diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 907e44988..20d3b0578 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -1,4 +1,4 @@ -import { StreamLanguage } from "@codemirror/language"; +import { defineLanguageFacet, Language, StreamLanguage } from "@codemirror/language"; import { dart } from "@codemirror/legacy-modes/mode/clike"; import { swift } from "@codemirror/legacy-modes/mode/swift"; import { parser as jsParser } from "@lezer/javascript"; @@ -18,65 +18,73 @@ import { csharpLanguage } from "@replit/codemirror-lang-csharp"; import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; -const parsersByExtension: Record = { +function language(parser: Parser): Language { + return new Language(defineLanguageFacet(), parser); +} + +const languagesByExtension: Record = { // JavaScript/TypeScript - js: jsParser, - jsx: jsParser.configure({ dialect: "jsx" }), - ts: jsParser.configure({ dialect: "ts" }), - tsx: jsParser.configure({ dialect: "ts jsx" }), - mjs: jsParser, - cjs: jsParser, + js: language(jsParser), + jsx: language(jsParser.configure({ dialect: "jsx" })), + ts: language(jsParser.configure({ dialect: "ts" })), + tsx: language(jsParser.configure({ dialect: "ts jsx" })), + mjs: language(jsParser), + cjs: language(jsParser), // C / C++ / Objective-C - c: cppParser, - h: cppParser, - cc: cppParser, - cpp: cppParser, - cxx: cppParser, - hpp: cppParser, - hxx: cppParser, - m: cppParser, - mm: cppParser, + c: language(cppParser), + h: language(cppParser), + cc: language(cppParser), + cpp: language(cppParser), + cxx: language(cppParser), + hpp: language(cppParser), + hxx: language(cppParser), + m: language(cppParser), + mm: language(cppParser), // JSON - json: jsonParser, + json: language(jsonParser), // CSS - css: cssParser, - scss: cssParser, + css: language(cssParser), + scss: language(cssParser), // HTML - html: htmlParser, - htm: htmlParser, + html: language(htmlParser), + htm: language(htmlParser), // XML - xml: xmlParser, + xml: language(xmlParser), // Java - java: javaParser, + java: language(javaParser), // Python - py: pythonParser, + py: language(pythonParser), // Go - go: goParser, + go: language(goParser), // PHP - php: phpParser, + php: language(phpParser), // YAML - yaml: yamlParser, - yml: yamlParser, + yaml: language(yamlParser), + yml: language(yamlParser), // Rust - rs: rustParser, + rs: language(rustParser), // Swift - swift: StreamLanguage.define(swift).parser, + swift: StreamLanguage.define(swift), // Dart - dart: StreamLanguage.define(dart).parser, + dart: StreamLanguage.define(dart), // C# - cs: csharpLanguage.parser, + cs: csharpLanguage, // Elixir - ex: elixirParser, - exs: elixirParser, + ex: language(elixirParser), + exs: language(elixirParser), // Markdown - md: markdownParser, - mdx: markdownParser, + md: language(markdownParser), + mdx: language(markdownParser), }; -export function getParserForFile(filename: string): Parser | null { +export function getLanguageForFile(filename: string): Language | null { const ext = filename.split(".").pop()?.toLowerCase(); if (!ext) return null; - return parsersByExtension[ext] ?? null; + return languagesByExtension[ext] ?? null; +} + +export function getParserForFile(filename: string): Parser | null { + return getLanguageForFile(filename)?.parser ?? null; } export function isLanguageSupported(filename: string): boolean { @@ -84,5 +92,5 @@ export function isLanguageSupported(filename: string): boolean { } export function getSupportedExtensions(): string[] { - return Object.keys(parsersByExtension); + return Object.keys(languagesByExtension); } diff --git a/packages/highlight/src/syntax-roles.ts b/packages/highlight/src/syntax-roles.ts new file mode 100644 index 000000000..768316828 --- /dev/null +++ b/packages/highlight/src/syntax-roles.ts @@ -0,0 +1,57 @@ +import { HighlightStyle as CodeMirrorHighlightStyle } from "@codemirror/language"; +import { tagHighlighter, tags, type Tag } from "@lezer/highlight"; +import type { HighlightStyle } from "./types.js"; + +export const syntaxRoleTags: ReadonlyArray<{ tag: Tag; role: HighlightStyle }> = [ + { tag: tags.keyword, role: "keyword" }, + { tag: tags.controlKeyword, role: "keyword" }, + { tag: tags.operatorKeyword, role: "keyword" }, + { tag: tags.definitionKeyword, role: "keyword" }, + { tag: tags.moduleKeyword, role: "keyword" }, + { tag: tags.comment, role: "comment" }, + { tag: tags.lineComment, role: "comment" }, + { tag: tags.blockComment, role: "comment" }, + { tag: tags.docComment, role: "comment" }, + { tag: tags.string, role: "string" }, + { tag: tags.special(tags.string), role: "string" }, + { tag: tags.number, role: "number" }, + { tag: tags.integer, role: "number" }, + { tag: tags.float, role: "number" }, + { tag: tags.bool, role: "literal" }, + { tag: tags.null, role: "literal" }, + { tag: tags.function(tags.variableName), role: "function" }, + { tag: tags.function(tags.propertyName), role: "function" }, + { tag: tags.definition(tags.variableName), role: "definition" }, + { tag: tags.definition(tags.propertyName), role: "definition" }, + { tag: tags.definition(tags.function(tags.variableName)), role: "definition" }, + { tag: tags.className, role: "class" }, + { tag: tags.definition(tags.className), role: "class" }, + { tag: tags.typeName, role: "type" }, + { tag: tags.tagName, role: "tag" }, + { tag: tags.attributeName, role: "attribute" }, + { tag: tags.attributeValue, role: "string" }, + { tag: tags.propertyName, role: "property" }, + { tag: tags.variableName, role: "variable" }, + { tag: tags.local(tags.variableName), role: "variable" }, + { tag: tags.special(tags.variableName), role: "variable" }, + { tag: tags.operator, role: "operator" }, + { tag: tags.punctuation, role: "punctuation" }, + { tag: tags.bracket, role: "punctuation" }, + { tag: tags.separator, role: "punctuation" }, + { tag: tags.regexp, role: "regexp" }, + { tag: tags.escape, role: "escape" }, + { tag: tags.meta, role: "meta" }, + { tag: tags.heading, role: "heading" }, + { tag: tags.link, role: "link" }, + { tag: tags.url, role: "link" }, +]; + +export const staticSyntaxHighlighter = tagHighlighter( + syntaxRoleTags.map(({ tag, role }) => ({ tag, class: role })), +); + +export function createCodeMirrorHighlightStyle(colors: Record) { + return CodeMirrorHighlightStyle.define( + syntaxRoleTags.map(({ tag, role }) => ({ tag, color: colors[role] })), + ); +} diff --git a/packages/protocol/src/binary-frames/file-transfer.ts b/packages/protocol/src/binary-frames/file-transfer.ts index 0ce0f8611..2ba0f9af6 100644 --- a/packages/protocol/src/binary-frames/file-transfer.ts +++ b/packages/protocol/src/binary-frames/file-transfer.ts @@ -14,6 +14,7 @@ export const FileBeginMetadataSchema = z.object({ size: z.number().int().nonnegative(), encoding: z.enum(["utf-8", "binary"]), modifiedAt: z.string(), + revision: z.string().optional(), fileName: z.string().optional(), }); diff --git a/packages/protocol/src/messages.file-editing.test.ts b/packages/protocol/src/messages.file-editing.test.ts new file mode 100644 index 000000000..9e2c16d99 --- /dev/null +++ b/packages/protocol/src/messages.file-editing.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; +import { + FileSubscribeRequestSchema, + FileSubscribeResponseSchema, + FileUnsubscribeRequestSchema, + FileUpdateSchema, + FileWriteRequestSchema, + FileWriteResponseSchema, + ServerInfoStatusPayloadSchema, +} from "./messages.js"; + +describe("workspace file editing messages", () => { + test("keeps the capability optional for older server info payloads", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: {}, + }).features?.workspaceFileEditing, + ).toBeUndefined(); + }); + + test("parses subscribe, unsubscribe, and version update messages", () => { + expect( + FileSubscribeRequestSchema.parse({ + type: "fs.file.subscribe.request", + cwd: "/workspace", + path: "file.ts", + subscriptionId: "subscription-1", + requestId: "request-1", + }).subscriptionId, + ).toBe("subscription-1"); + expect( + FileSubscribeResponseSchema.parse({ + type: "fs.file.subscribe.response", + payload: { + subscriptionId: "subscription-1", + initial: { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: 12, + modifiedAt: "2026-07-18T00:00:00.000Z", + }, + requestId: "request-1", + }, + }).payload.initial.status, + ).toBe("ready"); + expect( + FileUnsubscribeRequestSchema.parse({ + type: "fs.file.unsubscribe.request", + subscriptionId: "subscription-1", + requestId: "request-2", + }).subscriptionId, + ).toBe("subscription-1"); + expect( + FileUpdateSchema.parse({ + type: "fs.file.update", + payload: { + subscriptionId: "subscription-1", + version: { status: "missing", cwd: "/workspace", path: "file.ts" }, + }, + }).payload.version.status, + ).toBe("missing"); + }); + + test("parses optimistic write requests and conflict responses", () => { + expect( + FileWriteRequestSchema.parse({ + type: "fs.file.write.request", + cwd: "/workspace", + path: "file.ts", + content: "const value = 1;\n", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + requestId: "request-1", + }).content, + ).toBe("const value = 1;\n"); + expect( + FileWriteResponseSchema.parse({ + type: "fs.file.write.response", + payload: { + result: { + status: "conflict", + version: { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: 20, + modifiedAt: "2026-07-18T00:00:01.000Z", + }, + }, + requestId: "request-1", + }, + }).payload.result.status, + ).toBe("conflict"); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f4b419864..d1944179a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2132,6 +2132,7 @@ const FileExplorerFileSchema = z.object({ mimeType: z.string().optional(), size: z.number(), modifiedAt: z.string(), + revision: z.string().optional(), }); const FileExplorerDirectorySchema = z.object({ @@ -2148,6 +2149,52 @@ export const FileExplorerRequestSchema = z.object({ acceptBinary: z.boolean().optional(), }); +export const FileVersionSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ready"), + cwd: z.string(), + path: z.string(), + size: z.number().int().nonnegative(), + modifiedAt: z.string(), + revision: z.string().optional(), + }), + z.object({ + status: z.literal("missing"), + cwd: z.string(), + path: z.string(), + }), + z.object({ + status: z.literal("error"), + cwd: z.string(), + path: z.string(), + error: z.string(), + }), +]); + +export const FileSubscribeRequestSchema = z.object({ + type: z.literal("fs.file.subscribe.request"), + cwd: z.string(), + path: z.string(), + subscriptionId: z.string(), + requestId: z.string(), +}); + +export const FileUnsubscribeRequestSchema = z.object({ + type: z.literal("fs.file.unsubscribe.request"), + subscriptionId: z.string(), + requestId: z.string(), +}); + +export const FileWriteRequestSchema = z.object({ + type: z.literal("fs.file.write.request"), + cwd: z.string(), + path: z.string(), + content: z.string(), + expectedModifiedAt: z.string(), + expectedRevision: z.string().optional(), + requestId: z.string(), +}); + export const ProjectIconRequestSchema = z.object({ type: z.literal("project_icon_request"), cwd: z.string(), @@ -2463,6 +2510,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, FileExplorerRequestSchema, + FileSubscribeRequestSchema, + FileUnsubscribeRequestSchema, + FileWriteRequestSchema, ProjectIconRequestSchema, FileDownloadTokenRequestSchema, FileUploadRequestSchema, @@ -2698,6 +2748,8 @@ export const ServerInfoStatusPayloadSchema = z worktreeRestore: z.boolean().optional(), // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: z.boolean().optional(), + // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. + workspaceFileEditing: z.boolean().optional(), // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. providerUsageList: z.boolean().optional(), // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. @@ -4552,6 +4604,50 @@ export const FileExplorerResponseSchema = z.object({ }), }); +export const FileSubscribeResponseSchema = z.object({ + type: z.literal("fs.file.subscribe.response"), + payload: z.object({ + subscriptionId: z.string(), + initial: FileVersionSchema, + requestId: z.string(), + }), +}); + +export const FileUnsubscribeResponseSchema = z.object({ + type: z.literal("fs.file.unsubscribe.response"), + payload: z.object({ + subscriptionId: z.string(), + requestId: z.string(), + }), +}); + +export const FileWriteResultSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("written"), + modifiedAt: z.string(), + size: z.number(), + revision: z.string().optional(), + }), + z.object({ status: z.literal("conflict"), version: FileVersionSchema }), + z.object({ status: z.literal("error"), error: z.string() }), +]); + +export const FileWriteResponseSchema = z.object({ + type: z.literal("fs.file.write.response"), + payload: z.object({ + result: FileWriteResultSchema, + requestId: z.string(), + }), +}); + +export const FileUpdateSchema = z.object({ + type: z.literal("fs.file.update"), + payload: z.object({ + subscriptionId: z.string(), + version: FileVersionSchema, + }), +}); + const ProjectIconSchema = z.object({ data: z.string(), mimeType: z.string(), @@ -5099,6 +5195,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveResponseSchema, CreatePaseoWorktreeResponseSchema, FileExplorerResponseSchema, + FileSubscribeResponseSchema, + FileUnsubscribeResponseSchema, + FileWriteResponseSchema, + FileUpdateSchema, ProjectIconResponseSchema, FileDownloadTokenResponseSchema, FileUploadResponseSchema, @@ -5493,6 +5593,15 @@ export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; +export type FileVersion = z.infer; +export type FileSubscribeRequest = z.infer; +export type FileSubscribeResponse = z.infer; +export type FileUnsubscribeRequest = z.infer; +export type FileUnsubscribeResponse = z.infer; +export type FileWriteRequest = z.infer; +export type FileWriteResponse = z.infer; +export type FileWriteResult = z.infer; +export type FileUpdate = z.infer; export type ProjectIconRequest = z.infer; export type ProjectIconResponse = z.infer; export type ProjectIcon = z.infer; diff --git a/packages/server/src/server/file-explorer/observer.test.ts b/packages/server/src/server/file-explorer/observer.test.ts new file mode 100644 index 000000000..001e55732 --- /dev/null +++ b/packages/server/src/server/file-explorer/observer.test.ts @@ -0,0 +1,139 @@ +import { mkdtemp, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { FileObserver, type FileObserverDependencies } from "./observer.js"; + +class ObservationControls implements FileObserverDependencies { + watches = 0; + closes = 0; + private change: ((filename: string | null) => void) | null = null; + private timeout: (() => void | Promise) | null = null; + private interval: (() => void | Promise) | null = null; + + watchDirectory(_directory: string, onChange: (filename: string | null) => void) { + this.watches += 1; + this.change = onChange; + return { close: () => (this.closes += 1) }; + } + + setTimeout(callback: () => void | Promise): ReturnType { + this.timeout = callback; + return 1 as ReturnType; + } + + clearTimeout(): void { + this.timeout = null; + } + + setInterval(callback: () => void | Promise): ReturnType { + this.interval = callback; + return 2 as ReturnType; + } + + clearInterval(): void { + this.interval = null; + } + + async fileChanged(filename: string): Promise { + this.change?.(filename); + const timeout = this.timeout; + this.timeout = null; + await timeout?.(); + } +} + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function workspace() { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-")); + roots.push(root); + await writeFile(path.join(root, "file.txt"), "one", "utf8"); + return root; +} + +describe("FileObserver", () => { + test("shares one parent watcher and publishes each real version once", async () => { + const root = await workspace(); + const controls = new ObservationControls(); + const observer = new FileObserver(controls); + const firstUpdates: string[] = []; + const secondUpdates: string[] = []; + const first = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) => + firstUpdates.push(version.status), + ); + const second = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) => + secondUpdates.push(version.status), + ); + + await writeFile(path.join(root, "file.txt"), "changed content", "utf8"); + await controls.fileChanged("file.txt"); + await controls.fileChanged("file.txt"); + + expect(controls.watches).toBe(1); + expect(firstUpdates).toEqual(["ready"]); + expect(secondUpdates).toEqual(["ready"]); + first.unsubscribe(); + expect(controls.closes).toBe(0); + second.unsubscribe(); + expect(controls.closes).toBe(1); + }); + + test("coalesces concurrent subscriptions onto one watcher", async () => { + const root = await workspace(); + const controls = new ObservationControls(); + const observer = new FileObserver(controls); + + const [first, second] = await Promise.all([ + observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined), + observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined), + ]); + + expect(controls.watches).toBe(1); + first.unsubscribe(); + second.unsubscribe(); + expect(controls.closes).toBe(1); + }); + + test("publishes shared watcher updates in each subscriber's path coordinates", async () => { + const root = await workspace(); + const aliasParent = await mkdtemp(path.join(os.tmpdir(), "paseo-file-observer-alias-")); + roots.push(aliasParent); + const aliasRoot = path.join(aliasParent, "workspace-link"); + await symlink(root, aliasRoot, "dir"); + const controls = new ObservationControls(); + const observer = new FileObserver(controls); + const updates: Array<{ cwd: string; path: string }> = []; + const direct = await observer.subscribe({ cwd: root, path: "file.txt" }, () => undefined); + const alias = await observer.subscribe({ cwd: aliasRoot, path: "file.txt" }, (version) => + updates.push({ cwd: version.cwd, path: version.path }), + ); + + await writeFile(path.join(root, "file.txt"), "changed content", "utf8"); + await controls.fileChanged("file.txt"); + + expect(controls.watches).toBe(1); + expect(updates).toEqual([{ cwd: aliasRoot, path: "file.txt" }]); + direct.unsubscribe(); + alias.unsubscribe(); + }); + + test("publishes deletion without dropping the subscription", async () => { + const root = await workspace(); + const controls = new ObservationControls(); + const observer = new FileObserver(controls); + const updates: string[] = []; + const subscription = await observer.subscribe({ cwd: root, path: "file.txt" }, (version) => + updates.push(version.status), + ); + + await unlink(path.join(root, "file.txt")); + await controls.fileChanged("file.txt"); + + expect(updates).toEqual(["missing"]); + subscription.unsubscribe(); + }); +}); diff --git a/packages/server/src/server/file-explorer/observer.ts b/packages/server/src/server/file-explorer/observer.ts new file mode 100644 index 000000000..b49133a10 --- /dev/null +++ b/packages/server/src/server/file-explorer/observer.ts @@ -0,0 +1,173 @@ +import { watch, type FSWatcher } from "node:fs"; +import path from "node:path"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import { getExplorerFileVersion, resolveExplorerFilePath } from "./service.js"; + +interface FileWatch { + close(): void; +} + +export interface FileObserverDependencies { + watchDirectory( + directory: string, + onChange: (filename: string | null) => void, + onError: () => void, + ): FileWatch; + setTimeout(callback: () => void | Promise, delayMs: number): ReturnType; + clearTimeout(handle: ReturnType): void; + setInterval( + callback: () => void | Promise, + delayMs: number, + ): ReturnType; + clearInterval(handle: ReturnType): void; +} + +interface ObservedFile { + cwd: string; + path: string; + basename: string; + listeners: Map<(version: FileVersion) => void, { cwd: string; path: string }>; + fingerprint: string; + watcher: FileWatch | null; + debounce: ReturnType | null; + fallback: ReturnType | null; +} + +const nodeDependencies: FileObserverDependencies = { + watchDirectory(directory, onChange, onError) { + const watcher: FSWatcher = watch(directory, (_event, filename) => { + onChange(filename === null ? null : filename.toString()); + }); + watcher.on("error", onError); + return watcher; + }, + setTimeout, + clearTimeout, + setInterval, + clearInterval, +}; + +export class FileObserver { + private readonly dependencies: FileObserverDependencies; + private readonly observed = new Map(); + + constructor(dependencies: FileObserverDependencies = nodeDependencies) { + this.dependencies = dependencies; + } + + async subscribe( + input: { cwd: string; path: string }, + listener: (version: FileVersion) => void, + ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { + const target = await resolveExplorerFilePath({ root: input.cwd, relativePath: input.path }); + let observed = this.observed.get(target); + if (!observed) { + const initial = await getExplorerFileVersion({ root: input.cwd, relativePath: input.path }); + observed = this.observed.get(target); + if (!observed) { + observed = { + cwd: input.cwd, + path: input.path, + basename: path.basename(target), + listeners: new Map(), + fingerprint: fingerprint(initial), + watcher: null, + debounce: null, + fallback: null, + }; + this.observed.set(target, observed); + this.startWatching(target, path.dirname(target), observed); + } + } + const initial = await getExplorerFileVersion({ + root: input.cwd, + relativePath: input.path, + }); + observed.listeners.set(listener, { cwd: initial.cwd, path: initial.path }); + observed.fingerprint = fingerprint(initial); + + let active = true; + return { + initial, + unsubscribe: () => { + if (!active) return; + active = false; + observed?.listeners.delete(listener); + if (observed && observed.listeners.size === 0) { + this.stopWatching(observed); + this.observed.delete(target); + } + }, + }; + } + + dispose(): void { + for (const observed of this.observed.values()) { + this.stopWatching(observed); + } + this.observed.clear(); + } + + private startWatching(target: string, directory: string, observed: ObservedFile): void { + try { + observed.watcher = this.dependencies.watchDirectory( + directory, + (filename) => { + if (filename === null || filename === observed.basename) { + this.scheduleRestat(target, observed); + } + }, + () => this.useFallback(target, observed), + ); + } catch { + this.useFallback(target, observed); + } + } + + private useFallback(target: string, observed: ObservedFile): void { + observed.watcher?.close(); + observed.watcher = null; + if (observed.fallback) return; + observed.fallback = this.dependencies.setInterval(() => this.restate(target, observed), 5_000); + } + + private scheduleRestat(target: string, observed: ObservedFile): void { + if (observed.debounce) { + this.dependencies.clearTimeout(observed.debounce); + } + observed.debounce = this.dependencies.setTimeout(() => { + observed.debounce = null; + return this.restate(target, observed); + }, 50); + } + + private async restate(target: string, observed: ObservedFile): Promise { + if (this.observed.get(target) !== observed) return; + const version = await getExplorerFileVersion({ + root: observed.cwd, + relativePath: observed.path, + }); + const nextFingerprint = fingerprint(version); + if (nextFingerprint === observed.fingerprint) return; + observed.fingerprint = nextFingerprint; + for (const [listener, identity] of observed.listeners) { + listener({ ...version, ...identity }); + } + } + + private stopWatching(observed: ObservedFile): void { + observed.watcher?.close(); + if (observed.debounce) this.dependencies.clearTimeout(observed.debounce); + if (observed.fallback) this.dependencies.clearInterval(observed.fallback); + observed.watcher = null; + observed.debounce = null; + observed.fallback = null; + } +} + +function fingerprint(version: FileVersion): string { + if (version.status !== "ready") return version.status; + return `${version.status}:${version.revision ?? `${version.size}:${version.modifiedAt}`}`; +} + +export const workspaceFileObserver = new FileObserver(); diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 8c394ec22..1025f395e 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { readExplorerFile } from "./service.js"; +import { getExplorerFileVersion, readExplorerFile, writeExplorerFile } from "./service.js"; async function createHomeTempDir(prefix: string): Promise { return mkdtemp(path.join(os.homedir(), prefix)); @@ -13,6 +13,120 @@ async function createTempDir(prefix: string): Promise { } describe("file explorer service", () => { + it("atomically writes an existing text file at the expected revision", async () => { + const root = await createTempDir("paseo-file-write-"); + try { + const filePath = path.join(root, "notes.txt"); + await writeFile(filePath, "before", "utf8"); + const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" }); + expect(current.status).toBe("ready"); + if (current.status !== "ready") return; + + const result = await writeExplorerFile({ + root, + relativePath: "notes.txt", + content: "after", + expectedModifiedAt: current.modifiedAt, + expectedRevision: current.revision, + }); + + expect(result.status).toBe("written"); + expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("after"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === "win32")( + "preserves the original file permissions across atomic replacement", + async () => { + const root = await createTempDir("paseo-file-mode-"); + try { + const filePath = path.join(root, "script.sh"); + await writeFile(filePath, "before", "utf8"); + await chmod(filePath, 0o764); + const current = await getExplorerFileVersion({ root, relativePath: "script.sh" }); + expect(current.status).toBe("ready"); + if (current.status !== "ready") return; + + const result = await writeExplorerFile({ + root, + relativePath: "script.sh", + content: "after", + expectedModifiedAt: current.modifiedAt, + expectedRevision: current.revision, + }); + + expect(result.status).toBe("written"); + expect((await stat(filePath)).mode & 0o7777).toBe(0o764); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + ); + + it("preserves a newer disk revision instead of overwriting it", async () => { + const root = await createTempDir("paseo-file-conflict-"); + try { + const filePath = path.join(root, "notes.txt"); + await writeFile(filePath, "newer on disk", "utf8"); + + const result = await writeExplorerFile({ + root, + relativePath: "notes.txt", + content: "stale local edit", + expectedModifiedAt: "2020-01-01T00:00:00.000Z", + }); + + expect(result).toMatchObject({ status: "conflict", version: { status: "ready" } }); + expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe( + "newer on disk", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("prefers the high-precision revision token over the display timestamp", async () => { + const root = await createTempDir("paseo-file-revision-"); + try { + const filePath = path.join(root, "notes.txt"); + await writeFile(filePath, "on disk", "utf8"); + const current = await getExplorerFileVersion({ root, relativePath: "notes.txt" }); + expect(current.status).toBe("ready"); + if (current.status !== "ready") return; + + const result = await writeExplorerFile({ + root, + relativePath: "notes.txt", + content: "stale local edit", + expectedModifiedAt: current.modifiedAt, + expectedRevision: `${current.revision}-stale`, + }); + + expect(result.status).toBe("conflict"); + expect((await readExplorerFile({ root, relativePath: "notes.txt" })).content).toBe("on disk"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("never creates a missing file through the write API", async () => { + const root = await createTempDir("paseo-file-missing-"); + try { + const result = await writeExplorerFile({ + root, + relativePath: "missing.txt", + content: "new file", + expectedModifiedAt: "2020-01-01T00:00:00.000Z", + }); + + expect(result).toMatchObject({ status: "conflict", version: { status: "missing" } }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("reads .ex files as text", async () => { const root = await createTempDir("paseo-file-explorer-"); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 9457130ad..45a8d74ca 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -1,6 +1,7 @@ -import { constants, promises as fs } from "fs"; +import { constants, promises as fs, type BigIntStats } from "fs"; import type { FileHandle } from "fs/promises"; import path from "path"; +import { randomUUID } from "crypto"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; export type ExplorerEntryKind = "file" | "directory"; @@ -17,6 +18,29 @@ export interface ReadFileParams { relativePath: string; } +export interface WriteFileParams extends ReadFileParams { + content: string; + expectedModifiedAt: string; + expectedRevision?: string; +} + +export type ExplorerFileVersion = + | { + status: "ready"; + cwd: string; + path: string; + size: number; + modifiedAt: string; + revision: string; + } + | { status: "missing"; cwd: string; path: string } + | { status: "error"; cwd: string; path: string; error: string }; + +export type ExplorerFileWriteResult = + | { status: "written"; modifiedAt: string; size: number; revision: string } + | { status: "conflict"; version: ExplorerFileVersion } + | { status: "error"; error: string }; + export interface FileExplorerEntry { name: string; path: string; @@ -38,6 +62,7 @@ export interface FileExplorerFile { mimeType?: string; size: number; modifiedAt: string; + revision: string; } export interface FileExplorerFileBytes { @@ -48,6 +73,7 @@ export interface FileExplorerFileBytes { mimeType: string; size: number; modifiedAt: string; + revision: string; } const TEXT_MIME_TYPES: Record = { @@ -56,10 +82,25 @@ const TEXT_MIME_TYPES: Record = { const DEFAULT_TEXT_MIME_TYPE = "text/plain"; const FILE_TYPE_SAMPLE_BYTES = 8192; +export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024; const READ_FILE_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; const ACCESS_OUTSIDE_WORKSPACE_MESSAGE = "Access outside of workspace is not allowed"; +function fileRevision(stats: BigIntStats): string { + return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}`; +} + +function matchesExpectedRevision( + stats: BigIntStats, + expectedModifiedAt: string, + expectedRevision?: string, +): boolean { + return expectedRevision + ? fileRevision(stats) === expectedRevision + : stats.mtime.toISOString() === expectedModifiedAt; +} + const IMAGE_MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -151,6 +192,7 @@ export async function readExplorerFile({ mimeType: file.mimeType, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } @@ -162,6 +204,7 @@ export async function readExplorerFile({ mimeType: file.mimeType, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } @@ -173,6 +216,7 @@ export async function readExplorerFile({ mimeType: file.mimeType, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } @@ -184,7 +228,7 @@ export async function readExplorerFileBytes({ const handle = await openFileForRead(filePath.resolvedPath); try { - const stats = await handle.stat(); + const stats = await handle.stat({ bigint: true }); if (!stats.isFile()) { throw new Error("Requested path is not a file"); @@ -193,8 +237,9 @@ export async function readExplorerFileBytes({ const ext = path.extname(filePath.resolvedPath).toLowerCase(); const basePayload = { path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), - size: stats.size, + size: Number(stats.size), modifiedAt: stats.mtime.toISOString(), + revision: fileRevision(stats), }; const buffer = await handle.readFile(); @@ -208,7 +253,7 @@ export async function readExplorerFileBytes({ }; } - if (isLikelyBinary(buffer)) { + if (isLikelyBinary(buffer) || !isValidUtf8(buffer)) { return { ...basePayload, kind: "binary", @@ -230,6 +275,146 @@ export async function readExplorerFileBytes({ } } +export async function getExplorerFileVersion({ + root, + relativePath, +}: ReadFileParams): Promise { + const cwd = expandUserPath(root); + try { + const filePath = await resolveScopedPath({ root, relativePath }); + const stats = await fs.stat(filePath.resolvedPath, { bigint: true }); + if (!stats.isFile()) { + return { status: "error", cwd, path: relativePath, error: "Requested path is not a file" }; + } + return { + status: "ready", + cwd, + path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), + size: Number(stats.size), + modifiedAt: stats.mtime.toISOString(), + revision: fileRevision(stats), + }; + } catch (error) { + if (isMissingEntryError(error)) { + return { status: "missing", cwd, path: relativePath }; + } + return { + status: "error", + cwd, + path: relativePath, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function resolveExplorerFilePath({ + root, + relativePath, +}: ReadFileParams): Promise { + return (await resolveScopedPath({ root, relativePath })).resolvedPath; +} + +export async function writeExplorerFile({ + root, + relativePath, + content, + expectedModifiedAt, + expectedRevision, +}: WriteFileParams): Promise { + const encoded = Buffer.from(content, "utf8"); + if (encoded.byteLength > MAX_EDITABLE_FILE_BYTES) { + return { status: "error", error: "File is too large to edit" }; + } + + let filePath: ScopedPath; + let currentMode = 0o600; + try { + filePath = await resolveScopedPath({ root, relativePath }); + const handle = await openFileForRead(filePath.resolvedPath); + try { + const stats = await handle.stat({ bigint: true }); + if (!stats.isFile()) { + return { status: "error", error: "Requested path is not a file" }; + } + if (stats.size > BigInt(MAX_EDITABLE_FILE_BYTES)) { + return { status: "error", error: "File is too large to edit" }; + } + const current = await handle.readFile(); + if (isLikelyBinary(current) || !isValidUtf8(current)) { + return { status: "error", error: "Binary files cannot be edited" }; + } + currentMode = Number(stats.mode); + const modifiedAt = stats.mtime.toISOString(); + if (!matchesExpectedRevision(stats, expectedModifiedAt, expectedRevision)) { + return { + status: "conflict", + version: { + status: "ready", + cwd: expandUserPath(root), + path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), + size: Number(stats.size), + modifiedAt, + revision: fileRevision(stats), + }, + }; + } + } finally { + await handle.close(); + } + } catch (error) { + if (isMissingEntryError(error)) { + return { + status: "conflict", + version: { status: "missing", cwd: expandUserPath(root), path: relativePath }, + }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } + + const temporaryPath = path.join( + path.dirname(filePath.resolvedPath), + `.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`, + ); + let temporaryHandle: FileHandle | null = null; + try { + temporaryHandle = await fs.open(temporaryPath, "wx", currentMode); + if (process.platform !== "win32") { + await temporaryHandle.chmod(currentMode & 0o7777); + } + await temporaryHandle.writeFile(encoded); + await temporaryHandle.sync(); + await temporaryHandle.close(); + temporaryHandle = null; + const latestStats = await fs.stat(filePath.resolvedPath, { bigint: true }); + if (!matchesExpectedRevision(latestStats, expectedModifiedAt, expectedRevision)) { + return { + status: "conflict", + version: { + status: "ready", + cwd: expandUserPath(root), + path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }), + size: Number(latestStats.size), + modifiedAt: latestStats.mtime.toISOString(), + revision: fileRevision(latestStats), + }, + }; + } + await fs.rename(temporaryPath, filePath.resolvedPath); + const stats = await fs.stat(filePath.resolvedPath, { bigint: true }); + return { + status: "written", + modifiedAt: stats.mtime.toISOString(), + size: Number(stats.size), + revision: fileRevision(stats), + }; + } catch (error) { + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } finally { + await temporaryHandle?.close().catch(() => undefined); + await fs.unlink(temporaryPath).catch(() => undefined); + } +} + export async function getDownloadableFileInfo({ root, relativePath }: ReadFileParams): Promise<{ path: string; absolutePath: string; @@ -370,3 +555,12 @@ function isLikelyBinary(buffer: Buffer): boolean { return suspicious / buffer.length > 0.3; } + +function isValidUtf8(buffer: Buffer): boolean { + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer); + return true; + } catch { + return false; + } +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 183f02633..53ec923a8 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -804,6 +804,7 @@ describe("file explorer binary responses", () => { size: 5, encoding: "binary", modifiedAt: expect.any(String), + revision: expect.any(String), }, payload: new Uint8Array(), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b03fd97d0..829e6a396 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1962,6 +1962,13 @@ export class Session { switch (msg.type) { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg); + case "fs.file.subscribe.request": + return this.workspaceFilesSession.handleFileSubscribeRequest(msg); + case "fs.file.unsubscribe.request": + this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); + return undefined; + case "fs.file.write.request": + return this.workspaceFilesSession.handleFileWriteRequest(msg); case "project_icon_request": return this.workspaceFilesSession.handleProjectIconRequest(msg); case "file_download_token_request": @@ -6321,6 +6328,7 @@ export class Session { this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); + this.workspaceFilesSession.dispose(); } } diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index c5e97ecc4..740f74a61 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -9,6 +9,9 @@ import type { FileDownloadTokenRequest, FileExplorerRequest, FileUploadRequest, + FileSubscribeRequest, + FileUnsubscribeRequest, + FileWriteRequest, SessionInboundMessage, SessionOutboundMessage, } from "../../messages.js"; @@ -19,7 +22,9 @@ import { listDirectoryEntries, readExplorerFile, readExplorerFileBytes, + writeExplorerFile, } from "../../file-explorer/service.js"; +import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js"; import { getProjectIcon } from "../../../utils/project-icon.js"; /** @@ -39,6 +44,7 @@ export interface WorkspaceFilesSessionOptions { downloadTokenStore: DownloadTokenStore; paseoHome: string; logger: pino.Logger; + fileObserver?: FileObserver; } /** @@ -53,12 +59,81 @@ export class WorkspaceFilesSession { private readonly downloadTokenStore: DownloadTokenStore; private readonly logger: pino.Logger; private readonly fileUploads: FileUploadStore; + private readonly fileObserver: FileObserver; + private readonly fileSubscriptions = new Map void>(); constructor(options: WorkspaceFilesSessionOptions) { this.host = options.host; this.downloadTokenStore = options.downloadTokenStore; this.logger = options.logger; this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome }); + this.fileObserver = options.fileObserver ?? workspaceFileObserver; + } + + async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { + this.fileSubscriptions.get(request.subscriptionId)?.(); + try { + const subscription = await this.fileObserver.subscribe( + { cwd: request.cwd, path: request.path }, + (version) => { + this.host.emit({ + type: "fs.file.update", + payload: { subscriptionId: request.subscriptionId, version }, + }); + }, + ); + this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe); + this.host.emit({ + type: "fs.file.subscribe.response", + payload: { + subscriptionId: request.subscriptionId, + initial: subscription.initial, + requestId: request.requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "fs.file.subscribe.response", + payload: { + subscriptionId: request.subscriptionId, + initial: { + status: "error", + cwd: request.cwd, + path: request.path, + error: getErrorMessage(error), + }, + requestId: request.requestId, + }, + }); + } + } + + handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void { + this.fileSubscriptions.get(request.subscriptionId)?.(); + this.fileSubscriptions.delete(request.subscriptionId); + this.host.emit({ + type: "fs.file.unsubscribe.response", + payload: { subscriptionId: request.subscriptionId, requestId: request.requestId }, + }); + } + + async handleFileWriteRequest(request: FileWriteRequest): Promise { + const result = await writeExplorerFile({ + root: request.cwd, + relativePath: request.path, + content: request.content, + expectedModifiedAt: request.expectedModifiedAt, + expectedRevision: request.expectedRevision, + }); + this.host.emit({ + type: "fs.file.write.response", + payload: { result, requestId: request.requestId }, + }); + } + + dispose(): void { + for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); + this.fileSubscriptions.clear(); } async handleFileExplorerRequest(request: FileExplorerRequest): Promise { @@ -115,6 +190,7 @@ export class WorkspaceFilesSession { size: file.size, encoding: file.encoding, modifiedAt: file.modifiedAt, + revision: file.revision, }, }), ); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 73e6f80d1..b9c5b1e14 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1380,6 +1380,8 @@ export class VoiceAssistantWebSocketServer { worktreeRestore: true, // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. workspaceRecovery: true, + // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. + workspaceFileEditing: true, // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. providerUsageList: true, // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.