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 "", - e.modified if isinstance(e.modified, str) else "", - key=e.name, - ) + 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) def _update_info(self) -> None: - self.query_one("#info", Static).update( - f"[bold]lokal:[/] {self.local_path}\t" - f"[bold]entfernt:[/] {self.remote_path}\t" - f"[bold]protokoll:[/] {self.app.protocol.upper()}" + 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}" + ) + self.query_one("#statusbar", Static).update( + " F2/Tab Panele · Enter Öffnen · l Download · u Upload · " + "n Neu · d Löschen · r Umbenennen · ← Hoch · q Beenden" ) @on(DataTable.RowHighlighted) def _highlight(self, event: DataTable.RowHighlighted) -> None: self._update_info() + def _focused_table(self) -> DataTable: + return self.table_local if self.app.focused is self.table_local else self.table_remote + def _selected(self, table: DataTable) -> str | None: row = table.cursor_row if row is None: @@ -295,7 +334,7 @@ class BrowserScreen(Screen): sel = self._selected(table) if sel is None: return - if sel.startswith("\u2191"): + if sel.startswith("↑"): newdir = _parent(self.local_path if which == "local" else self.remote_path, which == "local") if which == "local": self.local_path = newdir @@ -317,14 +356,17 @@ class BrowserScreen(Screen): self._refresh_remote() self._update_info() + def action_open(self) -> None: + self._open("local" if self.app.focused is self.table_local else "remote") + def action_up_level(self) -> None: - table = self.app.focused - if table is self.table_local: + if self.app.focused is self.table_local: self.local_path = _parent(self.local_path, True) self._refresh_local() else: self.remote_path = _parent(self.remote_path, False) self._refresh_remote() + self._update_info() def action_download(self) -> None: sel = self._selected(self.table_remote) @@ -338,7 +380,7 @@ class BrowserScreen(Screen): try: self.backend.download(_join(self.remote_path, name, False), target_local) except Exception as exc: # noqa: BLE001 - self.query_one("#info", Static).update(f"[red]Download: {exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]Download: {exc}[/]") return self._refresh_local() @@ -354,7 +396,7 @@ class BrowserScreen(Screen): try: self.backend.upload(_join(self.local_path, name, True), target_remote) except Exception as exc: # noqa: BLE001 - self.query_one("#info", Static).update(f"[red]Upload: {exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]Upload: {exc}[/]") return self._refresh_remote() @@ -368,7 +410,6 @@ class BrowserScreen(Screen): if download: self.backend.mkdir(dst) entries = self.backend.listdir(src) - self.remote_path = self.backend.pwd() for e in entries: if e.kind == "dir": self._copy_tree( @@ -386,10 +427,10 @@ 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("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") def action_mkdir(self) -> None: - table = self.app.focused + table = self._focused_table() remote = table is not self.table_local try: if remote: @@ -399,10 +440,10 @@ 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("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") def action_delete(self) -> None: - table = self.app.focused + table = self._focused_table() sel = self._selected(table) if not sel: return @@ -424,19 +465,19 @@ class BrowserScreen(Screen): os.remove(p) self._refresh_local() except Exception as exc: # noqa: BLE001 - self.query_one("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") def action_rename(self) -> None: - table = self.app.focused + table = self._focused_table() sel = self._selected(table) if not sel: return self._rename_target = sel + self._rename_panel = "remote" if table is not self.table_local else "local" self.app.push_screen(RenameScreen()) def do_rename(self, new_name: str) -> None: - table = self.app.focused - remote = table is not self.table_local + remote = bool(getattr(self, "_rename_panel", "") == "remote") sel = self._rename_target old = sel[:-1] if sel.endswith("/") else sel try: @@ -453,15 +494,15 @@ class BrowserScreen(Screen): ) self._refresh_local() except Exception as exc: # noqa: BLE001 - self.query_one("#info", Static).update(f"[red]{exc}[/]") + self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") class RenameScreen(Screen): - """Kleiner Eingabe-Bildschirm zum Umbenennen.""" + """Eingabebildschirm zum Umbenennen.""" def compose(self) -> ComposeResult: - yield Label("Neuer Name:", classes="field") - yield Input(id="newname", placeholder="neuer-name") + yield Static("Neuer Name:", classes="field") + yield Input(id="newname", placeholder="neuer-name", classes="input") yield Horizontal( Button("OK", variant="primary", id="ok"), Button("Abbrechen", id="cancel"), @@ -480,43 +521,133 @@ class RenameScreen(Screen): class FTpTui(App): - """Wurzel der ftptui-Anwendung.""" + """Root der ftptui-Anwendung – klassisches Terminal-Thema (ranger/LF).""" CSS = """ - Screen { - background: $surface; + FTpTui, Screen { + background: $background; + color: $text; } - .field { - margin: 1 2; + + Header { + background: #101010; + color: #e0e0e0; + text-style: bold; } + .title { text-align: center; margin: 1 2; + padding: 1 0; + background: #0c0c0c; + color: #50fa7b; text-style: bold; } + + .field { + color: #8fa1b3; + padding: 0 2; + } + + Input, Select { + background: #101010; + color: #d7d7d7; + border: round #3a3a3a; + margin: 0 2 1 2; + } + + Input:focus, Select:focus { + border: round #4e9a06; + } + .row { margin: 1 2; height: 3; align: left middle; } + .row Button { margin-right: 1; } + + Button { + background: #1e1e1e; + color: #d7d7d7; + border: round #4a4a4a; + } + + Button:hover, Button:focus { + background: #4e9a06; + color: #101010; + border: round #4e9a06; + } + + #conn_status { + padding: 0 2; + height: 3; + } + + /* ---------- Browser ---------- */ + .panes { + height: 1fr; + } + .pane { height: 1fr; + width: 1fr; } + + .vsep { + width: 1; + background: #101010; + color: #00aa00; + } + .pane-title { - background: $panel; - color: $text; + background: #20202a; + color: #cfcfcf; text-style: bold; padding: 0 1; + height: 1; } + + .pane-title.active { + background: #4e9a06; + color: #0a0a0a; + } + DataTable { height: 1fr; + background: #0c0c0c; + color: #d7d7d7; } - #info { + + DataTable > .datatable--header { + background: #161616; + color: #aaaaaa; + text-style: bold; + height: 1; + } + + DataTable > .datatable--cursor { + background: #3465a4; + color: #ffffff; + text-style: bold; + } + + .pathbar { + background: #000000; + color: #00ff87; padding: 0 2; - background: $boost; + text-style: bold; + height: 1; + } + + .statusbar { + background: #101010; + color: #8fa1b3; + padding: 0 2; + height: 1; } """ @@ -524,6 +655,9 @@ class FTpTui(App): super().__init__() self.backend = None self.protocol = "sftp" + self.host = "" + self.port = 0 + self.user = "" def on_mount(self) -> None: self.push_screen(ConnectionScreen()) diff --git a/pyproject.toml b/pyproject.toml index 6338405..18e176f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ftptui" -version = "0.1.0" +version = "0.2.0" description = "Interaktiver FTP/SFTP-Client als Terminal-User-Interface in Python" readme = "README.md" requires-python = ">=3.10"