Compare commits
2
Commits
db84a6aaf8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba9ec9640 | ||
|
|
226108f4dc |
@@ -156,25 +156,66 @@ ftptui
|
||||
|
||||
## Tastaturkürzel
|
||||
|
||||
### Dateibrowser
|
||||
|
||||
| Taste | Aktion |
|
||||
|--------------|---------------------------------|
|
||||
| `Tab` | Zwischen lokalem und entferntem Paneel wechseln |
|
||||
| `↑` / `↓` | Zeilenauswahl bewegen |
|
||||
| `→` | Markierten Ordner/Eintrag öffnen |
|
||||
| `→` | Ordner öffnen / Datei ansehen |
|
||||
| `←` / `⌫` | Eine Verzeichnisebene höher |
|
||||
| `Enter` | Ordner öffnen (aktiv: fokussiertes Paneel) |
|
||||
| `Enter` | Ordner öffnen, Datei im Betrachter anzeigen |
|
||||
| `↑ ..` | Eine Ebene höher (Tabellenzeile) |
|
||||
| `l` | Auswahl **herunterladen** (entfernt → lokal) |
|
||||
| `u` | Auswahl **hochladen** (lokal → entfernt) |
|
||||
| `Leertaste` | Eintrag **markieren/abwählen** (Cursor rückt weiter) |
|
||||
| `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 |
|
||||
| `d` | Auswahl löschen (rekursiv) |
|
||||
| `r` | Auswahl umbenennen |
|
||||
| `q` / `Esc` | Beenden |
|
||||
| `d` | Löschen (rekursiv) |
|
||||
| `r` | Umbenennen |
|
||||
| `q` | 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`. Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur
|
||||
> (öffnen / eine Ebene hoch), `↑`/`↓` bewegen die Zeilenauswahl.
|
||||
> Hinweis: Hoch-/Herunterladen (`u`/`l`), Löschen (`d`) und Umbenennen (`r`)
|
||||
> wirken auf das **aktive (grün markierte) Paneel**. Wechsel der Seite mit `Tab`.
|
||||
> Die Pfeiltasten `→`/`←` navigieren die Verzeichnisstruktur (öffnen / eine
|
||||
> 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
@@ -1,3 +1,3 @@
|
||||
"""ftptui - Interaktiver FTP/SFTP-Client als Terminal-User-Interface."""
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
+504
-84
@@ -8,14 +8,18 @@ unten. Verwendet ``textual`` und unterstützt FTP sowie SFTP (SSH-FTP).
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
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.widgets import (
|
||||
Button,
|
||||
@@ -67,6 +71,114 @@ def _join(path: str, name: str, is_local: bool) -> str:
|
||||
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):
|
||||
"""DataTable mit Datei-Manager-Navigation.
|
||||
|
||||
@@ -89,10 +201,39 @@ class FileTable(DataTable):
|
||||
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):
|
||||
"""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:
|
||||
yield Header(show_clock=True)
|
||||
@@ -101,12 +242,12 @@ class ConnectionScreen(Screen):
|
||||
classes="title",
|
||||
)
|
||||
yield Label(" Verbindungsprofil:", classes="field")
|
||||
yield Select(
|
||||
yield NavSelect(
|
||||
[(p.name, p.name) for p in load_profiles()], id="profile",
|
||||
classes="input", prompt="Neues Profil…",
|
||||
)
|
||||
yield Label(" Protokoll:", classes="field")
|
||||
yield Select(
|
||||
yield NavSelect(
|
||||
[("SFTP (SSH)", "sftp"), ("FTP", "ftp")],
|
||||
value="sftp", id="protocol", classes="input",
|
||||
)
|
||||
@@ -166,6 +307,14 @@ class ConnectionScreen(Screen):
|
||||
self.app.switch_screen(BrowserScreen())
|
||||
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")
|
||||
def _save_profile(self) -> None:
|
||||
profiles = load_profiles()
|
||||
@@ -208,6 +357,10 @@ class BrowserScreen(Screen):
|
||||
Binding("q", "app.quit", "Beenden"),
|
||||
Binding("enter", "open", "Öffnen"),
|
||||
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("u", "upload", "Hochladen"),
|
||||
Binding("n", "mkdir", "Verzeichnis"),
|
||||
@@ -235,12 +388,17 @@ class BrowserScreen(Screen):
|
||||
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 Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.local_path = str(Path.home())
|
||||
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_remote = self.query_one("#remote_table", DataTable)
|
||||
self.backend: FileTransferBackend = self.app.backend
|
||||
@@ -256,6 +414,45 @@ class BrowserScreen(Screen):
|
||||
table.cursor_type = "row"
|
||||
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:
|
||||
"""Wechselt den Fokus direkt zwischen lokalem und entferntem Paneel.
|
||||
|
||||
@@ -284,7 +481,7 @@ class BrowserScreen(Screen):
|
||||
try:
|
||||
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}[/]")
|
||||
self._notify(f"[red]{exc}[/]")
|
||||
return
|
||||
for item in items:
|
||||
try:
|
||||
@@ -311,36 +508,131 @@ class BrowserScreen(Screen):
|
||||
entries = self.backend.listdir(self.remote_path)
|
||||
self.remote_path = self.backend.pwd()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]")
|
||||
self._notify(f"[red]{exc}[/]")
|
||||
return
|
||||
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:
|
||||
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__")
|
||||
present: set[str] = set()
|
||||
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 ""
|
||||
label = e.name + suffix
|
||||
present.add(label)
|
||||
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)
|
||||
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:
|
||||
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}"
|
||||
f"{self._mark_summary()}"
|
||||
)
|
||||
self.query_one("#statusbar", Static).update(
|
||||
" Tab Panele · ↑/↓ Auswahl · ← Hoch · → Öffnen · Enter Öffnen · "
|
||||
"l Download · u Upload · n Neu · d Löschen · r Umbenennen · q Beenden"
|
||||
)
|
||||
self.query_one("#statusbar", Static).update(self.HELP)
|
||||
|
||||
@on(DataTable.RowHighlighted)
|
||||
def _highlight(self, event: DataTable.RowHighlighted) -> None:
|
||||
@@ -378,23 +670,29 @@ class BrowserScreen(Screen):
|
||||
return
|
||||
if sel.startswith("↑"):
|
||||
newdir = _parent(self.local_path if which == "local" else self.remote_path, which == "local")
|
||||
if which == "local":
|
||||
self.local_path = newdir
|
||||
self._refresh_local()
|
||||
else:
|
||||
self.remote_path = newdir
|
||||
self._refresh_remote()
|
||||
self._update_info()
|
||||
self._goto(which, newdir)
|
||||
return
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
if not sel.endswith("/"):
|
||||
# Datei -> Betrachter statt Verzeichniswechsel
|
||||
self._view(which, name)
|
||||
return
|
||||
if which == "local":
|
||||
target = _join(self.local_path, name, True)
|
||||
if os.path.isdir(target):
|
||||
self.local_path = target
|
||||
self._refresh_local()
|
||||
self._goto("local", target)
|
||||
else:
|
||||
target = _join(self.remote_path, name, False)
|
||||
self.remote_path = target
|
||||
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()
|
||||
else:
|
||||
self.remote_path = path
|
||||
self._marked_remote.clear()
|
||||
self._refresh_remote()
|
||||
self._update_info()
|
||||
|
||||
@@ -403,54 +701,131 @@ class BrowserScreen(Screen):
|
||||
|
||||
def action_up_level(self) -> None:
|
||||
if self.app.focused is self.table_local:
|
||||
self.local_path = _parent(self.local_path, True)
|
||||
self._refresh_local()
|
||||
self._goto("local", _parent(self.local_path, True))
|
||||
else:
|
||||
self.remote_path = _parent(self.remote_path, False)
|
||||
self._refresh_remote()
|
||||
self._update_info()
|
||||
self._goto("remote", _parent(self.remote_path, False))
|
||||
|
||||
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:
|
||||
sel = self._selected_entry(self.table_remote)
|
||||
if not sel:
|
||||
targets = self._targets(self.table_remote)
|
||||
if not targets:
|
||||
return
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
if sel.endswith("/"):
|
||||
self._transfer_dir(self.remote_path, name, self.local_path, download=True)
|
||||
else:
|
||||
target_local = _join(self.local_path, name, True)
|
||||
done, failed = 0, 0
|
||||
for sel in targets:
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
if sel.endswith("/"):
|
||||
if self._transfer_dir(self.remote_path, name, self.local_path, download=True):
|
||||
done += 1
|
||||
else:
|
||||
failed += 1
|
||||
continue
|
||||
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
|
||||
self.query_one("#statusbar", Static).update(f"[red]Download: {exc}[/]")
|
||||
return
|
||||
failed += 1
|
||||
self._notify(f"[red]Download {name}: {exc}[/]")
|
||||
self._marked_remote.clear()
|
||||
self._refresh_remote()
|
||||
self._refresh_local()
|
||||
self._report("Heruntergeladen", done, failed)
|
||||
|
||||
def action_upload(self) -> None:
|
||||
sel = self._selected_entry(self.table_local)
|
||||
if not sel:
|
||||
targets = self._targets(self.table_local)
|
||||
if not targets:
|
||||
return
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
if sel.endswith("/"):
|
||||
self._transfer_dir(self.local_path, name, self.remote_path, download=False)
|
||||
else:
|
||||
target_remote = _join(self.remote_path, name, False)
|
||||
done, failed = 0, 0
|
||||
for sel in targets:
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
if sel.endswith("/"):
|
||||
if self._transfer_dir(self.local_path, name, self.remote_path, download=False):
|
||||
done += 1
|
||||
else:
|
||||
failed += 1
|
||||
continue
|
||||
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
|
||||
self.query_one("#statusbar", Static).update(f"[red]Upload: {exc}[/]")
|
||||
return
|
||||
failed += 1
|
||||
self._notify(f"[red]Upload {name}: {exc}[/]")
|
||||
self._marked_local.clear()
|
||||
self._refresh_local()
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
for e in entries:
|
||||
if e.kind == "dir":
|
||||
@@ -460,7 +835,8 @@ class BrowserScreen(Screen):
|
||||
else:
|
||||
self.backend.download(_join(src, e.name, False), _join(dst, e.name, True))
|
||||
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):
|
||||
if item.is_dir():
|
||||
self._copy_tree(
|
||||
@@ -469,7 +845,9 @@ 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("#statusbar", Static).update(f"[red]{exc}[/]")
|
||||
self._notify(f"[red]{exc}[/]")
|
||||
return False
|
||||
return True
|
||||
|
||||
def action_mkdir(self) -> None:
|
||||
table = self._focused_table()
|
||||
@@ -482,32 +860,39 @@ 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("#statusbar", Static).update(f"[red]{exc}[/]")
|
||||
self._notify(f"[red]{exc}[/]")
|
||||
|
||||
def action_delete(self) -> None:
|
||||
table = self._focused_table()
|
||||
sel = self._selected_entry(table)
|
||||
if not sel:
|
||||
targets = self._targets(table)
|
||||
if not targets:
|
||||
return
|
||||
remote = table is not self.table_local
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
try:
|
||||
if remote:
|
||||
if sel.endswith("/"):
|
||||
self.backend.rmdir(_join(self.remote_path, name, False))
|
||||
done, failed = 0, 0
|
||||
for sel in targets:
|
||||
name = sel[:-1] if sel.endswith("/") else sel
|
||||
try:
|
||||
if remote:
|
||||
if sel.endswith("/"):
|
||||
self.backend.rmdir(_join(self.remote_path, name, False))
|
||||
else:
|
||||
self.backend.remove(_join(self.remote_path, name, False))
|
||||
else:
|
||||
self.backend.remove(_join(self.remote_path, name, False))
|
||||
self._refresh_remote()
|
||||
else:
|
||||
p = _join(self.local_path, name, True)
|
||||
if os.path.isdir(p):
|
||||
import shutil
|
||||
shutil.rmtree(p)
|
||||
else:
|
||||
os.remove(p)
|
||||
self._refresh_local()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]")
|
||||
p = _join(self.local_path, name, True)
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p)
|
||||
else:
|
||||
os.remove(p)
|
||||
done += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
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:
|
||||
table = self._focused_table()
|
||||
@@ -536,7 +921,7 @@ class BrowserScreen(Screen):
|
||||
)
|
||||
self._refresh_local()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.query_one("#statusbar", Static).update(f"[red]{exc}[/]")
|
||||
self._notify(f"[red]{exc}[/]")
|
||||
|
||||
|
||||
class RenameScreen(Screen):
|
||||
@@ -576,6 +961,11 @@ class FTpTui(App):
|
||||
color: $text;
|
||||
}
|
||||
|
||||
/* Verbindungsdialog bei kleinen Terminals scrollbar halten */
|
||||
ConnectionScreen {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
Header {
|
||||
background: #101010;
|
||||
color: #e0e0e0;
|
||||
@@ -667,6 +1057,9 @@ class FTpTui(App):
|
||||
height: 1fr;
|
||||
background: #0c0c0c;
|
||||
color: #d7d7d7;
|
||||
/* Spaltenbreiten werden passend berechnet (_layout_columns);
|
||||
ein horizontaler Scrollbalken ist daher nie erwuenscht. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
DataTable > .datatable--header {
|
||||
@@ -682,19 +1075,46 @@ class FTpTui(App):
|
||||
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 {
|
||||
background: #000000;
|
||||
color: #00ff87;
|
||||
padding: 0 2;
|
||||
text-style: bold;
|
||||
height: 1;
|
||||
height: auto;
|
||||
max-height: 4;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.statusbar {
|
||||
background: #101010;
|
||||
color: #8fa1b3;
|
||||
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
@@ -29,7 +29,21 @@ class SFTPBackend:
|
||||
timeout=30,
|
||||
)
|
||||
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]:
|
||||
assert self._sftp is not None
|
||||
@@ -45,22 +59,23 @@ class SFTPBackend:
|
||||
modified=attr.st_mtime,
|
||||
)
|
||||
)
|
||||
self._cwd = path
|
||||
self._cwd = self._resolve(path)
|
||||
return entries
|
||||
|
||||
def chdir(self, path: str) -> None:
|
||||
assert self._sftp is not None
|
||||
self._sftp.chdir(path)
|
||||
self._cwd = self._sftp.getcwd() or path
|
||||
self._cwd = self._resolve(".")
|
||||
|
||||
def pwd(self) -> str:
|
||||
return self._cwd
|
||||
|
||||
def _abs(self, path: str) -> str:
|
||||
"""Baut einen absolut wertigen Pfad relativ zum SFTP-Root."""
|
||||
if path.startswith("/"):
|
||||
return path or "/"
|
||||
return "/" + path
|
||||
"""Baut einen absoluten Pfad – relative Pfade gelten zum aktuellen CWD."""
|
||||
if not path:
|
||||
return self._cwd or "/"
|
||||
base = self._cwd if self._cwd.startswith("/") else "/"
|
||||
return str(PurePosixPath(base) / path)
|
||||
|
||||
def download(self, remote: str, local: str) -> None:
|
||||
assert self._sftp is not None
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ftptui"
|
||||
version = "0.3.1"
|
||||
version = "0.4.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