use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use std::io;
use crate::bibtex::{entries_to_bibtex, entry_to_filename};
use crate::config::Config;
use crate::models::Entry;
use crate::storage::{find_by_key_mut, load_db, save_db};
#[derive(Clone, Copy, PartialEq)]
enum Panel {
Collections,
Entries,
Preview,
}
#[derive(Clone, Copy, PartialEq)]
enum PreviewMode {
Info,
Note,
Pdf,
}
impl PreviewMode {
fn next(self) -> Self {
match self {
PreviewMode::Info => PreviewMode::Note,
PreviewMode::Note => PreviewMode::Pdf,
PreviewMode::Pdf => PreviewMode::Info,
}
}
fn label(&self) -> &'static str {
match self {
PreviewMode::Info => "Info",
PreviewMode::Note => "Note",
PreviewMode::Pdf => "PDF",
}
}
}
enum Mode {
Normal,
Search,
Confirm(ConfirmAction),
Message(String),
Help,
SortMenu,
CollectionPicker,
TagEditor,
Loading(String),
ExportMenu,
Settings,
}
#[derive(Clone, Copy, PartialEq)]
enum ExportScope {
Selected,
Collection,
All,
}
#[derive(Clone, Copy, PartialEq)]
enum ExportFormat {
BibTeX,
Yaml,
Ris,
}
impl ExportFormat {
fn label(&self) -> &'static str {
match self {
ExportFormat::BibTeX => "BibTeX (.bib)",
ExportFormat::Yaml => "YAML (.yaml)",
ExportFormat::Ris => "RIS (.ris)",
}
}
fn ext(&self) -> &'static str {
match self {
ExportFormat::BibTeX => "bibtex",
ExportFormat::Yaml => "yaml",
ExportFormat::Ris => "ris",
}
}
}
struct ExportState {
scope_options: Vec<(ExportScope, String)>,
scope_idx: usize,
format_idx: usize,
include_pdf: bool,
section: usize,
}
enum ConfirmAction {
Delete(String),
FetchPdf(String),
}
struct BgTaskResult {
key: String,
file_path: String,
full_path: String,
}
#[derive(Clone, Copy, PartialEq)]
enum SortCriterion {
Year,
Author,
Title,
Created,
}
impl SortCriterion {
fn label(&self) -> &'static str {
match self {
SortCriterion::Year => "Year",
SortCriterion::Author => "Author",
SortCriterion::Title => "Title",
SortCriterion::Created => "Created",
}
}
fn default_ascending(&self) -> bool {
match self {
SortCriterion::Year => false,
SortCriterion::Author => true,
SortCriterion::Title => true,
SortCriterion::Created => false,
}
}
fn all() -> [SortCriterion; 4] {
[SortCriterion::Year, SortCriterion::Author, SortCriterion::Title, SortCriterion::Created]
}
}
struct ChecklistPicker {
title: String,
items: Vec<(String, bool)>,
index: usize,
new_item_input: Option<String>,
new_item_label: String,
}
impl ChecklistPicker {
fn new(title: String, items: Vec<(String, bool)>, new_item_label: String) -> Self {
Self { title, items, index: 0, new_item_input: None, new_item_label }
}
fn move_up(&mut self) { if self.index > 0 { self.index -= 1; } }
fn move_down(&mut self) {
let max = self.items.len();
if self.index < max { self.index += 1; }
}
fn is_on_new_item(&self) -> bool { self.index == self.items.len() }
fn toggle(&mut self) {
if self.is_on_new_item() {
self.new_item_input = Some(String::new());
} else if let Some(item) = self.items.get_mut(self.index) {
item.1 = !item.1;
}
}
fn in_input_mode(&self) -> bool { self.new_item_input.is_some() }
fn apply_char(&mut self, c: char) {
if let Some(ref mut input) = self.new_item_input { input.push(c); }
}
fn backspace(&mut self) {
if let Some(ref mut input) = self.new_item_input { input.pop(); }
}
fn confirm_input(&mut self) {
if let Some(input) = self.new_item_input.take() {
let name = input.trim().to_string();
if !name.is_empty() && !self.items.iter().any(|(n, _)| n == &name) {
self.items.push((name, true));
}
}
}
fn cancel_input(&mut self) { self.new_item_input = None; }
fn checked_names(&self) -> Vec<String> {
self.items.iter().filter(|(_, c)| *c).map(|(n, _)| n.clone()).collect()
}
}
pub struct App {
entries: Vec<Entry>,
filtered: Vec<usize>,
list_state: ListState,
col_list_state: ListState,
collections: Vec<String>, search_query: String,
col_search_query: String,
mode: Mode,
config: Config,
focus: Panel,
preview_mode: PreviewMode,
preview_scroll: u16,
note_content: String,
note_citekey: String,
pending_editor: Option<std::path::PathBuf>,
bg_result: Option<std::sync::mpsc::Receiver<Result<BgTaskResult>>>,
spinner_tick: usize,
sort_by: SortCriterion,
sort_ascending: bool,
sort_menu_index: usize,
prev_sort_by: SortCriterion,
prev_sort_ascending: bool,
picker: Option<ChecklistPicker>,
key_buf: String,
selected_keys: std::collections::HashSet<String>,
export_state: Option<ExportState>,
settings_idx: usize,
git_status_cache: String,
git_status_checked: bool,
}
impl App {
pub fn new(config: Config) -> Result<Self> {
let db_path = crate::config::resolve_db_path(&config);
let db = load_db(&db_path)?;
let entries = db.entries;
let mut col_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for e in &entries {
for c in &e.collections { col_set.insert(c.clone()); }
}
let collections: Vec<String> = col_set.into_iter().collect();
let filtered: Vec<usize> = (0..entries.len()).collect();
let mut list_state = ListState::default();
if !filtered.is_empty() { list_state.select(Some(0)); }
let mut col_list_state = ListState::default();
col_list_state.select(Some(0));
Ok(Self {
entries,
filtered,
list_state,
col_list_state,
collections,
search_query: String::new(),
col_search_query: String::new(),
mode: Mode::Normal,
config,
focus: Panel::Entries,
preview_mode: PreviewMode::Info,
preview_scroll: 0,
note_content: String::new(),
note_citekey: String::new(),
pending_editor: None,
bg_result: None,
spinner_tick: 0,
sort_by: SortCriterion::Created,
sort_ascending: false,
sort_menu_index: 0,
prev_sort_by: SortCriterion::Created,
prev_sort_ascending: false,
picker: None,
key_buf: String::new(),
selected_keys: std::collections::HashSet::new(),
export_state: None,
settings_idx: 0,
git_status_cache: String::new(),
git_status_checked: false,
})
}
fn current_collection(&self) -> Option<&str> {
let idx = self.col_list_state.selected().unwrap_or(0);
if idx == 0 { None } else { self.collections.get(idx - 1).map(|s| s.as_str()) }
}
fn col_count(&self) -> usize { self.collections.len() + 1 }
fn apply_filters(&mut self) {
let col = self.current_collection().map(|s| s.to_string());
let query = self.search_query.to_lowercase();
self.filtered = self.entries.iter().enumerate()
.filter(|(_, e)| {
let col_ok = match &col {
None => true,
Some(c) => e.collections.contains(c),
};
if !col_ok { return false; }
if query.is_empty() { return true; }
let title = e.title.as_deref().unwrap_or("").to_lowercase();
let author = e.author.join(" ").to_lowercase();
let key = e.bibtex_key.to_lowercase();
let tags = e.tags.join(" ").to_lowercase();
title.contains(&query) || author.contains(&query)
|| key.contains(&query) || tags.contains(&query)
})
.map(|(i, _)| i)
.collect();
if self.filtered.is_empty() {
self.list_state.select(None);
} else {
let cur = self.list_state.selected().unwrap_or(0);
if cur >= self.filtered.len() {
self.list_state.select(Some(self.filtered.len() - 1));
} else {
self.list_state.select(Some(cur));
}
}
self.apply_sort();
}
fn apply_sort(&mut self) {
let entries = &self.entries;
let sort_by = self.sort_by;
let ascending = self.sort_ascending;
self.filtered.sort_by(|&a, &b| {
let ea = &entries[a];
let eb = &entries[b];
match sort_by {
SortCriterion::Year => {
match (ea.year, eb.year) {
(Some(a), Some(b)) => { let c = a.cmp(&b); if ascending { c } else { c.reverse() } }
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
SortCriterion::Author => {
let a_n = ea.author.first().and_then(|a| a.split(',').next()).unwrap_or("");
let b_n = eb.author.first().and_then(|a| a.split(',').next()).unwrap_or("");
match (a_n.is_empty(), b_n.is_empty()) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => { let c = a_n.to_lowercase().cmp(&b_n.to_lowercase()); if ascending { c } else { c.reverse() } }
}
}
SortCriterion::Title => {
let a_t = ea.title.as_deref().unwrap_or("");
let b_t = eb.title.as_deref().unwrap_or("");
match (a_t.is_empty(), b_t.is_empty()) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => { let c = a_t.to_lowercase().cmp(&b_t.to_lowercase()); if ascending { c } else { c.reverse() } }
}
}
SortCriterion::Created => {
let c = ea.created_at.cmp(&eb.created_at);
if ascending { c } else { c.reverse() }
}
}
});
if !self.filtered.is_empty() {
let cur = self.list_state.selected().unwrap_or(0);
if cur >= self.filtered.len() {
self.list_state.select(Some(0));
}
}
}
fn selected_entry(&self) -> Option<&Entry> {
let sel = self.list_state.selected()?;
let idx = self.filtered.get(sel)?;
self.entries.get(*idx)
}
fn selected_entry_idx(&self) -> Option<usize> {
let sel = self.list_state.selected()?;
self.filtered.get(sel).copied()
}
fn move_entry_down(&mut self) {
if self.filtered.is_empty() { return; }
let next = match self.list_state.selected() {
Some(i) => (i + 1).min(self.filtered.len() - 1),
None => 0,
};
self.list_state.select(Some(next));
self.update_preview();
}
fn move_entry_up(&mut self) {
if self.filtered.is_empty() { return; }
let prev = match self.list_state.selected() {
Some(i) if i > 0 => i - 1,
_ => 0,
};
self.list_state.select(Some(prev));
self.update_preview();
}
fn move_col_down(&mut self) {
let n = self.col_count();
if n == 0 { return; }
let next = match self.col_list_state.selected() {
Some(i) => (i + 1).min(n - 1),
None => 0,
};
self.col_list_state.select(Some(next));
self.list_state.select(Some(0));
self.apply_filters();
}
fn move_col_up(&mut self) {
let prev = match self.col_list_state.selected() {
Some(i) if i > 0 => i - 1,
_ => 0,
};
self.col_list_state.select(Some(prev));
self.list_state.select(Some(0));
self.apply_filters();
}
fn update_preview(&mut self) {
self.preview_scroll = 0;
if self.preview_mode == PreviewMode::Note {
self.load_note_for_preview();
}
}
fn load_note_for_preview(&mut self) {
let citekey = match self.selected_entry() {
Some(entry) => entry.bibtex_key.clone(),
None => return,
};
if citekey == self.note_citekey { return; }
let note_path = self.config.notes_dir.join(format!("{}.md", citekey));
self.note_content = if note_path.exists() {
std::fs::read_to_string(¬e_path).unwrap_or_else(|_| "Error reading note.".into())
} else {
"No note yet. Press N to create one.".into()
};
self.note_citekey = citekey;
}
fn open_pdf(&self, entry: &Entry) {
if let Some(fp) = &entry.file_path {
let full_path = self.config.bibox_dir.join(fp);
if !full_path.exists() { return; }
let path_str = full_path.to_string_lossy().to_string();
if let Some(viewer) = &self.config.pdf_viewer {
let _ = std::process::Command::new(viewer).arg(&path_str).spawn();
} else {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&path_str).spawn();
#[cfg(not(target_os = "macos"))]
let _ = std::process::Command::new("xdg-open").arg(&path_str).spawn();
}
}
}
fn export_collection_bib(&self) -> String {
let col = self.current_collection();
let entries: Vec<&Entry> = self.entries.iter()
.filter(|e| match col {
None => true,
Some(c) => e.collections.iter().any(|ec| ec == c),
})
.collect();
let bib = entries_to_bibtex(&entries);
let col_name = col.unwrap_or("all");
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
let filename = format!("{}_{}.bib", col_name, timestamp);
let _ = std::fs::write(&filename, &bib);
filename
}
fn delete_selected(&mut self) -> Result<()> {
if let Some(idx) = self.selected_entry_idx() {
let entry = &self.entries[idx];
if let Some(fp) = &entry.file_path {
let path = self.config.bibox_dir.join(fp);
if path.exists() { let _ = std::fs::remove_file(&path); }
}
let key = entry.bibtex_key.clone();
self.entries.retain(|e| e.bibtex_key != key);
let db_path = crate::config::resolve_db_path(&self.config);
let mut db = load_db(&db_path)?;
db.entries = self.entries.clone();
save_db(&db, &db_path)?;
self.rebuild_collections();
self.apply_filters();
}
Ok(())
}
fn rebuild_collections(&mut self) {
let mut col_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for e in &self.entries {
for c in &e.collections { col_set.insert(c.clone()); }
}
self.collections = col_set.into_iter().collect();
let sel = self.col_list_state.selected().unwrap_or(0);
if sel >= self.col_count() {
self.col_list_state.select(Some(0));
}
}
fn open_collection_picker(&mut self) {
if let Some(entry) = self.selected_entry() {
let entry_cols: std::collections::HashSet<&String> = entry.collections.iter().collect();
let all_cols: std::collections::BTreeSet<String> = self.entries.iter()
.flat_map(|e| e.collections.iter().cloned()).collect();
let items: Vec<(String, bool)> = all_cols.into_iter()
.map(|c| { let checked = entry_cols.contains(&c); (c, checked) }).collect();
let key = entry.bibtex_key.clone();
self.picker = Some(ChecklistPicker::new(
format!("Collections for [{}]:", key), items, "+ New collection...".into(),
));
self.mode = Mode::CollectionPicker;
}
}
fn open_tag_editor(&mut self) {
if let Some(entry) = self.selected_entry() {
let entry_tags: std::collections::HashSet<&String> = entry.tags.iter().collect();
let all_tags: std::collections::BTreeSet<String> = self.entries.iter()
.flat_map(|e| e.tags.iter().cloned()).collect();
let items: Vec<(String, bool)> = all_tags.into_iter()
.map(|t| { let checked = entry_tags.contains(&t); (t, checked) }).collect();
let key = entry.bibtex_key.clone();
self.picker = Some(ChecklistPicker::new(
format!("Tags for [{}]:", key), items, "+ New tag...".into(),
));
self.mode = Mode::TagEditor;
}
}
fn apply_picker_collections(&mut self) -> Result<()> {
let (new_cols, idx) = match (&self.picker, self.selected_entry_idx()) {
(Some(picker), Some(idx)) => (picker.checked_names(), idx),
_ => { self.picker = None; return Ok(()); }
};
self.picker = None;
self.entries[idx].collections = new_cols;
let db_path = crate::config::resolve_db_path(&self.config);
let mut db = load_db(&db_path)?;
db.entries = self.entries.clone();
save_db(&db, &db_path)?;
self.rebuild_collections();
self.apply_filters();
Ok(())
}
fn apply_picker_tags(&mut self) -> Result<()> {
let (new_tags, idx) = match (&self.picker, self.selected_entry_idx()) {
(Some(picker), Some(idx)) => (picker.checked_names(), idx),
_ => { self.picker = None; return Ok(()); }
};
self.picker = None;
self.entries[idx].tags = new_tags;
let db_path = crate::config::resolve_db_path(&self.config);
let mut db = load_db(&db_path)?;
db.entries = self.entries.clone();
save_db(&db, &db_path)?;
self.apply_filters();
Ok(())
}
}
fn draw(f: &mut Frame, app: &mut App) {
let size = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.split(size);
let r = app.config.panel_ratio;
let total = r[0] + r[1] + r[2];
let pct = |v: u16| -> u16 { (v as u32 * 100 / total as u32) as u16 };
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(pct(r[0])),
Constraint::Percentage(pct(r[1])),
Constraint::Percentage(pct(r[2])),
])
.split(outer[0]);
draw_collections_panel(f, app, panels[0]);
draw_entries_panel(f, app, panels[1]);
draw_preview_panel(f, app, panels[2]);
draw_status_bar(f, app, outer[1]);
match &app.mode {
Mode::Confirm(ConfirmAction::Delete(key)) => {
let key = key.clone();
draw_confirm_popup(f, &format!("Delete '{}'? (y/n)", key), size);
}
Mode::Confirm(ConfirmAction::FetchPdf(key)) => {
let key = key.clone();
draw_confirm_popup(f, &format!("No PDF for '{}'. Fetch from web? (y/n)", key), size);
}
Mode::Message(msg) => {
let msg = msg.clone();
draw_message_popup(f, &msg, size);
}
Mode::Loading(msg) => {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let spinner = frames[app.spinner_tick % frames.len()];
let text = format!("{} {}", spinner, msg);
draw_message_popup(f, &text, size);
}
Mode::Help => draw_help_popup(f, size),
Mode::SortMenu => draw_sort_popup(f, app, size),
Mode::CollectionPicker | Mode::TagEditor => {
if let Some(ref picker) = app.picker {
draw_checklist_popup(f, picker, size);
}
}
Mode::ExportMenu => {
if let Some(ref es) = app.export_state {
draw_export_popup(f, es, size);
}
}
Mode::Settings => {
draw_settings_popup(f, app, size);
}
_ => {}
}
}
fn draw_collections_panel(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Panel::Collections;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let mut items: Vec<ListItem> = vec![];
let all_count = app.entries.len();
let all_text = format!("All ({})", all_count);
items.push(ListItem::new(all_text));
for col in &app.collections {
let count = app.entries.iter().filter(|e| e.collections.contains(col)).count();
items.push(ListItem::new(format!("{} ({})", col, count)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Collections ");
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut state = app.col_list_state.clone();
f.render_stateful_widget(list, area, &mut state);
}
fn draw_entries_panel(f: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == Panel::Entries;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
use crate::config::LineNumbers;
let selected_idx = app.list_state.selected().unwrap_or(0);
let items: Vec<ListItem> = app.filtered.iter().enumerate().map(|(i, &idx)| {
let e = &app.entries[idx];
let title = e.title.as_deref().unwrap_or("(no title)");
let author = e.author_display();
let year = e.year.map(|y| y.to_string()).unwrap_or_else(|| "n.d.".to_string());
let pdf_mark = if e.file_path.is_some() { " ◆" } else { "" };
let line_num = match app.config.line_numbers {
LineNumbers::Absolute => format!("{:>3} ", i + 1),
LineNumbers::Relative => {
if i == selected_idx {
format!("{:>3} ", i + 1)
} else {
let diff = (i as isize - selected_idx as isize).unsigned_abs();
format!("{:>3} ", diff)
}
}
LineNumbers::None => String::new(),
};
let num_style = if i == selected_idx {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let pad = " ".repeat(line_num.len());
let is_selected = app.selected_keys.contains(&e.bibtex_key);
let sel_mark = if is_selected { "✓ " } else { " " };
let sel_style = if is_selected { Style::default().fg(Color::Green) } else { Style::default() };
let line1 = Line::from(vec![
Span::styled(line_num, num_style),
Span::styled(sel_mark, sel_style),
Span::styled(e.bibtex_key.clone(), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::styled(pdf_mark.to_string(), Style::default().fg(Color::Green)),
]);
let line2 = Line::from(Span::raw(format!("{} {}", pad, title)));
let line3 = Line::from(Span::styled(
format!("{} {} · {}", pad, author, year),
Style::default().fg(Color::DarkGray),
));
let mut item = ListItem::new(Text::from(vec![line1, line2, line3]));
if is_selected {
item = item.style(Style::default().bg(Color::Rgb(30, 50, 30)));
}
item
}).collect();
let sel_count = app.selected_keys.len();
let title = if !app.search_query.is_empty() {
format!(" Search: {} ({}) ", app.search_query, app.filtered.len())
} else if sel_count > 0 {
format!(" Entries ({}) — {} selected ", app.filtered.len(), sel_count)
} else {
format!(" Entries ({}) ", app.filtered.len())
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title);
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.list_state);
}
fn draw_preview_panel(f: &mut Frame, app: &mut App, area: Rect) {
let focused = app.focus == Panel::Preview;
let border_style = if focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let modes = [PreviewMode::Info, PreviewMode::Note, PreviewMode::Pdf];
let tab_spans: Vec<Span> = modes.iter().map(|m| {
if *m == app.preview_mode {
Span::styled(
format!(" {} ", m.label()),
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
)
} else {
Span::styled(format!(" {} ", m.label()), Style::default().fg(Color::DarkGray))
}
}).collect();
let mut title_spans = vec![Span::raw(" ")];
for (i, s) in tab_spans.into_iter().enumerate() {
title_spans.push(s);
if i < modes.len() - 1 {
title_spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
}
}
title_spans.push(Span::raw(" "));
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(Line::from(title_spans));
let inner = block.inner(area);
f.render_widget(block, area);
match app.preview_mode {
PreviewMode::Info => draw_preview_info(f, app, inner),
PreviewMode::Note => draw_preview_note(f, app, inner),
PreviewMode::Pdf => draw_preview_pdf(f, app, inner),
}
}
fn draw_preview_info(f: &mut Frame, app: &App, area: Rect) {
let entry = match app.selected_entry() {
Some(e) => e,
None => {
f.render_widget(Paragraph::new("No entry selected.").style(Style::default().fg(Color::DarkGray)), area);
return;
}
};
let mut lines: Vec<Line<'_>> = vec![];
if let Some(t) = &entry.title {
lines.push(Line::from(Span::styled(t.as_str(), Style::default().add_modifier(Modifier::BOLD))));
lines.push(Line::from(""));
}
macro_rules! field {
($label:expr, $value:expr) => {
lines.push(Line::from(vec![
Span::styled(format!("{:<12}", $label), Style::default().fg(Color::Cyan)),
Span::raw($value.to_string()),
]));
};
}
field!("Key:", entry.bibtex_key.as_str());
field!("Type:", entry.entry_type.to_string());
if !entry.author.is_empty() { field!("Author:", entry.author.join("; ")); }
if let Some(y) = entry.year { field!("Year:", y.to_string()); }
if let Some(j) = &entry.journal { field!("Journal:", j.as_str()); }
if let Some(bt) = &entry.booktitle { field!("Booktitle:", bt.as_str()); }
if let Some(p) = &entry.publisher { field!("Publisher:", p.as_str()); }
if let Some(doi) = &entry.doi { field!("DOI:", doi.as_str()); }
if let Some(url) = &entry.url { field!("URL:", url.as_str()); }
if !entry.tags.is_empty() { field!("Tags:", entry.tags.join(", ")); }
if !entry.collections.is_empty() { field!("Collections:", entry.collections.join(", ")); }
if let Some(fp) = &entry.file_path {
field!("File:", fp.as_str());
} else {
lines.push(Line::from(vec![
Span::styled(format!("{:<12}", "File:"), Style::default().fg(Color::Cyan)),
Span::styled("No PDF", Style::default().fg(Color::DarkGray)),
]));
}
let p = Paragraph::new(lines)
.scroll((app.preview_scroll, 0))
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(p, area);
}
fn draw_preview_note(f: &mut Frame, app: &mut App, area: Rect) {
if let Some(entry) = app.selected_entry() {
let key = entry.bibtex_key.clone();
if key != app.note_citekey {
let note_path = app.config.notes_dir.join(format!("{}.md", key));
app.note_content = if note_path.exists() {
std::fs::read_to_string(¬e_path).unwrap_or_else(|_| "Error reading note.".into())
} else {
"No note yet. Press N to create one.".into()
};
app.note_citekey = key;
}
} else {
f.render_widget(
Paragraph::new("No entry selected.").style(Style::default().fg(Color::DarkGray)),
area,
);
return;
}
let lines = render_markdown_to_lines(&app.note_content);
let p = Paragraph::new(lines)
.scroll((app.preview_scroll, 0))
.wrap(ratatui::widgets::Wrap { trim: true });
f.render_widget(p, area);
}
fn render_markdown_to_lines(md: &str) -> Vec<Line<'static>> {
use pulldown_cmark::{Event, Parser, Tag, TagEnd, HeadingLevel};
let parser = Parser::new(md);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let mut bold = false;
let mut italic = false;
let mut in_heading: Option<HeadingLevel> = None;
let mut in_blockquote = false;
let mut _in_list = false;
let mut list_ordered = false;
let mut list_index: u64 = 0;
let mut in_code_block = false;
let mut list_item_started = false;
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
in_heading = Some(level);
}
Event::End(TagEnd::Heading(_)) => {
let style = match in_heading {
Some(HeadingLevel::H1) => Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Some(HeadingLevel::H2) => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
_ => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
};
let heading_spans: Vec<Span<'static>> = current_spans.drain(..)
.map(|s| Span::styled(s.content.to_string(), style))
.collect();
lines.push(Line::from(heading_spans));
lines.push(Line::from(""));
in_heading = None;
}
Event::Start(Tag::Strong) => { bold = true; }
Event::End(TagEnd::Strong) => { bold = false; }
Event::Start(Tag::Emphasis) => { italic = true; }
Event::End(TagEnd::Emphasis) => { italic = false; }
Event::Code(text) => {
let style = Style::default().fg(Color::Green).bg(Color::Rgb(40, 40, 40));
current_spans.push(Span::styled(format!(" {} ", text), style));
}
Event::Start(Tag::CodeBlock(_)) => {
if !current_spans.is_empty() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
in_code_block = true;
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
lines.push(Line::from(""));
}
Event::Start(Tag::BlockQuote(_)) => { in_blockquote = true; }
Event::End(TagEnd::BlockQuote(_)) => {
in_blockquote = false;
lines.push(Line::from(""));
}
Event::Start(Tag::List(ordered)) => {
_in_list = true;
list_ordered = ordered.is_some();
list_index = ordered.unwrap_or(1);
}
Event::End(TagEnd::List(_)) => {
_in_list = false;
lines.push(Line::from(""));
}
Event::Start(Tag::Item) => {
list_item_started = true;
}
Event::End(TagEnd::Item) => {
if !current_spans.is_empty() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
}
Event::Start(Tag::Paragraph) => {}
Event::End(TagEnd::Paragraph) => {
if !current_spans.is_empty() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
lines.push(Line::from(""));
}
Event::Text(text) => {
if in_code_block {
let style = Style::default().fg(Color::White).bg(Color::Rgb(40, 40, 40));
for line in text.lines() {
lines.push(Line::from(Span::styled(format!(" {}", line), style)));
}
} else {
if list_item_started {
let prefix = if in_blockquote { "│ " } else { "" };
if list_ordered {
current_spans.push(Span::styled(
format!("{} {}. ", prefix, list_index),
Style::default().fg(Color::DarkGray),
));
list_index += 1;
} else {
current_spans.push(Span::styled(
format!("{} • ", prefix),
Style::default().fg(Color::DarkGray),
));
}
list_item_started = false;
} else if in_blockquote && current_spans.is_empty() {
current_spans.push(Span::styled(
"│ ".to_string(),
Style::default().fg(Color::Cyan),
));
}
let mut style = Style::default();
if bold { style = style.add_modifier(Modifier::BOLD); }
if italic { style = style.add_modifier(Modifier::ITALIC); }
if in_blockquote { style = style.fg(Color::DarkGray).add_modifier(Modifier::ITALIC); }
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::SoftBreak | Event::HardBreak => {
if !current_spans.is_empty() {
lines.push(Line::from(std::mem::take(&mut current_spans)));
}
}
Event::Rule => {
lines.push(Line::from(Span::styled(
"────────────────────────────────".to_string(),
Style::default().fg(Color::DarkGray),
)));
}
_ => {}
}
}
if !current_spans.is_empty() {
lines.push(Line::from(current_spans));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Empty note.".to_string(),
Style::default().fg(Color::DarkGray),
)));
}
lines
}
fn draw_preview_pdf(f: &mut Frame, app: &App, area: Rect) {
let entry = match app.selected_entry() {
Some(e) => e,
None => {
f.render_widget(Paragraph::new("No entry selected.").style(Style::default().fg(Color::DarkGray)), area);
return;
}
};
if entry.file_path.is_none() {
f.render_widget(
Paragraph::new("No PDF attached.\nPress o to fetch or open.").style(Style::default().fg(Color::DarkGray)),
area,
);
return;
}
let fp = entry.file_path.as_ref().unwrap();
let full_path = app.config.bibox_dir.join(fp);
let text = std::process::Command::new("pdftotext")
.args(["-l", "1", "-layout", &full_path.to_string_lossy(), "-"])
.output()
.ok()
.and_then(|o| if o.status.success() { String::from_utf8(o.stdout).ok() } else { None });
match text {
Some(content) => {
let lines: Vec<Line> = content.lines()
.map(|l| Line::from(l.to_string()))
.collect();
let p = Paragraph::new(lines)
.scroll((app.preview_scroll, 0))
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(p, area);
}
None => {
f.render_widget(
Paragraph::new(format!("PDF: {}\n\nInstall pdftotext for text preview:\n brew install poppler", fp))
.style(Style::default().fg(Color::DarkGray)),
area,
);
}
}
}
fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
let status = match &app.mode {
Mode::Search => {
if app.focus == Panel::Collections {
format!("/ {} (collection search, Esc to clear)", app.col_search_query)
} else {
format!("/ {} (Esc to clear)", app.search_query)
}
}
_ => {
let panel_hint = match app.focus {
Panel::Collections => "j/k navigate l→entries",
Panel::Entries => "h←collections l→preview j/k navigate",
Panel::Preview => "h←entries Tab switch mode j/k scroll",
};
format!(
"{} │ / search s sort o open w web e export d delete c collect t tag N note , settings ? help q quit",
panel_hint
)
}
};
let status_widget = Paragraph::new(status).style(Style::default().fg(Color::DarkGray));
f.render_widget(status_widget, area);
}
fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((r.height.saturating_sub(height)) / 2),
Constraint::Length(height),
Constraint::Min(0),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn draw_confirm_popup(f: &mut Frame, msg: &str, area: Rect) {
let popup_area = centered_rect(60, 5, area);
f.render_widget(Clear, popup_area);
let text = Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL).title(" Confirm "))
.style(Style::default().fg(Color::Red));
f.render_widget(text, popup_area);
}
fn draw_message_popup(f: &mut Frame, msg: &str, area: Rect) {
let popup_area = centered_rect(60, 5, area);
f.render_widget(Clear, popup_area);
let text = Paragraph::new(msg)
.block(Block::default().borders(Borders::ALL).title(" Info "))
.style(Style::default().fg(Color::Green));
f.render_widget(text, popup_area);
}
fn draw_help_popup(f: &mut Frame, area: Rect) {
let popup_area = centered_rect(80, 28, area);
f.render_widget(Clear, popup_area);
let help_text = vec![
Line::from(Span::styled("bibox — Keyboard Shortcuts", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(""),
Line::from(Span::styled("Navigation", Style::default().fg(Color::Cyan))),
Line::from(" h/← Focus left panel l/→ Focus right panel"),
Line::from(" j/↓ Move down k/↑ Move up"),
Line::from(" gg Jump to top G Jump to bottom"),
Line::from(" H/M/L Screen top/mid/bot {n}j Move n lines"),
Line::from(" C-d Half page down C-u Half page up"),
Line::from(" Tab Switch preview mode (Info / Note / PDF)"),
Line::from(""),
Line::from(Span::styled("Selection", Style::default().fg(Color::Cyan))),
Line::from(" Space Toggle select V Select/deselect all"),
Line::from(" Esc Clear selection"),
Line::from(""),
Line::from(Span::styled("Actions", Style::default().fg(Color::Cyan))),
Line::from(" y Copy citekey o Open PDF / fetch"),
Line::from(" w Open web page"),
Line::from(" e Export menu d Delete entry"),
Line::from(" N Edit note ($EDITOR)"),
Line::from(""),
Line::from(Span::styled("Edit", Style::default().fg(Color::Cyan))),
Line::from(" c Manage collections t Edit tags"),
Line::from(" s Sort menu"),
Line::from(""),
Line::from(Span::styled("Other", Style::default().fg(Color::Cyan))),
Line::from(" / Search q Quit"),
Line::from(" , Settings ? This help"),
Line::from(""),
Line::from(Span::styled("Press ? or Esc to close", Style::default().fg(Color::DarkGray))),
];
let popup = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title(" Help "))
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(popup, popup_area);
}
fn draw_sort_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_area = centered_rect(50, 10, area);
f.render_widget(Clear, popup_area);
let criteria = SortCriterion::all();
let mut lines = vec![
Line::from(Span::styled("Sort by:", Style::default().fg(Color::Yellow))),
Line::from(""),
];
for (i, c) in criteria.iter().enumerate() {
let selected = *c == app.sort_by;
let arrow = if i == app.sort_menu_index { "▶ " } else { " " };
let dir = if selected { if app.sort_ascending { "↑ asc" } else { "↓ desc" } } else { " " };
let style = if selected { Style::default().fg(Color::Cyan) } else { Style::default() };
lines.push(Line::from(vec![
Span::styled(arrow, Style::default().fg(Color::Yellow)),
Span::styled(format!("{:<12}", c.label()), style),
Span::styled(dir, Style::default().fg(Color::DarkGray)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("↑↓ select Enter apply Space toggle ↑↓ Esc cancel", Style::default().fg(Color::DarkGray))));
let popup = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Sort "));
f.render_widget(popup, popup_area);
}
fn draw_checklist_popup(f: &mut Frame, picker: &ChecklistPicker, area: Rect) {
let item_count = picker.items.len() + 3;
let height = (item_count as u16 + 4).min(20);
let popup_area = centered_rect(60, height, area);
f.render_widget(Clear, popup_area);
let mut lines = vec![
Line::from(Span::styled(&picker.title, Style::default().fg(Color::Yellow))),
Line::from(""),
];
for (i, (name, checked)) in picker.items.iter().enumerate() {
let arrow = if i == picker.index { "▶ " } else { " " };
let check = if *checked { "[x]" } else { "[ ]" };
let check_style = if *checked { Style::default().fg(Color::Green) } else { Style::default().fg(Color::DarkGray) };
lines.push(Line::from(vec![
Span::styled(arrow, Style::default().fg(Color::Yellow)),
Span::styled(format!("{} ", check), check_style),
Span::raw(name.as_str()),
]));
}
lines.push(Line::from(Span::styled(" ─────────────────", Style::default().fg(Color::DarkGray))));
let new_arrow = if picker.is_on_new_item() { "▶ " } else { " " };
if let Some(ref input) = picker.new_item_input {
lines.push(Line::from(vec![
Span::styled(new_arrow, Style::default().fg(Color::Yellow)),
Span::styled(format!("{}▏", input), Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(new_arrow, Style::default().fg(Color::Yellow)),
Span::styled(&picker.new_item_label, Style::default().fg(Color::Cyan)),
]));
}
lines.push(Line::from(""));
let footer = if picker.in_input_mode() { "Enter confirm Esc cancel" } else { "↑↓ navigate Space toggle Enter done Esc cancel" };
lines.push(Line::from(Span::styled(footer, Style::default().fg(Color::DarkGray))));
let popup = Paragraph::new(lines).block(Block::default().borders(Borders::ALL));
f.render_widget(popup, popup_area);
}
fn draw_export_popup(f: &mut Frame, es: &ExportState, area: Rect) {
let height = (es.scope_options.len() + 10) as u16;
let popup_area = centered_rect(60, height.min(18), area);
f.render_widget(Clear, popup_area);
let formats = [ExportFormat::BibTeX, ExportFormat::Yaml, ExportFormat::Ris];
let mut lines = vec![
Line::from(Span::styled("Scope:", Style::default().fg(Color::Yellow))),
];
for (i, (_, label)) in es.scope_options.iter().enumerate() {
let arrow = if es.section == 0 && i == es.scope_idx { "▶ " } else { " " };
let style = if i == es.scope_idx { Style::default().fg(Color::Cyan) } else { Style::default() };
lines.push(Line::from(vec![
Span::styled(arrow, Style::default().fg(Color::Yellow)),
Span::styled(label.as_str(), style),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Format:", Style::default().fg(Color::Yellow))));
for (i, fmt) in formats.iter().enumerate() {
let arrow = if es.section == 1 && i == es.format_idx { "▶ " } else { " " };
let style = if i == es.format_idx { Style::default().fg(Color::Cyan) } else { Style::default() };
lines.push(Line::from(vec![
Span::styled(arrow, Style::default().fg(Color::Yellow)),
Span::styled(fmt.label(), style),
]));
}
lines.push(Line::from(""));
let pdf_arrow = if es.section == 2 { "▶ " } else { " " };
let pdf_check = if es.include_pdf { "[x]" } else { "[ ]" };
let pdf_style = if es.include_pdf { Style::default().fg(Color::Green) } else { Style::default().fg(Color::DarkGray) };
lines.push(Line::from(vec![
Span::styled(pdf_arrow, Style::default().fg(Color::Yellow)),
Span::styled(pdf_check, pdf_style),
Span::raw(" Include PDFs"),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"↑↓ navigate Tab section Space toggle Enter export Esc cancel",
Style::default().fg(Color::DarkGray),
)));
let popup = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Export "));
f.render_widget(popup, popup_area);
}
fn get_git_status(home: &std::path::Path) -> String {
let is_git = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !is_git { return "not a git repo".into(); }
let remote = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "remote"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
if remote.trim().is_empty() { return "no remote".into(); }
let status = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "status", "--porcelain"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
if !status.trim().is_empty() {
return format!("{} ● uncommitted changes", remote.trim());
}
let _ = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "fetch", "--quiet"])
.output();
let behind = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "rev-list", "--count", "HEAD..@{upstream}"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0);
let branch = std::process::Command::new("git")
.args(["-C", &home.to_string_lossy(), "branch", "--show-current"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_else(|| "master".into());
if behind > 0 {
format!("{}/{} ⚠ {} behind", remote.trim(), branch.trim(), behind)
} else {
format!("{}/{} ✓ up to date", remote.trim(), branch.trim())
}
}
fn draw_settings_popup(f: &mut Frame, app: &App, area: Rect) {
use crate::config::LineNumbers;
let popup_area = centered_rect(70, 20, area);
f.render_widget(Clear, popup_area);
let ln_label = match app.config.line_numbers {
LineNumbers::Absolute => "absolute",
LineNumbers::Relative => "relative",
LineNumbers::None => "none",
};
let ratio = app.config.panel_ratio;
let bib_dir = app.config.bib_export_dir.display().to_string();
let exp_dir = app.config.export_dir.display().to_string();
let home_label = match &app.config.home {
Some(h) => h.display().to_string(),
None => "(not set — use `bibox init <path>`)".into(),
};
let git_label = app.git_status_cache.clone();
let mut items: Vec<(String, bool)> = vec![
(format!("Line numbers [{}]", ln_label), true),
(format!("Panel ratio [{}, {}, {}]", ratio[0], ratio[1], ratio[2]), true),
(format!("Bib export dir [{}]", bib_dir), true),
(format!("Export dir [{}]", exp_dir), true),
];
let readonly_start = items.len();
items.push((format!("Home {}", home_label), false));
items.push((format!("Git {}", git_label), false));
let mut lines = vec![
Line::from(Span::styled("Settings", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
Line::from(""),
];
for (i, (label, editable)) in items.iter().enumerate() {
if i == readonly_start {
lines.push(Line::from(Span::styled(" ─────────────────────────────", Style::default().fg(Color::DarkGray))));
}
let arrow = if i == app.settings_idx { "▶ " } else { " " };
let style = if !editable {
Style::default().fg(Color::DarkGray)
} else if i == app.settings_idx {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::styled(arrow, Style::default().fg(Color::Yellow)),
Span::styled(label.as_str(), style),
]));
}
if app.settings_idx == readonly_start + 1 && app.config.home.is_some() {
let hint = if app.git_status_checked {
" [Enter: sync (pull → commit → push)]"
} else {
" [Enter: check status]"
};
lines.push(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"↑↓ navigate ←→ change value Enter save/sync Esc cancel",
Style::default().fg(Color::DarkGray),
)));
let popup = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" Settings "));
f.render_widget(popup, popup_area);
}
fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
match &app.mode {
Mode::Normal => handle_normal(app, key),
Mode::Search => handle_search(app, key),
Mode::Confirm(_) => handle_confirm(app, key),
Mode::Message(_) => { app.mode = Mode::Normal; Ok(false) }
Mode::Loading(_) => {
if key.code == KeyCode::Esc { app.bg_result = None; app.mode = Mode::Normal; }
Ok(false)
}
Mode::Help => handle_help(app, key),
Mode::SortMenu => handle_sort_menu(app, key),
Mode::CollectionPicker => handle_picker(app, key, false),
Mode::TagEditor => handle_picker(app, key, true),
Mode::ExportMenu => handle_export_menu(app, key),
Mode::Settings => handle_settings(app, key),
}
}
fn handle_normal(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char(c @ '0'..='9') => {
if !app.key_buf.is_empty() || c != '0' {
app.key_buf.push(c);
return Ok(false);
}
}
KeyCode::Char('g') => {
if app.key_buf == "g" {
match app.focus {
Panel::Collections => { app.col_list_state.select(Some(0)); app.list_state.select(Some(0)); app.apply_filters(); }
Panel::Entries => { app.list_state.select(Some(0)); app.update_preview(); }
Panel::Preview => { app.preview_scroll = 0; }
}
app.key_buf.clear();
return Ok(false);
} else if app.key_buf.is_empty() {
app.key_buf.push('g');
return Ok(false);
}
}
_ => {}
}
let count: usize = app.key_buf.chars().take_while(|c| c.is_ascii_digit())
.collect::<String>().parse().unwrap_or(1);
app.key_buf.clear();
match key.code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(true),
KeyCode::Char('h') | KeyCode::Left => {
app.focus = match app.focus {
Panel::Collections => Panel::Collections,
Panel::Entries => Panel::Collections,
Panel::Preview => Panel::Entries,
};
}
KeyCode::Char('l') | KeyCode::Right => {
app.focus = match app.focus {
Panel::Collections => Panel::Entries,
Panel::Entries => Panel::Preview,
Panel::Preview => Panel::Preview,
};
}
KeyCode::Char('j') | KeyCode::Down => {
for _ in 0..count {
match app.focus {
Panel::Collections => app.move_col_down(),
Panel::Entries => app.move_entry_down(),
Panel::Preview => { app.preview_scroll += 1; }
}
}
}
KeyCode::Char('k') | KeyCode::Up => {
for _ in 0..count {
match app.focus {
Panel::Collections => app.move_col_up(),
Panel::Entries => app.move_entry_up(),
Panel::Preview => { app.preview_scroll = app.preview_scroll.saturating_sub(1); }
}
}
}
KeyCode::Char('G') => {
match app.focus {
Panel::Collections => {
let last = app.col_count().saturating_sub(1);
app.col_list_state.select(Some(last));
app.list_state.select(Some(0));
app.apply_filters();
}
Panel::Entries => {
if !app.filtered.is_empty() {
app.list_state.select(Some(app.filtered.len() - 1));
app.update_preview();
}
}
Panel::Preview => { app.preview_scroll = u16::MAX; }
}
}
KeyCode::Char('H') if app.focus == Panel::Entries => {
let visible_height = 10; let cur = app.list_state.selected().unwrap_or(0);
let top = cur.saturating_sub(visible_height);
app.list_state.select(Some(top));
app.update_preview();
}
KeyCode::Char('M') if app.focus == Panel::Entries => {
if !app.filtered.is_empty() {
app.list_state.select(Some(app.filtered.len() / 2));
app.update_preview();
}
}
KeyCode::Char('L') if app.focus == Panel::Entries => {
if !app.filtered.is_empty() {
app.list_state.select(Some(app.filtered.len() - 1));
app.update_preview();
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match app.focus {
Panel::Entries => { for _ in 0..10 { app.move_entry_down(); } }
Panel::Preview => { app.preview_scroll += 10; }
Panel::Collections => { for _ in 0..5 { app.move_col_down(); } }
}
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match app.focus {
Panel::Entries => { for _ in 0..10 { app.move_entry_up(); } }
Panel::Preview => { app.preview_scroll = app.preview_scroll.saturating_sub(10); }
Panel::Collections => { for _ in 0..5 { app.move_col_up(); } }
}
}
KeyCode::Char(' ') if app.focus == Panel::Entries => {
if let Some(entry) = app.selected_entry() {
let key = entry.bibtex_key.clone();
if app.selected_keys.contains(&key) {
app.selected_keys.remove(&key);
} else {
app.selected_keys.insert(key);
}
app.move_entry_down(); }
}
KeyCode::Char('V') if app.focus == Panel::Entries => {
let visible_keys: Vec<String> = app.filtered.iter()
.map(|&idx| app.entries[idx].bibtex_key.clone()).collect();
let all_selected = visible_keys.iter().all(|k| app.selected_keys.contains(k));
if all_selected {
for k in &visible_keys { app.selected_keys.remove(k); }
} else {
for k in visible_keys { app.selected_keys.insert(k); }
}
}
KeyCode::Esc => {
if !app.selected_keys.is_empty() {
app.selected_keys.clear();
}
}
KeyCode::Tab => {
app.preview_mode = app.preview_mode.next();
app.preview_scroll = 0;
if app.preview_mode == PreviewMode::Note { app.load_note_for_preview(); }
}
KeyCode::Char('/') => {
if app.focus == Panel::Collections {
app.col_search_query.clear();
}
app.mode = Mode::Search;
}
KeyCode::Char('y') => {
if let Some(entry) = app.selected_entry() {
let bkey = entry.bibtex_key.clone();
if let Ok(mut ctx) = arboard::Clipboard::new() { let _ = ctx.set_text(&bkey); }
app.mode = Mode::Message(format!("Copied: {}", bkey));
}
}
KeyCode::Char('o') => {
if let Some(entry) = app.selected_entry() {
let entry = entry.clone();
if entry.file_path.is_some() {
let full_path = app.config.bibox_dir.join(entry.file_path.as_ref().unwrap());
if full_path.exists() { app.open_pdf(&entry); }
else { app.mode = Mode::Message("PDF file missing from disk.".into()); }
} else if entry.doi.is_some() || entry.url.is_some() {
let bkey = entry.bibtex_key.clone();
app.mode = Mode::Confirm(ConfirmAction::FetchPdf(bkey));
} else { app.mode = Mode::Message("No PDF attached.".into()); }
}
}
KeyCode::Char('w') => {
if let Some(entry) = app.selected_entry() {
let url = if let Some(ref doi) = entry.doi {
Some(format!("https://doi.org/{}", doi))
} else if let Some(ref u) = entry.url {
Some(u.clone())
} else {
None
};
if let Some(url) = url {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&url).spawn();
#[cfg(not(target_os = "macos"))]
let _ = std::process::Command::new("xdg-open").arg(&url).spawn();
} else {
app.mode = Mode::Message("No DOI or URL for this entry.".into());
}
}
}
KeyCode::Char('e') => {
let mut scope_options = vec![];
if !app.selected_keys.is_empty() {
scope_options.push((ExportScope::Selected, format!("{} selected entries", app.selected_keys.len())));
}
if let Some(col) = app.current_collection() {
let count = app.filtered.len();
scope_options.push((ExportScope::Collection, format!("{} collection ({})", col, count)));
}
scope_options.push((ExportScope::All, format!("All entries ({})", app.entries.len())));
app.export_state = Some(ExportState {
scope_options,
scope_idx: 0,
format_idx: 0,
include_pdf: false,
section: 0,
});
app.mode = Mode::ExportMenu;
}
KeyCode::Char('d') => {
if let Some(entry) = app.selected_entry() {
let bkey = entry.bibtex_key.clone();
app.mode = Mode::Confirm(ConfirmAction::Delete(bkey));
}
}
KeyCode::Char('?') => { app.mode = Mode::Help; }
KeyCode::Char('N') => {
if app.selected_entry().is_some() { return open_note_editor(app); }
else { app.mode = Mode::Message("No entry selected.".into()); }
}
KeyCode::Char('s') => {
app.prev_sort_by = app.sort_by;
app.prev_sort_ascending = app.sort_ascending;
app.sort_menu_index = SortCriterion::all().iter().position(|c| *c == app.sort_by).unwrap_or(0);
app.mode = Mode::SortMenu;
}
KeyCode::Char('c') => {
if app.selected_entry().is_some() { app.open_collection_picker(); }
else { app.mode = Mode::Message("No entry selected.".into()); }
}
KeyCode::Char('t') => {
if app.selected_entry().is_some() { app.open_tag_editor(); }
else { app.mode = Mode::Message("No entry selected.".into()); }
}
KeyCode::Char(',') => {
app.settings_idx = 0;
app.git_status_cache = "Press Enter to check".into();
app.git_status_checked = false;
app.mode = Mode::Settings;
}
KeyCode::Enter => {}
_ => {}
}
Ok(false)
}
fn handle_search(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
if app.focus == Panel::Collections {
match key.code {
KeyCode::Esc => { app.col_search_query.clear(); app.mode = Mode::Normal; }
KeyCode::Backspace => { app.col_search_query.pop(); filter_collections(app); }
KeyCode::Char(c) => { app.col_search_query.push(c); filter_collections(app); }
KeyCode::Enter => { app.mode = Mode::Normal; }
_ => {}
}
} else {
match key.code {
KeyCode::Esc => { app.search_query.clear(); app.apply_filters(); app.mode = Mode::Normal; }
KeyCode::Backspace => { app.search_query.pop(); app.apply_filters(); }
KeyCode::Char(c) => { app.search_query.push(c); app.apply_filters(); }
KeyCode::Enter => { app.mode = Mode::Normal; }
_ => {}
}
}
Ok(false)
}
fn filter_collections(app: &mut App) {
let query = app.col_search_query.to_lowercase();
if query.is_empty() {
app.col_list_state.select(Some(0));
return;
}
let col_count = app.col_count();
for i in 0..col_count {
let name = if i == 0 { "all" } else { &app.collections[i - 1] };
if name.to_lowercase().contains(&query) {
app.col_list_state.select(Some(i));
app.apply_filters();
return;
}
}
}
fn handle_confirm(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char('y') => {
let action = std::mem::replace(&mut app.mode, Mode::Normal);
match action {
Mode::Confirm(ConfirmAction::Delete(_)) => {
app.delete_selected()?;
app.mode = Mode::Message("Entry deleted.".to_string());
}
Mode::Confirm(ConfirmAction::FetchPdf(key)) => {
let entry = app.entries.iter().find(|e| e.bibtex_key == key).cloned();
if let Some(entry) = entry {
let bibox_dir = app.config.bibox_dir.clone();
let (tx, rx) = std::sync::mpsc::channel();
let key_clone = key.clone();
std::thread::spawn(move || {
let result = run_fetch_pdf(&entry, &bibox_dir);
let _ = tx.send(result.map(|(filename, full_path)| BgTaskResult {
key: key_clone, file_path: filename, full_path,
}));
});
app.bg_result = Some(rx);
app.spinner_tick = 0;
app.mode = Mode::Loading("Fetching PDF...".into());
}
}
_ => {}
}
}
KeyCode::Char('n') | KeyCode::Esc => { app.mode = Mode::Normal; }
_ => {}
}
Ok(false)
}
fn handle_help(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { app.mode = Mode::Normal; }
_ => {}
}
Ok(false)
}
fn handle_sort_menu(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
let criteria = SortCriterion::all();
match key.code {
KeyCode::Up | KeyCode::Char('k') => { if app.sort_menu_index > 0 { app.sort_menu_index -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if app.sort_menu_index < criteria.len() - 1 { app.sort_menu_index += 1; } }
KeyCode::Char(' ') => {
let selected = criteria[app.sort_menu_index];
if selected == app.sort_by { app.sort_ascending = !app.sort_ascending; }
else { app.sort_by = selected; }
}
KeyCode::Enter => {
let new_criterion = criteria[app.sort_menu_index];
if new_criterion != app.sort_by { app.sort_ascending = new_criterion.default_ascending(); }
app.sort_by = new_criterion;
app.apply_sort();
app.mode = Mode::Normal;
}
KeyCode::Esc => {
app.sort_by = app.prev_sort_by;
app.sort_ascending = app.prev_sort_ascending;
app.mode = Mode::Normal;
}
_ => {}
}
Ok(false)
}
fn handle_picker(app: &mut App, key: crossterm::event::KeyEvent, is_tags: bool) -> Result<bool> {
let in_input = app.picker.as_ref().map(|p| p.in_input_mode()).unwrap_or(false);
if in_input {
match key.code {
KeyCode::Enter => { if let Some(ref mut p) = app.picker { p.confirm_input(); } }
KeyCode::Esc => { if let Some(ref mut p) = app.picker { p.cancel_input(); } }
KeyCode::Backspace => { if let Some(ref mut p) = app.picker { p.backspace(); } }
KeyCode::Char(c) => { if let Some(ref mut p) = app.picker { p.apply_char(c); } }
_ => {}
}
} else {
match key.code {
KeyCode::Up | KeyCode::Char('k') => { if let Some(ref mut p) = app.picker { p.move_up(); } }
KeyCode::Down | KeyCode::Char('j') => { if let Some(ref mut p) = app.picker { p.move_down(); } }
KeyCode::Char(' ') => { if let Some(ref mut p) = app.picker { p.toggle(); } }
KeyCode::Enter => {
if is_tags { app.apply_picker_tags()?; } else { app.apply_picker_collections()?; }
app.mode = Mode::Normal;
}
KeyCode::Esc => { app.picker = None; app.mode = Mode::Normal; }
_ => {}
}
}
Ok(false)
}
fn handle_export_menu(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
let formats = [ExportFormat::BibTeX, ExportFormat::Yaml, ExportFormat::Ris];
if let Some(ref mut es) = app.export_state {
match key.code {
KeyCode::Tab => {
es.section = (es.section + 1) % 3;
}
KeyCode::Up | KeyCode::Char('k') => {
match es.section {
0 => { if es.scope_idx > 0 { es.scope_idx -= 1; } }
1 => { if es.format_idx > 0 { es.format_idx -= 1; } }
_ => {}
}
}
KeyCode::Down | KeyCode::Char('j') => {
match es.section {
0 => { if es.scope_idx < es.scope_options.len() - 1 { es.scope_idx += 1; } }
1 => { if es.format_idx < formats.len() - 1 { es.format_idx += 1; } }
_ => {}
}
}
KeyCode::Char(' ') => {
if es.section == 2 { es.include_pdf = !es.include_pdf; }
}
KeyCode::Enter => {
let scope = es.scope_options[es.scope_idx].0;
let format = formats[es.format_idx];
let include_pdf = es.include_pdf;
let col = app.current_collection().map(|s| s.to_string());
let keys: Vec<String> = match scope {
ExportScope::Selected => app.selected_keys.iter().cloned().collect(),
ExportScope::Collection => {
app.filtered.iter().map(|&i| app.entries[i].bibtex_key.clone()).collect()
}
ExportScope::All => app.entries.iter().map(|e| e.bibtex_key.clone()).collect(),
};
let col_name = match scope {
ExportScope::Selected => "selected".to_string(),
ExportScope::Collection => col.unwrap_or_else(|| "all".to_string()),
ExportScope::All => "all".to_string(),
};
app.export_state = None;
app.mode = Mode::Normal;
let result = crate::commands::cmd_export(
keys, None, None, false, None, None, false, include_pdf, false,
format.ext().to_string(), &app.config,
);
match result {
Ok(()) => { app.mode = Mode::Message("Export complete.".into()); }
Err(e) => { app.mode = Mode::Message(format!("Export failed: {}", e)); }
}
}
KeyCode::Esc => {
app.export_state = None;
app.mode = Mode::Normal;
}
_ => {}
}
}
Ok(false)
}
fn handle_settings(app: &mut App, key: crossterm::event::KeyEvent) -> Result<bool> {
use crate::config::LineNumbers;
let num_settings: usize = 6;
let download_dir = dirs::download_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")));
let home_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let cwd = std::path::PathBuf::from(".");
let dir_presets: Vec<std::path::PathBuf> = vec![
cwd.clone(),
download_dir.clone(),
home_dir.join("Documents"),
home_dir.join("Desktop"),
];
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if app.settings_idx > 0 { app.settings_idx -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if app.settings_idx < num_settings - 1 { app.settings_idx += 1; }
}
KeyCode::Right | KeyCode::Char('l') => {
match app.settings_idx {
0 => {
app.config.line_numbers = match app.config.line_numbers {
LineNumbers::Absolute => LineNumbers::Relative,
LineNumbers::Relative => LineNumbers::None,
LineNumbers::None => LineNumbers::Absolute,
};
}
1 => {
app.config.panel_ratio = match app.config.panel_ratio {
[2, 4, 4] => [1, 5, 4],
[1, 5, 4] => [2, 3, 5],
[2, 3, 5] => [1, 4, 5],
[1, 4, 5] => [3, 4, 3],
_ => [2, 4, 4],
};
}
2 => {
let cur = &app.config.bib_export_dir;
let pos = dir_presets.iter().position(|d| d == cur).unwrap_or(0);
app.config.bib_export_dir = dir_presets[(pos + 1) % dir_presets.len()].clone();
}
3 => {
let cur = &app.config.export_dir;
let pos = dir_presets.iter().position(|d| d == cur).unwrap_or(0);
app.config.export_dir = dir_presets[(pos + 1) % dir_presets.len()].clone();
}
_ => {} }
}
KeyCode::Left | KeyCode::Char('h') => {
match app.settings_idx {
0 => {
app.config.line_numbers = match app.config.line_numbers {
LineNumbers::Absolute => LineNumbers::None,
LineNumbers::Relative => LineNumbers::Absolute,
LineNumbers::None => LineNumbers::Relative,
};
}
1 => {
app.config.panel_ratio = match app.config.panel_ratio {
[2, 4, 4] => [3, 4, 3],
[3, 4, 3] => [1, 4, 5],
[1, 4, 5] => [2, 3, 5],
[2, 3, 5] => [1, 5, 4],
_ => [2, 4, 4],
};
}
2 => {
let cur = &app.config.bib_export_dir;
let pos = dir_presets.iter().position(|d| d == cur).unwrap_or(0);
let prev = if pos == 0 { dir_presets.len() - 1 } else { pos - 1 };
app.config.bib_export_dir = dir_presets[prev].clone();
}
3 => {
let cur = &app.config.export_dir;
let pos = dir_presets.iter().position(|d| d == cur).unwrap_or(0);
let prev = if pos == 0 { dir_presets.len() - 1 } else { pos - 1 };
app.config.export_dir = dir_presets[prev].clone();
}
_ => {}
}
}
KeyCode::Enter => {
if app.settings_idx == 5 {
if let Some(ref home) = app.config.home {
let home = crate::config::expand_tilde(home);
if !app.git_status_checked {
app.git_status_cache = get_git_status(&home);
app.git_status_checked = true;
} else {
let home_str = home.to_string_lossy().to_string();
let result = (|| -> Result<String> {
let pull = std::process::Command::new("git")
.args(["-C", &home_str, "pull", "--rebase"])
.output()?;
if !pull.status.success() {
let err = String::from_utf8_lossy(&pull.stderr);
anyhow::bail!("git pull failed: {}", err.trim());
}
let _ = std::process::Command::new("git")
.args(["-C", &home_str, "add", "."])
.output()?;
let diff = std::process::Command::new("git")
.args(["-C", &home_str, "diff", "--cached", "--quiet"])
.output()?;
if !diff.status.success() {
let _ = std::process::Command::new("git")
.args(["-C", &home_str, "commit", "-m", "bibox sync"])
.output()?;
}
let push = std::process::Command::new("git")
.args(["-C", &home_str, "push"])
.output()?;
if !push.status.success() {
let err = String::from_utf8_lossy(&push.stderr);
anyhow::bail!("git push failed: {}", err.trim());
}
Ok("Sync complete.".into())
})();
app.git_status_cache = get_git_status(&home);
match result {
Ok(msg) => {
let db_path = crate::config::resolve_db_path(&app.config);
if let Ok(db) = load_db(&db_path) {
app.entries = db.entries;
app.rebuild_collections();
app.apply_filters();
}
app.mode = Mode::Message(msg);
}
Err(e) => { app.mode = Mode::Message(format!("Sync failed: {}", e)); }
}
}
} else {
app.mode = Mode::Message("No home set. Run `bibox init <path>` first.".into());
}
} else {
let _ = crate::config::save_config(&app.config);
app.mode = Mode::Message("Settings saved.".into());
}
}
KeyCode::Esc => {
if let Ok(cfg) = crate::config::load_config() {
app.config.line_numbers = cfg.line_numbers;
app.config.panel_ratio = cfg.panel_ratio;
app.config.bib_export_dir = cfg.bib_export_dir;
app.config.export_dir = cfg.export_dir;
}
app.mode = Mode::Normal;
}
_ => {}
}
Ok(false)
}
fn open_note_editor(app: &mut App) -> Result<bool> {
let entry = match app.selected_entry() { Some(e) => e.clone(), None => return Ok(false) };
let notes_dir = &app.config.notes_dir;
std::fs::create_dir_all(notes_dir)?;
let note_path = notes_dir.join(format!("{}.md", entry.bibtex_key));
if !note_path.exists() {
let header = format!("# {}\ncitekey: {}\n\n", entry.title.as_deref().unwrap_or("Untitled"), entry.bibtex_key);
std::fs::write(¬e_path, &header)?;
}
app.pending_editor = Some(note_path);
app.mode = Mode::Normal;
Ok(false)
}
pub fn run_tui(config: &Config) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let config_clone = Config {
home: config.home.clone(),
bibox_dir: config.bibox_dir.clone(),
pdf_viewer: config.pdf_viewer.clone(),
default_collection: config.default_collection.clone(),
search_case_sensitive: config.search_case_sensitive,
default_page_size: config.default_page_size,
language: config.language.clone(),
git: config.git,
notes_dir: config.notes_dir.clone(),
templates_dir: config.templates_dir.clone(),
line_numbers: config.line_numbers.clone(),
panel_ratio: config.panel_ratio,
bib_export_dir: config.bib_export_dir.clone(),
export_dir: config.export_dir.clone(),
msgs: crate::i18n::Msgs::new(&config.language),
};
let mut app = App::new(config_clone)?;
app.apply_filters();
let result = run_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
result
}
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<()> {
loop {
terminal.draw(|f| draw(f, app))?;
if event::poll(std::time::Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
if handle_key(app, key)? { break; }
}
}
if let Some(note_path) = app.pending_editor.take() {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
if std::process::Command::new("which").arg("nano").output()
.map(|o| o.status.success()).unwrap_or(false)
{ "nano".to_string() } else { "vi".to_string() }
});
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
let status = std::process::Command::new(&editor).arg(¬e_path).status();
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
terminal.clear()?;
if let Err(e) = status {
app.mode = Mode::Message(format!("Editor failed: {}", e));
}
app.note_citekey.clear();
}
if let Some(ref rx) = app.bg_result {
match rx.try_recv() {
Ok(Ok(result)) => {
let db_path = crate::config::resolve_db_path(&app.config);
if let Ok(mut db) = load_db(&db_path) {
if let Some(db_entry) = find_by_key_mut(&mut db, &result.key) {
db_entry.file_path = Some(result.file_path.clone());
}
let _ = save_db(&db, &db_path);
}
if let Some(mem_entry) = app.entries.iter_mut().find(|e| e.bibtex_key == result.key) {
mem_entry.file_path = Some(result.file_path);
}
app.bg_result = None;
app.mode = Mode::Message(format!("PDF saved: {}", result.full_path));
}
Ok(Err(e)) => { app.bg_result = None; app.mode = Mode::Message(format!("Fetch failed: {}", e)); }
Err(std::sync::mpsc::TryRecvError::Empty) => { app.spinner_tick += 1; }
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.bg_result = None;
app.mode = Mode::Message("Fetch failed: thread disconnected.".into());
}
}
}
}
Ok(())
}
fn run_fetch_pdf(entry: &Entry, bibox_dir: &std::path::Path) -> Result<(String, String)> {
let rt = tokio::runtime::Runtime::new()?;
let tmp = std::env::temp_dir().join("bibox_download.pdf");
rt.block_on(async {
if let Some(ref doi) = entry.doi {
match crate::unpaywall::find_open_access(doi).await {
Ok(Some(oa)) => { crate::unpaywall::download_pdf(&oa.pdf_url, &tmp).await?; }
Ok(None) => {
if let Some(ref url) = entry.url {
crate::unpaywall::download_pdf(url, &tmp).await?;
} else { anyhow::bail!("No open-access PDF found."); }
}
Err(e) => return Err(e),
}
} else if let Some(ref url) = entry.url {
crate::unpaywall::download_pdf(url, &tmp).await?;
} else { anyhow::bail!("No DOI or URL to fetch from."); }
Ok(())
})?;
std::fs::create_dir_all(bibox_dir)?;
let filename = entry_to_filename(entry);
let dest = bibox_dir.join(&filename);
std::fs::copy(&tmp, &dest)?;
let _ = std::fs::remove_file(&tmp);
Ok((filename, dest.to_string_lossy().to_string()))
}