konoma 0.22.1

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), git suite, and an agent-watch mode that follows your AI's edits (macOS and Linux)
use super::*;

impl App {
    // --- フォローモード (`F`) — Agent Watch ② -------------------------------------
    /// `F`: toggle follow mode (auto-jump to externally changed files; the "watch the AI work" view).
    /// Turning it ON starts a fresh follow session (the "what changed while following" list for `n`/`N`).
    pub fn toggle_follow(&mut self) {
        self.follow_mode = !self.follow_mode;
        if self.follow_mode {
            self.follow_session.clear();
            // 新しい追尾セッション: 既定は「開始以降」表示・この瞬間をベースラインに固定する。
            self.follow_diff_full = false;
            // 表示中の follow diff があれば新ベースラインで取り直す(path 一致で残る旧 diff を落とす)。
            self.diff_cache = None;
            #[cfg(feature = "git")]
            self.capture_follow_baseline();
        }
        // OFF ではベースラインを保持する(follow_break/F-off の後も diff_follow_scope 上で
        // n/N 回遊と `f` トグルによる復習が効くように・次の F で作り直す・有界メモリ)。
        let msg = if self.follow_mode {
            crate::i18n::Msg::FollowOn
        } else {
            crate::i18n::Msg::FollowOff
        };
        self.flash = Some(tr(self.lang, msg).into());
    }

    /// Capture the follow-start snapshot: the content of every currently-dirty file (bounded by size),
    /// plus the pinned HEAD sha for clean files. Dirty files too large to snapshot are recorded as
    /// `None` so their follow diff falls back to the full git diff. `F` is a command → sync status first.
    #[cfg(feature = "git")]
    fn capture_follow_baseline(&mut self) {
        self.ensure_git_status_now();
        let head = crate::git::head_commit_id(&self.tab.root);
        let mut dirty: std::collections::HashMap<PathBuf, Option<Vec<u8>>> =
            std::collections::HashMap::new();
        let mut total = 0usize;
        for p in self.changed_paths() {
            let key = p.canonicalize().unwrap_or_else(|_| p.clone());
            // stat 先読み: 大きいファイルは読まずに None(=full diff 降格)。読んでから捨てない。
            let size = std::fs::metadata(&p).map(|m| m.len() as usize).unwrap_or(0);
            if size > FOLLOW_BASELINE_FILE_CAP
                || total.saturating_add(size) > FOLLOW_BASELINE_TOTAL_CAP
            {
                dirty.insert(key, None);
                continue;
            }
            match std::fs::read(&p) {
                Ok(content) => {
                    total = total.saturating_add(content.len());
                    dirty.insert(key, Some(content));
                }
                Err(_) => {
                    dirty.insert(key, None);
                }
            }
        }
        self.follow_baseline = Some(FollowBaseline { dirty, head });
    }

    /// The follow diff for `path`: baseline (follow-start) content vs the current on-disk content, as
    /// `DiffLine`s for the existing GitDiff renderer. None (→ caller uses the full git diff) when there
    /// is no baseline session, the file was dirty-but-too-large at follow-start, the current file is
    /// unreadable / too large, or either side is non-UTF-8 (binary).
    #[cfg(feature = "git")]
    pub(super) fn follow_baseline_diff(&self, path: &Path) -> Option<Vec<crate::git::DiffLine>> {
        let base = self.follow_baseline.as_ref()?;
        let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
        let baseline: Vec<u8> = match base.dirty.get(&key) {
            Some(Some(content)) => content.clone(),
            Some(None) => return None, // dirty at follow-start but too large → full diff
            None => {
                // Clean at follow-start → HEAD blob; a file created after follow-start is absent from
                // the pinned tree → empty baseline = all-added (correct: it is new since follow-start).
                // No HEAD to diff against (not a repo / unborn) → `?` returns None so `compute_gitdiff_lines`
                // defers to `file_diff`, which is empty outside a repo → `follow_jump` shows the file preview.
                let h = base.head.as_deref()?;
                crate::git::blob_at(&self.tab.root, h, path).unwrap_or_default()
            }
        };
        let current = std::fs::read(path).ok()?;
        if current.len() > FOLLOW_BASELINE_FILE_CAP {
            return None;
        }
        let old = String::from_utf8(baseline).ok()?;
        let new = String::from_utf8(current).ok()?;
        Some(crate::git::diff_contents(&old, &new))
    }

    /// `f` in a follow-opened diff: switch between the diff-since-follow-start (default) and the full
    /// git diff for the same file. No-op in a non-follow diff (already full). Drops the diff cache so
    /// the view recomputes in the new scope.
    #[cfg_attr(not(feature = "git"), allow(dead_code))]
    pub fn toggle_follow_diff_scope(&mut self) {
        if self.is_git_diff_preview() && self.diff_follow_scope {
            self.follow_diff_full = !self.follow_diff_full;
            self.diff_cache = None;
            let msg = if self.follow_diff_full {
                crate::i18n::Msg::FollowShowFull
            } else {
                crate::i18n::Msg::FollowShowSince
            };
            self.flash = Some(tr(self.lang, msg).into());
        }
    }

    /// The scope label for a follow-opened diff title/flash: `FollowShowSince` (baseline) or
    /// `FollowShowFull` (toggled). None for non-follow diffs (no suffix).
    pub fn follow_diff_scope_msg(&self) -> Option<crate::i18n::Msg> {
        if self.is_git_diff_preview() && self.diff_follow_scope {
            Some(if self.follow_diff_full {
                crate::i18n::Msg::FollowShowFull
            } else {
                crate::i18n::Msg::FollowShowSince
            })
        } else {
            None
        }
    }

    /// Whether follow mode is on (chip display / the run loop's jump gate).
    pub fn follow_enabled(&self) -> bool {
        self.follow_mode
    }

    /// Stop following. Called when the user leaves the follow diff with `q` or enters a text/modal
    /// surface (`main.rs::handle_key`). Follow is otherwise sticky — scroll/`n`/`N`/`f` keep it on, and
    /// `F` toggles it off via `toggle_follow`. Re-enable with one `F`. Flash so the change is visible.
    pub fn follow_break(&mut self) {
        if self.follow_mode {
            self.follow_mode = false;
            self.flash = Some(tr(self.lang, crate::i18n::Msg::FollowOff).into());
        }
    }

    /// Follow-mode jump: reveal `path` in the tree and open its preview. Gated to meaningful targets:
    /// under root, an existing visible file, not gitignored, not already being previewed (the preview
    /// auto-reload handles content changes of the current file). No-op outside Tree/Preview surfaces
    /// (dialogs and git views are never hijacked; follow is simply paused there and resumes on return).
    pub fn follow_jump(&mut self, path: &Path) {
        use crate::keymap::Surface;
        if !self.follow_mode {
            return;
        }
        // フォロー自身が開いた diff ビュー(PreviewGitDiff)からも次のファイルへ追従を続ける。
        // それ以外の git ビュー/ダイアログ等は乗っ取らない(通常はキー押下で follow が切れるので届かない)。
        let surface_ok = matches!(
            self.surface(),
            Surface::Tree | Surface::PreviewText | Surface::PreviewImage | Surface::PreviewTable
        );
        #[cfg(feature = "git")]
        let surface_ok = surface_ok || matches!(self.surface(), Surface::PreviewGitDiff);
        if !surface_ok {
            return;
        }
        if self.tab.preview_path.as_deref() == Some(path) {
            return;
        }
        if !self.follow_target_ok(path) {
            return;
        }
        // 追尾がビューを動かす瞬間に古い flash(「follow: on」等)を消す=フッターを
        // その面の操作ヒントに明け渡す(flash は本来キー入力で消えるが、追尾はキー無しで進むため)。
        self.flash = None;
        if self.tab.changed_filter {
            // 変更一覧を最新化してから対象を選択(一覧に居るはず=変更イベント由来)。
            self.reapply_changed_filter();
            if let Some(i) = self.tab.entries.iter().position(|e| e.path == path) {
                self.tab.selected = i;
            }
        } else if !matches!(self.reveal_path_deep(path), Ok(true)) {
            return;
        }
        // 既定(`ui.follow_view="diff"`)は**全画面 diff** で開く=何から何に変わったかがハンク単位で
        // 見える(hunk/livediff/diffpane と同じ提示)。diff を出せない未追跡(全行新規)・リポジトリ外や、
        // バイナリのメディア系はファイルプレビュー(+最初の変更ハンクへスクロール)へフォールバック。
        if self.cfg.ui.follow_view != "file" && !self.follow_is_media(path) {
            // フォロー由来 → 開始以降のベースライン差分(follow_diff_full なら従来のフル diff)。
            let diff = self.compute_gitdiff_lines(path, true);
            if !diff.is_empty() {
                self.open_git_diff(path);
                // いま取った diff をキャッシュに載せ、描画での再取得(git 再実行)を省く。
                self.diff_cache = Some(DiffCache {
                    path: path.to_path_buf(),
                    lines: diff,
                });
                // フォロー由来の diff: n/N と位置表示は「このセッションで変わったファイル」を回遊。
                self.diff_follow_scope = true;
                return;
            }
        }
        self.enter_preview(path);
        self.follow_scroll_to_first_change();
    }

    /// Whether `path` is a valid follow target: under root, an existing file, not gitignored, and not
    /// inside a hidden (dot) directory unless hidden files are shown. Shared by the jump and the
    /// session-list recording so both see the same set.
    fn follow_target_ok(&self, path: &Path) -> bool {
        if !path.starts_with(&self.tab.root) || !path.is_file() || self.is_ignored(path) {
            return false;
        }
        if !self.tab.show_hidden {
            // 隠し(ドット)ディレクトリ/ファイル配下はツリーに出せないので追わない(show_hidden で解禁)。
            let hidden = path
                .strip_prefix(&self.tab.root)
                .map(|r| {
                    r.components().any(|c| {
                        c.as_os_str()
                            .to_str()
                            .map(|s| s.starts_with('.'))
                            .unwrap_or(false)
                    })
                })
                .unwrap_or(true);
            if hidden {
                return false;
            }
        }
        true
    }

    /// Record one changed file into the current follow session (called from the run loop's event drain
    /// while follow is ON). Returns whether the path is a valid follow target (the caller uses this to
    /// decide whether it may become the pending jump target). First-change order, deduped.
    pub fn follow_note_change(&mut self, path: &Path) -> bool {
        if !self.follow_mode || !self.follow_target_ok(path) {
            return false;
        }
        if !self.follow_session.iter().any(|p| p == path) {
            self.follow_session.push(path.to_path_buf());
        }
        true
    }

    /// Whether `path` previews as media (image/SVG/video/PDF) — its git diff would be a useless
    /// "binary files differ" line, so follow shows the content preview instead.
    pub(super) fn follow_is_media(&self, path: &Path) -> bool {
        matches!(
            self.cfg.resolve_preview(path),
            PreviewKind::Image(_)
                | PreviewKind::Svg(_)
                | PreviewKind::Video(_)
                | PreviewKind::Pdf(_)
        )
    }

    /// After a follow jump, scroll the (windowed) preview to the **first changed hunk** instead of the
    /// file top — an edit deep in a large file would otherwise be off-screen and invisible, which defeats
    /// "watch the agent work" (Zed follows the edit position; diffpane scrolls to the latest change).
    /// A few context lines are kept above, and the caret lands on the changed line (ready for `v`/`Y`).
    /// No-ops for non-windowed previews, untracked files (all-new → top is right), and outside a repo.
    fn follow_scroll_to_first_change(&mut self) {
        if !self.is_windowed() {
            return;
        }
        let Some(path) = self.tab.preview_path.clone() else {
            return;
        };
        // ガター設定 OFF でも変更行は取得する(ON なら同じ計算が gutter_cache に載り描画でも再利用)。
        let marks = if self.cfg.ui.git_gutter {
            self.git_gutter_marks()
        } else {
            gutter_marks(&crate::git::file_diff(&self.tab.root, &path))
        };
        let Some(first) = marks.keys().min().copied() else {
            return;
        };
        let line0 = (first as usize).saturating_sub(1); // marks は 1-based
        let top = line0.saturating_sub(3); // 上に文脈を数行残す
        let Some(win) = self.preview_win.as_mut() else {
            return;
        };
        if let Ok((off, _)) = win.advance(0, top) {
            self.tab.preview_byte_top = off;
            self.tab.preview_top_line = top;
            self.tab.preview_cursor_line = line0;
        }
    }
}