rootle 0.13.1

Modal TUI for browsing remote source-control systems from the terminal
Documentation
//! Action dispatch — miller-column navigation: org/repo/tree selection, blobs, filters, visual marks, yank, and the launch repo popup
//! (moved from app/mod.rs, plans/0021 M1 — a pure move, zero behavior
//! change).

use super::super::diagnostics;
use super::super::{App, provider_status};
use crate::action::Action;
use crate::components::pane::EntryKind;
use crate::mode::Mode;
use rootle_provider::{GitRef, RepoId};

impl App {
    /// This domain's arms: `Some(action)` back when not ours, so the
    /// next domain tries it; arm bodies are moved verbatim.
    pub(crate) fn try_browse(&mut self, action: Action) -> Option<Action> {
        let _consumed: bool = match action {
            Action::SearchSubmitted(_) => {
                self.search_gen.tick();
                self.requests.search.start(self.search_gen);
                self.provider.advise_cancel(); // superseded in-flight work
                self.status = Some(format!("searching {}…", self.modeline.forge));
                if let Some(popup) = &mut self.popup {
                    popup.update(&action);
                }
                if !self.offline {
                    self.spawn_search(self.search_gen);
                }
                true
            }
            Action::SearchResults { .. } | Action::SearchFailed { .. } => {
                // Injected (tests) or misrouted worker outcomes.
                if let Some(popup) = &mut self.popup {
                    popup.update(&action);
                }
                self.status = None;
                true
            }
            Action::RepoSelected { owner, name } => {
                self.status = None;
                self.state.record_repo(&owner, &name);
                self.state.save();
                self.browser.set_repo(&owner, &name);
                self.popup = None;
                self.mode = Mode::Browse;
                self.handle_action(Action::LoadRepoTree { owner, name });
                true
            }
            Action::OrgSelected(org) => {
                self.status = None;
                self.state.record_org(&org);
                self.state.save();
                self.browser.select_org(&org);
                self.popup = None;
                self.mode = Mode::Browse;
                self.handle_action(Action::LoadOrgRepos(org));
                true
            }
            Action::LoadOrgRepos(org) => {
                self.status = None;
                let request = self.browser.begin_owner_load(&org);
                if !self.offline {
                    self.spawn_org_repos(request);
                }
                true
            }
            Action::OrgReposLoaded { request, repos } => {
                self.browser.org_repos_loaded(&request, repos);
                true
            }
            Action::OrgReposFailed { request, error } => {
                self.browser.org_repos_failed(&request, &error);
                true
            }
            Action::LoadRepoTree { owner, name } => {
                self.status = None;
                let request = self.browser.begin_tree_load(&owner, &name);
                if !self.offline {
                    self.spawn_tree(request);
                }
                true
            }
            Action::TreeLoaded {
                request,
                entries,
                truncated,
                branch,
            } => {
                self.browser
                    .tree_loaded(&request, entries, truncated, branch);
                true
            }
            Action::TreeFailed { request, error } => {
                self.browser.tree_failed(&request, &error);
                true
            }
            Action::LoadBlob { sha, name } => {
                if !self.offline {
                    self.spawn_blob(sha, name);
                }
                true
            }
            Action::BlobLoaded { sha, name, bytes } => {
                // Sanitize at the boundary, highlight once, cache in
                // the browser (PLAN.md §9).
                if crate::sanitize::is_binary(&bytes) {
                    self.browser.blob_failed(&sha, "binary file");
                    return None;
                }
                let text = crate::sanitize::sanitize(&bytes);
                let lines = self.highlighter.highlight(&name, &text);
                let lang = self.highlighter.language(&name);
                self.browser.blob_loaded(&sha, &name, &lang, text, lines);
                self.band_apply_or_fetch();
                true
            }
            Action::BlobFailed { sha, error } => {
                self.status = Some(provider_status(&error));
                self.browser.blob_failed(&sha, &error.message);
                true
            }
            Action::HitContextFailed { sha: _, error } => {
                // Auth/throttle surface; anything else stays quiet —
                // the bare path remains and revisit retries (§2).
                use rootle_provider::ErrorKind;
                if matches!(error.kind, ErrorKind::Auth | ErrorKind::RateLimited) {
                    self.status = Some(provider_status(&error));
                }
                true
            }
            Action::Visual => {
                self.mode = Mode::Visual;
                self.browser.enter_visual();
                true
            }
            Action::ExitVisual => {
                self.mode = Mode::Browse;
                self.browser.exit_visual();
                true
            }
            Action::ToggleSelect => {
                self.browser.toggle_selected();
                true
            }
            Action::LeaderReload => {
                self.mode = Mode::Browse;
                // Explicit reload is the retry path for failed blobs
                // too (0023): clear the failure cache so the preview
                // re-requests.
                self.browser.retry_failed_blobs();
                if let Some((owner, name)) = self.browser.repo_coords() {
                    // Conditional refetch: cheap when the ref ETag is
                    // still fresh (304), fresh tree when it moved.
                    self.handle_action(Action::LoadRepoTree { owner, name });
                } else if let Some(org) = self.browser.selected_owner().map(str::to_string) {
                    self.handle_action(Action::LoadOrgRepos(org));
                } else {
                    self.status = Some("nothing to reload".into());
                }
                true
            }
            Action::DeleteMarked => {
                self.mode = Mode::Browse;
                let deleted = self.browser.delete_marked_orgs();
                if deleted.is_empty() {
                    self.status = Some("no marked owners (mark owners in VISUAL, ␣d)".into());
                } else {
                    // Keep persisted recents in sync.
                    self.state.recent_orgs.retain(|o| !deleted.contains(o));
                    self.state.recent_owners.retain(|o| !deleted.contains(o));
                    if self
                        .state
                        .last_org
                        .as_deref()
                        .is_some_and(|o| deleted.iter().any(|d| d == o))
                    {
                        self.state.last_org = None;
                    }
                    self.state.save();
                    self.status = Some(format!("deleted {} owner(s)", deleted.len()));
                }
                true
            }
            Action::ClearMarks => {
                self.mode = Mode::Browse;
                self.browser.clear_marks();
                self.status = Some("marks cleared".into());
                true
            }
            Action::LeaderYank => {
                // Mock stage (plans/0003 §1): toast the URL that would
                // be yanked; clipboard (OSC 52) wires up later.
                // From the leader layer it drops back to Browse; from
                // the preview submode (␣ p y) the pane stays focused.
                if self.mode == Mode::Leader {
                    self.mode = Mode::Browse;
                }
                // URLs come from the provider — no GitHub grammar
                // outside the GitHub impl (plans/0005).
                let url = if let Some(view) = &self.search_view {
                    view.yank_target().and_then(|t| {
                        self.provider
                            .web_url(
                                &RepoId::from(t.repo.as_str()),
                                &t.path,
                                (!t.branch.is_empty())
                                    .then(|| GitRef::from(t.branch.as_str()))
                                    .as_ref(),
                                t.line,
                                t.end,
                                true,
                            )
                            .ok()
                    })
                } else if let Some((owner, repo)) = self.browser.repo_coords() {
                    // File under the cursor yanks the FILE (blob URL);
                    // otherwise the current directory (tree URL).
                    let (path, is_file) = match self.browser.selected_file() {
                        Some((file, _sha)) => (file, true),
                        None => (self.browser.dir_path(), false),
                    };
                    let branch = self.browser.branch().map(GitRef::from);
                    // File yank anchors to the preview line cursor —
                    // or the visual range as `#L3-L7` (v1.5); dirs/orgs
                    // stay line-less.
                    let (line, end) = if is_file {
                        self.browser.yank_anchor()
                    } else {
                        (None, None)
                    };
                    self.provider
                        .web_url(
                            &RepoId::from(format!("{owner}/{repo}")),
                            &path,
                            branch.as_ref(),
                            line,
                            end,
                            is_file,
                        )
                        .ok()
                } else {
                    self.browser
                        .selected_owner()
                        .and_then(|owner| self.provider.org_url(owner).ok())
                };
                match url {
                    Some(u) => {
                        self.pending_clipboard = Some(u.clone());
                        self.status = Some(format!("yanked {u}"));
                    }
                    None => self.status = Some("nothing to yank".into()),
                }
                true
            }
            Action::EnterSearch => {
                self.browser.filter_input.submode = crate::components::vim_input::SubMode::Insert;
                self.mode = Mode::Search;
                true
            }
            Action::CommitFilter => {
                self.mode = Mode::Browse;
                true
            }
            Action::ClearFilter => {
                // BROWSE Esc precedence (plans/0007 §3): a committed
                // find clears first (:nohlsearch), then the list filter.
                // In SEARCH mode Esc keeps its cancel-the-session role.
                if self.mode == Mode::Browse && self.browser.preview.find_active() {
                    self.browser.preview.clear_find();
                } else {
                    self.browser.clear_filter();
                }
                self.mode = Mode::Browse;
                true
            }
            Action::LeaderFindInFile => {
                // Reachable from the leader layer (Browse underneath)
                // and from the preview submode — FIND returns to
                // whichever raised it.
                let back = self.mode;
                self.find_return = Some(if back == Mode::Preview {
                    Mode::Preview
                } else {
                    Mode::Browse
                });
                self.mode = Mode::Browse; // leader layer down either way
                if self.browser.preview.findable() {
                    self.browser.find_input.clear();
                    self.browser.find_input.submode = crate::components::vim_input::SubMode::Insert;
                    self.browser.preview.begin_find();
                    self.mode = Mode::Find;
                } else {
                    self.mode = self.find_return.take().unwrap_or(Mode::Browse);
                    self.status = Some("find: preview is not a text file".into());
                }
                true
            }
            Action::UpdateFind => {
                let query = self.browser.find_input.value();
                self.browser.preview.update_find(query);
                true
            }
            Action::CommitFind => {
                self.mode = self.find_return.take().unwrap_or(Mode::Browse);
                true
            }
            Action::CancelFind => {
                self.browser.preview.cancel_find();
                self.browser.find_input.clear();
                self.mode = self.find_return.take().unwrap_or(Mode::Browse);
                true
            }
            Action::FindNext => {
                self.browser.preview.find_step(1);
                true
            }
            Action::FindPrev => {
                self.browser.preview.find_step(-1);
                true
            }
            Action::MoveUp
            | Action::MoveDown
            | Action::DrillIn
            | Action::DrillOut
            | Action::PreviewLineUp
            | Action::PreviewLineDown => {
                let follow = self.browser.update(&action);
                self.handle_action(follow);
                true
            }
            Action::OpenSelected => {
                match self.browser.selected_kind() {
                    Some(EntryKind::File) => {
                        // Enter on a file → open in the editor (read-only,
                        // PLAN.md §12). The blocking blob fetch is fine:
                        // the UI is about to suspend anyway.
                        if let (Some((path, sha)), Some((owner, repo))) =
                            (self.browser.selected_file(), self.browser.repo_coords())
                        {
                            // 0030: the blocking blob fetch is a
                            // synchronous job — record it and let the
                            // backend's transport records correlate.
                            let op = rootle_trace::operation_id();
                            let job = rootle_trace::in_operation(op, || {
                                rootle_trace::record_with(
                                    rootle_trace::EventKind::JobStarted,
                                    || {
                                        serde_json::json!({
                                            "job": "editor_prepare",
                                            "repo": format!("{owner}/{repo}"),
                                            "path": path,
                                            "sha": sha,
                                        })
                                    },
                                );
                                let started = rootle_trace::enabled().then(std::time::Instant::now);
                                let prepared = crate::editor::prepare(
                                    &self.config,
                                    self.provider.as_ref(),
                                    &format!("{owner}/{repo}"),
                                    &path,
                                    &sha,
                                );
                                rootle_trace::record_with(
                                    rootle_trace::EventKind::JobFinished,
                                    || {
                                        serde_json::json!({
                                            "job": "editor_prepare",
                                            "outcome": if prepared.is_ok() { "ok" } else { "err" },
                                            "error": prepared.as_ref().err().map(|e| diagnostics::text(e)),
                                            "duration_us": started.map(|clock| clock.elapsed().as_micros()),
                                        })
                                    },
                                );
                                prepared
                            });
                            match job {
                                Ok(job) => self.pending_editor = Some(job),
                                Err(message) => self.status = Some(format!("editor: {message}")),
                            }
                        }
                    }
                    Some(EntryKind::Dir | EntryKind::Repo | EntryKind::Org | EntryKind::Owner) => {
                        let follow = self.browser.update(&Action::DrillIn);
                        self.handle_action(follow);
                    }
                    None => {}
                }
                true
            }

            _ => return Some(action),
        };
        debug_assert!(_consumed);
        None
    }
}