diff --git a/README.md b/README.md index ab628bd..615276c 100644 --- a/README.md +++ b/README.md @@ -156,25 +156,66 @@ ftptui ## Tastaturkürzel +### Dateibrowser + | Taste | Aktion | |--------------|---------------------------------| | `Tab` | Zwischen lokalem und entferntem Paneel wechseln | | `↑` / `↓` | Zeilenauswahl bewegen | -| `→` | Markierten Ordner/Eintrag öffnen | +| `→` | Ordner öffnen / Datei ansehen | | `←` / `⌫` | Eine Verzeichnisebene höher | -| `Enter` | Ordner öffnen (aktiv: fokussiertes Paneel) | +| `Enter` | Ordner öffnen, Datei im Betrachter anzeigen | | `↑ ..` | Eine Ebene höher (Tabellenzeile) | -| `l` | Auswahl **herunterladen** (entfernt → lokal) | -| `u` | Auswahl **hochladen** (lokal → entfernt) | +| `Leertaste` | Eintrag **markieren/abwählen** (Cursor rückt weiter) | +| `Strg`+`A` | Alle Einträge markieren / Markierung aufheben | +| `Esc` | Markierung des aktiven Paneels aufheben | +| `v` | Datei im Betrachter anzeigen | +| `l` | **Herunterladen** (entfernt → lokal) | +| `u` | **Hochladen** (lokal → entfernt) | | `n` | Neues Verzeichnis anlegen | -| `d` | Auswahl löschen (rekursiv) | -| `r` | Auswahl umbenennen | -| `q` / `Esc` | Beenden | +| `d` | Löschen (rekursiv) | +| `r` | Umbenennen | +| `q` | Beenden | -> Hinweis: Hoch-/Herunterladen (`u`/`l`), Anlegen (`n`), Löschen (`d`) und -> Umbenennen (`r`) wirken auf das **aktive (grün markierte) Paneel**. Wechsel -> der Seite mit `Tab`. Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur -> (öffnen / eine Ebene hoch), `↑`/`↓` bewegen die Zeilenauswahl. +> Hinweis: Hoch-/Herunterladen (`u`/`l`), Löschen (`d`) und Umbenennen (`r`) +> wirken auf das **aktive (grün markierte) Paneel**. Wechsel der Seite mit `Tab`. +> Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur (öffnen / eine +> Ebene hoch), `↑`/`↓` bewegen die Zeilenauswahl. + +#### Mehrfachauswahl + +Mit der `Leertaste` markierte Einträge werden **fett und gelb** dargestellt; die +Pfadleiste oben zeigt an, wie viele Einträge je Paneel markiert sind. Liegt eine +Markierung vor, arbeiten `l`, `u` und `d` auf **allen markierten Einträgen**, +sonst auf dem Eintrag unter dem Cursor. Nach dem Transfer bzw. beim +Verzeichniswechsel wird die Markierung zurückgesetzt. + +### Datei-Betrachter + +`Enter` oder `v` auf einer Datei öffnet sie mit Syntax-Hervorhebung; die Sprache +wird über Pygments anhand der Endung erkannt (`.txt`, `.md`, `.py`, `.cpp`, +`.json`, `.yml`, …). Entfernte Dateien werden dafür in eine Temporärdatei +geladen und danach wieder entfernt. Binärdateien und Dateien über 2 MiB werden +abgelehnt statt angezeigt. + +| Taste | Aktion | +|------------------|-----------------------------| +| `Esc` / `q` | Betrachter schließen | +| `↑` / `↓` | Zeilenweise blättern | +| `Bild↑` / `Bild↓`| Seitenweise blättern | +| `Pos1` / `Ende` | Anfang / Ende | +| `w` | Zeilenumbruch umschalten | +| `l` | Zeilennummern umschalten | + +### Verbindungsdialog + +| Taste | Aktion | +|--------------|---------------------------------| +| `↑` / `↓` | Zwischen den Eingabefeldern wechseln | +| `Tab` | Nächstes Feld | +| `Enter` | In einem Eingabefeld: verbinden; in einer Auswahlliste: Liste öffnen | +| `Strg`+`S` | Profil speichern | +| `Esc` | Beenden | --- diff --git a/ftptui/__init__.py b/ftptui/__init__.py index 8861e2a..ab43fda 100644 --- a/ftptui/__init__.py +++ b/ftptui/__init__.py @@ -1,3 +1,3 @@ """ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface.""" -__version__ = "0.3.1" +__version__ = "0.4.0" diff --git a/ftptui/app.py b/ftptui/app.py index 8d276e0..0a69571 100644 --- a/ftptui/app.py +++ b/ftptui/app.py @@ -8,14 +8,18 @@ unten. Verwendet ``textual`` und unterstützt FTP sowie SFTP (SSH-FTP). from __future__ import annotations import os +import shutil +import tempfile from datetime import datetime from pathlib import Path +from rich.syntax import Syntax from rich.text import Text from textual import on from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, Vertical +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.coordinate import Coordinate from textual.screen import Screen from textual.widgets import ( Button, @@ -67,6 +71,114 @@ def _join(path: str, name: str, is_local: bool) -> str: return path.rstrip("/") + "/" + name +#: Dateien oberhalb dieser Größe werden nicht in den Viewer geladen. +MAX_VIEW_BYTES = 2 * 1024 * 1024 + + +def _looks_binary(data: bytes) -> bool: + """Heuristik: Null-Bytes oder viele nicht-druckbare Zeichen -> Binärdatei.""" + if b"\0" in data: + return True + sample = data[:4096] + if not sample: + return False + printable = sum(1 for b in sample if b in (9, 10, 13) or 32 <= b < 127 or b >= 160) + return printable / len(sample) < 0.85 + + +class ViewerScreen(Screen): + """Datei-Betrachter mit Syntax-Hervorhebung. + + Die Sprache wird von ``rich.syntax`` anhand des Dateinamens (und ersatzweise + des Inhalts) über Pygments bestimmt, sodass ``.txt``, ``.md``, ``.cpp``, + ``.py`` usw. ohne eigene Tabelle abgedeckt sind. + """ + + BINDINGS = [ + Binding("escape,q", "close", "Schließen"), + Binding("down,j", "scroll_down", "Runter", show=False), + Binding("up,k", "scroll_up", "Hoch", show=False), + Binding("pagedown", "page_down", "Seite runter", show=False), + Binding("pageup", "page_up", "Seite hoch", show=False), + Binding("home", "scroll_home", "Anfang", show=False), + Binding("end", "scroll_end", "Ende", show=False), + Binding("w", "toggle_wrap", "Umbruch"), + Binding("l", "toggle_lineno", "Zeilennummern"), + ] + + def __init__(self, filename: str, text: str, subtitle: str = "") -> None: + super().__init__() + self._filename = filename + self._text = text + self._subtitle = subtitle + self._wrap = True + self._lineno = True + + def compose(self) -> ComposeResult: + yield Static(id="viewer_title", classes="pathbar") + yield VerticalScroll(Static(id="viewer_body"), id="viewer_scroll") + yield Static( + " Esc/q Schließen · ↑/↓ Blättern · PgUp/PgDn Seite · " + "Pos1/Ende Anfang/Ende · w Umbruch · l Zeilennummern", + classes="statusbar", + ) + + def on_mount(self) -> None: + self.query_one("#viewer_title", Static).update( + f" {os.path.basename(self._filename)} {self._subtitle}" + ) + self._render_body() + self.query_one("#viewer_scroll").focus() + + def _render_body(self) -> None: + try: + lexer = Syntax.guess_lexer(self._filename, self._text) + except Exception: # noqa: BLE001 + lexer = "text" + self.query_one("#viewer_body", Static).update( + Syntax( + self._text, + lexer, + theme="monokai", + line_numbers=self._lineno, + word_wrap=self._wrap, + indent_guides=False, + ) + ) + + def action_toggle_wrap(self) -> None: + self._wrap = not self._wrap + self._render_body() + + def action_toggle_lineno(self) -> None: + self._lineno = not self._lineno + self._render_body() + + def action_close(self) -> None: + self.app.pop_screen() + + def _scroll(self): + return self.query_one("#viewer_scroll", VerticalScroll) + + def action_scroll_down(self) -> None: + self._scroll().scroll_down() + + def action_scroll_up(self) -> None: + self._scroll().scroll_up() + + def action_page_down(self) -> None: + self._scroll().scroll_page_down() + + def action_page_up(self) -> None: + self._scroll().scroll_page_up() + + def action_scroll_home(self) -> None: + self._scroll().scroll_home() + + def action_scroll_end(self) -> None: + self._scroll().scroll_end() + + class FileTable(DataTable): """DataTable mit Datei-Manager-Navigation. @@ -89,10 +201,39 @@ class FileTable(DataTable): self.screen.action_open() +class NavSelect(Select): + """``Select``, bei dem die Pfeiltasten das Feld wechseln. + + Ein eingeklapptes ``Select`` bindet ``up``/``down`` standardmäßig auf + ``show_overlay`` und verschluckt sie damit. Im Verbindungsdialog soll aber + überall einheitlich mit den Pfeiltasten zwischen den Feldern navigiert + werden; geöffnet wird die Liste mit Enter oder Leertaste. Ist das Overlay + offen, besitzt es selbst den Fokus und behält seine eigene + ``up``/``down``-Navigation über die Einträge. + """ + + BINDINGS = [ + Binding("enter,space", "show_overlay", "Auswahl öffnen", show=False), + Binding("up", "screen.prev_field", "Feld zurück", show=False), + Binding("down", "screen.next_field", "Feld weiter", show=False), + ] + + class ConnectionScreen(Screen): """Startbildschirm zur Eingabe der Verbindungsdaten (Terminal-Look).""" - BINDINGS = [Binding("escape", "app.quit", "Beenden")] + BINDINGS = [ + Binding("escape", "app.quit", "Beenden"), + Binding("up", "prev_field", "Feld zurück", show=False), + Binding("down", "next_field", "Feld weiter", show=False), + Binding("ctrl+s", "save_profile", "Profil speichern"), + ] + + def action_next_field(self) -> None: + self.focus_next() + + def action_prev_field(self) -> None: + self.focus_previous() def compose(self) -> ComposeResult: yield Header(show_clock=True) @@ -101,12 +242,12 @@ class ConnectionScreen(Screen): classes="title", ) yield Label(" Verbindungsprofil:", classes="field") - yield Select( + yield NavSelect( [(p.name, p.name) for p in load_profiles()], id="profile", classes="input", prompt="Neues Profil…", ) yield Label(" Protokoll:", classes="field") - yield Select( + yield NavSelect( [("SFTP (SSH)", "sftp"), ("FTP", "ftp")], value="sftp", id="protocol", classes="input", ) @@ -166,6 +307,14 @@ class ConnectionScreen(Screen): self.app.switch_screen(BrowserScreen()) status.update("") + @on(Input.Submitted) + def _submit(self, event: Input.Submitted) -> None: + """Enter in einem Eingabefeld verbindet direkt.""" + self._connect() + + def action_save_profile(self) -> None: + self._save_profile() + @on(Button.Pressed, "#save_profile") def _save_profile(self) -> None: profiles = load_profiles() @@ -208,6 +357,10 @@ class BrowserScreen(Screen): Binding("q", "app.quit", "Beenden"), Binding("enter", "open", "Öffnen"), Binding("tab", "focus_next", "Panele wechseln"), + Binding("space", "toggle_mark", "Markieren"), + Binding("ctrl+a", "mark_all", "Alle markieren"), + Binding("escape", "clear_marks", "Markierung aufheben"), + Binding("v", "view", "Ansehen"), Binding("l", "download", "Herunterladen"), Binding("u", "upload", "Hochladen"), Binding("n", "mkdir", "Verzeichnis"), @@ -235,12 +388,17 @@ class BrowserScreen(Screen): classes="panes", ) + yield Static(id="message", classes="message") + # Kein Footer: dessen Tasten werden bei schmalen Terminals hart + # abgeschnitten. Die Statusleiste unten bricht stattdessen um und + # zeigt die Belegung immer vollständig. yield Static(id="statusbar", classes="statusbar") - yield Footer() def on_mount(self) -> None: self.local_path = str(Path.home()) self.remote_path = "" + self._marked_local: set[str] = set() + self._marked_remote: set[str] = set() self.table_local = self.query_one("#local_table", DataTable) self.table_remote = self.query_one("#remote_table", DataTable) self.backend: FileTransferBackend = self.app.backend @@ -256,6 +414,45 @@ class BrowserScreen(Screen): table.cursor_type = "row" table.zebra_stripes = False + def on_resize(self, event=None) -> None: + # Beide Paneele identisch layouten: sie unterscheiden sich um ein + # Zeichen und lägen sonst auf verschiedenen Seiten der Schwelle, + # sodass nur eine Seite eine Datumsspalte hätte. + width = min(self.table_local.size.width, self.table_remote.size.width) + self._layout_columns(self.table_local, width) + self._layout_columns(self.table_remote, width) + + def _layout_columns(self, table: FileTable, width: int | None = None) -> None: + """Verteilt die Spaltenbreiten auf die Paneelbreite. + + Ohne das summieren sich die Spalten auf mehr als das Paneel breit ist, + die Tabelle bekommt einen horizontalen Scrollbalken und die + Datumsspalte wird abgeschnitten. Die Namensspalte bekommt deshalb den + Rest; bei schmalen Fenstern entfällt zuerst das Datum, dann die Größe. + """ + cols = list(table.columns.values()) + width = table.size.width if width is None else width + if len(cols) < 3 or not width: + return + # Jede Spalte kostet zusaetzlich 2 Zeichen Zellabstand; 1 Zeichen bleibt + # als Reserve fuer den vertikalen Scrollbalken. + avail = width - 2 * len(cols) - 1 + name_min, size_w, date_w = 8, 9, 16 + # Das Datum lohnt sich erst, wenn dem Namen danach noch genug Platz + # bleibt - sonst ist die Datumsspalte breiter als die Namensspalte. + if avail >= 16 + size_w + date_w: + pass + elif avail >= name_min + size_w: + date_w = 0 + else: + size_w = date_w = 0 + name_w = max(name_min, avail - size_w - date_w) + for col, width in zip(cols, (name_w, size_w, date_w)): + col.width = width + col.auto_width = False + # layout=True, damit die Scrollbalken-Sichtbarkeit neu berechnet wird + table.refresh(layout=True) + def action_focus_next(self) -> None: """Wechselt den Fokus direkt zwischen lokalem und entferntem Paneel. @@ -284,7 +481,7 @@ class BrowserScreen(Screen): try: items = sorted(os.scandir(self.local_path), key=lambda e: (not e.is_dir(), e.name.lower())) except OSError as exc: - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + self._notify(f"[red]{exc}[/]") return for item in items: try: @@ -311,36 +508,131 @@ class BrowserScreen(Screen): entries = self.backend.listdir(self.remote_path) self.remote_path = self.backend.pwd() except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + self._notify(f"[red]{exc}[/]") return self._fill(table, entries, local=False) + def _name_cell(self, label: str, marked: bool) -> Text: + """Baut die Namenszelle; markierte Einträge werden fett hervorgehoben.""" + if marked: + return Text(label, style="bold #f1fa8c") + if label.endswith("/"): + return Text(label, style="bold #4e9a06") + return Text(label, style="#d7d7d7") + def _fill(self, table: DataTable, entries: list[RemoteEntry], local: bool) -> None: - parent = _parent(self.local_path if local else self.remote_path, local) + marked = self._marked_local if local else self._marked_remote table.add_row("↑ ..", "", "", key="__parent__") + present: set[str] = set() for e in sorted(entries, key=lambda x: (x.kind != "dir", x.name.lower())): if e.name in (".", ".."): continue suffix = "/" if e.kind == "dir" else "" + label = e.name + suffix + present.add(label) size = _human_size(e.size) if e.kind == "file" else "