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