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.
This commit is contained in:
@@ -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
@@ -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"
|
||||||
|
|||||||
+480
-62
@@ -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,51 +701,127 @@ 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:
|
||||||
# dst ist ein *lokaler* Pfad -> lokal anlegen, nicht auf dem Server
|
# dst ist ein *lokaler* Pfad -> lokal anlegen, nicht auf dem Server
|
||||||
@@ -471,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()
|
||||||
@@ -484,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:
|
||||||
@@ -499,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()
|
||||||
@@ -538,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):
|
||||||
@@ -578,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;
|
||||||
@@ -669,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 {
|
||||||
@@ -684,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;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user