Files
tinyproject-monorepo/prg/SJNet_filemanager.py
T

884 lines
28 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)
/ 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()