#!/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 while typing) Backspace or h Go to parent directory . Toggle hidden files r Rename selected entry d Delete selected entry (asks for confirmation) n Create a new folder c / x Mark selected entry as copy / move source p Paste the marked entry into the current folder q Quit 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 shlex import shutil import subprocess from collections.abc import Callable 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": { ".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) # ---------- 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) stdscr.clrtoeol() stdscr.refresh() buf = initial while True: ch = stdscr.get_wch() if ch in (27, -1): # bare ESC (get_wch reports -1 for ESC) return None if ch in (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.""" answer = prompt_line(stdscr, prompt, initial="n") if answer is None: return False return answer.strip().lower().startswith("y") # ---------- 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], ) -> int: 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=True) 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 | / live-search | r rename | d delete | n new dir | c copy | x move | p paste | . 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: 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 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 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=True): 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=True): 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 == 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()