oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
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)
        }
    }
}