#!/usr/bin/env python3 """SJN File Manager - a TUI file manager. Keys: Up/Down or k/j Navigate Enter Open (folder -> cd, file -> menu) / Search (live-filter) Backspace or h Go to parent directory . Toggle hidden files q Quit """ import curses import os import shlex import subprocess from pathlib import Path PARENT = Path("..") # ---------- helpers --------------------------------------------------------- FILE_CATEGORIES = { "Bilder": { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg" }, "Videos": { ".mp4", ".mkv", ".avi", ".mov", ".webm" }, "Musik": { ".mp3", ".wav", ".flac", ".ogg", ".m4a" }, "Dokumente": { ".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf" }, "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): 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): """Return [.., dir1, dir2, ..., file1, file2, ...].""" try: all_entries = list(cwd.iterdir()) except PermissionError: return [PARENT] 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=False)), key=lambda e: e.name.lower(), ) files = sorted( (e for e in all_entries if e.is_file(follow_symlinks=False)), key=lambda e: e.name.lower(), ) return [PARENT] + dirs + files 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) # ---------- input ------------------------------------------------------------ def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None: """Show a prompt at the bottom and read a line. Returns None if cancelled (ESC). 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) stdscr.clrtoeol() stdscr.refresh() buf = initial while True: ch = stdscr.getch() if ch == 27: # bare ESC (no following escape-sequence bytes) return None if ch in (10, 13, curses.KEY_ENTER): break if ch in (curses.KEY_BACKSPACE, 127, 8): if buf: buf = buf[:-1] elif 0 <= ch < 256: # Plain ASCII printable; safe to append. buf += chr(ch) # Arrow keys, function keys, etc. are silently ignored. 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 # ---------- 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, entries, filtered, selected, cwd, status, status_kind, show_hidden, query, colors): stdscr.erase() max_y, max_x = stdscr.getmaxyx() visible = filtered if filtered is not None else entries # Header hidden_flag = " (hidden shown)" if show_hidden else "" header = f" {cwd}{hidden_flag} " 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) # 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=False) full = entry label = format_entry(name, is_dir, full, max_x - 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, max_x - 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) footer = " Up/Dn move | Enter open | / search | .. parent | . hidden | q quit " try: stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM) except curses.error: pass stdscr.refresh() return selected # ---------- 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, filepath: Path, colors) -> 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, 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): 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) while True: try: entries = list_entries(cwd, show_hidden) 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 selected = draw( stdscr, entries, filtered, selected, cwd, status, status_kind, show_hidden, query, colors, ) status = "" status_kind = "status_ok" ch = stdscr.getch() if ch in (ord("q"),): break 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 (curses.KEY_BACKSPACE, 127, 8, ord("h")): # Parent directory if cwd != cwd.parent: cwd = cwd.parent os.chdir(cwd) selected = 0 filtered = None query = None 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: cwd = cwd.parent os.chdir(cwd) selected = 0 filtered = None query = None elif entry.is_dir(follow_symlinks=False): try: os.chdir(entry) cwd = Path.cwd() selected = 0 filtered = None query = None 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=False): 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" cwd = Path.cwd() selected = 0 elif ch == ord("/"): q = prompt_line(stdscr, "/ ") 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 == ord("."): show_hidden = not show_hidden selected = 0 # Re-apply search if active if query: qlow = query.lower() candidates = [ e for e in list_entries(cwd, show_hidden) if qlow in e.name.lower() ] filtered = candidates if candidates else [] except KeyboardInterrupt: break def entrypoint(): try: curses.wrapper(main) except KeyboardInterrupt: pass if __name__ == "__main__": entrypoint()