v0.2.0: Bugfix Screenwechsel, ranger-Thema, Total-Commander-Layout
This commit is contained in:
@@ -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.*
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
+237
-103
@@ -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 "<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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user