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:": "保存到文件:",