diff --git a/prg/SJNet_filemanager.py b/prg/SJNet_filemanager.py index a8d26c2..342c3fb 100644 --- a/prg/SJNet_filemanager.py +++ b/prg/SJNet_filemanager.py @@ -4,16 +4,29 @@ Keys: Up/Down or k/j Navigate Enter Open (folder -> cd, file -> menu) + v or Tab Toggle the preview pane for the selected entry + f Open the selected text file in the full-screen viewer / Search (live filter while typing) Backspace or h Go to parent directory + g / G Jump to top / bottom of the list + PgUp / PgDn Scroll the list a page at a time + ~ Jump to the home directory + s Cycle sort order (name / size / mtime) . Toggle hidden files r Rename selected entry d Delete selected entry (asks for confirmation) n Create a new folder + t Create a new empty file c / x Mark selected entry as copy / move source p Paste the marked entry into the current folder q Quit +Preview pane (v / Tab): shows the first ~50 lines of the text file under +the cursor, a rendered view of Markdown files (headings, bold, italic, +inline code, code blocks, lists, block quotes, simple [text](url) links), +a sizes listing for folders, or "(binary)" otherwise. j/k scroll the +preview when it is open. + Symlinks to folders and files are followed: folder links can be entered like real directories. Broken symlinks are not listed. """ @@ -21,15 +34,38 @@ like real directories. Broken symlinks are not listed. import curses import locale import os +import re import shlex import shutil import subprocess +import time from collections.abc import Callable from pathlib import Path PARENT = Path("..") +# Preview settings: how many lines of a text file to show. +PREVIEW_LINES = 50 +# Files larger than this are treated as "too big" for the preview. +PREVIEW_MAX_BYTES = 2 * 1024 * 1024 +# Extensions considered (probably) plain text for the preview. +TEXT_EXTENSIONS = { + ".txt", ".md", ".markdown", ".rst", ".log", ".ini", ".cfg", ".conf", + ".toml", ".yaml", ".yml", ".json", ".xml", ".html", ".css", + ".py", ".js", ".ts", ".java", ".c", ".h", ".cpp", ".hpp", ".rs", + ".go", ".php", ".rb", ".sh", ".bash", ".zsh", ".fish", ".ps1", + ".sql", ".lua", ".vim", ".dockerfile", ".gitignore", ".csv", + ".service", ".desktop", ".env", ".text", +} +# Filenames (without extension) that are usually plain text. +TEXT_FILENAMES = { + "readme", "license", "changelog", "todo", "makefile", "dockerfile", + ".gitignore", ".gitattributes", ".editorconfig", ".env", +} + +SORT_MODES = ("name", "size", "mtime") + # ---------- helpers --------------------------------------------------------- @@ -134,6 +170,279 @@ def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str: return label[:width].ljust(width) +def sort_entries( + items: list[Path], mode: str +) -> list[Path]: + """Sort a list of entry paths (already without the '..' entry).""" + if mode == "size": + def key(e: Path) -> tuple: + try: + if e.is_dir(follow_symlinks=True): + return (0, 0, e.name.lower()) + return (0, e.stat().st_size, e.name.lower()) + except OSError: + return (0, -1, e.name.lower()) + elif mode == "mtime": + def key(e: Path) -> tuple: + try: + return (0, -e.stat().st_mtime, e.name.lower()) + except OSError: + return (0, 0, e.name.lower()) + else: + def key(e: Path) -> tuple: + return (0, 0, e.name.lower()) + return sorted(items, key=key) + + +def is_text_file(path: Path) -> bool: + """Heuristic: does this look like a plain-text file we can preview?""" + try: + st = path.stat() + except OSError: + return False + if st.st_size == 0: + return True # empty files are trivially "text" + name = path.name.lower() + if path.suffix.lower() in TEXT_EXTENSIONS or name in TEXT_FILENAMES: + return True + if name == "dockerfile" or name.startswith("dockerfile."): + return True + if st.st_size > PREVIEW_MAX_BYTES: + return False + # Sniff: read a small chunk and look for NUL bytes / too many weird bytes. + try: + with path.open("rb") as fh: + chunk = fh.read(8192) + except OSError: + return False + if not chunk: + return True + if b"\x00" in chunk: + return False + weird = sum(1 for b in chunk if b < 9 or (13 < b < 32)) + return weird / len(chunk) < 0.10 + + +def read_preview_lines(path: Path) -> tuple[list[str], str, bool]: + """Read the first PREVIEW_LINES lines of a text file. + + Returns (lines, error, truncated). `lines` always has the raw text + (undecorated); error is "" on success or a short message; truncated is + True when the file has more lines than were returned. + """ + try: + st = path.stat() + except OSError as e: + return [f"(cannot stat: {e})"], "err", True + if st.st_size > PREVIEW_MAX_BYTES: + return ( + [f"(file too large for preview: {human_size(st.st_size)})"], + "err", + True, + ) + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + lines = [] + for i, line in enumerate(fh): + if i >= PREVIEW_LINES: + break + lines.append(line.rstrip("\n\r")) + truncated = bool(fh.readline()) + except OSError as e: + return [f"(cannot read: {e})"], "err", True + if not lines: + return ["(empty file)"], "", True + return lines, "", truncated + + +def format_mtime(ts: float) -> str: + """Format a timestamp as a short local date-time string.""" + try: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + except (OverflowError, OSError, ValueError): + return "?" + + +def total_lines(path: Path) -> int: + """Count lines in a (text) file; returns -1 on error.""" + try: + with path.open("rb") as fh: + return sum(1 for _ in fh) + except OSError: + return -1 + + +# ---------- markdown rendering for the preview ------------------------------ + + +def _md_inline(text: str) -> str: + """Render inline markdown (bold/italic/code/links) as plain text. + + Bold becomes *text*, italic /text/, code 'code', links text (url). + Placeholder control characters are used while rewriting so the rules + cannot chew on each other's markers; they are resolved at the end. + """ + # Inline code first, protected from all other rules. + text = re.sub(r"`([^`]+)`", "\x02\\1\x02", text) + # Images ![alt](url) -> alt (url); do before links (pattern overlap). + text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)\)", r"\1 (\2)", text) + # Links [text](url) -> text (url) + text = re.sub( + r"\[([^\]]+)\]\(([^)\s]+)\)", r"\1 (\2)", text + ) + # Bold **text** / __text__ -> *text* (placeholder, resolved below) + text = re.sub(r"\*\*(.+?)\*\*", "\x01\\1\x01", text) + text = re.sub(r"__(.+?)__", "\x01\\1\x01", text) + # Italic *text* / _text_ -> /text/ + text = re.sub(r"(? -text- + text = re.sub(r"~~(.+?)~~", r"-\1-", text) + # Resolve placeholders: bold '*', italic '/', code "'". + return text.replace("\x01", "*").replace("\x03", "/").replace("\x02", "'") + + +def _wrap(text: str, width: int) -> list[str]: + """Word-wrap a plain text line to width (>=1). Never returns [].""" + width = max(1, width) + if len(text) <= width: + return [text] + out = [] + rest = text + while len(rest) > width: + cut = rest.rfind(" ", 0, width + 1) + if cut <= 0: + cut = width + out.append(rest[:cut]) + rest = rest[cut:].lstrip() + out.append(rest) + return out + + +def render_markdown( + lines: list[str], width: int +) -> tuple[list[str], list[int]]: + """Very small Markdown-to-plain-text renderer for the preview pane. + + Supports: ATX headings (# .. ######), fenced code blocks (``` or ~~~), + bullet/numbered lists (with simple wrapping), block quotes (>), + horizontal rules (---), and inline styles (bold, italic, code, links). + + Returns (out_lines, out_attrs) where out_attrs[i] is the curses attribute + (an int) to use for out_lines[i]. Callers without colors can ignore it + (values degrade to plain A_NORMAL / A_BOLD / A_REVERSE). + """ + out: list[str] = [] + attrs: list[int] = [] + width = max(4, width) + + heading_attrs = { + 1: curses.A_BOLD | curses.A_UNDERLINE, + 2: curses.A_BOLD | curses.A_UNDERLINE, + 3: curses.A_BOLD, + } + + def emit(text: str, attr: int = curses.A_NORMAL) -> None: + if not text: + out.append("") + attrs.append(attr) + return + for piece in _wrap(text, width): + out.append(piece[:width]) + attrs.append(attr) + + in_code = False + code_marker = "" + prev_blank = True + for raw in lines: + line = raw.rstrip("\n") + + # Fenced code blocks + stripped = line.lstrip() + if in_code: + if stripped.startswith(code_marker): + in_code = False + emit("") + prev_blank = True + else: + for piece in _wrap(stripped, width): + out.append(" " + piece[: max(1, width - 4)]) + attrs.append(curses.A_NORMAL) + continue + if stripped.startswith("```") or stripped.startswith("~~~"): + in_code = True + code_marker = stripped[:3] + emit("") + prev_blank = True + continue + + # Horizontal rule + if re.fullmatch(r"\s*(\*\s*){3,}(-\s*){3,}(_\s*){3,}", line): + emit("") + emit("-" * width, curses.A_DIM) + prev_blank = False + continue + + # Headings + m = re.match(r"(#{1,6})\s+(.*?)\s*#*\s*$", line) + if m: + level = len(m.group(1)) + text = _md_inline(m.group(2)) + emit("") + if level <= 2: + attr = heading_attrs[level] + emit(text, attr) + emit("=" * min(width, max(4, len(text))), curses.A_DIM) + else: + emit(text, heading_attrs.get(3, curses.A_BOLD)) + prev_blank = False + continue + + # Block quotes (single-level, consecutive > lines merged) + if stripped.startswith(">"): + content = stripped.lstrip("> ") + emit("│ " + _md_inline(content), curses.A_BOLD) + prev_blank = False + continue + + # Lists + lm = re.match(r"(\s*)([-*+]|\d+[.)])\s+(.*)$", line) + if lm: + indent = len(lm.group(1)) // 2 + marker = lm.group(2) + if marker in "-*+": + bullet = "• " + else: + bullet = marker.split(".")[0].split(")")[0] + ". " + prefix = " " * indent + bullet + body = _md_inline(lm.group(3)) + first = True + for piece in _wrap(prefix + body, max(4, width - indent * 2)): + if first: + emit(piece, curses.A_BOLD if bullet.strip() == "•" else curses.A_NORMAL) + first = False + else: + emit(" " * indent + " " + piece) + prev_blank = False + continue + + # Blank line + if not stripped: + if not prev_blank: + emit("") + prev_blank = True + continue + + # Paragraph text + emit(_md_inline(stripped)) + prev_blank = False + + while out and out[-1] == "": + out.pop() + attrs.pop() + return out, attrs + + # ---------- input ------------------------------------------------------------ def prompt_line( @@ -160,16 +469,18 @@ def prompt_line( # 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.addstr(row, 0, prompt + initial) 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) + # get_wch returns ints for special keys but plain strings for + # ordinary characters - including "\n", "\r" and "\x1b". + if ch in ("\x1b", 27, -1): # bare ESC return None - if ch in (10, 13, curses.KEY_ENTER): + if ch in ("\n", "\r", 10, 13, curses.KEY_ENTER): break if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"): if buf: @@ -198,11 +509,15 @@ def prompt_line( def confirm(stdscr: curses.window, prompt: str) -> bool: - """Ask a yes/no question via prompt_line. True only on explicit y/yes.""" + """Ask a yes/no question via prompt_line. True only on explicit y/yes. + + The buffer is prefilled with "n", so a typed "y" lands after it ("ny"); + we therefore look for a "y" anywhere instead of checking the first char. + """ answer = prompt_line(stdscr, prompt, initial="n") if answer is None: return False - return answer.strip().lower().startswith("y") + return "y" in answer.strip().lower() # ---------- rendering ------------------------------------------------------- @@ -246,15 +561,37 @@ def draw( show_hidden: bool, query: str | None, colors: dict[str, int], + split: bool = False, + preview_top: int = 0, + info_suffix: str = "", + ordered: bool = False, + sort_mode: str = "name", + copy_source: Path | None = None, + move_source: Path | None = None, + preview_top_box: list[int] | None = None, ) -> int: stdscr.erase() max_y, max_x = stdscr.getmaxyx() visible = filtered if filtered is not None else entries + # Split layout: leave the right half of the screen for the preview pane. + list_width = max_x + preview_lines: list[str] | None = None + preview_attrs: list[int] = [] + preview_title = "" + preview_width = 0 + preview_x = max_x + if split and max_x >= 40: + preview_x = max_x // 2 + 1 + preview_width = max_x - preview_x + list_width = preview_x - 1 + # Header hidden_flag = " (hidden shown)" if show_hidden else "" - header = f" {cwd}{hidden_flag} " + sort_flag = f" [sort: {sort_mode}]" if ordered else "" + flags = hidden_flag + sort_flag + header = f" {cwd}{flags} " if colors: stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD) try: @@ -264,6 +601,15 @@ def draw( if colors: stdscr.attroff(curses.color_pair(colors["header"]) | curses.A_BOLD) + # Right side of the header: entry counts (dirs/files). + try: + n_dirs = sum(1 for e in visible if not is_parent(e) and e.is_dir(follow_symlinks=True)) + n_files = len(visible) - n_dirs - (1 if any(is_parent(e) for e in visible) else 0) + counts = f"{n_dirs} dir(s), {n_files} file(s) " + stdscr.addnstr(0, max(0, max_x - len(counts) - 1), counts, max_x - 1, curses.A_DIM) + except curses.error: + pass + # List area list_top = 1 list_bottom = max_y - 2 # leave one row for status, one for footer @@ -297,7 +643,9 @@ def draw( is_dir = entry.is_dir(follow_symlinks=True) full = entry - label = format_entry(name, is_dir, full, max_x - 1) + label = format_entry(name, is_dir, full, list_width - 1) + if info_suffix and entry_i == selected: + label = (label.rstrip() + " " + info_suffix)[: list_width - 1].ljust(list_width - 1) attr = curses.A_NORMAL if colors: @@ -318,7 +666,7 @@ def draw( line_y = list_top + (entry_i - start) if line_y < list_bottom: try: - stdscr.addnstr(line_y, 0, label, max_x - 1, attr) + stdscr.addnstr(line_y, 0, label, list_width - 1, attr) except curses.error: pass @@ -338,17 +686,265 @@ def draw( 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 " + # Footer (key hints) - always ends with the quit hint. + base_footer = " Up/Dn move | Enter open | v preview | / search | r rename | d delete | n new dir | t new file | c copy | x move | p paste | s sort | . hidden | ~ home | q quit " + if split: + base_footer = " j/k scroll preview | Enter view file | f view | v close | g/G top/bottom | q quit " + mark = "" + if move_source is not None: + mark = f" [MOVE: {move_source.name}]" + elif copy_source is not None: + mark = f" [COPY: {copy_source.name}]" + footer = (base_footer + mark)[-max_x:] try: stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM) except curses.error: pass + # Preview pane is drawn last so it can overlay the (cleared) right half. + if split and max_x >= 40 and preview_lines is None and visible: + entry = visible[selected] + preview_lines, preview_attrs, preview_title = build_preview( + entry, cwd, preview_width + ) + if preview_lines is not None: + new_top = draw_preview( + stdscr, + preview_lines, + preview_attrs, + preview_title, + preview_top, + preview_x, + preview_width, + rows, + colors, + ) + if preview_top_box is not None: + preview_top_box[0] = new_top + stdscr.refresh() return selected +# ---------- preview ---------------------------------------------------------- + + +def build_preview( + entry: Path, cwd: Path, width: int +) -> tuple[list[str], list[int], str]: + """Build the preview content for an entry. + + Returns (lines, attrs, title). `lines`/`attrs` are parallel lists as in + render_markdown; title is shown at the top of the preview pane. + """ + if is_parent(entry): + target = cwd.parent + else: + target = entry + + try: + is_dir = target.is_dir(follow_symlinks=True) + except OSError: + is_dir = False + + if is_dir: + lines: list[str] = [] + attrs: list[int] = [] + try: + st = target.stat() + lines.append(f"Folder: {target.name or str(target)}") + attrs.append(curses.A_BOLD) + lines.append("") + attrs.append(0) + sub = sorted(target.iterdir(), key=lambda p: p.name.lower()) + dirs = [p for p in sub if p.is_dir(follow_symlinks=True)] + files = [p for p in sub if not p.is_dir(follow_symlinks=True)] + lines.append(f"{len(dirs)} folders, {len(files)} files") + attrs.append(0) + lines.append("") + attrs.append(0) + for p in (dirs + files)[:PREVIEW_LINES]: + try: + pst = p.stat() + size = human_size(pst.st_size) + mtime = format_mtime(pst.st_mtime) + except OSError: + size, mtime = "?", "?" + kind = "D" if p.is_dir(follow_symlinks=True) else "F" + name_w = max(1, width - 24) + lines.append( + f" {kind} {p.name[:name_w]:<{name_w}} {size:>7} {mtime}" + ) + attrs.append(0) + shown = min(len(sub), PREVIEW_LINES) + if len(sub) > shown: + lines.append(f" ... and {len(sub) - shown} more") + attrs.append(curses.A_DIM) + except OSError as e: + lines.append(f"(cannot read folder: {e})") + attrs.append(0) + return lines, attrs, f"Folder: {target.name or str(target)}" + + if not target.is_file(follow_symlinks=True): + return (["(not a regular file)"], [0], target.name) + + if is_text_file(target): + raw, err, truncated = read_preview_lines(target) + if err: + return raw, [0] * len(raw), target.name + if target.suffix.lower() in (".md", ".markdown"): + body, body_attrs = render_markdown(raw, max(4, width - 2)) + title = f"Markdown: {target.name}" + else: + body = [line[:width] for line in raw] + body_attrs = [0] * len(body) + title = target.name + try: + st = target.stat() + head = ( + f"{human_size(st.st_size)} {format_mtime(st.st_mtime)} " + f"first {len(raw)} of {total_lines(target)} lines" + ) + except OSError: + head = f"first {len(raw)} lines" + if truncated: + head += " (truncated)" + lines = [head, ""] + body + attrs = [curses.A_DIM, 0] + body_attrs + return lines, attrs, title + + return (["(binary file - no preview)"], [0], target.name) + + +def draw_preview( + stdscr: curses.window, + lines: list[str], + attrs: list[int], + title: str, + top: int, + x: int, + width: int, + height: int, + colors: dict[str, int], +) -> int: + """Draw the preview pane at (x, 1) with the given size. Returns new top.""" + if width < 3 or height < 1: + return top + top = max(0, min(top, max(0, len(lines) - 1))) + border_attr = curses.A_DIM + if colors and "header" in colors: + border_attr = curses.color_pair(colors["header"]) + + # Title row + title_text = f" {title} "[: width - 1] + try: + stdscr.addnstr(0, x, title_text, width - 1, border_attr | curses.A_BOLD) + stdscr.addnstr(0, x + len(title_text), "─" * (width - len(title_text) - 1), width - 1 - len(title_text), border_attr) + except curses.error: + pass + + for row in range(height): + idx = top + row + if idx >= len(lines): + break + text = lines[idx][: width - 2] + attr = attrs[idx] if idx < len(attrs) else curses.A_NORMAL + if attr == 0: + attr = curses.A_NORMAL + try: + stdscr.addnstr(1 + row, x, text, width - 2, attr) + except curses.error: + pass + + # Scroll indicator + if len(lines) > height: + frac = top / max(1, len(lines) - height) + pos = min(height - 1, int(frac * (height - 1))) + try: + stdscr.addch(1 + pos, x + width - 1, "█", border_attr) + except curses.error: + pass + return top + + +def view_file_fullscreen( + stdscr: curses.window, filepath: Path, colors: dict[str, int] +) -> str: + """Full-screen text viewer for a file (Enter on it in preview mode, or f). + + Returns a short status string. Supports Markdown rendering (toggle with m). + """ + raw, err, _trunc = read_preview_lines(filepath) + if err: + return err + is_md = filepath.suffix.lower() in (".md", ".markdown") + + def content(max_y: int, max_x: int, rendered: bool) -> tuple[list[str], list[int]]: + if rendered and is_md: + return render_markdown(raw, max(4, max_x - 2)) + return [line[: max(1, max_x - 1)] for line in raw], [0] * len(raw) + + top = 0 + rendered = is_md # default to rendered for Markdown + while True: + stdscr.erase() + max_y, max_x = stdscr.getmaxyx() + lines, attrs = content(max_y - 2, max_x, rendered) + top = max(0, min(top, max(0, len(lines) - 1))) + title = f" {filepath.name} " + if is_md: + title += "[m: raw]" if rendered else "[m: rendered]" + title += " [q: back]" + tattr = curses.A_BOLD | (curses.color_pair(colors["header"]) if colors else 0) + try: + stdscr.addnstr(0, 0, title[: max_x - 1], max_x - 1, tattr) + except curses.error: + pass + for row in range(max_y - 2): + idx = top + row + if idx >= len(lines): + break + attr = attrs[idx] if idx < len(attrs) else 0 + if attr == 0: + attr = curses.A_NORMAL + try: + stdscr.addnstr(1 + row, 0, lines[idx], max_x - 1, attr) + except curses.error: + pass + try: + stdscr.addnstr( + max_y - 1, 0, + f" {len(raw)} lines | j/k or arrows scroll | m toggle rendering | q back ", + max_x - 1, curses.A_DIM, + ) + except curses.error: + pass + stdscr.refresh() + + ch = stdscr.getch() + page = max(1, (max_y - 3)) + if ch in (ord("q"), 27): + break + elif ch in (curses.KEY_UP, ord("k")): + top = max(0, top - 1) + elif ch in (curses.KEY_DOWN, ord("j")): + top = min(max(0, len(lines) - 1), top + 1) + elif ch in (curses.KEY_PPAGE, curses.KEY_BACKSPACE, 127, 8): + top = max(0, top - page) + elif ch in (curses.KEY_NPAGE, ord(" ")): + top = min(max(0, len(lines) - 1), top + page) + elif ch in (curses.KEY_HOME, ord("g")): + top = 0 + elif ch in (curses.KEY_END, ord("G")): + top = max(0, len(lines) - 1) + elif ch in (ord("m"), ord("M")): + if is_md: + rendered = not rendered + elif ch == curses.KEY_RESIZE: + continue + return "" + + # ---------- file type / open-with configuration ----------------------------- OPEN_COMMANDS = { @@ -558,6 +1154,12 @@ def main(stdscr: curses.window) -> None: filtered = None # when not None, used for display (search active) copy_source: Path | None = None move_source: Path | None = None + preview = False # split screen with preview pane on/off + preview_top = 0 # scroll offset inside the preview pane + preview_top_box = [0] # draw() clamps it back through this + preview_sel_at_reset = 0 # selection the preview scroll was last reset for + sort_mode = "name" # current sort order + dir_cursors: dict[str, int] = {} # remember cursor per directory while True: try: @@ -582,6 +1184,32 @@ def main(stdscr: curses.window) -> None: ] filtered = candidates + # Sort (only the real entries; '..' stays on top). + if filtered is None and sort_mode != "name": + real = [e for e in entries if not is_parent(e)] + parent_only = [e for e in entries if is_parent(e)] + entries = parent_only + sort_entries(real, sort_mode) + + # Info for the selected entry, shown at the end of its row. + vis_now = filtered if filtered is not None else entries + info_suffix = "" + if vis_now: + sel_entry = vis_now[max(0, min(selected, len(vis_now) - 1))] + if not is_parent(sel_entry): + try: + st = sel_entry.stat() + info_suffix = ( + f"{human_size(st.st_size)} {format_mtime(st.st_mtime)}" + ) + except OSError: + pass + + # Scroll the preview back to its top when the selection changed. + if preview and selected != preview_sel_at_reset: + preview_top = 0 + preview_top_box[0] = 0 + preview_sel_at_reset = selected + selected = draw( stdscr, entries, @@ -593,6 +1221,14 @@ def main(stdscr: curses.window) -> None: show_hidden, query, colors, + split=preview, + preview_top=preview_top, + preview_top_box=preview_top_box, + info_suffix=info_suffix, + ordered=sort_mode != "name", + sort_mode=sort_mode, + copy_source=copy_source, + move_source=move_source, ) status = "" @@ -603,6 +1239,10 @@ def main(stdscr: curses.window) -> None: if ch in (ord("q"),): break + if ch == 12: # Ctrl+L: force a full redraw + stdscr.clear() + continue + if ch in (curses.KEY_UP, ord("k")): vis = filtered if filtered is not None else entries if vis: @@ -613,14 +1253,74 @@ def main(stdscr: curses.window) -> None: if vis: selected = (selected + 1) % len(vis) + elif ch in (ord("K"),) and preview: + preview_top = max(0, preview_top_box[0] - 1) + + elif ch in (ord("J"),) and preview: + preview_top = min( + max(0, preview_top_box[0] + 1), preview_top_box[0] + 1 + ) + + elif ch == ord("g"): + selected = 0 + + elif ch == ord("G"): + vis = filtered if filtered is not None else entries + if vis: + selected = len(vis) - 1 + + elif ch == curses.KEY_PPAGE: + max_y, _mx = stdscr.getmaxyx() + page = max(1, (max_y - 3)) + selected = max(0, selected - page) + + elif ch == curses.KEY_NPAGE: + max_y, _mx = stdscr.getmaxyx() + vis = filtered if filtered is not None else entries + page = max(1, (max_y - 3)) + if vis: + selected = min(len(vis) - 1, selected + page) + + elif ch == ord("~"): + try: + home = Path.home() + except RuntimeError: + home = None + if home is None or not home.is_dir(): + status = "Cannot determine home directory" + status_kind = "status_err" + elif home == cwd: + status = "Already in home directory" + status_kind = "status_ok" + else: + try: + os.chdir(home) + dir_cursors[str(cwd)] = selected + cwd = Path.cwd() + selected = dir_cursors.get(str(cwd), 0) + filtered = None + query = None + preview = False + except OSError as e: + status = f"Cannot enter home: {e}" + status_kind = "status_err" + + elif ch == ord("s"): + idx = SORT_MODES.index(sort_mode) + sort_mode = SORT_MODES[(idx + 1) % len(SORT_MODES)] + status = f"Sort: {sort_mode}" + status_kind = "status_ok" + elif ch in (curses.KEY_BACKSPACE, 127, 8, ord("h")): # Parent directory if cwd != cwd.parent: + dir_cursors[str(cwd)] = selected cwd = cwd.parent os.chdir(cwd) - selected = 0 + selected = dir_cursors.get(str(cwd), 0) filtered = None query = None + preview = False else: status = "Already at filesystem root" status_kind = "status_err" @@ -632,18 +1332,22 @@ def main(stdscr: curses.window) -> None: entry = vis[selected] if is_parent(entry): if cwd != cwd.parent: + dir_cursors[str(cwd)] = selected cwd = cwd.parent os.chdir(cwd) - selected = 0 + selected = dir_cursors.get(str(cwd), 0) filtered = None query = None + preview = False elif entry.is_dir(follow_symlinks=True): try: + dir_cursors[str(cwd)] = selected os.chdir(entry) cwd = Path.cwd() - selected = 0 + selected = dir_cursors.get(str(cwd), 0) filtered = None query = None + preview = False except PermissionError: status = f"Permission denied: {entry.name}" status_kind = "status_err" @@ -652,14 +1356,27 @@ def main(stdscr: curses.window) -> None: 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 + if preview: + # In preview mode Enter opens the built-in viewer. + if is_text_file(entry): + msg = view_file_fullscreen(stdscr, entry, colors) + if msg: + status = msg + status_kind = "status_err" + else: + 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" + else: + 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: @@ -703,6 +1420,48 @@ def main(stdscr: curses.window) -> None: ] filtered = candidates if candidates else [] + elif ch in (ord("v"), ord("V"), 9): # 9 = Tab + preview = not preview + if preview: + preview_top = 0 + preview_top_box[0] = 0 + preview_sel_at_reset = selected + + elif ch in (ord("f"), ord("F")): + vis = filtered if filtered is not None else entries + if not vis: + continue + entry = vis[selected] + if is_parent(entry): + status = "No file selected" + status_kind = "status_err" + elif entry.is_file(follow_symlinks=True) and is_text_file(entry): + msg = view_file_fullscreen(stdscr, entry, colors) + if msg: + status = msg + status_kind = "status_err" + else: + status = "Not a previewable text file" + status_kind = "status_err" + + elif ch == ord("t") or ch == ord("T"): + name = prompt_line(stdscr, "New file name: ") + if name is None: + continue + name = name.strip() + if not name: + continue + try: + target = cwd / name + if target.exists(): + raise FileExistsError(f"'{name}' already exists") + target.touch() + status = f"Created '{name}'" + status_kind = "status_ok" + except OSError as e: + status = f"Create failed: {e}" + status_kind = "status_err" + elif ch == ord("."): show_hidden = not show_hidden selected = 0