aether-wisp 0.4.20

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::components::common::VerticalCursor;
use crate::components::file_tree::{FileTree, FileTreeEntry, FileTreeEntryKind};
use crate::components::git_diff::{file_status_color, header_rule, push_diff_stats};
use crate::git_diff::{FileDiff, FileStatus};
use tui::{Component, Event, Frame, KeyCode, Line, MouseEventKind, Style, ViewContext, truncate_line, truncate_text};

const CHROME_HEIGHT: usize = 2;

pub struct FileListPanel {
    tree: FileTree,
    cursor: VerticalCursor,
    queued_comment_count: usize,
    file_count: usize,
    additions: usize,
    deletions: usize,
    focused: bool,
    file_comment_counts: Vec<usize>,
    scroll_consumed_this_frame: bool,
}

pub enum FileListMessage {
    Selected(usize),
    FileOpened(usize),
}

impl Default for FileListPanel {
    fn default() -> Self {
        Self {
            tree: FileTree::empty(),
            cursor: VerticalCursor::new(),
            queued_comment_count: 0,
            file_count: 0,
            additions: 0,
            deletions: 0,
            focused: false,
            file_comment_counts: Vec::new(),
            scroll_consumed_this_frame: false,
        }
    }
}

impl FileListPanel {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn rebuild_from_files(&mut self, files: &[FileDiff]) {
        self.file_count = files.len();
        self.additions = files.iter().map(FileDiff::additions).sum();
        self.deletions = files.iter().map(FileDiff::deletions).sum();
        self.tree = FileTree::from_files(files);
        self.cursor = VerticalCursor::new();
        self.file_comment_counts = vec![0; files.len()];
    }

    pub fn selected_file_index(&self) -> Option<usize> {
        self.tree.selected_file_index()
    }

    pub fn select_file_index(&mut self, file_index: usize) {
        self.tree.select_file_index(file_index);
    }

    pub fn sync_view_state(&mut self, queued_comment_count: usize, file_comment_counts: Vec<usize>) {
        self.queued_comment_count = queued_comment_count;
        self.file_comment_counts = file_comment_counts;
    }

    pub fn set_focused(&mut self, focused: bool) {
        self.focused = focused;
    }

    pub(crate) fn select_relative(&mut self, delta: isize) -> Option<usize> {
        let prev_file = self.tree.selected_file_index();
        self.tree.navigate(delta);
        let new_file = self.tree.selected_file_index();
        if let Some(idx) = new_file
            && Some(idx) != prev_file
        {
            return Some(idx);
        }
        None
    }

    pub(crate) fn tree_collapse_or_parent(&mut self) {
        self.tree.collapse_or_parent();
    }

    pub(crate) fn tree_expand_or_enter(&mut self) -> Option<usize> {
        let is_file = self.tree.expand_or_enter();
        if is_file { self.tree.selected_file_index() } else { None }
    }

    pub fn tree_mut(&mut self) -> &mut FileTree {
        &mut self.tree
    }

    fn header_row(&self, width: usize, theme: &tui::Theme) -> Line {
        let title_fg = if self.focused { theme.accent() } else { theme.text_primary() };
        let mut row = Line::default();
        row.push_text(" ");
        row.push_with_style("Git Diff", Style::fg(title_fg).bold());
        row.push_text("  ");
        row.push_with_style(
            format!("{} file{}", self.file_count, if self.file_count == 1 { "" } else { "s" }),
            Style::fg(theme.text_primary()),
        );
        row.push_text("  ");
        push_diff_stats(&mut row, self.additions, self.deletions, theme);
        if self.queued_comment_count > 0 {
            row.push_text("  ");
            row.push_with_style(format!("{}", self.queued_comment_count), Style::fg(theme.accent()));
        }
        row.extend_bg_to_width(width);
        truncate_line(&row, width)
    }

    fn ensure_visible(&mut self, viewport_height: usize) {
        self.cursor.ensure_visible(self.tree.selected_visible(), viewport_height);
    }
}

impl Component for FileListPanel {
    type Message = FileListMessage;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        if let Event::Mouse(mouse) = event {
            return match mouse.kind {
                MouseEventKind::ScrollUp => {
                    if self.scroll_consumed_this_frame {
                        return Some(vec![]);
                    }
                    self.scroll_consumed_this_frame = true;
                    Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
                }
                MouseEventKind::ScrollDown => {
                    if self.scroll_consumed_this_frame {
                        return Some(vec![]);
                    }
                    self.scroll_consumed_this_frame = true;
                    Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
                }
                _ => None,
            };
        }

        let Event::Key(key) = event else {
            return None;
        };
        match key.code {
            KeyCode::Char('j') | KeyCode::Down => {
                Some(self.select_relative(1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
            }
            KeyCode::Char('k') | KeyCode::Up => {
                Some(self.select_relative(-1).map(|idx| vec![FileListMessage::Selected(idx)]).unwrap_or_default())
            }
            KeyCode::Char('h') | KeyCode::Left => {
                self.tree_collapse_or_parent();
                Some(vec![])
            }
            KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
                if let Some(idx) = self.tree_expand_or_enter() {
                    Some(vec![FileListMessage::FileOpened(idx)])
                } else {
                    Some(vec![])
                }
            }
            _ => None,
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let theme = &ctx.theme;
        self.scroll_consumed_this_frame = false;
        let width = ctx.size.width as usize;
        let height = ctx.size.height as usize;
        if width < 2 {
            return Frame::new((0..height).map(|_| Line::new(" ".repeat(width))).collect());
        }

        let tree_height = height.saturating_sub(CHROME_HEIGHT);
        self.ensure_visible(tree_height);

        let mut lines = Vec::with_capacity(height);
        lines.push(self.header_row(width, theme));
        lines.push(header_rule(width, theme));

        let visible_entries = self.tree.visible_entries();
        let tree_selected = self.tree.selected_visible();
        for row in 0..tree_height {
            let entry_index = row + self.cursor.scroll;
            let mut content = Line::default();
            if let Some(entry) = visible_entries.get(entry_index) {
                let is_selected = entry_index == tree_selected;
                let comments =
                    entry_file_index(entry).and_then(|index| self.file_comment_counts.get(index).copied()).unwrap_or(0);
                let indent = tree_indent(visible_entries, entry_index);
                let flags = EntryFlags { is_selected, indent: &indent, width, comments };
                match &entry.kind {
                    FileTreeEntryKind::Directory { name, expanded, .. } => {
                        render_directory_entry(&mut content, name, *expanded, entry.depth, flags, theme);
                    }
                    FileTreeEntryKind::File { name, status, additions, deletions, .. } => {
                        render_file_entry(&mut content, name, *status, *additions, *deletions, flags, theme);
                    }
                }
            } else {
                content.push_text(" ".repeat(width));
            }
            lines.push(content);
        }

        lines.truncate(height);
        Frame::new(lines)
    }
}

fn entry_file_index(entry: &FileTreeEntry) -> Option<usize> {
    match &entry.kind {
        FileTreeEntryKind::File { file_index, .. } => Some(*file_index),
        FileTreeEntryKind::Directory { .. } => None,
    }
}

#[derive(Clone, Copy)]
struct EntryFlags<'a> {
    is_selected: bool,
    indent: &'a str,
    width: usize,
    comments: usize,
}

fn render_directory_entry(
    line: &mut Line,
    name: &str,
    expanded: bool,
    depth: usize,
    flags: EntryFlags<'_>,
    theme: &tui::Theme,
) {
    let EntryFlags { is_selected, indent, width, .. } = flags;
    let icon = if expanded { "" } else { "" };
    let connector = if depth == 0 { "" } else { "── " };
    let dir_style = row_fg_style(theme.info(), is_selected, theme);
    let indicator = if is_selected { "" } else { " " };
    let prefix_width = format!("{indicator}{indent}{connector}{icon}  ").chars().count();
    let name_budget = width.saturating_sub(prefix_width);
    let display_name = format!("{name}/");
    let truncated = truncate_text(&display_name, name_budget);

    line.push_with_style(indicator, row_fg_style(theme.accent(), is_selected, theme));
    line.push_with_style(format!("{indent}{connector}"), row_fg_style(theme.muted(), is_selected, theme));
    line.push_with_style(format!("{icon}  "), dir_style);
    line.push_with_style(truncated.as_ref(), dir_style.bold());
    line.extend_bg_to_width(width);
}

fn render_file_entry(
    line: &mut Line,
    name: &str,
    status: FileStatus,
    additions: usize,
    deletions: usize,
    flags: EntryFlags<'_>,
    theme: &tui::Theme,
) {
    let EntryFlags { is_selected, indent, width, comments } = flags;
    let style = row_style(is_selected, theme);
    let guide_style = row_fg_style(theme.muted(), is_selected, theme);

    let badge = (comments > 0).then(|| format!("{comments} "));
    let badge_width = badge.as_deref().map_or(0, |b| b.chars().count());
    let add_str = format!("+{additions}");
    let del_str = format!(" -{deletions}");
    let marker_str = format!(" {}", status.marker());
    let suffix_width = badge_width + add_str.chars().count() + del_str.chars().count() + marker_str.chars().count();
    let prefix = format!("{}{indent}── ", if is_selected { "" } else { " " });
    let prefix_width = prefix.chars().count();
    let name_budget = width.saturating_sub(prefix_width + suffix_width + 1);
    let truncated = truncate_text(name, name_budget);

    line.push_with_style(if is_selected { "" } else { " " }, row_fg_style(theme.accent(), is_selected, theme));
    line.push_with_style(format!("{indent}── "), guide_style);
    line.push_with_style(truncated.as_ref(), style);
    let padding = width.saturating_sub(prefix_width + truncated.chars().count() + suffix_width);
    if padding > 0 {
        line.push_with_style(" ".repeat(padding), style);
    }
    if let Some(badge) = badge {
        line.push_with_style(badge, row_fg_style(theme.accent(), is_selected, theme));
    }
    line.push_with_style(add_str, row_fg_style(theme.diff_added_fg(), is_selected, theme));
    line.push_with_style(del_str, row_fg_style(theme.diff_removed_fg(), is_selected, theme));
    line.push_with_style(marker_str, row_fg_style(file_status_color(status, theme), is_selected, theme));
}

fn row_style(is_selected: bool, theme: &tui::Theme) -> Style {
    if is_selected { theme.selected_row_style() } else { Style::default() }
}

fn row_fg_style(fg: tui::Color, is_selected: bool, theme: &tui::Theme) -> Style {
    if is_selected { theme.selected_row_style_with_fg(fg) } else { Style::fg(fg) }
}

fn tree_indent(entries: &[FileTreeEntry], index: usize) -> String {
    let Some(entry) = entries.get(index) else {
        return String::new();
    };
    if entry.depth == 0 {
        return String::new();
    }
    let mut indent = String::new();
    for level in 1..entry.depth {
        indent.push_str(if level_continues(entries, index, level) { "" } else { "  " });
    }
    indent.push(if level_continues(entries, index, entry.depth) { '' } else { '' });
    indent
}

fn level_continues(entries: &[FileTreeEntry], index: usize, level: usize) -> bool {
    for next in &entries[index + 1..] {
        if next.depth < level {
            return false;
        }
        if next.depth == level {
            return true;
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git_diff::Hunk;
    use tui::{KeyEvent, KeyModifiers};

    fn modified(path: &str) -> FileDiff {
        FileDiff {
            old_path: None,
            path: path.to_string(),
            status: FileStatus::Modified,
            hunks: vec![Hunk {
                header: "@@ -1 +1 @@".to_string(),
                old_start: 1,
                old_count: 1,
                new_start: 1,
                new_count: 1,
                lines: Vec::new(),
            }],
            binary: false,
        }
    }

    #[tokio::test]
    async fn active_indicator_follows_selected_directory() {
        let mut panel = FileListPanel::new();
        panel.rebuild_from_files(&[modified("lib/c.rs"), modified("src/a.rs")]);
        panel.select_file_index(0);
        panel.on_event(&Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))).await;

        let frame = panel.render(&ViewContext::new((32, 8)));
        let lines = frame.lines();

        assert!(!lines[3].plain_text().starts_with(''), "previous file row should not keep the active indicator");
        assert!(lines[4].plain_text().starts_with(''), "selected directory row should have the active indicator");
    }
}