647 lines
18 KiB
Python
647 lines
18 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)
|
|
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()
|