use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui_image::errors::Errors;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::Protocol;
use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol};
use ratatui_image::{FilterType, Resize};
use tokio::sync::mpsc::UnboundedSender;
use crate::config::Config;
use crate::i18n::tr;
use crate::preview::PreviewKind;
mod bookmark_actions;
mod copy_actions;
mod dialog_actions;
mod file_actions;
mod follow;
mod git_view;
mod md_items;
mod md_media;
mod md_render;
mod md_tasks;
mod md_text;
mod media_load;
mod outline;
mod paste_jump;
mod preview_visual;
mod search;
mod session_actions;
mod tab_lifecycle;
mod table_actions;
use md_text::*;
#[cfg(test)]
pub use preview_visual::PreviewSelection;
use preview_visual::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Tree,
Preview,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayMode {
Tree,
Preview,
Image,
Table,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InternalMode {
Visual,
PreviewVisual,
Filter,
ChangedFilter,
Search,
Sort,
Mark,
Bookmarks,
Tabs,
Outline,
Info,
Create,
Rename,
BatchRename,
RenamePreview,
DeleteConfirm,
DropConfirm,
QuitConfirm,
BookmarkConfirm,
GitChanges,
GitDiff,
Commit,
GitLog,
GitDetail,
GitBranch,
GitGraph,
GitGraphPicker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyScheme {
Vim,
Less,
}
impl KeyScheme {
pub fn parse(s: &str) -> Self {
match s {
"less" => Self::Less,
_ => Self::Vim,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLayout {
Unified,
Split,
Auto,
}
impl DiffLayout {
pub fn parse(s: &str) -> Self {
let n: String = s
.chars()
.filter(|c| !matches!(c, ' ' | '-' | '_'))
.flat_map(|c| c.to_lowercase())
.collect();
match n.as_str() {
"split" | "horizontal" | "sidebyside" | "side" => Self::Split,
"auto" => Self::Auto,
_ => Self::Unified, }
}
pub fn is_split(self, width: u16) -> bool {
match self {
DiffLayout::Unified => false,
DiffLayout::Split => true,
DiffLayout::Auto => width >= DIFF_AUTO_MIN_WIDTH,
}
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn next(self) -> Self {
match self {
DiffLayout::Unified => DiffLayout::Split,
DiffLayout::Split => DiffLayout::Auto,
DiffLayout::Auto => DiffLayout::Unified,
}
}
}
const DIFF_AUTO_MIN_WIDTH: u16 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusbarLayout {
Split,
Bottom,
}
impl StatusbarLayout {
pub fn parse(s: &str) -> Self {
match s {
"bottom" => Self::Bottom,
_ => Self::Split,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathStyle {
Relative,
Home,
Full,
}
impl PathStyle {
pub fn parse(s: &str) -> Self {
match s {
"home" => Self::Home,
"full" => Self::Full,
_ => Self::Relative,
}
}
pub fn next(self) -> Self {
match self {
Self::Relative => Self::Home,
Self::Home => Self::Full,
Self::Full => Self::Relative,
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Name,
Size,
Modified,
Ext,
}
#[derive(Clone, Copy)]
pub struct Sort {
pub key: SortKey,
pub reverse: bool,
pub dirs_first: bool,
}
impl Default for Sort {
fn default() -> Self {
Self {
key: SortKey::Name,
reverse: false,
dirs_first: true,
}
}
}
impl SortKey {
pub fn parse(s: &str) -> Self {
match s.trim().to_lowercase().as_str() {
"size" => SortKey::Size,
"modified" | "mtime" | "mod" => SortKey::Modified,
"ext" | "extension" | "type" => SortKey::Ext,
_ => SortKey::Name,
}
}
}
impl Sort {
pub fn from_config(c: &crate::config::SortConfig) -> Self {
Self {
key: SortKey::parse(&c.key),
reverse: c.reverse,
dirs_first: c.dirs_first,
}
}
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
#[derive(Clone)]
enum PendingOp {
Create { dir: PathBuf },
Rename { target: PathBuf },
Delete { targets: Vec<PathBuf> },
BatchRenameInput { targets: Vec<PathBuf> },
BatchRenameApply { plan: Vec<(PathBuf, PathBuf)> },
GitDiscard { path: PathBuf },
GitCommit,
GitCreateBranch,
GitDeleteBranch { name: String },
DropTransfer { sources: Vec<PathBuf>, dir: PathBuf },
Quit,
BookmarkOverwrite { key: char, target: PathBuf },
}
struct Dialog {
op: PendingOp,
kind: DialogKind,
}
enum DialogKind {
Confirm {
message: String,
allow_permanent: bool,
},
Input {
title: String,
buffer: String,
cursor: usize,
},
Preview {
title: String,
lines: Vec<String>,
scroll: usize,
},
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ClipOp {
Copy,
Cut,
}
#[derive(Clone)]
struct Clipboard {
op: ClipOp,
paths: Vec<PathBuf>,
}
#[cfg(test)]
pub fn sizeof_app() -> usize {
std::mem::size_of::<App>()
}
#[cfg(test)]
pub fn sizeof_pertab() -> usize {
std::mem::size_of::<PerTab>()
}
pub enum MediaPayload {
Static(image::DynamicImage),
Gif(Vec<(image::DynamicImage, std::time::Duration)>),
Vector {
img: image::DynamicImage,
svg: std::sync::Arc<Vec<u8>>,
},
}
pub struct MediaResult {
gen: u64,
payload: Option<MediaPayload>,
}
type KittyGeom = ((u32, u32, u32, u32), u16, u16);
pub struct KittyResult {
gen: u64,
image: Option<crate::preview::kitty::KittyImage>,
}
enum MediaJob {
Svg(PathBuf, u32),
Gif(PathBuf),
Video(PathBuf),
Pdf(PathBuf, u32),
Mermaid(PathBuf, u32, String),
MermaidSrc(String, u32, String),
SvgReraster(std::sync::Arc<Vec<u8>>, PathBuf, u32),
}
impl MediaJob {
fn run(self) -> Option<MediaPayload> {
match self {
MediaJob::Svg(p, max_px) => {
let data = std::fs::read(&p).ok()?;
let img = crate::preview::svg::rasterize_bytes(&data, &p, max_px)?;
Some(MediaPayload::Vector {
img,
svg: std::sync::Arc::new(data),
})
}
MediaJob::Gif(p) => match crate::preview::image::decode_gif(&p) {
Some(frames) => Some(MediaPayload::Gif(frames)),
None => crate::preview::image::decode_static(&p).map(MediaPayload::Static),
},
MediaJob::Video(p) => crate::preview::video::thumbnail(&p).map(MediaPayload::Static),
MediaJob::Pdf(p, page) => {
crate::preview::pdf::render_page(&p, page).map(MediaPayload::Static)
}
MediaJob::Mermaid(p, max_px, theme) => {
let code = std::fs::read_to_string(&p).ok()?;
MediaJob::MermaidSrc(code, max_px, theme).run()
}
MediaJob::MermaidSrc(code, max_px, theme) => {
let svg = crate::preview::markdown::mermaid_to_svg(&code, &theme)?;
let data = svg.into_bytes();
let img =
crate::preview::svg::rasterize_bytes(&data, Path::new("mermaid.svg"), max_px)?;
Some(MediaPayload::Vector {
img,
svg: std::sync::Arc::new(data),
})
}
MediaJob::SvgReraster(svg, p, max_px) => {
let img = crate::preview::svg::rasterize_bytes(&svg, &p, max_px)?;
Some(MediaPayload::Vector { img, svg })
}
}
}
}
pub struct IgnoredResult {
gen: u64,
workdir: PathBuf,
set: std::collections::HashSet<PathBuf>,
}
struct MediaCache {
path: PathBuf,
mtime: Option<std::time::SystemTime>,
len: Option<u64>,
page: u32,
fence_ord: Option<usize>,
src: std::sync::Arc<image::DynamicImage>,
logical: Option<(u32, u32)>,
vector_svg: Option<std::sync::Arc<Vec<u8>>>,
}
pub struct StatusResult {
gen: u64,
workdir: Option<PathBuf>,
statuses: std::collections::HashMap<PathBuf, crate::git::FileStatus>,
branch: Option<String>,
}
pub struct App {
launch_dir: PathBuf,
pub path_style: PathStyle,
pub key_scheme: KeyScheme,
pub lang: crate::i18n::Lang,
pub cfg: Config,
pub sort: Sort,
sort_menu: bool,
pub bookmarks: crate::bookmarks::Bookmarks,
session_store: Option<crate::session::SessionStore>,
session_restoring: bool,
mark_set_pending: bool,
bookmark_list: bool,
bookmark_list_sel: usize,
dialog: Option<Dialog>,
clipboard: Option<Clipboard>,
follow_mode: bool,
follow_session: Vec<PathBuf>,
diff_follow_scope: bool,
follow_diff_full: bool,
#[cfg(feature = "git")]
follow_baseline: Option<FollowBaseline>,
pub md_view_rows: usize,
preview_win: Option<crate::preview::window::FileWindow>,
preview_total_lines: Option<usize>,
win_cache: Option<WinCache>,
hl_pending: bool,
hl_warming: bool,
spinner_frame: usize,
pending_edit: Option<(PathBuf, Option<usize>)>,
pending_git_tool: bool,
md_cache: Option<MdCache>,
diff_cache: Option<DiffCache>,
gutter_cache: Option<GutterCache>,
md_items: Vec<MdItem>,
details_open: std::collections::HashMap<usize, bool>,
table_search_hits: std::collections::HashSet<(usize, usize)>,
picker: Option<Picker>,
img_tx: Option<UnboundedSender<ResizeRequest>>,
pub image: Option<ThreadProtocol>,
use_kitty: bool,
kitty_is_tmux: bool,
kitty_image: Option<crate::preview::kitty::KittyImage>,
kitty_tx: Option<std::sync::mpsc::Sender<KittyResult>>,
kitty_gen: u64,
kitty_want: Option<KittyGeom>,
kitty_shown: Option<KittyGeom>,
image_src: Option<std::sync::Arc<image::DynamicImage>>,
image_crop: Option<(u32, u32, u32, u32)>,
image_vis_frac: (f64, f64),
vector_svg: Option<std::sync::Arc<Vec<u8>>>,
image_logical: Option<(u32, u32)>,
vector_reraster_inflight: bool,
md_overlay_last: Option<u64>,
md_overlay_seen: Option<u64>,
md_overlay_moved: bool,
preview_media_mtime: Option<std::time::SystemTime>,
media_cache: Option<MediaCache>,
table_data: Option<crate::preview::table::TableData>,
pub(crate) tab: PerTab,
table_viewport_rows: u16,
preview_visual_anchor: Option<(usize, usize)>,
preview_visual_linewise: bool,
gif_frames: Vec<(image::DynamicImage, std::time::Duration)>,
gif_idx: usize,
gif_shown_at: Option<std::time::Instant>,
gif_protocol: Option<Protocol>,
gif_proto_key: Option<(usize, (u32, u32, u32, u32))>,
media_tx: Option<std::sync::mpsc::Sender<MediaResult>>,
md_image_cache: std::collections::HashMap<PathBuf, MdImgEntry>,
detail_cells_cache: std::collections::HashMap<PathBuf, Vec<String>>,
md_img_tx: Option<std::sync::mpsc::Sender<MdImageResult>>,
md_remote_inflight: std::collections::HashSet<String>,
md_remote_failed: std::collections::HashSet<String>,
md_remote_tx: Option<std::sync::mpsc::Sender<RemoteFetch>>,
md_enc_tx: Option<std::sync::mpsc::Sender<MdEncodeRequest>>,
media_gen: u64,
media_loading: bool,
pub keymaps: crate::keymap::KeyMap,
pub pending_leader: Option<crate::keymap::LeaderId>,
pub flash: Option<String>,
pub show_help: bool,
pub help_scroll: u16,
pub show_info: bool,
tabs: Vec<PerTab>,
active_tab: usize,
tab_list: bool,
tab_list_sel: usize,
outline_open: bool,
outline_sel: usize,
git_status: std::collections::HashMap<PathBuf, crate::git::FileStatus>,
git_ignored: std::collections::HashSet<PathBuf>,
git_status_for: Option<PathBuf>,
git_status_workdir: Option<PathBuf>,
git_status_dirty: bool,
git_status_pending: Option<PathBuf>,
git_status_gen: u64,
status_tx: Option<std::sync::mpsc::Sender<StatusResult>>,
git_ignored_for: Option<PathBuf>,
git_ignored_pending: Option<PathBuf>,
git_ignored_gen: u64,
git_ignored_dirty: bool,
ignored_tx: Option<std::sync::mpsc::Sender<IgnoredResult>>,
diff_layout: DiffLayout,
git_branch: Option<String>,
git_graph_picker: bool,
git_graph_picker_sel: usize,
git_graph_picker_set: std::collections::HashSet<String>,
git_graph_reordered: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CopyKind {
Name,
Full,
Relative,
Parent,
AtRef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub enum GitCopyKind {
ShortHash,
FullHash,
Subject,
Message,
Author,
Date,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchTarget {
Windowed,
Table,
Markdown,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableCopyKind {
Cell,
Row,
Column,
}
struct MdCache {
path: PathBuf,
width: u16,
lines: Vec<Line<'static>>,
items: Vec<MdItem>,
images: Vec<crate::preview::markdown::ImagePlacement>,
src_lines: usize,
max_line_cols: usize,
row_prefix: Vec<usize>,
fence_rows: u16,
anchors: Vec<(String, usize)>,
details_states: Vec<bool>,
}
struct DiffCache {
path: PathBuf,
lines: Vec<crate::git::DiffLine>,
}
#[cfg(feature = "git")]
const FOLLOW_BASELINE_FILE_CAP: usize = 5 * 1024 * 1024;
#[cfg(feature = "git")]
const FOLLOW_BASELINE_TOTAL_CAP: usize = 64 * 1024 * 1024;
#[cfg(feature = "git")]
struct FollowBaseline {
dirty: std::collections::HashMap<PathBuf, Option<Vec<u8>>>,
head: Option<String>,
}
struct GutterCache {
path: PathBuf,
marks: std::collections::HashMap<u32, GutterMark>,
}
#[derive(Clone)]
struct MdItem {
line: usize,
kind: MdItemKind,
}
#[derive(Clone)]
enum MdItemKind {
Link { target: String },
Task { state: char },
CodeBlock,
MermaidFence { ordinal: usize },
Details { ordinal: usize },
}
struct WinCache {
path: PathBuf,
byte_top: u64,
height: u16,
styled: bool,
lines: Vec<Line<'static>>,
}
#[derive(Default)]
struct MdImgEntry {
decoded: Option<Arc<image::DynamicImage>>,
protocol: Option<Protocol>,
proto_size: Option<(u16, u16)>,
clip_protocol: Option<Protocol>,
clip_key: Option<(u16, u16, u16, u16)>,
enc_inflight: bool,
failed: bool,
zoom_protocol: Option<Protocol>,
zoom_key: Option<(u16, u16, PxRect)>,
svg: Option<std::sync::Arc<Vec<u8>>>,
layout_px: Option<(u32, u32)>,
reraster_inflight: bool,
}
#[derive(PartialEq)]
enum FenceSharpen {
NotNeeded,
Spawned,
AppliedSync,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum MdEncodeKey {
Full { cols: u16, rows: u16 },
Clip {
cols: u16,
full_rows: u16,
row_off: u16,
vis_rows: u16,
},
Zoom { cols: u16, rows: u16, crop: PxRect },
}
pub struct MdEncodeRequest {
path: PathBuf,
key: MdEncodeKey,
image: Arc<image::DynamicImage>,
crop: Option<PxRect>,
cols: u16,
rows: u16,
}
pub struct MdEncodeResult {
path: PathBuf,
key: MdEncodeKey,
protocol: Option<Protocol>,
}
pub fn md_encode_worker(
picker: Picker,
rx: std::sync::mpsc::Receiver<MdEncodeRequest>,
tx: std::sync::mpsc::Sender<MdEncodeResult>,
) {
while let Ok(req) = rx.recv() {
let (path, key) = (req.path.clone(), req.key);
let picker = &picker; let protocol = crate::preview::markdown::catch_silent(move || {
let img = match req.crop {
Some((x, y, w, h)) => req.image.crop_imm(x, y, w, h),
None => (*req.image).clone(),
};
let size = ratatui::layout::Size::new(req.cols, req.rows);
let resize =
if crate::preview::markdown::is_mermaid_fence_url(&req.path.to_string_lossy()) {
Resize::Scale(Some(FilterType::Lanczos3))
} else {
Resize::Fit(Some(FilterType::Lanczos3))
};
picker.new_protocol(img, size, resize).ok()
})
.flatten();
if tx
.send(MdEncodeResult {
path,
key,
protocol,
})
.is_err()
{
break;
}
}
}
pub struct MdImageResult {
path: PathBuf,
image: Result<image::DynamicImage, String>,
svg: Option<std::sync::Arc<Vec<u8>>>,
reraster: bool,
}
pub struct RemoteFetch {
url: String,
ok: bool,
}
struct DecoratedMarkdown {
lines: Vec<Line<'static>>,
images: Vec<crate::preview::markdown::ImagePlacement>,
remote_urls: Vec<String>,
mermaid_fences: Vec<String>,
math_exprs: Vec<(String, bool)>,
src_lines: usize,
}
#[derive(Clone)]
pub(crate) struct PerTab {
pub(crate) root: PathBuf,
pub(crate) open_dir: PathBuf,
pub(crate) entries: Vec<Entry>,
pub(crate) selected: usize,
pub(crate) show_hidden: bool,
pub(crate) tree_viewport: u16,
pub(crate) mode: Mode,
pub(crate) preview_path: Option<PathBuf>,
pub(crate) preview_kind: Option<PreviewKind>,
pub(crate) preview_scroll: u16,
pub(crate) preview_hscroll: u16,
pub(crate) preview_viewport: u16,
table_cur_row: usize,
table_cur_col: usize,
table_top_row: usize,
table_left_col: usize,
git_view: bool,
git_view_sel: usize,
git_view_entries: Vec<crate::git::ChangeEntry>,
came_from_git_view: bool,
git_log: Option<Vec<crate::git::CommitInfo>>,
git_log_sel: usize,
git_detail: Option<Vec<crate::git::DiffLine>>,
git_detail_meta: Option<crate::git::CommitMeta>,
git_detail_title: Option<String>,
git_detail_scroll: u16,
git_detail_hscroll: u16,
git_detail_viewport: u16,
git_detail_total: usize,
git_branches: Option<Vec<crate::git::BranchInfo>>,
git_branch_sel: usize,
git_branch_filter: String,
git_branch_filtering: bool,
git_graph: Option<Vec<crate::git::GraphRow>>,
git_graph_sel: usize,
git_graph_base: Option<String>,
git_graph_base_label: Option<String>,
git_graph_visible: std::collections::HashSet<String>,
git_graph_legend: Vec<crate::git::LegendEntry>,
git_graph_hidden: usize,
git_graph_order: Vec<String>,
preview_byte_top: u64,
preview_top_line: usize,
preview_cursor_line: usize,
preview_cursor_col: usize,
pub(crate) image_zoom: f64,
image_center: (f64, f64),
pdf_page: u32,
pdf_pages: Option<u32>,
md_raw: bool,
focused_item: Option<usize>,
fence_zoom: f64,
fence_center: (f64, f64),
fence_return: Option<(u16, Option<usize>)>,
selection: BTreeSet<PathBuf>,
visual_anchor: Option<usize>,
tree_filter: Option<String>,
filter_input: Option<String>,
filter_pool: Vec<Entry>,
changed_filter: bool,
preview_search: Option<String>,
search_input: Option<String>,
search_matches: Vec<(u64, usize, usize)>,
search_idx: usize,
}
impl Default for PerTab {
fn default() -> Self {
Self {
root: PathBuf::new(),
open_dir: PathBuf::new(),
entries: Vec::new(),
selected: 0,
show_hidden: false,
tree_viewport: 0,
mode: Mode::Tree,
preview_path: None,
preview_kind: None,
preview_scroll: 0,
preview_hscroll: 0,
preview_viewport: 0,
table_cur_row: 0,
table_cur_col: 0,
table_top_row: 0,
table_left_col: 0,
git_view: false,
git_view_sel: 0,
git_view_entries: Vec::new(),
came_from_git_view: false,
git_log: None,
git_log_sel: 0,
git_detail: None,
git_detail_meta: None,
git_detail_title: None,
git_detail_scroll: 0,
git_detail_hscroll: 0,
git_detail_viewport: 0,
git_detail_total: 0,
git_branches: None,
git_branch_sel: 0,
git_branch_filter: String::new(),
git_branch_filtering: false,
git_graph: None,
git_graph_sel: 0,
git_graph_base: None,
git_graph_base_label: None,
git_graph_visible: std::collections::HashSet::new(),
git_graph_legend: Vec::new(),
git_graph_hidden: 0,
git_graph_order: Vec::new(),
preview_byte_top: 0,
preview_top_line: 0,
preview_cursor_line: 0,
preview_cursor_col: 0,
image_zoom: 1.0,
image_center: (0.5, 0.5),
pdf_page: 1,
pdf_pages: None,
md_raw: false,
focused_item: None,
fence_zoom: 1.0,
fence_center: (0.5, 0.5),
fence_return: None,
selection: BTreeSet::new(),
visual_anchor: None,
tree_filter: None,
filter_input: None,
filter_pool: Vec::new(),
changed_filter: false,
preview_search: None,
search_input: None,
search_matches: Vec::new(),
search_idx: 0,
}
}
}
impl App {
pub fn new(root: PathBuf, cfg: Config) -> Result<Self> {
let path_style = PathStyle::parse(&cfg.ui.path_style);
let key_scheme = KeyScheme::parse(&cfg.ui.keys);
let lang = crate::i18n::Lang::resolve(&cfg.ui.lang);
let sort = Sort::from_config(&cfg.ui.sort);
let diff_layout = DiffLayout::parse(&cfg.git.diff);
let keymaps = crate::keymap::KeyMap::from_config(
crate::keymap::scheme_from_str(&cfg.ui.keys),
&cfg.keys.to_keymap_config(),
);
let mut app = Self {
launch_dir: root.clone(),
path_style,
key_scheme,
lang,
cfg,
sort,
sort_menu: false,
bookmarks: crate::bookmarks::Bookmarks::load(&root),
session_store: None,
session_restoring: false,
mark_set_pending: false,
bookmark_list: false,
bookmark_list_sel: 0,
dialog: None,
clipboard: None,
follow_mode: false,
follow_session: Vec::new(),
diff_follow_scope: false,
follow_diff_full: false,
#[cfg(feature = "git")]
follow_baseline: None,
md_view_rows: 0,
preview_win: None,
preview_total_lines: None,
win_cache: None,
hl_pending: false,
hl_warming: false,
spinner_frame: 0,
pending_edit: None,
pending_git_tool: false,
md_cache: None,
diff_cache: None,
gutter_cache: None,
md_items: Vec::new(),
details_open: std::collections::HashMap::new(),
table_search_hits: std::collections::HashSet::new(),
picker: None,
img_tx: None,
image: None,
use_kitty: false,
kitty_is_tmux: false,
kitty_image: None,
kitty_tx: None,
kitty_gen: 0,
kitty_want: None,
kitty_shown: None,
image_src: None,
image_crop: None,
image_vis_frac: (1.0, 1.0),
vector_svg: None,
image_logical: None,
vector_reraster_inflight: false,
md_overlay_last: None,
md_overlay_seen: None,
md_overlay_moved: false,
preview_media_mtime: None,
media_cache: None,
table_data: None,
tab: PerTab {
root: root.clone(),
open_dir: root.clone(),
..PerTab::default()
},
table_viewport_rows: 0,
preview_visual_anchor: None,
preview_visual_linewise: false,
gif_frames: Vec::new(),
gif_idx: 0,
gif_shown_at: None,
gif_protocol: None,
gif_proto_key: None,
media_tx: None,
md_image_cache: std::collections::HashMap::new(),
detail_cells_cache: std::collections::HashMap::new(),
md_img_tx: None,
md_remote_inflight: std::collections::HashSet::new(),
md_remote_failed: std::collections::HashSet::new(),
md_remote_tx: None,
md_enc_tx: None,
media_gen: 0,
media_loading: false,
keymaps,
pending_leader: None,
flash: None,
show_help: false,
show_info: false,
help_scroll: 0,
tabs: Vec::new(),
active_tab: 0,
tab_list: false,
tab_list_sel: 0,
outline_open: false,
outline_sel: 0,
git_status: std::collections::HashMap::new(),
git_ignored: std::collections::HashSet::new(),
git_status_for: None,
git_status_workdir: None,
git_status_dirty: false,
git_status_pending: None,
git_status_gen: 0,
status_tx: None,
git_ignored_for: None,
git_ignored_pending: None,
git_ignored_gen: 0,
git_ignored_dirty: false,
ignored_tx: None,
diff_layout,
git_branch: None,
git_graph_picker: false,
git_graph_picker_sel: 0,
git_graph_picker_set: std::collections::HashSet::new(),
git_graph_reordered: false,
};
app.rebuild_tree()?;
app.tabs.push(app.snapshot_tab());
Ok(app)
}
pub fn attach_session_store(&mut self, store: crate::session::SessionStore) {
self.session_store = Some(store);
}
pub fn keymap_report(&self) -> Option<String> {
let nc = self.keymaps.conflicts.len();
let nw = self.keymaps.warnings.len();
if nc == 0 && nw == 0 {
return None;
}
let mut parts: Vec<String> = Vec::new();
if nc > 0 {
parts.push(match self.lang {
crate::i18n::Lang::Jp => format!("キー衝突{nc}件(既定で継続)"),
crate::i18n::Lang::En => format!("{nc} key conflict(s) (using defaults)"),
});
}
if nw > 0 {
parts.push(match self.lang {
crate::i18n::Lang::Jp => format!("無効な設定{nw}件を無視"),
crate::i18n::Lang::En => format!("{nw} invalid key setting(s) ignored"),
});
}
let head = tr(self.lang, crate::i18n::Msg::Keymap);
Some(format!("{head}: {}", parts.join(" / ")))
}
pub fn rebuild_tree(&mut self) -> Result<()> {
let mut out = Vec::new();
let expanded_dirs: std::collections::HashSet<PathBuf> = self
.tab
.entries
.iter()
.filter(|e| e.is_dir && e.expanded)
.map(|e| e.path.clone())
.collect();
build_dir(
&self.tab.root,
0,
&expanded_dirs,
self.tab.show_hidden,
self.sort,
&mut out,
)?;
self.tab.entries = out;
if self.tab.selected >= self.tab.entries.len() {
self.tab.selected = self.tab.entries.len().saturating_sub(1);
}
self.tab.visual_anchor = None;
self.detail_cells_cache.clear();
Ok(())
}
pub fn ensure_detail_cells(&mut self, rows: &[(PathBuf, bool)], cols: &[String]) {
for (path, is_dir) in rows {
if self.detail_cells_cache.contains_key(path) {
continue;
}
let meta = crate::fileops::quick_meta(path).unwrap_or(crate::fileops::RowMeta {
is_dir: *is_dir,
is_symlink: false,
size: 0,
mtime: None,
mode: 0,
});
let cells: Vec<String> = cols
.iter()
.map(|id| crate::fileops::detail_cell(id, path, &meta).unwrap_or_default())
.collect();
self.detail_cells_cache.insert(path.clone(), cells);
}
}
pub fn detail_cells_get(&self, path: &Path) -> Option<&Vec<String>> {
self.detail_cells_cache.get(path)
}
fn rebuild_tree_notify(&mut self) -> bool {
if let Err(e) = self.rebuild_tree() {
self.flash = Some(format!(
"{}{e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::OperationFailed)
));
false
} else {
true
}
}
pub fn is_sort_menu(&self) -> bool {
self.sort_menu
}
pub fn open_sort_menu(&mut self) {
self.sort_menu = true;
}
pub fn close_sort_menu(&mut self) {
self.sort_menu = false;
}
pub fn sort_menu_key(&mut self, c: char) -> Result<()> {
let keep_open = match c {
'n' => {
self.sort.key = SortKey::Name;
false
}
's' => {
self.sort.key = SortKey::Size;
false
}
'm' => {
self.sort.key = SortKey::Modified;
false
}
'e' => {
self.sort.key = SortKey::Ext;
false
}
'r' => {
self.sort.reverse = !self.sort.reverse;
true
}
'.' => {
self.sort.dirs_first = !self.sort.dirs_first;
true
}
_ => {
self.sort_menu = false;
return Ok(());
}
};
if self.tab.tree_filter.is_none() {
self.rebuild_tree()?;
}
self.sort_menu = keep_open;
Ok(())
}
pub fn sort_label(&self) -> String {
let k = match self.sort.key {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "mod",
SortKey::Ext => "ext",
};
let arrow = if self.sort.reverse { "↓" } else { "↑" };
format!("sort: {k} {arrow}")
}
pub fn display_mode(&self) -> DisplayMode {
match self.tab.mode {
Mode::Tree => DisplayMode::Tree,
Mode::Preview => {
if self.is_image_preview() {
DisplayMode::Image
} else if self.is_table_preview() {
DisplayMode::Table
} else {
DisplayMode::Preview
}
}
}
}
pub fn internal_mode(&self) -> Option<InternalMode> {
if let Some(d) = &self.dialog {
return Some(match &d.kind {
DialogKind::Confirm { .. } if self.confirm_is_quit() => InternalMode::QuitConfirm,
DialogKind::Confirm { .. } if self.confirm_is_bookmark() => {
InternalMode::BookmarkConfirm
}
DialogKind::Confirm { .. } if self.confirm_is_drop() => InternalMode::DropConfirm,
DialogKind::Confirm { .. } => InternalMode::DeleteConfirm,
DialogKind::Preview { .. } => InternalMode::RenamePreview,
DialogKind::Input { .. } => match &d.op {
PendingOp::Create { .. } => InternalMode::Create,
PendingOp::BatchRenameInput { .. } => InternalMode::BatchRename,
PendingOp::GitCommit => InternalMode::Commit,
PendingOp::GitCreateBranch => InternalMode::GitBranch,
_ => InternalMode::Rename,
},
});
}
if self.is_git_detail() {
return Some(InternalMode::GitDetail);
}
if self.is_git_log() {
return Some(InternalMode::GitLog);
}
if self.is_git_graph_picker() {
return Some(InternalMode::GitGraphPicker);
}
if self.is_git_graph() {
return Some(InternalMode::GitGraph);
}
if self.is_git_branches() {
return Some(InternalMode::GitBranch);
}
if self.is_git_view() {
return Some(InternalMode::GitChanges);
}
if self.is_git_diff_preview() {
return Some(InternalMode::GitDiff);
}
if self.is_filtering() {
return Some(InternalMode::Filter);
}
if self.is_searching() {
return Some(InternalMode::Search);
}
if self.is_sort_menu() {
return Some(InternalMode::Sort);
}
if self.is_marking() {
return Some(InternalMode::Mark);
}
if self.is_bookmark_list() {
return Some(InternalMode::Bookmarks);
}
if self.is_tab_list() {
return Some(InternalMode::Tabs);
}
if self.is_outline() {
return Some(InternalMode::Outline);
}
if self.is_info() {
return Some(InternalMode::Info);
}
if self.is_visual() {
return Some(InternalMode::Visual);
}
if self.is_preview_visual() {
return Some(InternalMode::PreviewVisual);
}
if self.tab.changed_filter && matches!(self.tab.mode, Mode::Tree) {
return Some(InternalMode::ChangedFilter);
}
None
}
pub fn surface(&self) -> crate::keymap::Surface {
use crate::keymap::Surface as S;
if self.is_dialog() {
if self.dialog_is_preview() {
return S::DialogRenamePreview;
}
if self.dialog_is_confirm() {
if self.confirm_is_quit() {
return S::DialogConfirmQuit;
}
if self.confirm_is_bookmark() {
return S::DialogConfirmBookmark;
}
if self.confirm_is_drop() {
return S::DialogConfirmDrop;
}
return S::DialogConfirmDelete;
}
return S::DialogInput;
}
if self.show_help {
return S::Help;
}
#[cfg(feature = "git")]
{
if self.is_git_detail() {
return S::GitDetail;
}
if self.is_git_log() {
return S::GitLog;
}
if self.is_git_graph_picker() {
return S::GitGraphPicker;
}
if self.is_git_graph() {
return S::GitGraph;
}
if self.is_git_branches() {
return if self.git_branch_filtering() {
S::BranchFilter
} else {
S::GitBranches
};
}
if self.is_git_view() {
return S::GitChanges;
}
if self.is_git_diff_preview() {
return S::PreviewGitDiff;
}
}
if self.is_filtering() {
return S::Filter;
}
if self.is_searching() {
return S::Search;
}
if self.is_sort_menu() {
return S::Sort;
}
if self.is_marking() {
return S::Mark;
}
if self.is_bookmark_list() {
return S::Bookmarks;
}
if self.is_tab_list() {
return S::Tabs;
}
if self.is_outline() {
return S::Outline;
}
if self.is_info() {
return S::Info;
}
if self.is_visual() {
return S::Visual;
}
match self.tab.mode {
Mode::Preview => {
if self.is_image_preview() {
S::PreviewImage
} else if self.is_table_preview() {
S::PreviewTable
} else if self.is_preview_visual() {
S::PreviewTextVisual
} else {
S::PreviewText
}
}
Mode::Tree => S::Tree,
}
}
pub fn clipboard_label(&self) -> Option<String> {
self.clipboard.as_ref().map(|c| {
let verb = match c.op {
ClipOp::Copy => crate::i18n::tr(self.lang, crate::i18n::Msg::CopyHint),
ClipOp::Cut => crate::i18n::tr(self.lang, crate::i18n::Msg::CutHint),
};
format!("{verb} {}", c.paths.len())
})
}
pub fn copy_selection(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let n = targets.len();
self.clipboard = Some(Clipboard {
op: ClipOp::Copy,
paths: targets,
});
self.clear_selection();
self.flash = Some(format!(
"{} ({n})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Copied)
));
}
pub fn cut_selection(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let n = targets.len();
self.clipboard = Some(Clipboard {
op: ClipOp::Cut,
paths: targets,
});
self.clear_selection();
self.flash = Some(format!(
"{} ({n})",
crate::i18n::tr(self.lang, crate::i18n::Msg::CutDone)
));
}
pub fn paste(&mut self) -> Result<()> {
let Some(clip) = self.clipboard.clone() else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::ClipboardEmpty).into());
return Ok(());
};
let dir = self.op_base_dir();
let (mut ok, mut last, mut err) = (0usize, None, None);
for src in &clip.paths {
if dir.starts_with(src) {
err = Some(
crate::i18n::tr(self.lang, crate::i18n::Msg::CannotPasteIntoSelf).to_string(),
);
continue;
}
let res = match clip.op {
ClipOp::Copy => crate::fileops::copy_into(&dir, src),
ClipOp::Cut => crate::fileops::move_into(&dir, src),
};
match res {
Ok(p) => {
ok += 1;
last = Some(p);
}
Err(e) => err = Some(e.to_string()),
}
}
if matches!(clip.op, ClipOp::Cut) {
self.clipboard = None; }
self.refresh()?;
if let Some(p) = &last {
self.reveal_and_select(p)?;
}
self.flash = Some(match err {
Some(e) => format!(
"{} {ok} / {}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Pasted),
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed),
),
None => format!(
"{} ({ok})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Pasted)
),
});
Ok(())
}
pub fn duplicate_selection(&mut self) -> Result<()> {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return Ok(());
}
let (mut ok, mut last, mut err) = (0usize, None, None);
for src in &targets {
let Some(dir) = src.parent() else {
err =
Some(crate::i18n::tr(self.lang, crate::i18n::Msg::OperationFailed).to_string());
continue;
};
match crate::fileops::copy_into(dir, src) {
Ok(p) => {
ok += 1;
last = Some(p);
}
Err(e) => err = Some(e.to_string()),
}
}
self.clear_selection();
self.refresh()?;
if let Some(p) = &last {
self.reveal_and_select(p)?;
}
self.flash = Some(match err {
Some(e) => format!(
"{} {ok} / {}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Duplicated),
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
),
None => format!(
"{} ({ok})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Duplicated)
),
});
Ok(())
}
fn selection_in_display_order(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self
.tab
.entries
.iter()
.filter(|e| self.tab.selection.contains(&e.path))
.map(|e| e.path.clone())
.collect();
for p in &self.tab.selection {
if !v.contains(p) {
v.push(p.clone());
}
}
v
}
fn batch_rename_title(&self, count: usize) -> String {
format!(
"{} {} {} {{n}} {{n:0W}} {{name}} {{ext}}",
crate::i18n::tr(self.lang, crate::i18n::Msg::BatchRename),
count,
crate::i18n::tr(self.lang, crate::i18n::Msg::Items),
)
}
pub fn start_batch_rename(&mut self) {
let targets = self.selection_in_display_order();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let title = self.batch_rename_title(targets.len());
self.dialog = Some(Dialog {
op: PendingOp::BatchRenameInput { targets },
kind: DialogKind::Input {
title,
buffer: String::new(),
cursor: 0,
},
});
}
pub fn start_create(&mut self) {
let dir = self.op_base_dir();
let where_ = self.format_path(&dir);
self.dialog = Some(Dialog {
op: PendingOp::Create { dir },
kind: DialogKind::Input {
title: format!(
"{} in {where_} ({})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Create),
crate::i18n::tr(self.lang, crate::i18n::Msg::TrailingSlashFolder),
),
buffer: String::new(),
cursor: 0,
},
});
}
pub fn start_rename(&mut self) {
let Some(target) = self
.tab
.entries
.get(self.tab.selected)
.map(|e| e.path.clone())
else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
};
let name = target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let cursor = name.chars().count(); self.dialog = Some(Dialog {
op: PendingOp::Rename { target },
kind: DialogKind::Input {
title: crate::i18n::tr(self.lang, crate::i18n::Msg::Rename).into(),
buffer: name,
cursor,
},
});
}
pub fn start_delete(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let label = if targets.len() == 1 {
targets[0]
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
} else {
let first = targets[0]
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
format!(
"{} {} ({first} {})",
targets.len(),
crate::i18n::tr(self.lang, crate::i18n::Msg::Items),
crate::i18n::tr(self.lang, crate::i18n::Msg::Etc),
)
};
self.dialog = Some(Dialog {
op: PendingOp::Delete { targets },
kind: DialogKind::Confirm {
message: format!(
"{}: {label}",
crate::i18n::tr(self.lang, crate::i18n::Msg::DeleteTarget),
),
allow_permanent: true,
},
});
}
pub fn tree_next(&mut self) {
if self.tab.selected + 1 < self.tab.entries.len() {
self.tab.selected += 1;
}
}
pub fn tree_prev(&mut self) {
self.tab.selected = self.tab.selected.saturating_sub(1);
}
pub fn tree_first(&mut self) {
self.tab.selected = 0;
}
pub fn tree_last(&mut self) {
self.tab.selected = self.tab.entries.len().saturating_sub(1);
}
pub fn tree_page(&mut self, dir: i32) {
let page = self.tab.tree_viewport.saturating_sub(1).max(1) as i32;
self.tree_move(dir * page);
}
pub fn tree_half_page(&mut self, dir: i32) {
let half = (self.tab.tree_viewport / 2).max(1) as i32;
self.tree_move(dir * half);
}
fn tree_move(&mut self, delta: i32) {
self.tab.selected = clamp_cursor(self.tab.selected, delta, self.tab.entries.len());
}
pub fn tree_activate(&mut self) -> Result<()> {
if self.tab.tree_filter.is_some() || self.tab.changed_filter {
return self.tree_descend();
}
let Some(entry) = self.tab.entries.get(self.tab.selected).cloned() else {
return Ok(());
};
if entry.is_dir {
if let Some(e) = self.tab.entries.get_mut(self.tab.selected) {
e.expanded = !e.expanded;
}
self.rebuild_tree()?;
} else {
self.enter_preview(&entry.path);
}
Ok(())
}
fn clear_for_root_change(&mut self) {
self.tab.selection.clear();
self.tab.visual_anchor = None;
self.clear_filter_state();
self.search_clear();
}
pub fn tree_leave(&mut self) -> Result<()> {
if self.tab.tree_filter.is_some() {
self.filter_clear();
return Ok(());
}
if self.tab.changed_filter {
self.toggle_changed_filter(); return Ok(());
}
if let Some(parent) = self.tab.root.parent().map(Path::to_path_buf) {
self.clear_for_root_change();
self.tab.root = parent;
self.tab.entries.clear();
self.tab.selected = 0;
self.rebuild_tree()?;
}
Ok(())
}
pub fn tree_descend(&mut self) -> Result<()> {
let Some(entry) = self.tab.entries.get(self.tab.selected).cloned() else {
return Ok(());
};
let was_filtering = self.tab.tree_filter.is_some();
if entry.is_dir {
self.clear_for_root_change();
self.tab.root = entry.path;
self.tab.entries.clear();
self.tab.selected = 0;
self.rebuild_tree()?;
} else {
let _ = was_filtering;
self.enter_preview(&entry.path);
}
Ok(())
}
pub fn toggle_hidden(&mut self) -> Result<()> {
self.tab.show_hidden = !self.tab.show_hidden;
self.rebuild_tree()
}
pub(super) fn compute_gitdiff_lines(
&self,
path: &Path,
follow: bool,
) -> Vec<crate::git::DiffLine> {
#[cfg(feature = "git")]
if follow && !self.follow_diff_full {
if let Some(lines) = self.follow_baseline_diff(path) {
return lines;
}
}
let _ = follow;
crate::git::file_diff(&self.tab.root, path)
}
fn enter_preview(&mut self, path: &Path) {
let same_file = self.tab.preview_path.as_deref() == Some(path);
self.tab.preview_path = Some(path.to_path_buf());
let kind = self.cfg.resolve_preview(path);
self.tab.preview_scroll = 0;
self.tab.preview_hscroll = 0;
self.tab.preview_byte_top = 0;
self.tab.preview_top_line = 0;
self.clear_image();
if matches!(kind, PreviewKind::Pdf(_)) {
self.tab.pdf_pages = crate::preview::pdf::page_count(path);
}
self.start_media_load(&kind, path);
self.tab.preview_kind = Some(kind);
self.tab.fence_return = None; self.tab.fence_zoom = 1.0;
self.tab.fence_center = (0.5, 0.5);
self.tab.md_raw = false; self.md_cache = None; self.md_items.clear();
if !same_file {
self.md_image_cache.clear(); }
self.tab.focused_item = None;
self.outline_open = false; self.details_open.clear(); self.tab.preview_search = None;
self.tab.search_input = None;
self.tab.search_matches.clear();
self.table_search_hits.clear();
self.tab.search_idx = 0;
self.setup_windowed(); self.tab.preview_cursor_line = 0;
self.tab.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
self.tab.table_cur_row = 0;
self.tab.table_cur_col = 0;
self.tab.table_top_row = 0;
self.tab.table_left_col = 0;
self.load_table();
self.tab.mode = Mode::Preview;
}
fn setup_windowed(&mut self) {
self.preview_win = None;
self.win_cache = None;
self.preview_total_lines = None;
self.hl_pending = self.cfg.ui.syntax_highlight
&& matches!(self.tab.preview_kind, Some(PreviewKind::Code(_)))
&& !crate::preview::code::is_ext_warm(self.current_preview_ext());
self.hl_warming = false;
let windowed_kind = matches!(
self.tab.preview_kind,
Some(PreviewKind::Code(_)) | Some(PreviewKind::Text(_))
) || self.is_raw_source();
if !windowed_kind {
return;
}
let Some(path) = self.tab.preview_path.clone() else {
return;
};
if let Ok(w) = crate::preview::window::FileWindow::open(&path) {
self.preview_win = Some(w);
}
}
pub fn current_preview_ext(&self) -> &str {
self.tab
.preview_path
.as_deref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or("")
}
pub fn is_highlight_pending(&self) -> bool {
self.hl_pending
}
pub fn loading_is_indicator(&self) -> bool {
self.cfg.ui.preview_loading != "progressive"
}
pub fn clear_highlight_pending(&mut self) {
self.hl_pending = false;
self.hl_warming = false;
}
pub fn take_warm_job(&mut self) -> Option<(String, PathBuf)> {
if self.hl_pending && !self.hl_warming {
if let Some(path) = self.tab.preview_path.clone() {
self.hl_warming = true;
return Some((self.current_preview_ext().to_string(), path));
}
}
None
}
pub fn busy_jobs(&self) -> Vec<crate::i18n::Msg> {
let mut v = Vec::new();
if self.git_ignored_pending.is_some() || self.git_status_pending.is_some() {
v.push(crate::i18n::Msg::BusyGitScan);
}
if self.media_loading {
v.push(crate::i18n::Msg::BusyMedia);
}
if self.hl_pending || self.hl_warming {
v.push(crate::i18n::Msg::BusyHighlight);
}
if self.md_images_loading() {
v.push(crate::i18n::Msg::BusyImages);
}
v
}
pub fn busy_indicator_active(&self) -> bool {
self.cfg.ui.busy_indicator && !self.busy_jobs().is_empty()
}
pub fn tick_spinner(&mut self) {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
}
pub fn spinner_glyph(&self) -> &'static str {
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
FRAMES[self.spinner_frame % FRAMES.len()]
}
pub fn request_edit(&mut self) {
let target = match self.tab.mode {
Mode::Tree => match self.tab.entries.get(self.tab.selected) {
Some(e) if e.is_dir => {
self.flash = Some(tr(self.lang, crate::i18n::Msg::CannotEditDirectory).into());
return;
}
Some(e) => Some(e.path.clone()),
None => None,
},
Mode::Preview => self.tab.preview_path.clone(),
};
match target {
Some(p) => self.pending_edit = Some((p, self.preview_edit_line())),
None => self.flash = Some(tr(self.lang, crate::i18n::Msg::NoFileToEdit).into()),
}
}
fn preview_edit_line(&self) -> Option<usize> {
if self.tab.mode != Mode::Preview {
return None;
}
if self.is_windowed() {
return Some(self.tab.preview_cursor_line + 1);
}
if matches!(self.tab.preview_kind, Some(PreviewKind::Markdown(_))) {
if let Some(c) = &self.md_cache {
let same = self.tab.preview_path.as_ref().is_some_and(|p| *p == c.path);
if same && c.src_lines > 0 && self.md_view_rows > 0 {
let (logical, row) = match self.md_focused_visible_line() {
Some(hit) => hit,
None => (
self.md_top_logical_line().unwrap_or(0),
self.tab.preview_scroll as usize,
),
};
let est = (row * c.src_lines / self.md_view_rows).min(c.src_lines - 1);
return Some(self.md_content_anchor_line(logical, est).unwrap_or(est) + 1);
}
}
}
None
}
fn md_focused_visible_line(&self) -> Option<(usize, usize)> {
let item = self.tab.focused_item.and_then(|i| self.md_items.get(i))?;
let (row, h) = self.md_visual_span(item.line);
let scroll = self.tab.preview_scroll as usize;
let vh = self.tab.preview_viewport.max(1) as usize;
(row < scroll + vh && row + h > scroll).then_some((item.line, row))
}
fn md_top_logical_line(&self) -> Option<usize> {
let c = self.md_cache.as_ref()?;
if c.lines.is_empty() {
return None;
}
let scroll = self.tab.preview_scroll as usize;
if !self.cfg.ui.wrap || c.width == 0 || c.row_prefix.len() != c.lines.len() + 1 {
return Some(scroll.min(c.lines.len() - 1));
}
let i = c
.row_prefix
.partition_point(|&p| p <= scroll)
.saturating_sub(1);
Some(i.min(c.lines.len() - 1))
}
fn md_content_anchor_line(&self, logical: usize, est: usize) -> Option<usize> {
use ratatui::style::Modifier;
let c = self.md_cache.as_ref()?;
let line = c.lines.get(logical)?;
let key = line
.spans
.iter()
.filter(|s| !s.style.add_modifier.contains(Modifier::HIDDEN))
.map(|s| s.content.trim())
.filter(|t| !t.is_empty())
.max_by_key(|t| t.chars().count())?;
if key.chars().count() < 4 {
return None;
}
let content = crate::preview::text::load(c.path.as_path()).ok()?;
let mut best: Option<usize> = None;
let mut best_d = usize::MAX;
for (i, sl) in content.lines.iter().enumerate() {
if sl.contains(key) {
let d = i.abs_diff(est);
if d < best_d {
best_d = d;
best = Some(i);
}
}
}
best
}
pub fn take_pending_edit(&mut self) -> Option<(PathBuf, Option<usize>)> {
self.pending_edit.take()
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn launch_git_tool(&mut self) {
self.pending_git_tool = true;
}
pub fn take_launch_git_tool(&mut self) -> bool {
std::mem::take(&mut self.pending_git_tool)
}
pub fn reload_preview(&mut self) {
self.md_cache = None;
self.win_cache = None;
if matches!(self.tab.mode, Mode::Preview) {
self.setup_windowed();
self.reload_media_if_changed();
if matches!(self.tab.preview_kind, Some(PreviewKind::Table { .. })) {
self.load_table();
self.clamp_table_cursor();
}
}
}
fn reload_media_if_changed(&mut self) {
let is_media = match &self.tab.preview_kind {
Some(kind) => self.kind_loads_media(kind),
None => false,
};
if !is_media {
return;
}
let Some(path) = self.tab.preview_path.clone() else {
return;
};
let Some(kind) = self.tab.preview_kind.clone() else {
return;
};
if file_mtime(&path) == self.preview_media_mtime {
return; }
let (zoom, center, page) = (
self.tab.image_zoom,
self.tab.image_center,
self.tab.pdf_page,
);
self.clear_image(); if matches!(kind, PreviewKind::Pdf(_)) {
self.tab.pdf_pages = crate::preview::pdf::page_count(&path);
self.tab.pdf_page = page.clamp(1, self.tab.pdf_pages.unwrap_or(1).max(1));
}
self.start_media_load(&kind, &path); self.tab.image_zoom = zoom;
self.tab.image_center = center;
}
pub fn out_of_root_watch_dir(&self) -> Option<PathBuf> {
if !matches!(self.tab.mode, Mode::Preview) {
return None;
}
let p = self.tab.preview_path.as_ref()?;
if p.starts_with(&self.tab.root) {
return None; }
p.parent().map(|d| d.to_path_buf())
}
pub fn git_dir_watch(&self) -> Option<PathBuf> {
let wd = crate::git::workdir(&self.tab.root)?;
if self.tab.root == wd {
return None; }
let git = wd.join(".git");
git.is_dir().then_some(git)
}
pub fn is_windowed(&self) -> bool {
self.preview_win.is_some()
}
#[cfg(test)]
pub fn focused_item(&self) -> Option<usize> {
self.tab.focused_item
}
#[cfg(test)]
pub fn md_link_targets(&self) -> Vec<String> {
self.md_items
.iter()
.filter_map(|it| match &it.kind {
MdItemKind::Link { target } => Some(target.clone()),
_ => None,
})
.collect()
}
pub fn is_raw_source(&self) -> bool {
self.tab.md_raw
&& matches!(
self.tab.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_))
)
}
pub fn is_decorated_kind(&self) -> bool {
matches!(
self.tab.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_))
)
}
pub fn is_md_raw(&self) -> bool {
self.tab.md_raw
}
pub fn toggle_md_raw(&mut self) {
if !self.is_decorated_kind() {
return;
}
self.tab.md_raw = !self.tab.md_raw;
self.tab.preview_byte_top = 0;
self.tab.preview_top_line = 0;
self.tab.preview_scroll = 0;
self.tab.preview_hscroll = 0;
self.tab.preview_cursor_line = 0;
self.tab.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
self.md_cache = None;
self.setup_windowed();
}
pub fn preview_jump_file(&mut self, dir: i32) {
if self.tab.mode != Mode::Preview || self.tab.entries.is_empty() {
return;
}
let anchor = self
.tab
.preview_path
.as_ref()
.and_then(|p| self.tab.entries.iter().position(|e| e.path == *p))
.unwrap_or_else(|| self.tab.selected.min(self.tab.entries.len() - 1));
let n = self.tab.entries.len() as i64;
let mut i = anchor as i64;
for _ in 0..n {
i = (i + dir as i64).rem_euclid(n);
if i as usize == anchor {
break; }
if !self.tab.entries[i as usize].is_dir {
self.tab.selected = i as usize;
let path = self.tab.entries[i as usize].path.clone();
self.enter_preview(&path);
return;
}
}
}
pub fn back_to_tree(&mut self) {
if matches!(self.tab.preview_kind, Some(PreviewKind::MermaidFence(_))) {
if let Some(md) = self.tab.preview_path.clone() {
let ret = self.tab.fence_return.take();
self.enter_preview(&md);
if let Some((scroll, focus)) = ret {
self.tab.preview_scroll = scroll;
self.tab.focused_item = focus;
}
return;
}
}
self.tab.mode = Mode::Tree;
self.git_status_for = None;
self.git_status_dirty = true;
self.detail_cells_cache.clear();
self.tab.preview_path = None;
self.tab.preview_kind = None;
self.clear_image(); self.md_cache = None;
self.tab.md_raw = false;
self.preview_win = None;
self.win_cache = None;
self.preview_total_lines = None;
self.hl_pending = false;
self.hl_warming = false;
self.tab.preview_byte_top = 0;
self.tab.preview_top_line = 0;
self.md_items.clear();
self.tab.focused_item = None;
self.tab.preview_search = None;
self.tab.search_input = None;
self.tab.search_matches.clear();
self.table_search_hits.clear();
self.tab.search_idx = 0;
self.tab.came_from_git_view = false;
self.table_data = None;
self.tab.table_cur_row = 0;
self.tab.table_cur_col = 0;
self.tab.table_top_row = 0;
self.tab.table_left_col = 0;
self.tab.preview_cursor_line = 0;
self.tab.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
}
fn stash_media_cache(&mut self) {
if !self.gif_frames.is_empty() {
return;
}
let (Some(path), Some(src)) = (self.tab.preview_path.clone(), self.image_src.clone())
else {
return;
};
const MAX_CACHED_BYTES: usize = 128 * 1024 * 1024;
if src.as_bytes().len() > MAX_CACHED_BYTES {
self.media_cache = None; return;
}
let meta = std::fs::metadata(&path).ok();
self.media_cache = Some(MediaCache {
path,
mtime: self.preview_media_mtime,
len: meta.map(|m| m.len()),
page: self.tab.pdf_page.max(1),
fence_ord: self.preview_fence_ord(),
src,
logical: self.image_logical,
vector_svg: self.vector_svg.clone(),
});
}
fn drop_media_cache_for_active(&mut self) {
let cur = self.tab.preview_path.as_deref();
if matches!((&self.media_cache, cur), (Some(c), Some(p)) if c.path == p) {
self.media_cache = None;
}
if self.image_src.is_some() && cur.is_some() {
self.media_cache = match self.media_cache.take() {
Some(c) if Some(c.path.as_path()) != cur => Some(c),
_ => None,
};
}
}
fn preview_fence_ord(&self) -> Option<usize> {
match &self.tab.preview_kind {
Some(PreviewKind::MermaidFence(ord)) => Some(*ord),
_ => None,
}
}
fn restore_media_cache(&mut self, path: &Path, page: u32) -> bool {
let len = std::fs::metadata(path).ok().map(|m| m.len());
let fence = self.preview_fence_ord();
let hit = matches!(&self.media_cache, Some(c)
if c.path == path
&& c.page == page
&& c.fence_ord == fence
&& c.mtime == file_mtime(path)
&& c.mtime.is_some()
&& c.len == len);
if !hit {
return false;
}
let c = self.media_cache.as_ref().expect("checked above");
self.image_logical = c.logical;
self.vector_svg = c.vector_svg.clone();
self.preview_media_mtime = c.mtime;
self.image_src = Some(c.src.clone());
self.image_crop = None;
true
}
fn kind_loads_media(&self, kind: &PreviewKind) -> bool {
matches!(
kind,
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
) || (matches!(kind, PreviewKind::Mermaid(_) | PreviewKind::MermaidFence(_))
&& self.mermaid_image_mode())
}
fn clear_image(&mut self) {
self.image = None;
self.kitty_image = None;
self.kitty_gen = self.kitty_gen.wrapping_add(1);
self.kitty_want = None;
self.kitty_shown = None;
self.image_src = None;
self.tab.image_zoom = 1.0;
self.tab.image_center = (0.5, 0.5);
self.image_crop = None;
self.image_vis_frac = (1.0, 1.0);
self.vector_svg = None;
self.image_logical = None;
self.gif_frames = Vec::new();
self.gif_idx = 0;
self.gif_shown_at = None;
self.gif_protocol = None;
self.gif_proto_key = None;
self.tab.pdf_page = 1;
self.tab.pdf_pages = None;
self.media_gen = self.media_gen.wrapping_add(1);
self.media_loading = false;
self.vector_reraster_inflight = false;
self.preview_media_mtime = None;
}
pub fn preview_page(&mut self, dir: i32) {
let page = self.tab.preview_viewport.saturating_sub(1).max(1) as i32;
self.preview_scroll(dir * page);
}
pub fn preview_half_page(&mut self, dir: i32) {
let half = (self.tab.preview_viewport / 2).max(1) as i32;
self.preview_scroll(dir * half);
}
pub fn preview_to_top(&mut self) {
if self.is_windowed() {
self.tab.preview_cursor_line = 0;
self.tab.preview_byte_top = 0;
self.tab.preview_top_line = 0;
} else {
self.tab.preview_scroll = 0;
}
}
pub fn preview_to_bottom(&mut self) {
if !self.is_windowed() {
self.tab.preview_scroll = u16::MAX;
return;
}
let vh = self.tab.preview_viewport.max(1) as usize;
let total = self.win_total();
let cur = self.tab.preview_top_line;
if let Some(b) = self
.preview_win
.as_mut()
.map(|w| w.last_page_top(vh).unwrap_or(0))
{
self.tab.preview_byte_top = b;
self.tab.preview_top_line = total.map(|t| t.saturating_sub(vh)).unwrap_or(cur);
}
if let Some(t) = total {
self.tab.preview_cursor_line = t.saturating_sub(1);
}
}
fn win_total(&mut self) -> Option<usize> {
if let Some(t) = self.preview_total_lines {
return Some(t);
}
let t = self.preview_win.as_mut()?.count_lines().ok()?;
self.preview_total_lines = Some(t);
Some(t)
}
pub fn apply_image_resize(&mut self, resp: Result<ResizeResponse, Errors>) -> bool {
let Ok(resp) = resp else {
return false; };
match self.image.as_mut() {
Some(state) => state.update_resized_protocol(resp),
None => false,
}
}
pub fn preview_scroll(&mut self, delta: i32) {
if self.is_windowed() {
self.preview_cursor_move(delta);
return;
}
let next = self.tab.preview_scroll as i32 + delta;
self.tab.preview_scroll = next.max(0) as u16;
}
fn preview_cursor_move(&mut self, delta: i32) {
let total = self.win_total().unwrap_or(usize::MAX);
let maxl = total.saturating_sub(1) as i64;
let next =
(self.tab.preview_cursor_line as i64 + delta as i64).clamp(0, maxl.max(0)) as usize;
self.tab.preview_cursor_line = next;
self.follow_cursor();
}
fn follow_cursor(&mut self) {
let vh = self.tab.preview_viewport.max(1) as usize;
let top = self.tab.preview_top_line;
let cur = self.tab.preview_cursor_line;
if cur < top {
self.win_scroll_lines(-((top - cur) as i32));
} else if cur >= top + vh {
self.win_scroll_lines((cur + 1 - (top + vh)) as i32);
}
}
fn win_scroll_lines(&mut self, delta: i32) {
let vh = self.tab.preview_viewport.max(1) as usize;
let top = self.tab.preview_byte_top;
let line = self.tab.preview_top_line;
let total = if self.cfg.ui.line_numbers || self.preview_total_lines.is_some() {
self.win_total()
} else {
None
};
let result = self.preview_win.as_mut().map(|w| {
if delta > 0 {
let (adv, moved) = w.advance(top, delta as usize).unwrap_or((top, 0));
let maxt = w.last_page_top(vh).unwrap_or(top);
if adv >= maxt {
let bl = total.map(|t| t.saturating_sub(vh)).unwrap_or(line + moved);
(maxt, bl)
} else {
(adv, line + moved)
}
} else if delta < 0 {
let (ret, moved) = w.retreat(top, (-delta) as usize).unwrap_or((0, 0));
(ret, line.saturating_sub(moved))
} else {
(top, line)
}
});
if let Some((nt, nl)) = result {
self.tab.preview_byte_top = nt;
self.tab.preview_top_line = nl;
}
}
pub fn windowed_lines(&mut self, height: u16, width: u16) -> Vec<Line<'static>> {
let _ = width; let h = height.max(1) as usize;
let top0 = self.tab.preview_byte_top;
let maxt = self
.preview_win
.as_mut()
.and_then(|w| w.last_page_top(h).ok());
if let Some(maxt) = maxt {
if top0 > maxt {
self.tab.preview_byte_top = maxt;
if let Some(t) = self.win_total() {
self.tab.preview_top_line = t.saturating_sub(h);
}
}
}
let top = self.tab.preview_byte_top;
let path = self.tab.preview_path.clone().unwrap_or_default();
let syntax_kind = match &self.tab.preview_kind {
Some(PreviewKind::Code(_)) => true,
Some(PreviewKind::Text(p)) => crate::preview::code::has_named_syntax(p),
Some(PreviewKind::Markdown(p)) | Some(PreviewKind::Mermaid(p)) if self.tab.md_raw => {
crate::preview::code::has_named_syntax(p)
}
_ => false,
};
let want_syntax = self.cfg.ui.syntax_highlight
&& syntax_kind
&& (!self.hl_pending || self.loading_is_indicator());
let cacheable = want_syntax || !(self.cfg.ui.syntax_highlight && syntax_kind);
let mut content: Vec<Line<'static>> = if cacheable {
let hit = matches!(
&self.win_cache,
Some(c) if c.path == path && c.byte_top == top && c.height == height
&& c.styled == want_syntax
);
if !hit {
let raw = self
.preview_win
.as_mut()
.and_then(|w| w.read_lines(top, h).ok())
.unwrap_or_default();
let lines = if want_syntax {
let src = raw.join("\n");
crate::preview::code::highlight(&src, &path, &self.cfg.ui.theme.code_theme)
} else {
raw.into_iter().map(Line::from).collect()
};
self.win_cache = Some(WinCache {
path,
byte_top: top,
height,
styled: want_syntax,
lines,
});
}
self.win_cache
.as_ref()
.map(|c| c.lines.clone())
.unwrap_or_default()
} else {
let raw = self
.preview_win
.as_mut()
.and_then(|w| w.read_lines(top, h).ok())
.unwrap_or_default();
raw.into_iter().map(Line::from).collect()
};
content = crate::preview::code::expand_tabs(content, self.cfg.ui.tab_width);
if let Some(q) = self.tab.preview_search.clone() {
let (cur_line, cur_rank) =
match self.tab.search_matches.get(self.tab.search_idx).copied() {
Some((_, line, _)) => {
let rank = self.tab.search_matches[..self.tab.search_idx]
.iter()
.filter(|(_, l, _)| *l == line)
.count();
(Some(line), Some(rank))
}
None => (None, None),
};
let top_line = self.tab.preview_top_line;
content = content
.into_iter()
.enumerate()
.map(|(i, l)| {
let abs = top_line + i;
let rank = if cur_line == Some(abs) {
cur_rank
} else {
None
};
highlight_query_in_line(l, &q, rank)
})
.collect();
}
let top_line = self.tab.preview_top_line;
if self.tab.preview_cursor_line >= top_line
&& self.tab.preview_cursor_line < top_line + content.len()
{
let ln = &content[self.tab.preview_cursor_line - top_line];
let len: usize = ln.spans.iter().map(|s| s.content.chars().count()).sum();
self.tab.preview_cursor_col = self.tab.preview_cursor_col.min(len.saturating_sub(1));
}
let content = apply_preview_caret(
content,
top_line,
self.tab.preview_cursor_line,
self.tab.preview_cursor_col,
self.preview_selection(),
);
let content = if self.cfg.ui.line_numbers {
with_line_numbers(content, top_line)
} else {
content
};
let marks = self.git_gutter_marks();
with_git_gutter(content, top_line, &marks)
}
fn git_gutter_marks(&mut self) -> std::collections::HashMap<u32, GutterMark> {
if !self.cfg.ui.git_gutter {
return std::collections::HashMap::new();
}
let Some(path) = self.tab.preview_path.clone() else {
return std::collections::HashMap::new();
};
let hit = matches!(&self.gutter_cache, Some(c) if c.path == path);
if !hit {
let diff = crate::git::file_diff(&self.tab.root, &path);
let marks = gutter_marks(&diff);
self.gutter_cache = Some(GutterCache {
path: path.clone(),
marks,
});
}
self.gutter_cache
.as_ref()
.map(|c| c.marks.clone())
.unwrap_or_default()
}
pub fn window_progress(&self) -> Option<u16> {
let w = self.preview_win.as_ref()?;
let len = w.len();
if len == 0 {
return Some(0);
}
Some((self.tab.preview_byte_top.min(len) * 100 / len) as u16)
}
pub fn preview_hscroll(&mut self, delta: i32) {
let next = self.tab.preview_hscroll as i32 + delta;
self.tab.preview_hscroll = next.max(0) as u16;
}
pub fn preview_hscroll_home(&mut self) {
self.tab.preview_hscroll = 0;
}
pub fn preview_hscroll_end(&mut self) {
self.tab.preview_hscroll = u16::MAX;
}
pub fn cycle_path_style(&mut self) {
self.path_style = self.path_style.next();
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
self.help_scroll = 0;
}
pub fn toggle_info(&mut self) {
if self.show_info {
self.show_info = false;
} else if self.info_target().is_some() {
self.show_info = true;
} else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
}
}
pub fn is_info(&self) -> bool {
self.show_info
}
pub fn info_target(&self) -> Option<PathBuf> {
match self.tab.mode {
Mode::Tree => self
.tab
.entries
.get(self.tab.selected)
.map(|e| e.path.clone()),
Mode::Preview => self.tab.preview_path.clone(),
}
}
pub fn help_scroll_by(&mut self, delta: i32) {
self.help_scroll = (self.help_scroll as i32 + delta).max(0) as u16;
}
pub fn format_path(&self, path: &Path) -> String {
match self.path_style {
PathStyle::Full => path.display().to_string(),
PathStyle::Home => home_relative(path),
PathStyle::Relative => rel_to_open(&self.tab.open_dir, path),
}
}
}
fn clamp_cursor(cur: usize, delta: i32, len: usize) -> usize {
if len == 0 {
return 0;
}
let last = len.saturating_sub(1) as i64;
(cur as i64).saturating_add(delta as i64).clamp(0, last) as usize
}
fn highlight_query_in_line(
line: Line<'static>,
query: &str,
current_occurrence: Option<usize>,
) -> Line<'static> {
use ratatui::style::{Color, Modifier};
if query.is_empty() {
return line;
}
let full: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let lower = full.to_lowercase();
let q = query.to_lowercase();
if lower.len() != full.len() {
return line; }
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while let Some(rel) = lower[i..].find(&q) {
let s = i + rel;
let e = s + q.len();
ranges.push((s, e));
i = e;
}
if ranges.is_empty() {
return line;
}
let style = line.style;
let mut out: Vec<Span<'static>> = Vec::new();
let mut pos = 0usize; for span in line.spans {
let text = span.content.into_owned();
let span_start = pos;
let span_end = pos + text.len();
let mut points = vec![span_start, span_end];
for (s, e) in &ranges {
if *s > span_start && *s < span_end {
points.push(*s);
}
if *e > span_start && *e < span_end {
points.push(*e);
}
}
points.sort_unstable();
points.dedup();
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
if let Some(seg) = text.get(a - span_start..b - span_start) {
if seg.is_empty() {
continue;
}
let hit = ranges.iter().position(|(s, e)| a >= *s && b <= *e);
let st = if let Some(idx) = hit {
let bg = if current_occurrence == Some(idx) {
Color::Rgb(0xff, 0x8c, 0x00) } else {
Color::Yellow };
span.style
.bg(bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
span.style
};
out.push(Span::styled(seg.to_string(), st));
}
}
pos = span_end;
}
Line::from(out).style(style)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum GutterMark {
Added,
Modified,
Deleted,
}
const GUTTER_ADDED: ratatui::style::Color = ratatui::style::Color::Rgb(87, 171, 90);
const GUTTER_MODIFIED: ratatui::style::Color = ratatui::style::Color::Rgb(216, 166, 74);
const GUTTER_DELETED: ratatui::style::Color = ratatui::style::Color::Rgb(199, 84, 80);
fn gutter_marks(diff: &[crate::git::DiffLine]) -> std::collections::HashMap<u32, GutterMark> {
use crate::git::DiffLineKind;
let mut marks = std::collections::HashMap::new();
let mut i = 0;
while i < diff.len() {
if diff[i].kind == DiffLineKind::Context {
i += 1;
continue;
}
let start = i;
let mut removed = 0usize;
let mut added: Vec<u32> = Vec::new();
while i < diff.len() && diff[i].kind != DiffLineKind::Context {
match diff[i].kind {
DiffLineKind::Removed => removed += 1,
DiffLineKind::Added => {
if let Some(n) = diff[i].new_no {
added.push(n);
}
}
DiffLineKind::Context => {}
}
i += 1;
}
if !added.is_empty() {
let mark = if removed > 0 {
GutterMark::Modified
} else {
GutterMark::Added
};
for n in added {
marks.insert(n, mark);
}
} else if removed > 0 {
let anchor = diff
.get(i)
.and_then(|d| d.new_no)
.or_else(|| diff[..start].iter().rev().find_map(|d| d.new_no));
if let Some(a) = anchor {
marks.entry(a).or_insert(GutterMark::Deleted);
}
}
}
marks
}
fn with_git_gutter(
lines: Vec<Line<'static>>,
top_line: usize,
marks: &std::collections::HashMap<u32, GutterMark>,
) -> Vec<Line<'static>> {
use ratatui::style::{Color, Style};
if marks.is_empty() {
return lines;
}
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let n = (top_line + i + 1) as u32;
let (glyph, color) = match marks.get(&n) {
Some(GutterMark::Added) => ("▌", GUTTER_ADDED),
Some(GutterMark::Modified) => ("▌", GUTTER_MODIFIED),
Some(GutterMark::Deleted) => ("▔", GUTTER_DELETED),
None => (" ", Color::Reset),
};
let style = line.style;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(glyph, Style::new().fg(color)));
spans.extend(line.spans);
Line::from(spans).style(style)
})
.collect()
}
fn with_line_numbers(lines: Vec<Line<'static>>, top_line: usize) -> Vec<Line<'static>> {
use ratatui::style::{Color, Style};
let last = top_line + lines.len().max(1);
let width = last.to_string().len().max(3); lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let n = top_line + i + 1;
let gutter = Span::styled(format!("{n:>width$} "), Style::new().fg(Color::DarkGray));
let style = line.style;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(gutter);
spans.extend(line.spans);
Line::from(spans).style(style)
})
.collect()
}
fn centered_rect(cells: (u16, u16), inner: Rect, allow_upscale: bool) -> Rect {
let (cw, ch) = cells;
if cw == 0 || ch == 0 || inner.width == 0 || inner.height == 0 {
return inner;
}
let mut scale = (inner.width as f64 / cw as f64).min(inner.height as f64 / ch as f64);
if !allow_upscale {
scale = scale.min(1.0);
}
let w = ((cw as f64 * scale).round() as u16).clamp(1, inner.width);
let h = ((ch as f64 * scale).round() as u16).clamp(1, inner.height);
let x = inner.x + (inner.width - w) / 2;
let y = inner.y + (inner.height - h) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
const MD_IMAGE_MAX_ROWS: u16 = 24;
fn resolve_md_image_path(url: &str, base: Option<&Path>) -> Option<PathBuf> {
let u = url.trim();
if u.is_empty() {
return None;
}
if crate::preview::markdown::is_remote_image_url(u) {
let p = md_remote_cache_path(u)?;
return p.is_file().then_some(p);
}
let lower = u.to_ascii_lowercase();
if lower.starts_with("data:") {
return None;
}
let u = u.strip_prefix("file://").unwrap_or(u);
let p = PathBuf::from(u);
let p = if p.is_absolute() {
p
} else if let Some(b) = base {
b.join(p)
} else {
p
};
p.is_file().then_some(p)
}
fn md_image_dims(path: &Path) -> Option<(u32, u32)> {
crate::preview::image::dimensions(path).or_else(|| crate::preview::svg::intrinsic_size(path))
}
fn md_decode_image(path: &Path, svg_max_px: u32) -> Option<image::DynamicImage> {
crate::preview::image::decode_static(path)
.or_else(|| crate::preview::svg::rasterize(path, svg_max_px))
}
fn md_band_pixels(full_rows: u16, row_off: u16, vis_rows: u16, dh: u32) -> (u32, u32) {
let fr = full_rows.max(1) as u32;
let y0 = (row_off as u32 * dh) / fr;
let y1 = ((row_off as u32 + vis_rows as u32).min(fr) * dh) / fr;
(y0, y1.saturating_sub(y0).max(1))
}
fn cache_root() -> Option<PathBuf> {
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
if !x.is_empty() {
return Some(PathBuf::from(x));
}
}
let home = std::env::var_os("HOME")?;
if home.is_empty() {
return None;
}
Some(PathBuf::from(home).join(".cache"))
}
fn md_remote_cache_path(url: &str) -> Option<PathBuf> {
use std::hash::{Hash, Hasher};
let root = cache_root()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.trim().hash(&mut hasher);
Some(
root.join("konoma")
.join("remote-images")
.join(format!("{:016x}", hasher.finish())),
)
}
const MD_REMOTE_MAX_BYTES: u64 = 25 * 1024 * 1024;
fn fetch_remote_image(url: &str, dest: &Path) -> bool {
use std::process::{Command, Stdio};
let Some(parent) = dest.parent() else {
return false;
};
if std::fs::create_dir_all(parent).is_err() {
return false;
}
let tmp = dest.with_extension("part");
let status = Command::new("curl")
.args([
"-sSL", "--fail",
"--max-time",
"20",
"--max-filesize",
&MD_REMOTE_MAX_BYTES.to_string(),
"-A",
"konoma image preview",
"-o",
])
.arg(&tmp)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let downloaded = matches!(status, Ok(s) if s.success());
if !downloaded {
let _ = std::fs::remove_file(&tmp);
return false;
}
if md_image_dims(&tmp).is_none() {
let _ = std::fs::remove_file(&tmp);
return false;
}
let ok = std::fs::rename(&tmp, dest).is_ok();
if ok {
prune_remote_cache(parent, MD_REMOTE_CACHE_MAX);
}
ok
}
fn prune_remote_cache(dir: &Path, keep: usize) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<(std::time::SystemTime, PathBuf)> = rd
.flatten()
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| {
let p = e.path();
if p.extension().is_some_and(|x| x == "part") {
return None;
}
let mtime = e.metadata().and_then(|m| m.modified()).ok()?;
Some((mtime, p))
})
.collect();
if files.len() <= keep {
return;
}
files.sort_by_key(|(t, _)| *t); for (_, p) in files.iter().take(files.len() - keep) {
let _ = std::fs::remove_file(p);
}
}
const MD_REMOTE_CACHE_MAX: usize = 256;
fn md_image_cells(
pw: u32,
ph: u32,
fw: u16,
fh: u16,
avail_cols: u16,
max_rows: u16,
) -> (u16, u16) {
let fw = (fw.max(1)) as f64;
let fh = (fh.max(1)) as f64;
let nat_cols = (pw as f64 / fw).ceil().max(1.0);
let nat_rows = (ph as f64 / fh).ceil().max(1.0);
let avail = avail_cols.max(1) as f64;
let (mut cols, mut rows) = if nat_cols <= avail {
(nat_cols, nat_rows)
} else {
let s = avail / nat_cols;
(avail, (nat_rows * s).round().max(1.0))
};
let maxr = max_rows.max(1) as f64;
if rows > maxr {
let s = maxr / rows;
rows = maxr;
cols = (cols * s).round().max(1.0);
}
(cols as u16, rows as u16)
}
fn mermaid_cells(pw: u32, ph: u32, fw: u16, fh: u16, avail: u16, target_rows: u16) -> (u16, u16) {
let (fw, fh) = (fw.max(1) as f64, fh.max(1) as f64);
let ar = (pw.max(1) as f64) / (ph.max(1) as f64); let mut rows = target_rows.max(1) as f64;
let mut cols = (rows * fh * ar / fw).round().max(1.0);
let avail = avail.max(1) as f64;
if cols > avail {
cols = avail;
rows = (cols * fw / ar / fh).round().max(1.0);
}
(cols as u16, rows as u16)
}
fn math_cells(uw: u32, uh: u32, fw: u16, fh: u16, avail: u16, display: bool) -> (u16, u16) {
let (fw, fh) = (fw.max(1) as f64, fh.max(1) as f64);
let em_h = (uh.max(1) as f64) / 40.0;
let rows_per_em = if display { 1.5 } else { 1.3 };
let mut rows = (em_h * rows_per_em).round().max(1.0);
let ar = (uw.max(1) as f64) / (uh.max(1) as f64);
let mut cols = (rows * fh * ar / fw).round().max(1.0);
let avail = avail.max(1) as f64;
if cols > avail {
cols = avail;
rows = (cols * fw / ar / fh).round().max(1.0);
}
let maxr = 24.0;
if rows > maxr {
let s = maxr / rows;
rows = maxr;
cols = (cols * s).round().max(1.0);
}
(cols as u16, rows as u16)
}
type PxRect = (u32, u32, u32, u32);
fn fence_crop(dims: (u32, u32), f: f64, center: (f64, f64)) -> (PxRect, (f64, f64)) {
let (sw, sh) = dims;
let f = f.clamp(0.0, 1.0);
let cx = center.0.clamp(f / 2.0, 1.0 - f / 2.0);
let cy = center.1.clamp(f / 2.0, 1.0 - f / 2.0);
let cw = ((sw as f64 * f).round() as u32).clamp(1, sw);
let ch = ((sh as f64 * f).round() as u32).clamp(1, sh);
let x0 = ((cx * sw as f64) as i64 - cw as i64 / 2).clamp(0, (sw - cw) as i64) as u32;
let y0 = ((cy * sh as f64) as i64 - ch as i64 / 2).clamp(0, (sh - ch) as i64) as u32;
((x0, y0, cw, ch), (cx, cy))
}
#[derive(Clone, Copy)]
struct ImageLayout {
target: Rect,
crop_rect: PxRect,
center: (f64, f64),
frac: (f64, f64),
}
fn image_layout(
src: &image::DynamicImage,
font_size: ratatui_image::FontSize,
zoom: f64,
center: (f64, f64),
inner: Rect,
render_scale: f64,
logical: Option<(u32, u32)>,
) -> Option<ImageLayout> {
use image::GenericImageView;
let (sw, sh) = src.dimensions();
if sw == 0 || sh == 0 || inner.width == 0 || inner.height == 0 {
return None;
}
let (nw, nh) = match logical {
Some((lw, lh)) if lw > 0 && lh > 0 => (
(lw as u16).div_ceil(font_size.width.max(1)),
(lh as u16).div_ceil(font_size.height.max(1)),
),
_ => {
let n = Resize::natural_size(src, font_size);
(n.width, n.height)
}
};
let base = centered_rect((nw, nh), inner, false);
let disp_w = (base.width as f64 * zoom).max(1.0);
let disp_h = (base.height as f64 * zoom).max(1.0);
let tw = (disp_w.min(inner.width as f64).round() as u16).max(1);
let th = (disp_h.min(inner.height as f64).round() as u16).max(1);
let fw = (tw as f64 / disp_w).min(1.0);
let fh = (th as f64 / disp_h).min(1.0);
let cx = center.0.clamp(fw / 2.0, 1.0 - fw / 2.0);
let cy = center.1.clamp(fh / 2.0, 1.0 - fh / 2.0);
let cw = ((sw as f64 * fw).round() as u32).clamp(1, sw);
let ch = ((sh as f64 * fh).round() as u32).clamp(1, sh);
let x0 = ((cx * sw as f64) as i64 - cw as i64 / 2).clamp(0, (sw - cw) as i64) as u32;
let y0 = ((cy * sh as f64) as i64 - ch as i64 / 2).clamp(0, (sh - ch) as i64) as u32;
let crop_rect = (x0, y0, cw, ch);
let scale = render_scale.clamp(0.1, 1.0);
let (tw, th) = if scale >= 0.999 {
(tw, th)
} else {
(
((tw as f64 * scale).round() as u16).max(1),
((th as f64 * scale).round() as u16).max(1),
)
};
let target = Rect {
x: inner.x + (inner.width - tw.min(inner.width)) / 2,
y: inner.y + (inner.height - th.min(inner.height)) / 2,
width: tw.min(inner.width),
height: th.min(inner.height),
};
Some(ImageLayout {
target,
crop_rect,
center: (cx, cy),
frac: (fw, fh),
})
}
fn file_mtime(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
fn copy_text(path: &Path, open_dir: &Path, kind: CopyKind) -> String {
match kind {
CopyKind::Name => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
CopyKind::Full => path.display().to_string(),
CopyKind::Parent => path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default(),
CopyKind::Relative => rel_to_open(open_dir, path),
CopyKind::AtRef => at_ref_text(open_dir, path),
}
}
fn at_ref_text(open_dir: &Path, path: &Path) -> String {
let rel = match path.strip_prefix(open_dir) {
Ok(r) if !r.as_os_str().is_empty() => r.display().to_string(),
_ => match rel_from(open_dir, path) {
Some(r) if !r.as_os_str().is_empty() => r.display().to_string(),
_ => path.display().to_string(),
},
};
format!("@{rel}")
}
fn rel_to_open(open_dir: &Path, path: &Path) -> String {
if path.starts_with(open_dir) {
let base = open_dir.parent().unwrap_or(open_dir);
match path.strip_prefix(base) {
Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
_ => home_relative(path),
}
} else {
match rel_from(open_dir, path) {
Some(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
_ => home_relative(path),
}
}
}
fn set_clipboard(text: &str) -> Result<()> {
#[cfg(target_os = "linux")]
{
use arboard::SetExtLinux;
let _ = arboard::Clipboard::new()?;
let owned = text.to_string();
std::thread::spawn(move || {
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set().wait().text(owned);
}
});
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
let mut cb = arboard::Clipboard::new()?;
cb.set_text(text.to_string())?;
Ok(())
}
}
pub(crate) fn home_relative(path: &Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if let Ok(rel) = path.strip_prefix(&home) {
if rel.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rel.display());
}
}
path.display().to_string()
}
fn parse_dropped_paths(text: &str) -> Vec<PathBuf> {
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut escaped = false;
for c in text.chars() {
if escaped {
cur.push(c); escaped = false;
} else if c == '\\' {
escaped = true;
} else if c.is_whitespace() {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
.into_iter()
.map(PathBuf::from)
.filter(|p| p.exists()) .collect()
}
fn build_rename_plan(targets: &[PathBuf], template: &str) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut plan: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(targets.len());
for (idx, src) in targets.iter().enumerate() {
let n = idx + 1;
let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let ext = src.extension().and_then(|s| s.to_str()).unwrap_or("");
let rendered = crate::fileops::render_rename_template(template, n, stem, ext);
let rendered = rendered.trim().to_string();
if rendered.is_empty() {
anyhow::bail!("空の名前になります (n={n})");
}
if rendered.contains('/') {
anyhow::bail!("名前に / は使えません: {rendered}");
}
let has_ext = Path::new(&rendered)
.extension()
.filter(|e| !e.is_empty())
.is_some();
let new_name = if !has_ext && !ext.is_empty() {
format!("{rendered}.{ext}")
} else {
rendered
};
let dst = src
.parent()
.unwrap_or_else(|| Path::new("."))
.join(&new_name);
plan.push((src.clone(), dst));
}
let mut seen = BTreeSet::new();
for (_, dst) in &plan {
if !seen.insert(dst.clone()) {
anyhow::bail!("リネーム先が重複: {}", dst.display());
}
}
let vacated: BTreeSet<PathBuf> = plan
.iter()
.filter(|(s, d)| s != d)
.map(|(s, _)| s.clone())
.collect();
for (src, dst) in &plan {
if src == dst {
continue;
}
if dst.exists() && !vacated.contains(dst) {
anyhow::bail!("既に存在します: {}", dst.display());
}
}
Ok(plan)
}
fn char_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn rel_from(base: &Path, target: &Path) -> Option<PathBuf> {
use std::path::Component;
let bc: Vec<Component> = base.components().collect();
let tc: Vec<Component> = target.components().collect();
let mut i = 0;
while i < bc.len() && i < tc.len() && bc[i] == tc[i] {
i += 1;
}
if i == 0 {
let is_root = |c: Option<&Component>| {
matches!(c, Some(Component::Prefix(_)) | Some(Component::RootDir))
};
if is_root(bc.first()) || is_root(tc.first()) {
return None;
}
}
let mut rel = PathBuf::new();
for _ in i..bc.len() {
rel.push("..");
}
for c in &tc[i..] {
rel.push(c.as_os_str());
}
Some(rel)
}
struct ChildMeta {
path: PathBuf,
name_lower: String,
ext_lower: String,
is_dir: bool,
size: u64,
mtime: Option<std::time::SystemTime>,
}
fn child_meta(path: PathBuf, ft: Option<std::fs::FileType>, sort: Sort) -> ChildMeta {
let need_stat = matches!(sort.key, SortKey::Size | SortKey::Modified);
let is_symlink = ft.map(|t| t.is_symlink()).unwrap_or(true);
let (is_dir, size, mtime) = if need_stat || is_symlink {
let md = std::fs::metadata(&path).ok();
(
md.as_ref().map(|m| m.is_dir()).unwrap_or(false),
md.as_ref().map(|m| m.len()).unwrap_or(0),
md.as_ref().and_then(|m| m.modified().ok()),
)
} else {
(ft.map(|t| t.is_dir()).unwrap_or(false), 0, None)
};
let name_lower = path
.file_name()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
let ext_lower = if matches!(sort.key, SortKey::Ext) {
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase()
} else {
String::new()
};
ChildMeta {
path,
name_lower,
ext_lower,
is_dir,
size,
mtime,
}
}
fn sort_cmp(a: &ChildMeta, b: &ChildMeta, sort: Sort) -> std::cmp::Ordering {
use std::cmp::Ordering;
if sort.dirs_first {
let d = b.is_dir.cmp(&a.is_dir); if d != Ordering::Equal {
return d;
}
}
let mut ord = match sort.key {
SortKey::Name => a.name_lower.cmp(&b.name_lower),
SortKey::Size => a.size.cmp(&b.size),
SortKey::Modified => a.mtime.cmp(&b.mtime),
SortKey::Ext => a.ext_lower.cmp(&b.ext_lower),
};
if sort.reverse {
ord = ord.reverse();
}
ord.then_with(|| a.name_lower.cmp(&b.name_lower))
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
fn base_label_from(refs: &str, short: &str) -> String {
for part in refs.split(',').map(str::trim) {
let p = part.strip_prefix("HEAD -> ").unwrap_or(part);
if p.is_empty() || p.starts_with("tag:") {
continue;
}
return p.to_string();
}
short.to_string()
}
fn build_dir(
dir: &Path,
depth: usize,
expanded_dirs: &std::collections::HashSet<PathBuf>,
show_hidden: bool,
sort: Sort,
out: &mut Vec<Entry>,
) -> Result<()> {
let mut children: Vec<ChildMeta> = std::fs::read_dir(dir)?
.filter_map(|r| r.ok())
.map(|e| {
let ft = e.file_type().ok();
(e.path(), ft)
})
.filter(|(p, _)| {
if show_hidden {
return true;
}
!p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
})
.map(|(p, ft)| child_meta(p, ft, sort))
.collect();
children.sort_by(|a, b| sort_cmp(a, b, sort));
for c in children {
let expanded = c.is_dir && expanded_dirs.contains(&c.path);
out.push(Entry {
path: c.path.clone(),
is_dir: c.is_dir,
depth,
expanded,
});
if expanded {
build_dir(&c.path, depth + 1, expanded_dirs, show_hidden, sort, out)?;
}
}
Ok(())
}
fn collect_all(root: &Path, show_hidden: bool) -> Vec<Entry> {
const CAP: usize = 50_000;
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if out.len() >= CAP {
break;
}
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for path in rd.filter_map(|r| r.ok()).map(|e| e.path()) {
if out.len() >= CAP {
break;
}
let hidden = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false);
if hidden && !show_hidden {
continue;
}
let is_symlink = std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let is_dir = path.is_dir();
out.push(Entry {
path: path.clone(),
is_dir,
depth: 0,
expanded: false,
});
if is_dir && !is_symlink {
stack.push(path);
}
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[cfg(test)]
mod tests;