Skip to content

Commit ef45c07

Browse files
committed
fix(gpui): harden repo refresh and rendering
1 parent b503b3e commit ef45c07

52 files changed

Lines changed: 2701 additions & 480 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 732 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shell/gpui/Cargo.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ path = "src/main.rs"
1313
[dependencies]
1414
jayjay-core = { path = "../../crates/jayjay-core" }
1515
jayjay-network = { path = "../../crates/jayjay-network" }
16-
gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.4.4" }
17-
gpui_platform = { git = "https://github.com/zed-industries/zed", tag = "v1.4.4", features = ["font-kit", "runtime_shaders"] }
16+
gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.6.3" }
17+
# font-kit/runtime_shaders are macOS-only forwards; wayland/x11 enable the Linux
18+
# windowing backends (no-ops elsewhere). Without them the Linux binary cannot open a window.
19+
gpui_platform = { git = "https://github.com/zed-industries/zed", tag = "v1.6.3", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
1820
chrono = { workspace = true }
1921
font-kit = "0.14"
2022
md-5 = "0.11"
@@ -24,8 +26,9 @@ notify = "8"
2426
flume = "0.12"
2527
serde = { workspace = true }
2628
toml = { workspace = true }
27-
unicode-segmentation = "1"
29+
unicode-segmentation = { workspace = true }
30+
unicode-width = { workspace = true }
2831

2932
[dev-dependencies]
30-
gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.4.4", features = ["test-support"] }
33+
gpui = { git = "https://github.com/zed-industries/zed", tag = "v1.6.3", features = ["test-support"] }
3134
jj-test = { path = "../../crates/jj-test" }

shell/gpui/src/app/actions.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ actions!(
44
jayjay,
55
[
66
OpenSettings,
7+
// cmd-w: close the focused window (after dismissing any open overlay).
78
CloseWindow,
9+
// escape: dismiss an open overlay without closing the window.
10+
Dismiss,
811
Refresh,
912
OpenCommandPalette,
1013
OpenFind,

shell/gpui/src/app/config/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod diff;
77
pub mod features;
88
pub mod layout;
99
pub mod store;
10+
pub mod telemetry;
1011
pub mod tools;
1112
pub mod window;
1213

@@ -19,6 +20,7 @@ pub use diff::DiffConfig;
1920
pub use features::FeaturesConfig;
2021
pub use layout::LayoutConfig;
2122
pub use store::{AppConfigStore, current, update};
23+
pub use telemetry::TelemetryConfig;
2224
pub use tools::ToolsConfig;
2325
pub use window::WindowState;
2426

@@ -32,6 +34,7 @@ pub struct AppConfig {
3234
pub layout: LayoutConfig,
3335
pub tools: ToolsConfig,
3436
pub features: FeaturesConfig,
37+
pub telemetry: TelemetryConfig,
3538
pub window: WindowState,
3639
}
3740

@@ -45,6 +48,7 @@ impl Default for AppConfig {
4548
layout: LayoutConfig::default(),
4649
tools: ToolsConfig::default(),
4750
features: FeaturesConfig::default(),
51+
telemetry: TelemetryConfig::default(),
4852
window: WindowState::default(),
4953
}
5054
}
@@ -104,6 +108,12 @@ mod tests {
104108
assert_eq!(cfg, AppConfig::default());
105109
}
106110

111+
#[test]
112+
fn telemetry_is_disabled_by_default() {
113+
let cfg = AppConfig::default();
114+
assert!(!cfg.telemetry.enabled);
115+
}
116+
107117
#[test]
108118
fn unknown_keys_are_ignored() {
109119
let s = "appearance = \"dark\"\nunknown_root_key = 42\n";
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
4+
#[serde(default)]
5+
pub struct TelemetryConfig {
6+
/// Anonymous daily ping (app version, OS, arch). No personal data; opt in by setting true.
7+
pub enabled: bool,
8+
}

shell/gpui/src/app/fs_watcher.rs

Lines changed: 0 additions & 94 deletions
This file was deleted.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
use std::path::{Path, PathBuf};
2+
use std::sync::{Arc, Mutex};
3+
use std::time::{Duration, Instant};
4+
5+
use flume::Sender;
6+
use gpui::{App, Global};
7+
use notify::event::{EventKind, ModifyKind};
8+
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
9+
10+
const OP_DEBOUNCE: Duration = Duration::from_millis(1000);
11+
const WC_DEBOUNCE: Duration = Duration::from_millis(2000);
12+
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub enum FsEvent {
15+
OpHeads,
16+
WorkingCopy,
17+
}
18+
19+
/// Returns `true` if any path is unignored. Mirrors `hasUnignoredWorkingCopyPaths` in SwiftUI.
20+
pub type IsRelevantWcChange = Arc<dyn Fn(&[PathBuf]) -> bool + Send + Sync>;
21+
22+
/// When set, the watcher is armed but the real `notify` OS thread isn't spawned — tests
23+
/// install this so the FSEvents loop can't trip the GPUI scheduler's nondeterminism guard.
24+
#[derive(Default)]
25+
struct WatcherSuppressed(bool);
26+
27+
impl Global for WatcherSuppressed {}
28+
29+
/// True when the real OS watcher must not be spawned (test scheduler is active).
30+
pub fn is_watcher_suppressed(cx: &App) -> bool {
31+
cx.try_global::<WatcherSuppressed>().is_some_and(|s| s.0)
32+
}
33+
34+
/// Suppress real OS-thread watchers for the rest of this process. Tests call this.
35+
pub fn suppress_for_tests(cx: &mut App) {
36+
cx.set_global(WatcherSuppressed(true));
37+
}
38+
39+
/// What an FS event means before debounce, derived purely from its kind and paths.
40+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41+
enum EventClass {
42+
/// Metadata-only / uninteresting, or a `.jj/` internal we already cover via op_heads.
43+
Ignore,
44+
/// A jj operation landed (op_heads changed) — refresh the graph.
45+
OpHeads,
46+
/// A working-copy path changed; relevance + debounce still gate the send.
47+
WorkingCopy,
48+
}
49+
50+
/// Static per-watcher config used to classify each raw event.
51+
struct PathClassifier {
52+
op_heads_dir: PathBuf,
53+
repo_root: PathBuf,
54+
}
55+
56+
impl PathClassifier {
57+
fn classify(&self, event: &notify::Event) -> EventClass {
58+
// Metadata-only events — Spotlight / Time Machine spam them on macOS.
59+
let interesting = matches!(
60+
&event.kind,
61+
EventKind::Create(_)
62+
| EventKind::Remove(_)
63+
| EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_))
64+
);
65+
if !interesting {
66+
return EventClass::Ignore;
67+
}
68+
let touches_op_heads = event
69+
.paths
70+
.iter()
71+
.any(|p| p == &self.op_heads_dir || p.starts_with(&self.op_heads_dir));
72+
if touches_op_heads {
73+
return EventClass::OpHeads;
74+
}
75+
// Other `.jj/` internals are already handled via op_heads above.
76+
let in_jj_dir = event.paths.iter().all(|p| {
77+
p.strip_prefix(&self.repo_root)
78+
.is_ok_and(starts_with_dot_jj)
79+
});
80+
if in_jj_dir {
81+
return EventClass::Ignore;
82+
}
83+
EventClass::WorkingCopy
84+
}
85+
}
86+
87+
/// Leading-edge debounce timestamps for the two event streams.
88+
struct Debounce {
89+
last_op: Instant,
90+
last_wc: Instant,
91+
}
92+
93+
impl Debounce {
94+
fn fresh() -> Self {
95+
Self {
96+
last_op: Instant::now() - OP_DEBOUNCE,
97+
last_wc: Instant::now() - WC_DEBOUNCE,
98+
}
99+
}
100+
101+
/// Whether enough time has elapsed to emit another op-heads refresh.
102+
fn op_ready(&self, now: Instant) -> bool {
103+
now.duration_since(self.last_op) >= OP_DEBOUNCE
104+
}
105+
106+
/// Whether enough time has elapsed to emit another working-copy refresh.
107+
fn wc_ready(&self, now: Instant) -> bool {
108+
now.duration_since(self.last_wc) >= WC_DEBOUNCE
109+
}
110+
}
111+
112+
pub struct RepoFsWatcher {
113+
_watcher: RecommendedWatcher,
114+
}
115+
116+
impl RepoFsWatcher {
117+
pub fn new(
118+
repo_path: &Path,
119+
tx: Sender<FsEvent>,
120+
is_relevant_wc_change: IsRelevantWcChange,
121+
) -> Option<Self> {
122+
let classifier = PathClassifier {
123+
// Match by prefix: jj writes one file per head under this dir.
124+
op_heads_dir: repo_path.join(".jj/repo/op_heads/heads"),
125+
repo_root: repo_path.to_path_buf(),
126+
};
127+
let debounce = Mutex::new(Debounce::fresh());
128+
129+
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
130+
let Ok(event) = res else {
131+
return;
132+
};
133+
if let Some(out) = next_event(
134+
&classifier,
135+
&debounce,
136+
&event,
137+
Instant::now(),
138+
is_relevant_wc_change.as_ref(),
139+
) {
140+
let _ = tx.send(out);
141+
}
142+
})
143+
.ok()?;
144+
145+
watcher.watch(repo_path, RecursiveMode::Recursive).ok()?;
146+
Some(Self { _watcher: watcher })
147+
}
148+
}
149+
150+
/// Decide whether a raw event should emit, stamping the debounce on a send. The window is
151+
/// checked before the relevance filter so a build storm is dropped without running the
152+
/// gitignore matcher more than once per `WC_DEBOUNCE`.
153+
fn next_event(
154+
classifier: &PathClassifier,
155+
debounce: &Mutex<Debounce>,
156+
event: &notify::Event,
157+
now: Instant,
158+
is_relevant_wc_change: &dyn Fn(&[PathBuf]) -> bool,
159+
) -> Option<FsEvent> {
160+
match classifier.classify(event) {
161+
EventClass::Ignore => None,
162+
EventClass::OpHeads => {
163+
let mut guard = debounce.lock().expect("op debounce lock");
164+
guard.op_ready(now).then(|| {
165+
guard.last_op = now;
166+
FsEvent::OpHeads
167+
})
168+
}
169+
EventClass::WorkingCopy => {
170+
// Read-only window check first; bail before touching the gitignore matcher.
171+
{
172+
let guard = debounce.lock().expect("wc debounce lock");
173+
if !guard.wc_ready(now) {
174+
return None;
175+
}
176+
}
177+
if !is_relevant_wc_change(&event.paths) {
178+
return None;
179+
}
180+
let mut guard = debounce.lock().expect("wc debounce lock");
181+
// Re-check under the lock: a concurrent event may have stamped it meanwhile.
182+
guard.wc_ready(now).then(|| {
183+
guard.last_wc = now;
184+
FsEvent::WorkingCopy
185+
})
186+
}
187+
}
188+
}
189+
190+
fn starts_with_dot_jj(rel: &Path) -> bool {
191+
rel.components()
192+
.next()
193+
.map(|c| c.as_os_str() == ".jj")
194+
.unwrap_or(false)
195+
}
196+
197+
#[cfg(test)]
198+
mod tests;

0 commit comments

Comments
 (0)