Compare commits

..
2 Commits
Author SHA1 Message Date
x3 9ba9ec9640 v0.4.0: Markierung, Datei-Betrachter, Pfeiltasten im Login, skalierendes Bedienmenue
Mehrfachauswahl
- Leertaste markiert/entmarkiert den Eintrag unter dem Cursor und rueckt eine
  Zeile weiter; markierte Eintraege werden fett und gelb dargestellt.
- Strg+A markiert alles bzw. hebt alles auf, Esc leert die Markierung.
- Die Pfadleiste oben zeigt die Anzahl markierter Eintraege je Paneel.
- l/u/d arbeiten auf allen markierten Eintraegen, sonst auf dem Cursor-Eintrag,
  und melden anschliessend Erfolge und Fehler.
- Markierungen werden bei Verzeichniswechsel und nach Transfers verworfen und
  beim Neuaufbau auf tatsaechlich vorhandene Eintraege eingedampft.

Datei-Betrachter
- Enter bzw. v oeffnet Textdateien mit Syntax-Hervorhebung; die Sprache
  bestimmt Pygments anhand des Dateinamens (.txt, .md, .cpp, .py, ...).
- Entfernte Dateien werden dafuer in eine Temporaerdatei geladen und danach
  wieder entfernt. Binaerdateien und Dateien ueber 2 MiB werden abgelehnt.
- Blaettern mit Pfeiltasten/Bild/Pos1/Ende, w schaltet den Umbruch, l die
  Zeilennummern, Esc/q schliesst.
- Enter auf einer Datei setzte vorher remote_path auf die Datei selbst, was
  das anschliessende listdir() scheitern liess.

Verbindungsdialog
- Pfeiltasten wechseln jetzt zwischen den Feldern. Ein eingeklapptes Select
  band up/down auf show_overlay und verschluckte sie; NavSelect gibt sie an
  den Screen weiter, geoeffnet wird mit Enter/Leertaste. Ist das Overlay
  offen, behaelt es seine eigene up/down-Navigation.
- Enter in einem Eingabefeld verbindet, Strg+S speichert das Profil.
- Dialog bei kleinen Terminals scrollbar.

Bedienmenue skaliert
- Pfad- und Statusleiste hatten "height: 1" und wurden am rechten Rand
  abgeschnitten; sie brechen jetzt um (height: auto).
- Der Footer wurde entfernt: seine Tasten reichten bei 78 Zeichen Breite bis
  Spalte 193 und waren zu zwei Dritteln unsichtbar. Die umbrechende
  Statusleiste zeigt die Belegung stattdessen vollstaendig.
- Spaltenbreiten der Dateiliste richten sich nach der Paneelbreite; zuerst
  entfaellt das Datum, dann die Groesse. Vorher ragten die Spalten ueber das
  Paneel hinaus und erzeugten einen horizontalen Scrollbalken.
- Fehlermeldungen stehen in einer eigenen Zeile, die nur bei Inhalt Platz
  belegt. Bisher wurden sie in die Statusleiste geschrieben und dort vom
  naechsten Cursor-Wechsel sofort wieder ueberschrieben.
2026-08-31 17:33:00 +00:00
x3 226108f4dc Up-/Download gefixt: absoluter Remote-Pfad statt "/./datei" (#3)
paramikos getcwd() liefert None, solange kein chdir() erfolgt ist. Der
Rueckfall auf "." landete als remote_path in der App, woraus _join()/_abs()
Pfade wie "/./datei" bauten. Der Server loeste die relativ zum Dateisystem-
Root statt zum Home auf -> "No such file" beim Download, "Permission denied"
beim Upload. Das Listing funktionierte, weil listdir(".") serverseitig
relativ zum Home aufgeloest wird -- daher fiel es erst beim Transfer auf.

- connect()/chdir()/listdir() loesen den Pfad ueber sftp.normalize() auf
- _abs() haengt relative Pfade an das CWD an, statt blind "/" voranzustellen
- _copy_tree(): die beiden mkdir-Zweige waren vertauscht. Beim Download
  wurde backend.mkdir() auf einen lokalen Pfad angewendet (Verzeichnis
  entstand auf dem Server), beim Upload os.makedirs() auf einen entfernten.
  Ordner-Transfers waren dadurch in beide Richtungen defekt.

Verifiziert per End-to-End-Test gegen einen echten sshd/SFTP-Server:
Datei- und rekursiver Ordner-Transfer in beide Richtungen.

Closes #3
2026-08-31 17:19:15 +00:00
5 changed files with 580 additions and 104 deletions
+52 -11
View File
@@ -156,25 +156,66 @@ ftptui
## Tastaturkürzel ## Tastaturkürzel
### Dateibrowser
| Taste | Aktion | | Taste | Aktion |
|--------------|---------------------------------| |--------------|---------------------------------|
| `Tab` | Zwischen lokalem und entferntem Paneel wechseln | | `Tab` | Zwischen lokalem und entferntem Paneel wechseln |
| `↑` / `↓` | Zeilenauswahl bewegen | | `↑` / `↓` | Zeilenauswahl bewegen |
| `→` | Markierten Ordner/Eintrag öffnen | | `→` | Ordner öffnen / Datei ansehen |
| `←` / `⌫` | Eine Verzeichnisebene höher | | `←` / `⌫` | Eine Verzeichnisebene höher |
| `Enter` | Ordner öffnen (aktiv: fokussiertes Paneel) | | `Enter` | Ordner öffnen, Datei im Betrachter anzeigen |
| `↑ ..` | Eine Ebene höher (Tabellenzeile) | | `↑ ..` | Eine Ebene höher (Tabellenzeile) |
| `l` | Auswahl **herunterladen** (entfernt → lokal) | | `Leertaste` | Eintrag **markieren/abwählen** (Cursor rückt weiter) |
| `u` | Auswahl **hochladen** (lokal → entfernt) | | `Strg`+`A` | Alle Einträge markieren / Markierung aufheben |
| `Esc` | Markierung des aktiven Paneels aufheben |
| `v` | Datei im Betrachter anzeigen |
| `l` | **Herunterladen** (entfernt → lokal) |
| `u` | **Hochladen** (lokal → entfernt) |
| `n` | Neues Verzeichnis anlegen | | `n` | Neues Verzeichnis anlegen |
| `d` | Auswahl löschen (rekursiv) | | `d` | Löschen (rekursiv) |
| `r` | Auswahl umbenennen | | `r` | Umbenennen |
| `q` / `Esc` | Beenden | | `q` | Beenden |
> Hinweis: Hoch-/Herunterladen (`u`/`l`), Anlegen (`n`), Löschen (`d`) und > Hinweis: Hoch-/Herunterladen (`u`/`l`), Löschen (`d`) und Umbenennen (`r`)
> Umbenennen (`r`) wirken auf das **aktive (grün markierte) Paneel**. Wechsel > wirken auf das **aktive (grün markierte) Paneel**. Wechsel der Seite mit `Tab`.
> der Seite mit `Tab`. Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur > Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur (öffnen / eine
> (öffnen / eine Ebene hoch), `↑`/`↓` bewegen die Zeilenauswahl. > Ebene hoch), `↑`/`↓` bewegen die Zeilenauswahl.
#### Mehrfachauswahl
Mit der `Leertaste` markierte Einträge werden **fett und gelb** dargestellt; die
Pfadleiste oben zeigt an, wie viele Einträge je Paneel markiert sind. Liegt eine
Markierung vor, arbeiten `l`, `u` und `d` auf **allen markierten Einträgen**,
sonst auf dem Eintrag unter dem Cursor. Nach dem Transfer bzw. beim
Verzeichniswechsel wird die Markierung zurückgesetzt.
### Datei-Betrachter
`Enter` oder `v` auf einer Datei öffnet sie mit Syntax-Hervorhebung; die Sprache
wird über Pygments anhand der Endung erkannt (`.txt`, `.md`, `.py`, `.cpp`,
`.json`, `.yml`, …). Entfernte Dateien werden dafür in eine Temporärdatei
geladen und danach wieder entfernt. Binärdateien und Dateien über 2 MiB werden
abgelehnt statt angezeigt.
| Taste | Aktion |
|------------------|-----------------------------|
| `Esc` / `q` | Betrachter schließen |
| `↑` / `↓` | Zeilenweise blättern |
| `Bild↑` / `Bild↓`| Seitenweise blättern |
| `Pos1` / `Ende` | Anfang / Ende |
| `w` | Zeilenumbruch umschalten |
| `l` | Zeilennummern umschalten |
### Verbindungsdialog
| Taste | Aktion |
|--------------|---------------------------------|
| `↑` / `↓` | Zwischen den Eingabefeldern wechseln |
| `Tab` | Nächstes Feld |
| `Enter` | In einem Eingabefeld: verbinden; in einer Auswahlliste: Liste öffnen |
| `Strg`+`S` | Profil speichern |
| `Esc` | Beenden |
--- ---
+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.3.1" __version__ = "0.4.0"
+484 -64
View File
@@ -8,14 +8,18 @@ unten. Verwendet ``textual`` und unterstützt FTP sowie SFTP (SSH-FTP).
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from rich.syntax import Syntax
from rich.text import Text 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
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.coordinate import Coordinate
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import ( from textual.widgets import (
Button, Button,
@@ -67,6 +71,114 @@ def _join(path: str, name: str, is_local: bool) -> str:
return path.rstrip("/") + "/" + name return path.rstrip("/") + "/" + name
#: Dateien oberhalb dieser Größe werden nicht in den Viewer geladen.
MAX_VIEW_BYTES = 2 * 1024 * 1024
def _looks_binary(data: bytes) -> bool:
"""Heuristik: Null-Bytes oder viele nicht-druckbare Zeichen -> Binärdatei."""
if b"\0" in data:
return True
sample = data[:4096]
if not sample:
return False
printable = sum(1 for b in sample if b in (9, 10, 13) or 32 <= b < 127 or b >= 160)
return printable / len(sample) < 0.85
class ViewerScreen(Screen):
"""Datei-Betrachter mit Syntax-Hervorhebung.
Die Sprache wird von ``rich.syntax`` anhand des Dateinamens (und ersatzweise
des Inhalts) über Pygments bestimmt, sodass ``.txt``, ``.md``, ``.cpp``,
``.py`` usw. ohne eigene Tabelle abgedeckt sind.
"""
BINDINGS = [
Binding("escape,q", "close", "Schließen"),
Binding("down,j", "scroll_down", "Runter", show=False),
Binding("up,k", "scroll_up", "Hoch", show=False),
Binding("pagedown", "page_down", "Seite runter", show=False),
Binding("pageup", "page_up", "Seite hoch", show=False),
Binding("home", "scroll_home", "Anfang", show=False),
Binding("end", "scroll_end", "Ende", show=False),
Binding("w", "toggle_wrap", "Umbruch"),
Binding("l", "toggle_lineno", "Zeilennummern"),
]
def __init__(self, filename: str, text: str, subtitle: str = "") -> None:
super().__init__()
self._filename = filename
self._text = text
self._subtitle = subtitle
self._wrap = True
self._lineno = True
def compose(self) -> ComposeResult:
yield Static(id="viewer_title", classes="pathbar")
yield VerticalScroll(Static(id="viewer_body"), id="viewer_scroll")
yield Static(
" Esc/q Schließen · ↑/↓ Blättern · PgUp/PgDn Seite · "
"Pos1/Ende Anfang/Ende · w Umbruch · l Zeilennummern",
classes="statusbar",
)
def on_mount(self) -> None:
self.query_one("#viewer_title", Static).update(
f" {os.path.basename(self._filename)} {self._subtitle}"
)
self._render_body()
self.query_one("#viewer_scroll").focus()
def _render_body(self) -> None:
try:
lexer = Syntax.guess_lexer(self._filename, self._text)
except Exception: # noqa: BLE001
lexer = "text"
self.query_one("#viewer_body", Static).update(
Syntax(
self._text,
lexer,
theme="monokai",
line_numbers=self._lineno,
word_wrap=self._wrap,
indent_guides=False,
)
)
def action_toggle_wrap(self) -> None:
self._wrap = not self._wrap
self._render_body()
def action_toggle_lineno(self) -> None:
self._lineno = not self._lineno
self._render_body()
def action_close(self) -> None:
self.app.pop_screen()
def _scroll(self):
return self.query_one("#viewer_scroll", VerticalScroll)
def action_scroll_down(self) -> None:
self._scroll().scroll_down()
def action_scroll_up(self) -> None:
self._scroll().scroll_up()
def action_page_down(self) -> None:
self._scroll().scroll_page_down()
def action_page_up(self) -> None:
self._scroll().scroll_page_up()
def action_scroll_home(self) -> None:
self._scroll().scroll_home()
def action_scroll_end(self) -> None:
self._scroll().scroll_end()
class FileTable(DataTable): class FileTable(DataTable):
"""DataTable mit Datei-Manager-Navigation. """DataTable mit Datei-Manager-Navigation.
@@ -89,10 +201,39 @@ class FileTable(DataTable):
self.screen.action_open() self.screen.action_open()
class NavSelect(Select):
"""``Select``, bei dem die Pfeiltasten das Feld wechseln.
Ein eingeklapptes ``Select`` bindet ``up``/``down`` standardmäßig auf
``show_overlay`` und verschluckt sie damit. Im Verbindungsdialog soll aber
überall einheitlich mit den Pfeiltasten zwischen den Feldern navigiert
werden; geöffnet wird die Liste mit Enter oder Leertaste. Ist das Overlay
offen, besitzt es selbst den Fokus und behält seine eigene
``up``/``down``-Navigation über die Einträge.
"""
BINDINGS = [
Binding("enter,space", "show_overlay", "Auswahl öffnen", show=False),
Binding("up", "screen.prev_field", "Feld zurück", show=False),
Binding("down", "screen.next_field", "Feld weiter", show=False),
]
class ConnectionScreen(Screen): class ConnectionScreen(Screen):
"""Startbildschirm zur Eingabe der Verbindungsdaten (Terminal-Look).""" """Startbildschirm zur Eingabe der Verbindungsdaten (Terminal-Look)."""
BINDINGS = [Binding("escape", "app.quit", "Beenden")] BINDINGS = [
Binding("escape", "app.quit", "Beenden"),
Binding("up", "prev_field", "Feld zurück", show=False),
Binding("down", "next_field", "Feld weiter", show=False),
Binding("ctrl+s", "save_profile", "Profil speichern"),
]
def action_next_field(self) -> None:
self.focus_next()
def action_prev_field(self) -> None:
self.focus_previous()
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=True) yield Header(show_clock=True)
@@ -101,12 +242,12 @@ class ConnectionScreen(Screen):
classes="title", classes="title",
) )
yield Label(" Verbindungsprofil:", classes="field") yield Label(" Verbindungsprofil:", classes="field")
yield Select( yield NavSelect(
[(p.name, p.name) for p in load_profiles()], id="profile", [(p.name, p.name) for p in load_profiles()], id="profile",
classes="input", prompt="Neues Profil…", classes="input", prompt="Neues Profil…",
) )
yield Label(" Protokoll:", classes="field") yield Label(" Protokoll:", classes="field")
yield Select( yield NavSelect(
[("SFTP (SSH)", "sftp"), ("FTP", "ftp")], [("SFTP (SSH)", "sftp"), ("FTP", "ftp")],
value="sftp", id="protocol", classes="input", value="sftp", id="protocol", classes="input",
) )
@@ -166,6 +307,14 @@ class ConnectionScreen(Screen):
self.app.switch_screen(BrowserScreen()) self.app.switch_screen(BrowserScreen())
status.update("") status.update("")
@on(Input.Submitted)
def _submit(self, event: Input.Submitted) -> None:
"""Enter in einem Eingabefeld verbindet direkt."""
self._connect()
def action_save_profile(self) -> None:
self._save_profile()
@on(Button.Pressed, "#save_profile") @on(Button.Pressed, "#save_profile")
def _save_profile(self) -> None: def _save_profile(self) -> None:
profiles = load_profiles() profiles = load_profiles()
@@ -208,6 +357,10 @@ class BrowserScreen(Screen):
Binding("q", "app.quit", "Beenden"), Binding("q", "app.quit", "Beenden"),
Binding("enter", "open", "Öffnen"), Binding("enter", "open", "Öffnen"),
Binding("tab", "focus_next", "Panele wechseln"), Binding("tab", "focus_next", "Panele wechseln"),
Binding("space", "toggle_mark", "Markieren"),
Binding("ctrl+a", "mark_all", "Alle markieren"),
Binding("escape", "clear_marks", "Markierung aufheben"),
Binding("v", "view", "Ansehen"),
Binding("l", "download", "Herunterladen"), Binding("l", "download", "Herunterladen"),
Binding("u", "upload", "Hochladen"), Binding("u", "upload", "Hochladen"),
Binding("n", "mkdir", "Verzeichnis"), Binding("n", "mkdir", "Verzeichnis"),
@@ -235,12 +388,17 @@ class BrowserScreen(Screen):
classes="panes", classes="panes",
) )
yield Static(id="message", classes="message")
# Kein Footer: dessen Tasten werden bei schmalen Terminals hart
# abgeschnitten. Die Statusleiste unten bricht stattdessen um und
# zeigt die Belegung immer vollständig.
yield Static(id="statusbar", classes="statusbar") yield Static(id="statusbar", classes="statusbar")
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 = "" self.remote_path = ""
self._marked_local: set[str] = set()
self._marked_remote: set[str] = set()
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
@@ -256,6 +414,45 @@ class BrowserScreen(Screen):
table.cursor_type = "row" table.cursor_type = "row"
table.zebra_stripes = False table.zebra_stripes = False
def on_resize(self, event=None) -> None:
# Beide Paneele identisch layouten: sie unterscheiden sich um ein
# Zeichen und lägen sonst auf verschiedenen Seiten der Schwelle,
# sodass nur eine Seite eine Datumsspalte hätte.
width = min(self.table_local.size.width, self.table_remote.size.width)
self._layout_columns(self.table_local, width)
self._layout_columns(self.table_remote, width)
def _layout_columns(self, table: FileTable, width: int | None = None) -> None:
"""Verteilt die Spaltenbreiten auf die Paneelbreite.
Ohne das summieren sich die Spalten auf mehr als das Paneel breit ist,
die Tabelle bekommt einen horizontalen Scrollbalken und die
Datumsspalte wird abgeschnitten. Die Namensspalte bekommt deshalb den
Rest; bei schmalen Fenstern entfällt zuerst das Datum, dann die Größe.
"""
cols = list(table.columns.values())
width = table.size.width if width is None else width
if len(cols) < 3 or not width:
return
# Jede Spalte kostet zusaetzlich 2 Zeichen Zellabstand; 1 Zeichen bleibt
# als Reserve fuer den vertikalen Scrollbalken.
avail = width - 2 * len(cols) - 1
name_min, size_w, date_w = 8, 9, 16
# Das Datum lohnt sich erst, wenn dem Namen danach noch genug Platz
# bleibt - sonst ist die Datumsspalte breiter als die Namensspalte.
if avail >= 16 + size_w + date_w:
pass
elif avail >= name_min + size_w:
date_w = 0
else:
size_w = date_w = 0
name_w = max(name_min, avail - size_w - date_w)
for col, width in zip(cols, (name_w, size_w, date_w)):
col.width = width
col.auto_width = False
# layout=True, damit die Scrollbalken-Sichtbarkeit neu berechnet wird
table.refresh(layout=True)
def action_focus_next(self) -> None: def action_focus_next(self) -> None:
"""Wechselt den Fokus direkt zwischen lokalem und entferntem Paneel. """Wechselt den Fokus direkt zwischen lokalem und entferntem Paneel.
@@ -284,7 +481,7 @@ class BrowserScreen(Screen):
try: try:
items = 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: except OSError as exc:
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") self._notify(f"[red]{exc}[/]")
return return
for item in items: for item in items:
try: try:
@@ -311,36 +508,131 @@ class BrowserScreen(Screen):
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("#statusbar", Static).update(f"[red]{exc}[/]") self._notify(f"[red]{exc}[/]")
return return
self._fill(table, entries, local=False) self._fill(table, entries, local=False)
def _name_cell(self, label: str, marked: bool) -> Text:
"""Baut die Namenszelle; markierte Einträge werden fett hervorgehoben."""
if marked:
return Text(label, style="bold #f1fa8c")
if label.endswith("/"):
return Text(label, style="bold #4e9a06")
return Text(label, style="#d7d7d7")
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) marked = self._marked_local if local else self._marked_remote
table.add_row("↑ ..", "", "", key="__parent__") table.add_row("↑ ..", "", "", key="__parent__")
present: set[str] = set()
for e in sorted(entries, key=lambda x: (x.kind != "dir", x.name.lower())): 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 ""
label = e.name + suffix
present.add(label)
size = _human_size(e.size) if e.kind == "file" else "<DIR>" 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}" key = f"{'L' if local else 'R'}|{e.kind}|{e.name}"
table.add_row(name_cell, size, e.modified, key=key) table.add_row(self._name_cell(label, label in marked), size, e.modified, key=key)
# Markierungen auf tatsächlich vorhandene Einträge eindampfen
marked &= present
def _marks(self, table: FileTable) -> set[str]:
return self._marked_local if table is self.table_local else self._marked_remote
def _labels(self, table: FileTable) -> list[str]:
"""Alle Zeilenbeschriftungen in Anzeigereihenfolge (ohne ``..``)."""
out: list[str] = []
for i in range(len(table.rows)):
cell = table.get_row_at(i)[0]
label = cell.plain if isinstance(cell, Text) else str(cell)
if not label.startswith(""):
out.append(label)
return out
def _targets(self, table: FileTable) -> list[str]:
"""Zielauswahl: markierte Einträge, sonst der Eintrag unter dem Cursor."""
marked = self._marks(table)
if marked:
return [lbl for lbl in self._labels(table) if lbl in marked]
sel = self._selected_entry(table)
return [sel] if sel else []
def action_toggle_mark(self) -> None:
table = self._focused_table()
sel = self._selected_entry(table)
if not sel:
return
marked = self._marks(table)
if sel in marked:
marked.discard(sel)
else:
marked.add(sel)
row = table.cursor_row
table.update_cell_at(Coordinate(row, 0), self._name_cell(sel, sel in marked))
if row + 1 < len(table.rows):
table.move_cursor(row=row + 1)
self._update_info()
def action_mark_all(self) -> None:
table = self._focused_table()
marked = self._marks(table)
labels = self._labels(table)
already_all = bool(labels) and len(marked) == len(labels)
marked.clear()
if not already_all:
marked.update(labels)
self._restyle(table)
self._update_info()
def action_clear_marks(self) -> None:
table = self._focused_table()
self._marks(table).clear()
self._restyle(table)
self._update_info()
def _restyle(self, table: FileTable) -> None:
marked = self._marks(table)
for i in range(len(table.rows)):
cell = table.get_row_at(i)[0]
label = cell.plain if isinstance(cell, Text) else str(cell)
if label.startswith(""):
continue
table.update_cell_at(Coordinate(i, 0), self._name_cell(label, label in marked))
#: Bedienhinweise. Die Statusleiste bricht um, damit nichts abgeschnitten wird.
HELP = (
" Tab Panele · ↑/↓ Auswahl · ← Hoch · → Öffnen · Enter Öffnen/Ansehen · "
"Leertaste Markieren · Strg+A Alle · Esc Markierung weg · v Ansehen · "
"l Download · u Upload · n Neu · d Löschen · r Umbenennen · q Beenden"
)
def _notify(self, markup: str) -> None:
"""Schreibt eine Meldung in die eigene Meldungszeile.
Bewusst getrennt von der Statusleiste: die wurde früher von jedem
Cursor-Wechsel überschrieben, sodass Fehlermeldungen sofort verschwanden.
Ohne Inhalt wird die Zeile ausgeblendet, statt eine Leerzeile zu belegen.
"""
msg = self.query_one("#message", Static)
msg.update(markup)
msg.display = bool(markup)
def _mark_summary(self) -> str:
n_local, n_remote = len(self._marked_local), len(self._marked_remote)
parts = []
if n_local:
parts.append(f"{n_local} lokal")
if n_remote:
parts.append(f"{n_remote} entfernt")
return f" ▶ markiert: {' · '.join(parts)}" if parts else ""
def _update_info(self) -> None: def _update_info(self) -> None:
active = self.app.focused
side = "lokal" if active is self.table_local else "entfernt"
self.query_one("#pathbar", Static).update( self.query_one("#pathbar", Static).update(
f" {self.app.host} [{self.app.protocol.upper()}] " f" {self.app.host} [{self.app.protocol.upper()}] "
f"lokal: {self.local_path} | entfernt: {self.remote_path}" f"lokal: {self.local_path} | entfernt: {self.remote_path}"
f"{self._mark_summary()}"
) )
self.query_one("#statusbar", Static).update( self.query_one("#statusbar", Static).update(self.HELP)
" Tab Panele · ↑/↓ Auswahl · ← Hoch · → Öffnen · Enter Öffnen · "
"l Download · u Upload · n Neu · d Löschen · r Umbenennen · q Beenden"
)
@on(DataTable.RowHighlighted) @on(DataTable.RowHighlighted)
def _highlight(self, event: DataTable.RowHighlighted) -> None: def _highlight(self, event: DataTable.RowHighlighted) -> None:
@@ -378,23 +670,29 @@ class BrowserScreen(Screen):
return return
if sel.startswith(""): 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": self._goto(which, newdir)
self.local_path = newdir
self._refresh_local()
else:
self.remote_path = newdir
self._refresh_remote()
self._update_info()
return return
name = sel[:-1] if sel.endswith("/") else sel name = sel[:-1] if sel.endswith("/") else sel
if not sel.endswith("/"):
# Datei -> Betrachter statt Verzeichniswechsel
self._view(which, name)
return
if which == "local": if which == "local":
target = _join(self.local_path, name, True) target = _join(self.local_path, name, True)
if os.path.isdir(target): if os.path.isdir(target):
self.local_path = target self._goto("local", target)
else:
self._goto("remote", _join(self.remote_path, name, False))
def _goto(self, which: str, path: str) -> None:
"""Wechselt das Verzeichnis eines Paneels und verwirft dessen Markierung."""
if which == "local":
self.local_path = path
self._marked_local.clear()
self._refresh_local() self._refresh_local()
else: else:
target = _join(self.remote_path, name, False) self.remote_path = path
self.remote_path = target self._marked_remote.clear()
self._refresh_remote() self._refresh_remote()
self._update_info() self._update_info()
@@ -403,54 +701,131 @@ class BrowserScreen(Screen):
def action_up_level(self) -> None: def action_up_level(self) -> None:
if self.app.focused is self.table_local: if self.app.focused is self.table_local:
self.local_path = _parent(self.local_path, True) self._goto("local", _parent(self.local_path, True))
self._refresh_local()
else: else:
self.remote_path = _parent(self.remote_path, False) self._goto("remote", _parent(self.remote_path, False))
self._refresh_remote()
self._update_info() def action_view(self) -> None:
table = self._focused_table()
sel = self._selected_entry(table)
if not sel or sel.endswith("/"):
self._notify("[yellow]Nur Dateien können angesehen werden.[/]")
return
self._view("local" if table is self.table_local else "remote", sel)
def _view(self, which: str, name: str) -> None:
"""Lädt eine Datei (entfernt: in eine Temporärdatei) und zeigt sie an."""
local = which == "local"
try:
if local:
path = _join(self.local_path, name, True)
size = os.path.getsize(path)
if size > MAX_VIEW_BYTES:
self._notify(
f"[yellow]{name} ist {_human_size(size)} groß "
f"Anzeige ab {_human_size(MAX_VIEW_BYTES)} deaktiviert.[/]"
)
return
data = Path(path).read_bytes()
subtitle = f"lokal · {_human_size(size)}"
else:
remote = _join(self.remote_path, name, False)
with tempfile.NamedTemporaryFile(suffix=f"-{name}", delete=False) as tmp:
tmp_path = tmp.name
try:
self.backend.download(remote, tmp_path)
size = os.path.getsize(tmp_path)
if size > MAX_VIEW_BYTES:
self._notify(
f"[yellow]{name} ist {_human_size(size)} groß "
f"Anzeige ab {_human_size(MAX_VIEW_BYTES)} deaktiviert.[/]"
)
return
data = Path(tmp_path).read_bytes()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
subtitle = f"entfernt · {_human_size(size)}"
except Exception as exc: # noqa: BLE001
self._notify(f"[red]Ansehen: {exc}[/]")
return
if _looks_binary(data):
self._notify(f"[yellow]{name} ist keine Textdatei.[/]")
return
text = data.decode("utf-8", errors="replace")
self.app.push_screen(ViewerScreen(name, text, subtitle))
def action_download(self) -> None: def action_download(self) -> None:
sel = self._selected_entry(self.table_remote) targets = self._targets(self.table_remote)
if not sel: if not targets:
return return
done, failed = 0, 0
for sel in targets:
name = sel[:-1] if sel.endswith("/") else sel name = sel[:-1] if sel.endswith("/") else sel
if sel.endswith("/"): if sel.endswith("/"):
self._transfer_dir(self.remote_path, name, self.local_path, download=True) if self._transfer_dir(self.remote_path, name, self.local_path, download=True):
done += 1
else: else:
target_local = _join(self.local_path, name, True) failed += 1
continue
try: try:
self.backend.download(_join(self.remote_path, name, False), target_local) self.backend.download(
_join(self.remote_path, name, False), _join(self.local_path, name, True)
)
done += 1
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.query_one("#statusbar", Static).update(f"[red]Download: {exc}[/]") failed += 1
return self._notify(f"[red]Download {name}: {exc}[/]")
self._marked_remote.clear()
self._refresh_remote()
self._refresh_local() self._refresh_local()
self._report("Heruntergeladen", done, failed)
def action_upload(self) -> None: def action_upload(self) -> None:
sel = self._selected_entry(self.table_local) targets = self._targets(self.table_local)
if not sel: if not targets:
return return
done, failed = 0, 0
for sel in targets:
name = sel[:-1] if sel.endswith("/") else sel name = sel[:-1] if sel.endswith("/") else sel
if sel.endswith("/"): if sel.endswith("/"):
self._transfer_dir(self.local_path, name, self.remote_path, download=False) if self._transfer_dir(self.local_path, name, self.remote_path, download=False):
done += 1
else: else:
target_remote = _join(self.remote_path, name, False) failed += 1
continue
try: try:
self.backend.upload(_join(self.local_path, name, True), target_remote) self.backend.upload(
_join(self.local_path, name, True), _join(self.remote_path, name, False)
)
done += 1
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.query_one("#statusbar", Static).update(f"[red]Upload: {exc}[/]") failed += 1
return self._notify(f"[red]Upload {name}: {exc}[/]")
self._marked_local.clear()
self._refresh_local()
self._refresh_remote() self._refresh_remote()
self._report("Hochgeladen", done, failed)
def _transfer_dir(self, src_dir: str, name: str, dst_dir: str, download: bool) -> None: def _report(self, verb: str, done: int, failed: int) -> None:
if failed:
self._notify(f"[red]{verb}: {done} ok, {failed} fehlgeschlagen.[/]")
elif done:
self._notify(f"[green]{verb}: {done} Eintrag/Einträge.[/]")
def _transfer_dir(self, src_dir: str, name: str, dst_dir: str, download: bool) -> bool:
src = _join(src_dir, name, not download) src = _join(src_dir, name, not download)
dst = _join(dst_dir, name, download) dst = _join(dst_dir, name, download)
self._copy_tree(src, dst, download) return self._copy_tree(src, dst, download)
def _copy_tree(self, src: str, dst: str, download: bool) -> None: def _copy_tree(self, src: str, dst: str, download: bool) -> bool:
try: try:
if download: if download:
self.backend.mkdir(dst) # dst ist ein *lokaler* Pfad -> lokal anlegen, nicht auf dem Server
os.makedirs(dst, exist_ok=True)
entries = self.backend.listdir(src) entries = self.backend.listdir(src)
for e in entries: for e in entries:
if e.kind == "dir": if e.kind == "dir":
@@ -460,7 +835,8 @@ class BrowserScreen(Screen):
else: else:
self.backend.download(_join(src, e.name, False), _join(dst, e.name, True)) self.backend.download(_join(src, e.name, False), _join(dst, e.name, True))
else: else:
os.makedirs(dst, exist_ok=True) # dst ist ein *entfernter* Pfad -> auf dem Server anlegen
self.backend.mkdir(dst)
for item in os.scandir(src): for item in os.scandir(src):
if item.is_dir(): if item.is_dir():
self._copy_tree( self._copy_tree(
@@ -469,7 +845,9 @@ 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("#statusbar", Static).update(f"[red]{exc}[/]") self._notify(f"[red]{exc}[/]")
return False
return True
def action_mkdir(self) -> None: def action_mkdir(self) -> None:
table = self._focused_table() table = self._focused_table()
@@ -482,14 +860,16 @@ 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("#statusbar", Static).update(f"[red]{exc}[/]") self._notify(f"[red]{exc}[/]")
def action_delete(self) -> None: def action_delete(self) -> None:
table = self._focused_table() table = self._focused_table()
sel = self._selected_entry(table) targets = self._targets(table)
if not sel: if not targets:
return return
remote = table is not self.table_local remote = table is not self.table_local
done, failed = 0, 0
for sel in targets:
name = sel[:-1] if sel.endswith("/") else sel name = sel[:-1] if sel.endswith("/") else sel
try: try:
if remote: if remote:
@@ -497,17 +877,22 @@ class BrowserScreen(Screen):
self.backend.rmdir(_join(self.remote_path, name, False)) self.backend.rmdir(_join(self.remote_path, name, False))
else: else:
self.backend.remove(_join(self.remote_path, name, False)) self.backend.remove(_join(self.remote_path, name, False))
self._refresh_remote()
else: else:
p = _join(self.local_path, name, True) p = _join(self.local_path, name, True)
if os.path.isdir(p): if os.path.isdir(p):
import shutil
shutil.rmtree(p) shutil.rmtree(p)
else: else:
os.remove(p) os.remove(p)
self._refresh_local() done += 1
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") failed += 1
self._notify(f"[red]Löschen {name}: {exc}[/]")
self._marks(table).clear()
if remote:
self._refresh_remote()
else:
self._refresh_local()
self._report("Gelöscht", done, failed)
def action_rename(self) -> None: def action_rename(self) -> None:
table = self._focused_table() table = self._focused_table()
@@ -536,7 +921,7 @@ class BrowserScreen(Screen):
) )
self._refresh_local() self._refresh_local()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]") self._notify(f"[red]{exc}[/]")
class RenameScreen(Screen): class RenameScreen(Screen):
@@ -576,6 +961,11 @@ class FTpTui(App):
color: $text; color: $text;
} }
/* Verbindungsdialog bei kleinen Terminals scrollbar halten */
ConnectionScreen {
overflow-y: auto;
}
Header { Header {
background: #101010; background: #101010;
color: #e0e0e0; color: #e0e0e0;
@@ -667,6 +1057,9 @@ class FTpTui(App):
height: 1fr; height: 1fr;
background: #0c0c0c; background: #0c0c0c;
color: #d7d7d7; color: #d7d7d7;
/* Spaltenbreiten werden passend berechnet (_layout_columns);
ein horizontaler Scrollbalken ist daher nie erwuenscht. */
overflow-x: hidden;
} }
DataTable > .datatable--header { DataTable > .datatable--header {
@@ -682,19 +1075,46 @@ class FTpTui(App):
text-style: bold; text-style: bold;
} }
/* Pfad- und Statusleiste umbrechen lassen, statt sie abzuschneiden.
Bei festem "height: 1" verschwand das Bedienmenue in schmalen
Terminals einfach am rechten Rand. */
.pathbar { .pathbar {
background: #000000; background: #000000;
color: #00ff87; color: #00ff87;
padding: 0 2; padding: 0 2;
text-style: bold; text-style: bold;
height: 1; height: auto;
max-height: 4;
text-wrap: wrap;
} }
.statusbar { .statusbar {
background: #101010; background: #101010;
color: #8fa1b3; color: #8fa1b3;
padding: 0 2; padding: 0 2;
height: 1; height: auto;
max-height: 6;
text-wrap: wrap;
}
/* Meldungszeile: nimmt nur Platz ein, wenn tatsaechlich etwas anliegt. */
.message {
display: none;
background: #101010;
padding: 0 2;
height: auto;
max-height: 4;
text-wrap: wrap;
}
#viewer_scroll {
height: 1fr;
background: #0c0c0c;
border: none;
}
#viewer_body {
width: auto;
} }
""" """
+22 -7
View File
@@ -29,7 +29,21 @@ class SFTPBackend:
timeout=30, timeout=30,
) )
self._sftp = self._ssh.open_sftp() self._sftp = self._ssh.open_sftp()
self._cwd = self._sftp.getcwd() or "." self._cwd = self._resolve(".")
def _resolve(self, path: str) -> str:
"""Löst einen Pfad serverseitig zu einem absoluten Pfad auf.
``paramiko.SFTPClient.getcwd()`` liefert ``None``, solange kein
explizites ``chdir()`` erfolgt ist ein Rückfall auf ``"."`` würde
später zu Pfaden wie ``/./datei`` führen, die der Server relativ zum
Dateisystem-Root statt zum Home-Verzeichnis auflöst.
"""
assert self._sftp is not None
try:
return self._sftp.normalize(path)
except OSError:
return path if path.startswith("/") else "/"
def listdir(self, path: str) -> list[RemoteEntry]: def listdir(self, path: str) -> list[RemoteEntry]:
assert self._sftp is not None assert self._sftp is not None
@@ -45,22 +59,23 @@ class SFTPBackend:
modified=attr.st_mtime, modified=attr.st_mtime,
) )
) )
self._cwd = path self._cwd = self._resolve(path)
return entries return entries
def chdir(self, path: str) -> None: def chdir(self, path: str) -> None:
assert self._sftp is not None assert self._sftp is not None
self._sftp.chdir(path) self._sftp.chdir(path)
self._cwd = self._sftp.getcwd() or path self._cwd = self._resolve(".")
def pwd(self) -> str: def pwd(self) -> str:
return self._cwd return self._cwd
def _abs(self, path: str) -> str: def _abs(self, path: str) -> str:
"""Baut einen absolut wertigen Pfad relativ zum SFTP-Root.""" """Baut einen absoluten Pfad relative Pfade gelten zum aktuellen CWD."""
if path.startswith("/"): if not path:
return path or "/" return self._cwd or "/"
return "/" + path base = self._cwd if self._cwd.startswith("/") else "/"
return str(PurePosixPath(base) / path)
def download(self, remote: str, local: str) -> None: def download(self, remote: str, local: str) -> None:
assert self._sftp is not None assert self._sftp is not None
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ftptui" name = "ftptui"
version = "0.3.1" version = "0.4.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"