Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ vocabulary** here.

## Glossary

### File Browser
The interactive **filesystem** panel that scans a directory for Markdown files
and lets the user switch between them inside the TUI (inspired by yazi). A left
side panel coexisting with the reader. **Never call this "目录"** — see
[[Contents (ToC)]] for the collision. The user-facing word is **File Browser**;
in Chinese, **文件浏览器**.

Status: proposed, not yet built. Reserve the name now so it can't drift.

### Contents (ToC)
The heading-outline side panel toggled by `t`, titled `"Contents"`, built from
the active document's `RenderedDoc.headings`. In casual Chinese this is often
called "目录", which **collides** with the filesystem sense used by the
[[File Browser]]. In this project: **Contents / ToC = headings of one document**;
**File Browser = files on disk**. They are different panels with different data
sources; do not conflate them in code, docs, or conversation.

### Frontmatter
A block of metadata written at the **very beginning** of a Markdown file, fenced
by either `---` (YAML syntax) or `+++` (TOML syntax). Used by static site
Expand Down
7 changes: 7 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,10 @@
- [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示
- [ ] 整理项目文档
- [x] 整理测试用的 markdown 文件,现在太乱
- [ ] 文件浏览器 File Browser(`termdown <dir>`,yazi 式实时预览,draft PR)
- [ ] **commit(Enter)进入全屏阅读器后渲染有多个问题,待排查修复**(已知,先记着)
- [ ] 阅读器内按键唤回浏览器(提交后目前回不去)
- [ ] 子目录进入 / 递归、隐藏文件、.gitignore
- [ ] 构建缓存(path+mtime,重访秒开)
- [ ] File Browser 与 ToC(目录)面板的共存规则
- [ ] 预览态可滚动 / 更轻的纯文本「安静模式」
24 changes: 21 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn main() {
println!();
println!("Arguments:");
println!(" FILE Markdown file to render (use - or omit for stdin)");
println!(" DIR Directory to browse for Markdown files (yazi-style file browser)");
println!();
println!("Options:");
println!(" -h, --help Show this help message");
Expand All @@ -36,7 +37,8 @@ fn main() {
println!(" --cat Force non-interactive cat-style output");
println!(" --no-bell Disable the edge-scroll terminal bell");
println!();
println!("By default, passing FILE opens it in the interactive TUI.");
println!("By default, passing FILE opens it in the interactive TUI, and");
println!("passing a directory opens the File Browser (j/k move, Enter opens, q quits).");
println!("Piped/redirected stdout and stdin input automatically use cat mode.");
println!();
println!("Config: ~/.config/termdown/config.toml");
Expand Down Expand Up @@ -94,11 +96,27 @@ fn main() {
found
};

let stdout_tty = io::stdout().is_tty();
let path_is_dir = file_arg
.as_deref()
.map(|p| p != "-" && std::path::Path::new(p).is_dir())
.unwrap_or(false);

// `termdown <dir>` on a TTY launches the File Browser: a yazi-style panel
// that scans the directory for Markdown files and live-previews them.
if !cat_flag && stdout_tty && path_is_dir {
let dir = file_arg.expect("path_is_dir implies a path");
tui::run_browser(&dir, &config, theme);
return;
}

// TUI is the default when we have a real file path and stdout is a
// terminal; --cat, piping/redirecting, or stdin input all fall through
// to cat mode so scripts like `termdown foo.md | less` keep working.
let want_tui =
!cat_flag && matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty();
let want_tui = !cat_flag
&& stdout_tty
&& !path_is_dir
&& matches!(file_arg.as_deref(), Some(p) if p != "-");

if want_tui {
let path = file_arg.expect("want_tui implies a file path");
Expand Down
8 changes: 8 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,14 @@ pub fn delete_placement<W: Write>(w: &mut W, id: u32) -> std::io::Result<()> {
write!(w, "\x1b_Ga=d,d=i,i={id},q=2;\x1b\\")
}

/// Delete `id`'s placements AND free its cached image data (`d=I`, capital).
/// Unlike `delete_placement` (`d=i`), the terminal reclaims the stored PNG.
/// Used to reap ephemeral File Browser previews the user has scrolled past so
/// a long browse session doesn't accumulate orphaned image data.
pub fn delete_image_data<W: Write>(w: &mut W, id: u32) -> std::io::Result<()> {
write!(w, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")
}

/// Delete all placements and image data this client has created. Used at
/// TUI exit to clean up the terminal.
pub fn delete_all_for_client<W: Write>(w: &mut W) -> std::io::Result<()> {
Expand Down
84 changes: 84 additions & 0 deletions src/tui/browser.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! File Browser state (yazi-style): scans a directory for Markdown files and
//! tracks the cursor + debounce timing that drives the live preview.
//!
//! This is the **filesystem** browser — distinct from the Table of Contents
//! ("Contents") panel, which lists the *headings of one document*. See
//! `CONTEXT.md` for the 目录 terminology collision.

use std::path::{Path, PathBuf};
use std::time::Instant;

pub struct FileBrowser {
/// Directory being browsed (as given on the CLI; may be relative).
pub dir: PathBuf,
/// Markdown files in `dir`, sorted. Single level, no recursion (HALF 1).
pub entries: Vec<PathBuf>,
/// Index into `entries` of the highlighted row.
pub cursor: usize,
/// `Some(t)` = the cursor moved at `t` and we're waiting for it to settle
/// before (re)building the preview. `None` = settled/idle.
pub last_move: Option<Instant>,
/// Path the current preview was built for; `None` until the first build or
/// after a commit (so re-entering the browser rebuilds).
pub preview_path: Option<PathBuf>,
}

impl FileBrowser {
/// Scan `dir` for `*.md` / `*.markdown` files (single level). Returns an
/// error only if the directory itself can't be read; an empty result is a
/// valid (caller-handled) outcome.
pub fn scan(dir: &Path) -> std::io::Result<FileBrowser> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && is_markdown(p))
.collect();
entries.sort();
Ok(FileBrowser {
dir: dir.to_path_buf(),
entries,
cursor: 0,
// None so the first loop iteration treats the selection as
// "settled" and builds the initial preview immediately.
last_move: None,
preview_path: None,
})
}

pub fn selected(&self) -> Option<&PathBuf> {
self.entries.get(self.cursor)
}

/// Display name for a row (file name only).
pub fn name_at(&self, idx: usize) -> String {
self.entries
.get(idx)
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
}

fn is_markdown(p: &Path) -> bool {
matches!(
p.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref(),
Some("md") | Some("markdown")
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn is_markdown_matches_extensions() {
assert!(is_markdown(Path::new("a.md")));
assert!(is_markdown(Path::new("a.markdown")));
assert!(is_markdown(Path::new("DIR/A.MD")));
assert!(!is_markdown(Path::new("a.txt")));
assert!(!is_markdown(Path::new("README")));
}
}
35 changes: 35 additions & 0 deletions src/tui/kitty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ impl ImageLifecycle {
Ok(())
}

/// Free the cached image *data* for `ids` (not just their placements), so
/// the terminal reclaims memory, and drop them from our tracking. Used to
/// reap ephemeral File Browser previews the user scrolled past — without
/// this, a long browse session leaves orphaned PNGs cached in the terminal
/// until exit.
pub fn forget<W: Write>(&mut self, w: &mut W, ids: &[u32]) -> io::Result<()> {
for &id in ids {
render::delete_image_data(w, id)?;
self.transmitted.remove(&id);
self.placed.remove(&id);
}
Ok(())
}

/// Delete every placement + cached image data this client created.
/// Clears our tracking. Called on TUI exit.
pub fn cleanup<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
Expand Down Expand Up @@ -183,4 +197,25 @@ mod tests {
assert!(lc.placed.is_empty());
assert!(lc.transmitted.is_empty());
}

#[test]
fn forget_frees_data_and_drops_tracking() {
let mut lc = ImageLifecycle::default();
let mut buf = Vec::new();
lc.register(&mut buf, 7, b"png").unwrap();
let mut desired = HashMap::new();
desired.insert(7u32, (0, 0));
lc.sync(&mut buf, &desired).unwrap();
buf.clear();

lc.forget(&mut buf, &[7]).unwrap();
let s = String::from_utf8(buf).unwrap();
// d=I (capital) frees the image data, not just the placement.
assert!(
s.contains("a=d,d=I,i=7"),
"expected data delete, got: {s:?}"
);
assert!(!lc.transmitted.contains(&7));
assert!(!lc.placed.contains_key(&7));
}
}
Loading
Loading