initialise project

This commit is contained in:
2026-08-23 13:03:06 +02:00
commit 2dfdf54d42
22 changed files with 3169 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
#!/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()