konoma 0.28.1

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), a git suite (jj/Jujutsu in preview), and an agent-watch mode that follows your AI's edits (macOS and Linux)
//! Nerd Font icon mapping for the leading column of tree rows.
//!
//! Design (important):
//! - Icons are **mostly monochrome = they inherit the terminal's default foreground**. No color is set here
//!   (`ui/tree.rs` sets no Style = it automatically follows the user's theme color).
//! - In the future, color is applied semantically only to entries with a **git status (FR-7)**. No decorative per-extension colors.
//! - On terminals without a Nerd Font, `ui.icons = false` falls back to plain symbols
//!   (branched on the caller side; if no icon is emitted, no tofu (□) appears either).
//!
//! Code points have been verified by rendering with the actual Symbols Nerd Font (standard devicons/seti positions).

use std::path::Path;

/// Icon for Markdown links (placed before the link label). Used only when `ui.icons=true`.
pub fn link_icon() -> char {
    '\u{f0c1}' // nf-fa-link (chain link)
}

/// Icons for Markdown task-list checkboxes. Used only when `ui.icons=true`.
/// Unicode ☐/☑ (U+2610/U+2611) are East-Asian-Neutral = 1 cell to unicode-width, but CJK
/// fallback fonts draw them double-width, clipping the glyph and halving the focus highlight —
/// so like the tree icons we use Nerd Font glyphs (guaranteed 1 cell) instead.
pub fn task_icon(checked: bool) -> char {
    if checked {
        '\u{f046}' // nf-fa-check_square_o
    } else {
        '\u{f096}' // nf-fa-square_o
    }
}

/// Glyph for a repo's current branch (U+2387 ALTERNATIVE KEY SYMBOL, ⎇ — the conventional
/// git-branch icon). Used only when `ui.icons=true`; see `branch_marker`.
pub fn branch_icon() -> char {
    '\u{2387}'
}

/// Glyph for the git graph's pinned base branch (U+2316 POSITION INDICATOR, ⌖). Used only when
/// `ui.icons=true`; see `base_marker`.
pub fn base_icon() -> char {
    '\u{2316}'
}

/// Display marker for a repo's current branch, placed directly before the branch name — the tree
/// root chip, the git changes/log titles, and the graph legend's HEAD entry. `icons=true` →
/// `branch_icon()` (⎇); `icons=false` → the ASCII label `"br:"`.
///
/// Neither Menlo/SF Mono/Monaco/Courier/Andale Mono/Courier New nor HackGen Console NF (measured
/// via a `fontTools` cmap dump) contain U+2387 — macOS always falls back to Apple Symbols, which
/// does, so the glyph never renders as tofu (□) there. But that fallback glyph's advance width
/// (~1.033em) is ~1.7x a Menlo cell's (~0.602em); if the terminal doesn't shrink it to fit the
/// cell, it can spill into the next cell — the same failure mode the ☐/☑ task icons had before
/// v0.6.0. Callers must put a space after the marker (never glue it directly to the next glyph)
/// so an oversized fallback glyph overflows into blank space instead of overlapping content.
pub fn branch_marker(icons: bool) -> &'static str {
    if icons {
        "\u{2387}"
    } else {
        "br:"
    }
}

/// Display marker for the git graph's pinned base branch, placed directly before the branch name
/// in the graph legend's base entry. `icons=true` → `base_icon()` (⌖); `icons=false` → the ASCII
/// label `"base:"`. Same overflow caveat as `branch_marker` — always follow with a space.
pub fn base_marker(icons: bool) -> &'static str {
    if icons {
        "\u{2316}"
    } else {
        "base:"
    }
}

/// Icon for directories. Distinguished by open/closed state.
pub fn dir_icon(expanded: bool) -> char {
    if expanded {
        '\u{f07c}' // nf-fa-folder_open
    } else {
        '\u{f07b}' // nf-fa-folder
    }
}

/// Icon for files. Resolved in order: special file name → extension → default (generic file).
pub fn file_icon(path: &Path) -> char {
    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    match name.to_ascii_lowercase().as_str() {
        ".gitignore" | ".gitattributes" | ".gitmodules" => return '\u{e702}', // git
        "license" | "license.md" | "license.txt" | "copying" => return '\u{f0f6}', // doc
        _ => {}
    }

    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    match ext.as_str() {
        "rs" => '\u{e7a8}',                                             // rust
        "md" | "markdown" | "mmd" | "mermaid" => '\u{e73e}',            // markdown
        "toml" | "yaml" | "yml" | "ini" | "cfg" | "conf" => '\u{e615}', // config gear
        "json" => '\u{e60b}',                                           // json
        "js" | "mjs" | "cjs" => '\u{e74e}',                             // javascript
        "ts" | "tsx" => '\u{e628}',                                     // typescript
        "py" => '\u{e73c}',                                             // python
        "go" => '\u{e627}',                                             // go
        "c" | "h" => '\u{e61e}',                                        // c
        "cpp" | "cc" | "cxx" | "hpp" => '\u{e61d}',                     // c++
        "sh" | "bash" | "zsh" | "fish" => '\u{f489}',                   // shell
        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "svg" => '\u{f1c5}', // image
        "mp4" | "mov" | "mkv" | "webm" | "avi" => '\u{f03d}',           // video
        "lock" => '\u{f023}',                                           // lock (Cargo.lock etc.)
        "txt" | "log" => '\u{f15c}',                                    // text
        _ => '\u{f016}',                                                // default: generic file
    }
}

/// Glyph for one commit-graph node. **The backend decides the [`NodeKind`](crate::git::NodeKind);
/// this table decides the glyph.** Keeping them apart is what lets the node cell be located by
/// position rather than by matching on the character, so a second VCS can bring its own symbols
/// (jj draws `@` for the working copy, `○` for an ordinary commit, `×` for a conflict) without
/// breaking the code that paints the legend and the selected row.
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn node_glyph(kind: crate::git::NodeKind, vcs: crate::vcs::VcsKind) -> char {
    use crate::git::NodeKind as K;
    #[cfg(feature = "git")]
    if vcs == crate::vcs::VcsKind::Jj {
        // jj's own symbols. A jj user reads `@` as "where I am" everywhere jj prints a graph, and
        // `◆` means immutable there — the opposite of git's "this is a merge".
        return match kind {
            K::WorkingCopy => '@',
            K::Normal | K::Merge => '\u{25cb}', //            K::Immutable => '\u{25c6}',         //            K::Conflict => '\u{00d7}',          // ×
        };
    }
    let _ = vcs;
    match kind {
        // git has no glyph of its own for the working copy: its pseudo-row is drawn as an ordinary
        // node and recoloured yellow-bold afterwards.
        K::Normal | K::WorkingCopy => '\u{25cf}', //        K::Merge | K::Immutable => '\u{25c6}',    //        K::Conflict => '\u{00d7}',                // ×
    }
}

/// Marker in front of the tree's repository chip.
///
/// git names a branch, so it gets the branch glyph. jj names the working-copy commit and its label
/// already opens with `@` — jj's own symbol for it — so a second marker would only repeat it.
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn chip_marker(vcs: crate::vcs::VcsKind, icons: bool) -> &'static str {
    match vcs {
        crate::vcs::VcsKind::Git => branch_marker(icons),
        #[cfg(feature = "git")]
        crate::vcs::VcsKind::Jj => "",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::NodeKind;
    use crate::vcs::VcsKind;

    // --- node_glyph: the backend picks the *meaning* (NodeKind), this table picks the *glyph*.
    // git and jj deliberately fold different pairs of kinds together (see the asymmetry test
    // below) — these tests spell out all 5*2=10 (kind, vcs) combinations as an outside promise
    // ("jj's working copy is @", "a conflict is x"), not a copy of the match arms, so a swapped
    // glyph breaks a test whose name says what should have been drawn.

    #[test]
    fn node_glyph_git_all_five_kinds() {
        assert_eq!(
            node_glyph(NodeKind::Normal, VcsKind::Git),
            '\u{25cf}',
            "git の通常コミットは ●"
        );
        assert_eq!(
            node_glyph(NodeKind::Merge, VcsKind::Git),
            '\u{25c6}',
            "git のマージは ◆"
        );
        assert_eq!(
            node_glyph(NodeKind::WorkingCopy, VcsKind::Git),
            '\u{25cf}',
            "git に作業コピー専用の字は無く、Normal と同じ ●(擬似行は色で後から塗り分ける)"
        );
        assert_eq!(
            node_glyph(NodeKind::Immutable, VcsKind::Git),
            '\u{25c6}',
            "git に Immutable の概念は無く、Merge と同じ ◆ に落ちる"
        );
        assert_eq!(
            node_glyph(NodeKind::Conflict, VcsKind::Git),
            '\u{00d7}',
            "衝突は ×"
        );
    }

    #[cfg(feature = "git")]
    #[test]
    fn node_glyph_jj_all_five_kinds() {
        assert_eq!(
            node_glyph(NodeKind::WorkingCopy, VcsKind::Jj),
            '@',
            "jj の作業コピーは @"
        );
        assert_eq!(
            node_glyph(NodeKind::Normal, VcsKind::Jj),
            '\u{25cb}',
            "jj の通常コミットは ○"
        );
        assert_eq!(
            node_glyph(NodeKind::Merge, VcsKind::Jj),
            '\u{25cb}',
            "jj はマージを通常コミットと同じ ○ に畳む(git と違い ◆ にはしない)"
        );
        assert_eq!(
            node_glyph(NodeKind::Immutable, VcsKind::Jj),
            '\u{25c6}',
            "jj の不変コミットは ◆"
        );
        assert_eq!(
            node_glyph(NodeKind::Conflict, VcsKind::Jj),
            '\u{00d7}',
            "衝突は ×"
        );
    }

    #[cfg(feature = "git")]
    #[test]
    fn git_and_jj_fold_different_kind_pairs_into_the_same_glyph() {
        // The asymmetry this table exists to protect: git has no "immutable" concept, so it
        // reuses Merge's ◆ for Immutable too — but jj *does* distinguish them (◆ is Immutable
        // only). jj instead folds Merge into Normal's ○ — but git keeps those two apart (● vs
        // ◆). Losing either distinction (e.g. by copy-pasting one backend's fallback arm into the
        // other's branch) would silently mislabel a commit's meaning.
        assert_eq!(
            node_glyph(NodeKind::Merge, VcsKind::Git),
            node_glyph(NodeKind::Immutable, VcsKind::Git),
            "git: Merge と Immutable は同じ字のはず"
        );
        assert_ne!(
            node_glyph(NodeKind::Merge, VcsKind::Jj),
            node_glyph(NodeKind::Immutable, VcsKind::Jj),
            "jj: Merge と Immutable は別の字でなければならない"
        );
        assert_eq!(
            node_glyph(NodeKind::Normal, VcsKind::Jj),
            node_glyph(NodeKind::Merge, VcsKind::Jj),
            "jj: Normal と Merge は同じ字のはず"
        );
        assert_ne!(
            node_glyph(NodeKind::Normal, VcsKind::Git),
            node_glyph(NodeKind::Merge, VcsKind::Git),
            "git: Normal と Merge は別の字でなければならない"
        );
    }

    // --- chip_marker: the tree title chip's leading marker. git names a branch (gets the branch
    // glyph/label); jj's working-copy label already opens with its own `@`, so a second marker
    // would just repeat it.

    #[test]
    fn chip_marker_git_both_icon_settings() {
        assert_eq!(
            chip_marker(VcsKind::Git, true),
            "\u{2387}",
            "git+icons=true は ⎇"
        );
        assert_eq!(
            chip_marker(VcsKind::Git, false),
            "br:",
            "git+icons=false は ASCII ラベル br:"
        );
    }

    #[cfg(feature = "git")]
    #[test]
    fn chip_marker_jj_is_always_empty() {
        assert_eq!(
            chip_marker(VcsKind::Jj, true),
            "",
            "jj+icons=true はチップ記号を足さない(ラベルが既に @ で始まる)"
        );
        assert_eq!(chip_marker(VcsKind::Jj, false), "", "jj+icons=false も同様");
    }
}