add prewie to filemanger

This commit is contained in:
2026-09-11 15:06:09 +02:00
parent 7786d20dd7
commit 0e9d6ede90
+780 -21
View File
@@ -4,16 +4,29 @@
Keys: Keys:
Up/Down or k/j Navigate Up/Down or k/j Navigate
Enter Open (folder -> cd, file -> menu) Enter Open (folder -> cd, file -> menu)
v or Tab Toggle the preview pane for the selected entry
f Open the selected text file in the full-screen viewer
/ Search (live filter while typing) / Search (live filter while typing)
Backspace or h Go to parent directory Backspace or h Go to parent directory
g / G Jump to top / bottom of the list
PgUp / PgDn Scroll the list a page at a time
~ Jump to the home directory
s Cycle sort order (name / size / mtime)
. Toggle hidden files . Toggle hidden files
r Rename selected entry r Rename selected entry
d Delete selected entry (asks for confirmation) d Delete selected entry (asks for confirmation)
n Create a new folder n Create a new folder
t Create a new empty file
c / x Mark selected entry as copy / move source c / x Mark selected entry as copy / move source
p Paste the marked entry into the current folder p Paste the marked entry into the current folder
q Quit q Quit
Preview pane (v / Tab): shows the first ~50 lines of the text file under
the cursor, a rendered view of Markdown files (headings, bold, italic,
inline code, code blocks, lists, block quotes, simple [text](url) links),
a sizes listing for folders, or "(binary)" otherwise. j/k scroll the
preview when it is open.
Symlinks to folders and files are followed: folder links can be entered Symlinks to folders and files are followed: folder links can be entered
like real directories. Broken symlinks are not listed. like real directories. Broken symlinks are not listed.
""" """
@@ -21,15 +34,38 @@ like real directories. Broken symlinks are not listed.
import curses import curses
import locale import locale
import os import os
import re
import shlex import shlex
import shutil import shutil
import subprocess import subprocess
import time
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
PARENT = Path("..") PARENT = Path("..")
# Preview settings: how many lines of a text file to show.
PREVIEW_LINES = 50
# Files larger than this are treated as "too big" for the preview.
PREVIEW_MAX_BYTES = 2 * 1024 * 1024
# Extensions considered (probably) plain text for the preview.
TEXT_EXTENSIONS = {
".txt", ".md", ".markdown", ".rst", ".log", ".ini", ".cfg", ".conf",
".toml", ".yaml", ".yml", ".json", ".xml", ".html", ".css",
".py", ".js", ".ts", ".java", ".c", ".h", ".cpp", ".hpp", ".rs",
".go", ".php", ".rb", ".sh", ".bash", ".zsh", ".fish", ".ps1",
".sql", ".lua", ".vim", ".dockerfile", ".gitignore", ".csv",
".service", ".desktop", ".env", ".text",
}
# Filenames (without extension) that are usually plain text.
TEXT_FILENAMES = {
"readme", "license", "changelog", "todo", "makefile", "dockerfile",
".gitignore", ".gitattributes", ".editorconfig", ".env",
}
SORT_MODES = ("name", "size", "mtime")
# ---------- helpers --------------------------------------------------------- # ---------- helpers ---------------------------------------------------------
@@ -134,6 +170,279 @@ def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str:
return label[:width].ljust(width) return label[:width].ljust(width)
def sort_entries(
items: list[Path], mode: str
) -> list[Path]:
"""Sort a list of entry paths (already without the '..' entry)."""
if mode == "size":
def key(e: Path) -> tuple:
try:
if e.is_dir(follow_symlinks=True):
return (0, 0, e.name.lower())
return (0, e.stat().st_size, e.name.lower())
except OSError:
return (0, -1, e.name.lower())
elif mode == "mtime":
def key(e: Path) -> tuple:
try:
return (0, -e.stat().st_mtime, e.name.lower())
except OSError:
return (0, 0, e.name.lower())
else:
def key(e: Path) -> tuple:
return (0, 0, e.name.lower())
return sorted(items, key=key)
def is_text_file(path: Path) -> bool:
"""Heuristic: does this look like a plain-text file we can preview?"""
try:
st = path.stat()
except OSError:
return False
if st.st_size == 0:
return True # empty files are trivially "text"
name = path.name.lower()
if path.suffix.lower() in TEXT_EXTENSIONS or name in TEXT_FILENAMES:
return True
if name == "dockerfile" or name.startswith("dockerfile."):
return True
if st.st_size > PREVIEW_MAX_BYTES:
return False
# Sniff: read a small chunk and look for NUL bytes / too many weird bytes.
try:
with path.open("rb") as fh:
chunk = fh.read(8192)
except OSError:
return False
if not chunk:
return True
if b"\x00" in chunk:
return False
weird = sum(1 for b in chunk if b < 9 or (13 < b < 32))
return weird / len(chunk) < 0.10
def read_preview_lines(path: Path) -> tuple[list[str], str, bool]:
"""Read the first PREVIEW_LINES lines of a text file.
Returns (lines, error, truncated). `lines` always has the raw text
(undecorated); error is "" on success or a short message; truncated is
True when the file has more lines than were returned.
"""
try:
st = path.stat()
except OSError as e:
return [f"(cannot stat: {e})"], "err", True
if st.st_size > PREVIEW_MAX_BYTES:
return (
[f"(file too large for preview: {human_size(st.st_size)})"],
"err",
True,
)
try:
with path.open("r", encoding="utf-8", errors="replace") as fh:
lines = []
for i, line in enumerate(fh):
if i >= PREVIEW_LINES:
break
lines.append(line.rstrip("\n\r"))
truncated = bool(fh.readline())
except OSError as e:
return [f"(cannot read: {e})"], "err", True
if not lines:
return ["(empty file)"], "", True
return lines, "", truncated
def format_mtime(ts: float) -> str:
"""Format a timestamp as a short local date-time string."""
try:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
except (OverflowError, OSError, ValueError):
return "?"
def total_lines(path: Path) -> int:
"""Count lines in a (text) file; returns -1 on error."""
try:
with path.open("rb") as fh:
return sum(1 for _ in fh)
except OSError:
return -1
# ---------- markdown rendering for the preview ------------------------------
def _md_inline(text: str) -> str:
"""Render inline markdown (bold/italic/code/links) as plain text.
Bold becomes *text*, italic /text/, code 'code', links text (url).
Placeholder control characters are used while rewriting so the rules
cannot chew on each other's markers; they are resolved at the end.
"""
# Inline code first, protected from all other rules.
text = re.sub(r"`([^`]+)`", "\x02\\1\x02", text)
# Images ![alt](url) -> alt (url); do before links (pattern overlap).
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)", r"\1 (\2)", text)
# Links [text](url) -> text (url)
text = re.sub(
r"\[([^\]]+)\]\(([^)\s]+)\)", r"\1 (\2)", text
)
# Bold **text** / __text__ -> *text* (placeholder, resolved below)
text = re.sub(r"\*\*(.+?)\*\*", "\x01\\1\x01", text)
text = re.sub(r"__(.+?)__", "\x01\\1\x01", text)
# Italic *text* / _text_ -> /text/
text = re.sub(r"(?<![\w*])\*(?![\s*])(.+?)(?<![\s*])\*(?![\w])", "\x03\\1\x03", text)
text = re.sub(r"(?<![\w_])_(?![\s_])(.+?)(?<![\s_])_(?![\w])", "\x03\\1\x03", text)
# Strikethrough ~~text~~ -> -text-
text = re.sub(r"~~(.+?)~~", r"-\1-", text)
# Resolve placeholders: bold '*', italic '/', code "'".
return text.replace("\x01", "*").replace("\x03", "/").replace("\x02", "'")
def _wrap(text: str, width: int) -> list[str]:
"""Word-wrap a plain text line to width (>=1). Never returns []."""
width = max(1, width)
if len(text) <= width:
return [text]
out = []
rest = text
while len(rest) > width:
cut = rest.rfind(" ", 0, width + 1)
if cut <= 0:
cut = width
out.append(rest[:cut])
rest = rest[cut:].lstrip()
out.append(rest)
return out
def render_markdown(
lines: list[str], width: int
) -> tuple[list[str], list[int]]:
"""Very small Markdown-to-plain-text renderer for the preview pane.
Supports: ATX headings (# .. ######), fenced code blocks (``` or ~~~),
bullet/numbered lists (with simple wrapping), block quotes (>),
horizontal rules (---), and inline styles (bold, italic, code, links).
Returns (out_lines, out_attrs) where out_attrs[i] is the curses attribute
(an int) to use for out_lines[i]. Callers without colors can ignore it
(values degrade to plain A_NORMAL / A_BOLD / A_REVERSE).
"""
out: list[str] = []
attrs: list[int] = []
width = max(4, width)
heading_attrs = {
1: curses.A_BOLD | curses.A_UNDERLINE,
2: curses.A_BOLD | curses.A_UNDERLINE,
3: curses.A_BOLD,
}
def emit(text: str, attr: int = curses.A_NORMAL) -> None:
if not text:
out.append("")
attrs.append(attr)
return
for piece in _wrap(text, width):
out.append(piece[:width])
attrs.append(attr)
in_code = False
code_marker = ""
prev_blank = True
for raw in lines:
line = raw.rstrip("\n")
# Fenced code blocks
stripped = line.lstrip()
if in_code:
if stripped.startswith(code_marker):
in_code = False
emit("")
prev_blank = True
else:
for piece in _wrap(stripped, width):
out.append(" " + piece[: max(1, width - 4)])
attrs.append(curses.A_NORMAL)
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
in_code = True
code_marker = stripped[:3]
emit("")
prev_blank = True
continue
# Horizontal rule
if re.fullmatch(r"\s*(\*\s*){3,}(-\s*){3,}(_\s*){3,}", line):
emit("")
emit("-" * width, curses.A_DIM)
prev_blank = False
continue
# Headings
m = re.match(r"(#{1,6})\s+(.*?)\s*#*\s*$", line)
if m:
level = len(m.group(1))
text = _md_inline(m.group(2))
emit("")
if level <= 2:
attr = heading_attrs[level]
emit(text, attr)
emit("=" * min(width, max(4, len(text))), curses.A_DIM)
else:
emit(text, heading_attrs.get(3, curses.A_BOLD))
prev_blank = False
continue
# Block quotes (single-level, consecutive > lines merged)
if stripped.startswith(">"):
content = stripped.lstrip("> ")
emit("│ " + _md_inline(content), curses.A_BOLD)
prev_blank = False
continue
# Lists
lm = re.match(r"(\s*)([-*+]|\d+[.)])\s+(.*)$", line)
if lm:
indent = len(lm.group(1)) // 2
marker = lm.group(2)
if marker in "-*+":
bullet = "• "
else:
bullet = marker.split(".")[0].split(")")[0] + ". "
prefix = " " * indent + bullet
body = _md_inline(lm.group(3))
first = True
for piece in _wrap(prefix + body, max(4, width - indent * 2)):
if first:
emit(piece, curses.A_BOLD if bullet.strip() == "•" else curses.A_NORMAL)
first = False
else:
emit(" " * indent + " " + piece)
prev_blank = False
continue
# Blank line
if not stripped:
if not prev_blank:
emit("")
prev_blank = True
continue
# Paragraph text
emit(_md_inline(stripped))
prev_blank = False
while out and out[-1] == "":
out.pop()
attrs.pop()
return out, attrs
# ---------- input ------------------------------------------------------------ # ---------- input ------------------------------------------------------------
def prompt_line( def prompt_line(
@@ -160,16 +469,18 @@ def prompt_line(
# Keep keypad ON so arrow keys arrive as KEY_* constants instead of an # Keep keypad ON so arrow keys arrive as KEY_* constants instead of an
# ESC prefix that would otherwise be mis-read as "user pressed cancel". # ESC prefix that would otherwise be mis-read as "user pressed cancel".
try: try:
stdscr.addstr(row, 0, prompt) stdscr.addstr(row, 0, prompt + initial)
stdscr.clrtoeol() stdscr.clrtoeol()
stdscr.refresh() stdscr.refresh()
buf = initial buf = initial
while True: while True:
ch = stdscr.get_wch() ch = stdscr.get_wch()
if ch in (27, -1): # bare ESC (get_wch reports -1 for ESC) # get_wch returns ints for special keys but plain strings for
# ordinary characters - including "\n", "\r" and "\x1b".
if ch in ("\x1b", 27, -1): # bare ESC
return None return None
if ch in (10, 13, curses.KEY_ENTER): if ch in ("\n", "\r", 10, 13, curses.KEY_ENTER):
break break
if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"): if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"):
if buf: if buf:
@@ -198,11 +509,15 @@ def prompt_line(
def confirm(stdscr: curses.window, prompt: str) -> bool: def confirm(stdscr: curses.window, prompt: str) -> bool:
"""Ask a yes/no question via prompt_line. True only on explicit y/yes.""" """Ask a yes/no question via prompt_line. True only on explicit y/yes.
The buffer is prefilled with "n", so a typed "y" lands after it ("ny");
we therefore look for a "y" anywhere instead of checking the first char.
"""
answer = prompt_line(stdscr, prompt, initial="n") answer = prompt_line(stdscr, prompt, initial="n")
if answer is None: if answer is None:
return False return False
return answer.strip().lower().startswith("y") return "y" in answer.strip().lower()
# ---------- rendering ------------------------------------------------------- # ---------- rendering -------------------------------------------------------
@@ -246,15 +561,37 @@ def draw(
show_hidden: bool, show_hidden: bool,
query: str | None, query: str | None,
colors: dict[str, int], colors: dict[str, int],
split: bool = False,
preview_top: int = 0,
info_suffix: str = "",
ordered: bool = False,
sort_mode: str = "name",
copy_source: Path | None = None,
move_source: Path | None = None,
preview_top_box: list[int] | None = None,
) -> int: ) -> int:
stdscr.erase() stdscr.erase()
max_y, max_x = stdscr.getmaxyx() max_y, max_x = stdscr.getmaxyx()
visible = filtered if filtered is not None else entries visible = filtered if filtered is not None else entries
# Split layout: leave the right half of the screen for the preview pane.
list_width = max_x
preview_lines: list[str] | None = None
preview_attrs: list[int] = []
preview_title = ""
preview_width = 0
preview_x = max_x
if split and max_x >= 40:
preview_x = max_x // 2 + 1
preview_width = max_x - preview_x
list_width = preview_x - 1
# Header # Header
hidden_flag = " (hidden shown)" if show_hidden else "" hidden_flag = " (hidden shown)" if show_hidden else ""
header = f" {cwd}{hidden_flag} " sort_flag = f" [sort: {sort_mode}]" if ordered else ""
flags = hidden_flag + sort_flag
header = f" {cwd}{flags} "
if colors: if colors:
stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD) stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD)
try: try:
@@ -264,6 +601,15 @@ def draw(
if colors: if colors:
stdscr.attroff(curses.color_pair(colors["header"]) | curses.A_BOLD) stdscr.attroff(curses.color_pair(colors["header"]) | curses.A_BOLD)
# Right side of the header: entry counts (dirs/files).
try:
n_dirs = sum(1 for e in visible if not is_parent(e) and e.is_dir(follow_symlinks=True))
n_files = len(visible) - n_dirs - (1 if any(is_parent(e) for e in visible) else 0)
counts = f"{n_dirs} dir(s), {n_files} file(s) "
stdscr.addnstr(0, max(0, max_x - len(counts) - 1), counts, max_x - 1, curses.A_DIM)
except curses.error:
pass
# List area # List area
list_top = 1 list_top = 1
list_bottom = max_y - 2 # leave one row for status, one for footer list_bottom = max_y - 2 # leave one row for status, one for footer
@@ -297,7 +643,9 @@ def draw(
is_dir = entry.is_dir(follow_symlinks=True) is_dir = entry.is_dir(follow_symlinks=True)
full = entry full = entry
label = format_entry(name, is_dir, full, max_x - 1) label = format_entry(name, is_dir, full, list_width - 1)
if info_suffix and entry_i == selected:
label = (label.rstrip() + " " + info_suffix)[: list_width - 1].ljust(list_width - 1)
attr = curses.A_NORMAL attr = curses.A_NORMAL
if colors: if colors:
@@ -318,7 +666,7 @@ def draw(
line_y = list_top + (entry_i - start) line_y = list_top + (entry_i - start)
if line_y < list_bottom: if line_y < list_bottom:
try: try:
stdscr.addnstr(line_y, 0, label, max_x - 1, attr) stdscr.addnstr(line_y, 0, label, list_width - 1, attr)
except curses.error: except curses.error:
pass pass
@@ -338,17 +686,265 @@ def draw(
except curses.error: except curses.error:
pass pass
# Footer (key hints) # Footer (key hints) - always ends with the quit hint.
footer = " Up/Dn move | Enter open | / live-search | r rename | d delete | n new dir | c copy | x move | p paste | . hidden | q quit " base_footer = " Up/Dn move | Enter open | v preview | / search | r rename | d delete | n new dir | t new file | c copy | x move | p paste | s sort | . hidden | ~ home | q quit "
if split:
base_footer = " j/k scroll preview | Enter view file | f view | v close | g/G top/bottom | q quit "
mark = ""
if move_source is not None:
mark = f" [MOVE: {move_source.name}]"
elif copy_source is not None:
mark = f" [COPY: {copy_source.name}]"
footer = (base_footer + mark)[-max_x:]
try: try:
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM) stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
except curses.error: except curses.error:
pass pass
# Preview pane is drawn last so it can overlay the (cleared) right half.
if split and max_x >= 40 and preview_lines is None and visible:
entry = visible[selected]
preview_lines, preview_attrs, preview_title = build_preview(
entry, cwd, preview_width
)
if preview_lines is not None:
new_top = draw_preview(
stdscr,
preview_lines,
preview_attrs,
preview_title,
preview_top,
preview_x,
preview_width,
rows,
colors,
)
if preview_top_box is not None:
preview_top_box[0] = new_top
stdscr.refresh() stdscr.refresh()
return selected return selected
# ---------- preview ----------------------------------------------------------
def build_preview(
entry: Path, cwd: Path, width: int
) -> tuple[list[str], list[int], str]:
"""Build the preview content for an entry.
Returns (lines, attrs, title). `lines`/`attrs` are parallel lists as in
render_markdown; title is shown at the top of the preview pane.
"""
if is_parent(entry):
target = cwd.parent
else:
target = entry
try:
is_dir = target.is_dir(follow_symlinks=True)
except OSError:
is_dir = False
if is_dir:
lines: list[str] = []
attrs: list[int] = []
try:
st = target.stat()
lines.append(f"Folder: {target.name or str(target)}")
attrs.append(curses.A_BOLD)
lines.append("")
attrs.append(0)
sub = sorted(target.iterdir(), key=lambda p: p.name.lower())
dirs = [p for p in sub if p.is_dir(follow_symlinks=True)]
files = [p for p in sub if not p.is_dir(follow_symlinks=True)]
lines.append(f"{len(dirs)} folders, {len(files)} files")
attrs.append(0)
lines.append("")
attrs.append(0)
for p in (dirs + files)[:PREVIEW_LINES]:
try:
pst = p.stat()
size = human_size(pst.st_size)
mtime = format_mtime(pst.st_mtime)
except OSError:
size, mtime = "?", "?"
kind = "D" if p.is_dir(follow_symlinks=True) else "F"
name_w = max(1, width - 24)
lines.append(
f" {kind} {p.name[:name_w]:<{name_w}} {size:>7} {mtime}"
)
attrs.append(0)
shown = min(len(sub), PREVIEW_LINES)
if len(sub) > shown:
lines.append(f" ... and {len(sub) - shown} more")
attrs.append(curses.A_DIM)
except OSError as e:
lines.append(f"(cannot read folder: {e})")
attrs.append(0)
return lines, attrs, f"Folder: {target.name or str(target)}"
if not target.is_file(follow_symlinks=True):
return (["(not a regular file)"], [0], target.name)
if is_text_file(target):
raw, err, truncated = read_preview_lines(target)
if err:
return raw, [0] * len(raw), target.name
if target.suffix.lower() in (".md", ".markdown"):
body, body_attrs = render_markdown(raw, max(4, width - 2))
title = f"Markdown: {target.name}"
else:
body = [line[:width] for line in raw]
body_attrs = [0] * len(body)
title = target.name
try:
st = target.stat()
head = (
f"{human_size(st.st_size)} {format_mtime(st.st_mtime)} "
f"first {len(raw)} of {total_lines(target)} lines"
)
except OSError:
head = f"first {len(raw)} lines"
if truncated:
head += " (truncated)"
lines = [head, ""] + body
attrs = [curses.A_DIM, 0] + body_attrs
return lines, attrs, title
return (["(binary file - no preview)"], [0], target.name)
def draw_preview(
stdscr: curses.window,
lines: list[str],
attrs: list[int],
title: str,
top: int,
x: int,
width: int,
height: int,
colors: dict[str, int],
) -> int:
"""Draw the preview pane at (x, 1) with the given size. Returns new top."""
if width < 3 or height < 1:
return top
top = max(0, min(top, max(0, len(lines) - 1)))
border_attr = curses.A_DIM
if colors and "header" in colors:
border_attr = curses.color_pair(colors["header"])
# Title row
title_text = f" {title} "[: width - 1]
try:
stdscr.addnstr(0, x, title_text, width - 1, border_attr | curses.A_BOLD)
stdscr.addnstr(0, x + len(title_text), "─" * (width - len(title_text) - 1), width - 1 - len(title_text), border_attr)
except curses.error:
pass
for row in range(height):
idx = top + row
if idx >= len(lines):
break
text = lines[idx][: width - 2]
attr = attrs[idx] if idx < len(attrs) else curses.A_NORMAL
if attr == 0:
attr = curses.A_NORMAL
try:
stdscr.addnstr(1 + row, x, text, width - 2, attr)
except curses.error:
pass
# Scroll indicator
if len(lines) > height:
frac = top / max(1, len(lines) - height)
pos = min(height - 1, int(frac * (height - 1)))
try:
stdscr.addch(1 + pos, x + width - 1, "█", border_attr)
except curses.error:
pass
return top
def view_file_fullscreen(
stdscr: curses.window, filepath: Path, colors: dict[str, int]
) -> str:
"""Full-screen text viewer for a file (Enter on it in preview mode, or f).
Returns a short status string. Supports Markdown rendering (toggle with m).
"""
raw, err, _trunc = read_preview_lines(filepath)
if err:
return err
is_md = filepath.suffix.lower() in (".md", ".markdown")
def content(max_y: int, max_x: int, rendered: bool) -> tuple[list[str], list[int]]:
if rendered and is_md:
return render_markdown(raw, max(4, max_x - 2))
return [line[: max(1, max_x - 1)] for line in raw], [0] * len(raw)
top = 0
rendered = is_md # default to rendered for Markdown
while True:
stdscr.erase()
max_y, max_x = stdscr.getmaxyx()
lines, attrs = content(max_y - 2, max_x, rendered)
top = max(0, min(top, max(0, len(lines) - 1)))
title = f" {filepath.name} "
if is_md:
title += "[m: raw]" if rendered else "[m: rendered]"
title += " [q: back]"
tattr = curses.A_BOLD | (curses.color_pair(colors["header"]) if colors else 0)
try:
stdscr.addnstr(0, 0, title[: max_x - 1], max_x - 1, tattr)
except curses.error:
pass
for row in range(max_y - 2):
idx = top + row
if idx >= len(lines):
break
attr = attrs[idx] if idx < len(attrs) else 0
if attr == 0:
attr = curses.A_NORMAL
try:
stdscr.addnstr(1 + row, 0, lines[idx], max_x - 1, attr)
except curses.error:
pass
try:
stdscr.addnstr(
max_y - 1, 0,
f" {len(raw)} lines | j/k or arrows scroll | m toggle rendering | q back ",
max_x - 1, curses.A_DIM,
)
except curses.error:
pass
stdscr.refresh()
ch = stdscr.getch()
page = max(1, (max_y - 3))
if ch in (ord("q"), 27):
break
elif ch in (curses.KEY_UP, ord("k")):
top = max(0, top - 1)
elif ch in (curses.KEY_DOWN, ord("j")):
top = min(max(0, len(lines) - 1), top + 1)
elif ch in (curses.KEY_PPAGE, curses.KEY_BACKSPACE, 127, 8):
top = max(0, top - page)
elif ch in (curses.KEY_NPAGE, ord(" ")):
top = min(max(0, len(lines) - 1), top + page)
elif ch in (curses.KEY_HOME, ord("g")):
top = 0
elif ch in (curses.KEY_END, ord("G")):
top = max(0, len(lines) - 1)
elif ch in (ord("m"), ord("M")):
if is_md:
rendered = not rendered
elif ch == curses.KEY_RESIZE:
continue
return ""
# ---------- file type / open-with configuration ----------------------------- # ---------- file type / open-with configuration -----------------------------
OPEN_COMMANDS = { OPEN_COMMANDS = {
@@ -558,6 +1154,12 @@ def main(stdscr: curses.window) -> None:
filtered = None # when not None, used for display (search active) filtered = None # when not None, used for display (search active)
copy_source: Path | None = None copy_source: Path | None = None
move_source: Path | None = None move_source: Path | None = None
preview = False # split screen with preview pane on/off
preview_top = 0 # scroll offset inside the preview pane
preview_top_box = [0] # draw() clamps it back through this
preview_sel_at_reset = 0 # selection the preview scroll was last reset for
sort_mode = "name" # current sort order
dir_cursors: dict[str, int] = {} # remember cursor per directory
while True: while True:
try: try:
@@ -582,6 +1184,32 @@ def main(stdscr: curses.window) -> None:
] ]
filtered = candidates filtered = candidates
# Sort (only the real entries; '..' stays on top).
if filtered is None and sort_mode != "name":
real = [e for e in entries if not is_parent(e)]
parent_only = [e for e in entries if is_parent(e)]
entries = parent_only + sort_entries(real, sort_mode)
# Info for the selected entry, shown at the end of its row.
vis_now = filtered if filtered is not None else entries
info_suffix = ""
if vis_now:
sel_entry = vis_now[max(0, min(selected, len(vis_now) - 1))]
if not is_parent(sel_entry):
try:
st = sel_entry.stat()
info_suffix = (
f"{human_size(st.st_size)} {format_mtime(st.st_mtime)}"
)
except OSError:
pass
# Scroll the preview back to its top when the selection changed.
if preview and selected != preview_sel_at_reset:
preview_top = 0
preview_top_box[0] = 0
preview_sel_at_reset = selected
selected = draw( selected = draw(
stdscr, stdscr,
entries, entries,
@@ -593,6 +1221,14 @@ def main(stdscr: curses.window) -> None:
show_hidden, show_hidden,
query, query,
colors, colors,
split=preview,
preview_top=preview_top,
preview_top_box=preview_top_box,
info_suffix=info_suffix,
ordered=sort_mode != "name",
sort_mode=sort_mode,
copy_source=copy_source,
move_source=move_source,
) )
status = "" status = ""
@@ -603,6 +1239,10 @@ def main(stdscr: curses.window) -> None:
if ch in (ord("q"),): if ch in (ord("q"),):
break break
if ch == 12: # Ctrl+L: force a full redraw
stdscr.clear()
continue
if ch in (curses.KEY_UP, ord("k")): if ch in (curses.KEY_UP, ord("k")):
vis = filtered if filtered is not None else entries vis = filtered if filtered is not None else entries
if vis: if vis:
@@ -613,14 +1253,74 @@ def main(stdscr: curses.window) -> None:
if vis: if vis:
selected = (selected + 1) % len(vis) selected = (selected + 1) % len(vis)
elif ch in (ord("K"),) and preview:
preview_top = max(0, preview_top_box[0] - 1)
elif ch in (ord("J"),) and preview:
preview_top = min(
max(0, preview_top_box[0] + 1), preview_top_box[0] + 1
)
elif ch == ord("g"):
selected = 0
elif ch == ord("G"):
vis = filtered if filtered is not None else entries
if vis:
selected = len(vis) - 1
elif ch == curses.KEY_PPAGE:
max_y, _mx = stdscr.getmaxyx()
page = max(1, (max_y - 3))
selected = max(0, selected - page)
elif ch == curses.KEY_NPAGE:
max_y, _mx = stdscr.getmaxyx()
vis = filtered if filtered is not None else entries
page = max(1, (max_y - 3))
if vis:
selected = min(len(vis) - 1, selected + page)
elif ch == ord("~"):
try:
home = Path.home()
except RuntimeError:
home = None
if home is None or not home.is_dir():
status = "Cannot determine home directory"
status_kind = "status_err"
elif home == cwd:
status = "Already in home directory"
status_kind = "status_ok"
else:
try:
os.chdir(home)
dir_cursors[str(cwd)] = selected
cwd = Path.cwd()
selected = dir_cursors.get(str(cwd), 0)
filtered = None
query = None
preview = False
except OSError as e:
status = f"Cannot enter home: {e}"
status_kind = "status_err"
elif ch == ord("s"):
idx = SORT_MODES.index(sort_mode)
sort_mode = SORT_MODES[(idx + 1) % len(SORT_MODES)]
status = f"Sort: {sort_mode}"
status_kind = "status_ok"
elif ch in (curses.KEY_BACKSPACE, 127, 8, ord("h")): elif ch in (curses.KEY_BACKSPACE, 127, 8, ord("h")):
# Parent directory # Parent directory
if cwd != cwd.parent: if cwd != cwd.parent:
dir_cursors[str(cwd)] = selected
cwd = cwd.parent cwd = cwd.parent
os.chdir(cwd) os.chdir(cwd)
selected = 0 selected = dir_cursors.get(str(cwd), 0)
filtered = None filtered = None
query = None query = None
preview = False
else: else:
status = "Already at filesystem root" status = "Already at filesystem root"
status_kind = "status_err" status_kind = "status_err"
@@ -632,18 +1332,22 @@ def main(stdscr: curses.window) -> None:
entry = vis[selected] entry = vis[selected]
if is_parent(entry): if is_parent(entry):
if cwd != cwd.parent: if cwd != cwd.parent:
dir_cursors[str(cwd)] = selected
cwd = cwd.parent cwd = cwd.parent
os.chdir(cwd) os.chdir(cwd)
selected = 0 selected = dir_cursors.get(str(cwd), 0)
filtered = None filtered = None
query = None query = None
preview = False
elif entry.is_dir(follow_symlinks=True): elif entry.is_dir(follow_symlinks=True):
try: try:
dir_cursors[str(cwd)] = selected
os.chdir(entry) os.chdir(entry)
cwd = Path.cwd() cwd = Path.cwd()
selected = 0 selected = dir_cursors.get(str(cwd), 0)
filtered = None filtered = None
query = None query = None
preview = False
except PermissionError: except PermissionError:
status = f"Permission denied: {entry.name}" status = f"Permission denied: {entry.name}"
status_kind = "status_err" status_kind = "status_err"
@@ -652,14 +1356,27 @@ def main(stdscr: curses.window) -> None:
status_kind = "status_err" status_kind = "status_err"
else: else:
if entry.is_file(follow_symlinks=True): if entry.is_file(follow_symlinks=True):
argv = file_menu(stdscr, entry, colors) if preview:
if argv is not None: # In preview mode Enter opens the built-in viewer.
status = run_external(stdscr, argv) if is_text_file(entry):
status_kind = "status_ok" if status.startswith("Ran:") else "status_err" msg = view_file_fullscreen(stdscr, entry, colors)
new_cwd = Path.cwd() if msg:
if new_cwd != cwd: status = msg
cwd = new_cwd status_kind = "status_err"
selected = 0 else:
argv = file_menu(stdscr, entry, colors)
if argv is not None:
status = run_external(stdscr, argv)
status_kind = "status_ok" if status.startswith("Ran:") else "status_err"
else:
argv = file_menu(stdscr, entry, colors)
if argv is not None:
status = run_external(stdscr, argv)
status_kind = "status_ok" if status.startswith("Ran:") else "status_err"
new_cwd = Path.cwd()
if new_cwd != cwd:
cwd = new_cwd
selected = 0
elif ch == ord("/"): elif ch == ord("/"):
def apply_search(buf: str) -> None: def apply_search(buf: str) -> None:
@@ -703,6 +1420,48 @@ def main(stdscr: curses.window) -> None:
] ]
filtered = candidates if candidates else [] filtered = candidates if candidates else []
elif ch in (ord("v"), ord("V"), 9): # 9 = Tab
preview = not preview
if preview:
preview_top = 0
preview_top_box[0] = 0
preview_sel_at_reset = selected
elif ch in (ord("f"), ord("F")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "No file selected"
status_kind = "status_err"
elif entry.is_file(follow_symlinks=True) and is_text_file(entry):
msg = view_file_fullscreen(stdscr, entry, colors)
if msg:
status = msg
status_kind = "status_err"
else:
status = "Not a previewable text file"
status_kind = "status_err"
elif ch == ord("t") or ch == ord("T"):
name = prompt_line(stdscr, "New file name: ")
if name is None:
continue
name = name.strip()
if not name:
continue
try:
target = cwd / name
if target.exists():
raise FileExistsError(f"'{name}' already exists")
target.touch()
status = f"Created '{name}'"
status_kind = "status_ok"
except OSError as e:
status = f"Create failed: {e}"
status_kind = "status_err"
elif ch == ord("."): elif ch == ord("."):
show_hidden = not show_hidden show_hidden = not show_hidden
selected = 0 selected = 0