mnml-rs 0.2.20

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
//! Notes activity-bar panel — persistent workspace scratch notes
//! (`.mnml/notes/*.md`). (#8)
//!
//! v1 scope: flat list of note files under the workspace's
//! `.mnml/notes/` directory + a `+ New note` action. Click a row →
//! opens the file in an editor pane (goes through the same markdown
//! preview path as any other `.md`). Notes gitignore themselves by
//! default (the `.mnml/` prefix is already common for mnml-scoped
//! files); users can check them in per-workspace by removing them
//! from `.gitignore`.

use ratatui::{
    Frame,
    layout::Rect,
    style::Style,
    text::{Line, Span},
    widgets::{Block, Paragraph},
};

use crate::app::App;
use crate::ui::theme;

pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
    let t = theme::cur();
    let bg = t.bg_darker;
    frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
    if area.height < 2 || area.width < 8 {
        return;
    }
    app.rects.notes_panel_files.clear();
    app.rects.notes_panel_new_chip = None;
    app.rects.notes_panel_filter_input = None;

    // Files come from the cache — populated on first activation.
    // Keeps per-frame stat() calls off the render path.
    if !app.notes_panel_scanned_once {
        app.notes_panel_refresh();
    }
    let filter_lc = app.notes_panel_filter.to_ascii_lowercase();
    let all_files = app.notes_panel_files_cache.clone();
    let files: Vec<std::path::PathBuf> = if filter_lc.is_empty() {
        all_files.clone()
    } else {
        all_files
            .iter()
            .filter(|p| {
                p.file_name()
                    .and_then(|s| s.to_str())
                    .unwrap_or("")
                    .to_ascii_lowercase()
                    .contains(&filter_lc)
            })
            .cloned()
            .collect()
    };

    // 2026-08-24 (user ask) — refresh chip in top-right of the
    // panel header, matching git + todos. Count-in-parens always
    // shown (parity with FINDINGS): total when unfiltered,
    // `M of N` when a filter narrows it.
    let subtitle = if filter_lc.is_empty() {
        format!("  ({})", all_files.len())
    } else {
        format!("  ({} of {})", files.len(), all_files.len())
    };
    app.rects.notes_panel_refresh_chip = crate::ui::panel_chrome::draw_caps_header_with_refresh(
        frame,
        Rect {
            x: area.x,
            y: area.y,
            width: area.width,
            height: 1,
        },
        "NOTES",
        Some(&subtitle),
        bg,
        &t,
        app.config.ui.ascii_icons,
    );
    // Filter row (row 1). Same idiom as HTTP / Agents / TODOs.
    {
        let y_filter = area.y + 1;
        if y_filter < area.y + area.height {
            let focused = app.notes_panel_filter_focused;
            let bg_chip = crate::ui::panel_chrome::filter_chip_bg(&t);
            let fg_chip = if app.notes_panel_filter.is_empty() && !focused {
                t.comment
            } else {
                t.fg
            };
            let display = if app.notes_panel_filter.is_empty() {
                crate::ui::filter_placeholder::for_state(focused).to_string()
            } else {
                app.notes_panel_filter.clone()
            };
            let cursor = if focused { "\u{258F}" } else { " " };
            let pad = (area.width as usize).saturating_sub(3 + display.chars().count() + 1 + 1);
            let line = Line::from(vec![
                Span::styled(" ", Style::default().bg(bg)),
                Span::styled(
                    format!("{} ", crate::ui::search_glyph::NERD),
                    Style::default().fg(t.comment).bg(bg_chip),
                ),
                Span::styled(display, Style::default().fg(fg_chip).bg(bg_chip)),
                Span::styled(cursor, Style::default().fg(t.cyan).bg(bg_chip)),
                Span::styled(" ".repeat(pad), Style::default().bg(bg_chip)),
                Span::styled(" ", Style::default().bg(bg)),
            ]);
            let row_rect = Rect {
                x: area.x,
                y: y_filter,
                width: area.width,
                height: 1,
            };
            frame.render_widget(Paragraph::new(line), row_rect);
            app.rects.notes_panel_filter_input = Some(row_rect);
        }
    }
    // 2026-08-23 — "+ New note" chip lives ABOVE the list now (was
    // pinned to the bottom, where it fell offscreen once the list
    // grew past the panel height). Same idiom as the sessions rail's
    // "+ New session": narrow chip + `bg2` background so it reads as
    // a distinct button instead of a floating bare-text label. Sits
    // one row below the filter (no blank in between) so the notes
    // panel matches every other panel's tighter header stack.
    let mut y = area.y + 2;
    if y < area.y + area.height {
        // 2026-08-23 (#1200) — routed through the shared
        // `action_button::primary` role so this chip matches every
        // other activity panel's primary action.
        let label = "+ New note";
        let chip_w = crate::ui::action_button::chip_width(label);
        let avail = area.width.saturating_sub(1);
        let new_rect = Rect {
            x: area.x + 1,
            y,
            width: chip_w.min(avail),
            height: 1,
        };
        frame.render_widget(
            Paragraph::new(crate::ui::action_button::chip_line(
                label,
                crate::ui::action_button::primary(&t),
            )),
            new_rect,
        );
        app.rects.notes_panel_new_chip = Some(new_rect);
        y += 2;
    }

    if files.is_empty() && !filter_lc.is_empty() {
        y = crate::ui::empty_state::draw(
            frame,
            Rect {
                x: area.x,
                y,
                width: area.width,
                height: area.height.saturating_sub(y - area.y),
            },
            "No matches — Esc clears",
            None,
            bg,
            &t,
        );
        y += 1; // extra breathing space to match prior layout
    } else if files.is_empty() {
        y = crate::ui::empty_state::draw(
            frame,
            Rect {
                x: area.x,
                y,
                width: area.width,
                height: area.height.saturating_sub(y - area.y),
            },
            "No notes yet — click + New note above.",
            Some("Stored under .mnml/notes/*.md"),
            bg,
            &t,
        );
        y += 1;
    } else {
        // #polish 2026-07-06 — right-aligned age column. Users
        // reported it was hard to find "the note I edited yesterday"
        // among many notes — surfacing mtime does that at a glance.
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        // Clamp cursor to filtered length so a stale index
        // after a filter narrows doesn't paint invisibly.
        let clamped_cursor = app.notes_panel_cursor.min(files.len().saturating_sub(1));
        app.notes_panel_cursor = clamped_cursor;
        for (row_i, path) in files
            .iter()
            .take(area.height.saturating_sub(4) as usize)
            .enumerate()
        {
            if y >= area.y + area.height {
                break;
            }
            let is_focused_row = row_i == clamped_cursor;
            let row_bg = if is_focused_row { t.bg2 } else { bg };
            let name = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("note")
                .to_string();
            let icon = if app.config.ui.ascii_icons {
                ""
            } else {
                "\u{F249}"
            };
            // Age string from file mtime — falls back to empty on
            // any I/O error (rare; usually missing metadata).
            let age_str: String = std::fs::metadata(path)
                .and_then(|m| m.modified())
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| {
                    let secs = now.saturating_sub(d.as_secs() as i64);
                    crate::ui::git_graph_view::humanize_age(secs)
                })
                .unwrap_or_default();
            let name_width = (area.width as usize)
                .saturating_sub(4)
                .saturating_sub(age_str.chars().count())
                .saturating_sub(1);
            let name_clipped: String = name.chars().take(name_width).collect();
            let name_padded = format!("{name_clipped:<width$}", width = name_width);
            let row_rect = Rect {
                x: area.x,
                y,
                width: area.width,
                height: 1,
            };
            frame.render_widget(
                Paragraph::new(Line::from(vec![
                    Span::styled("  ", Style::default().bg(row_bg)),
                    Span::styled(format!("{icon} "), Style::default().fg(t.yellow).bg(row_bg)),
                    Span::styled(name_padded, Style::default().fg(t.fg).bg(row_bg)),
                    Span::styled(
                        format!(" {age_str}"),
                        Style::default().fg(t.comment).bg(row_bg),
                    ),
                ])),
                row_rect,
            );
            app.rects.notes_panel_files.push((row_rect, path.clone()));
            y += 1;
        }
    }
    let _ = y;
}

pub fn notes_dir(workspace: &std::path::Path) -> std::path::PathBuf {
    workspace.join(".mnml").join("notes")
}