Files
2026-09-11 15:06:09 +02:00

1643 lines
56 KiB
Python

#!/usr/bin/env python3
"""SJN File Manager - a TUI file manager.
Keys:
Up/Down or k/j Navigate
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)
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
r Rename selected entry
d Delete selected entry (asks for confirmation)
n Create a new folder
t Create a new empty file
c / x Mark selected entry as copy / move source
p Paste the marked entry into the current folder
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
like real directories. Broken symlinks are not listed.
"""
import curses
import locale
import os
import re
import shlex
import shutil
import subprocess
import time
from collections.abc import Callable
from pathlib import 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 ---------------------------------------------------------
FILE_CATEGORIES = {
"Bilder": {
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"
},
"Videos": {
".mp4", ".mkv", ".avi", ".mov", ".webm"
},
"Musik": {
".mp3", ".wav", ".flac", ".ogg", ".m4a"
},
"Dokumente": {
".doc", ".docx", ".odt", ".txt", ".rtf"
},
"PDF": {
".pdf"
},
"Tabellen": {
".xls", ".xlsx", ".ods", ".csv"
},
"Präsentationen": {
".ppt", ".pptx", ".odp"
},
"Code": {
".py", ".js", ".ts", ".java", ".c", ".cpp", ".h",
".rs", ".go", ".php", ".sh"
},
"Archive": {
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar"
},
}
def get_category(filename: str) -> str:
extension = Path(filename).suffix.lower()
for category, extensions in FILE_CATEGORIES.items():
if extension in extensions:
return category
return "Sonstige"
def human_size(n: int) -> str:
"""Return a human-readable file size."""
units = ["B", "K", "M", "G", "T"]
size = float(n)
for unit in units:
if size < 1024 or unit == units[-1]:
if unit == "B":
return f"{int(size)}{unit}"
return f"{size:.1f}{unit}"
size /= 1024
return f"{int(size)}T"
def list_entries(cwd: Path, show_hidden: bool) -> tuple[list[Path], str | None]:
"""Return (entries, error); error is None on success.
Entries are [.., dir1, dir2, ..., file1, file2, ...]. Symlinks are
followed: links to folders are listed and can be entered like real
directories, links to files are listed as files. Broken symlinks are
not listed. On error, entries is just [..] and error carries a message.
"""
try:
all_entries = list(cwd.iterdir())
except OSError as e:
return [PARENT], str(e) or type(e).__name__
if not show_hidden:
all_entries = [e for e in all_entries if not e.name.startswith(".")]
dirs = sorted(
(e for e in all_entries if e.is_dir(follow_symlinks=True)),
key=lambda e: e.name.lower(),
)
files = sorted(
(e for e in all_entries if e.is_file(follow_symlinks=True)),
key=lambda e: e.name.lower(),
)
return [PARENT] + dirs + files, None
def is_parent(entry: Path) -> bool:
return entry == PARENT
def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str:
"""Format an entry for display, padded to width."""
if is_dir:
label = f"[D] {name}/"
else:
try:
label = f"[F] {name} ({human_size(full_path.stat().st_size)})"
except OSError:
label = f"[F] {name}"
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 ------------------------------------------------------------
def prompt_line(
stdscr: curses.window,
prompt: str,
initial: str = "",
on_change: Callable[[str], None] | None = None,
) -> str | None:
"""Show a prompt at the bottom and read a line. Returns None if cancelled (ESC).
Control characters (Tab, Ctrl+C, ...) never enter the buffer; printable
ASCII and Unicode characters (umlauts etc.) are accepted. If `on_change`
is given it is called with the current buffer after every edit, which
allows live-filtering while the user types.
Modes are restored in `finally` so a curses.error or any other exception
cannot leave the terminal in a half-configured state.
"""
max_y, max_x = stdscr.getmaxyx()
row = max_y - 2
curses.curs_set(1)
curses.echo()
# 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".
try:
stdscr.addstr(row, 0, prompt + initial)
stdscr.clrtoeol()
stdscr.refresh()
buf = initial
while True:
ch = stdscr.get_wch()
# 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
if ch in ("\n", "\r", 10, 13, curses.KEY_ENTER):
break
if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"):
if buf:
buf = buf[:-1]
elif isinstance(ch, str) and 32 <= ord(ch) != 127:
# Printable ASCII plus Unicode (umlauts, ...).
# Control characters (< 0x20) and DEL are ignored.
buf += ch
# ints (arrow keys, function keys, ...) are silently ignored.
if on_change is not None:
on_change(buf)
stdscr.addstr(row, 0, prompt + (buf or ""))
stdscr.clrtoeol()
stdscr.refresh()
return buf
finally:
curses.noecho()
curses.curs_set(0)
try:
stdscr.touchwin()
stdscr.refresh()
except curses.error:
pass
def confirm(stdscr: curses.window, prompt: str) -> bool:
"""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")
if answer is None:
return False
return "y" in answer.strip().lower()
# ---------- rendering -------------------------------------------------------
def init_colors() -> dict[str, int]:
"""Initialize color pairs and return a mapping of name -> pair id."""
pairs = {
"dir": 1,
"file": 2,
"selected": 3,
"selected_dir": 4,
"status_ok": 5,
"status_err": 6,
"header": 7,
}
if not curses.has_colors():
return {}
curses.start_color()
curses.use_default_colors()
try:
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_WHITE, -1)
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(4, curses.COLOR_CYAN, curses.COLOR_WHITE)
curses.init_pair(5, curses.COLOR_GREEN, -1)
curses.init_pair(6, curses.COLOR_RED, -1)
curses.init_pair(7, curses.COLOR_YELLOW, -1)
except curses.error:
pass
return pairs
def draw(
stdscr: curses.window,
entries: list[Path],
filtered: list[Path] | None,
selected: int,
cwd: Path,
status: str,
status_kind: str,
show_hidden: bool,
query: str | None,
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:
stdscr.erase()
max_y, max_x = stdscr.getmaxyx()
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
hidden_flag = " (hidden shown)" if show_hidden else ""
sort_flag = f" [sort: {sort_mode}]" if ordered else ""
flags = hidden_flag + sort_flag
header = f" {cwd}{flags} "
if colors:
stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD)
try:
stdscr.addstr(0, 0, header[: max_x - 1])
except curses.error:
pass
if colors:
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_top = 1
list_bottom = max_y - 2 # leave one row for status, one for footer
rows = max(0, list_bottom - list_top)
# Clamp selection to a valid index in `visible`.
if visible:
selected = max(0, min(selected, len(visible) - 1))
else:
selected = 0
if rows <= 0:
# Terminal too small to render anything meaningful.
stdscr.refresh()
return selected
# Scroll window
start = 0
if selected >= rows:
start = selected - rows + 1
end = min(start + rows, len(visible))
for entry_i in range(start, end):
entry = visible[entry_i]
if is_parent(entry):
name = ".."
is_dir = True
full = cwd.parent
else:
name = entry.name
is_dir = entry.is_dir(follow_symlinks=True)
full = entry
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
if colors:
if entry_i == selected:
attr = curses.color_pair(
colors["selected_dir"] if is_dir else colors["selected"]
) | curses.A_BOLD
else:
attr = curses.color_pair(
colors["dir"] if is_dir else colors["file"]
)
else:
if entry_i == selected:
attr = curses.A_REVERSE
elif is_dir:
attr = curses.A_BOLD
line_y = list_top + (entry_i - start)
if line_y < list_bottom:
try:
stdscr.addnstr(line_y, 0, label, list_width - 1, attr)
except curses.error:
pass
# Status line (one row above footer)
status_y = max_y - 2
status_text = ""
status_attr = curses.A_DIM
if status:
status_text = status
elif query is not None:
status_text = f"/ {query}"
if status_text and colors and status_kind in colors:
status_attr = curses.color_pair(colors[status_kind]) | curses.A_BOLD
try:
if status_text:
stdscr.addnstr(status_y, 0, status_text, max_x - 1, status_attr)
except curses.error:
pass
# Footer (key hints) - always ends with the quit hint.
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:
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
except curses.error:
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()
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 -----------------------------
OPEN_COMMANDS = {
"Bilder": [
("feh", ["feh", "{file}"]),
("imv", ["imv", "{file}"]),
],
"Videos": [
("mpv", ["mpv", "{file}"]),
("vlc", ["vlc", "{file}"]),
("show", ["show", "{file}"]),
("newshow", ["newshow", "{file}"]),
],
"Musik": [
("mpv", ["mpv", "{file}"]),
("ffplay", ["ffplay", "{file}"]),
],
"Dokumente": [
("LibreOffice", ["libreoffice", "{file}"]),
("firefox", ["firefox", "{file}"]),
],
"PDF": [
("zathura", ["zathura", "{file}"]),
("firefox", ["firefox", "{file}"]),
],
"Code": [
("nano", ["nano", "{file}"]),
("helix", ["helix", "{file}"]),
],
"Archive": [
("File Roller", ["file-roller", "{file}"]),
],
"Sonstige": [
("nano", ["nano", "{file}"]),
],
}
def file_menu(
stdscr: curses.window, filepath: Path, colors: dict[str, int]
) -> list[str] | None:
"""Show the open-with menu based on the file category.
Returns argv list, or None if cancelled.
"""
category = get_category(filepath)
# Get commands for this category.
# If the category does not exist, fall back to "Sonstige".
commands = OPEN_COMMANDS.get(
category,
OPEN_COMMANDS.get("Sonstige", [])
)
stdscr.erase()
max_y, max_x = stdscr.getmaxyx()
lines = [
f"Open: {filepath.name}",
f"Type: {category}",
"",
]
# Add category-specific programs.
for number, (name, _argv) in enumerate(commands, start=1):
lines.append(f" {number}) {name}")
# Custom command is always available.
custom_number = len(commands) + 1
lines.extend([
f" {custom_number}) custom command",
"",
f"Press 1-{custom_number} - ESC to cancel",
])
for i, line in enumerate(lines):
if i >= max_y - 1:
break
try:
if colors:
stdscr.addnstr(
i,
2,
line,
max_x - 3,
curses.color_pair(colors["header"]) | curses.A_BOLD,
)
else:
stdscr.addnstr(
i,
2,
line,
max_x - 3,
curses.A_BOLD,
)
except curses.error:
pass
stdscr.refresh()
while True:
ch = stdscr.getch()
if ch == 27: # ESC
return None
if ord("1") <= ch <= ord("9"):
number = ch - ord("0")
# Predefined command
if 1 <= number <= len(commands):
_name, argv = commands[number - 1]
return [
arg.replace("{file}", str(filepath))
for arg in argv
]
# Custom command
if number == custom_number:
cmd = prompt_line(
stdscr,
"Command (use {{file}} for path): "
)
if cmd is None or not cmd.strip():
return None
if "{file}" in cmd:
resolved = cmd.replace(
"{file}",
str(filepath)
)
else:
resolved = cmd + " " + str(filepath)
try:
return shlex.split(resolved)
except ValueError:
return resolved.split()
def run_external(stdscr: curses.window, argv: list[str]) -> str:
"""Suspend curses, run argv, then restore. Returns a status string.
Defensive: we snapshot cwd before, run, then forcibly chdir back to the
snapshot afterwards. Even though `subprocess.run` with a list argv does
not invoke a shell and therefore cannot change the parent's cwd, if a
future change ever switches to `shell=True` this guarantees we stay put.
"""
saved_cwd = os.getcwd()
curses.def_prog_mode()
curses.endwin()
status = ""
try:
try:
result = subprocess.run(argv)
if result.returncode == 0:
status = f"Ran: {' '.join(argv)}"
else:
status = f"Exit {result.returncode}: {' '.join(argv)}"
except FileNotFoundError:
status = f"Command not found: {argv[0]}"
except KeyboardInterrupt:
status = "Interrupted"
except Exception as e: # noqa: BLE001
status = f"Error: {e}"
finally:
# Always restore cwd before resuming curses.
try:
os.chdir(saved_cwd)
except OSError:
pass
curses.reset_prog_mode()
try:
stdscr.redrawwin()
stdscr.refresh()
except curses.error:
pass
return status
# ---------- main loop -------------------------------------------------------
def main(stdscr: curses.window) -> None:
colors = init_colors()
curses.curs_set(0)
stdscr.keypad(True)
cwd = Path.cwd()
show_hidden = False
selected = 0
status = ""
status_kind = "status_ok"
query = None
filtered = None # when not None, used for display (search active)
copy_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:
try:
entries, list_error = list_entries(cwd, show_hidden)
if list_error is not None:
status = f"Cannot read directory: {list_error}"
status_kind = "status_err"
if filtered is not None:
# Re-apply filter against the fresh entries
if query is None:
filtered = None
else:
qlow = query.lower()
candidates = [e for e in entries if qlow in e.name.lower()]
if not candidates:
filtered = []
else:
if candidates[0] != PARENT and PARENT in entries:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
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(
stdscr,
entries,
filtered,
selected,
cwd,
status,
status_kind,
show_hidden,
query,
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_kind = "status_ok"
ch = stdscr.getch()
if ch in (ord("q"),):
break
if ch == 12: # Ctrl+L: force a full redraw
stdscr.clear()
continue
if ch in (curses.KEY_UP, ord("k")):
vis = filtered if filtered is not None else entries
if vis:
selected = (selected - 1) % len(vis)
elif ch in (curses.KEY_DOWN, ord("j")):
vis = filtered if filtered is not None else entries
if 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")):
# Parent directory
if cwd != cwd.parent:
dir_cursors[str(cwd)] = selected
cwd = cwd.parent
os.chdir(cwd)
selected = dir_cursors.get(str(cwd), 0)
filtered = None
query = None
preview = False
else:
status = "Already at filesystem root"
status_kind = "status_err"
elif ch in (curses.KEY_ENTER, 10, 13):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
if cwd != cwd.parent:
dir_cursors[str(cwd)] = selected
cwd = cwd.parent
os.chdir(cwd)
selected = dir_cursors.get(str(cwd), 0)
filtered = None
query = None
preview = False
elif entry.is_dir(follow_symlinks=True):
try:
dir_cursors[str(cwd)] = selected
os.chdir(entry)
cwd = Path.cwd()
selected = dir_cursors.get(str(cwd), 0)
filtered = None
query = None
preview = False
except PermissionError:
status = f"Permission denied: {entry.name}"
status_kind = "status_err"
except OSError as e:
status = f"Cannot enter {entry.name}: {e}"
status_kind = "status_err"
else:
if entry.is_file(follow_symlinks=True):
if preview:
# In preview mode Enter opens the built-in viewer.
if is_text_file(entry):
msg = view_file_fullscreen(stdscr, entry, colors)
if msg:
status = msg
status_kind = "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"
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("/"):
def apply_search(buf: str) -> None:
"""Live-render the list while the user types."""
nonlocal query, filtered, selected
query = buf
if buf == "":
filtered = None
else:
qlow = buf.lower()
candidates = [e for e in entries if qlow in e.name.lower()]
if not candidates:
filtered = []
else:
if PARENT in entries:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
filtered = candidates
selected = draw(
stdscr, entries, filtered, selected, cwd,
"", "status_ok", show_hidden, query, colors,
)
q = prompt_line(stdscr, "/ ", on_change=apply_search)
if q is None:
# cancel search
filtered = None
query = None
selected = 0
else:
query = q
if q == "":
filtered = None
else:
qlow = q.lower()
candidates = [e for e in entries if qlow in e.name.lower()]
if PARENT in entries:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
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("."):
show_hidden = not show_hidden
selected = 0
# Re-apply search if active
if query:
fresh, _err = list_entries(cwd, show_hidden)
qlow = query.lower()
candidates = [
e for e in fresh if qlow in e.name.lower()
]
if PARENT in fresh:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
filtered = candidates if candidates else []
elif ch in (ord("r"), ord("R")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot rename '..'"
status_kind = "status_err"
continue
new_name = prompt_line(
stdscr, f"Rename '{entry.name}' to: ", initial=entry.name
)
if new_name is None:
continue
new_name = new_name.strip()
if not new_name or new_name == entry.name:
continue
try:
target = entry.parent / new_name
if target.exists():
raise FileExistsError(f"'{new_name}' already exists")
entry.rename(target)
status = f"Renamed to '{new_name}'"
status_kind = "status_ok"
except OSError as e:
status = f"Rename failed: {e}"
status_kind = "status_err"
elif ch in (ord("d"), ord("D")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot delete '..'"
status_kind = "status_err"
continue
if not confirm(stdscr, f"Delete '{entry.name}'? (y/N) "):
status = "Delete cancelled"
status_kind = "status_ok"
continue
try:
if entry.is_symlink():
entry.unlink()
elif entry.is_dir():
shutil.rmtree(entry)
else:
entry.unlink()
status = f"Deleted '{entry.name}'"
status_kind = "status_ok"
except OSError as e:
status = f"Delete failed: {e}"
status_kind = "status_err"
elif ch in (ord("n"), ord("N")):
name = prompt_line(stdscr, "New folder name: ")
if name is None:
continue
name = name.strip()
if not name:
continue
try:
(cwd / name).mkdir()
status = f"Created '{name}/'"
status_kind = "status_ok"
except OSError as e:
status = f"Create failed: {e}"
status_kind = "status_err"
elif ch in (ord("c"), ord("C")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot copy '..'"
status_kind = "status_err"
continue
copy_source = entry
move_source = None
status = f"Copy: '{entry.name}' (press p to paste)"
status_kind = "status_ok"
elif ch in (ord("x"), ord("X")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot move '..'"
status_kind = "status_err"
continue
move_source = entry
copy_source = None
status = f"Move: '{entry.name}' (press p to paste)"
status_kind = "status_ok"
elif ch in (ord("p"), ord("P")):
src = copy_source if copy_source is not None else move_source
if src is None:
status = "Nothing to paste (mark with c or x first)"
status_kind = "status_err"
continue
moving = move_source is not None
dest = cwd / src.name
if src == dest:
status = "Source and destination are the same"
status_kind = "status_err"
copy_source = None
move_source = None
continue
try:
if dest.exists():
if not confirm(stdscr, f"Overwrite '{dest.name}'? (y/N) "):
status = "Paste cancelled"
status_kind = "status_ok"
continue
if dest.is_dir() and not src.is_dir():
raise IsADirectoryError(
f"'{dest.name}' is a directory"
)
if dest.is_file() and src.is_dir():
raise NotADirectoryError(
f"'{dest.name}' is a file"
)
if moving:
if dest.exists() and dest.is_dir() and src.is_dir():
# Replace the target folder (user confirmed).
shutil.rmtree(dest)
shutil.move(src, dest)
else:
if src.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True)
else:
shutil.copy2(src, dest)
status = f"{'Moved' if moving else 'Copied'} '{src.name}'"
status_kind = "status_ok"
except OSError as e:
status = f"{'Move' if moving else 'Copy'} failed: {e}"
status_kind = "status_err"
finally:
copy_source = None
move_source = None
except KeyboardInterrupt:
break
def entrypoint() -> None:
# Make sure multi-byte input (umlauts, ...) is decoded correctly.
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error:
pass
try:
curses.wrapper(main)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
entrypoint()