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