use crate::action::Action;
use crate::cast::u32_sat;
use crate::config::{Config, SearchPreview, TreePosition};
use crate::event::EventHandler;
use crate::fs::discovery::FileEntry;
use crate::fs::git_status;
use crate::markdown::DocBlock;
use crate::mermaid::{MermaidCache, MermaidEntry};
use crate::state::{AppState, TabSession};
use crate::theme::{Palette, Theme};
use crate::ui::file_tree::FileTreeState;
use crate::ui::link_picker::LinkPickerState;
use crate::ui::markdown_view::TableLayout;
use crate::ui::search_modal::SearchState;
use crate::ui::tab_picker::TabPickerState;
use crate::ui::tabs::{OpenOutcome, Tabs};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui_image::picker::Picker;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
mod file_ops;
mod key_handlers;
mod search;
mod table_modal;
mod yank;
fn copy_to_clipboard(text: &str) {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
let osc52 = format!("\x1b]52;c;{encoded}\x07");
let _ = std::io::Write::write_all(&mut std::io::stdout(), osc52.as_bytes());
}
pub(crate) fn build_yank_text(content: &str, start_source: u32, end_source: u32) -> String {
let (lo, hi) = if start_source <= end_source {
(start_source as usize, end_source as usize)
} else {
(end_source as usize, start_source as usize)
};
content
.lines()
.skip(lo)
.take(hi - lo + 1)
.collect::<Vec<_>>()
.join("\n")
}
fn contains(rect: Rect, col: u16, row: u16) -> bool {
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
}
pub fn collect_match_lines(
blocks: &[DocBlock],
table_layouts: &HashMap<crate::markdown::TableBlockId, TableLayout>,
mermaid_cache: &MermaidCache,
query_lower: &str,
) -> Vec<u32> {
let mut matches = Vec::new();
let mut offset = 0u32;
for block in blocks {
match block {
DocBlock::Text { text, .. } => {
for (i, line) in text.lines.iter().enumerate() {
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if line_text.to_lowercase().contains(query_lower) {
matches.push(offset + u32_sat(i));
}
}
offset += u32_sat(text.lines.len());
}
DocBlock::Table(table) => {
if let Some(layout) = table_layouts.get(&table.id) {
for (i, line) in layout.text.lines.iter().enumerate() {
let line_text: String =
line.spans.iter().map(|s| s.content.as_ref()).collect();
if line_text.to_lowercase().contains(query_lower) {
matches.push(offset + u32_sat(i));
}
}
} else {
let mut row_offset = 1u32; let all_rows = std::iter::once(&table.headers).chain(table.rows.iter());
for row in all_rows {
let row_text: String = row
.iter()
.map(|cell| crate::markdown::cell_to_string(cell))
.collect::<Vec<_>>()
.join(" ");
if row_text.to_lowercase().contains(query_lower) {
matches.push(offset + row_offset);
}
row_offset += 1;
}
}
offset += table.rendered_height;
}
DocBlock::Mermaid { id, source, .. } => {
let block_height = block.height();
let show_as_source = match mermaid_cache.get(*id) {
None | Some(MermaidEntry::Failed(_) | MermaidEntry::SourceOnly(_)) => {
true
}
Some(MermaidEntry::Pending | MermaidEntry::Ready { .. }) => false,
};
if show_as_source {
let limit = block_height.saturating_sub(1) as usize;
for (i, line) in source.lines().take(limit).enumerate() {
if line.to_lowercase().contains(query_lower) {
matches.push(offset + u32_sat(i));
}
}
}
offset += block_height;
}
}
}
matches
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Focus {
Tree,
Viewer,
Search,
DocSearch,
Config,
GotoLine,
TabPicker,
TableModal,
CopyMenu,
LinkPicker,
Editor,
}
#[derive(Debug, Clone)]
pub struct CopyMenuState {
pub cursor: usize,
pub path: PathBuf,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct TableModalState {
pub tab_id: crate::ui::tabs::TabId,
pub h_scroll: u16,
pub v_scroll: u16,
pub headers: Vec<crate::markdown::CellSpans>,
pub rows: Vec<Vec<crate::markdown::CellSpans>>,
pub alignments: Vec<pulldown_cmark::Alignment>,
pub natural_widths: Vec<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigPopupState {
pub cursor: usize,
}
impl ConfigPopupState {
pub const SECTIONS: &'static [(&'static str, usize)] = &[
("Theme", Theme::ALL.len()),
("Markdown", 1),
("Panels", 2),
("Search", 2),
];
pub fn total_rows() -> usize {
Self::SECTIONS.iter().map(|(_, n)| n).sum()
}
pub fn move_up(&mut self) {
let total = Self::total_rows();
self.cursor = (self.cursor + total - 1) % total;
}
pub fn move_down(&mut self) {
let total = Self::total_rows();
self.cursor = (self.cursor + 1) % total;
}
}
#[derive(Debug, Default)]
pub struct DocSearchState {
pub active: bool,
pub query: String,
pub match_lines: Vec<u32>,
pub current_match: usize,
}
#[derive(Debug, Default)]
pub struct GotoLineState {
pub active: bool,
pub input: String,
}
#[allow(clippy::struct_excessive_bools)]
#[allow(clippy::struct_field_names)]
pub struct App {
pub running: bool,
pub focus: Focus,
pub pre_config_focus: Focus,
pub tree: FileTreeState,
pub tabs: Tabs,
pub search: SearchState,
pub goto_line: GotoLineState,
pub config_popup: Option<ConfigPopupState>,
pub show_help: bool,
pub tree_hidden: bool,
pub tree_width_pct: u16,
pub root: PathBuf,
pub theme: Theme,
pub palette: Palette,
pub show_line_numbers: bool,
pub tree_position: TreePosition,
pub search_preview: SearchPreview,
pub copy_menu: Option<CopyMenuState>,
pub app_state: AppState,
pub action_tx: Option<tokio::sync::mpsc::UnboundedSender<Action>>,
pub pending_chord: Option<char>,
pub tab_bar_rects: Vec<(crate::ui::tabs::TabId, ratatui::layout::Rect)>,
pub tab_close_rects: Vec<(crate::ui::tabs::TabId, ratatui::layout::Rect)>,
pub tab_picker_rects: Vec<(crate::ui::tabs::TabId, ratatui::layout::Rect)>,
pub search_result_rects: Vec<(usize, ratatui::layout::Rect)>,
pub tab_picker: Option<TabPickerState>,
pub link_picker: Option<LinkPickerState>,
pub tree_area_rect: Option<ratatui::layout::Rect>,
pub viewer_area_rect: Option<ratatui::layout::Rect>,
pub mermaid_cache: MermaidCache,
pub picker: Option<Picker>,
pub table_modal: Option<TableModalState>,
pub table_modal_rect: Option<ratatui::layout::Rect>,
search_generation: Arc<AtomicU64>,
pub last_file_save_at: Option<(PathBuf, std::time::Instant)>,
pub status_message: Option<String>,
pub pending_jump: Option<(PathBuf, u32)>,
pub initial_file: Option<PathBuf>,
}
impl App {
pub fn new(root: PathBuf, initial_file: Option<PathBuf>) -> Self {
let config = Config::load();
let palette = Palette::from_theme(config.theme);
let app_state = AppState::load();
let entries = FileEntry::discover(&root);
let mut tree = FileTreeState::default();
tree.rebuild(entries);
let picker = crate::mermaid::create_picker();
let mut app = Self {
running: true,
focus: Focus::Tree,
pre_config_focus: Focus::Tree,
tree,
tabs: Tabs::new(),
search: SearchState::default(),
goto_line: GotoLineState::default(),
config_popup: None,
show_help: false,
tree_hidden: false,
tree_width_pct: 25,
root,
theme: config.theme,
palette,
show_line_numbers: config.show_line_numbers,
tree_position: config.tree_position,
search_preview: config.search_preview,
copy_menu: None,
app_state,
action_tx: None,
pending_chord: None,
tab_bar_rects: Vec::new(),
tab_close_rects: Vec::new(),
tab_picker_rects: Vec::new(),
search_result_rects: Vec::new(),
tab_picker: None,
link_picker: None,
tree_area_rect: None,
viewer_area_rect: None,
mermaid_cache: MermaidCache::new(),
picker,
table_modal: None,
table_modal_rect: None,
search_generation: Arc::new(AtomicU64::new(0)),
last_file_save_at: None,
status_message: None,
pending_jump: None,
initial_file,
};
app.restore_session();
app
}
pub fn doc_search(&self) -> Option<&crate::app::DocSearchState> {
self.tabs.active_tab().map(|t| &t.doc_search)
}
pub fn doc_search_mut(&mut self) -> Option<&mut crate::app::DocSearchState> {
self.tabs.active_tab_mut().map(|t| &mut t.doc_search)
}
fn restore_session(&mut self) {
let Some(session) = self.app_state.sessions.get(&self.root).cloned() else {
return;
};
let mut last_loaded_path: Option<PathBuf> = None;
for tab_session in &session.tabs {
let path = &tab_session.file;
if path.as_os_str().is_empty() || !path.exists() || !path.starts_with(&self.root) {
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let scroll = tab_session.scroll;
let (_, outcome) = self.tabs.open_or_focus(path, true);
if matches!(outcome, OpenOutcome::Opened | OpenOutcome::Replaced) {
let tab = self
.tabs
.active_tab_mut()
.expect("active tab must exist after open_or_focus");
tab.view
.load(path.clone(), name, content, &self.palette, self.theme);
let max_scroll = tab.view.total_lines.saturating_sub(1);
let clamped = scroll.min(max_scroll);
tab.view.scroll_offset = clamped;
tab.view.cursor_line = clamped;
}
last_loaded_path = Some(path.clone());
}
let target_active = session.active.min(self.tabs.len().saturating_sub(1));
self.tabs.activate_by_index(target_active + 1);
if self.tabs.is_empty() {
return;
}
let active_path = self
.tabs
.active_tab()
.and_then(|t| t.view.current_path.clone());
let tree_path = active_path.or(last_loaded_path);
if let Some(path) = tree_path {
self.expand_and_select(&path);
}
self.focus = Focus::Viewer;
}
fn save_session(&mut self) {
let Some((mut state, root, tab_sessions, active_idx)) = self.session_snapshot() else {
return;
};
state.update_session(&root, tab_sessions, active_idx);
}
fn session_snapshot(&self) -> Option<(AppState, PathBuf, Vec<TabSession>, usize)> {
let tab_sessions: Vec<TabSession> = self
.tabs
.iter()
.filter_map(|t| {
t.view.current_path.as_ref().map(|p| TabSession {
file: p.clone(),
scroll: t.view.scroll_offset,
})
})
.collect();
if tab_sessions.is_empty() {
return None;
}
let active_idx = self.tabs.active_index().unwrap_or(0);
Some((
self.app_state.clone(),
self.root.clone(),
tab_sessions,
active_idx,
))
}
fn persist_config(&self) {
let config = Config {
theme: self.theme,
show_line_numbers: self.show_line_numbers,
tree_position: self.tree_position,
search_preview: self.search_preview,
};
tokio::task::spawn_blocking(move || config.save());
}
fn refresh_git_status(&self) {
let Some(tx) = self.action_tx.clone() else {
return;
};
let root = self.root.clone();
tokio::task::spawn_blocking(move || {
let map = git_status::collect(&root);
let _ = tx.send(Action::GitStatusReady(map));
});
}
fn rerender_all_tabs(&mut self) {
let palette = self.palette;
self.tabs.rerender_all(&palette, self.theme);
self.mermaid_cache.clear();
}
pub async fn run(
&mut self,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
) -> Result<()> {
let (mut events, tx) = EventHandler::new();
self.action_tx = Some(tx.clone());
self.refresh_git_status();
if let Some(file) = self.initial_file.take() {
self.expand_and_select(&file);
self.open_or_focus(file, true, None);
}
let root_clone = self.root.clone();
let _watcher = crate::fs::watcher::spawn_watcher(&root_clone, tx.clone());
loop {
terminal.draw(|f| crate::ui::draw(f, self))?;
if let Some(action) = events.next().await {
self.handle_action(action);
}
if !self.running {
self.save_session();
break;
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
pub(crate) fn handle_action(&mut self, action: Action) {
match action {
Action::RawKey(key) => self.handle_key(key.code, key.modifiers),
Action::Quit => self.running = false,
Action::FocusLeft => self.focus = Focus::Tree,
Action::FocusRight => self.focus = Focus::Viewer,
Action::TreeUp => self.tree.move_up(),
Action::TreeDown => self.tree.move_down(),
Action::TreeToggle => self.tree.toggle_expand(),
Action::TreeFirst => self.tree.go_first(),
Action::TreeLast => self.tree.go_last(),
Action::TreeSelect => self.open_in_active_tab(),
Action::ScrollUp(n) => {
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_up(u32::from(n));
tab.view.scroll_to_cursor(vh);
}
}
Action::ScrollDown(n) => {
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_down(u32::from(n));
tab.view.scroll_to_cursor(vh);
}
}
Action::ScrollHalfPageUp => {
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_up(vh / 2);
tab.view.scroll_to_cursor(vh);
}
}
Action::ScrollHalfPageDown => {
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_down(vh / 2);
tab.view.scroll_to_cursor(vh);
}
}
Action::ScrollToTop => {
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_to_top();
}
}
Action::ScrollToBottom => {
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_to_bottom(vh);
}
}
Action::EnterSearch => {
self.search.activate();
self.focus = Focus::Search;
}
Action::ExitSearch => {
self.search.active = false;
self.focus = Focus::Tree;
}
Action::SearchInput(c) => {
self.search.query.push(c);
self.perform_search();
}
Action::SearchBackspace => {
self.search.query.pop();
self.perform_search();
}
Action::SearchNext => self.search.next_result(),
Action::SearchPrev => self.search.prev_result(),
Action::SearchToggleMode => {
self.search.toggle_mode();
self.perform_search();
}
Action::SearchConfirm => self.confirm_search(),
Action::FilesChanged(changed) => {
self.reload_changed_tabs(&changed);
self.refresh_git_status();
if let Some(tx) = self.action_tx.clone() {
let root = self.root.clone();
tokio::task::spawn_blocking(move || {
let entries = FileEntry::discover(&root);
let _ = tx.send(Action::TreeDiscovered(entries));
});
}
}
Action::TreeDiscovered(entries) => {
self.tree.rebuild(entries);
}
Action::Resize(_, _) => {}
Action::Mouse(m) => self.handle_mouse(m),
Action::MermaidReady(id, entry) => {
self.mermaid_cache.insert(id, *entry);
}
Action::SearchResults {
generation,
results,
truncated,
} => {
if self.search_generation.load(Ordering::Relaxed) == generation {
self.search.results = results;
self.search.selected_index = 0;
self.search.truncated_at_cap = truncated;
}
}
Action::FileLoaded {
path,
content,
new_tab,
} => {
self.apply_file_loaded(path, content, new_tab);
}
Action::FileReloaded { path, content } => {
self.apply_file_reloaded(path, content);
}
Action::GitStatusReady(map) => {
self.tree.git_status = map;
}
Action::FileSaved {
path,
saved_content,
} => {
self.apply_file_saved(path, saved_content);
}
Action::FileSaveError { path: _, error } => {
let msg = format!("save error: {error}");
if let Some(tab) = self.tabs.active_tab_mut()
&& let Some(editor) = tab.editor.as_mut()
{
editor.status_message = Some(msg);
} else {
self.status_message = Some(msg);
}
}
Action::FileLoadFailed { path } => {
if let Some((ref pending_path, _)) = self.pending_jump
&& *pending_path == path
{
self.pending_jump = None;
}
}
}
}
#[allow(clippy::too_many_lines)]
fn handle_mouse(&mut self, m: crossterm::event::MouseEvent) {
if self.table_modal.is_some() {
self.handle_table_modal_mouse(m);
return;
}
if self.focus == Focus::Editor {
return;
}
let col = m.column;
let row = m.row;
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
if self.search.active {
let search_hit = self
.search_result_rects
.iter()
.find(|(_, rect)| contains(*rect, col, row))
.map(|(idx, _)| *idx);
if let Some(idx) = search_hit {
self.search.selected_index = idx;
self.confirm_search();
return;
}
return;
}
let picker_hit = self
.tab_picker_rects
.iter()
.find(|(_, rect)| contains(*rect, col, row))
.map(|(id, _)| *id);
if let Some(id) = picker_hit {
self.tabs.set_active(id);
self.tab_picker = None;
self.close_table_modal();
self.focus = Focus::Viewer;
return;
}
let close_hit = self
.tab_close_rects
.iter()
.find(|(_, rect)| contains(*rect, col, row))
.map(|(id, _)| *id);
if let Some(id) = close_hit {
self.tabs.close(id);
if self.tabs.is_empty() {
self.focus = Focus::Tree;
}
return;
}
let tab_hit = self
.tab_bar_rects
.iter()
.find(|(_, rect)| contains(*rect, col, row))
.map(|(id, _)| *id);
if let Some(id) = tab_hit {
self.commit_doc_search_if_active();
self.close_table_modal();
self.tabs.set_active(id);
self.focus = Focus::Viewer;
return;
}
if let Some(tree_rect) = self.tree_area_rect
&& contains(tree_rect, col, row)
{
self.focus = Focus::Tree;
let inner_y = tree_rect.y + 1;
if row >= inner_y {
let viewport_row = (row - inner_y) as usize;
let offset = self.tree.list_state.offset();
let idx = offset + viewport_row;
if idx < self.tree.flat_items.len() {
self.tree.list_state.select(Some(idx));
let item = self.tree.flat_items[idx].clone();
if item.is_dir {
self.tree.toggle_expand();
} else {
self.open_in_active_tab();
}
}
}
return;
}
if let Some(viewer_rect) = self.viewer_area_rect
&& contains(viewer_rect, col, row)
{
self.focus = Focus::Viewer;
self.try_follow_link_click(viewer_rect, col, row);
}
}
MouseEventKind::ScrollDown => {
if let Some(viewer_rect) = self.viewer_area_rect
&& contains(viewer_rect, col, row)
{
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_down(3);
tab.view.scroll_to_cursor(vh);
}
} else if let Some(tree_rect) = self.tree_area_rect
&& contains(tree_rect, col, row)
{
self.tree.move_down();
self.tree.move_down();
self.tree.move_down();
}
}
MouseEventKind::ScrollUp => {
if let Some(viewer_rect) = self.viewer_area_rect
&& contains(viewer_rect, col, row)
{
let vh = self.tabs.view_height;
if let Some(tab) = self.tabs.active_tab_mut() {
tab.view.cursor_up(3);
tab.view.scroll_to_cursor(vh);
}
} else if let Some(tree_rect) = self.tree_area_rect
&& contains(tree_rect, col, row)
{
self.tree.move_up();
self.tree.move_up();
self.tree.move_up();
}
}
_ => {}
}
}
fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
if self.show_help {
self.show_help = false;
return;
}
if self.focus == Focus::Config {
self.handle_config_key(code);
return;
}
if code == KeyCode::Char('H')
&& self.focus != Focus::Search
&& self.focus != Focus::TableModal
{
self.tree_hidden = !self.tree_hidden;
if self.tree_hidden && self.focus == Focus::Tree {
self.focus = Focus::Viewer;
}
return;
}
if code == KeyCode::Char('?') && self.focus != Focus::Search {
self.show_help = true;
return;
}
match self.focus {
Focus::Search => self.handle_search_key(code, modifiers),
Focus::Tree => self.handle_tree_key(code, modifiers),
Focus::Viewer => self.handle_viewer_key(code, modifiers),
Focus::DocSearch => self.handle_doc_search_key(code, modifiers),
Focus::GotoLine => self.handle_goto_line_key(code),
Focus::Config => {}
Focus::TabPicker => {
crate::ui::tab_picker::handle_key(self, code);
if self.tab_picker.is_none() {
self.focus = Focus::Viewer;
}
}
Focus::LinkPicker => {
crate::ui::link_picker::handle_key(self, code);
if self.link_picker.is_none() {
self.focus = Focus::Viewer;
}
}
Focus::TableModal => {
self.handle_table_modal_key(code);
}
Focus::CopyMenu => self.handle_copy_menu_key(code),
Focus::Editor => {
let key = crossterm::event::KeyEvent::new(code, modifiers);
self.handle_editor_key(key);
}
}
}
}
#[cfg(test)]
mod tests;