diff --git a/README.md b/README.md index f4ce74d..ff04fd1 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,25 @@ Verbindungsprofilen. ## Funktionen -- **Dual-Pane-Dateibrowser** – lokales und entferntes Verzeichnis nebeneinander, - Spalten: Name, Größe, Änderungsdatum. +- **Dual-Pane-Dateibrowser** – Layout und Bedienung im Stil von + **Total Commander** / **Midnight Commander**: zwei benachbarte Panele + (lokal & entfernt) mit den Spalten *Name*, *Größe*, *Datum*, einer + Pfadleiste oben, einer Statusleiste unten und grün markiertem aktivem Panel. +- **Klassisches Terminal-Thema** – schlanker Dark-Look angelehnt an den + Dateimanager **ranger**: schwarzer Hintergrund, Verzeichnisse grün/fett, + Auswahl per inverser (hellblauer) Markierung. - **FTP** über die Standardbibliothek, **SFTP** über `paramiko` (SSH-FTP). - **Herunterladen** (`l`) und **Hochladen** (`u`) einzelner Dateien. - **Rekursive Übertragung** kompletter Verzeichnisbäume. - **Verzeichnisse anlegen** (`n`), **umbenennen** (`r`), **löschen** (`d`), rekursives Löschen nicht-leerer Ordner. -- **Navigation** mit Maus/Tastatur, „eine Ebene hoch“ (`←`), Doppelklick/`Enter`. +- **Navigation** mit Maus/Tastatur, „eine Ebene hoch“ (`←`/`⌫`), `Enter` öffnet. - **Verbindungsprofile** werden als JSON unter `~/.config/ftptui/profiles.json` gespeichert und können wieder geladen werden. - Passwortfelder sind abgedunkelt (`password=True`). -- Modernes, terminalfreundliches Layout auf Basis von - [Textual](https://textual.textualize.io/). +- Gebaut mit dem terminalfreundlichen TUI-Framework + [Textual](https://textual.textualize.io/), ohne fancy Material-Design – + ein „echtes“ Konsolenprogramm. --- @@ -133,18 +139,22 @@ ftptui ## Tastaturkürzel -| Taste | Aktion | -|------------|---------------------------------| -| `Tab` | Zwischen lokalem/entferntem Paneel wechseln | -| `Enter` | Ordner öffnen / in Datei wechseln | -| `←` | Eine Verzeichnisebene höher | -| `↑ ..` | Eine Ebene hoch (Tabellenzeile) | -| `l` | Auswahl **herunterladen** (entfernt → lokal) | -| `u` | Auswahl **hochladen** (lokal → entfernt) | -| `n` | Neues Verzeichnis anlegen | -| `d` | Auswahl löschen (rekursiv) | -| `r` | Auswahl umbenennen | -| `q` / `Esc`| Beenden | +| Taste | Aktion | +|--------------|---------------------------------| +| `Tab` | Zwischen lokalem und entferntem Paneel wechseln | +| `Enter` | Ordner öffnen (aktiv: fokussiertes Paneel) | +| `←` / `⌫` | Eine Verzeichnisebene höher | +| `↑ ..` | Eine Ebene höher (Tabellenzeile) | +| `l` | Auswahl **herunterladen** (entfernt → lokal) | +| `u` | Auswahl **hochladen** (lokal → entfernt) | +| `n` | Neues Verzeichnis anlegen | +| `d` | Auswahl löschen (rekursiv) | +| `r` | Auswahl umbenennen | +| `q` / `Esc` | 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`. --- @@ -293,5 +303,22 @@ MIT – siehe Projektträger/Repositories. Keine gewerblichen Einschränkungen. --- +## Changelog + +### v0.2.0 +- **Bugfix:** Wechsel in den Browser-Bildschirm korrigiert + (`set_screen` → `switch_screen`). Vorher führte eine erfolgreiche Verbindung + in einen `AttributeError` und der FTP/SFTP-Client beendete sich. +- **Theme:** Umstellung auf ein klassisches Terminal-Design im Stil von + **ranger** (Dark-Look, grün/fette Verzeichnisse, inverse Auswahl). +- **Layout:** Neuaufbau als **Total-Commander-artiger** Dual-Pane-Browser mit + Pfadleiste oben, Statusleiste unten und grün markiertem aktivem Panel. +- Datums-Spalte wird nun als lesbares Datum (`YYYY-MM-DD HH:MM`) angezeigt. + +### v0.1.0 +- Erste Veröffentlichung: FTP- & SFTP-Backends, TUI, Verbindungsprofile. + +--- + *Projekt wird innerhalb der `sysdaemon.xyz`-Instanz über Gitea verwaltet – https://git.sysdaemon.xyz/admin/ftptui.* diff --git a/ftptui/__init__.py b/ftptui/__init__.py index 3d6ba91..d7ad592 100644 --- a/ftptui/__init__.py +++ b/ftptui/__init__.py @@ -1,3 +1,3 @@ """ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/ftptui/app.py b/ftptui/app.py index ee514ee..4563984 100644 --- a/ftptui/app.py +++ b/ftptui/app.py @@ -1,15 +1,17 @@ """Haupt-TUI-Anwendung für ftptui. -Basiert auf dem ``textual``-Framework und bietet einen -zweispaltigen Datei-Browser für lokal und entfernt, sowie -eine Verbindungsmaske für FTP und SFTP. +Klassischer Terminal-Dateibrowser im Stil von ``ranger``/``Total Commander``: +zwei benachbarte Datei-Panele (lokal & entfernt), Pfadleiste oben, Statusleiste +unten. Verwendet ``textual`` und unterstützt FTP sowie SFTP (SSH-FTP). """ from __future__ import annotations import os +from datetime import datetime from pathlib import Path +from rich.text import Text from textual import on from textual.app import App, ComposeResult from textual.binding import Binding @@ -39,10 +41,19 @@ def _human_size(size: int) -> str: return f"{size:.2f} TiB" +def _human_date(ts: object) -> str: + try: + return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M") + except (TypeError, ValueError, OSError): + try: + return str(ts) + except Exception: # noqa: BLE001 + return "" + + def _parent(path: str, is_local: bool) -> str: if is_local: - p = Path(path) - return str(p.parent) + return str(Path(path).parent) if path in ("/", ""): return "/" return path.rstrip("/").rsplit("/", 1)[0] or "/" @@ -57,35 +68,35 @@ def _join(path: str, name: str, is_local: bool) -> str: class ConnectionScreen(Screen): - """Startbildschirm zur Eingabe der Verbindungsdaten.""" + """Startbildschirm zur Eingabe der Verbindungsdaten (Terminal-Look).""" BINDINGS = [Binding("escape", "quit", "Beenden")] def compose(self) -> ComposeResult: - yield Header() - yield Label( - "[bold cyan]ftptui[/] - FTP/SFTP Terminal-Client\n" - "----------------------------", + yield Header(show_clock=True) + yield Static( + "[b]ftptui[/b] – FTP / SFTP Terminal-Client (SSH-FTP unterstützt)", classes="title", ) - yield Label("Verbindungsprofil:", classes="field") + yield Label(" Verbindungsprofil:", classes="field") yield Select( [(p.name, p.name) for p in load_profiles()], id="profile", - classes="field", prompt="Neues Profil…", + classes="input", prompt="Neues Profil…", ) - yield Label("Protokoll:", classes="field") + yield Label(" Protokoll:", classes="field") yield Select( [("SFTP (SSH)", "sftp"), ("FTP", "ftp")], - value="sftp", id="protocol", classes="field", + value="sftp", id="protocol", classes="input", ) - yield Label("Host:", classes="field") - yield Input(id="host", placeholder="git.sysdaemon.xyz") - yield Label("Port:", classes="field") - yield Input(id="port", placeholder="22 (SFTP) / 21 (FTP)", value="22") - yield Label("Benutzer:", classes="field") - yield Input(id="username", placeholder="user") - yield Label("Passwort:", classes="field") - yield Input(id="password", password=True) + yield Label(" Host:", classes="field") + yield Input(id="host", placeholder="host.example.org", classes="input") + yield Label(" Port:", classes="field") + yield Input(id="port", placeholder="22 (SFTP) / 21 (FTP)", value="22", + classes="input") + yield Label(" Benutzer:", classes="field") + yield Input(id="username", placeholder="user", classes="input") + yield Label(" Passwort:", classes="field") + yield Input(id="password", password=True, classes="input") yield Horizontal( Button("Verbinden", variant="primary", id="connect"), Button("Profil speichern", id="save_profile"), @@ -110,9 +121,7 @@ class ConnectionScreen(Screen): try: port = int(port_s) if port_s else (22 if proto == "sftp" else 21) except ValueError: - self.query_one("#conn_status", Static).update( - "[red]Ungültiger Port.[/]" - ) + self.query_one("#conn_status", Static).update("[red]Ungültiger Port.[/]") return if not host or not user: self.query_one("#conn_status", Static).update( @@ -129,7 +138,10 @@ class ConnectionScreen(Screen): return self.app.backend = backend self.app.protocol = proto - self.app.set_screen(BrowserScreen.SCREEN_NAME) + self.app.host = host + self.app.port = port + self.app.user = user + self.app.switch_screen(BrowserScreen()) status.update("") @on(Button.Pressed, "#save_profile") @@ -149,9 +161,7 @@ class ConnectionScreen(Screen): ) ) save_profiles(profiles) - self.query_one("#conn_status", Static).update( - "[green]Profil gespeichert.[/]" - ) + self.query_one("#conn_status", Static).update("[green]Profil gespeichert.[/]") @on(Button.Pressed, "#load_profile") def _load_profile(self) -> None: @@ -165,16 +175,13 @@ class ConnectionScreen(Screen): self.query_one("#port", Input).value = str(p.port) self.query_one("#username", Input).value = p.username self.query_one("#password", Input).value = p.password - self.query_one("#conn_status", Static).update( - "[green]Profil geladen.[/]" - ) + self.query_one("#conn_status", Static).update("[green]Profil geladen.[/]") return class BrowserScreen(Screen): - """Zweispaltiger Datei-Browser zwischen lokalem und entferntem System.""" + """Total-Commander-artiger Datei-Browser zwischen lokal & entfernt.""" - SCREEN_NAME = "browser" BINDINGS = [ Binding("q", "quit", "Beenden"), Binding("enter", "open", "Öffnen"), @@ -185,97 +192,129 @@ class BrowserScreen(Screen): Binding("d", "delete", "Löschen"), Binding("r", "rename", "Umbenennen"), Binding("left", "up_level", "Eine Ebene hoch"), - Binding("space", "select", "Auswählen"), + Binding("backspace", "up_level", "Eine Ebene hoch"), ] def compose(self) -> ComposeResult: - yield Header() + yield Header(show_clock=True) + yield Static(id="pathbar", classes="pathbar") + yield Horizontal( Vertical( - Static("Lokales System", classes="pane-title"), - DataTable(id="local_table", zebra_stripes=True), + Static(" Lokal ", classes="pane-title local-title active"), + DataTable(id="local_table"), classes="pane", ), + Static(" ", classes="vsep"), Vertical( - Static("Entferntes System", classes="pane-title"), - DataTable(id="remote_table", zebra_stripes=True), + Static(" Entfernt ", classes="pane-title remote-title"), + DataTable(id="remote_table"), classes="pane", ), + classes="panes", ) - yield Static(id="info", classes="info") + + yield Static(id="statusbar", classes="statusbar") yield Footer() def on_mount(self) -> None: self.local_path = str(Path.home()) - self.remote_path: str = "" + self.remote_path = "" self.table_local = self.query_one("#local_table", DataTable) self.table_remote = self.query_one("#remote_table", DataTable) self.backend: FileTransferBackend = self.app.backend - self.table_local.add_columns("Name", "Größe", "Geändert", key="name") - self.table_remote.add_columns("Name", "Größe", "Geändert", key="name") + self._setup_table(self.table_local) + self._setup_table(self.table_remote) self._refresh_local() self._refresh_remote() + def _setup_table(self, table: DataTable) -> None: + table.add_column("Name", key="name") + table.add_column("Größe", key="size") + table.add_column("Datum", key="date") + table.cursor_type = "row" + table.zebra_stripes = False + + def on_descendant_focus(self, event) -> None: + if event.widget is self.table_local: + self.query_one(".local-title", Static).add_class("active") + self.query_one(".remote-title", Static).remove_class("active") + elif event.widget is self.table_remote: + self.query_one(".remote-title", Static).add_class("active") + self.query_one(".local-title", Static).remove_class("active") + def _refresh_local(self) -> None: - self.table_local.clear() + table = self.table_local + table.clear() entries: list[RemoteEntry] = [] try: - for item in sorted(os.scandir(self.local_path), key=lambda e: (not e.is_dir(), e.name.lower())): - try: - st = item.stat() - kind = "dir" if item.is_dir() else "file" - entries.append( - RemoteEntry( - name=item.name, - kind=kind, - size=st.st_size, - modified=item.name, - ) - ) - except OSError: - continue + items = sorted(os.scandir(self.local_path), key=lambda e: (not e.is_dir(), e.name.lower())) except OSError as exc: - self.query_one("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") return - self._fill(self.table_local, entries, local=True) + for item in items: + try: + st = item.stat() + kind = "dir" if item.is_dir() else "file" + entries.append( + RemoteEntry( + name=item.name, + kind=kind, + size=st.st_size if kind == "file" else 0, + modified=_human_date(st.st_mtime), + ) + ) + except OSError: + continue + self._fill(table, entries, local=True) def _refresh_remote(self) -> None: - self.table_remote.clear() + table = self.table_remote + table.clear() try: if not self.remote_path: self.remote_path = self.backend.pwd() entries = self.backend.listdir(self.remote_path) self.remote_path = self.backend.pwd() except Exception as exc: # noqa: BLE001 - self.query_one("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") return - self._fill(self.table_remote, entries, local=False) + self._fill(table, entries, local=False) def _fill(self, table: DataTable, entries: list[RemoteEntry], local: bool) -> None: parent = _parent(self.local_path if local else self.remote_path, local) - table.add_row("\u2191 ..", "", "", key=parent) - for e in entries: + table.add_row("↑ ..", "", "", key="__parent__") + 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 "" - table.add_row( - e.name + suffix, - _human_size(e.size) if e.kind == "file" else "