v0.2.0: Bugfix Screenwechsel, ranger-Thema, Total-Commander-Layout

This commit is contained in:
x3
2026-08-31 15:05:27 +00:00
parent 33827d5598
commit 94c9a8de9e
4 changed files with 283 additions and 122 deletions
+38 -11
View File
@@ -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.
---
@@ -134,17 +140,21 @@ 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) |
|--------------|---------------------------------|
| `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 |
| `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.*
+1 -1
View File
@@ -1,3 +1,3 @@
"""ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface."""
__version__ = "0.1.0"
__version__ = "0.2.0"
+228 -94
View File
@@ -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,42 +192,67 @@ 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())):
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}[/]")
return
for item in items:
try:
st = item.stat()
kind = "dir" if item.is_dir() else "file"
@@ -228,54 +260,61 @@ class BrowserScreen(Screen):
RemoteEntry(
name=item.name,
kind=kind,
size=st.st_size,
modified=item.name,
size=st.st_size if kind == "file" else 0,
modified=_human_date(st.st_mtime),
)
)
except OSError:
continue
except OSError as exc:
self.query_one("#info", Static).update(f"[red]{exc}[/]")
return
self._fill(self.table_local, entries, local=True)
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 "<dir>",
e.modified if isinstance(e.modified, str) else "",
key=e.name,
)
size = _human_size(e.size) if e.kind == "file" else "<DIR>"
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())
+1 -1
View File
@@ -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"