use std::collections::VecDeque;
use std::io;

use crossterm::{
    event::{
        DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
        Event as CrosstermEvent, EventStream, KeyCode, KeyEventKind, KeyModifiers,
    },
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::StreamExt as _;
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::sync::mpsc::UnboundedSender;

use std::collections::HashMap;

use crate::commands::CommandRegistry;
use crate::input;
use crate::log_buffer::LogState;
use crate::lsp::{LspClient, lang_id_for_path, path_to_uri};
use std::time::Duration;

use crate::file_index::NucleoSearch;
use crate::operation::{
    BranchViewOp, ClipOp, CommitOp, Event, ExtensionConfigOp, FileSelectorOp, GitHistoryOp,
    GitPullOp, HistoryEditorOp, LspOp, NotificationLevel, Operation, SearchOp, TerminalOp,
};
use crate::prelude::*;
use crate::vcs::{GitVcs, Vcs as _};
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,
    // legacy SearchReplace removed; editor now hosts expanded project search
    issue_view::IssueView,
    log_view::LogView,
    task_archive::TaskArchiveView,
    terminal::TerminalView,
};
use crate::{
    app_state::{AppState, Screen, ScreenKind},
    editor::history::ChangeKind,
    file_index,
};

pub mod builtin;
mod lsp_cmds;

pub fn run(project: project::Project, log_state: std::sync::Arc<LogState>) -> Result<()> {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?
        .block_on(async_run(project, log_state))
}

async fn async_run(project: project::Project, log_state: std::sync::Arc<LogState>) -> Result<()> {
    install_panic_hook();

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(
        stdout,
        EnterAlternateScreen,
        EnableBracketedPaste,
        EnableMouseCapture
    )?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = event_loop(&mut terminal, project, log_state).await;

    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        DisableBracketedPaste,
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;
    result
}

/// Flush the paste-heuristic buffer as either a single `InsertText` (one char,
/// preserving normal Enter auto-indent) or a `PasteText` (multiple chars,
/// inserted via `insert_text_raw` with no auto-indent).
fn flush_heuristic(
    buf: &mut String,
    deadline: &mut Option<tokio::time::Instant>,
    ops: &mut Vec<Operation>,
    app: &AppState,
) {
    *deadline = None;
    if buf.is_empty() {
        return;
    }
    let text = std::mem::take(buf);
    let Screen::Editor(ed) = &app.screen else {
        return;
    };
    let path = ed.buffer.path.clone();
    if text.chars().count() == 1 {
        // Single char typed by the user — go through the normal path so that
        // Enter still triggers auto-indent.
        ops.push(Operation::InsertText {
            path: path.clone(),
            cursor: ed.buffer.cursor(),
            text: text.clone(),
        });
        // If the character is an LSP trigger character, also emit a completion
        // request. This mirrors what EditorView::handle_key does for chars that
        // bypass the heuristic buffer.
        let c = text.chars().next().unwrap();
        let lang_id = ed.lang_id.as_ref().map(|l| l.as_str());
        if crate::views::editor::is_trigger_character(&c, lang_id, &[]) {
            ops.push(Operation::LspTriggerCompletion {
                path,
                trigger_char: Some(text),
            });
        }
    } else {
        // More than one char arrived rapidly — it's a paste.
        ops.push(Operation::PasteText { path, text });
    }
}

fn install_panic_hook() {
    let original = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(
            std::io::stdout(),
            crossterm::terminal::LeaveAlternateScreen,
            crossterm::event::DisableBracketedPaste,
            crossterm::event::DisableMouseCapture,
        );
        original(info);
    }));
}

async fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    mut project: project::Project,
    log_state: std::sync::Arc<LogState>,
) -> Result<()> {
    // Operation channel: async tasks (LSP reader, future git, etc.) send Vec<Operation> here.
    let (op_tx, mut op_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<Operation>>();

    // Load extension config first (before settings, so extensions can provide defaults).
    let ext_config = crate::extension::config::load()?;

    // Load WASM extensions now that the op_tx channel is available.
    let mut settings =
        crate::settings::Settings::new(&project.project_settings_path.join("config.yaml"))?;

    // Load extensions and get their default configs for merging into settings.
    let ext_defaults = project.init_extensions(op_tx.clone(), &settings, &ext_config);

    // Merge extension defaults into settings (after defaults, before global/project).
    settings.merge_extension_defaults(ext_defaults);

    let mut lsp: HashMap<String, LspClient> = HashMap::new();

    // Start background file index walk — runs in a blocking thread, result
    // arrives asynchronously via the SharedFileIndex Arc.
    let file_index = file_index::spawn_indexer(project.project_path.clone());
    // Watch for filesystem changes and keep the index live.
    file_index::spawn_watcher(project.project_path.clone(), file_index.clone());
    // Make the file index available to widgets that perform hot-path lookups.
    crate::widgets::terminal::set_global_file_index(file_index.clone());

    let initial = Screen::FileSelector(FileSelector::new(
        project.project_path.clone(),
        project.history_entries(),
        file_index.clone(),
        project.dirty_paths(),
        project.last_opened_file.clone(),
    ));
    let mut app = AppState::new(initial, project, settings, file_index);
    app.clipboard = arboard::Clipboard::new().ok();

    // Kick the initial reveal if the startup FileSelector has a pending_load
    // (set when last_opened_file requires lazy directory expansion).
    kick_file_selector_pending_load(&mut app, &op_tx);
    let mut command_registry = CommandRegistry::new();
    builtin::register_all(&mut command_registry);
    lsp_cmds::register_lsp(&mut command_registry);

    let shortcut_registry = shortcuts::ShortcutRegistry::new(&app.project.project_settings_path)?;

    // Register commands contributed by extensions.
    app.project
        .extension_manager
        .register_commands(&mut command_registry);

    // Register tasks from .oo/tasks.yaml as commands.
    crate::app::builtin::register_tasks(&mut command_registry, &app);

    // Seed command registry history from persisted project state (.oo/project_state.yaml)
    if !app.project.command_history_persisted.is_empty() {
        for entry in app.project.command_history_persisted.iter().rev() {
            match entry.id.parse::<crate::commands::CommandId>() {
                Ok(cmd_id) => {
                    let args: std::collections::HashMap<String, crate::commands::ArgValue> = entry
                        .args
                        .iter()
                        .map(|(k, v): (&String, &String)| (k.clone(), crate::commands::ArgValue::String(v.clone())))
                        .collect();
                    command_registry.record_palette_selection(cmd_id, args);
                }
                Err(e) => {
                    log::warn!("Invalid command id in persisted history: {} ({})", entry.id, e);
                }
            };
        }
    }

    // Dispatch project_opened event to all active extensions.
    {
        let mut payload = HashMap::new();
        payload.insert(
            "path".to_owned(),
            app.project.project_path.to_string_lossy().into_owned(),
        );
        app.project
            .extension_manager
            .dispatch_event("project_opened", &payload);
    }

    // Load persistent issues from disk (.oo/issues.yaml) in a background
    // thread; each record arrives as an Operation::AddIssue so it flows
    // through the normal registry pipeline.
    {
        let issues_path = app.project.project_settings_path.join("issues.yaml");
        let tx = op_tx.clone();
        tokio::task::spawn_blocking(move || {
            match crate::persistent_issues::load(&issues_path) {
                Ok(new_issues) if !new_issues.is_empty() => {
                    let ops = new_issues
                        .into_iter()
                        .map(|issue| crate::operation::Operation::AddIssue { issue })
                        .collect::<Vec<_>>();
                    let _ = tx.send(ops);
                }
                Ok(_) => {} // first run or empty file — nothing to load
                Err(e) => log::warn!("persistent issues: startup load failed: {}", e),
            }
        });
    }

    // Initial render
    render(terminal, &mut app)?;

    let mut event_stream = EventStream::new();
    // Live log broadcast — receives entries while LogView is open.
    let mut log_rx = log_state.subscribe();

    // ── Paste-heuristic state ────────────────────────────────────────────────
    // When bracketed paste is unavailable, rapidly arriving plain-char events
    // (< PASTE_THRESHOLD apart) are coalesced into a single PasteText op so
    // they are inserted via insert_text_raw (no auto-indent on newlines).
    const PASTE_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(5);
    let mut heuristic_buf = String::new();
    let mut heuristic_deadline: Option<tokio::time::Instant> = None;

    loop {
        let mut ops: Vec<Operation> = Vec::new();

        tokio::select! {
            // Terminal events
            maybe_ev = event_stream.next() => {
                match maybe_ev {
                    Some(Ok(CrosstermEvent::Key(key))) => {
                        if key.kind == KeyEventKind::Press {
                            // ── Paste heuristic (editor only) ─────────────────
                            // Plain chars and, while accumulating, Enter/Tab are
                            // buffered. Non-bufferable keys flush the buffer first.
                            // Only apply the heuristic when the editor is in its
                            // normal text-entry mode — not when the search bar or
                            // clipboard picker is open, since those widgets need
                            // to receive characters via handle_key directly.
                            let is_editor_text_mode = match &app.screen {
                                Screen::Editor(ed) => ed.search.is_none() && ed.clipboard_picker.is_none() && ed.go_to_line.is_none() && ed.rename_prompt.is_none(),
                                _ => false,
                            };
                            let mut handled = false;
                            if is_editor_text_mode {
                                let buf_char = match key.code {
                                    KeyCode::Char(c)
                                        if !key.modifiers.intersects(
                                            KeyModifiers::CONTROL | KeyModifiers::ALT,
                                        ) => Some(c),
                                    // Include Enter/Tab only while already accumulating
                                    // so a typed Enter still auto-indents when alone.
                                    KeyCode::Enter if heuristic_deadline.is_some() => Some('\n'),
                                    KeyCode::Tab if heuristic_deadline.is_some() => Some('\t'),
                                    _ => None,
                                };

                                if let Some(c) = buf_char {
                                    heuristic_buf.push(c);
                                    heuristic_deadline =
                                        Some(tokio::time::Instant::now() + PASTE_THRESHOLD);
                                    handled = true;
                                } else if !heuristic_buf.is_empty() {
                                    // Flush before processing this non-char key.
                                    flush_heuristic(&mut heuristic_buf, &mut heuristic_deadline, &mut ops, &app);
                                }
                            }

                            if !handled
                                && let Ok(oo_key) = input::KeyEvent::try_from(key) {
                                    // Context menu captures all key input when open.
                                    if app.context_menu.is_some() {
                                        use crate::input::Key;
                                        match oo_key.key {
                                            Key::Escape => {
                                                ops.push(Operation::CloseContextMenu);
                                            }
                                            Key::ArrowUp => {
                                                if let Some(m) = app.context_menu.as_mut() {
                                                    m.move_up();
                                                }
                                            }
                                            Key::ArrowDown => {
                                                if let Some(m) = app.context_menu.as_mut() {
                                                    m.move_down();
                                                }
                                            }
                                            Key::Enter => {
                                                let cmd = app.context_menu
                                                    .as_ref()
                                                    .and_then(|m| m.selected_command())
                                                    .cloned();
                                                if let Some(cmd) = cmd {
                                                    ops.push(Operation::RunCommand {
                                                        id: cmd,
                                                        args: Default::default(),
                                                    });
                                                }
                                                ops.push(Operation::CloseContextMenu);
                                            }
                                            _ => {} // Swallow; context menu has focus
                                        }
                                    } else if let Some(commands) = shortcut_registry.find_commands(&oo_key) &&
                                        let Some(id) = command_registry.select_command_in_context(commands, &app.active_contexts) {
                                            ops.extend(command_registry.execute(&id.clone(), Default::default(), &app));
                                    } else {
                                        let view_ops = match &app.screen {
                                            Screen::FileSelector(fs) => fs.handle_key(oo_key),
                                            Screen::Editor(ed) => ed.handle_key(oo_key),
                                            Screen::CommandRunner(cr) => cr.handle_key(oo_key),
                                            Screen::LogView(lv) => lv.handle_key(oo_key),
                                            Screen::CommitWindow(cw) => cw.handle_key(oo_key),
                                                                                Screen::Terminal(tv) => tv.handle_key(oo_key),
                                            Screen::GitHistory(gh) => gh.handle_key(oo_key),
                                            Screen::GitBranches(bv) => bv.handle_key(oo_key),
                                            Screen::GitPull(gp) => gp.handle_key(oo_key),
                                            Screen::ExtensionConfig(ec) => ec.handle_key(oo_key),
                                            Screen::TaskArchive(ta) => ta.handle_key(oo_key),
                                            Screen::IssueList(iv) => iv.handle_key(oo_key),
                                            Screen::GitHistoryEditor(he) => he.handle_key(oo_key),
                                        };
                                        ops.extend(view_ops);
                                    }
                                }
                        }
                    }
                    Some(Ok(CrosstermEvent::Mouse(mouse))) => {
                        if let Ok(oo_mouse) = input::MouseEvent::try_from(mouse) {
                            // Context menu captures all mouse input when open.
                            if app.context_menu.is_some() {
                                use crate::input::{MouseEventKind, MouseButton};
                                match oo_mouse.kind {
                                    MouseEventKind::Down(MouseButton::Left) => {
                                        // Use Menu::hit_test to check if click is inside menu.
                                        let hit = app.context_menu.as_ref().and_then(|m| {
                                            let area = ratatui::layout::Rect {
                                                x: m.x,
                                                y: m.y,
                                                width: m.items.iter().map(|i| i.label.len() + 4).max().unwrap_or(10) as u16,
                                                height: m.items.len() as u16 + 2,
                                            };
                                            let labels: Vec<&str> = m.items.iter().map(|i| i.label.as_str()).collect();
                                            crate::widgets::button::Menu::new(&labels)
                                                .cursor(m.cursor)
                                                .hit_test((oo_mouse.column, oo_mouse.row), area)
                                        });
                                        if let Some(idx) = hit
                                            && let Some(item) = app.context_menu.as_ref().and_then(|m| m.items.get(idx))
                                                && item.enabled {
                                                    ops.push(Operation::RunCommand {
                                                        id: item.command.clone(),
                                                        args: Default::default(),
                                                    });
                                                }
                                        // Always close on any left-click (inside = execute, outside = dismiss).
                                        ops.push(Operation::CloseContextMenu);
                                    }
                                    MouseEventKind::Down(MouseButton::Right) => {
                                        ops.push(Operation::CloseContextMenu);
                                    }
                                    _ => {} // Swallow other mouse events while menu is open
                                }
                            } else {
                                // Status bar click check first (bottom row commands/cancel).
                                if matches!(oo_mouse.kind, input::MouseEventKind::Down(_)) {
                                    if let Some(cmd) = app.status_bar_clicks.hit_command(oo_mouse.column, oo_mouse.row) {
                                        let cmd = cmd.clone();
                                        ops.push(Operation::RunCommand { id: cmd, args: Default::default() });
                                    } else if let Some(task_id) = app.status_bar_clicks.hit_cancel(oo_mouse.column, oo_mouse.row) {
                                        ops.push(Operation::CancelTask(task_id));
                                    } else if let Some(task_id) = app.status_bar_clicks.hit_task_label(oo_mouse.column, oo_mouse.row) {
                                        // Open the Task Archive and asynchronously load the
                                        // clicked task's log.  The async loader will deliver
                                        // the lines via `Operation::ShowTaskOutput` which the
                                        // TaskArchive view will consume and display.
                                        ops.push(Operation::OpenTaskArchive);

                                        // Spawn async task to read the on-disk log and send
                                        // the result back to the main loop.
                                        let title = app.task_registry.get(task_id)
                                            .map(|t| format!("{}: {}", t.key.queue.0, t.command))
                                            .unwrap_or_default();
                                        let status = app.task_registry.get(task_id)
                                            .map(|t| t.status.clone())
                                            .unwrap_or(crate::task_registry::TaskStatus::Cancelled);
                                        let log_store = app.log_store.clone();
                                        let tx = op_tx.clone();
                                        tokio::spawn(async move {
                                            let lines = log_store.read_log(task_id).await.unwrap_or_default();
                                            let _ = tx.send(vec![Operation::ShowTaskOutput {
                                                task_id,
                                                title,
                                                status,
                                                lines,
                                            }]);
                                        });
                                    }
                                }
                                let view_ops = match &app.screen {
                                    Screen::FileSelector(fs) => fs.handle_mouse(oo_mouse),
                                    Screen::Editor(ed) => ed.handle_mouse(oo_mouse),
                                    Screen::CommandRunner(cr) => cr.handle_mouse(oo_mouse),
                                    Screen::LogView(lv) => lv.handle_mouse(oo_mouse),
                                    Screen::CommitWindow(cw) => cw.handle_mouse(oo_mouse),
                                    Screen::Terminal(tv) => tv.handle_mouse(oo_mouse),
                                    Screen::GitHistory(gh) => gh.handle_mouse(oo_mouse),
                                    Screen::GitBranches(bv) => bv.handle_mouse(oo_mouse),
                                    Screen::GitPull(gp) => gp.handle_mouse(oo_mouse),
                                    Screen::ExtensionConfig(ec) => ec.handle_mouse(oo_mouse),
                                    Screen::TaskArchive(ta) => ta.handle_mouse(oo_mouse),
                                    Screen::IssueList(iv) => iv.handle_mouse(oo_mouse),
                                    Screen::GitHistoryEditor(he) => he.handle_mouse(oo_mouse),
                                };
                                ops.extend(view_ops);
                            }
                        }
                    }
                    // Bracketed paste — arrives as a single event with the full text.
                    Some(Ok(CrosstermEvent::Paste(text))) => {
                        match &app.screen {
                            Screen::Editor(ed) => {
                                let path = ed.buffer.path.clone();
                                ops.push(Operation::PasteText { path, text });
                            }
                            Screen::Terminal(_) => {
                                if let Screen::Terminal(tv) = &app.screen {
                                    ops.extend(tv.handle_paste(&text));
                                }
                            }
                            _ => {}
                        }
                    }
                    Some(Ok(CrosstermEvent::Resize(cols, rows))) => {
                        ops.push(Operation::TerminalLocal(TerminalOp::Resize { cols, rows }));
                    }
                    Some(Ok(_)) => {} // focus, etc.
                    Some(Err(_)) | None => break, // terminal gone
                }
            }
            // Flush heuristic buffer when no new chars arrive within the threshold.
            () = async {
                match heuristic_deadline {
                    Some(dl) => tokio::time::sleep_until(dl).await,
                    None => std::future::pending().await,
                }
            } => {
                flush_heuristic(&mut heuristic_buf, &mut heuristic_deadline, &mut ops, &app);
            }
            // Async operations from background tasks
            Some(async_ops) = op_rx.recv() => {
                ops.extend(async_ops);
            }
            // Live log entries — push directly into the LogView if it's open.
            Ok(entry) = log_rx.recv() => {
                if let Screen::LogView(lv) = &mut app.screen {
                    lv.push(entry);
                }
            }
        }

        // Apply operations
        let mut emitted: Vec<Event> = Vec::new();
        let mut queue: VecDeque<Operation> = ops.into_iter().collect();

        while let Some(op) = queue.pop_front() {
            // Lazily start the per-language LSP server before processing file opens.
            if let Operation::OpenFile { path } = &op {
                let project_path = app.project.project_path.clone();
                ensure_lsp_for_path(path, &app.settings, &project_path, &mut lsp, op_tx.clone())
                    .await;
            }
            apply_operation(
                op,
                &mut app,
                &mut command_registry,
                &mut emitted,
                &mut queue,
                &mut lsp,
                &log_state,
                &op_tx,
            );
        }

        // Drain deferred ops from EditorView (e.g. completion-confirm InsertText)
        if let Screen::Editor(ed) = &mut app.screen {
            let deferred = ed.take_deferred_ops();
            if !deferred.is_empty() {
                let mut queue2: VecDeque<Operation> = deferred.into_iter().collect();
                while let Some(op) = queue2.pop_front() {
                    apply_operation(
                        op,
                        &mut app,
                        &mut command_registry,
                        &mut emitted,
                        &mut queue2,
                        &mut lsp,
                        &log_state,
                        &op_tx,
                    );
                }
            }
        }

        let _ = emitted; // subscriber system future work

        app.recompute_contexts();

        if app.should_quit {
            return Ok(());
        }

        // Before rendering, schedule highlights for visible lines that aren't cached.
        // With incremental highlighting, old highlights persist — we only compute
        // lines that are missing or dirty.
        if let Screen::Editor(ed) = &mut app.screen {
            let scroll = ed.buffer.scroll;
            let height = ed.last_body_area.height as usize;
            let buf_len = ed.buffer.lines().len();
            let visible_end = (scroll + height).min(buf_len);

            let mut dirty_lines = Vec::new();
            for line in scroll..visible_end {
                let in_cache = ed.highlight_cache.contains_key(&line);
                let is_stale = ed.stale_highlight_lines.contains(&line);
                let is_pending = ed.pending_highlight_lines.contains(&line);
                // Schedule if: not cached, or cached-but-stale — and not already pending.
                if (!in_cache || is_stale) && !is_pending {
                    dirty_lines.push(line);
                }
            }

            // Prefetch one screenful ahead of the scroll direction so highlights
            // are ready before the user sees the lines.
            if height > 0 {
                let (prefetch_start, prefetch_end) = if scroll > ed.prev_scroll {
                    // Scrolling down: prefetch below the viewport.
                    (visible_end, (visible_end + height).min(buf_len))
                } else if scroll < ed.prev_scroll {
                    // Scrolling up: prefetch above the viewport.
                    let start = scroll.saturating_sub(height);
                    (start, scroll)
                } else {
                    (0, 0) // no scroll movement
                };
                for line in prefetch_start..prefetch_end {
                    if !ed.highlight_cache.contains_key(&line)
                        && !ed.pending_highlight_lines.contains(&line)
                    {
                        dirty_lines.push(line);
                    }
                }
                ed.prev_scroll = scroll;
            }

            let has_work = !dirty_lines.is_empty() || !ed.pending_highlight_lines.is_empty();
            if has_work {
                schedule_incremental_highlight(ed, &op_tx, dirty_lines);
            }

            let changed_paths = app.file_watcher.check_events();
            if !changed_paths.is_empty() {
                ed.check_external_modification_for_paths(&changed_paths);
            }
        }

        render(terminal, &mut app)?;
    }

    Ok(())
}

/// Apply one operation.  App-level ops are handled here; everything else is
/// dispatched to the active view.
#[allow(clippy::too_many_arguments)]
fn apply_operation(
    op: Operation,
    app: &mut AppState,
    registry: &mut CommandRegistry,
    emitted: &mut Vec<Event>,
    queue: &mut VecDeque<Operation>,
    lsp: &mut HashMap<String, LspClient>,
    log_state: &std::sync::Arc<crate::log_buffer::LogState>,
    op_tx: &UnboundedSender<Vec<Operation>>,
) {
    match &op {
        Operation::Quit => {
            app.should_quit = true;
            emitted.push(Event::applied("app", op));
        }

        Operation::SwitchScreen { to } => {
            switch_screen(to.clone(), app, registry, log_state, op_tx);
            emitted.push(Event::applied("app", op));
        }

        Operation::Close => {
            // Priority-ordered close: close the topmost visible overlay/sub-view/modal.

            // 1. Context menu
            if app.context_menu.is_some() {
                queue.push_back(Operation::CloseContextMenu);
                emitted.push(Event::applied("app", op));
                return;
            }

            // 2 & 3. Modal screens that have a detail sub-view — close the detail first.
            if let Screen::IssueList(iv) = &app.screen
                && iv.is_detail() {
                    queue.push_back(Operation::IssueViewLocal(
                        crate::operation::IssueViewOp::CloseDetail,
                    ));
                    emitted.push(Event::applied("app", op));
                    return;
                }
            if let Screen::TaskArchive(ta) = &app.screen
                && ta.is_detail() {
                    queue.push_back(Operation::TaskArchiveLocal(
                        crate::operation::TaskArchiveOp::CloseDetail,
                    ));
                    emitted.push(Event::applied("app", op));
                    return;
                }

            // 4. Any remaining modal screen → restore stashed primary.
            if matches!(app.screen.view_kind(), crate::views::ViewKind::Modal) {
                debug_assert!(
                    app.stashed_primary.is_some(),
                    "Closing a modal with no stashed primary"
                );
                if let Some(prev_primary) = app.stashed_primary.take() {
                    let restoring_editor = matches!(prev_primary, Screen::Editor(_));
                    if let Some(prev_modal) = app.set_screen(prev_primary) {
                        consume_prev_screen(prev_modal, app);
                    }
                    if restoring_editor && let Screen::Editor(ref mut ed) = app.screen {
                        ed.start_file_watching();
                        if let Some(ref path) = ed.buffer.path {
                            app.file_watcher.watch(path.clone()).ok();
                        }
                    }
                }
                emitted.push(Event::applied("app", op));
                return;
            }

            // 4b. Non-editor primary views — delegate Close so that views with
            //     sub-modes (e.g. GitHistoryEditor in Reword/Squash mode) can
            //     cancel their inner state without the caller needing to know
            //     the view-specific operation type.
            if !matches!(app.screen, Screen::Editor(_))
                && let Some(event) = dispatch_to_view(op.clone(), app) {
                    emitted.push(event);
                    return;
                }

            // 5–10. Editor primary — close overlays in priority order.
            if let Screen::Editor(ref mut ed) = app.screen {
                // 5. Clipboard history picker
                if ed.clipboard_picker.is_some() {
                    queue.push_back(Operation::ClipboardLocal(
                        crate::operation::ClipOp::HistoryClose,
                    ));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 6. Go-to-line bar
                if ed.go_to_line.is_some() {
                    queue.push_back(Operation::GoToLineLocal(
                        crate::operation::GoToLineOp::Close,
                    ));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 6b. LSP rename prompt
                if ed.rename_prompt.is_some() {
                    queue.push_back(Operation::LspRenameLocal(
                        crate::operation::LspRenameOp::Dismiss,
                    ));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 7. Search / replace bar
                if ed.search.is_some() {
                    queue.push_back(Operation::SearchLocal(
                        crate::operation::SearchOp::Close,
                    ));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 8. Completion menu
                if ed.completion.is_some() {
                    queue.push_back(Operation::LspLocal(LspOp::CompletionDismiss));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 9. Hover box
                if ed.hover.is_some() {
                    queue.push_back(Operation::LspLocal(LspOp::HoverDismiss));
                    emitted.push(Event::applied("editor", op));
                    return;
                }
                // 10. Text selection
                if ed.buffer.selection().is_some() {
                    queue.push_back(Operation::SelectionLocal(
                        crate::operation::SelectionOp::Clear,
                    ));
                    emitted.push(Event::applied("editor", op));
                }
            }
        }

        Operation::OpenContextMenu { items, x, y } => {
            use crate::widgets::context_menu::{ContextMenu, ContextMenuItem};
            let screen_size = {
                // Use the current terminal size for clamping.
                crossterm::terminal::size().unwrap_or((80, 24))
            };
            let menu_items: Vec<ContextMenuItem> = items
                .iter()
                .map(|(label, cmd, enabled_opt)| {
                    let enabled = match enabled_opt {
                        Some(b) => *b,
                        None => {
                            // Default enablement heuristics.
                            // - editor::paste: enabled if clipboard or registers non-empty
                            if cmd.group.as_ref() == "editor" && cmd.name.as_ref() == "paste" {
                                !app.registers.is_empty() || app.clipboard.is_some()
                            } else {
                                true
                            }
                        }
                    };
                    ContextMenuItem::with_enabled(label.clone(), cmd.clone(), enabled)
                })
                .collect();
            let menu = ContextMenu::new(menu_items, *x, *y)
                .clamped(screen_size.0, screen_size.1.saturating_sub(1));
            app.context_menu = Some(menu);
            emitted.push(Event::applied("app", op));
        }

        Operation::CloseContextMenu => {
            app.context_menu = None;
            emitted.push(Event::applied("app", op));
        }

        Operation::RunCommand { id, args } => {
            let new_ops = registry.execute(id, args.clone(), app);
            if !new_ops.is_empty() {
                registry.record_palette_selection(id.clone(), args.clone());
                // Persist updated command history asynchronously.
                app.project.persist_command_history_async(registry.history());
            }
            // Only return to FileSelector if the command didn't switch screens itself.
            let has_switch = new_ops
                .iter()
                .any(|o| matches!(o, Operation::SwitchScreen { .. }));
            // Dismiss the palette first so the editor is restored before the
            // command op runs (e.g. ClipboardLocal needs Screen::Editor active).
            if !has_switch {
                queue.push_back(Operation::Close);
            }
            for new_op in new_ops {
                queue.push_back(new_op);
            }
            emitted.push(Event::applied("app", op));
        }

        Operation::OpenFile { path } => {
            let path = path.clone();
            // If there is a stashed primary (e.g. Editor stashed when FileSelector opened),
            // flush it to the project now so take_buffer can restore it below.
            // Without this, set_screen would clear stashed_primary after take_buffer has
            // already loaded the file from disk, silently dropping unsaved changes.
            app.save_stashed_primary();
            // Record last opened file for reveal in FileSelector and persist.
            app.project.set_last_opened_file(Some(path.clone()));
            app.project.history_push(&path);
            let folds = app.project.get_fold_state(&path);
            match app.project.take_buffer(path.clone()) {
                Ok(buf) => {
                    let lang = lang_id_for_path(&path);
                    // Notify LSP of newly opened file.
                    if let Some(lsp_client) = lsp.get(lang) {
                        let uri = path_to_uri(&path);
                        let text = buf.lines().join("\n") + "\n";
                        lsp_client.did_open(&uri, lang, &text);
                    }
                    if let Some(prev) = app.set_screen(Screen::Editor(Box::new(EditorView::open(
                        buf,
                        folds,
                        &app.settings,
                    )))) {
                        consume_prev_screen(prev, app);
                    }
                    if let Screen::Editor(ref mut ed) = app.screen {
                        ed.start_file_watching();
                        if let Some(ref path) = ed.buffer.path {
                            app.file_watcher.watch(path.clone()).ok();
                        }
                        // Populate gutter markers from any existing issues for this file.
                        let theme = app.theme.clone();
                        rebuild_editor_gutter_markers(ed, &app.issue_registry, &theme);
                        // Kick off a background git diff to populate git gutter markers.
                        if let Some(ref file_path) = ed.buffer.path {
                            spawn_git_diff_for_editor(
                                app.project.project_path.clone(),
                                file_path.clone(),
                                op_tx,
                            );
                        }
                        // Restore project search state saved by GoToProjectMatch.
                        if let Some(search_state) = app.pending_search_state.take() {
                            ed.search = Some(search_state);
                        }
                        // Apply pending cursor position from GoToProjectMatch.
                        if let Some((line, col)) = app.pending_go_to_position.take() {
                            ed.buffer.set_cursor(crate::editor::Position::new(line, col));
                        }
                    }
                    emitted.push(Event::applied("app", op));
                    // Dispatch file_opened extension event.
                    let mut payload = HashMap::new();
                    payload.insert("path".to_owned(), path.to_string_lossy().into_owned());
                    payload.insert("language".to_owned(), lang.to_owned());
                    queue.push_back(Operation::ExtensionEvent {
                        name: "file_opened".into(),
                        payload,
                    });
                }
                Err(e) => {
                    app.status_msg = Some(format!("Failed to open: {}", e));
                }
            }
        }

        Operation::GoToProjectMatch { file, line, col } => {
            let file = file.clone();
            let line = *line;
            let col = *col;
            let is_current = matches!(&app.screen, Screen::Editor(ed) if ed.buffer.path.as_deref() == Some(&file));
            if is_current {
                // Already viewing this file — just jump the cursor.
                if let Screen::Editor(ref mut ed) = app.screen {
                    ed.buffer.set_cursor(crate::editor::Position::new(line, col));
                }
            } else {
                // Save the current search state before switching files.
                if let Screen::Editor(ref mut ed) = app.screen {
                    app.pending_search_state = ed.search.take();
                }
                // Reuse the existing OpenFile machinery via the queue.
                queue.push_back(Operation::OpenFile { path: file });
                // After the file opens, the pending state will be restored
                // and the cursor positioned — see the pending_search_state
                // handling after OpenFile.
            }
            // Whether we jumped in-place or queued an open, position the
            // cursor once the file is definitely loaded.
            if is_current {
                emitted.push(Event::applied("app", op));
            } else {
                // Stash the target line/col so we can apply it after OpenFile
                // creates the new EditorView.
                app.pending_go_to_position = Some((line, col));
            }
        }

        Operation::OpenUrl { url } => {
            let url = url.clone();
            // Spawn the platform default opener.
            if cfg!(target_os = "windows") {
                let _ = std::process::Command::new("cmd")
                    .args(["/C", "start", "", &url])
                    .spawn();
            } else if cfg!(target_os = "macos") {
                let _ = std::process::Command::new("open")
                    .arg(&url)
                    .spawn();
            } else {
                let _ = std::process::Command::new("xdg-open")
                    .arg(&url)
                    .spawn();
            }
            emitted.push(Event::applied("app", op.clone()));
        }
        Operation::SaveFile { path } => {
            let path = path.clone();
            let save_result = if let Screen::Editor(ed) = &mut app.screen {
                if Some(&path) == ed.buffer.path.as_ref() {
                    let result = ed.buffer.save(&app.settings);
                    if result.is_ok() {
                        ed.folds.prune_invalid(ed.buffer.line_count());
                    }
                    Some(result)
                } else {
                    None
                }
            } else {
                None
            };

            if let Some(result) = save_result {
                match result {
                    Ok(()) => {
                        app.status_msg = Some("Saved.".into());
                        emitted.push(Event::applied("editor", op.clone()));
                        // Refresh git gutter markers after save (diff vs index may change).
                        spawn_git_diff_for_editor(
                            app.project.project_path.clone(),
                            path.clone(),
                            op_tx,
                        );
                    }
                    Err(e) => {
                        app.status_msg = Some(format!("Save error: {}", e));
                    }
                }
            }

            let mut payload = HashMap::new();
            payload.insert("path".to_owned(), path.to_string_lossy().into_owned());
            queue.push_back(Operation::ExtensionEvent {
                name: "file_saved".into(),
                payload,
            });
        }

        Operation::ReloadFromDisk {
            path,
            accept_external,
            silent,
        } => {
            let path = path.clone();
            if let Screen::Editor(ed) = &mut app.screen
                && Some(&path) == ed.buffer.path.as_ref()
            {
                if ed.buffer.external_modification_detected && !accept_external {
                    ed.external_conflict_msg = Some(
                        "File modified externally. Press Ctrl+R to reload or Ctrl+S to save."
                            .to_string(),
                    );
                } else {
                    match ed.reload_from_disk() {
                        Ok(()) => {
                            ed.folds.prune_invalid(ed.buffer.line_count());
                            if !silent {
                                app.status_msg = Some("Reloaded from disk.".into());
                            }
                            emitted.push(Event::applied("editor", op.clone()));
                        }
                        Err(e) => {
                            app.status_msg = Some(format!("Reload error: {}", e));
                        }
                    }
                }
            }
        }

        // Extension lifecycle events
        Operation::ExtensionEvent { name, payload } => {
            let name = name.clone();
            let payload = payload.clone();
            app.project
                .extension_manager
                .dispatch_event(&name, &payload);
        }

        // Handle extension config changes coming from the ExtensionConfig view.
        Operation::ExtensionConfig(_ext_op) => {
            // Dispatch to the view so it updates internal selection/state.
            if let Some(event) = dispatch_to_view(op.clone(), app) {
                emitted.push(event);
            }

            // Only handle SetStatus here for persistence and manager update.
            if let Operation::ExtensionConfig(ExtensionConfigOp::SetStatus(idx)) = &op {
                use crate::extension::activation::ActivationState;
                use crate::extension::config as ext_config;

                if let Screen::ExtensionConfig(ec) = &app.screen
                    && let Some(name) = ec.selected_extension_name()
                {
                    let new_state = match idx {
                        0 => ActivationState::Global,
                        1 => ActivationState::Project,
                        2 => ActivationState::Auto,
                        _ => ActivationState::Disabled,
                    };

                    let res = match new_state {
                        ActivationState::Global => {
                            // Persist as global 'enabled'.
                            ext_config::ExtensionConfig::set_global_status(
                                &name,
                                ext_config::ExtensionStatus::Enabled,
                            )
                        }
                        ActivationState::Project => {
                            // Persist as project 'enabled'.
                            ext_config::ExtensionConfig::set_project_status(
                                &name,
                                ext_config::ExtensionStatus::Enabled,
                            )
                        }
                        ActivationState::Auto => {
                            // Save 'auto' to the global config by default.
                            ext_config::ExtensionConfig::set_global_status(
                                &name,
                                ext_config::ExtensionStatus::Auto,
                            )
                        }
                        ActivationState::Disabled => {
                            ext_config::ExtensionConfig::set_project_status(
                                &name,
                                ext_config::ExtensionStatus::Disabled,
                            )
                        }
                    };

                    if let Err(e) = res {
                        app.status_msg = Some(format!("Failed to save extension config: {}", e));
                    } else {
                        app.project.extension_manager.set_state(&name, new_state);
                    }
                }
            }
        }

        // Operations emitted by extensions
        Operation::ShowNotification { message, level } => {
            let prefix = match level {
                NotificationLevel::Info => "",
                NotificationLevel::Warn => "Warning: ",
                NotificationLevel::Error => "Error: ",
            };
            app.status_msg = Some(format!("{}{}", prefix, message));
        }

        Operation::UpdateStatusBar { text, .. } => {
            app.status_msg = Some(text.clone());
        }

        Operation::CreateTerminal { command } => {
            let command = command.clone();
            // Switch to terminal screen (creating it if needed), then spawn a tab.
            if !matches!(app.screen, Screen::Terminal(_)) {
                app.set_screen(Screen::Terminal(Box::default()));
            }
            if let Screen::Terminal(tv) = &mut app.screen {
                let cwd = app.project.project_path.clone();
                tv.spawn_tab(Some(command), "", &cwd, op_tx, 1000);
            }
        }

        Operation::TerminalInput { id, data } => {
            let (id, data) = (*id, data.clone());
            if let Screen::Terminal(tv) = &mut app.screen {
                tv.write_input(id, &data);
            }
        }

        Operation::TerminalLocal(terminal_op) => {
            let terminal_op = terminal_op.clone();
            // Handle ops that need app-level access (spawning, switching screen).
            match &terminal_op {
                TerminalOp::NewTab { command } => {
                    let command = command.clone();
                    if !matches!(app.screen, Screen::Terminal(_)) {
                        app.set_screen(Screen::Terminal(Box::default()));
                    }
                    if let Screen::Terminal(tv) = &mut app.screen {
                        let cwd = app.project.project_path.clone();
                        tv.spawn_tab(command, "", &cwd, op_tx, 1000);
                    }
                }
                TerminalOp::ProcessExited { id } => {
                    let id = *id;
                    // Clear terminal issues for this tab before removing it.
                    queue.push_back(Operation::ClearIssuesByMarker {
                        marker: format!("terminal:{id}"),
                    });
                    // Remove the tab from wherever the terminal lives (active or stashed).
                    let remove_from = |tv: &mut TerminalView| {
                        if let Some(pos) = tv.tabs.iter().position(|t| t.id == id) {
                            tv.tabs.remove(pos);
                            if tv.active >= tv.tabs.len() && !tv.tabs.is_empty() {
                                tv.active = tv.tabs.len() - 1;
                            }
                        }
                    };
                    if let Screen::Terminal(tv) = &mut app.screen {
                        remove_from(tv);
                        if tv.tabs.is_empty() {
                            let cwd = app.project.project_path.clone();
                            tv.spawn_tab(None, "", &cwd, op_tx, 1000);
                        }
                    } else if let Some(tv) = app.stashed_terminal.as_mut() {
                        remove_from(tv);
                        // Leave the stashed terminal empty — switch_screen will
                        // spawn a fresh tab when the user navigates back.
                    }
                }
                _ => {
                    // Handle CloseTab explicitly so we can clear the associated issues.
                    if let TerminalOp::CloseTab { id } = &terminal_op {
                        queue.push_back(Operation::ClearIssuesByMarker {
                            marker: format!("terminal:{id}"),
                        });
                    }
                    dispatch_to_view(Operation::TerminalLocal(terminal_op.clone()), app);

                    // When the terminal is stashed (user is in another view), PTY
                    // output and styled scrollback ops still arrive.  Forward them
                    // directly so the stashed VT100 parser and scrollback buffer stay
                    // up-to-date.  This ensures the display is current when the user
                    // switches back, rather than showing a frozen snapshot.
                    if !matches!(app.screen, Screen::Terminal(_)) {
                        match &terminal_op {
                            TerminalOp::Output { id, data } => {
                                if let Some(stashed_tv) = app.stashed_terminal.as_mut()
                                    && let Some(tab) =
                                        stashed_tv.tabs.iter_mut().find(|t| t.id == *id)
                                {
                                    tab.parser.borrow_mut().process(data);
                                    // Skip link detection while stashed — recomputed on restore.
                                }
                            }
                            TerminalOp::AppendScrollback { .. } => {
                                if let Some(stashed_tv) = app.stashed_terminal.as_mut() {
                                    stashed_tv.handle_operation(
                                        &Operation::TerminalLocal(terminal_op),
                                        &app.settings,
                                    );
                                }
                            }
                            _ => {}
                        }
                    }

                    // After any terminal op, ensure there's at least one tab
                    // (e.g. last tab closed via menu).
                    if let Screen::Terminal(tv) = &app.screen
                        && tv.tabs.is_empty()
                    {
                        queue.push_back(Operation::TerminalLocal(TerminalOp::NewTab {
                            command: None,
                        }));
                    }
                }
            }
        }

        Operation::ShowPicker { title, items } => {
            // Full picker UI is a future enhancement; log and show count in status bar.
            log::info!("ShowPicker: {:?} ({} items)", title, items.len());
            app.status_msg = Some(format!("Picker: {} ({} items)", title, items.len()));
        }

        // LSP trigger ops — forward to LspClient if active.
        Operation::LspTriggerCompletion { path: _, trigger_char } => {
            log::debug!("lsp: LspTriggerCompletion — {} active client(s)", lsp.len());
            if let Screen::Editor(ed) = &app.screen {
                // Capture trigger cursor/version/lines for schema completion tasks
                let trigger_cursor = ed.buffer.cursor();
                let _version = ed.lsp_version;
                let _lines = ed.buffer.lines().clone();

                // Show loading state immediately
                queue.push_back(Operation::LspLocal(LspOp::CompletionLoading {
                    trigger: trigger_cursor,
                }));

                if let Some(lsp_client) = lsp_for_editor(lsp, ed) {
                    match &ed.buffer.path {
                        Some(path) => {
                            let uri = path_to_uri(path);
                            let row = trigger_cursor.line as u32;
                            let col = trigger_cursor.column as u32;
                            log::debug!("lsp: requesting completion at {uri} {row}:{col}, trigger_char: {:?}", trigger_char);
                            lsp_client.request_completion(&uri, row, col, trigger_char.clone());
                        }
                        None => {
                            log::debug!("lsp: completion skipped — buffer has no path");
                            // Dismiss the loading state since there's no path
                            queue.push_back(Operation::LspLocal(LspOp::CompletionDismiss));
                        }
                    }
                } else {
                    log::debug!(
                        "lsp: completion skipped — no client for {:?}",
                        ed.lang_id.as_ref().map(|l| l.as_str())
                    );
                    // Dismiss the loading state since there's no LSP client
                    queue.push_back(Operation::LspLocal(LspOp::CompletionDismiss));
                }

                // Schema-based completion (fallback/parallel) — prepare data and spawn a blocking task
                // Collect editor data first so we don't hold an &app borrow while mutating AppState below.
                let maybe_editor_data = if let Screen::Editor(ed) = &app.screen {
                    Some((
                        ed.buffer.cursor(),
                        ed.lsp_version,
                        ed.buffer.lines().clone(),
                        ed.buffer.path.clone(),
                    ))
                } else {
                    None
                };

                if let Some((trigger_cursor, version, lines_clone, path_opt)) = maybe_editor_data
                    && let Some(path) = path_opt
                {
                    let schema_opt = {
                        let reg = app
                            .project
                            .schema_registry
                            .read()
                            .expect("schema_registry poisoned");
                        reg.resolve(&path)
                    };
                    if let Some(schema_content) = schema_opt {
                        // Ensure parsed schema is cached in AppState (mutating app is allowed here).
                        if !app.schema_cache.contains_key(&schema_content.schema_json)
                            && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(
                                &schema_content.schema_json,
                            )
                        {
                            app.schema_cache.insert(
                                schema_content.schema_json.clone(),
                                std::sync::Arc::new(parsed),
                            );
                        }
                        // Clone Arc for the background task (Arc is cheap to clone).
                        if let Some(arc_schema) =
                            app.schema_cache.get(&schema_content.schema_json).cloned()
                        {
                            let op_tx = op_tx.clone();
                            let lines_clone2 = lines_clone.clone();
                            let trigger_pos = trigger_cursor;
                            let version_copy = version;
                            let pb = path.clone();
                            tokio::spawn(async move {
                                let items = tokio::task::spawn_blocking(move || -> Vec<_> {
                                    crate::schema::completions_from_parsed_schema(
                                        arc_schema.as_ref(),
                                        &lines_clone2,
                                        trigger_pos,
                                        Some(pb.as_path()),
                                    )
                                })
                                .await
                                .unwrap_or_default();
                                let op = Operation::LspLocal(LspOp::CompletionResponse {
                                    items,
                                    trigger: Some(trigger_pos),
                                    version: Some(version_copy),
                                });
                                let _ = op_tx.send(vec![op]);
                            });
                        }
                    }
                }
            }
        }

        Operation::LspTriggerHover { idle_generation, .. } => {
            // If this is a stale idle-timer trigger, discard it.
            if let Some(trigger_gen) = idle_generation {
                if *trigger_gen != app.hover_idle_generation {
                    return;
                }
                // Don't clobber an already-visible hover box.
                if let Screen::Editor(ed) = &app.screen
                    && ed.hover.is_some() {
                        return;
                    }
            }
            if let Screen::Editor(ed) = &app.screen
                && let Some(lsp_client) = lsp_for_editor(lsp, ed)
                && let Some(ref path) = ed.buffer.path
            {
                let uri = path_to_uri(path);
                let row = ed.buffer.cursor().line as u32;
                let col = ed.buffer.cursor().column as u32;
                lsp_client.request_hover(&uri, row, col);
            }
        }

        Operation::GoTo { target } => {
            match target {
                crate::operation::GoToTarget::LspRequest => {
                    if let Screen::Editor(ed) = &app.screen
                        && let Some(lsp_client) = lsp_for_editor(lsp, ed)
                        && let Some(ref path) = ed.buffer.path
                    {
                        let uri = path_to_uri(path);
                        let row = ed.buffer.cursor().line as u32;
                        let col = ed.buffer.cursor().column as u32;
                        lsp_client.request_definition(&uri, row, col);
                    }
                }
                crate::operation::GoToTarget::LspLocation(loc) => {
                    let path = loc.path.clone();
                    let row = loc.row;
                    let col = loc.col;
                    queue.push_back(Operation::OpenFile { path: path.clone() });
                    queue.push_back(Operation::MoveCursor {
                        path: Some(path.clone()),
                        cursor: crate::editor::Position::new(row, col),
                    });
                    app.status_msg = Some(format!(
                        "Jumped to {}:{}:{}",
                        path.display(),
                        row + 1,
                        col + 1
                    ));
                }
                crate::operation::GoToTarget::FileLine { path, line, col } => {
                    let p = path.clone();
                    queue.push_back(Operation::OpenFile { path: p.clone() });
                    queue.push_back(Operation::MoveCursor {
                        path: Some(p.clone()),
                        cursor: crate::editor::Position::new(*line, col.unwrap_or(0)),
                    });
                    app.status_msg = Some(format!(
                        "Opened {}:{}",
                        p.display(),
                        line + 1
                    ));
                }
                _ => {
                    app.status_msg = Some("GoTo: target not implemented.".into());
                }
            }
        }

        Operation::LspGoToDefinitionResult(locations) => {
            let locations = locations.clone();
            match locations.as_slice() {
                [] => {
                    app.status_msg = Some("No definition found.".into());
                }
                [loc] => {
                    queue.push_back(Operation::GoTo {
                        target: crate::operation::GoToTarget::LspLocation(loc.clone()),
                    });
                }
                _ => {
                    // Multiple results: open the first one for now.
                    let loc = &locations[0];
                    queue.push_back(Operation::GoTo {
                        target: crate::operation::GoToTarget::LspLocation(loc.clone()),
                    });
                }
            }
        }

        // Store LSP trigger characters from server capabilities.
        Operation::LspTriggerCharactersReceived { lang_id, trigger_chars } => {
            let lang = lang_id.clone();
            let chars = trigger_chars.clone();
            if !chars.is_empty() {
                log::debug!("lsp: storing trigger characters for {}: {:?}", lang, chars);
                app.lsp_trigger_chars.insert(lang, chars);
            }
        }

        // Trigger LSP rename: open prepare-rename request or prompt directly.
        Operation::LspTriggerRename { .. } => {
            if let Screen::Editor(ed) = &app.screen {
                // Extract the word under the cursor as a fallback pre-fill.
                let fallback_name = word_at_cursor(&ed.buffer);
                match lsp_for_editor(lsp, ed) {
                    None => {
                        app.status_msg = Some("No LSP server for this file type.".into());
                    }
                    Some(lsp_client) => {
                        if let Some(ref path) = ed.buffer.path {
                            let uri = path_to_uri(path);
                            let row = ed.buffer.cursor().line as u32;
                            let col = ed.buffer.cursor().column as u32;
                            lsp_client.request_prepare_rename(&uri, row, col, fallback_name);
                        }
                    }
                }
            }
        }

        // User confirmed the rename prompt — send the rename request.
        Operation::LspTriggerRenameWith { new_name, row, col } => {
            if new_name.is_empty() {
                return;
            }
            if let Screen::Editor(ed) = &app.screen
                && let Some(ref path) = ed.buffer.path
            {
                // Save unsaved changes before rename so the server sees current content.
                if ed.buffer.is_dirty() {
                    queue.push_back(Operation::SaveFile { path: path.clone() });
                }
                if let Some(lsp_client) = lsp_for_editor(lsp, ed) {
                    let uri = path_to_uri(path);
                    lsp_client.request_rename(&uri, *row, *col, new_name.clone());
                }
            }
        }

        // Apply the workspace edit returned by the server.
        Operation::LspRenameResult(file_edits) => {
            let file_edits = file_edits.clone();
            if file_edits.is_empty() {
                app.status_msg = Some("Rename: no changes returned by server.".into());
                return;
            }
            let mut file_count = 0usize;
            let mut total_edits = 0usize;

            // Collect the open file path before the loop (borrowing limitation).
            let open_path = if let Screen::Editor(ed) = &app.screen {
                ed.buffer.path.clone()
            } else {
                None
            };

            for file_edit in &file_edits {
                file_count += 1;
                total_edits += file_edit.edits.len();
                // Write all edits to disk uniformly, regardless of whether the file
                // is currently open. The open buffer will be reloaded afterward.
                apply_rename_edits_to_file(&file_edit.path, &file_edit.edits);
            }

            // Reload the open file if it was among the edited files.
            // Compare canonicalized paths to handle symlinks / relative-vs-absolute mismatches.
            if let Some(ref path) = open_path {
                let canon_open = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
                let was_edited = file_edits.iter().any(|fe| {
                    let canon_fe = std::fs::canonicalize(&fe.path).unwrap_or_else(|_| fe.path.clone());
                    canon_fe == canon_open
                });
                if was_edited {
                    queue.push_back(Operation::ReloadFromDisk {
                        path: path.clone(),
                        accept_external: true,
                        silent: true,
                    });
                }
            }

            app.status_msg = Some(format!(
                "Renamed: {} edit{} across {} file{}",
                total_edits,
                if total_edits == 1 { "" } else { "s" },
                file_count,
                if file_count == 1 { "" } else { "s" },
            ));
        }

        // Editor-local rename prompt operations — dispatch to view.
        Operation::LspRenameLocal(_) => {
            if let Some(event) = dispatch_to_view(op.clone(), app) {
                emitted.push(event);
            }
        }

        // Text edits: dispatch to view, then notify LSP of the change.
        Operation::InsertText { .. }
        | Operation::DeleteText { .. }
        | Operation::DeleteForward { .. }
        | Operation::ReplaceRange { .. } => {
            if let Some(event) = dispatch_to_view(op.clone(), app) {
                emitted.push(event);
            }
            // Send didChange to LSP after the buffer has been mutated.
            if let Screen::Editor(ed) = &app.screen
                && let Some(lsp_client) = lsp_for_editor(lsp, ed)
                && let Some(ref path) = ed.buffer.path
            {
                let uri = path_to_uri(path);
                let version = ed.lsp_version;
                let text = ed.buffer.lines().join("\n") + "\n";
                lsp_client.did_change(&uri, version, &text);
            }
        }

        // ── CommitWindow git trigger ops ────────────────────────────────────
        Operation::CommitLocal(CommitOp::ToggleFileStage) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let path = cw.selected_file_path();
                let is_staged = cw.is_selected_staged();
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                if let Some(file_path) = path {
                    let display = file_path.display().to_string();
                    tokio::task::spawn_blocking(move || {
                        let result = GitVcs::open(&root).and_then(|v| {
                            if is_staged {
                                v.unstage_file(&file_path)?;
                            } else {
                                v.stage_file(&file_path)?;
                            }
                            let (staged, unstaged) = v.status()?;
                            let (branches, current_branch) = v.branches()?;
                            let verb = if is_staged { "Unstaged" } else { "Staged" };
                            Ok(vec![
                                Operation::CommitLocal(CommitOp::StatusLoaded {
                                    staged,
                                    unstaged,
                                    branches,
                                    current_branch,
                                }),
                                Operation::CommitLocal(CommitOp::GitOpSuccess(format!(
                                    "{} {}",
                                    verb, display
                                ))),
                            ])
                        });
                        let _ = tx.send(result.unwrap_or_else(|e| {
                            vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                        }));
                    });
                }
            }
        }

        Operation::CommitLocal(CommitOp::EnterAmendMode) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    // Probe git for HEAD message + oid using the CLI, then load status via GitVcs
                    let root_str = root.to_string_lossy().to_string();
                    let mut msg = String::new();
                    let mut oid_opt: Option<String> = None;

                    if let Ok(out) = std::process::Command::new("git")
                        .args(["-C", root_str.as_str(), "log", "-1", "--pretty=%B"])
                        .output()
                        && out.status.success() {
                            msg = String::from_utf8_lossy(&out.stdout).trim_end().to_string();
                        }
                    if let Ok(out) = std::process::Command::new("git")
                        .args(["-C", root_str.as_str(), "rev-parse", "HEAD"])
                        .output()
                        && out.status.success() {
                            oid_opt = Some(String::from_utf8_lossy(&out.stdout).trim().to_string());
                        }

                    let result = GitVcs::open(&root).and_then(|v| {
                        let (staged, unstaged) = v.status()?;
                        let (branches, current_branch) = v.branches()?;

                        Ok(vec![
                            Operation::CommitLocal(CommitOp::StatusLoaded {
                                staged: staged.clone(),
                                unstaged: unstaged.clone(),
                                branches: branches.clone(),
                                current_branch: current_branch.clone(),
                            }),
                            Operation::CommitLocal(CommitOp::MessageChanged(msg.clone())),
                            Operation::CommitLocal(CommitOp::AmendModeSet(true, oid_opt.clone())),
                        ])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                    }));
                });
            }
        }

        Operation::CommitLocal(CommitOp::ConfirmAmend) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                if cw.message_is_empty() {
                    if let Screen::CommitWindow(cw) = &mut app.screen {
                        cw.status_msg = Some("Commit message required.".into());
                    }
                    return;
                }
                let message = cw.message_text();
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                // If there are line-level staged lines, stage them first then amend.
                let diff_info = cw.diff_stage_info();
                tokio::task::spawn_blocking(move || {
                    // Stage any marked lines first (via GitVcs), then run `git commit --amend` via CLI,
                    // finally refresh status via a fresh GitVcs instance.
                    if let Some((path, _staged, selected, diff)) = diff_info
                        && !selected.is_empty()
                            && let Ok(v0) = GitVcs::open(&root) {
                                v0.stage_lines(&path, &selected, &diff).ok();
                            };

                    // Run git CLI amend with message via stdin
                    let root_str = root.to_string_lossy().to_string();
                    let mut cmd = std::process::Command::new("git");
                    cmd.arg("-C").arg(root_str.as_str()).arg("commit").arg("--amend").arg("-F").arg("-").stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
                    match cmd.spawn() {
                        Ok(mut child) => {
                            if let Some(mut stdin) = child.stdin.take() {
                                use std::io::Write;
                                let _ = stdin.write_all(message.as_bytes());
                            }
                            let out = child.wait_with_output();
                            match out {
                                Ok(o) if o.status.success() => {
                                    // Refresh status from a new GitVcs instance
                                    let result = GitVcs::open(&root).and_then(|v| {
                                        let (staged, unstaged) = v.status()?;
                                        let (branches, current_branch) = v.branches()?;
                                        Ok(vec![
                                            Operation::CommitLocal(CommitOp::StatusLoaded {
                                                staged,
                                                unstaged,
                                                branches,
                                                current_branch,
                                            }),
                                            Operation::CommitLocal(CommitOp::GitOpSuccess("Amended.".into())),
                                        ])
                                    });
                                    let _ = tx.send(result.unwrap_or_else(|e| {
                                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                                    }));
                                }
                                Ok(o) => {
                                    let err = String::from_utf8_lossy(&o.stderr).to_string();
                                    let _ = tx.send(vec![Operation::CommitLocal(CommitOp::GitOpError(err))]);
                                }
                                Err(e) => {
                                    let _ = tx.send(vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]);
                                }
                            }
                        }
                        Err(e) => {
                            let _ = tx.send(vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]);
                        }
                    }
                });
                // Clear message in view immediately.
                if let Screen::CommitWindow(cw) = &mut app.screen {
                    cw.message_buf =
                        crate::editor::buffer::Buffer::from_lines(vec![String::new()], None);
                }
            }
        }

        Operation::CommitLocal(CommitOp::Confirm) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                if cw.message_is_empty() {
                    if let Screen::CommitWindow(cw) = &mut app.screen {
                        cw.status_msg = Some("Commit message required.".into());
                    }
                    return;
                }
                if cw.has_conflicts {
                    // Already shown in the view — guard here so the git call is never made.
                    return;
                }
                let message = cw.message_text();
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                let git_mode = cw.git_mode;
                let diff_info = cw.diff_stage_info();

                match git_mode {
                    crate::vcs::GitMode::Rebase => {
                        // In rebase mode, Confirm = git rebase --continue.
                        tokio::task::spawn_blocking(move || {
                            // Write the (possibly edited) message to COMMIT_EDITMSG.
                            let git_dir = root.join(".git");
                            let _ = std::fs::write(git_dir.join("COMMIT_EDITMSG"), &message);
                            let root_str = root.to_string_lossy().to_string();
                            let out = std::process::Command::new("git")
                                .args(["-C", root_str.as_str(), "rebase", "--continue"])
                                .env("GIT_EDITOR", "true") // skip interactive editor
                                .output();
                            let ops = match out {
                                Ok(o) if o.status.success() => {
                                    let state = crate::vcs::detect_git_op_state(&root);
                                    let mut result_ops = vec![
                                        Operation::CommitLocal(CommitOp::GitModeDetected {
                                            mode: state.mode,
                                            has_conflicts: state.has_conflicts,
                                            message: state.message,
                                        }),
                                    ];
                                    if state.mode == crate::vcs::GitMode::Normal {
                                        result_ops.push(Operation::CommitLocal(
                                            CommitOp::GitOpSuccess("Rebase step completed.".into()),
                                        ));
                                    }
                                    // Refresh status.
                                    if let Ok(v) = GitVcs::open(&root)
                                        && let Ok((staged, unstaged)) = v.status()
                                            && let Ok((branches, current_branch)) = v.branches() {
                                                result_ops.push(Operation::CommitLocal(
                                                    CommitOp::StatusLoaded {
                                                        staged,
                                                        unstaged,
                                                        branches,
                                                        current_branch,
                                                    },
                                                ));
                                            }
                                    result_ops
                                }
                                Ok(o) => {
                                    let err = String::from_utf8_lossy(&o.stderr).to_string();
                                    vec![Operation::CommitLocal(CommitOp::GitOpError(err))]
                                }
                                Err(e) => vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))],
                            };
                            let _ = tx.send(ops);
                        });
                    }
                    crate::vcs::GitMode::Merge | crate::vcs::GitMode::Normal => {
                        // Normal commit (also finalises a merge).
                        tokio::task::spawn_blocking(move || {
                            let result = GitVcs::open(&root).and_then(|v| {
                                if let Some((path, _staged, selected, diff)) = diff_info
                                    && !selected.is_empty()
                                {
                                    v.stage_lines(&path, &selected, &diff)?;
                                }
                                v.commit(&message)?;
                                let (staged, unstaged) = v.status()?;
                                let (branches, current_branch) = v.branches()?;
                                let success_msg = match git_mode {
                                    crate::vcs::GitMode::Merge => "Merge committed.",
                                    _ => "Committed.",
                                };
                                Ok(vec![
                                    Operation::CommitLocal(CommitOp::StatusLoaded {
                                        staged,
                                        unstaged,
                                        branches,
                                        current_branch,
                                    }),
                                    Operation::CommitLocal(CommitOp::GitOpSuccess(success_msg.into())),
                                    // Reset mode after a successful merge commit.
                                    Operation::CommitLocal(CommitOp::GitModeDetected {
                                        mode: crate::vcs::GitMode::Normal,
                                        has_conflicts: false,
                                        message: String::new(),
                                    }),
                                ])
                            });
                            let _ = tx.send(result.unwrap_or_else(|e| {
                                vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                            }));
                        });
                    }
                }
                // Clear message in view immediately.
                if let Screen::CommitWindow(cw) = &mut app.screen {
                    cw.message_buf =
                        crate::editor::buffer::Buffer::from_lines(vec![String::new()], None);
                }
            }
        }

        Operation::CommitLocal(CommitOp::AbortRebase) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let root_str = root.to_string_lossy().to_string();
                    let out = std::process::Command::new("git")
                        .args(["-C", root_str.as_str(), "rebase", "--abort"])
                        .output();
                    let ops = match out {
                        Ok(o) if o.status.success() => {
                            let mut result_ops = vec![
                                Operation::CommitLocal(CommitOp::GitModeDetected {
                                    mode: crate::vcs::GitMode::Normal,
                                    has_conflicts: false,
                                    message: String::new(),
                                }),
                                Operation::CommitLocal(CommitOp::GitOpSuccess("Rebase aborted.".into())),
                            ];
                            if let Ok(v) = GitVcs::open(&root)
                                && let Ok((staged, unstaged)) = v.status()
                                    && let Ok((branches, current_branch)) = v.branches() {
                                        result_ops.push(Operation::CommitLocal(CommitOp::StatusLoaded {
                                            staged, unstaged, branches, current_branch,
                                        }));
                                    }
                            result_ops
                        }
                        Ok(o) => {
                            let err = String::from_utf8_lossy(&o.stderr).to_string();
                            vec![Operation::CommitLocal(CommitOp::GitOpError(err))]
                        }
                        Err(e) => vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))],
                    };
                    let _ = tx.send(ops);
                });
            }
        }

        Operation::CommitLocal(CommitOp::AbortMerge) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let root_str = root.to_string_lossy().to_string();
                    let out = std::process::Command::new("git")
                        .args(["-C", root_str.as_str(), "merge", "--abort"])
                        .output();
                    let ops = match out {
                        Ok(o) if o.status.success() => {
                            let mut result_ops = vec![
                                Operation::CommitLocal(CommitOp::GitModeDetected {
                                    mode: crate::vcs::GitMode::Normal,
                                    has_conflicts: false,
                                    message: String::new(),
                                }),
                                Operation::CommitLocal(CommitOp::GitOpSuccess("Merge aborted.".into())),
                            ];
                            if let Ok(v) = GitVcs::open(&root)
                                && let Ok((staged, unstaged)) = v.status()
                                    && let Ok((branches, current_branch)) = v.branches() {
                                        result_ops.push(Operation::CommitLocal(CommitOp::StatusLoaded {
                                            staged, unstaged, branches, current_branch,
                                        }));
                                    }
                            result_ops
                        }
                        Ok(o) => {
                            let err = String::from_utf8_lossy(&o.stderr).to_string();
                            vec![Operation::CommitLocal(CommitOp::GitOpError(err))]
                        }
                        Err(e) => vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))],
                    };
                    let _ = tx.send(ops);
                });
            }
        }

        Operation::CommitLocal(CommitOp::RevertLine) => {
            if let Screen::CommitWindow(cw) = &app.screen
                && let Some((path, _staged, _sel, diff)) = cw.diff_stage_info()
            {
                let cursor = cw
                    .diff_widget
                    .as_ref()
                    .and_then(|w| w.selected_line_no())
                    .unwrap_or(0);
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        v.unstage_lines(&path, &[cursor], &diff)?;
                        let lines = v.diff_file(&path, false)?;
                        Ok(vec![Operation::CommitLocal(CommitOp::DiffLoaded {
                            path,
                            staged: false,
                            lines,
                        })])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                    }));
                });
            }
        }

        Operation::CommitLocal(CommitOp::BranchCreate) => {
            if let Screen::CommitWindow(cw) = &app.screen
                && let Some(ref bi) = cw.branch_input
                && !bi.text().is_empty()
            {
                let name = bi.text().to_string();
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        v.create_branch(&name)?;
                        v.checkout_branch(&name)?;
                        let (staged, unstaged) = v.status()?;
                        let (branches, current_branch) = v.branches()?;
                        Ok(vec![
                            Operation::CommitLocal(CommitOp::StatusLoaded {
                                staged,
                                unstaged,
                                branches,
                                current_branch,
                            }),
                            Operation::CommitLocal(CommitOp::GitOpSuccess(format!(
                                "Switched to new branch '{}'",
                                name
                            ))),
                        ])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                    }));
                });
                // Clear branch input.
                if let Screen::CommitWindow(cw) = &mut app.screen {
                    cw.branch_input = None;
                }
            }
            // Also dispatch to view to clear branch_input in state.
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        Operation::CommitLocal(CommitOp::BranchCheckout(name)) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let root = cw.repo_path.clone();
                let name = name.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        v.checkout_branch(&name)?;
                        let (staged, unstaged) = v.status()?;
                        let (branches, current_branch) = v.branches()?;
                        Ok(vec![
                            Operation::CommitLocal(CommitOp::StatusLoaded {
                                staged,
                                unstaged,
                                branches,
                                current_branch,
                            }),
                            Operation::CommitLocal(CommitOp::GitOpSuccess(format!(
                                "Switched to branch '{}'",
                                name
                            ))),
                        ])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                    }));
                });
            }
            // Clear any open branch selector in app state.
            if let Screen::CommitWindow(cw) = &mut app.screen {
                // Close any open branch selector popup.
                cw.branch_popup = None;
            }
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        Operation::CommitLocal(CommitOp::LoadDiff { path, staged }) => {
            if let Screen::CommitWindow(cw) = &app.screen {
                let path = path.clone();
                let staged = *staged;
                let root = cw.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        let lines = v.diff_file(&path, staged)?;
                        Ok(vec![Operation::CommitLocal(CommitOp::DiffLoaded {
                            path,
                            staged,
                            lines,
                        })])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                    }));
                });
            }
        }

        // CommitLocal SwitchTab — dispatch to view, then handle side-effects.
        Operation::CommitLocal(CommitOp::SwitchTab) => {
            // Capture line-staging state BEFORE the tab switch so we can apply it
            // when switching Diff → Commit.
            let pre_switch_diff_info = if let Screen::CommitWindow(cw) = &app.screen {
                use crate::views::commit::CommitTab;
                if cw.tab == CommitTab::Diff {
                    cw.diff_stage_info()
                } else {
                    None
                }
            } else {
                None
            };

            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }

            if let Screen::CommitWindow(cw) = &app.screen {
                use crate::views::commit::CommitTab;
                match cw.tab {
                    // Just switched TO the Diff tab — load diff if needed.
                    CommitTab::Diff => {
                        if let Some(ref path) = cw.diff_file.clone()
                            && cw.diff_widget.is_none()
                        {
                            let path = path.clone();
                            let staged = cw.diff_staged;
                            queue.push_back(Operation::CommitLocal(CommitOp::LoadDiff {
                                path,
                                staged,
                            }));
                        }
                    }
                    // Just switched TO the Commit tab — apply any marked diff lines.
                    CommitTab::Commit => {
                        if let Some((path, _, selected, diff)) = pre_switch_diff_info
                            && !selected.is_empty()
                        {
                            let root = cw.repo_path.clone();
                            let tx = op_tx.clone();
                            tokio::task::spawn_blocking(move || {
                                let result = GitVcs::open(&root).and_then(|v| {
                                    v.stage_lines(&path, &selected, &diff)?;
                                    let (staged, unstaged) = v.status()?;
                                    let (branches, current_branch) = v.branches()?;
                                    Ok(vec![Operation::CommitLocal(CommitOp::StatusLoaded {
                                        staged,
                                        unstaged,
                                        branches,
                                        current_branch,
                                    })])
                                });
                                let _ = tx.send(result.unwrap_or_else(|e| {
                                    vec![Operation::CommitLocal(CommitOp::GitOpError(
                                        e.to_string(),
                                    ))]
                                }));
                            });
                        }
                    }
                }
            }
        }

        // All other CommitLocal ops are pure UI — dispatch directly to the view.
        Operation::CommitLocal(_) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        // ── History Editor ops ───────────────────────────────────────────────

        // MoveUp / MoveDown: update in-memory list and set pending_move_origin.
        Operation::HistoryEditorLocal(HistoryEditorOp::MoveUp) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                if he.pending_move_origin.is_none() {
                    he.pending_move_origin = Some(he.cursor);
                }
                he.swap_toward_older();
            }
        }

        Operation::HistoryEditorLocal(HistoryEditorOp::MoveDown) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                if he.pending_move_origin.is_none() {
                    he.pending_move_origin = Some(he.cursor);
                }
                he.swap_toward_newer();
            }
        }

        // ConfirmMove: run git rebase to apply the reordered list.
        Operation::HistoryEditorLocal(HistoryEditorOp::ConfirmMove) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                he.pending_move_origin = None;

                let root = he.repo_path.clone();
                let instructions: Vec<crate::vcs::RebaseInstruction> = he
                    .commits
                    .iter()
                    .map(|c| crate::vcs::RebaseInstruction::Pick { sha: c.oid.clone() })
                    .collect();
                let tx = op_tx.clone();
                he.loading = true;
                he.status_msg = None;

                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        let pre_sha = v.run_interactive_rebase(&instructions)?;
                        let new_commits = v.log_commits(100, "")?;
                        Ok((pre_sha, new_commits))
                    });
                    let ops = match result {
                        Ok((pre_sha, commits)) => vec![Operation::HistoryEditorLocal(
                            HistoryEditorOp::OperationCompleted {
                                commits,
                                pre_sha: Some(pre_sha),
                                description: "Move commit".to_owned(),
                            },
                        )],
                        Err(e) => vec![Operation::HistoryEditorLocal(
                            HistoryEditorOp::OperationFailed(e.to_string()),
                        )],
                    };
                    let _ = tx.send(ops);
                });
            }
        }

        // StartReword: enter editor mode (pre-fill buffer).
        Operation::HistoryEditorLocal(HistoryEditorOp::StartReword) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                he.enter_reword_mode();
            }
        }

        // StartSquash: enter squash editor mode.
        Operation::HistoryEditorLocal(HistoryEditorOp::StartSquash { target_idx }) => {
            let target_idx = *target_idx;
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                he.enter_squash_mode(target_idx);
            }
        }

        // ConfirmEdit: take the message and run git rebase (reword/squash).
        Operation::HistoryEditorLocal(HistoryEditorOp::ConfirmEdit) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                if he.message_is_empty() {
                    he.status_msg = Some("Message cannot be empty".to_owned());
                    return;
                }
                let message = he.current_message();
                let mode = he.mode.clone();
                let cursor = he.cursor;
                let root = he.repo_path.clone();
                let tx = op_tx.clone();

                let instructions: Vec<crate::vcs::RebaseInstruction> = he
                    .commits
                    .iter()
                    .enumerate()
                    .map(|(i, c)| match &mode {
                        crate::views::git_history_editor::EditorMode::Reword if i == cursor => {
                            crate::vcs::RebaseInstruction::Reword {
                                sha: c.oid.clone(),
                                new_message: message.clone(),
                            }
                        }
                        crate::views::git_history_editor::EditorMode::Squash { target_idx }
                            if i == cursor =>
                        {
                            let _ = target_idx;
                            crate::vcs::RebaseInstruction::Squash {
                                sha: c.oid.clone(),
                                new_message: message.clone(),
                            }
                        }
                        _ => crate::vcs::RebaseInstruction::Pick { sha: c.oid.clone() },
                    })
                    .collect();

                // Only rebase from cursor (newest affected) to HEAD.
                let instructions = instructions[..=cursor].to_vec();

                he.loading = true;
                he.mode = crate::views::git_history_editor::EditorMode::List;
                he.status_msg = None;

                let description = match &mode {
                    crate::views::git_history_editor::EditorMode::Squash { .. } => "Squash commit",
                    _ => "Reword commit",
                }
                .to_owned();

                tokio::task::spawn_blocking(move || {
                    let result = GitVcs::open(&root).and_then(|v| {
                        let pre_sha = v.run_interactive_rebase(&instructions)?;
                        let new_commits = v.log_commits(100, "")?;
                        Ok((pre_sha, new_commits))
                    });
                    let ops = match result {
                        Ok((pre_sha, commits)) => vec![Operation::HistoryEditorLocal(
                            HistoryEditorOp::OperationCompleted {
                                commits,
                                pre_sha: Some(pre_sha),
                                description,
                            },
                        )],
                        Err(e) => {
                            let msg = e.to_string();
                            if msg.contains("CONFLICT") || msg.contains("conflict") {
                                vec![
                                    Operation::SwitchScreen {
                                        to: ScreenKind::CommitWindow,
                                    },
                                ]
                            } else {
                                vec![Operation::HistoryEditorLocal(
                                    HistoryEditorOp::OperationFailed(msg),
                                )]
                            }
                        }
                    };
                    let _ = tx.send(ops);
                });
            }
        }

        // DeleteCommit: first press sets pending; second press runs git drop.
        Operation::HistoryEditorLocal(HistoryEditorOp::DeleteCommit) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                let selected_sha = he.commits.get(he.cursor).map(|c| c.oid.clone());

                if let Some(sha) = selected_sha {
                    if he.pending_delete_sha.as_deref() == Some(sha.as_str()) {
                        he.pending_delete_sha = None;
                        let cursor = he.cursor;
                        let root = he.repo_path.clone();
                        let tx = op_tx.clone();

                        let instructions: Vec<crate::vcs::RebaseInstruction> = he
                            .commits
                            .iter()
                            .enumerate()
                            .map(|(i, c)| {
                                if i == cursor {
                                    crate::vcs::RebaseInstruction::Drop { sha: c.oid.clone() }
                                } else {
                                    crate::vcs::RebaseInstruction::Pick { sha: c.oid.clone() }
                                }
                            })
                            .collect();
                        let instructions = instructions[..=cursor].to_vec();

                        he.loading = true;
                        tokio::task::spawn_blocking(move || {
                            let result = GitVcs::open(&root).and_then(|v| {
                                let pre_sha = v.run_interactive_rebase(&instructions)?;
                                let commits = v.log_commits(100, "")?;
                                Ok((pre_sha, commits))
                            });
                            let ops = match result {
                                Ok((pre_sha, commits)) => vec![Operation::HistoryEditorLocal(
                                    HistoryEditorOp::OperationCompleted {
                                        commits,
                                        pre_sha: Some(pre_sha),
                                        description: "Drop commit".to_owned(),
                                    },
                                )],
                                Err(e) => vec![Operation::HistoryEditorLocal(
                                    HistoryEditorOp::OperationFailed(e.to_string()),
                                )],
                            };
                            let _ = tx.send(ops);
                        });
                    } else {
                        he.pending_delete_sha = Some(sha.clone());
                        he.status_msg = Some("Press Del again to confirm drop".to_owned());
                    }
                }
            }
        }

        // UndoLast: run git reset --hard <pre_op_sha>.
        Operation::HistoryEditorLocal(HistoryEditorOp::UndoLast) => {
            if let Screen::GitHistoryEditor(he) = &mut app.screen {
                if let Some(entry) = he.undo_stack.pop() {
                    let sha = entry.pre_op_sha.clone();
                    let root = he.repo_path.clone();
                    let tx = op_tx.clone();
                    he.loading = true;
                    he.status_msg = None;

                    tokio::task::spawn_blocking(move || {
                        let result = GitVcs::open(&root).and_then(|v| {
                            v.reset_hard(&sha)?;
                            let commits = v.log_commits(100, "")?;
                            Ok(commits)
                        });
                        let ops = match result {
                            Ok(commits) => vec![Operation::HistoryEditorLocal(
                                HistoryEditorOp::OperationCompleted {
                                    commits,
                                    pre_sha: None,
                                    description: String::new(),
                                },
                            )],
                            Err(e) => vec![Operation::HistoryEditorLocal(
                                HistoryEditorOp::OperationFailed(e.to_string()),
                            )],
                        };
                        let _ = tx.send(ops);
                    });
                } else if let Screen::GitHistoryEditor(he) = &mut app.screen {
                    he.status_msg = Some("Nothing to undo".to_owned());
                }
            }
        }

        // All other HistoryEditorLocal ops fall through to dispatch_to_view.
        Operation::HistoryEditorLocal(_) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        // ── GitHistory ops ───────────────────────────────────────────────────
        Operation::GitHistoryLocal(GitHistoryOp::SelectRevision) => {
            if let Screen::GitHistory(gh) = &mut app.screen
                && let Some(oid) = gh.selected_revision_oid().map(|s| s.to_owned())
            {
                let root = gh.repo_path.clone();
                gh.loading = true;
                gh.files.set_items(vec![]);
                gh.diff = None;
                gh.loaded_oid = None;
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                        let files = v.commit_files(&oid)?;
                        Ok(vec![Operation::GitHistoryLocal(
                            GitHistoryOp::FilesLoaded {
                                oid: oid.clone(),
                                files,
                            },
                        )])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::GitHistoryLocal(GitHistoryOp::LoadError(
                            e.to_string(),
                        ))]
                    }));
                });
            }
        }

        // SelectFile: spawn diff loading for the currently selected file.
        Operation::GitHistoryLocal(GitHistoryOp::SelectFile) => {
            if let Screen::GitHistory(gh) = &app.screen
                && let (Some(oid), Some(path)) = (
                    gh.loaded_oid.clone(),
                    gh.selected_file_path().map(|p| p.to_path_buf()),
                )
            {
                let root = gh.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                        let lines = v.commit_diff(&oid, &path)?;
                        Ok(vec![Operation::GitHistoryLocal(GitHistoryOp::DiffLoaded {
                            lines,
                        })])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::GitHistoryLocal(GitHistoryOp::LoadError(
                            e.to_string(),
                        ))]
                    }));
                });
            }
        }

        // FilesLoaded: dispatch to view, then auto-load diff for first file.
        Operation::GitHistoryLocal(GitHistoryOp::FilesLoaded { .. }) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
            // After files land, auto-load diff for the first file.
            if let Screen::GitHistory(gh) = &app.screen
                && let (Some(oid), Some(path)) = (
                    gh.loaded_oid.clone(),
                    gh.selected_file_path().map(|p| p.to_path_buf()),
                )
            {
                let root = gh.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                        let lines = v.commit_diff(&oid, &path)?;
                        Ok(vec![Operation::GitHistoryLocal(GitHistoryOp::DiffLoaded {
                            lines,
                        })])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::GitHistoryLocal(GitHistoryOp::LoadError(
                            e.to_string(),
                        ))]
                    }));
                });
            }
        }

        // FilterInput: dispatch to view (updates state + increments generation),
        // then spawn a new commit-list search with the updated filter.
        Operation::GitHistoryLocal(GitHistoryOp::FilterInput(_)) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
            if let Screen::GitHistory(gh) = &app.screen {
                let root = gh.repo_path.clone();
                let filter = gh.filter_text().to_owned();
                let generation = gh.search_generation;
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                        let commits = v.log_commits(200, &filter)?;
                        Ok(vec![Operation::GitHistoryLocal(
                            GitHistoryOp::CommitsLoaded {
                                generation,
                                commits,
                            },
                        )])
                    });
                    let _ = tx.send(result.unwrap_or_else(|e| {
                        vec![Operation::GitHistoryLocal(GitHistoryOp::LoadError(
                            e.to_string(),
                        ))]
                    }));
                });
            }
        }

        // All other GitHistoryLocal ops are pure UI.
        Operation::GitHistoryLocal(_) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        // ── Branch Management View ─────────────────────────────────────────

        // CheckoutBranch: spawn an async checkout task.
        Operation::BranchViewLocal(BranchViewOp::CheckoutBranch) => {
            if let Screen::GitBranches(bv) = &mut app.screen
                && let Some(name) = bv.selected_branch_name().map(|s| s.to_owned()) {
                    let is_remote = bv.is_remote_selected();
                    let root = bv.repo_path.clone();
                    bv.loading = true;
                    let tx = op_tx.clone();
                    tokio::task::spawn_blocking(move || {
                        let result = if is_remote {
                            // For remote branches (e.g. "origin/feature"), create a local
                            // tracking branch: `git checkout --track origin/feature`.
                            std::process::Command::new("git")
                                .args(["-C", root.to_str().unwrap_or("."), "checkout", "--track", &name])
                                .output()
                                .map_err(|e| anyhow::anyhow!(e))
                                .and_then(|o| {
                                    if o.status.success() {
                                        // Determine the local branch name (strip remote prefix).
                                        let local_name = name.split_once('/').map(|x| x.1)
                                            .unwrap_or(&name)
                                            .to_owned();
                                        Ok(vec![Operation::BranchViewLocal(
                                            BranchViewOp::CheckoutCompleted(local_name),
                                        )])
                                    } else {
                                        let stderr =
                                            String::from_utf8_lossy(&o.stderr).trim().to_owned();
                                        Err(anyhow::anyhow!("{stderr}"))
                                    }
                                })
                        } else {
                            crate::vcs::GitVcs::open(&root).and_then(|v| {
                                v.checkout_branch(&name)?;
                                Ok(vec![Operation::BranchViewLocal(
                                    BranchViewOp::CheckoutCompleted(name),
                                )])
                            })
                        };
                        let _ = tx.send(result.unwrap_or_else(|e| {
                            vec![Operation::BranchViewLocal(BranchViewOp::CheckoutError(
                                e.to_string(),
                            ))]
                        }));
                    });
                }
        }

        // NewBranchConfirm: spawn an async create+checkout task.
        Operation::BranchViewLocal(BranchViewOp::NewBranchConfirm) => {
            if let Screen::GitBranches(bv) = &mut app.screen {
                let name = bv.new_branch_text().unwrap_or("").trim().to_owned();
                if name.is_empty() {
                    bv.new_branch_input = None;
                } else {
                    let root = bv.repo_path.clone();
                    bv.loading = true;
                    let tx = op_tx.clone();
                    let branch = name.clone();
                    tokio::task::spawn_blocking(move || {
                        let result = std::process::Command::new("git")
                            .args(["-C", root.to_str().unwrap_or("."), "checkout", "-b", &branch])
                            .output()
                            .map_err(|e| anyhow::anyhow!(e))
                            .and_then(|o| {
                                if o.status.success() {
                                    Ok(vec![Operation::BranchViewLocal(
                                        BranchViewOp::CreateCompleted(branch),
                                    )])
                                } else {
                                    let stderr =
                                        String::from_utf8_lossy(&o.stderr).trim().to_owned();
                                    Err(anyhow::anyhow!("{stderr}"))
                                }
                            });
                        let _ = tx.send(result.unwrap_or_else(|e| {
                            vec![Operation::BranchViewLocal(BranchViewOp::CreateError(
                                e.to_string(),
                            ))]
                        }));
                    });
                }
            }
        }

        // DeleteRequested: check if branch is merged and either delete or require confirmation.
        Operation::BranchViewLocal(BranchViewOp::DeleteRequested) => {
            if let Screen::GitBranches(bv) = &mut app.screen
                && let Some(name) = bv.selected_branch_name().map(|s| s.to_owned()) {
                    let is_remote = bv.is_remote_selected();
                    let root = bv.repo_path.clone();
                    let tx = op_tx.clone();
                    // Spawn blocking check + possible immediate delete.
                    tokio::task::spawn_blocking(move || {
                        let res = if is_remote {
                            // Remote deletion requires explicit confirmation.
                            Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteConfirmNeeded {
                                branch: name.clone(),
                                merged: false,
                            })])
                        } else {
                            // Check if the branch is merged into HEAD using `git branch --merged`.
                            match std::process::Command::new("git")
                                .args(["-C", root.to_str().unwrap_or("."), "branch", "--merged"])
                                .output()
                            {
                                Ok(out) if out.status.success() => {
                                    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
                                    let merged = stdout.lines().any(|ln| ln.trim().ends_with(&name));
                                    if merged {
                                        // Safe to delete non-forced.
                                        match std::process::Command::new("git")
                                            .args(["-C", root.to_str().unwrap_or("."), "branch", "-d", &name])
                                            .output()
                                        {
                                            Ok(dout) if dout.status.success() => Ok(vec![Operation::BranchViewLocal(
                                                BranchViewOp::DeleteCompleted(name.clone()),
                                            )]),
                                            _ => Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteConfirmNeeded {
                                                branch: name.clone(),
                                                merged: false,
                                            })]),
                                        }
                                    } else {
                                        Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteConfirmNeeded {
                                            branch: name.clone(),
                                            merged: false,
                                        })])
                                    }
                                }
                                _ => Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteConfirmNeeded {
                                    branch: name.clone(),
                                    merged: false,
                                })]),
                            }
                        };
                        let _ = tx.send(res.unwrap_or_else(|e: anyhow::Error| vec![Operation::BranchViewLocal(BranchViewOp::DeleteError(e.to_string()))]));
                    });
                }
        }

        // DeleteConfirmed: perform forced deletion (local) or remote deletion.
        Operation::BranchViewLocal(BranchViewOp::DeleteConfirmed) => {
            if let Screen::GitBranches(bv) = &mut app.screen
                && let Some(name) = bv.selected_branch_name().map(|s| s.to_owned()) {
                    let is_remote = bv.is_remote_selected();
                    let root = bv.repo_path.clone();
                    bv.loading = true;
                    let tx = op_tx.clone();
                    let branch = name.clone();
                    tokio::task::spawn_blocking(move || {
                        let result = if is_remote {
                            // remote name like "origin/feature" -> remote=origin, branch=feature
                            if let Some((remote, branch_name)) = branch.split_once('/') {
                                std::process::Command::new("git")
                                    .args(["-C", root.to_str().unwrap_or("."), "push", remote, "--delete", branch_name])
                                    .output()
                                    .map_err(|e| anyhow::anyhow!(e))
                                    .and_then(|o| {
                                        if o.status.success() {
                                            Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteCompleted(branch.clone()))])
                                        } else {
                                            let stderr = String::from_utf8_lossy(&o.stderr).trim().to_owned();
                                            Err(anyhow::anyhow!(stderr))
                                        }
                                    })
                            } else {
                                Err(anyhow::anyhow!("Invalid remote branch name"))
                            }
                        } else {
                            std::process::Command::new("git")
                                .args(["-C", root.to_str().unwrap_or("."), "branch", "-D", &branch])
                                .output()
                                .map_err(|e| anyhow::anyhow!(e))
                                .and_then(|o| {
                                    if o.status.success() {
                                        Ok(vec![Operation::BranchViewLocal(BranchViewOp::DeleteCompleted(branch.clone()))])
                                    } else {
                                        let stderr = String::from_utf8_lossy(&o.stderr).trim().to_owned();
                                        Err(anyhow::anyhow!(stderr))
                                    }
                                })
                        };
                        let _ = tx.send(result.unwrap_or_else(|e| vec![Operation::BranchViewLocal(BranchViewOp::DeleteError(e.to_string()))]));
                    });
                }
        }

        // All other BranchViewLocal ops are pure UI.
        Operation::BranchViewLocal(_) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        // ── Git Pull workflow ──────────────────────────────────────────────

        // FetchCompleted with fast-forward: transition to FastForwarding in the
        // view, then immediately spawn the fast-forward task.
        Operation::GitPullLocal(GitPullOp::FetchCompleted {
            can_fast_forward: true,
            upstream,
            current_branch,
        }) => {
            let upstream = upstream.clone();
            let current_branch = current_branch.clone();
            // Let the view update its phase first.
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
            if let Screen::GitPull(gp) = &app.screen {
                let root = gp.repo_path.clone();
                let tx = op_tx.clone();
                tokio::task::spawn_blocking(move || {
                    let ops = run_fast_forward(&root);
                    let _ = tx.send(ops);
                });
                let _ = (upstream, current_branch); // used by the view via dispatch
            }
        }

        // FetchCompleted without fast-forward: show choice prompt (pure view update).
        Operation::GitPullLocal(GitPullOp::FetchCompleted {
            can_fast_forward: false,
            ..
        }) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        // ChoiceConfirm: read the cursor and spawn the appropriate git task.
        Operation::GitPullLocal(GitPullOp::ChoiceConfirm) => {
            if let Screen::GitPull(gp) = &mut app.screen {
                let cursor = gp.cursor;
                let root = gp.repo_path.clone();
                match cursor {
                    0 => {
                        // Rebase — update phase then spawn.
                        gp.phase = crate::views::git_pull::PullPhase::ApplyingRebase;
                        let tx = op_tx.clone();
                        tokio::task::spawn_blocking(move || {
                            let ops = run_git_rebase(&root);
                            let _ = tx.send(ops);
                        });
                    }
                    1 => {
                        // Merge — update phase then spawn.
                        gp.phase = crate::views::git_pull::PullPhase::ApplyingMerge;
                        let tx = op_tx.clone();
                        tokio::task::spawn_blocking(move || {
                            let ops = run_git_merge(&root);
                            let _ = tx.send(ops);
                        });
                    }
                    _ => {
                        // Cancel — just close the view.
                        queue.push_back(Operation::Close);
                    }
                }
            }
        }

        // All other GitPullLocal ops (result delivery, navigation) are pure view updates.
        Operation::GitPullLocal(_) => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }

        Operation::ClipboardLocal(clip_op) => {
            let clip_op = clip_op.clone();
            if let Some(event) = apply_clipboard_op(&clip_op, app) {
                emitted.push(event);
            }
        }

        Operation::FileSelectorLocal(fs_op) => {
            apply_file_selector_op(fs_op, app, op_tx);
        }

        // Legacy SearchReplace ops are no longer supported; ignore them.
        Operation::SearchReplaceLocal(_) => {
            // Consume the op but don't dispatch — editor-based search handles project searches now.
        }

        Operation::SearchLocal(sop) => {
            let sop = sop.clone();
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
            if matches!(app.screen, Screen::Editor(_)) {
                maybe_trigger_project_search(&sop, app, op_tx);
            }
        }

        Operation::PasteText { path, text } => {
            let path = path.clone();
            let text = text.clone();
            // Update yank ring and OS clipboard with pasted content.
            app.registers.push(text.clone());
            if let Some(cb) = &mut app.clipboard {
                let _ = cb.set_text(&text);
            }
            if let Screen::Editor(ed) = &mut app.screen
                && ed.path_matches(&path)
            {
                ed.buffer.begin_transaction(ChangeKind::Paste);
                ed.buffer.delete_selection();
                ed.buffer.insert_text_raw(&text);
                ed.buffer.end_transaction();
                ed.lsp_version = ed.lsp_version.wrapping_add(1);
                ed.recompute_search_matches();
                ed.status_msg = Some("Pasted.".into());
                // Notify LSP of the change.
                if let Some(lsp_client) = lsp_for_editor(lsp, ed)
                    && let Some(ref p) = ed.buffer.path
                {
                    let uri = crate::lsp::path_to_uri(p);
                    let version = ed.lsp_version;
                    let content = ed.buffer.lines().join("\n") + "\n";
                    lsp_client.did_change(&uri, version, &content);
                }
                emitted.push(Event::applied("editor", op));
            }
        }

        // ── Issue registry — ephemeral commands (no disk I/O) ───────────────
        Operation::AddIssue { issue } => {
            let id = app.issue_registry.add_issue(issue.clone());
            queue.push_back(Operation::IssueAdded { id });
        }

        Operation::RemoveIssue { id } => {
            if app.issue_registry.remove_issue(*id) {
                queue.push_back(Operation::IssueRemoved { id: *id });
            }
        }

        Operation::ResolveIssue { id } => {
            if app.issue_registry.resolve_issue(*id) {
                queue.push_back(Operation::IssueUpdated { id: *id });
            }
        }

        Operation::DismissIssue { id } => {
            if app.issue_registry.dismiss_issue(*id) {
                queue.push_back(Operation::IssueUpdated { id: *id });
            }
        }

        Operation::ClearIssuesByMarker { marker } => {
            for id in app.issue_registry.clear_by_marker(marker) {
                queue.push_back(Operation::IssueRemoved { id });
            }
        }

        // ── Persistent issue commands (registry + disk) ──────────────────────
        Operation::PersistentIssueLocal(persistent_op) => {
            use crate::operation::PersistentIssueOp;
            match persistent_op {
                PersistentIssueOp::Add { issue } => {
                    let new_issue = issue.clone().into_new_issue();
                    let id = app.issue_registry.add_issue(new_issue);
                    queue.push_back(Operation::IssueAdded { id });
                    schedule_persistent_save(
                        &app.issue_registry,
                        &app.project.project_settings_path,
                    );
                }
                PersistentIssueOp::Remove { id } => {
                    if app.issue_registry.remove_issue(*id) {
                        queue.push_back(Operation::IssueRemoved { id: *id });
                        schedule_persistent_save(
                            &app.issue_registry,
                            &app.project.project_settings_path,
                        );
                    }
                }
                PersistentIssueOp::Resolve { id } => {
                    if app.issue_registry.resolve_issue(*id) {
                        queue.push_back(Operation::IssueUpdated { id: *id });
                        schedule_persistent_save(
                            &app.issue_registry,
                            &app.project.project_settings_path,
                        );
                    }
                }
                PersistentIssueOp::Dismiss { id } => {
                    if app.issue_registry.dismiss_issue(*id) {
                        queue.push_back(Operation::IssueUpdated { id: *id });
                        schedule_persistent_save(
                            &app.issue_registry,
                            &app.project.project_settings_path,
                        );
                    }
                }
            }
        }

        // ── Task system commands ─────────────────────────────────────────────
        Operation::ScheduleTask {
            key,
            trigger,
            command,
        } => {
            let key = key.clone();
            let trigger = trigger.clone();
            let command = command.clone();
            // Derive the marker before consuming `key`.
            let marker = format!("task:{}:{}", key.queue.0, key.target);
            // Clear stale diagnostics from the previous run of this task slot.
            queue.push_back(Operation::ClearIssuesByMarker {
                marker: marker.clone(),
            });
            let task_id = app
                .task_registry
                .schedule_task(key, trigger, command.clone());
            queue.push_back(Operation::TaskScheduled(task_id));
            // If the queue was idle, the task is now Running — spawn it.
            if app
                .task_registry
                .get(task_id)
                .is_some_and(|t| t.status == crate::task_registry::TaskStatus::Running)
            {
                let token = app
                    .task_registry
                    .get(task_id)
                    .unwrap()
                    .cancellation_token
                    .clone();
                app.task_executor.spawn(
                    task_id,
                    command,
                    token,
                    marker,
                    Some(app.log_store.clone()),
                    op_tx,
                );
                queue.push_back(Operation::TaskStarted(task_id));
            }
        }

        Operation::TaskFinished { id, status } => {
            let id = *id;
            let status = status.clone();
            if let Some(next_id) = app.task_registry.mark_finished(id, status) {
                // A queued task was promoted to Running — spawn it.
                if let Some(task) = app.task_registry.get(next_id) {
                    let token = task.cancellation_token.clone();
                    let command = task.command.clone();
                    let marker = format!("task:{}:{}", task.key.queue.0, task.key.target);
                    app.task_executor.spawn(
                        next_id,
                        command,
                        token,
                        marker,
                        Some(app.log_store.clone()),
                        op_tx,
                    );
                    queue.push_back(Operation::TaskStarted(next_id));
                }
            }
            // Update TaskArchive view if it's open
            let total = app.task_registry.task_count();
            if matches!(app.screen, Screen::TaskArchive(_)) {
                queue.push_back(Operation::TaskArchiveLocal(
                    crate::operation::TaskArchiveOp::TaskUpdated { new_len: total },
                ));
            }
        }

        Operation::CancelTask(id) => {
            let id = *id;
            app.task_executor.abort(id);
            if let Some(next_id) = app.task_registry.cancel(id) {
                // Cancellation released the running slot — spawn the next task.
                if let Some(task) = app.task_registry.get(next_id) {
                    let token = task.cancellation_token.clone();
                    let command = task.command.clone();
                    let marker = format!("task:{}:{}", task.key.queue.0, task.key.target);
                    app.task_executor.spawn(
                        next_id,
                        command,
                        token,
                        marker,
                        Some(app.log_store.clone()),
                        op_tx,
                    );
                    queue.push_back(Operation::TaskStarted(next_id));
                }
            }
            queue.push_back(Operation::TaskCancelled(id));
        }

        Operation::OpenLogForTask { task_id } => {
            let filter = app
                .task_registry
                .get(*task_id)
                .map(|t| t.command.clone())
                .unwrap_or_default();
            let snapshot = log_state
                .buffer
                .lock()
                .map(|b| b.snapshot())
                .unwrap_or_default();
            let lv = LogView::with_filter(snapshot, filter);
            if let Some(prev) = app.set_screen(Screen::LogView(lv)) {
                consume_prev_screen(prev, app);
            }
        }

        Operation::OpenIssueList => {
            queue.push_back(Operation::SwitchScreen {
                to: ScreenKind::IssueList,
            });
        }

        Operation::OpenTaskArchive => {
            queue.push_back(Operation::SwitchScreen {
                to: ScreenKind::TaskArchive,
            });
        }

        Operation::ShowTaskOutput {
            task_id,
            title,
            status,
            lines,
        } => {
            let task_id = *task_id;
            let title = title.clone();
            let status = status.clone();
            let lines = lines.clone();
            if let Screen::TaskArchive(ta) = &mut app.screen {
                ta.show_detail(task_id, title, status, lines);
            }
        }

        Operation::TaskArchiveLocal(ta_op) => {
            use crate::operation::TaskArchiveOp;
            let ta_op = ta_op.clone();
            match ta_op {
                TaskArchiveOp::OpenSelected => {
                    if let Screen::TaskArchive(ta) = &app.screen
                        && let Some(task_id) = ta.selected_task_id(&app.task_registry)
                    {
                        let title = app
                            .task_registry
                            .get(task_id)
                            .map(|t| format!("{}: {}", t.key.queue.0, t.command))
                            .unwrap_or_default();
                        let status = app
                            .task_registry
                            .get(task_id)
                            .map(|t| t.status.clone())
                            .unwrap_or(crate::task_registry::TaskStatus::Cancelled);
                        let log_store = app.log_store.clone();
                        let tx = op_tx.clone();
                        // Clone title/status for the spawned task so we can still
                        // push an immediate ShowDetail into the operation queue.
                        let title_for_spawn = title.clone();
                        let status_for_spawn = status.clone();
                        tokio::spawn(async move {
                            let lines = log_store.read_log(task_id).await.unwrap_or_default();
                            let _ = tx.send(vec![Operation::ShowTaskOutput {
                                task_id,
                                title: title_for_spawn,
                                status: status_for_spawn,
                                lines,
                            }]);
                        });

                        // Show the (initially empty) detail pane immediately so Enter
                        // feels responsive; the async loader will populate lines.
                        queue.push_back(Operation::TaskArchiveLocal(TaskArchiveOp::ShowDetail {
                            task_id,
                            title,
                            status,
                            lines: Vec::new(),
                        }));
                    }
                }
                TaskArchiveOp::CancelSelected => {
                    if let Screen::TaskArchive(ta) = &app.screen
                        && let Some(task_id) = ta.selected_task_id(&app.task_registry)
                    {
                        queue.push_back(Operation::CancelTask(task_id));
                    }
                }
                _ => {
                    if let Some(event) = dispatch_to_view(op, app) {
                        emitted.push(event);
                    }
                }
            }
        }

        Operation::IssueViewLocal(iv_op) => {
            let iv_op = iv_op.clone();
            if let Some(event) = dispatch_to_view(Operation::IssueViewLocal(iv_op), app) {
                emitted.push(event);
            }
        }

        // Task event variants — observed by subscribers; no registry mutation needed.
        Operation::TaskScheduled(_) | Operation::TaskStarted(_) | Operation::TaskCancelled(_) => {
            let total = app.task_registry.task_count();
            if matches!(app.screen, Screen::TaskArchive(_)) {
                queue.push_back(Operation::TaskArchiveLocal(
                    crate::operation::TaskArchiveOp::TaskUpdated { new_len: total },
                ));
            }
        }

        // Event variants — observed by views; also forward to IssueList if open.
        Operation::IssueAdded { id } => {
            if matches!(app.screen, Screen::IssueList(_)) {
                queue.push_back(Operation::IssueViewLocal(
                    crate::operation::IssueViewOp::RegistryChanged,
                ));
            }
            if let Screen::Editor(ref mut ed) = app.screen {
                rebuild_editor_gutter_markers(ed, &app.issue_registry, &app.theme);
            }
            // Notify extensions so they can react to new diagnostics.
            if let Some(issue) = app.issue_registry.get(*id) {
                let mut payload = HashMap::new();
                payload.insert("id".to_owned(), id.to_string());
                payload.insert("source".to_owned(), issue.source.clone());
                payload.insert("severity".to_owned(), format!("{:?}", issue.severity).to_lowercase());
                if let Some(path) = &issue.path {
                    payload.insert("path".to_owned(), path.to_string_lossy().into_owned());
                }
                payload.insert("message".to_owned(), issue.message.clone());
                queue.push_back(Operation::ExtensionEvent {
                    name: "issue_added".into(),
                    payload,
                });
            }
        }

        Operation::IssueRemoved { id } => {
            if matches!(app.screen, Screen::IssueList(_)) {
                queue.push_back(Operation::IssueViewLocal(
                    crate::operation::IssueViewOp::RegistryChanged,
                ));
            }
            if let Screen::Editor(ref mut ed) = app.screen {
                rebuild_editor_gutter_markers(ed, &app.issue_registry, &app.theme);
            }
            // Notify extensions of the removal (issue data is already gone from registry).
            let mut payload = HashMap::new();
            payload.insert("id".to_owned(), id.to_string());
            queue.push_back(Operation::ExtensionEvent {
                name: "issue_removed".into(),
                payload,
            });
        }

        Operation::IssueUpdated { .. } => {
            if matches!(app.screen, Screen::IssueList(_)) {
                queue.push_back(Operation::IssueViewLocal(
                    crate::operation::IssueViewOp::RegistryChanged,
                ));
            }
            if let Screen::Editor(ref mut ed) = app.screen {
                rebuild_editor_gutter_markers(ed, &app.issue_registry, &app.theme);
            }
        }

        _ => {
            if let Some(event) = dispatch_to_view(op, app) {
                emitted.push(event);
            }
        }
    }
}

/// Rebuild gutter markers and hover texts for an EditorView from the issue registry.
///
/// Called after any issue mutation event (IssueAdded / IssueRemoved / IssueUpdated)
/// when an editor is the active screen, and when opening a new file.
fn rebuild_editor_gutter_markers(
    ed: &mut EditorView,
    registry: &crate::issue_registry::IssueRegistry,
    theme: &crate::theme::Theme,
) {
    use crate::issue_registry::Severity;
    use ratatui::style::{Color, Modifier, Style};
    use std::collections::HashMap;

    let Some(path) = ed.buffer.path.as_deref() else {
        ed.gutter_markers.clear();
        ed.gutter_issue_texts.clear();
        return;
    };

    // Use all issues and match by path suffix to handle relative vs absolute mismatches.
    // e.g., issue path "src/main.rs" matches editor path "/home/user/project/src/main.rs"
    // because the absolute path ends_with the relative one (Path::ends_with is component-wise).
    let all_issues = registry.list_all();
    let issues: Vec<_> = all_issues
        .into_iter()
        .filter(|i| {
            i.path.as_deref().is_some_and(|p| path.ends_with(p) || p.ends_with(path))
        })
        .collect();

    // Group active issues by line: track worst severity and accumulate message lines.
    let mut line_map: HashMap<usize, (Severity, Vec<String>)> = HashMap::new();
    for issue in issues {
        if issue.dismissed || issue.resolved {
            continue;
        }
        let Some((start, _)) = issue.range else { continue };
        let line = start.line;
        let entry = line_map.entry(line).or_insert((issue.severity, Vec::new()));
        if issue.severity > entry.0 {
            entry.0 = issue.severity;
        }
        let label = match issue.severity {
            Severity::Error   => "● Error",
            Severity::Warning => "▲ Warning",
            Severity::Info    => "ℹ Info",
            Severity::Todo    => "☐ Todo",
        };
        entry.1.push(format!("{} [{}]: {}", label, issue.source, issue.message));
    }

    ed.gutter_markers = line_map
        .iter()
        .map(|(&line, (sev, _))| {
            let (symbol, fg, bg) = match sev {
                Severity::Error   => ('●', Color::Black, theme.level_error()),
                Severity::Warning => ('▲', Color::Black, theme.level_warn()),
                Severity::Info    => ('ℹ', Color::Black, theme.level_info()),
                Severity::Todo    => ('☐', Color::DarkGray, Color::Rgb(60, 60, 60)),
            };
            crate::widgets::editor::GutterMarker {
                line,
                symbol,
                style: Style::default()
                    .fg(fg)
                    .bg(bg)
                    .add_modifier(Modifier::BOLD),
            }
        })
        .collect();
    ed.gutter_markers.sort_by_key(|m| m.line);

    // Count errors and non-error issues per line (worst severity wins per line).
    let mut errors = 0usize;
    let mut warnings = 0usize;
    for (sev, _) in line_map.values() {
        match sev {
            Severity::Error => errors += 1,
            _ => warnings += 1,
        }
    }
    ed.issue_error_count = errors;
    ed.issue_warning_count = warnings;

    ed.gutter_issue_texts = line_map
        .into_iter()
        .map(|(line, (_, msgs))| (line, msgs.join("\n")))
        .collect();
}

/// Snapshot all persistent issues and spawn a fire-and-forget background task
/// to write them atomically to `{settings_dir}/issues.yaml`.
///
/// Failures are logged as warnings; the in-memory registry is never rolled back.
fn schedule_persistent_save(
    registry: &crate::issue_registry::IssueRegistry,
    settings_dir: &std::path::Path,
) {
    let snapshot = registry.list_persistent();
    let path = settings_dir.join("issues.yaml");
    tokio::task::spawn_blocking(move || {
        if let Err(e) = crate::persistent_issues::save_atomic(&path, &snapshot) {
            log::warn!("persistent issues: save failed: {}", e);
        }
    });
}

/// Handle all `ClipboardLocal` operations with access to both `AppState`
/// (registers + system clipboard) and the active `EditorView` (buffer state).
fn apply_clipboard_op(clip_op: &ClipOp, app: &mut AppState) -> Option<Event> {
    // Split-borrow the three independent fields of AppState.
    let AppState {
        screen,
        registers,
        clipboard,
        ..
    } = app;
    let Screen::Editor(ed) = screen else {
        return None;
    };

    match clip_op {
        // ── Copy ────────────────────────────────────────────────────────────
        ClipOp::Copy => {
            // Collect from all cursors/selections; fall back to current line.
            let text = ed.buffer.selected_text_all();
            let text = if text.is_empty() {
                // No selection on any cursor: copy the current line.
                let row = ed.buffer.cursor().line;
                format!(
                    "{}\n",
                    ed.buffer.lines().get(row).cloned().unwrap_or_default()
                )
            } else {
                text
            };
            registers.push(text.clone());
            if let Some(cb) = clipboard {
                let _ = cb.set_text(&text);
            }
            ed.status_msg = Some("Copied.".into());
        }

        // ── Cut ─────────────────────────────────────────────────────────────
        ClipOp::Cut => {
            let cut_line = ed.buffer.cursor().line;
            let text = ed.buffer.delete_selection();
            let text = if text.is_empty() {
                format!("{}\n", ed.buffer.delete_line())
            } else {
                text
            };
            if !text.is_empty() && text != "\n" {
                registers.push(text.clone());
                if let Some(cb) = clipboard {
                    let _ = cb.set_text(&text);
                }
                ed.lsp_version = ed.lsp_version.wrapping_add(1);
                ed.invalidate_highlights_from(cut_line);
                ed.recompute_search_matches();
                ed.status_msg = Some("Cut.".into());
            }
        }

        // ── Paste ───────────────────────────────────────────────────────────
        // Reads from the yank ring (source of truth), not the OS clipboard.
        ClipOp::Paste => {
            if let Some(text) = registers.latest() {
                let text = text.to_owned();
                let paste_line = ed.buffer.cursor().line;
                ed.buffer.begin_transaction(ChangeKind::Paste);
                ed.buffer.delete_selection();
                ed.buffer.insert_text_raw(&text);
                ed.buffer.end_transaction();
                ed.lsp_version = ed.lsp_version.wrapping_add(1);
                ed.invalidate_highlights_from(paste_line);
                ed.recompute_search_matches();
                ed.status_msg = Some("Pasted.".into());
            }
        }

        // ── History picker open ──────────────────────────────────────────────
        ClipOp::OpenHistory => {
            if registers.is_empty() {
                ed.status_msg = Some("Clipboard history is empty.".into());
            } else {
                ed.clipboard_picker =
                    Some(crate::views::editor::ClipboardPickerState { cursor: 0 });
            }
        }

        // ── Picker navigation ────────────────────────────────────────────────
        ClipOp::HistoryUp => {
            if let Some(p) = &mut ed.clipboard_picker {
                p.cursor = p.cursor.saturating_sub(1);
            }
        }

        ClipOp::HistoryDown => {
            if let Some(p) = &mut ed.clipboard_picker
                && p.cursor + 1 < registers.len()
            {
                p.cursor += 1;
            }
        }

        // ── Picker confirm ───────────────────────────────────────────────────
        ClipOp::HistoryConfirm => {
            if let Some(p) = ed.clipboard_picker.take()
                && let Some(text) = registers.get(p.cursor)
            {
                let text = text.to_owned();
                let paste_line = ed.buffer.cursor().line;
                ed.buffer.begin_transaction(ChangeKind::Paste);
                ed.buffer.delete_selection();
                ed.buffer.insert_text_raw(&text);
                ed.buffer.end_transaction();
                ed.lsp_version = ed.lsp_version.wrapping_add(1);
                ed.invalidate_highlights_from(paste_line);
                ed.recompute_search_matches();
                ed.status_msg = Some("Pasted.".into());
            }
        }

        // ── Picker close ─────────────────────────────────────────────────────
        ClipOp::HistoryClose => {
            ed.clipboard_picker = None;
        }
    }

    Some(Event::applied(
        "editor",
        Operation::ClipboardLocal(clip_op.clone()),
    ))
}

fn dispatch_to_view(op: Operation, app: &mut AppState) -> Option<Event> {
    let settings = &app.settings;
    match &mut app.screen {
        Screen::Editor(ed) => ed.handle_operation(&op, settings),
        Screen::FileSelector(fs) => fs.handle_operation(&op, settings),
        Screen::CommandRunner(cr) => cr.handle_operation(&op, settings),
        Screen::LogView(lv) => lv.handle_operation(&op, settings),
        Screen::CommitWindow(cw) => cw.handle_operation(&op, settings),
        Screen::Terminal(tv) => tv.handle_operation(&op, settings),
        Screen::GitHistory(gh) => gh.handle_operation(&op, settings),
        Screen::GitBranches(bv) => bv.handle_operation(&op, settings),
        Screen::GitPull(gp) => gp.handle_operation(&op, settings),
        Screen::GitHistoryEditor(he) => he.handle_operation(&op, settings),
        Screen::ExtensionConfig(ec) => ec.handle_operation(&op, settings),
        Screen::TaskArchive(ta) => ta.handle_operation(&op, settings),
        Screen::IssueList(iv) => iv.handle_operation(&op, settings),
    }
}

/// Handle all `FileSelectorLocal` operations.
///
/// If the current screen is a `FileSelector` with `pending_load` set, take
/// that path and spawn a blocking task to read it, delivering `DirLoaded`
/// back via `op_tx`.  Called after constructing a `FileSelector` and after
/// each `DirLoaded` reply (which may queue the next ancestor).
fn kick_file_selector_pending_load(app: &mut AppState, op_tx: &UnboundedSender<Vec<Operation>>) {
    if let Screen::FileSelector(fs) = &mut app.screen
        && let Some(dir) = fs.pending_load.take()
    {
        let tx = op_tx.clone();
        tokio::task::spawn_blocking(move || {
            let entries = read_dir_entries(&dir);
            let _ = tx.send(vec![Operation::FileSelectorLocal(
                FileSelectorOp::DirLoaded { path: dir, entries },
            )]);
        });
    }
}

/// After the editor view has processed a `SearchLocal` op, check whether the
/// expanded project search should be (re-)started or cleared.
///
/// Triggers on `Open { replace: false }` (mode just switched to Expanded) and
/// `QueryInput` (user edited the query while Expanded).  Any other variant is
/// a no-op here.
fn maybe_trigger_project_search(
    sop: &SearchOp,
    app: &mut AppState,
    op_tx: &UnboundedSender<Vec<Operation>>,
) {
    use crate::views::editor::SearchMode;

    // Close: always revoke any in-flight search — no new search is started.
    if matches!(sop, SearchOp::Close) {
        if let Some(prev) = app.project_search_cancel.take() {
            log::debug!(
                "project_search: cancelling gen={} (search bar closed)",
                app.project_search_generation
            );
            prev.cancel();
        }
        return;
    }

    let is_trigger = matches!(
        sop,
        SearchOp::Open { replace: false }
            | SearchOp::QueryInput(_)
            | SearchOp::IncludeGlobInput(_)
            | SearchOp::ExcludeGlobInput(_)
            | SearchOp::ToggleIgnoreCase
            | SearchOp::ToggleRegex
            | SearchOp::ToggleSmartCase
    );
    if !is_trigger {
        return;
    }

    let Screen::Editor(ed) = &app.screen else {
        return;
    };
    let Some(search) = &ed.search else { return };
    if search.mode != SearchMode::Expanded {
        // Not in Expanded mode — cancel any stale task and bail out.
        if let Some(prev) = app.project_search_cancel.take() {
            log::debug!(
                "project_search: cancelling gen={} (left Expanded mode)",
                app.project_search_generation
            );
            prev.cancel();
        }
        return;
    }

    let query = search.query.text().to_owned();
    let opts = search.opts.clone();
    let root = app.project.project_path.clone();

    // Cancel the previous search before starting a new one — guarantees no
    // concurrent duplicate searches.
    if let Some(prev) = app.project_search_cancel.take() {
        log::debug!(
            "project_search: cancelling gen={} (superseded by new trigger)",
            app.project_search_generation
        );
        prev.cancel();
    }

    // Bump the generation so the editor can discard any AddProjectResult ops
    // that were already in the channel from the cancelled search.
    app.project_search_generation = app.project_search_generation.wrapping_add(1);
    let generation = app.project_search_generation;
    // Update the shared atomic so the aggregator thread can skip stale flushes.
    app.project_search_generation_shared.store(generation, std::sync::atomic::Ordering::Relaxed);

    // Clear stale results before streaming new ones.  The generation is
    // forwarded so the editor knows which results to expect next.
    let _ = op_tx.send(vec![Operation::SearchLocal(SearchOp::ClearProjectResults { generation })]);

    if query.is_empty() {
        log::debug!("project_search gen={generation}: empty query, not scheduling");
        return;
    }

    let debounce_ms = (*app.settings.get::<i64>("search.debounce_ms")).max(0) as u64;
    log::debug!("project_search gen={generation}: scheduling, query={query:?} debounce={debounce_ms}ms");

    let cancel = tokio_util::sync::CancellationToken::new();
    app.project_search_cancel = Some(cancel.clone());
    let tx = op_tx.clone();
    let gen_shared = app.project_search_generation_shared.clone();

    // Debounce: wait for inactivity before touching the filesystem.  Duration
    // is configurable via `search.debounce_ms` (default 200 ms).  If the user
    // types another character (or changes an option) before the timer fires,
    // `cancel` is cancelled and this task silently exits, so no blocking thread
    // is ever spawned for the superseded query.
    tokio::spawn(async move {
        tokio::select! {
            _ = cancel.cancelled() => {
                log::debug!("project_search gen={generation}: debounce cancelled (superseded)");
            }
            _ = tokio::time::sleep(std::time::Duration::from_millis(debounce_ms)) => {
                log::debug!("project_search gen={generation}: debounce elapsed, spawning blocking walk");
                // Debounce elapsed: start the real (blocking) filesystem walk.
                tokio::task::spawn_blocking(move || {
                    crate::views::project_search::run_project_search(
                        &root, &query, &opts, generation, cancel, &tx, &gen_shared,
                    );
                });
            }
        }
    });
}

/// Navigation and state ops are forwarded to the view immediately.
/// `FilterInput` additionally aborts any in-flight search and spawns a new
/// debounced search task (100 ms delay) that sends `SetResults` back via `op_tx`.
/// `ExpandOrRight` / `ToggleDir` may set `pending_load` on the view, in which
/// case a `spawn_blocking` task is launched to read the directory and deliver
/// `DirLoaded` back via `op_tx`.
fn apply_file_selector_op(
    fs_op: &FileSelectorOp,
    app: &mut AppState,
    op_tx: &UnboundedSender<Vec<Operation>>,
) {
    // Always dispatch to the view first so filter/state are up-to-date.
    dispatch_to_view(Operation::FileSelectorLocal(fs_op.clone()), app);

    // Drain any pending lazy directory load set by the view.
    kick_file_selector_pending_load(app, op_tx);

    if !matches!(fs_op, FileSelectorOp::FilterInput(_)) {
        return;
    }

    // Read the new filter and generation (set by handle_operation above).
    let (query, generation) = match &app.screen {
        Screen::FileSelector(fs) => (fs.filter.text().to_string(), fs.search_generation),
        _ => return,
    };

    // Abort any previous in-flight search.
    if let Some(h) = app.file_selector_search.take() {
        h.abort();
    }

    let tx = op_tx.clone();

    if query.is_empty() {
        // Clear results immediately — no async work needed.
        let _ = tx.send(vec![Operation::FileSelectorLocal(
            FileSelectorOp::SetResults {
                generation,
                paths: vec![],
            },
        )]);
        return;
    }

    let index = app.file_index.clone();
    app.file_selector_search = Some(tokio::spawn(async move {
        // Debounce: wait for the user to stop typing.
        tokio::time::sleep(Duration::from_millis(100)).await;

        let paths = tokio::task::spawn_blocking(move || {
            let guard = index.load();
            let Some(idx) = (**guard).as_ref() else {
                return vec![];
            };
            NucleoSearch::new()
                .search_top(idx, &query, 500)
                .into_iter()
                .map(|e| e.path.clone())
                .collect()
        })
        .await
        .unwrap_or_default();

        let _ = tx.send(vec![Operation::FileSelectorLocal(
            FileSelectorOp::SetResults { generation, paths },
        )]);
    }));
}

/// Read one level of a directory for lazy tree expansion.
/// Returns `(absolute_path, is_dir)` tuples, filtered and sorted the same
/// way as `load_dir_shallow` in `widgets/file_tree.rs`.
fn read_dir_entries(dir: &std::path::Path) -> Vec<(std::path::PathBuf, bool)> {
    use utils::fs::is_noise;
    let Ok(rd) = std::fs::read_dir(dir) else {
        return vec![];
    };

    let mut entries: Vec<(std::path::PathBuf, bool)> = rd
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|n| !is_noise(n))
                .unwrap_or(false)
        })
        .map(|path| {
            let is_dir = path.is_dir();
            (path, is_dir)
        })
        .collect();

    // Directories first, then alphabetically.
    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    entries
}

fn consume_prev_screen(prev: crate::app_state::Screen, app: &mut AppState) {
    use crate::app_state::Screen;
    match prev {
        Screen::Editor(ed) => {
            let mut ed = ed;
            // Delegate pre-save logic to the view implementation.
            ed.save_state(app);
            // Now consume the editor to stash the buffer (matches original behavior).
            let (buf, folds) = ed.into_state();
            app.project.stash_buffer(buf, folds);
        }
        Screen::Terminal(tv) => {
            let mut tv = tv;
            tv.save_state(app);
            app.stashed_terminal = Some(tv);
        }
        Screen::CommitWindow(cw) => {
            let mut cw = cw;
            cw.save_state(app);
            app.stashed_commit_window = Some(cw);
        }
        Screen::FileSelector(fs) => {
            let mut fs = fs;
            fs.save_state(app);
        }
        Screen::CommandRunner(cr) => {
            let mut cr = cr;
            cr.save_state(app);
        }
        Screen::LogView(lv) => {
            let mut lv = lv;
            lv.save_state(app);
        }
        Screen::GitHistory(gh) => {
            let mut gh = gh;
            gh.save_state(app);
        }
        Screen::GitBranches(bv) => {
            let mut bv = bv;
            bv.save_state(app);
        }
        Screen::ExtensionConfig(ec) => {
            let mut ec = ec;
            ec.save_state(app);
        }
        Screen::TaskArchive(ta) => {
            let mut ta = ta;
            ta.save_state(app);
        }
        Screen::IssueList(_) => {
            // IssueList is a modal; no state to stash.
        }
        Screen::GitHistoryEditor(he) => {
            let mut he = he;
            he.save_state(app);
        }
        Screen::GitPull(gp) => {
            let mut gp = gp;
            gp.save_state(app);
        }
    }
}

// ---------------------------------------------------------------------------
// Git Pull helper functions (called from spawn_blocking)
// ---------------------------------------------------------------------------

/// Run `git fetch` then determine whether a fast-forward is possible.
///
/// Returns a single `GitPullLocal` operation containing the result.
fn run_fetch_and_check(root: &std::path::Path) -> Vec<Operation> {
    let root_str = root.to_string_lossy();

    // 1. Fetch.
    let fetch = std::process::Command::new("git")
        .args(["-C", &root_str, "fetch"])
        .output();

    match fetch {
        Err(e) => {
            return vec![Operation::GitPullLocal(GitPullOp::FetchError(e.to_string()))];
        }
        Ok(out) if !out.status.success() => {
            let msg = String::from_utf8_lossy(&out.stderr).trim().to_owned();
            return vec![Operation::GitPullLocal(GitPullOp::FetchError(
                if msg.is_empty() { "git fetch failed".to_owned() } else { msg },
            ))];
        }
        Ok(_) => {}
    }

    // 2. Determine the current branch name.
    let branch_out = std::process::Command::new("git")
        .args(["-C", &root_str, "symbolic-ref", "--short", "HEAD"])
        .output();

    let current_branch = match branch_out {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_owned(),
        _ => "(detached HEAD)".to_owned(),
    };

    // 3. Resolve the upstream tracking ref.
    let upstream_out = std::process::Command::new("git")
        .args(["-C", &root_str, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
        .output();

    let upstream = match upstream_out {
        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_owned(),
        _ => {
            // No upstream configured — treat as error.
            return vec![Operation::GitPullLocal(GitPullOp::FetchError(
                "No upstream tracking branch configured".to_owned(),
            ))];
        }
    };

    // 4. Check if fast-forward is possible: HEAD must be an ancestor of upstream.
    let ff_check = std::process::Command::new("git")
        .args(["-C", &root_str, "merge-base", "--is-ancestor", "HEAD", "@{u}"])
        .output();

    // Also check if HEAD == @{u} (already up-to-date).
    let head_eq_upstream = {
        let h = std::process::Command::new("git")
            .args(["-C", &root_str, "rev-parse", "HEAD"])
            .output()
            .ok()
            .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_owned()) } else { None });
        let u = std::process::Command::new("git")
            .args(["-C", &root_str, "rev-parse", "@{u}"])
            .output()
            .ok()
            .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_owned()) } else { None });
        h.is_some() && h == u
    };

    let can_ff = head_eq_upstream
        || matches!(ff_check, Ok(ref o) if o.status.success());

    vec![Operation::GitPullLocal(GitPullOp::FetchCompleted {
        can_fast_forward: can_ff,
        upstream,
        current_branch,
    })]
}

/// Run `git merge --ff-only` to fast-forward HEAD to the upstream.
fn run_fast_forward(root: &std::path::Path) -> Vec<Operation> {
    let root_str = root.to_string_lossy();
    let out = std::process::Command::new("git")
        .args(["-C", &root_str, "merge", "--ff-only"])
        .output();

    match out {
        Err(e) => vec![Operation::GitPullLocal(GitPullOp::FastForwardError(e.to_string()))],
        Ok(o) if o.status.success() => vec![Operation::GitPullLocal(GitPullOp::FastForwardCompleted)],
        Ok(o) => {
            let msg = String::from_utf8_lossy(&o.stderr).trim().to_owned();
            vec![Operation::GitPullLocal(GitPullOp::FastForwardError(
                if msg.is_empty() { "Fast-forward failed".to_owned() } else { msg },
            ))]
        }
    }
}

/// Run `git rebase @{u}` to rebase the local branch onto the upstream.
fn run_git_rebase(root: &std::path::Path) -> Vec<Operation> {
    let root_str = root.to_string_lossy();
    let out = std::process::Command::new("git")
        .args(["-C", &root_str, "rebase", "@{u}"])
        .output();

    match out {
        Err(e) => vec![Operation::GitPullLocal(GitPullOp::RebaseError(e.to_string()))],
        Ok(o) if o.status.success() => vec![Operation::GitPullLocal(GitPullOp::RebaseCompleted)],
        Ok(o) => {
            let msg = String::from_utf8_lossy(&o.stderr).trim().to_owned();
            vec![Operation::GitPullLocal(GitPullOp::RebaseError(
                if msg.is_empty() { "Rebase failed".to_owned() } else { msg },
            ))]
        }
    }
}

/// Run `git merge --no-ff @{u}` to merge the upstream into the local branch.
fn run_git_merge(root: &std::path::Path) -> Vec<Operation> {
    let root_str = root.to_string_lossy();
    let out = std::process::Command::new("git")
        .args(["-C", &root_str, "merge", "--no-ff", "@{u}"])
        .output();

    match out {
        Err(e) => vec![Operation::GitPullLocal(GitPullOp::MergeError(e.to_string()))],
        Ok(o) if o.status.success() => vec![Operation::GitPullLocal(GitPullOp::MergeCompleted)],
        Ok(o) => {
            let msg = String::from_utf8_lossy(&o.stderr).trim().to_owned();
            vec![Operation::GitPullLocal(GitPullOp::MergeError(
                if msg.is_empty() { "Merge failed".to_owned() } else { msg },
            ))]
        }
    }
}

fn switch_screen(
    to: ScreenKind,
    app: &mut AppState,
    command_registry: &CommandRegistry,
    log_state: &std::sync::Arc<crate::log_buffer::LogState>,
    op_tx: &UnboundedSender<Vec<Operation>>,
) {
    match to {
        ScreenKind::FileSelector => {
            // Install a preliminary FileSelector (stashes any primary screen into stashed_primary
            // or calls consume_prev_screen for modal→modal / primary→primary transitions).
            if let Some(prev) = app.set_screen(Screen::FileSelector(FileSelector::new(
                app.project.project_path.clone(),
                app.project.history_entries(),
                app.file_index.clone(),
                std::collections::HashSet::new(),
                app.project.last_opened_file.clone(),
            ))) {
                consume_prev_screen(prev, app);
            }

            // If an Editor was stashed as the primary, stop its file watcher while backgrounded.
            if let Some(Screen::Editor(ed)) = &mut app.stashed_primary {
                ed.stop_file_watching();
                if let Some(ref path) = ed.buffer.path {
                    app.file_watcher.unwatch(path).ok();
                }
            }

            // Compute dirty paths: project-stashed files plus the live stashed editor if dirty.
            let mut dirty = app.project.dirty_paths();
            if let Some(Screen::Editor(ed)) = &app.stashed_primary
                && ed.buffer.is_dirty()
                && let Some(ref path) = ed.buffer.path
            {
                dirty.insert(path.clone());
            }

            // Replace the placeholder with a correctly-initialised FileSelector.
            if let Some(old_fs) = app.set_screen(Screen::FileSelector(FileSelector::new(
                app.project.project_path.clone(),
                app.project.history_entries(),
                app.file_index.clone(),
                dirty,
                app.project.last_opened_file.clone(),
            ))) {
                consume_prev_screen(old_fs, app);
            }
        }
        ScreenKind::CommandRunner => {
            let all_metas: Vec<&crate::commands::CommandMeta> = command_registry
                .user_commands_sorted()
                .into_iter()
                .map(|c| &c.meta)
                .collect();
            let history: Vec<_> = command_registry.history().iter().cloned().collect();
            if let Some(prev) = app.set_screen(Screen::CommandRunner(CommandRunner::new(
                &all_metas, &history,
            ))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::LogView => {
            let snapshot = log_state
                .buffer
                .lock()
                .map(|b| b.snapshot())
                .unwrap_or_default();
            if let Some(prev) = app.set_screen(Screen::LogView(LogView::new(snapshot))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::CommitWindow => {
            // Restore stashed state if available; otherwise create fresh.
            let mut cw = app
                .stashed_commit_window
                .take()
                .unwrap_or_else(|| Box::new(CommitWindow::new(app.project.project_path.clone())));
            // Always refresh status from git (mark as loading).
            cw.loading = true;
            let root = cw.repo_path.clone();
            let tx = op_tx.clone();
            tokio::task::spawn_blocking(move || {
                let result = GitVcs::open(&root).and_then(|v| {
                    let (staged, unstaged) = v.status()?;
                    let (branches, current_branch) = v.branches()?;
                    Ok(vec![Operation::CommitLocal(CommitOp::StatusLoaded {
                        staged,
                        unstaged,
                        branches,
                        current_branch,
                    })])
                });
                let _ = tx.send(result.unwrap_or_else(|e| {
                    vec![Operation::CommitLocal(CommitOp::GitOpError(e.to_string()))]
                }));
            });
            // Spawn a second task to detect rebase/merge state.
            let root2 = app.project.project_path.clone();
            let tx2 = op_tx.clone();
            tokio::task::spawn_blocking(move || {
                let state = crate::vcs::detect_git_op_state(&root2);
                let _ = tx2.send(vec![Operation::CommitLocal(CommitOp::GitModeDetected {
                    mode: state.mode,
                    has_conflicts: state.has_conflicts,
                    message: state.message,
                })]);
            });
            if let Some(prev) = app.set_screen(Screen::CommitWindow(cw)) {
                consume_prev_screen(prev, app);
            }
        }

        ScreenKind::Terminal => {
            if matches!(app.screen, Screen::Terminal(_)) {
                // Already in terminal — open a new tab.
                if let Screen::Terminal(tv) = &mut app.screen {
                    let cwd = app.project.project_path.clone();
                    tv.spawn_tab(None, "", &cwd, op_tx, 1000);
                }
            } else if let Some(mut tv) = app.stashed_terminal.take() {
                // Restore the live stashed terminal (PTY sessions still running).
                // Spawn a replacement shell if all tabs exited while stashed.
                if tv.tabs.is_empty() {
                    let cwd = app.project.project_path.clone();
                    tv.spawn_tab(None, "", &cwd, op_tx, 1000);
                }
                // Recompute links for the active tab — the VT100 buffer was kept
                // current while stashed, but link detection was skipped to avoid
                // doing unnecessary work in the background.
                {
                    let active = tv.active;
                    if active < tv.tabs.len() && tv.show_links {
                        let links = TerminalView::detect_links_for_tab(&tv.tabs[active]);
                        tv.tabs[active].links = links;
                    } else if active < tv.tabs.len() {
                        tv.tabs[active].links.clear();
                    }
                }
                if let Some(prev) = app.set_screen(Screen::Terminal(tv)) {
                    consume_prev_screen(prev, app);
                }
            } else {
                // Cold start: create fresh view and respawn tabs from saved state.
                let mut tv = TerminalView::new();
                let saved = app.project.get_terminal_state();
                let cwd = app.project.project_path.clone();
                if saved.tabs.is_empty() {
                    tv.spawn_tab(None, "", &cwd, op_tx, 1000);
                } else {
                    tv.next_id = saved.next_id;
                    tv.active = saved.active;
                    for tab in &saved.tabs {
                        tv.spawn_tab(Some(tab.command.clone()), "", &tab.cwd, op_tx, 1000);
                    }
                    // Clamp active index in case saved state has stale count.
                    if tv.active >= tv.tabs.len() {
                        tv.active = tv.tabs.len().saturating_sub(1);
                    }
                }
                if let Some(prev) = app.set_screen(Screen::Terminal(Box::new(tv))) {
                    consume_prev_screen(prev, app);
                }
            }
        }
        ScreenKind::Editor => {}

        ScreenKind::GitHistory => {
            let root = app.project.project_path.clone();
            let gh = GitHistoryView::new(root.clone());
            let tx = op_tx.clone();
            let generation = gh.search_generation;
            // Spawn initial commit list load with empty filter.
            tokio::task::spawn_blocking(move || {
                let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                    let commits = v.log_commits(200, "")?;
                    Ok(vec![Operation::GitHistoryLocal(
                        GitHistoryOp::CommitsLoaded {
                            generation,
                            commits,
                        },
                    )])
                });
                let _ = tx.send(result.unwrap_or_else(|e| {
                    vec![Operation::GitHistoryLocal(GitHistoryOp::LoadError(
                        e.to_string(),
                    ))]
                }));
            });
            if let Some(prev) = app.set_screen(Screen::GitHistory(Box::new(gh))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::GitBranches => {
            let root = app.project.project_path.clone();
            let bv = BranchView::new(root.clone());
            let tx = op_tx.clone();
            // Spawn async load of local + remote branches.
            tokio::task::spawn_blocking(move || {
                let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                    let (local, current) = v.branches()?;
                    let remote = v.remote_branches()?;
                    Ok(vec![Operation::BranchViewLocal(BranchViewOp::BranchesLoaded {
                        local,
                        remote,
                        current,
                    })])
                });
                let _ = tx.send(result.unwrap_or_else(|e| {
                    vec![Operation::BranchViewLocal(BranchViewOp::LoadError(e.to_string()))]
                }));
            });
            if let Some(prev) = app.set_screen(Screen::GitBranches(Box::new(bv))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::ExtensionConfig => {
            if let Some(prev) = app.set_screen(Screen::ExtensionConfig(ExtensionConfigView::new(
                &app.settings,
            ))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::TaskArchive => {
            let ta = TaskArchiveView::new();
            app.set_screen(Screen::TaskArchive(Box::new(ta)));
            // No consume_prev_screen — set_screen returns None for Modal
        }
        ScreenKind::IssueList => {
            let iv = IssueView::new();
            app.set_screen(Screen::IssueList(Box::new(iv)));
        }
        ScreenKind::GitHistoryEditor => {
            use crate::views::git_history_editor::GitHistoryEditorView;
            let root = app.project.project_path.clone();
            let he = GitHistoryEditorView::new(root.clone());
            let tx = op_tx.clone();
            tokio::task::spawn_blocking(move || {
                let result = crate::vcs::GitVcs::open(&root).and_then(|v| {
                    let commits = v.log_commits(100, "")?;
                    Ok(vec![Operation::HistoryEditorLocal(
                        crate::operation::HistoryEditorOp::CommitsLoaded(commits),
                    )])
                });
                let _ = tx.send(result.unwrap_or_else(|e| {
                    vec![Operation::HistoryEditorLocal(
                        crate::operation::HistoryEditorOp::OperationFailed(e.to_string()),
                    )]
                }));
            });
            if let Some(prev) = app.set_screen(Screen::GitHistoryEditor(Box::new(he))) {
                consume_prev_screen(prev, app);
            }
        }
        ScreenKind::GitPull => {
            let root = app.project.project_path.clone();
            let gp = GitPullView::new(root.clone());
            let tx = op_tx.clone();
            // Spawn the fetch task.  On completion, the result operation is
            // delivered back to the main loop which dispatches it to the view.
            tokio::task::spawn_blocking(move || {
                let result = run_fetch_and_check(&root);
                let _ = tx.send(result);
            });
            if let Some(prev) = app.set_screen(Screen::GitPull(Box::new(gp))) {
                consume_prev_screen(prev, app);
            }
        }
    }

    // If we just switched to a FileSelector that has a reveal pending (set during
    // construction), kick the first directory load now.
    kick_file_selector_pending_load(app, op_tx);
}

fn render(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut AppState) -> Result<()> {
    use crate::views::View;
    use crate::widgets::status_bar::{StatusBarBuilder, StatusBarRenderer};
    use ratatui::layout::{Constraint, Direction, Layout};

    // Collect left-side status items from the current view.
    // Both borrows are shared, so they can coexist before the mutable
    // borrow split required by terminal.draw.
    let status_items = {
        let mut builder = StatusBarBuilder::new();
        let app_ref: &AppState = &*app; // shared reborrow
        match &app.screen {
            Screen::Editor(ed) => ed.status_bar(app_ref, &mut builder),
            Screen::FileSelector(fs) => fs.status_bar(app_ref, &mut builder),
            Screen::CommandRunner(cr) => cr.status_bar(app_ref, &mut builder),
            Screen::LogView(lv) => lv.status_bar(app_ref, &mut builder),
            Screen::CommitWindow(cw) => cw.status_bar(app_ref, &mut builder),
            Screen::Terminal(tv) => tv.status_bar(app_ref, &mut builder),
            Screen::GitHistory(gh) => gh.status_bar(app_ref, &mut builder),
            Screen::GitBranches(bv) => bv.status_bar(app_ref, &mut builder),
            Screen::GitHistoryEditor(he) => he.status_bar(app_ref, &mut builder),
            Screen::GitPull(gp) => gp.status_bar(app_ref, &mut builder),
            Screen::ExtensionConfig(ec) => ec.status_bar(app_ref, &mut builder),
            Screen::TaskArchive(ta) => ta.status_bar(app_ref, &mut builder),
            Screen::IssueList(iv) => iv.status_bar(app_ref, &mut builder),
        }
        builder.build()
    };

    // Split borrows: registers and theme are immutable, screen and
    // status_bar_clicks are mutable.
    let (
        screen,
        registers,
        theme,
        task_registry,
        app_issue_registry,
        status_bar_clicks,
        context_menu,
    ) = (
        &mut app.screen,
        &app.registers,
        &app.theme,
        &app.task_registry,
        &app.issue_registry,
        &mut app.status_bar_clicks,
        &app.context_menu,
    );

    terminal.draw(|frame| {
        // Reserve the last row for the global status bar.
        let full = frame.area();
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Min(1), Constraint::Length(1)])
            .split(full);
        let view_area = chunks[0];
        let bar_area = chunks[1];
        log::debug!(
            "render: full={}x{} view_area={}x{} bar_area={}x{}",
            full.width,
            full.height,
            view_area.width,
            view_area.height,
            bar_area.width,
            bar_area.height
        );

        // Render the active view in the reduced area.
        match screen {
            Screen::FileSelector(fs) => fs.render(frame, view_area, theme),
            Screen::Editor(ed) => ed.render_with_registers(frame, view_area, registers, theme),
            Screen::CommandRunner(cr) => cr.render(frame, view_area, theme),
            Screen::LogView(lv) => lv.render(frame, view_area, theme),
            Screen::CommitWindow(cw) => cw.render(frame, view_area, theme),
            Screen::Terminal(tv) => tv.render(frame, view_area, theme),
            Screen::GitHistory(gh) => gh.render(frame, view_area, theme),
            Screen::GitBranches(bv) => bv.render(frame, view_area, theme),
            Screen::GitHistoryEditor(he) => he.render(frame, view_area, theme),
            Screen::GitPull(gp) => gp.render(frame, view_area, theme),
            Screen::ExtensionConfig(ec) => ec.render(frame, view_area, theme),
            Screen::TaskArchive(ta) => {
                ta.render_with_registry(frame, view_area, theme, task_registry);
            }
            Screen::IssueList(iv) => {
                iv.render_with_registry(frame, view_area, theme, app_issue_registry);
            }
        }

        // Render context menu overlay (above status bar, on top of view).
        if let Some(ctx) = context_menu {
            let available = ratatui::layout::Rect {
                x: ctx.x,
                y: ctx.y,
                width: full.width.saturating_sub(ctx.x),
                height: view_area.height.saturating_sub(ctx.y),
            };
            let labels: Vec<&str> = ctx.items.iter().map(|i| i.label.as_str()).collect();
            frame.render_widget(
                crate::widgets::button::Menu::new(&labels).cursor(ctx.cursor),
                available,
            );
        }

        // Render the global status bar at the bottom.
        *status_bar_clicks =
            StatusBarRenderer::render(frame, bar_area, status_items.clone(), task_registry, theme);
    })?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Async highlight scheduling
// ---------------------------------------------------------------------------

/// Incremental async syntax highlighting with Arc<Vec<String>> to avoid cloning.
///
/// CPU-intensive parsing doesn't stall the async runtime. Results are
/// delivered back via `op_tx` as [`Operation::SetEditorHighlightsChunk`].
///
/// Key design:
/// - Does NOT abort a running task. Edits call `invalidate_highlights_from` which
///   aborts the task explicitly. Scroll just adds new lines to pending.
/// - If the task is still running, dirty_lines are added to pending and we return.
///   When the task finishes, the next frame will detect pending lines and start a new task.
/// - Uses Arc<Vec<String>> so the buffer is shared without cloning.
/// - Processes lines in chunks (10 lines) for incremental cache updates.
fn schedule_incremental_highlight(
    ed: &mut EditorView,
    op_tx: &UnboundedSender<Vec<Operation>>,
    dirty_lines: Vec<usize>,
) {
    // Merge dirty lines into pending.
    for line in dirty_lines {
        ed.pending_highlight_lines.insert(line);
    }

    // If a task is already running, leave it. It will deliver results for its lines,
    // and the next frame will schedule the remaining pending lines.
    if ed.highlight_task.as_ref().is_some_and(|t| !t.is_finished()) {
        return;
    }

    let pending: Vec<usize> = ed.pending_highlight_lines.drain().collect();
    if pending.is_empty() {
        return;
    }

    // Increment generation so results from any previously aborted tasks are discarded.
    ed.highlight_generation = ed.highlight_generation.wrapping_add(1);
    let generation = ed.highlight_generation;

    // Pass the highlighter's accumulated checkpoints so the background worker
    // can resume from the nearest checkpoint instead of re-parsing from line 0.
    // HighlighterState is Send (verified by compile-time assertion in highlight.rs).
    let highlighter_state = ed.highlighter.snapshot_state();
    let version = ed.buffer.version();
    let path = ed.buffer.path.clone();
    let tx = op_tx.clone();

    // Use Arc to share buffer without cloning.
    let lines_arc = ed.buffer.lines_arc();

    ed.highlight_task = Some(tokio::spawn(async move {
        let mut lines_to_process = pending;
        lines_to_process.sort_unstable();

        // Use a channel so the blocking task can deliver results incrementally
        // while reusing a single SyntaxHighlighter (preserving checkpoints and
        // cache across chunks).
        let (result_tx, mut result_rx) = tokio::sync::mpsc::channel::<
            Vec<(usize, Vec<crate::editor::highlight::StyledSpan>)>,
        >(4);

        tokio::task::spawn_blocking(move || {
            use crate::editor::highlight::SyntaxHighlighter;
            // Reconstruct the highlighter from the snapshotted state so it
            // inherits all accumulated checkpoints.  This avoids re-parsing
            // from line 0 on every background job.
            let highlighter = SyntaxHighlighter::from_state(highlighter_state);

            // Viewport-sized chunks for incremental delivery while keeping a
            // single highlighter instance that accumulates checkpoints.
            let chunk_size = 50;
            for chunk in lines_to_process.chunks(chunk_size) {
                let batch = highlighter.highlight_lines_batch(&lines_arc, version, chunk);
                let spans: Vec<_> = batch
                    .into_iter()
                    .map(|(line, tokens)| {
                        let text = lines_arc.get(line).map(|s| s.as_str()).unwrap_or("");
                        (line, highlighter.tokens_to_spans(text, &tokens))
                    })
                    .collect();
                if result_tx.blocking_send(spans).is_err() {
                    break;
                }
            }
        });

        while let Some(spans) = result_rx.recv().await {
            let _ = tx.send(vec![Operation::SetEditorHighlightsChunk {
                path: path.clone(),
                version,
                generation,
                spans,
            }]);
        }
    }));
}

// ---------------------------------------------------------------------------
// LSP rename — workspace edit application
// ---------------------------------------------------------------------------

use crate::operation::LspTextEdit;

/// Extract the identifier token (word) under the cursor from the current buffer.
/// Walks left and right from the cursor column while characters are alphanumeric or `_`.
fn word_at_cursor(buffer: &crate::editor::buffer::Buffer) -> String {
    let cursor = buffer.cursor();
    let line_text = buffer.line(cursor.line).unwrap_or_default();
    let chars: Vec<char> = line_text.chars().collect();
    let col = cursor.column.min(chars.len().saturating_sub(1));

    let is_ident = |c: char| c.is_alphanumeric() || c == '_';

    if chars.is_empty() || !is_ident(chars[col]) {
        return String::new();
    }

    let start = (0..=col).rev().take_while(|&i| is_ident(chars[i])).last().unwrap_or(col);
    let end = (col..chars.len()).take_while(|&i| is_ident(chars[i])).last().unwrap_or(col);

    chars[start..=end].iter().collect()
}

/// Apply a list of LSP text edits to a file on disk (the file is not open in
/// the editor).  Reads the file, patches it line-by-line, then writes it back.
fn apply_rename_edits_to_file(path: &std::path::Path, edits: &[LspTextEdit]) {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => {
            log::warn!("rename: could not read {}: {}", path.display(), e);
            return;
        }
    };

    // Split into a Vec<String> so we can address by line index.
    let mut lines: Vec<String> = content.lines().map(str::to_owned).collect();

    // Sort descending (bottom-to-top) to preserve positions.
    let mut sorted: Vec<&LspTextEdit> = edits.iter().collect();
    sorted.sort_by(|a, b| {
        b.start_line.cmp(&a.start_line)
            .then_with(|| b.start_char.cmp(&a.start_char))
    });

    for te in sorted {
        let sl = te.start_line as usize;
        let el = te.end_line as usize;
        let sc = te.start_char as usize;
        let ec = te.end_char as usize;

        if sl >= lines.len() {
            // Append at end of file.
            lines.push(te.new_text.clone());
            continue;
        }

        if sl == el {
            // Single-line edit.
            let line = &lines[sl];
            let chars: Vec<char> = line.chars().collect();
            let sc = sc.min(chars.len());
            let ec = ec.min(chars.len());
            let before: String = chars[..sc].iter().collect();
            let after: String = chars[ec..].iter().collect();
            lines[sl] = format!("{}{}{}", before, te.new_text, after);
        } else {
            // Multi-line edit: splice from sl:sc to el:ec.
            let start_chars: Vec<char> = lines[sl].chars().collect();
            let end_chars: Vec<char> = lines.get(el).map(|l| l.chars().collect()).unwrap_or_default();
            let sc = sc.min(start_chars.len());
            let ec = ec.min(end_chars.len());
            let prefix: String = start_chars[..sc].iter().collect();
            let suffix: String = end_chars[ec..].iter().collect();
            let replacement = format!("{}{}{}", prefix, te.new_text, suffix);
            // Replace lines[sl..=el] with the single replacement line.
            let drain_end = (el + 1).min(lines.len());
            lines.drain(sl..drain_end);
            lines.insert(sl, replacement);
        }
    }

    let new_content = lines.join("\n") + "\n";
    if let Err(e) = std::fs::write(path, new_content) {
        log::warn!("rename: could not write {}: {}", path.display(), e);
    }
}

// ---------------------------------------------------------------------------
// Git gutter diff task
// ---------------------------------------------------------------------------

/// Spawn a background task that fetches the workdir git diff for `file_path`
/// and delivers the set of added/modified 0-based line indices back via `op_tx`
/// as [`Operation::SetEditorGitChanges`].
///
/// Errors are silently ignored (no git repo, untracked, etc.) — the editor
/// simply shows no git markers in that case.
fn spawn_git_diff_for_editor(
    repo_root: std::path::PathBuf,
    file_path: std::path::PathBuf,
    op_tx: &UnboundedSender<Vec<Operation>>,
) {
    use crate::vcs::{GitVcs, Vcs as _};

    let tx = op_tx.clone();
    tokio::task::spawn_blocking(move || {
        // changed_lines_vs_head requires a path relative to the repo root.
        // If the file is outside the repo (e.g. an external file opened via a
        // terminal link), skip git diff entirely — passing an absolute path to
        // git2::Index::get_path panics because it rejects non-relative paths.
        let rel_path = match file_path.strip_prefix(&repo_root) {
            Ok(rel) => rel.to_path_buf(),
            Err(_) => return, // external file — no git markers to show
        };

        let changed_lines = GitVcs::open(&repo_root)
            .and_then(|v| v.changed_lines_vs_head(&rel_path))
            .unwrap_or_default();

        let _ = tx.send(vec![Operation::SetEditorGitChanges {
            path: file_path,
            changed_lines,
        }]);
    });
}

// ---------------------------------------------------------------------------
// LSP bootstrap — per-language
// ---------------------------------------------------------------------------


/// Look up the client for the language of the given editor, if one is running.
fn lsp_for_editor<'a>(
    lsp: &'a HashMap<String, LspClient>,
    ed: &EditorView,
) -> Option<&'a LspClient> {
    ed.lang_id
        .as_ref()
        .and_then(|lang: &crate::language::LanguageId| lsp.get(lang.as_str()))
}

/// Start an LSP server for `lang_id` if the settings describe one.
/// Uses per-language settings merged over global `lsp.*` defaults.
async fn try_start_lsp_for_language(
    settings: &crate::settings::Settings,
    lang_id: &str,
    project_path: &std::path::Path,
    op_tx: UnboundedSender<Vec<Operation>>,
) -> Option<LspClient> {
    let lsp_settings_enabled = settings::adapters::language::lsp::enable(settings, lang_id);
    if !lsp_settings_enabled {
        log::debug!("lsp[{lang_id}]: disabled in settings");
        return None;
    }
    let lsp_settings_cmd = settings::adapters::language::lsp::server_command(settings, lang_id);
    if lsp_settings_cmd.is_empty() {
        log::error!("lsp[{lang_id}]: lsp is enabled but no server command is set");
        // No server configured for this language — silently skip.
        return None;
    }
    let lsp_settings_server_args =
        settings::adapters::language::lsp::server_args(settings, lang_id);
    log::info!(
        "lsp[{lang_id}]: spawning '{}' args={:?}",
        lsp_settings_cmd,
        lsp_settings_server_args
    );
    match LspClient::spawn(lsp_settings_cmd, lsp_settings_server_args, op_tx, lang_id).await {
        Ok(client) => {
            let root_uri = path_to_uri(project_path);
            log::debug!("lsp[{lang_id}]: spawned ok, sending initialize (root={root_uri})");
            client.initialize(&root_uri);
            Some(client)
        }
        Err(e) => {
            log::error!("lsp[{lang_id}]: failed to start '{lsp_settings_cmd}': {e}");
            None
        }
    }
}

/// Ensure an LSP client is running for the language of `path`, starting one
/// if needed.  A no-op if the language is unknown or already has a client.
async fn ensure_lsp_for_path(
    path: &std::path::Path,
    settings: &crate::settings::Settings,
    project_path: &std::path::Path,
    lsp: &mut HashMap<String, LspClient>,
    op_tx: UnboundedSender<Vec<Operation>>,
) {
    let lang_id = match crate::language::detect_language(path) {
        Some(id) => id,
        None => return,
    };
    let lang_str = lang_id.as_str().to_owned();
    if lsp.contains_key(&lang_str) {
        return; // Already have a client for this language.
    }
    if let Some(client) = try_start_lsp_for_language(settings, &lang_str, project_path, op_tx).await
    {
        lsp.insert(lang_str, client);
    }
}
