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 "" - if e.kind == "dir": - name_cell = Text(e.name + suffix, style="bold #4e9a06") - else: - name_cell = Text(e.name + suffix, style="#d7d7d7") key = f"{'L' if local else 'R'}|{e.kind}|{e.name}" - table.add_row(name_cell, size, e.modified, key=key) + table.add_row(self._name_cell(label, label in marked), size, e.modified, key=key) + # Markierungen auf tatsächlich vorhandene Einträge eindampfen + marked &= present + + def _marks(self, table: FileTable) -> set[str]: + return self._marked_local if table is self.table_local else self._marked_remote + + def _labels(self, table: FileTable) -> list[str]: + """Alle Zeilenbeschriftungen in Anzeigereihenfolge (ohne ``..``).""" + out: list[str] = [] + for i in range(len(table.rows)): + cell = table.get_row_at(i)[0] + label = cell.plain if isinstance(cell, Text) else str(cell) + if not label.startswith("↑"): + out.append(label) + return out + + def _targets(self, table: FileTable) -> list[str]: + """Zielauswahl: markierte Einträge, sonst der Eintrag unter dem Cursor.""" + marked = self._marks(table) + if marked: + return [lbl for lbl in self._labels(table) if lbl in marked] + sel = self._selected_entry(table) + return [sel] if sel else [] + + def action_toggle_mark(self) -> None: + table = self._focused_table() + sel = self._selected_entry(table) + if not sel: + return + marked = self._marks(table) + if sel in marked: + marked.discard(sel) + else: + marked.add(sel) + row = table.cursor_row + table.update_cell_at(Coordinate(row, 0), self._name_cell(sel, sel in marked)) + if row + 1 < len(table.rows): + table.move_cursor(row=row + 1) + self._update_info() + + def action_mark_all(self) -> None: + table = self._focused_table() + marked = self._marks(table) + labels = self._labels(table) + already_all = bool(labels) and len(marked) == len(labels) + marked.clear() + if not already_all: + marked.update(labels) + self._restyle(table) + self._update_info() + + def action_clear_marks(self) -> None: + table = self._focused_table() + self._marks(table).clear() + self._restyle(table) + self._update_info() + + def _restyle(self, table: FileTable) -> None: + marked = self._marks(table) + for i in range(len(table.rows)): + cell = table.get_row_at(i)[0] + label = cell.plain if isinstance(cell, Text) else str(cell) + if label.startswith("↑"): + continue + table.update_cell_at(Coordinate(i, 0), self._name_cell(label, label in marked)) + + #: Bedienhinweise. Die Statusleiste bricht um, damit nichts abgeschnitten wird. + HELP = ( + " Tab Panele · ↑/↓ Auswahl · ← Hoch · → Öffnen · Enter Öffnen/Ansehen · " + "Leertaste Markieren · Strg+A Alle · Esc Markierung weg · v Ansehen · " + "l Download · u Upload · n Neu · d Löschen · r Umbenennen · q Beenden" + ) + + def _notify(self, markup: str) -> None: + """Schreibt eine Meldung in die eigene Meldungszeile. + + Bewusst getrennt von der Statusleiste: die wurde früher von jedem + Cursor-Wechsel überschrieben, sodass Fehlermeldungen sofort verschwanden. + Ohne Inhalt wird die Zeile ausgeblendet, statt eine Leerzeile zu belegen. + """ + msg = self.query_one("#message", Static) + msg.update(markup) + msg.display = bool(markup) + + def _mark_summary(self) -> str: + n_local, n_remote = len(self._marked_local), len(self._marked_remote) + parts = [] + if n_local: + parts.append(f"{n_local} lokal") + if n_remote: + parts.append(f"{n_remote} entfernt") + return f" ▶ markiert: {' · '.join(parts)}" if parts else "" def _update_info(self) -> None: - active = self.app.focused - side = "lokal" if active is self.table_local else "entfernt" self.query_one("#pathbar", Static).update( f" {self.app.host} [{self.app.protocol.upper()}] " f"lokal: {self.local_path} | entfernt: {self.remote_path}" + f"{self._mark_summary()}" ) - self.query_one("#statusbar", Static).update( - " Tab Panele · ↑/↓ Auswahl · ← Hoch · → Öffnen · Enter Öffnen · " - "l Download · u Upload · n Neu · d Löschen · r Umbenennen · q Beenden" - ) + self.query_one("#statusbar", Static).update(self.HELP) @on(DataTable.RowHighlighted) def _highlight(self, event: DataTable.RowHighlighted) -> None: @@ -378,23 +670,29 @@ class BrowserScreen(Screen): return if sel.startswith("↑"): newdir = _parent(self.local_path if which == "local" else self.remote_path, which == "local") - if which == "local": - self.local_path = newdir - self._refresh_local() - else: - self.remote_path = newdir - self._refresh_remote() - self._update_info() + self._goto(which, newdir) return name = sel[:-1] if sel.endswith("/") else sel + if not sel.endswith("/"): + # Datei -> Betrachter statt Verzeichniswechsel + self._view(which, name) + return if which == "local": target = _join(self.local_path, name, True) if os.path.isdir(target): - self.local_path = target - self._refresh_local() + self._goto("local", target) else: - target = _join(self.remote_path, name, False) - self.remote_path = target + self._goto("remote", _join(self.remote_path, name, False)) + + def _goto(self, which: str, path: str) -> None: + """Wechselt das Verzeichnis eines Paneels und verwirft dessen Markierung.""" + if which == "local": + self.local_path = path + self._marked_local.clear() + self._refresh_local() + else: + self.remote_path = path + self._marked_remote.clear() self._refresh_remote() self._update_info() @@ -403,51 +701,127 @@ class BrowserScreen(Screen): def action_up_level(self) -> None: if self.app.focused is self.table_local: - self.local_path = _parent(self.local_path, True) - self._refresh_local() + self._goto("local", _parent(self.local_path, True)) else: - self.remote_path = _parent(self.remote_path, False) - self._refresh_remote() - self._update_info() + self._goto("remote", _parent(self.remote_path, False)) + + def action_view(self) -> None: + table = self._focused_table() + sel = self._selected_entry(table) + if not sel or sel.endswith("/"): + self._notify("[yellow]Nur Dateien können angesehen werden.[/]") + return + self._view("local" if table is self.table_local else "remote", sel) + + def _view(self, which: str, name: str) -> None: + """Lädt eine Datei (entfernt: in eine Temporärdatei) und zeigt sie an.""" + local = which == "local" + try: + if local: + path = _join(self.local_path, name, True) + size = os.path.getsize(path) + if size > MAX_VIEW_BYTES: + self._notify( + f"[yellow]{name} ist {_human_size(size)} groß – " + f"Anzeige ab {_human_size(MAX_VIEW_BYTES)} deaktiviert.[/]" + ) + return + data = Path(path).read_bytes() + subtitle = f"lokal · {_human_size(size)}" + else: + remote = _join(self.remote_path, name, False) + with tempfile.NamedTemporaryFile(suffix=f"-{name}", delete=False) as tmp: + tmp_path = tmp.name + try: + self.backend.download(remote, tmp_path) + size = os.path.getsize(tmp_path) + if size > MAX_VIEW_BYTES: + self._notify( + f"[yellow]{name} ist {_human_size(size)} groß – " + f"Anzeige ab {_human_size(MAX_VIEW_BYTES)} deaktiviert.[/]" + ) + return + data = Path(tmp_path).read_bytes() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + subtitle = f"entfernt · {_human_size(size)}" + except Exception as exc: # noqa: BLE001 + self._notify(f"[red]Ansehen: {exc}[/]") + return + + if _looks_binary(data): + self._notify(f"[yellow]{name} ist keine Textdatei.[/]") + return + text = data.decode("utf-8", errors="replace") + self.app.push_screen(ViewerScreen(name, text, subtitle)) def action_download(self) -> None: - sel = self._selected_entry(self.table_remote) - if not sel: + targets = self._targets(self.table_remote) + if not targets: return - name = sel[:-1] if sel.endswith("/") else sel - if sel.endswith("/"): - self._transfer_dir(self.remote_path, name, self.local_path, download=True) - else: - target_local = _join(self.local_path, name, True) + done, failed = 0, 0 + for sel in targets: + name = sel[:-1] if sel.endswith("/") else sel + if sel.endswith("/"): + if self._transfer_dir(self.remote_path, name, self.local_path, download=True): + done += 1 + else: + failed += 1 + continue try: - self.backend.download(_join(self.remote_path, name, False), target_local) + self.backend.download( + _join(self.remote_path, name, False), _join(self.local_path, name, True) + ) + done += 1 except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]Download: {exc}[/]") - return + failed += 1 + self._notify(f"[red]Download {name}: {exc}[/]") + self._marked_remote.clear() + self._refresh_remote() self._refresh_local() + self._report("Heruntergeladen", done, failed) def action_upload(self) -> None: - sel = self._selected_entry(self.table_local) - if not sel: + targets = self._targets(self.table_local) + if not targets: return - name = sel[:-1] if sel.endswith("/") else sel - if sel.endswith("/"): - self._transfer_dir(self.local_path, name, self.remote_path, download=False) - else: - target_remote = _join(self.remote_path, name, False) + done, failed = 0, 0 + for sel in targets: + name = sel[:-1] if sel.endswith("/") else sel + if sel.endswith("/"): + if self._transfer_dir(self.local_path, name, self.remote_path, download=False): + done += 1 + else: + failed += 1 + continue try: - self.backend.upload(_join(self.local_path, name, True), target_remote) + self.backend.upload( + _join(self.local_path, name, True), _join(self.remote_path, name, False) + ) + done += 1 except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]Upload: {exc}[/]") - return + failed += 1 + self._notify(f"[red]Upload {name}: {exc}[/]") + self._marked_local.clear() + self._refresh_local() self._refresh_remote() + self._report("Hochgeladen", done, failed) - def _transfer_dir(self, src_dir: str, name: str, dst_dir: str, download: bool) -> None: + def _report(self, verb: str, done: int, failed: int) -> None: + if failed: + self._notify(f"[red]{verb}: {done} ok, {failed} fehlgeschlagen.[/]") + elif done: + self._notify(f"[green]{verb}: {done} Eintrag/Einträge.[/]") + + def _transfer_dir(self, src_dir: str, name: str, dst_dir: str, download: bool) -> bool: src = _join(src_dir, name, not download) dst = _join(dst_dir, name, download) - self._copy_tree(src, dst, download) + return self._copy_tree(src, dst, download) - def _copy_tree(self, src: str, dst: str, download: bool) -> None: + def _copy_tree(self, src: str, dst: str, download: bool) -> bool: try: if download: # dst ist ein *lokaler* Pfad -> lokal anlegen, nicht auf dem Server @@ -471,7 +845,9 @@ class BrowserScreen(Screen): else: self.backend.upload(_join(src, item.name, True), _join(dst, item.name, False)) except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + self._notify(f"[red]{exc}[/]") + return False + return True def action_mkdir(self) -> None: table = self._focused_table() @@ -484,32 +860,39 @@ class BrowserScreen(Screen): os.makedirs(_join(self.local_path, "neues-verzeichnis", True), exist_ok=True) self._refresh_local() except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + self._notify(f"[red]{exc}[/]") def action_delete(self) -> None: table = self._focused_table() - sel = self._selected_entry(table) - if not sel: + targets = self._targets(table) + if not targets: return remote = table is not self.table_local - name = sel[:-1] if sel.endswith("/") else sel - try: - if remote: - if sel.endswith("/"): - self.backend.rmdir(_join(self.remote_path, name, False)) + done, failed = 0, 0 + for sel in targets: + name = sel[:-1] if sel.endswith("/") else sel + try: + if remote: + if sel.endswith("/"): + self.backend.rmdir(_join(self.remote_path, name, False)) + else: + self.backend.remove(_join(self.remote_path, name, False)) else: - self.backend.remove(_join(self.remote_path, name, False)) - self._refresh_remote() - else: - p = _join(self.local_path, name, True) - if os.path.isdir(p): - import shutil - shutil.rmtree(p) - else: - os.remove(p) - self._refresh_local() - except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + p = _join(self.local_path, name, True) + if os.path.isdir(p): + shutil.rmtree(p) + else: + os.remove(p) + done += 1 + except Exception as exc: # noqa: BLE001 + failed += 1 + self._notify(f"[red]Löschen {name}: {exc}[/]") + self._marks(table).clear() + if remote: + self._refresh_remote() + else: + self._refresh_local() + self._report("Gelöscht", done, failed) def action_rename(self) -> None: table = self._focused_table() @@ -538,7 +921,7 @@ class BrowserScreen(Screen): ) self._refresh_local() except Exception as exc: # noqa: BLE001 - self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") + self._notify(f"[red]{exc}[/]") class RenameScreen(Screen): @@ -578,6 +961,11 @@ class FTpTui(App): color: $text; } + /* Verbindungsdialog bei kleinen Terminals scrollbar halten */ + ConnectionScreen { + overflow-y: auto; + } + Header { background: #101010; color: #e0e0e0; @@ -669,6 +1057,9 @@ class FTpTui(App): height: 1fr; background: #0c0c0c; color: #d7d7d7; + /* Spaltenbreiten werden passend berechnet (_layout_columns); + ein horizontaler Scrollbalken ist daher nie erwuenscht. */ + overflow-x: hidden; } DataTable > .datatable--header { @@ -684,19 +1075,46 @@ class FTpTui(App): text-style: bold; } + /* Pfad- und Statusleiste umbrechen lassen, statt sie abzuschneiden. + Bei festem "height: 1" verschwand das Bedienmenue in schmalen + Terminals einfach am rechten Rand. */ .pathbar { background: #000000; color: #00ff87; padding: 0 2; text-style: bold; - height: 1; + height: auto; + max-height: 4; + text-wrap: wrap; } .statusbar { background: #101010; color: #8fa1b3; padding: 0 2; - height: 1; + height: auto; + max-height: 6; + text-wrap: wrap; + } + + /* Meldungszeile: nimmt nur Platz ein, wenn tatsaechlich etwas anliegt. */ + .message { + display: none; + background: #101010; + padding: 0 2; + height: auto; + max-height: 4; + text-wrap: wrap; + } + + #viewer_scroll { + height: 1fr; + background: #0c0c0c; + border: none; + } + + #viewer_body { + width: auto; } """ diff --git a/pyproject.toml b/pyproject.toml index f72162c..9127ba3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ftptui" -version = "0.3.1" +version = "0.4.0" description = "Interaktiver FTP/SFTP-Client als Terminal-User-Interface in Python" readme = "README.md" requires-python = ">=3.10"