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