mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00: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
This commit is contained in:
@@ -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<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DocumentInfo> {
|
||||
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<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
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<IdeEvent> {
|
||||
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::<ide_core::settings::FileSettings>(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);
|
||||
}
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
||||
let paths: Vec<PathBuf> = 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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DocumentInfo> {
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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
Block a user