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
+44 -17
View File
@@ -37,19 +37,25 @@ Verbindungsprofilen.
## Funktionen ## Funktionen
- **Dual-Pane-Dateibrowser** lokales und entferntes Verzeichnis nebeneinander, - **Dual-Pane-Dateibrowser** Layout und Bedienung im Stil von
Spalten: Name, Größe, Änderungsdatum. **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). - **FTP** über die Standardbibliothek, **SFTP** über `paramiko` (SSH-FTP).
- **Herunterladen** (`l`) und **Hochladen** (`u`) einzelner Dateien. - **Herunterladen** (`l`) und **Hochladen** (`u`) einzelner Dateien.
- **Rekursive Übertragung** kompletter Verzeichnisbäume. - **Rekursive Übertragung** kompletter Verzeichnisbäume.
- **Verzeichnisse anlegen** (`n`), **umbenennen** (`r`), **löschen** (`d`), - **Verzeichnisse anlegen** (`n`), **umbenennen** (`r`), **löschen** (`d`),
rekursives Löschen nicht-leerer Ordner. 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` - **Verbindungsprofile** werden als JSON unter `~/.config/ftptui/profiles.json`
gespeichert und können wieder geladen werden. gespeichert und können wieder geladen werden.
- Passwortfelder sind abgedunkelt (`password=True`). - Passwortfelder sind abgedunkelt (`password=True`).
- Modernes, terminalfreundliches Layout auf Basis von - Gebaut mit dem terminalfreundlichen TUI-Framework
[Textual](https://textual.textualize.io/). [Textual](https://textual.textualize.io/), ohne fancy Material-Design
ein „echtes“ Konsolenprogramm.
--- ---
@@ -133,18 +139,22 @@ ftptui
## Tastaturkürzel ## Tastaturkürzel
| Taste | Aktion | | Taste | Aktion |
|------------|---------------------------------| |--------------|---------------------------------|
| `Tab` | Zwischen lokalem/entferntem Paneel wechseln | | `Tab` | Zwischen lokalem und entferntem Paneel wechseln |
| `Enter` | Ordner öffnen / in Datei wechseln | | `Enter` | Ordner öffnen (aktiv: fokussiertes Paneel) |
| `←` | Eine Verzeichnisebene höher | | `←` / `⌫` | Eine Verzeichnisebene höher |
| `↑ ..` | Eine Ebene hoch (Tabellenzeile) | | `↑ ..` | Eine Ebene höher (Tabellenzeile) |
| `l` | Auswahl **herunterladen** (entfernt → lokal) | | `l` | Auswahl **herunterladen** (entfernt → lokal) |
| `u` | Auswahl **hochladen** (lokal → entfernt) | | `u` | Auswahl **hochladen** (lokal → entfernt) |
| `n` | Neues Verzeichnis anlegen | | `n` | Neues Verzeichnis anlegen |
| `d` | Auswahl löschen (rekursiv) | | `d` | Auswahl löschen (rekursiv) |
| `r` | Auswahl umbenennen | | `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 *Projekt wird innerhalb der `sysdaemon.xyz`-Instanz über Gitea verwaltet
https://git.sysdaemon.xyz/admin/ftptui.* https://git.sysdaemon.xyz/admin/ftptui.*
+1 -1
View File
@@ -1,3 +1,3 @@
"""ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface.""" """ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface."""
__version__ = "0.1.0" __version__ = "0.2.0"
+237 -103
View File
@@ -1,15 +1,17 @@
"""Haupt-TUI-Anwendung für ftptui. """Haupt-TUI-Anwendung für ftptui.
Basiert auf dem ``textual``-Framework und bietet einen Klassischer Terminal-Dateibrowser im Stil von ``ranger``/``Total Commander``:
zweispaltigen Datei-Browser für lokal und entfernt, sowie zwei benachbarte Datei-Panele (lokal & entfernt), Pfadleiste oben, Statusleiste
eine Verbindungsmaske für FTP und SFTP. unten. Verwendet ``textual`` und unterstützt FTP sowie SFTP (SSH-FTP).
""" """
from __future__ import annotations from __future__ import annotations
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from rich.text import Text
from textual import on from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
@@ -39,10 +41,19 @@ def _human_size(size: int) -> str:
return f"{size:.2f} TiB" 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: def _parent(path: str, is_local: bool) -> str:
if is_local: if is_local:
p = Path(path) return str(Path(path).parent)
return str(p.parent)
if path in ("/", ""): if path in ("/", ""):
return "/" return "/"
return path.rstrip("/").rsplit("/", 1)[0] or "/" return path.rstrip("/").rsplit("/", 1)[0] or "/"
@@ -57,35 +68,35 @@ def _join(path: str, name: str, is_local: bool) -> str:
class ConnectionScreen(Screen): class ConnectionScreen(Screen):
"""Startbildschirm zur Eingabe der Verbindungsdaten.""" """Startbildschirm zur Eingabe der Verbindungsdaten (Terminal-Look)."""
BINDINGS = [Binding("escape", "quit", "Beenden")] BINDINGS = [Binding("escape", "quit", "Beenden")]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header(show_clock=True)
yield Label( yield Static(
"[bold cyan]ftptui[/] - FTP/SFTP Terminal-Client\n" "[b]ftptui[/b] FTP / SFTP Terminal-Client (SSH-FTP unterstützt)",
"----------------------------",
classes="title", classes="title",
) )
yield Label("Verbindungsprofil:", classes="field") yield Label(" Verbindungsprofil:", classes="field")
yield Select( yield Select(
[(p.name, p.name) for p in load_profiles()], id="profile", [(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( yield Select(
[("SFTP (SSH)", "sftp"), ("FTP", "ftp")], [("SFTP (SSH)", "sftp"), ("FTP", "ftp")],
value="sftp", id="protocol", classes="field", value="sftp", id="protocol", classes="input",
) )
yield Label("Host:", classes="field") yield Label(" Host:", classes="field")
yield Input(id="host", placeholder="git.sysdaemon.xyz") yield Input(id="host", placeholder="host.example.org", classes="input")
yield Label("Port:", classes="field") yield Label(" Port:", classes="field")
yield Input(id="port", placeholder="22 (SFTP) / 21 (FTP)", value="22") yield Input(id="port", placeholder="22 (SFTP) / 21 (FTP)", value="22",
yield Label("Benutzer:", classes="field") classes="input")
yield Input(id="username", placeholder="user") yield Label(" Benutzer:", classes="field")
yield Label("Passwort:", classes="field") yield Input(id="username", placeholder="user", classes="input")
yield Input(id="password", password=True) yield Label(" Passwort:", classes="field")
yield Input(id="password", password=True, classes="input")
yield Horizontal( yield Horizontal(
Button("Verbinden", variant="primary", id="connect"), Button("Verbinden", variant="primary", id="connect"),
Button("Profil speichern", id="save_profile"), Button("Profil speichern", id="save_profile"),
@@ -110,9 +121,7 @@ class ConnectionScreen(Screen):
try: try:
port = int(port_s) if port_s else (22 if proto == "sftp" else 21) port = int(port_s) if port_s else (22 if proto == "sftp" else 21)
except ValueError: except ValueError:
self.query_one("#conn_status", Static).update( self.query_one("#conn_status", Static).update("[red]Ungültiger Port.[/]")
"[red]Ungültiger Port.[/]"
)
return return
if not host or not user: if not host or not user:
self.query_one("#conn_status", Static).update( self.query_one("#conn_status", Static).update(
@@ -129,7 +138,10 @@ class ConnectionScreen(Screen):
return return
self.app.backend = backend self.app.backend = backend
self.app.protocol = proto 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("") status.update("")
@on(Button.Pressed, "#save_profile") @on(Button.Pressed, "#save_profile")
@@ -149,9 +161,7 @@ class ConnectionScreen(Screen):
) )
) )
save_profiles(profiles) save_profiles(profiles)
self.query_one("#conn_status", Static).update( self.query_one("#conn_status", Static).update("[green]Profil gespeichert.[/]")
"[green]Profil gespeichert.[/]"
)
@on(Button.Pressed, "#load_profile") @on(Button.Pressed, "#load_profile")
def _load_profile(self) -> None: def _load_profile(self) -> None:
@@ -165,16 +175,13 @@ class ConnectionScreen(Screen):
self.query_one("#port", Input).value = str(p.port) self.query_one("#port", Input).value = str(p.port)
self.query_one("#username", Input).value = p.username self.query_one("#username", Input).value = p.username
self.query_one("#password", Input).value = p.password self.query_one("#password", Input).value = p.password
self.query_one("#conn_status", Static).update( self.query_one("#conn_status", Static).update("[green]Profil geladen.[/]")
"[green]Profil geladen.[/]"
)
return return
class BrowserScreen(Screen): class BrowserScreen(Screen):
"""Zweispaltiger Datei-Browser zwischen lokalem und entferntem System.""" """Total-Commander-artiger Datei-Browser zwischen lokal & entfernt."""
SCREEN_NAME = "browser"
BINDINGS = [ BINDINGS = [
Binding("q", "quit", "Beenden"), Binding("q", "quit", "Beenden"),
Binding("enter", "open", "Öffnen"), Binding("enter", "open", "Öffnen"),
@@ -185,97 +192,129 @@ class BrowserScreen(Screen):
Binding("d", "delete", "Löschen"), Binding("d", "delete", "Löschen"),
Binding("r", "rename", "Umbenennen"), Binding("r", "rename", "Umbenennen"),
Binding("left", "up_level", "Eine Ebene hoch"), Binding("left", "up_level", "Eine Ebene hoch"),
Binding("space", "select", "Auswählen"), Binding("backspace", "up_level", "Eine Ebene hoch"),
] ]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header(show_clock=True)
yield Static(id="pathbar", classes="pathbar")
yield Horizontal( yield Horizontal(
Vertical( Vertical(
Static("Lokales System", classes="pane-title"), Static(" Lokal ", classes="pane-title local-title active"),
DataTable(id="local_table", zebra_stripes=True), DataTable(id="local_table"),
classes="pane", classes="pane",
), ),
Static(" ", classes="vsep"),
Vertical( Vertical(
Static("Entferntes System", classes="pane-title"), Static(" Entfernt ", classes="pane-title remote-title"),
DataTable(id="remote_table", zebra_stripes=True), DataTable(id="remote_table"),
classes="pane", classes="pane",
), ),
classes="panes",
) )
yield Static(id="info", classes="info")
yield Static(id="statusbar", classes="statusbar")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
self.local_path = str(Path.home()) self.local_path = str(Path.home())
self.remote_path: str = "" self.remote_path = ""
self.table_local = self.query_one("#local_table", DataTable) self.table_local = self.query_one("#local_table", DataTable)
self.table_remote = self.query_one("#remote_table", DataTable) self.table_remote = self.query_one("#remote_table", DataTable)
self.backend: FileTransferBackend = self.app.backend self.backend: FileTransferBackend = self.app.backend
self.table_local.add_columns("Name", "Größe", "Geändert", key="name") self._setup_table(self.table_local)
self.table_remote.add_columns("Name", "Größe", "Geändert", key="name") self._setup_table(self.table_remote)
self._refresh_local() self._refresh_local()
self._refresh_remote() 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: def _refresh_local(self) -> None:
self.table_local.clear() table = self.table_local
table.clear()
entries: list[RemoteEntry] = [] entries: list[RemoteEntry] = []
try: 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()))
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
except OSError as exc: except OSError as exc:
self.query_one("#info", Static).update(f"[red]{exc}[/]") self.query_one("#statusbar", Static).update(f"[red]{exc}[/]")
return 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: def _refresh_remote(self) -> None:
self.table_remote.clear() table = self.table_remote
table.clear()
try: try:
if not self.remote_path: if not self.remote_path:
self.remote_path = self.backend.pwd() self.remote_path = self.backend.pwd()
entries = self.backend.listdir(self.remote_path) entries = self.backend.listdir(self.remote_path)
self.remote_path = self.backend.pwd() self.remote_path = self.backend.pwd()
except Exception as exc: # noqa: BLE001 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 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: def _fill(self, table: DataTable, entries: list[RemoteEntry], local: bool) -> None:
parent = _parent(self.local_path if local else self.remote_path, local) parent = _parent(self.local_path if local else self.remote_path, local)
table.add_row("\u2191 ..", "", "", key=parent) table.add_row(" ..", "", "", key="__parent__")
for e in entries: for e in sorted(entries, key=lambda x: (x.kind != "dir", x.name.lower())):
if e.name in (".", ".."): if e.name in (".", ".."):
continue continue
suffix = "/" if e.kind == "dir" else "" suffix = "/" if e.kind == "dir" else ""
table.add_row( size = _human_size(e.size) if e.kind == "file" else "<DIR>"
e.name + suffix, if e.kind == "dir":
_human_size(e.size) if e.kind == "file" else "<dir>", name_cell = Text(e.name + suffix, style="bold #4e9a06")
e.modified if isinstance(e.modified, str) else "", else:
key=e.name, 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: def _update_info(self) -> None:
self.query_one("#info", Static).update( active = self.app.focused
f"[bold]lokal:[/] {self.local_path}\t" side = "lokal" if active is self.table_local else "entfernt"
f"[bold]entfernt:[/] {self.remote_path}\t" self.query_one("#pathbar", Static).update(
f"[bold]protokoll:[/] {self.app.protocol.upper()}" 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) @on(DataTable.RowHighlighted)
def _highlight(self, event: DataTable.RowHighlighted) -> None: def _highlight(self, event: DataTable.RowHighlighted) -> None:
self._update_info() 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: def _selected(self, table: DataTable) -> str | None:
row = table.cursor_row row = table.cursor_row
if row is None: if row is None:
@@ -295,7 +334,7 @@ class BrowserScreen(Screen):
sel = self._selected(table) sel = self._selected(table)
if sel is None: if sel is None:
return return
if sel.startswith("\u2191"): if sel.startswith(""):
newdir = _parent(self.local_path if which == "local" else self.remote_path, which == "local") newdir = _parent(self.local_path if which == "local" else self.remote_path, which == "local")
if which == "local": if which == "local":
self.local_path = newdir self.local_path = newdir
@@ -317,14 +356,17 @@ class BrowserScreen(Screen):
self._refresh_remote() self._refresh_remote()
self._update_info() 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: def action_up_level(self) -> None:
table = self.app.focused if self.app.focused is self.table_local:
if table is self.table_local:
self.local_path = _parent(self.local_path, True) self.local_path = _parent(self.local_path, True)
self._refresh_local() self._refresh_local()
else: else:
self.remote_path = _parent(self.remote_path, False) self.remote_path = _parent(self.remote_path, False)
self._refresh_remote() self._refresh_remote()
self._update_info()
def action_download(self) -> None: def action_download(self) -> None:
sel = self._selected(self.table_remote) sel = self._selected(self.table_remote)
@@ -338,7 +380,7 @@ class BrowserScreen(Screen):
try: try:
self.backend.download(_join(self.remote_path, name, False), target_local) self.backend.download(_join(self.remote_path, name, False), target_local)
except Exception as exc: # noqa: BLE001 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 return
self._refresh_local() self._refresh_local()
@@ -354,7 +396,7 @@ class BrowserScreen(Screen):
try: try:
self.backend.upload(_join(self.local_path, name, True), target_remote) self.backend.upload(_join(self.local_path, name, True), target_remote)
except Exception as exc: # noqa: BLE001 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 return
self._refresh_remote() self._refresh_remote()
@@ -368,7 +410,6 @@ class BrowserScreen(Screen):
if download: if download:
self.backend.mkdir(dst) self.backend.mkdir(dst)
entries = self.backend.listdir(src) entries = self.backend.listdir(src)
self.remote_path = self.backend.pwd()
for e in entries: for e in entries:
if e.kind == "dir": if e.kind == "dir":
self._copy_tree( self._copy_tree(
@@ -386,10 +427,10 @@ class BrowserScreen(Screen):
else: else:
self.backend.upload(_join(src, item.name, True), _join(dst, item.name, False)) self.backend.upload(_join(src, item.name, True), _join(dst, item.name, False))
except Exception as exc: # noqa: BLE001 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: def action_mkdir(self) -> None:
table = self.app.focused table = self._focused_table()
remote = table is not self.table_local remote = table is not self.table_local
try: try:
if remote: if remote:
@@ -399,10 +440,10 @@ class BrowserScreen(Screen):
os.makedirs(_join(self.local_path, "neues-verzeichnis", True), exist_ok=True) os.makedirs(_join(self.local_path, "neues-verzeichnis", True), exist_ok=True)
self._refresh_local() self._refresh_local()
except Exception as exc: # noqa: BLE001 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: def action_delete(self) -> None:
table = self.app.focused table = self._focused_table()
sel = self._selected(table) sel = self._selected(table)
if not sel: if not sel:
return return
@@ -424,19 +465,19 @@ class BrowserScreen(Screen):
os.remove(p) os.remove(p)
self._refresh_local() self._refresh_local()
except Exception as exc: # noqa: BLE001 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: def action_rename(self) -> None:
table = self.app.focused table = self._focused_table()
sel = self._selected(table) sel = self._selected(table)
if not sel: if not sel:
return return
self._rename_target = sel self._rename_target = sel
self._rename_panel = "remote" if table is not self.table_local else "local"
self.app.push_screen(RenameScreen()) self.app.push_screen(RenameScreen())
def do_rename(self, new_name: str) -> None: def do_rename(self, new_name: str) -> None:
table = self.app.focused remote = bool(getattr(self, "_rename_panel", "") == "remote")
remote = table is not self.table_local
sel = self._rename_target sel = self._rename_target
old = sel[:-1] if sel.endswith("/") else sel old = sel[:-1] if sel.endswith("/") else sel
try: try:
@@ -453,15 +494,15 @@ class BrowserScreen(Screen):
) )
self._refresh_local() self._refresh_local()
except Exception as exc: # noqa: BLE001 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): class RenameScreen(Screen):
"""Kleiner Eingabe-Bildschirm zum Umbenennen.""" """Eingabebildschirm zum Umbenennen."""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Label("Neuer Name:", classes="field") yield Static("Neuer Name:", classes="field")
yield Input(id="newname", placeholder="neuer-name") yield Input(id="newname", placeholder="neuer-name", classes="input")
yield Horizontal( yield Horizontal(
Button("OK", variant="primary", id="ok"), Button("OK", variant="primary", id="ok"),
Button("Abbrechen", id="cancel"), Button("Abbrechen", id="cancel"),
@@ -480,43 +521,133 @@ class RenameScreen(Screen):
class FTpTui(App): class FTpTui(App):
"""Wurzel der ftptui-Anwendung.""" """Root der ftptui-Anwendung klassisches Terminal-Thema (ranger/LF)."""
CSS = """ CSS = """
Screen { FTpTui, Screen {
background: $surface; background: $background;
color: $text;
} }
.field {
margin: 1 2; Header {
background: #101010;
color: #e0e0e0;
text-style: bold;
} }
.title { .title {
text-align: center; text-align: center;
margin: 1 2; margin: 1 2;
padding: 1 0;
background: #0c0c0c;
color: #50fa7b;
text-style: bold; 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 { .row {
margin: 1 2; margin: 1 2;
height: 3; height: 3;
align: left middle; align: left middle;
} }
.row Button { .row Button {
margin-right: 1; 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 { .pane {
height: 1fr; height: 1fr;
width: 1fr;
} }
.vsep {
width: 1;
background: #101010;
color: #00aa00;
}
.pane-title { .pane-title {
background: $panel; background: #20202a;
color: $text; color: #cfcfcf;
text-style: bold; text-style: bold;
padding: 0 1; padding: 0 1;
height: 1;
} }
.pane-title.active {
background: #4e9a06;
color: #0a0a0a;
}
DataTable { DataTable {
height: 1fr; 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; 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__() super().__init__()
self.backend = None self.backend = None
self.protocol = "sftp" self.protocol = "sftp"
self.host = ""
self.port = 0
self.user = ""
def on_mount(self) -> None: def on_mount(self) -> None:
self.push_screen(ConnectionScreen()) self.push_screen(ConnectionScreen())
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ftptui" name = "ftptui"
version = "0.1.0" version = "0.2.0"
description = "Interaktiver FTP/SFTP-Client als Terminal-User-Interface in Python" description = "Interaktiver FTP/SFTP-Client als Terminal-User-Interface in Python"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"