From 17e3cd2b39543e1196a4367c9a019e2e5abe1fa3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:12:00 +0800 Subject: [PATCH] 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 --- crates/ide-core/src/settings.rs | 3 + crates/ide-host/src/ops/doc.rs | 15 ++ crates/ide-host/src/ops/doc/tests.rs | 200 ++++++++++++++++++ crates/ide-host/src/rpc.rs | 1 + crates/ide-host/tests/rpc.rs | 8 + crates/ide-workspace/src/documents.rs | 18 ++ crates/ide-workspace/tests/documents.rs | 4 + src-tauri/src/commands/doc.rs | 6 + src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 3 + src/editor/core/backend.ts | 3 + src/editor/core/controller.ts | 8 + src/editor/core/diskSync.test.ts | 175 +++++++++++++++ src/editor/core/diskSync.ts | 51 +++++ .../settings/__tests__/staging.test.ts | 5 + src/features/settings/model/schema.ts | 3 + src/features/settings/pages/behavior.tsx | 5 + src/platform/locales/text/zh.json | 1 + 18 files changed, 510 insertions(+) create mode 100644 crates/ide-host/src/ops/doc/tests.rs create mode 100644 src/editor/core/diskSync.test.ts create mode 100644 src/editor/core/diskSync.ts diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index f8254c19..e2b347ab 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -446,6 +446,8 @@ impl Default for EditorSettings { pub struct FileSettings { /// Save on focus loss / tab switch, like IDEA. pub auto_save: bool, + /// Re-check open files against disk when the IDE window gains focus. + pub sync_on_focus: bool, pub exclude: Vec, /// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. pub ignored_names: Vec, @@ -477,6 +479,7 @@ impl Default for FileSettings { fn default() -> Self { Self { auto_save: true, + sync_on_focus: true, exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()], ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(), trim_trailing_whitespace: true, diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs index dd969387..c366a218 100644 --- a/crates/ide-host/src/ops/doc.rs +++ b/crates/ide-host/src/ops/doc.rs @@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> { host.debugger.refresh_source_mirrors(host.owner_window(window)).await } +/// Recovers external changes missed by the project's file watchers. +pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> { + let project = host.project(window)?; + host.blocking(move || { + if project.settings.effective().files.sync_on_focus { + project.documents.sync_from_disk(); + } + Ok(()) + }) + .await +} + pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult { let project = host.project(window)?; host.blocking(move || project.documents.snapshot(doc)).await @@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult, + events: Arc>>, + path: PathBuf, + outside: PathBuf, + _temp: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + // Keep watcher delivery out of these tests: the op must recover it itself. + let excluded = root.join("node_modules"); + std::fs::create_dir_all(&excluded).unwrap(); + let path = excluded.join("file.txt"); + let outside = base.join("outside.txt"); + std::fs::write(&path, "original\n").unwrap(); + std::fs::write(&outside, "outside\n").unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let captured = events.clone(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(move |_| { + let events = captured.clone(); + Arc::new(move |event| events.lock().push(event)) + }), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &root).unwrap(); + Self { _temp: temp, host, project, events, path, outside } + } + + async fn open(&self, path: &Path) -> DocumentInfo { + open(&self.host, "main", path.to_path_buf()).await.unwrap() + } + + async fn sync(&self) { + sync_from_disk(&self.host, "main").await.unwrap(); + } + + fn document_events(&self) -> Vec { + self.events + .lock() + .iter() + .filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. })) + .cloned() + .collect() + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn changed_clean_document_reloads_once() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + std::fs::write(&f.path, "externally changed\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, "externally changed\n"); + assert!(!loaded.dirty); + assert!(loaded.version > original.version); + f.events.lock().clear(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unchanged_document_has_no_patch_or_event() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + f.events.lock().clear(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, original.text); + assert_eq!(loaded.version, original.version); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dirty_document_keeps_edits_and_warns_once() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + edit( + &f.host, + "main", + original.id, + original.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] }, + ) + .unwrap(); + f.events.lock().clear(); + std::fs::write(&f.path, "external replacement\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, "local original\n"); + assert!(loaded.dirty); + assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }])); + f.events.lock().clear(); + f.sync().await; + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() { + let f = Fixture::new(); + let original = f.open(&f.path).await; + let outside = f.open(&f.outside).await; + std::fs::remove_file(&f.path).unwrap(); + std::fs::write(&f.outside, "outside changed after deletion\n").unwrap(); + f.sync().await; + let loaded = f.project.documents.snapshot(original.id).unwrap(); + assert_eq!(loaded.text, original.text); + assert_eq!(loaded.version, original.version); + assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn outside_project_document_reloads() { + let f = Fixture::new(); + assert!(!f.outside.starts_with(&f.project.root)); + let original = f.open(&f.outside).await; + std::fs::write(&f.outside, "outside changed externally\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn setting_defaults_on_and_disabled_sync_keeps_buffer() { + let f = Fixture::new(); + assert!(f.project.settings.effective().files.sync_on_focus); + assert!(serde_json::from_value::(serde_json::json!({})).unwrap().sync_on_focus); + let original = f.open(&f.outside).await; + f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap(); + std::fs::write(&f.outside, "external change while disabled\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version); + f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn properties_reload_preserves_native_to_ascii_decoding() { + let f = Fixture::new(); + let path = f.outside.with_extension("properties"); + std::fs::write(&path, "key=\\u4F60\n").unwrap(); + let mut defaults = f.project.documents.file_defaults(); + defaults.native_to_ascii = true; + f.project.documents.set_file_defaults(defaults); + let original = f.open(&path).await; + assert_eq!(original.text, "key=你\n"); + std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn binary_and_virtual_documents_are_skipped() { + let f = Fixture::new(); + let path = f.outside.with_extension("bin"); + std::fs::write(&path, b"\0\0binary payload").unwrap(); + let binary = f.open(&path).await; + assert!(binary.read_only); + let virtual_doc = + f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap(); + std::fs::write(&path, "now ordinary text").unwrap(); + f.events.lock().clear(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version); + assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual"); + assert!(f.document_events().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn synchronization_only_changes_the_calling_windows_documents() { + let f = Fixture::new(); + let original = f.open(&f.outside).await; + let other_root = f.outside.parent().unwrap().join("other-project"); + std::fs::create_dir(&other_root).unwrap(); + let other = f.host.open_project("other", &other_root).unwrap(); + let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap(); + std::fs::write(&f.outside, "external change in shared file\n").unwrap(); + f.sync().await; + assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n"); + assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text); +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 14f15e80..811a9c53 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -189,6 +189,7 @@ impl Dispatcher { }) .add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await }) .add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await }) + .add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await }) .add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await }) .add("search_start", |c| async move { let on_event = c.channel("onEvent")?; diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index 39d9c129..ca2bd49e 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -112,6 +112,14 @@ async fn serves_projects_files_documents_search_and_runs() { invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n"); + let outside = tmp.path().join("outside.txt"); + std::fs::write(&outside, "original\n").unwrap(); + let external = invoke(&peer, "doc_open", json!({ "path": outside })).await.unwrap(); + std::fs::write(&outside, "external change\n").unwrap(); + invoke(&peer, "doc_sync_from_disk", json!({})).await.unwrap(); + let snapshot = invoke(&peer, "doc_snapshot", json!({ "doc": external["id"] })).await.unwrap(); + assert_eq!(snapshot["text"], "external change\n"); + // Commands shared with the desktop shell (camelCase argument names, as Tauri takes them). invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap(); invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap(); diff --git a/crates/ide-workspace/src/documents.rs b/crates/ide-workspace/src/documents.rs index 74a18572..2be27035 100644 --- a/crates/ide-workspace/src/documents.rs +++ b/crates/ide-workspace/src/documents.rs @@ -791,6 +791,21 @@ impl Documents { }) } + /// Re-checks open file buffers, including files outside the watched roots. + /// Call from a blocking worker; unchanged files only need their metadata. + pub fn sync_from_disk(&self) { + let slots: Vec = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect(); + let paths: Vec = slots + .iter() + .filter_map(|slot| { + let st = slot.lock(); + (!st.closed && st.uri.is_none() && !st.binary && !ide_vfs::is_archive_path(&st.path)) + .then(|| st.path.clone()) + }) + .collect(); + self.on_disk_changed(&paths); + } + /// Re-reads from disk after an external change. Clean documents reload /// silently; dirty ones are left alone and reported via `IdeEvent::Notify`. /// Changes matching our own last save are ignored. @@ -798,6 +813,9 @@ impl Documents { for path in paths { let Some(id) = self.by_path(path) else { continue }; let result = self.with_doc(id, |st| { + if st.uri.is_some() || st.binary || ide_vfs::is_archive_path(&st.path) { + return Ok(()); + } let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) }; let mtime = ide_vfs::mtime_ms(&meta); if mtime == st.disk_mtime && meta.len() == st.disk_len { diff --git a/crates/ide-workspace/tests/documents.rs b/crates/ide-workspace/tests/documents.rs index 48e9e85a..028617eb 100644 --- a/crates/ide-workspace/tests/documents.rs +++ b/crates/ide-workspace/tests/documents.rs @@ -597,6 +597,10 @@ fn archive_entries_open_read_only_without_observers() { assert_eq!((info.text.as_str(), info.language.as_str(), info.read_only), ("fn main() {}\n", "rust", true)); assert_eq!(*info.path, *path); assert!(f.rec.log.lock().is_empty(), "language servers / history never see archive entries"); + fs::remove_file(&jar).unwrap(); + f.docs.sync_from_disk(); + assert_eq!(f.docs.snapshot(info.id).unwrap().version, info.version); + assert_eq!(f.docs.snapshot(info.id).unwrap().text, info.text); f.docs.close(info.id); } diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index b19a32a3..91e0781e 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -50,6 +50,12 @@ pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResu ide_host::ops::doc::save_all(&state.host, window.label()).await } +#[tauri::command] +#[specta::specta] +pub async fn doc_sync_from_disk(window: Window, state: State<'_, AppState>) -> IdeResult<()> { + ide_host::ops::doc::sync_from_disk(&state.host, window.label()).await +} + #[tauri::command] #[specta::specta] pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a031514b..3eeea13c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -143,6 +143,7 @@ pub fn builder() -> tauri_specta::Builder { doc::doc_edit, doc::doc_save, doc::doc_save_all, + doc::doc_sync_from_disk, doc::doc_snapshot, doc::doc_ui_mode, doc::doc_set_encoding, diff --git a/src/bindings.ts b/src/bindings.ts index bc6221f5..9af2daa4 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -199,6 +199,7 @@ export const commands = { docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })), docSave: (doc: DocId) => typedError(__TAURI_INVOKE("doc_save", { doc })), docSaveAll: () => typedError(__TAURI_INVOKE("doc_save_all")), + docSyncFromDisk: () => typedError(__TAURI_INVOKE("doc_sync_from_disk")), docSnapshot: (doc: DocId) => typedError(__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[], diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index bcaefa95..670c8632 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -38,6 +38,8 @@ export interface DocBackend { close(doc: DocId): Promise; edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise; save(doc: DocId): Promise; + /** Re-check every open file for external changes missed by file watchers. */ + syncFromDisk?(): Promise; snapshot(doc: DocId): Promise; setLineEnding(doc: DocId, lineEnding: LineEnding): Promise; /** "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)), diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 7bf9e028..cab61a97 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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); diff --git a/src/editor/core/diskSync.test.ts b/src/editor/core/diskSync.test.ts new file mode 100644 index 00000000..a6354360 --- /dev/null +++ b/src/editor/core/diskSync.test.ts @@ -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()), + 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((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((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(); + } + }); +}); diff --git a/src/editor/core/diskSync.ts b/src/editor/core/diskSync.ts new file mode 100644 index 00000000..8ca828a2 --- /dev/null +++ b/src/editor/core/diskSync.ts @@ -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, canSync: () => boolean): () => void { + let timer: ReturnType | 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); + }; +} diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index 67542ccd..d47ca839 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -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); diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 9a5bc568..8bf6e185 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -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], diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index f44d76c7..ecd39bfb 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -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", + }, ], }, { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e2c86d14..6d9016ca 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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:": "保存到文件:",