дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(editor): synchronize external file changes on focus (#564)
Open files only reloaded from file watcher events, so a change the watcher skipped stayed stale in its tab: excluded and git-ignored folders, files outside the project, and missed events. - Re-check open documents against disk when the window gains focus, off the UI thread and debounced. Clean documents reload, dirty ones keep the existing warning, and unchanged ones cost one metadata call. - Refresh the project tree and Git status on the same focus event. - Add "Synchronize external changes when switching to the IDE window" under System Settings, on by default. Fixes #563
Цей коміт міститься в:
1 джерело
4796265b5c
коміт
17e3cd2b39
18 files changed
+510
No files matched your search
@@ -199,6 +199,7 @@ export const commands = {
|
||||
docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError<number, IdeError>(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })),
|
||||
docSave: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_save", { doc })),
|
||||
docSaveAll: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_save_all")),
|
||||
docSyncFromDisk: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_sync_from_disk")),
|
||||
docSnapshot: (doc: DocId) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_snapshot", { doc })),
|
||||
/**
|
||||
* The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
@@ -3050,6 +3051,8 @@ export type FileRenameEdits = {
|
||||
export type FileSettings = {
|
||||
/** Save on focus loss / tab switch, like IDEA. */
|
||||
autoSave?: boolean,
|
||||
/** Re-check open files against disk when the IDE window gains focus. */
|
||||
syncOnFocus?: boolean,
|
||||
exclude?: string[],
|
||||
/** Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. */
|
||||
ignoredNames?: string[],
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DocBackend {
|
||||
close(doc: DocId): Promise<void>;
|
||||
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
|
||||
save(doc: DocId): Promise<void>;
|
||||
/** Re-check every open file for external changes missed by file watchers. */
|
||||
syncFromDisk?(): Promise<void>;
|
||||
snapshot(doc: DocId): Promise<DocumentInfo>;
|
||||
setLineEnding(doc: DocId, lineEnding: LineEnding): Promise<void>;
|
||||
/** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding. */
|
||||
@@ -67,6 +69,7 @@ export const ipcBackend: DocBackend = {
|
||||
close: async (doc) => void (await call(commands.docClose(doc))),
|
||||
edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })),
|
||||
save: async (doc) => void (await call(commands.docSave(doc))),
|
||||
syncFromDisk: async () => void (await call(commands.docSyncFromDisk())),
|
||||
snapshot: (doc) => call(commands.docSnapshot(doc)),
|
||||
setLineEnding: async (doc, le) => void (await call(commands.docSetLineEnding(doc, le))),
|
||||
setEncoding: (doc, encoding, reload) => call(commands.docSetEncoding(doc, encoding, reload)),
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
type TabsState,
|
||||
} from "./model/tabs";
|
||||
import { DocumentManager, useDocMeta } from "./model/document";
|
||||
import { installDiskSync } from "./diskSync";
|
||||
import {
|
||||
closedDocuments,
|
||||
combinedError,
|
||||
@@ -313,6 +314,13 @@ export class EditorController implements SlotHost {
|
||||
};
|
||||
const onPageHide = () => this.saveSession();
|
||||
if (typeof window !== "undefined") {
|
||||
if (this.manager.backend.syncFromDisk)
|
||||
this.disposers.push(
|
||||
installDiskSync(
|
||||
() => this.manager.backend.syncFromDisk!(),
|
||||
() => !this.closeSession,
|
||||
),
|
||||
);
|
||||
window.addEventListener("blur", onWindowBlur);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
window.addEventListener("beforeunload", onPageHide);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { installDiskSync } from "./diskSync";
|
||||
import { EditorController } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
|
||||
vi.mock("@/platform/actions", async (original) => ({
|
||||
...(await original<typeof import("@/platform/actions")>()),
|
||||
executeAction: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
let dispose: (() => void) | undefined;
|
||||
let controller: EditorController | undefined;
|
||||
const focus = () => window.dispatchEvent(new Event("focus"));
|
||||
const settings = (syncOnFocus = true) => ({ files: { syncOnFocus, autoSave: false } }) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
useProject.setState({
|
||||
project: { root: "/project" } as never,
|
||||
settings: settings(),
|
||||
opening: false,
|
||||
lightEdit: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = undefined;
|
||||
controller?.stop();
|
||||
controller = undefined;
|
||||
useProject.setState({ project: null, settings: null, lightEdit: false, opening: false });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("window disk synchronization", () => {
|
||||
it("debounces focus events and refreshes the project tree and VCS", async () => {
|
||||
const sync = vi.fn().mockResolvedValue(undefined);
|
||||
dispose = installDiskSync(sync, () => true);
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(sync).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sync).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(executeAction).mock.calls).toEqual([["project.refresh"], ["vcs.refresh"]]);
|
||||
});
|
||||
|
||||
it("defaults on for older settings and does nothing when the setting is off", async () => {
|
||||
const sync = vi.fn().mockResolvedValue(undefined);
|
||||
dispose = installDiskSync(sync, () => true);
|
||||
useProject.setState({ settings: {} as never });
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).toHaveBeenCalledOnce();
|
||||
sync.mockClear();
|
||||
vi.mocked(executeAction).mockClear();
|
||||
useProject.setState({ settings: settings(false) });
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(sync).not.toHaveBeenCalled();
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["setting", "project", "opening", "closing", "dispose"])(
|
||||
"cancels pending sync on %s changes",
|
||||
async (change) => {
|
||||
let canSync = true;
|
||||
const sync = vi.fn().mockResolvedValue(undefined);
|
||||
dispose = installDiskSync(sync, () => canSync);
|
||||
focus();
|
||||
if (change === "setting") useProject.setState({ settings: settings(false) });
|
||||
if (change === "project") useProject.setState({ project: { root: "/other" } as never });
|
||||
if (change === "opening") useProject.setState({ opening: true });
|
||||
if (change === "closing") canSync = false;
|
||||
if (change === "dispose") dispose();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).not.toHaveBeenCalled();
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores welcome windows and synchronizes LightEdit without project refreshes", async () => {
|
||||
const sync = vi.fn().mockResolvedValue(undefined);
|
||||
dispose = installDiskSync(sync, () => true);
|
||||
useProject.setState({ project: null });
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).not.toHaveBeenCalled();
|
||||
useProject.setState({ lightEdit: true });
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).toHaveBeenCalledOnce();
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces focus during a slow check into a follow-up and removes its listener", async () => {
|
||||
let finish!: () => void;
|
||||
const sync = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
dispose = installDiskSync(sync, () => true);
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).toHaveBeenCalledOnce();
|
||||
finish();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refresh a replacement project when an old check finishes", async () => {
|
||||
let finish!: () => void;
|
||||
dispose = installDiskSync(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
() => true,
|
||||
);
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
useProject.setState({ project: { root: "/other" } as never });
|
||||
finish();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("controller preserves the unchanged document's selection and scroll, then stops checking", async () => {
|
||||
useProject.setState({ project: null, lightEdit: true });
|
||||
const syncFromDisk = vi.fn().mockResolvedValue(undefined);
|
||||
const backend = { ...createMemoryBackend({ "/file.txt": "line\n".repeat(100) }), syncFromDisk };
|
||||
controller = new EditorController(backend);
|
||||
controller.start();
|
||||
await controller.openFile("/file.txt", { focus: false });
|
||||
const slot = controller.activeSlot!;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
try {
|
||||
const view = slot.mount(host);
|
||||
controller.onMounted(slot);
|
||||
view.dispatch({ selection: { anchor: 150 } });
|
||||
view.scrollDOM.scrollTop = 80;
|
||||
const state = view.state;
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(syncFromDisk).toHaveBeenCalledOnce();
|
||||
expect(view.state.doc).toBe(state.doc);
|
||||
expect(view.state.selection.eq(state.selection)).toBe(true);
|
||||
expect(view.state.selection.main.anchor).toBe(150);
|
||||
expect(view.scrollDOM.scrollTop).toBe(80);
|
||||
controller.stop();
|
||||
controller = undefined;
|
||||
focus();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(syncFromDisk).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
|
||||
/** One debounced disk check per window activation, scoped to its current project. */
|
||||
export function installDiskSync(sync: () => Promise<void>, canSync: () => boolean): () => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let running = false;
|
||||
let pending = false;
|
||||
let disposed = false;
|
||||
const enabled = () => {
|
||||
const s = useProject.getState();
|
||||
return (
|
||||
!disposed && canSync() && !s.opening && (s.project || s.lightEdit) && s.settings?.files?.syncOnFocus !== false
|
||||
);
|
||||
};
|
||||
const onFocus = () => {
|
||||
clearTimeout(timer);
|
||||
if (!enabled()) return;
|
||||
const { project, lightEdit } = useProject.getState();
|
||||
timer = setTimeout(async () => {
|
||||
if (!enabled() || useProject.getState().project !== project || useProject.getState().lightEdit !== lightEdit)
|
||||
return;
|
||||
if (running) {
|
||||
pending = true;
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
await sync();
|
||||
if (enabled() && useProject.getState().project === project && !lightEdit) {
|
||||
void executeAction("project.refresh");
|
||||
void executeAction("vcs.refresh");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Cannot synchronize external file changes", e);
|
||||
} finally {
|
||||
running = false;
|
||||
if (pending) {
|
||||
pending = false;
|
||||
onFocus();
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}
|
||||
@@ -147,6 +147,11 @@ describe("settings staging", () => {
|
||||
});
|
||||
|
||||
describe("validateSettings", () => {
|
||||
it("defaults focus synchronization on and preserves an explicit opt-out", () => {
|
||||
expect(validateSettings({ files: {} }).files.syncOnFocus).toBe(true);
|
||||
expect(validateSettings({ files: { syncOnFocus: false } }).files.syncOnFocus).toBe(false);
|
||||
});
|
||||
|
||||
it("fills defaults for missing or malformed values", () => {
|
||||
expect(validateSettings(undefined)).toEqual(DEFAULT_SETTINGS);
|
||||
expect(validateSettings("garbage")).toEqual(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface ValidSettings extends WorkflowSettings {
|
||||
keymap: string;
|
||||
files: {
|
||||
autoSave: boolean;
|
||||
syncOnFocus: boolean;
|
||||
exclude: string[];
|
||||
ignoredNames: string[];
|
||||
trimTrailingWhitespace: boolean;
|
||||
@@ -134,6 +135,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
||||
keymap: "intellij-macos",
|
||||
files: {
|
||||
autoSave: true,
|
||||
syncOnFocus: true,
|
||||
exclude: ["**/.git", "**/node_modules", "**/target"],
|
||||
ignoredNames: [...DEFAULT_IGNORED_NAMES],
|
||||
trimTrailingWhitespace: true,
|
||||
@@ -233,6 +235,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
||||
keymap: str(root.keymap, d.keymap),
|
||||
files: {
|
||||
autoSave: bool(f.autoSave, d.files.autoSave),
|
||||
syncOnFocus: bool(f.syncOnFocus, d.files.syncOnFocus),
|
||||
exclude: Array.isArray(f.exclude)
|
||||
? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim())
|
||||
: [...d.files.exclude],
|
||||
|
||||
@@ -588,6 +588,11 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
key: "files.autoSave",
|
||||
label: "Save files when switching to a different application or a built-in terminal",
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
key: "files.syncOnFocus",
|
||||
label: "Synchronize external changes when switching to the IDE window",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1795,6 +1795,7 @@
|
||||
"Save Files": "保存文件",
|
||||
"Save files if the IDE is idle for": "IDE 空闲以下时长时保存文件",
|
||||
"Save files when switching to a different application or a built-in terminal": "切换到其他应用程序或内置终端时保存文件",
|
||||
"Synchronize external changes when switching to the IDE window": "切换回 IDE 窗口时同步外部更改",
|
||||
"Save or roll back the changes before reloading it from disk.": "从磁盘重新加载前,请先保存或回滚更改。",
|
||||
"Save password": "保存密码",
|
||||
"Save to file:": "保存到文件:",
|
||||
|
||||
Reference in new issue
Заблокувати користувача