1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use crossterm::event::{Event, EventStream};
use futures_util::StreamExt;
use tokio::time::{MissedTickBehavior, interval, sleep};
use crate::watcher::{self, WatchEvent};
use super::{App, EditorInvocation, KeyEffect, ViewMode};
/// Async event loop. See ADR-0003 / ADR-0005.
pub async fn run() -> Result<()> {
use std::io::Write;
let log_path = std::env::var("KIZU_STARTUP_TIMING_FILE").ok();
let stage = |label: &str, t: Instant| {
if let Some(path) = log_path.as_deref()
&& let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "[kizu-startup] {label:<28} +{:?}", t.elapsed());
}
};
let t_total = Instant::now();
let cwd = std::env::current_dir().context("reading current directory")?;
stage("current_dir", t_total);
let mut terminal = ratatui::try_init().context("initializing terminal")?;
// Enable bracketed paste so terminals send IME-committed text
// (e.g. Japanese kanji) as Event::Paste instead of individual
// keystrokes. Without this, IME composition is invisible in
// raw mode and committed text may arrive garbled.
{
use crossterm::ExecutableCommand;
let _ = std::io::stdout().execute(crossterm::event::EnableBracketedPaste);
}
stage("ratatui::try_init", t_total);
let result = async {
// Show something immediately, even before the initial bootstrap
// `git diff` completes. On large repos this avoids a black screen
// during the synchronous bootstrap shell-outs.
terminal
.draw(|frame| {
let area = frame.area();
frame.render_widget(
ratatui::widgets::Paragraph::new("Loading kizu...")
.alignment(ratatui::layout::Alignment::Center),
area,
);
})
.context("ratatui loading draw")?;
stage("draw Loading...", t_total);
let t_bootstrap = Instant::now();
let mut app = App::bootstrap(cwd)?;
stage("App::bootstrap", t_bootstrap);
// Write session file so the Stop hook can scope its scan
// to files changed since this baseline. Best-effort: a
// failure here is not fatal to the TUI itself.
if let Err(e) = crate::session::write_session(&app.root, &app.baseline_sha) {
eprintln!("warning: failed to write kizu session file: {e}");
}
// Session isolation: older events are now filtered on ingest
// via `session_start_ms` rather than by bulk-deleting the
// shared events directory. The delete path used to destroy a
// concurrently-running kizu session's live history on the
// same project; the filter approach is non-destructive.
// Diff snapshots were already seeded inside `App::bootstrap`
// from the same `git diff` that produced the initial file
// list, so the first stream event shows only the per-op
// delta without an extra startup subprocess sweep.
// Draw one static frame before watcher startup. On macOS the
// PollWatcher fallback may take noticeable time to arm because it
// performs an initial scan; showing the bootstrap snapshot first
// keeps startup feeling immediate instead of blank-screening until
// watcher init finishes.
terminal
.draw(|frame| crate::ui::render(frame, &app))
.context("ratatui initial draw")?;
stage("draw bootstrap snapshot", t_total);
let t_watcher = Instant::now();
let mut watch = watcher::start(
&app.root,
&app.git_dir,
&app.common_git_dir,
app.current_branch_ref.as_deref(),
)?;
stage("watcher::start", t_watcher);
// Replay any event files that were written in the gap
// between `clean_stale_events` and `watcher::start`. Without
// this the next event for the same file would absorb the
// dropped operation's contents into its op_diff because the
// seeded snapshot is still the pre-edit state. Dedup inside
// `handle_event_log` makes this safe even when the watcher
// later re-delivers the same file.
if let Some(events_dir) = crate::paths::events_dir(&app.root) {
app.replay_events_dir(&events_dir);
}
stage("replay startup-gap events", t_total);
let result = run_loop(&mut terminal, &mut app, &mut watch).await;
crate::session::remove_session(&app.root);
result
}
.await;
{
use crossterm::ExecutableCommand;
let _ = std::io::stdout().execute(crossterm::event::DisableBracketedPaste);
}
let _ = ratatui::try_restore();
result
}
async fn run_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
watch: &mut watcher::WatchHandle,
) -> Result<()> {
let mut events = EventStream::new();
// ~60 fps frame tick. Only polled inside `select!` when an animation
// is live — idle frames never pay the cost. `Skip` means a long
// idle gap doesn't turn into a burst of catch-up ticks once the
// user kicks off a new animation.
let mut frame = interval(Duration::from_millis(16));
frame.set_missed_tick_behavior(MissedTickBehavior::Skip);
// notify backends can have a short arm-up window right after startup.
// Without a one-shot self-heal refresh, an edit that lands during that
// gap can be missed forever until the *next* filesystem event. The
// existing watcher tests used `sleep(150ms)` to paper over this; the app
// should instead recover on its own.
let startup_refresh = sleep(Duration::from_millis(400));
tokio::pin!(startup_refresh);
let mut startup_refresh_pending = true;
while !app.should_quit {
// Draw at the top of the loop so the bootstrap state is visible
// before we ever block on `select!`.
terminal
.draw(|frame| crate::ui::render(frame, app))
.context("ratatui draw")?;
// Retire finished animations after the frame that showed their
// final position — the next frame will then draw the static
// target without another tween sample.
app.tick_anim(Instant::now());
app.tick_file_view_anim();
tokio::select! {
event = events.next() => {
match event {
Some(Ok(Event::Key(key))) => {
app.input_health = None;
let effect = app.handle_key(key);
match effect {
KeyEffect::OpenEditor(inv) => {
if let Err(err) = run_external_editor(terminal, inv) {
app.last_error = Some(format!("editor: {err:#}"));
}
}
other => apply_key_effect(other, app, watch),
}
}
Some(Ok(Event::Paste(text))) => {
app.input_health = None;
app.handle_paste(&text);
}
Some(Ok(_)) => {
app.input_health = None;
}
Some(Err(e)) => {
app.input_health = Some(format!("input: {e}"));
}
None => {
app.input_health = Some("input: event stream ended".into());
app.should_quit = true;
}
}
}
watch_event = watch.events.recv() => {
if let Some(first) = watch_event {
startup_refresh_pending = false;
// Drain any events that piled up behind `first` and
// hand the whole burst to `handle_watch_burst` so the
// coalescing + health-transition rules stay testable
// in one place.
let mut burst: Vec<WatchEvent> = vec![first];
while let Ok(more) = watch.events.try_recv() {
burst.push(more);
}
let (need_recompute, need_head_dirty) = app.handle_watch_burst(burst);
if need_recompute {
watch.refresh_worktree_watches();
// In stream mode, don't overwrite files/layout with
// git diff — the scroll view shows stream events.
// The diff will be refreshed when the user tabs back.
if app.view_mode != ViewMode::Stream {
app.recompute_diff();
}
}
if need_head_dirty {
app.mark_head_dirty();
}
} else {
app.last_error = Some("watcher: event channel closed".into());
app.should_quit = true;
}
}
_ = &mut startup_refresh, if startup_refresh_pending => {
startup_refresh_pending = false;
if app.view_mode != ViewMode::Stream {
app.recompute_diff();
}
}
_ = frame.tick(), if app.anim.is_some() || app.file_view.as_ref().is_some_and(|fv| fv.anim.is_some()) => {
// The tick itself carries no payload — falling through
// the bottom of the select! loops back to the `draw`
// call at the top, which is the whole point.
}
}
}
Ok(())
}
/// Dispatch post-key-handler side effects back onto the watcher.
/// Factored out so `run_loop` stays focused on the event-loop
/// plumbing and tests can reason about the effect contract without
/// spinning up a real terminal.
fn apply_key_effect(effect: KeyEffect, app: &App, watch: &watcher::WatchHandle) {
match effect {
KeyEffect::None => {}
KeyEffect::ReconfigureWatcher => {
watch.update_current_branch_ref(app.current_branch_ref.as_deref());
}
KeyEffect::OpenEditor(_) => {
// Handled inline inside `run_loop`: the editor
// spawn needs mutable access to the terminal for the
// suspend/resume dance, which this `&App` /
// `&WatchHandle` helper cannot provide. Any
// `OpenEditor` that reaches this arm is a caller
// bug.
debug_assert!(
false,
"OpenEditor must be handled by run_loop, not apply_key_effect"
);
}
}
}
/// Suspend the ratatui terminal, run an external editor
/// synchronously, then re-enter the alternate screen and force a
/// full repaint on the next draw tick. Blocks the event loop for
/// the editor's lifetime — intentional, because the user is
/// inside the editor anyway and no diff-view update would be
/// visible under it.
fn run_external_editor(
terminal: &mut ratatui::DefaultTerminal,
invocation: EditorInvocation,
) -> Result<()> {
use crossterm::{
ExecutableCommand,
event::{DisableBracketedPaste, EnableBracketedPaste},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use std::io::stdout;
// Tear down the ratatui terminal state so the child editor
// sees a plain cooked terminal. Errors here are surfaced —
// half-suspended state is worse than not launching the editor
// at all.
disable_raw_mode().context("disable raw mode before editor")?;
let mut out = stdout();
out.execute(LeaveAlternateScreen)
.context("leave alternate screen before editor")?;
out.execute(DisableBracketedPaste).ok();
let status = std::process::Command::new(&invocation.program)
.args(&invocation.args)
.status()
.with_context(|| format!("spawning editor `{}`", invocation.program));
// Always re-arm the alternate screen + raw mode even if the
// spawn itself failed. Otherwise a mistyped `$EDITOR` would
// leave the user stranded at a raw-mode prompt.
enable_raw_mode().context("re-enable raw mode after editor")?;
stdout()
.execute(EnterAlternateScreen)
.context("re-enter alternate screen after editor")?;
stdout().execute(EnableBracketedPaste).ok();
terminal.clear().ok();
let status = status?;
if !status.success() {
return Err(anyhow!(
"editor `{}` exited with status {}",
invocation.program,
status
));
}
Ok(())
}