Skip to main content

oo_ide/
app_state.rs

1use serde_json::Value;
2use std::collections::{HashMap, HashSet};
3use std::sync::Arc;
4
5use crate::editor::file_watcher::FileWatcher;
6use crate::log_store::{DEFAULT_QUOTA_BYTES, LogStore};
7use crate::registers::Registers;
8use crate::task_config::TasksFile;
9use crate::task_executor::TaskExecutor;
10use crate::task_registry::TaskRegistry;
11use crate::views::{
12    command_runner::CommandRunner, commit::CommitWindow, editor::EditorView,
13    extension_config::ExtensionConfigView, file_selector::FileSelector,
14    git_branches::BranchView, git_history::GitHistoryView, git_pull::GitPullView,
15    issue_view::IssueView, log_view::LogView,
16    task_archive::TaskArchiveView, terminal::TerminalView,
17};
18use crate::{file_index::SharedFileIndex, issue_registry::IssueRegistry, project, settings, theme};
19
20/// Lightweight discriminant — used in `Operation::SwitchScreen` without
21/// carrying a full view value.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ScreenKind {
24    FileSelector,
25    #[allow(dead_code)]
26    Editor,
27    CommandRunner,
28    LogView,
29    CommitWindow,
30    Terminal,
31    GitHistory,
32    GitBranches,
33    GitHistoryEditor,
34    ExtensionConfig,
35    TaskArchive,
36    IssueList,
37    GitPull,
38}
39
40#[derive(Debug)]
41pub enum Screen {
42    FileSelector(FileSelector),
43    Editor(Box<EditorView>),
44    CommandRunner(CommandRunner),
45    LogView(LogView),
46    CommitWindow(Box<CommitWindow>),
47    Terminal(Box<TerminalView>),
48    GitHistory(Box<GitHistoryView>),
49    GitBranches(Box<BranchView>),
50    GitHistoryEditor(Box<crate::views::git_history_editor::GitHistoryEditorView>),
51    ExtensionConfig(ExtensionConfigView),
52    TaskArchive(Box<TaskArchiveView>),
53    IssueList(Box<IssueView>),
54    GitPull(Box<GitPullView>),
55}
56
57impl Screen {
58    /// Returns the [`crate::views::ViewKind`] of the currently active screen.
59    pub fn view_kind(&self) -> crate::views::ViewKind {
60        use crate::views::{
61            View, command_runner::CommandRunner, commit::CommitWindow, editor::EditorView,
62            extension_config::ExtensionConfigView, file_selector::FileSelector,
63            git_branches::BranchView, git_history::GitHistoryView,
64            git_history_editor::GitHistoryEditorView, git_pull::GitPullView,
65            issue_view::IssueView,
66            log_view::LogView, task_archive::TaskArchiveView, terminal::TerminalView,
67        };
68        match self {
69            Screen::Editor(_) => EditorView::KIND,
70            Screen::Terminal(_) => TerminalView::KIND,
71            Screen::CommitWindow(_) => CommitWindow::KIND,
72            Screen::GitHistory(_) => GitHistoryView::KIND,
73            Screen::GitBranches(_) => BranchView::KIND,
74            Screen::GitHistoryEditor(_) => GitHistoryEditorView::KIND,
75            Screen::GitPull(_) => GitPullView::KIND,
76            Screen::FileSelector(_) => FileSelector::KIND,
77            Screen::CommandRunner(_) => CommandRunner::KIND,
78            Screen::LogView(_) => LogView::KIND,
79            Screen::ExtensionConfig(_) => ExtensionConfigView::KIND,
80            Screen::TaskArchive(_) => TaskArchiveView::KIND,
81            Screen::IssueList(_) => IssueView::KIND,
82        }
83    }
84
85    /// Return the lightweight `ScreenKind` corresponding to this `Screen`.
86    pub fn kind(&self) -> ScreenKind {
87        match self {
88            Screen::FileSelector(_) => ScreenKind::FileSelector,
89            Screen::Editor(_) => ScreenKind::Editor,
90            Screen::CommandRunner(_) => ScreenKind::CommandRunner,
91            Screen::LogView(_) => ScreenKind::LogView,
92            Screen::CommitWindow(_) => ScreenKind::CommitWindow,
93            Screen::Terminal(_) => ScreenKind::Terminal,
94            Screen::GitHistory(_) => ScreenKind::GitHistory,
95            Screen::GitBranches(_) => ScreenKind::GitBranches,
96            Screen::GitHistoryEditor(_) => ScreenKind::GitHistoryEditor,
97            Screen::GitPull(_) => ScreenKind::GitPull,
98            Screen::ExtensionConfig(_) => ScreenKind::ExtensionConfig,
99            Screen::TaskArchive(_) => ScreenKind::TaskArchive,
100            Screen::IssueList(_) => ScreenKind::IssueList,
101        }
102    }
103}
104
105pub struct AppState {
106    pub screen: Screen,
107    pub status_msg: Option<String>,
108    pub should_quit: bool,
109    pub project: project::Project,
110    pub settings: settings::Settings,
111    pub theme: theme::Theme,
112    /// Derived each frame by `recompute_contexts` — never mutated directly.
113    pub active_contexts: HashSet<String>,
114    /// Background file index shared with every `FileSelector` instance.
115    /// `None` while the initial walk is still running.
116    pub file_index: SharedFileIndex,
117    /// Stashed CommitWindow — preserved when navigating to another screen so
118    /// state (staged files, message, cursor, etc.) survives a round-trip.
119    pub stashed_commit_window: Option<Box<CommitWindow>>,
120    /// Stashed primary screen — preserved when opening a modal so that a
121    /// primary view (Editor, Terminal, CommitWindow, GitHistory) can be
122    /// restored when modals close. Stores the full `Screen` value.
123    pub stashed_primary: Option<Screen>,
124    /// Stashed TerminalView — preserved when navigating away so PTY sessions
125    /// survive switching to the file selector or editor and back.
126    pub stashed_terminal: Option<Box<TerminalView>>,
127    /// Authoritative clipboard history (yank ring).
128    pub registers: Registers,
129    /// System clipboard handle — initialised once at startup, best-effort.
130    /// `None` when the OS clipboard is unavailable (headless systems, etc.).
131    pub clipboard: Option<arboard::Clipboard>,
132    /// Parsed schema cache: maps schema JSON -> parsed Value. Primary cache
133    /// stored on AppState so completion tasks can reuse the same parsed tree.
134    /// This map is mutated only on the main thread while `AppState` is mutable
135    /// (e.g., when handling operations); background tasks must be given a
136    /// cloned Arc<serde_json::Value> to avoid concurrent mutation.
137    pub schema_cache: HashMap<String, Arc<Value>>,
138    /// Handle for the currently in-flight async file-selector search task.
139    /// Aborted (and replaced) each time the filter changes.
140    pub file_selector_search: Option<tokio::task::JoinHandle<()>>,
141    /// Global file watcher for detecting external modifications.
142    pub file_watcher: FileWatcher,
143    /// Central in-memory store for all IDE issues (ephemeral + persistent).
144    /// Ephemeral issues are cleared on IDE restart (new registry = empty).
145    /// The Persistent Component populates this via `load_persistent` at startup.
146    pub issue_registry: IssueRegistry,
147    /// Central scheduler for all named-queue tasks (build, lint, test, etc.).
148    /// Lives on the main thread; async workers communicate results via `op_tx`.
149    pub task_registry: TaskRegistry,
150    /// Async process runner — spawns tasks and streams their output.
151    pub task_executor: TaskExecutor,
152    /// On-disk log storage for task output.
153    pub log_store: LogStore,
154    /// Parsed task configuration from `.oo/tasks.yaml`.
155    ///
156    /// `None` if the file does not exist or failed validation.
157    pub task_config: Option<TasksFile>,
158    /// Click regions produced by the last status-bar render.
159    /// Used by `handle_mouse` to dispatch menu/cancel actions.
160    pub status_bar_clicks: crate::widgets::status_bar::StatusBarClickState,
161    /// Currently open context menu, if any.
162    ///
163    /// Set by `Operation::OpenContextMenu`, cleared by `Operation::CloseContextMenu`
164    /// or when the user selects an item.  `app.rs` intercepts input and renders
165    /// the menu as a floating overlay when this is `Some`.
166    pub context_menu: Option<crate::widgets::context_menu::ContextMenu>,
167    /// Cancellation token for any in-flight project-wide search spawned from the
168    /// editor's Expanded search bar.  Replaced (and the old token cancelled) each
169    /// time a new search starts.
170    pub project_search_cancel: Option<tokio_util::sync::CancellationToken>,
171    /// Shared atomic generation counter that the search aggregator thread
172    /// checks before flushing batches.  Updated by the main thread when a new
173    /// search starts.  This is a best-effort optimisation: the receiver-side
174    /// generation filter in `editor.rs` remains the correctness mechanism.
175    pub project_search_generation_shared: std::sync::Arc<std::sync::atomic::AtomicU64>,
176    /// LSP trigger characters per language (populated from server capabilities).
177    pub lsp_trigger_chars: HashMap<String, Vec<String>>,
178    /// Monotonically-increasing counter bumped on every key/mouse event while
179    /// the editor is active.  The idle-hover task captures the value at spawn
180    /// time and discards itself if it no longer matches when the 300 ms delay
181    /// expires.
182    pub hover_idle_generation: u64,
183    /// Handle for the currently pending idle-hover task.  Aborted (and
184    /// replaced) each time the cursor moves or the user types.
185    pub hover_idle_task: Option<tokio::task::JoinHandle<()>>,
186    /// Monotonically increasing generation counter for project searches.
187    /// Incremented each time a new search is triggered; threaded through
188    /// `ClearProjectResults` and `AddProjectResult` so the editor can discard
189    /// results that belong to a superseded search.
190    pub project_search_generation: u64,
191    /// Search state saved across file switches triggered by `GoToProjectMatch`.
192    /// Restored into the new `EditorView` after `OpenFile` creates it.
193    pub pending_search_state: Option<crate::views::editor::SearchState>,
194    /// Cursor position to apply after the next `OpenFile` creates a new
195    /// `EditorView` (used by `GoToProjectMatch` to jump to the match).
196    pub pending_go_to_position: Option<(usize, usize)>,
197}
198
199impl AppState {
200    pub fn new(
201        screen: Screen,
202        project: project::Project,
203        settings: settings::Settings,
204        file_index: SharedFileIndex,
205    ) -> Self {
206        let theme = theme::Theme::resolve(&settings);
207        let log_dir = project.project_settings_path.join("cache").join("tasks");
208        let tasks_yaml = project.project_settings_path.join("tasks.yaml");
209        let task_config = match crate::task_config::load(&tasks_yaml) {
210            Ok(cfg) => cfg,
211            Err(errs) => {
212                for e in &errs {
213                    log::warn!("tasks.yaml: {}", e);
214                }
215                None
216            }
217        };
218        // Clone the matcher registry from the project's extension manager so
219        // the TaskExecutor can snapshot active matchers when spawning tasks.
220        let matcher_registry = project.extension_manager.matcher_registry_arc();
221
222        Self {
223            screen,
224            project,
225            settings,
226            theme,
227            file_index,
228            status_msg: None,
229            should_quit: false,
230            active_contexts: HashSet::new(),
231            stashed_commit_window: None,
232            stashed_primary: None,
233            stashed_terminal: None,
234            registers: Registers::new(20),
235            clipboard: None,
236            schema_cache: HashMap::new(),
237            file_selector_search: None,
238            file_watcher: FileWatcher::new(),
239            issue_registry: IssueRegistry::new(),
240            task_registry: TaskRegistry::new(),
241            task_executor: TaskExecutor::with_matcher_registry(matcher_registry),
242            log_store: LogStore::new(log_dir, DEFAULT_QUOTA_BYTES),
243            task_config,
244            status_bar_clicks: crate::widgets::status_bar::StatusBarClickState::default(),
245            context_menu: None,
246            project_search_cancel: None,
247            project_search_generation_shared: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
248            lsp_trigger_chars: HashMap::new(),
249            hover_idle_generation: 0,
250            hover_idle_task: None,
251            project_search_generation: 0,
252            pending_search_state: None,
253            pending_go_to_position: None,
254        }
255    }
256
257    /// Called once per frame, after operations are applied.
258    /// Each subsystem contributes its own tags — no push/pop anywhere else.
259    pub fn recompute_contexts(&mut self) {
260        self.active_contexts.clear();
261        self.active_contexts.insert("global".into());
262
263        match &self.screen {
264            Screen::Editor(ed) => {
265                self.active_contexts.insert("editor".into());
266                if ed.buffer.is_dirty() {
267                    self.active_contexts.insert("editor.dirty".into());
268                }
269            }
270            Screen::FileSelector(_) => {
271                self.active_contexts.insert("file_selector".into());
272            }
273            Screen::CommandRunner(_) => {
274                self.active_contexts.insert("command_runner".into());
275            }
276            Screen::LogView(_) => {
277                self.active_contexts.insert("log_view".into());
278            }
279            Screen::CommitWindow(_) => {
280                self.active_contexts.insert("commit".into());
281            }
282            Screen::Terminal(_) => {
283                self.active_contexts.insert("terminal".into());
284            }
285            Screen::GitHistory(_) => {
286                self.active_contexts.insert("git_history".into());
287            }
288            Screen::GitBranches(_) => {
289                self.active_contexts.insert("git_branches".into());
290            }
291            Screen::GitHistoryEditor(_) => {
292                self.active_contexts.insert("git_history_editor".into());
293            }
294            Screen::GitPull(_) => {
295                self.active_contexts.insert("git_pull".into());
296            }
297            Screen::ExtensionConfig(_) => {
298                self.active_contexts.insert("extension_config".into());
299            }
300            Screen::TaskArchive(_) => {
301                self.active_contexts.insert("task_archive".into());
302            }
303            Screen::IssueList(_) => {
304                self.active_contexts.insert("issue_list".into());
305            }
306        }
307
308        if self.project.has_git_repo() {
309            self.active_contexts.insert("git_repo".into());
310        }
311    }
312}
313
314impl AppState {
315    /// Replace the active screen.
316    ///
317    /// Opens a file while preserving any stashed primary view state.
318    ///
319    /// This is the state-management core of `Operation::OpenFile`: it flushes
320    /// any stashed editor into `project.editor_state` so that `take_buffer` can
321    /// restore unsaved changes, cursor, and undo history for the target file.
322    ///
323    /// Returns `true` if the file was opened successfully, `false` on I/O error.
324    pub fn open_file_preserving_stash(&mut self, path: std::path::PathBuf) -> bool {
325        self.save_stashed_primary();
326        let folds = self.project.get_fold_state(&path);
327        match self.project.take_buffer(path) {
328            Ok(buf) => {
329                let ed = crate::views::editor::EditorView::open(buf, folds, &self.settings);
330                self.set_screen(Screen::Editor(Box::new(ed)));
331                true
332            }
333            Err(_) => false,
334        }
335    }
336
337    /// Consumes `stashed_primary` and saves each view type into its persistent slot
338    /// so state survives Primary→Modal→Primary transitions.
339    ///
340    /// Must be called BEFORE `project.take_buffer()` for the Editor case, since
341    /// take_buffer reads from `editor_state.files` which is only populated after this runs.
342    /// Also called by `set_screen` as a safety net for Terminal/CommitWindow stashing.
343    pub(crate) fn save_stashed_primary(&mut self) {
344        let Some(stashed) = self.stashed_primary.take() else {
345            return;
346        };
347        match stashed {
348            Screen::Editor(mut ed) => {
349                // Stop file watching (idempotent if already stopped).
350                ed.stop_file_watching();
351                if let Some(ref path) = ed.buffer.path.clone() {
352                    self.file_watcher.unwatch(path).ok();
353                }
354                let (buf, folds) = ed.into_state();
355                self.project.stash_buffer(buf, folds);
356            }
357            Screen::Terminal(tv) => {
358                // Move PTY session to the terminal stash slot so it stays alive.
359                self.stashed_terminal = Some(tv);
360            }
361            Screen::CommitWindow(cw) => {
362                // Preserve form state (staged files, message, cursor, etc.).
363                self.stashed_commit_window = Some(cw);
364            }
365            _ => {
366                // GitHistory and other primaries are ephemeral; always reloaded from git.
367            }
368        }
369    }
370
371    /// If transitioning from a Primary -> Modal view and no primary is currently
372    /// stashed, move the previous screen into `stashed_primary` and return None
373    /// (caller should not attempt to consume the previous screen).
374    /// Otherwise return Some(prev) so callers can run `consume_prev_screen`.
375    pub fn set_screen(&mut self, new_screen: Screen) -> Option<Screen> {
376        use crate::views::ViewKind;
377        let prev = std::mem::replace(&mut self.screen, new_screen);
378        let entering_modal = matches!(self.screen.view_kind(), ViewKind::Modal);
379        let was_primary = matches!(prev.view_kind(), ViewKind::Primary);
380        let now_primary = matches!(self.screen.view_kind(), ViewKind::Primary);
381
382        // If transitioning from Primary -> Modal, stash the previous primary.
383        if entering_modal && was_primary {
384            // Overwrite any existing stash — single-slot policy.
385            self.stashed_primary = Some(prev);
386            // Caller should NOT attempt to consume the previous screen; it has been moved.
387            None
388        } else {
389            // If we've moved to a primary screen, save stashed state before clearing.
390            if now_primary {
391                self.save_stashed_primary();
392            }
393            Some(prev)
394        }
395    }
396}