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
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::editor::file_watcher::FileWatcher;
use crate::log_store::{DEFAULT_QUOTA_BYTES, LogStore};
use crate::registers::Registers;
use crate::task_config::TasksFile;
use crate::task_executor::TaskExecutor;
use crate::task_registry::TaskRegistry;
use crate::views::{
command_runner::CommandRunner, commit::CommitWindow, editor::EditorView,
extension_config::ExtensionConfigView, file_selector::FileSelector,
git_branches::BranchView, git_history::GitHistoryView, git_pull::GitPullView,
issue_view::IssueView, log_view::LogView,
task_archive::TaskArchiveView, terminal::TerminalView,
};
use crate::{file_index::SharedRegistry, issue_registry::IssueRegistry, project, settings, theme};
/// Lightweight discriminant — used in `Operation::SwitchScreen` without
/// carrying a full view value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenKind {
FileSelector,
#[allow(dead_code)]
Editor,
CommandRunner,
LogView,
CommitWindow,
Terminal,
GitHistory,
GitBranches,
GitHistoryEditor,
ExtensionConfig,
TaskArchive,
IssueList,
GitPull,
}
#[derive(Debug)]
pub enum Screen {
FileSelector(FileSelector),
Editor(Box<EditorView>),
CommandRunner(CommandRunner),
LogView(LogView),
CommitWindow(Box<CommitWindow>),
Terminal(Box<TerminalView>),
GitHistory(Box<GitHistoryView>),
GitBranches(Box<BranchView>),
GitHistoryEditor(Box<crate::views::git_history_editor::GitHistoryEditorView>),
ExtensionConfig(ExtensionConfigView),
TaskArchive(Box<TaskArchiveView>),
IssueList(Box<IssueView>),
GitPull(Box<GitPullView>),
}
impl Screen {
/// Returns the [`crate::views::ViewKind`] of the currently active screen.
pub fn view_kind(&self) -> crate::views::ViewKind {
use crate::views::{
View, command_runner::CommandRunner, commit::CommitWindow, editor::EditorView,
extension_config::ExtensionConfigView, file_selector::FileSelector,
git_branches::BranchView, git_history::GitHistoryView,
git_history_editor::GitHistoryEditorView, git_pull::GitPullView,
issue_view::IssueView,
log_view::LogView, task_archive::TaskArchiveView, terminal::TerminalView,
};
match self {
Screen::Editor(_) => EditorView::KIND,
Screen::Terminal(_) => TerminalView::KIND,
Screen::CommitWindow(_) => CommitWindow::KIND,
Screen::GitHistory(_) => GitHistoryView::KIND,
Screen::GitBranches(_) => BranchView::KIND,
Screen::GitHistoryEditor(_) => GitHistoryEditorView::KIND,
Screen::GitPull(_) => GitPullView::KIND,
Screen::FileSelector(_) => FileSelector::KIND,
Screen::CommandRunner(_) => CommandRunner::KIND,
Screen::LogView(_) => LogView::KIND,
Screen::ExtensionConfig(_) => ExtensionConfigView::KIND,
Screen::TaskArchive(_) => TaskArchiveView::KIND,
Screen::IssueList(_) => IssueView::KIND,
}
}
/// Return the lightweight `ScreenKind` corresponding to this `Screen`.
pub fn kind(&self) -> ScreenKind {
match self {
Screen::FileSelector(_) => ScreenKind::FileSelector,
Screen::Editor(_) => ScreenKind::Editor,
Screen::CommandRunner(_) => ScreenKind::CommandRunner,
Screen::LogView(_) => ScreenKind::LogView,
Screen::CommitWindow(_) => ScreenKind::CommitWindow,
Screen::Terminal(_) => ScreenKind::Terminal,
Screen::GitHistory(_) => ScreenKind::GitHistory,
Screen::GitBranches(_) => ScreenKind::GitBranches,
Screen::GitHistoryEditor(_) => ScreenKind::GitHistoryEditor,
Screen::GitPull(_) => ScreenKind::GitPull,
Screen::ExtensionConfig(_) => ScreenKind::ExtensionConfig,
Screen::TaskArchive(_) => ScreenKind::TaskArchive,
Screen::IssueList(_) => ScreenKind::IssueList,
}
}
}
pub struct AppState {
pub screen: Screen,
pub status_msg: Option<String>,
pub should_quit: bool,
pub project: project::Project,
pub settings: settings::Settings,
pub theme: theme::Theme,
/// Derived each frame by `recompute_contexts` — never mutated directly.
pub active_contexts: HashSet<String>,
/// Background file registry shared with every `FileSelector` instance.
pub registry: SharedRegistry,
/// Stashed CommitWindow — preserved when navigating to another screen so
/// state (staged files, message, cursor, etc.) survives a round-trip.
pub stashed_commit_window: Option<Box<CommitWindow>>,
/// Stashed primary screen — preserved when opening a modal so that a
/// primary view (Editor, Terminal, CommitWindow, GitHistory) can be
/// restored when modals close. Stores the full `Screen` value.
pub stashed_primary: Option<Screen>,
/// Stashed TerminalView — preserved when navigating away so PTY sessions
/// survive switching to the file selector or editor and back.
pub stashed_terminal: Option<Box<TerminalView>>,
/// Authoritative clipboard history (yank ring).
pub registers: Registers,
/// System clipboard handle — initialised once at startup, best-effort.
/// `None` when the OS clipboard is unavailable (headless systems, etc.).
pub clipboard: Option<arboard::Clipboard>,
/// Parsed schema cache: maps schema JSON -> parsed Value. Primary cache
/// stored on AppState so completion tasks can reuse the same parsed tree.
/// This map is mutated only on the main thread while `AppState` is mutable
/// (e.g., when handling operations); background tasks must be given a
/// cloned Arc<serde_json::Value> to avoid concurrent mutation.
pub schema_cache: HashMap<String, Arc<Value>>,
/// Handle for the currently in-flight async file-selector search task.
/// Aborted (and replaced) each time the filter changes.
pub file_selector_search: Option<tokio::task::JoinHandle<()>>,
/// Global file watcher for detecting external modifications.
pub file_watcher: FileWatcher,
/// Central in-memory store for all IDE issues (ephemeral + persistent).
/// Ephemeral issues are cleared on IDE restart (new registry = empty).
/// The Persistent Component populates this via `load_persistent` at startup.
pub issue_registry: IssueRegistry,
/// Central scheduler for all named-queue tasks (build, lint, test, etc.).
/// Lives on the main thread; async workers communicate results via `op_tx`.
pub task_registry: TaskRegistry,
/// Async process runner — spawns tasks and streams their output.
pub task_executor: TaskExecutor,
/// On-disk log storage for task output.
pub log_store: LogStore,
/// Parsed task configuration from `.oo/tasks.yaml`.
///
/// `None` if the file does not exist or failed validation.
pub task_config: Option<TasksFile>,
/// Click regions produced by the last status-bar render.
/// Used by `handle_mouse` to dispatch menu/cancel actions.
pub status_bar_clicks: crate::widgets::status_bar::StatusBarClickState,
/// Currently open context menu, if any.
///
/// Set by `Operation::OpenContextMenu`, cleared by `Operation::CloseContextMenu`
/// or when the user selects an item. `app.rs` intercepts input and renders
/// the menu as a floating overlay when this is `Some`.
pub context_menu: Option<crate::widgets::context_menu::ContextMenu>,
/// Cancellation token for any in-flight project-wide search spawned from the
/// editor's Expanded search bar. Replaced (and the old token cancelled) each
/// time a new search starts.
pub project_search_cancel: Option<tokio_util::sync::CancellationToken>,
/// Shared atomic generation counter that the search aggregator thread
/// checks before flushing batches. Updated by the main thread when a new
/// search starts. This is a best-effort optimisation: the receiver-side
/// generation filter in `editor.rs` remains the correctness mechanism.
pub project_search_generation_shared: std::sync::Arc<std::sync::atomic::AtomicU64>,
/// LSP trigger characters per language (populated from server capabilities).
pub lsp_trigger_chars: HashMap<String, Vec<String>>,
/// Monotonically-increasing counter bumped on every key/mouse event while
/// the editor is active. The idle-hover task captures the value at spawn
/// time and discards itself if it no longer matches when the 300 ms delay
/// expires.
pub hover_idle_generation: u64,
/// Handle for the currently pending idle-hover task. Aborted (and
/// replaced) each time the cursor moves or the user types.
pub hover_idle_task: Option<tokio::task::JoinHandle<()>>,
/// Monotonically increasing generation counter for project searches.
/// Incremented each time a new search is triggered; threaded through
/// `ClearProjectResults` and `AddProjectResult` so the editor can discard
/// results that belong to a superseded search.
pub project_search_generation: u64,
/// Search state saved across file switches triggered by `GoToProjectMatch`.
/// Restored into the new `EditorView` after `OpenFile` creates it.
pub pending_search_state: Option<crate::views::editor::SearchState>,
/// Cursor position to apply after the next `OpenFile` creates a new
/// `EditorView` (used by `GoToProjectMatch` to jump to the match).
pub pending_go_to_position: Option<(usize, usize)>,
}
impl AppState {
pub fn new(
screen: Screen,
project: project::Project,
settings: settings::Settings,
registry: SharedRegistry,
) -> Self {
let theme = theme::Theme::resolve(&settings);
let log_dir = project.project_settings_path.join("cache").join("tasks");
let tasks_yaml = project.project_settings_path.join("tasks.yaml");
let task_config = match crate::task_config::load(&tasks_yaml) {
Ok(cfg) => cfg,
Err(errs) => {
for e in &errs {
log::warn!("tasks.yaml: {}", e);
}
None
}
};
// Clone the matcher registry from the project's extension manager so
// the TaskExecutor can snapshot active matchers when spawning tasks.
let matcher_registry = project.extension_manager.matcher_registry_arc();
Self {
screen,
project,
settings,
theme,
registry,
status_msg: None,
should_quit: false,
active_contexts: HashSet::new(),
stashed_commit_window: None,
stashed_primary: None,
stashed_terminal: None,
registers: Registers::new(20),
clipboard: None,
schema_cache: HashMap::new(),
file_selector_search: None,
file_watcher: FileWatcher::new(),
issue_registry: IssueRegistry::new(),
task_registry: TaskRegistry::new(),
task_executor: TaskExecutor::with_matcher_registry(matcher_registry),
log_store: LogStore::new(log_dir, DEFAULT_QUOTA_BYTES),
task_config,
status_bar_clicks: crate::widgets::status_bar::StatusBarClickState::default(),
context_menu: None,
project_search_cancel: None,
project_search_generation_shared: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
lsp_trigger_chars: HashMap::new(),
hover_idle_generation: 0,
hover_idle_task: None,
project_search_generation: 0,
pending_search_state: None,
pending_go_to_position: None,
}
}
/// Called once per frame, after operations are applied.
/// Each subsystem contributes its own tags — no push/pop anywhere else.
pub fn recompute_contexts(&mut self) {
self.active_contexts.clear();
self.active_contexts.insert("global".into());
match &self.screen {
Screen::Editor(ed) => {
self.active_contexts.insert("editor".into());
if ed.buffer.is_dirty() {
self.active_contexts.insert("editor.dirty".into());
}
}
Screen::FileSelector(_) => {
self.active_contexts.insert("file_selector".into());
}
Screen::CommandRunner(_) => {
self.active_contexts.insert("command_runner".into());
}
Screen::LogView(_) => {
self.active_contexts.insert("log_view".into());
}
Screen::CommitWindow(_) => {
self.active_contexts.insert("commit".into());
}
Screen::Terminal(_) => {
self.active_contexts.insert("terminal".into());
}
Screen::GitHistory(_) => {
self.active_contexts.insert("git_history".into());
}
Screen::GitBranches(_) => {
self.active_contexts.insert("git_branches".into());
}
Screen::GitHistoryEditor(_) => {
self.active_contexts.insert("git_history_editor".into());
}
Screen::GitPull(_) => {
self.active_contexts.insert("git_pull".into());
}
Screen::ExtensionConfig(_) => {
self.active_contexts.insert("extension_config".into());
}
Screen::TaskArchive(_) => {
self.active_contexts.insert("task_archive".into());
}
Screen::IssueList(_) => {
self.active_contexts.insert("issue_list".into());
}
}
if self.project.has_git_repo() {
self.active_contexts.insert("git_repo".into());
}
}
}
impl AppState {
/// Replace the active screen.
///
/// Opens a file while preserving any stashed primary view state.
///
/// This is the state-management core of `Operation::OpenFile`: it flushes
/// any stashed editor into `project.editor_state` so that `take_buffer` can
/// restore unsaved changes, cursor, and undo history for the target file.
///
/// Returns `true` if the file was opened successfully, `false` on I/O error.
pub fn open_file_preserving_stash(&mut self, path: std::path::PathBuf) -> bool {
self.save_stashed_primary();
let folds = self.project.get_fold_state(&path);
match self.project.take_buffer(path) {
Ok(buf) => {
let ed = crate::views::editor::EditorView::open(buf, folds, &self.settings);
self.set_screen(Screen::Editor(Box::new(ed)));
true
}
Err(_) => false,
}
}
/// Consumes `stashed_primary` and saves each view type into its persistent slot
/// so state survives Primary→Modal→Primary transitions.
///
/// Must be called BEFORE `project.take_buffer()` for the Editor case, since
/// take_buffer reads from `editor_state.files` which is only populated after this runs.
/// Also called by `set_screen` as a safety net for Terminal/CommitWindow stashing.
pub(crate) fn save_stashed_primary(&mut self) {
let Some(stashed) = self.stashed_primary.take() else {
return;
};
match stashed {
Screen::Editor(mut ed) => {
// Stop file watching (idempotent if already stopped).
ed.stop_file_watching();
if let Some(ref path) = ed.buffer.path.clone() {
self.file_watcher.unwatch(path).ok();
}
let (buf, folds) = ed.into_state();
self.project.stash_buffer(buf, folds);
}
Screen::Terminal(tv) => {
// Move PTY session to the terminal stash slot so it stays alive.
self.stashed_terminal = Some(tv);
}
Screen::CommitWindow(cw) => {
// Preserve form state (staged files, message, cursor, etc.).
self.stashed_commit_window = Some(cw);
}
_ => {
// GitHistory and other primaries are ephemeral; always reloaded from git.
}
}
}
/// If transitioning from a Primary -> Modal view and no primary is currently
/// stashed, move the previous screen into `stashed_primary` and return None
/// (caller should not attempt to consume the previous screen).
/// Otherwise return Some(prev) so callers can run `consume_prev_screen`.
pub fn set_screen(&mut self, new_screen: Screen) -> Option<Screen> {
use crate::views::ViewKind;
let prev = std::mem::replace(&mut self.screen, new_screen);
let entering_modal = matches!(self.screen.view_kind(), ViewKind::Modal);
let was_primary = matches!(prev.view_kind(), ViewKind::Primary);
let now_primary = matches!(self.screen.view_kind(), ViewKind::Primary);
// If transitioning from Primary -> Modal, stash the previous primary.
if entering_modal && was_primary {
// Overwrite any existing stash — single-slot policy.
self.stashed_primary = Some(prev);
// Caller should NOT attempt to consume the previous screen; it has been moved.
None
} else {
// If we've moved to a primary screen, save stashed state before clearing.
if now_primary {
self.save_stashed_primary();
}
Some(prev)
}
}
}