add prewie to filemanger
This commit is contained in:
+772
-13
@@ -4,16 +4,29 @@
|
|||||||
Keys:
|
Keys:
|
||||||
Up/Down or k/j Navigate
|
Up/Down or k/j Navigate
|
||||||
Enter Open (folder -> cd, file -> menu)
|
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)
|
/ Search (live filter while typing)
|
||||||
Backspace or h Go to parent directory
|
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
|
. Toggle hidden files
|
||||||
r Rename selected entry
|
r Rename selected entry
|
||||||
d Delete selected entry (asks for confirmation)
|
d Delete selected entry (asks for confirmation)
|
||||||
n Create a new folder
|
n Create a new folder
|
||||||
|
t Create a new empty file
|
||||||
c / x Mark selected entry as copy / move source
|
c / x Mark selected entry as copy / move source
|
||||||
p Paste the marked entry into the current folder
|
p Paste the marked entry into the current folder
|
||||||
q Quit
|
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
|
Symlinks to folders and files are followed: folder links can be entered
|
||||||
like real directories. Broken symlinks are not listed.
|
like real directories. Broken symlinks are not listed.
|
||||||
"""
|
"""
|
||||||
@@ -21,15 +34,38 @@ like real directories. Broken symlinks are not listed.
|
|||||||
import curses
|
import curses
|
||||||
import locale
|
import locale
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
PARENT = 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 ---------------------------------------------------------
|
# ---------- helpers ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -134,6 +170,279 @@ def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str:
|
|||||||
return label[:width].ljust(width)
|
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); 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"(?<![\w*])\*(?![\s*])(.+?)(?<![\s*])\*(?![\w])", "\x03\\1\x03", text)
|
||||||
|
text = re.sub(r"(?<![\w_])_(?![\s_])(.+?)(?<![\s_])_(?![\w])", "\x03\\1\x03", text)
|
||||||
|
# Strikethrough ~~text~~ -> -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 ------------------------------------------------------------
|
# ---------- input ------------------------------------------------------------
|
||||||
|
|
||||||
def prompt_line(
|
def prompt_line(
|
||||||
@@ -160,16 +469,18 @@ def prompt_line(
|
|||||||
# Keep keypad ON so arrow keys arrive as KEY_* constants instead of an
|
# 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".
|
# ESC prefix that would otherwise be mis-read as "user pressed cancel".
|
||||||
try:
|
try:
|
||||||
stdscr.addstr(row, 0, prompt)
|
stdscr.addstr(row, 0, prompt + initial)
|
||||||
stdscr.clrtoeol()
|
stdscr.clrtoeol()
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
buf = initial
|
buf = initial
|
||||||
while True:
|
while True:
|
||||||
ch = stdscr.get_wch()
|
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
|
return None
|
||||||
if ch in (10, 13, curses.KEY_ENTER):
|
if ch in ("\n", "\r", 10, 13, curses.KEY_ENTER):
|
||||||
break
|
break
|
||||||
if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"):
|
if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"):
|
||||||
if buf:
|
if buf:
|
||||||
@@ -198,11 +509,15 @@ def prompt_line(
|
|||||||
|
|
||||||
|
|
||||||
def confirm(stdscr: curses.window, prompt: str) -> bool:
|
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")
|
answer = prompt_line(stdscr, prompt, initial="n")
|
||||||
if answer is None:
|
if answer is None:
|
||||||
return False
|
return False
|
||||||
return answer.strip().lower().startswith("y")
|
return "y" in answer.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
# ---------- rendering -------------------------------------------------------
|
# ---------- rendering -------------------------------------------------------
|
||||||
@@ -246,15 +561,37 @@ def draw(
|
|||||||
show_hidden: bool,
|
show_hidden: bool,
|
||||||
query: str | None,
|
query: str | None,
|
||||||
colors: dict[str, int],
|
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:
|
) -> int:
|
||||||
stdscr.erase()
|
stdscr.erase()
|
||||||
max_y, max_x = stdscr.getmaxyx()
|
max_y, max_x = stdscr.getmaxyx()
|
||||||
|
|
||||||
visible = filtered if filtered is not None else entries
|
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
|
# Header
|
||||||
hidden_flag = " (hidden shown)" if show_hidden else ""
|
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:
|
if colors:
|
||||||
stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD)
|
stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD)
|
||||||
try:
|
try:
|
||||||
@@ -264,6 +601,15 @@ def draw(
|
|||||||
if colors:
|
if colors:
|
||||||
stdscr.attroff(curses.color_pair(colors["header"]) | curses.A_BOLD)
|
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 area
|
||||||
list_top = 1
|
list_top = 1
|
||||||
list_bottom = max_y - 2 # leave one row for status, one for footer
|
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)
|
is_dir = entry.is_dir(follow_symlinks=True)
|
||||||
full = entry
|
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
|
attr = curses.A_NORMAL
|
||||||
if colors:
|
if colors:
|
||||||
@@ -318,7 +666,7 @@ def draw(
|
|||||||
line_y = list_top + (entry_i - start)
|
line_y = list_top + (entry_i - start)
|
||||||
if line_y < list_bottom:
|
if line_y < list_bottom:
|
||||||
try:
|
try:
|
||||||
stdscr.addnstr(line_y, 0, label, max_x - 1, attr)
|
stdscr.addnstr(line_y, 0, label, list_width - 1, attr)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -338,17 +686,265 @@ def draw(
|
|||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Footer (key hints)
|
# Footer (key hints) - always ends with the quit hint.
|
||||||
footer = " Up/Dn move | Enter open | / live-search | r rename | d delete | n new dir | c copy | x move | p paste | . hidden | q quit "
|
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:
|
try:
|
||||||
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
|
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
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()
|
stdscr.refresh()
|
||||||
return selected
|
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 -----------------------------
|
# ---------- file type / open-with configuration -----------------------------
|
||||||
|
|
||||||
OPEN_COMMANDS = {
|
OPEN_COMMANDS = {
|
||||||
@@ -558,6 +1154,12 @@ def main(stdscr: curses.window) -> None:
|
|||||||
filtered = None # when not None, used for display (search active)
|
filtered = None # when not None, used for display (search active)
|
||||||
copy_source: Path | None = None
|
copy_source: Path | None = None
|
||||||
move_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:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -582,6 +1184,32 @@ def main(stdscr: curses.window) -> None:
|
|||||||
]
|
]
|
||||||
filtered = candidates
|
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(
|
selected = draw(
|
||||||
stdscr,
|
stdscr,
|
||||||
entries,
|
entries,
|
||||||
@@ -593,6 +1221,14 @@ def main(stdscr: curses.window) -> None:
|
|||||||
show_hidden,
|
show_hidden,
|
||||||
query,
|
query,
|
||||||
colors,
|
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 = ""
|
status = ""
|
||||||
@@ -603,6 +1239,10 @@ def main(stdscr: curses.window) -> None:
|
|||||||
if ch in (ord("q"),):
|
if ch in (ord("q"),):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if ch == 12: # Ctrl+L: force a full redraw
|
||||||
|
stdscr.clear()
|
||||||
|
continue
|
||||||
|
|
||||||
if ch in (curses.KEY_UP, ord("k")):
|
if ch in (curses.KEY_UP, ord("k")):
|
||||||
vis = filtered if filtered is not None else entries
|
vis = filtered if filtered is not None else entries
|
||||||
if vis:
|
if vis:
|
||||||
@@ -613,14 +1253,74 @@ def main(stdscr: curses.window) -> None:
|
|||||||
if vis:
|
if vis:
|
||||||
selected = (selected + 1) % len(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")):
|
elif ch in (curses.KEY_BACKSPACE, 127, 8, ord("h")):
|
||||||
# Parent directory
|
# Parent directory
|
||||||
if cwd != cwd.parent:
|
if cwd != cwd.parent:
|
||||||
|
dir_cursors[str(cwd)] = selected
|
||||||
cwd = cwd.parent
|
cwd = cwd.parent
|
||||||
os.chdir(cwd)
|
os.chdir(cwd)
|
||||||
selected = 0
|
selected = dir_cursors.get(str(cwd), 0)
|
||||||
filtered = None
|
filtered = None
|
||||||
query = None
|
query = None
|
||||||
|
preview = False
|
||||||
else:
|
else:
|
||||||
status = "Already at filesystem root"
|
status = "Already at filesystem root"
|
||||||
status_kind = "status_err"
|
status_kind = "status_err"
|
||||||
@@ -632,18 +1332,22 @@ def main(stdscr: curses.window) -> None:
|
|||||||
entry = vis[selected]
|
entry = vis[selected]
|
||||||
if is_parent(entry):
|
if is_parent(entry):
|
||||||
if cwd != cwd.parent:
|
if cwd != cwd.parent:
|
||||||
|
dir_cursors[str(cwd)] = selected
|
||||||
cwd = cwd.parent
|
cwd = cwd.parent
|
||||||
os.chdir(cwd)
|
os.chdir(cwd)
|
||||||
selected = 0
|
selected = dir_cursors.get(str(cwd), 0)
|
||||||
filtered = None
|
filtered = None
|
||||||
query = None
|
query = None
|
||||||
|
preview = False
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
try:
|
try:
|
||||||
|
dir_cursors[str(cwd)] = selected
|
||||||
os.chdir(entry)
|
os.chdir(entry)
|
||||||
cwd = Path.cwd()
|
cwd = Path.cwd()
|
||||||
selected = 0
|
selected = dir_cursors.get(str(cwd), 0)
|
||||||
filtered = None
|
filtered = None
|
||||||
query = None
|
query = None
|
||||||
|
preview = False
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
status = f"Permission denied: {entry.name}"
|
status = f"Permission denied: {entry.name}"
|
||||||
status_kind = "status_err"
|
status_kind = "status_err"
|
||||||
@@ -652,6 +1356,19 @@ def main(stdscr: curses.window) -> None:
|
|||||||
status_kind = "status_err"
|
status_kind = "status_err"
|
||||||
else:
|
else:
|
||||||
if entry.is_file(follow_symlinks=True):
|
if entry.is_file(follow_symlinks=True):
|
||||||
|
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)
|
argv = file_menu(stdscr, entry, colors)
|
||||||
if argv is not None:
|
if argv is not None:
|
||||||
status = run_external(stdscr, argv)
|
status = run_external(stdscr, argv)
|
||||||
@@ -703,6 +1420,48 @@ def main(stdscr: curses.window) -> None:
|
|||||||
]
|
]
|
||||||
filtered = candidates if candidates else []
|
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("."):
|
elif ch == ord("."):
|
||||||
show_hidden = not show_hidden
|
show_hidden = not show_hidden
|
||||||
selected = 0
|
selected = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user