#!/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"(? -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()