Rust-Werkstatt
Werkstatt · Fährmann · Etappe 12 von 13

Fortschritt und Fehlermeldungen

Lädst du ein Urlaubsvideo mit einem Gigabyte hoch, steht bisher eine Minute lang nur „Lade … hoch“ da. Läuft es noch? Wie lange dauert es? Heute bekommt der Fährmann einen Fortschrittsbalken mit Prozentzahl und Tempo, der sich mehrmals pro Sekunde aktualisiert. Und wenn etwas schiefgeht, übersetzt er die technischen Fehlermeldungen in verständliche Sätze und hebt sie rot hervor.

3 Schritte · ca. 50 Minuten · baut auf Etappe 11 auf

Der Fährmann während einer Übertragung, unten ein Fortschrittsbalken mit Prozentzahl und Tempo
So sieht der Fährmann am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

Wir machen da weiter, wo Etappe 11 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand.

Für einen Fortschrittsbalken braucht man zwei Zahlen: wie viel schon übertragen ist und wie viel es insgesamt wird. Die erste muss die Übertragung im Hintergrund laufend mitzählen, während view sie gleichzeitig liest. Zwei Seiten, die gleichzeitig dieselbe Zahl anfassen: Das ist ein Fall für einen ganz besonderen Zahlentyp, den atomaren Zähler. Und damit der Balken sich bewegt, obwohl niemand eine Taste drückt, abonnieren wir zusätzlich eine Uhr.

Schritt 1

Jedes Byte mitzählen

Bisher kopiert tokio::io::copy eine Datei in einem Rutsch, und wir erfahren erst am Ende, wie viel es war. Für einen Fortschrittsbalken brauchen wir eine eigene Kopierschleife, die nach jedem Häppchen Bescheid sagt. Zuerst die Werkzeuge:

src/main.rsganz oben, bei Standardbibliothek und tokio
use std::pin::Pin;use std::sync::Arc;use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};    ⋮use tokio::io::AsyncWriteExt;use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};

AtomicU64 ist eine Zahl, die mehrere Aufgaben gleichzeitig anfassen dürfen, ohne sich in die Quere zu kommen und ohne Mutex. „Atomar“ heißt: Jede Änderung passiert in einem einzigen, unteilbaren Schritt. Niemand kann eine halb geänderte Zahl lesen. Für einen einfachen Zähler ist das genau richtig, und viel schneller als ein Mikrofon, das man herumreichen muss. Die Bilanz bekommt so einen Zähler, in einem Arc, damit auch view ihn lesen kann:

src/main.rsdas struct Bilanz
    ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließen    zaehler: Arc<AtomicU64>, // übertragene Bytes, laufend mitgezählt}

Jetzt die Kopierschleife, hinter als_text:

src/main.rsdirekt hinter der Funktion als_text
fn als_text<E: fmt::Display>(fehler: E) -> String {
    fehler.to_string()
}// Kopiert von einer Quelle in ein Ziel und zählt dabei jedes Byte mit.
async fn zaehlend_kopieren<Q, Z>(quelle: &mut Q, ziel: &mut Z, zaehler: &AtomicU64) -> Result<(), String>
where
    Q: AsyncRead + Unpin,
    Z: AsyncWrite + Unpin,
{
    let mut puffer = vec![0u8; 64 * 1024];
    loop {
        let gelesen = quelle.read(&mut puffer).await.map_err(als_text)?;
        if gelesen == 0 {
            return Ok(()); // Quelle leer: fertig
        }
        ziel.write_all(&puffer[..gelesen]).await.map_err(als_text)?;
        zaehler.fetch_add(gelesen as u64, Ordering::Relaxed);
    }
}
  • Die Funktion ist generisch über Quelle Q und Ziel Z. Das where darunter sagt, was sie können müssen: Aus der Quelle muss man asynchron lesen können (AsyncRead), ins Ziel schreiben (AsyncWrite). So funktioniert dieselbe Schleife für lokale Dateien, FTP-Datenströme und SFTP-Dateien. Unpin ist eine technische Bedingung für das Lesen und Schreiben, die all diese Typen erfüllen.
  • Der Puffer ist ein Eimer mit 64 Kilobyte. read füllt ihn so weit es geht und sagt, wie viel hineingekommen ist. Sind es 0 Bytes, ist die Quelle leer.
  • write_all leert genau diese Bytes ins Ziel. &puffer[..gelesen] ist ein Slice: der Anfang des Eimers bis zur gelesenen Menge.
  • fetch_add zählt atomar hoch. Ordering::Relaxed heißt: Es reicht, dass die Zahl stimmt, die genaue Reihenfolge gegenüber anderen Speicherzugriffen ist uns egal. Für einen Fortschrittszähler ist das immer die richtige Wahl.

Die Leitung benutzt jetzt diese Schleife statt tokio::io::copy. Bei FTP brauchen wir dafür put_with_stream statt put_file, denn das liefert einen Datenstrom, in den wir selbst schreiben können:

src/main.rsin impl Strom, datei_hoch und datei_runter
    async fn datei_hoch(&mut self, von: &Path, nach: &str) -> Result<(), String> {    async fn datei_hoch(
        &mut self,
        von: &Path,
        nach: &str,
        zaehler: &AtomicU64,
    ) -> Result<(), String> {        let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;    ⋮            Strom::Ftp(ftp) => {                ftp.put_file(nach, &mut datei).await.map_err(als_text)?;                let mut ziel = ftp.put_with_stream(nach).await.map_err(als_text)?;
                zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
                ziel.finish().await.map_err(als_text)?;            }    ⋮                let mut ziel = sftp.create(nach).await.map_err(als_text)?;                tokio::io::copy(&mut datei, &mut ziel).await.map_err(als_text)?;                zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;                ziel.shutdown().await.map_err(als_text)?;    ⋮    async fn datei_runter(&mut self, von: &str, nach: &Path) -> Result<(), String> {    async fn datei_runter(
        &mut self,
        von: &str,
        nach: &Path,
        zaehler: &AtomicU64,
    ) -> Result<(), String> {        let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;    ⋮                let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;                tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;                zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;                quelle.finish().await.map_err(als_text)?;    ⋮                let mut quelle = sftp.open(von).await.map_err(als_text)?;                tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;                zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;            }

hoch und runter geben den Zähler aus der Bilanz weiter:

src/main.rsin hoch und runter
            strom.datei_hoch(von, nach).await?;            strom.datei_hoch(von, nach, &bilanz.zaehler).await?;    ⋮            strom.datei_runter(von, nach).await?;            strom.datei_runter(von, nach, &bilanz.zaehler).await?;

Und die beiden Hintergrundaufgaben bekommen den Zähler von außen, damit der Fährmann ihn behalten und anschauen kann. ..Bilanz::default() heißt wieder: „Alle anderen Felder wie üblich“, also 0 Dateien und 0 Fehlschläge:

src/main.rsin hochladen und herunterladen
async fn hochladen(ftp: Server, von: PathBuf, nach: PathBuf, name: String) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz::default();async fn hochladen(
    ftp: Server,
    von: PathBuf,
    nach: PathBuf,
    name: String,
    zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz {
        zaehler,
        ..Bilanz::default()
    };    ⋮    rechte: u32,
    name: String,) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz::default();    zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz {
        zaehler,
        ..Bilanz::default()
    };

Vorerst legt kopieren einfach einen neuen Zähler an. Angezeigt wird er im nächsten Schritt:

src/main.rsin der Funktion kopieren
            return Task::perform(hochladen(ftp, von, nach, name), Message::Fertig);            let zaehler = Arc::new(AtomicU64::new(0));
            return Task::perform(hochladen(ftp, von, nach, name, zaehler), Message::Fertig);    ⋮            let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name);            let zaehler = Arc::new(AtomicU64::new(0));
            let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name, zaehler);
Probelauf Speichern, dann im Terminal:
cargo run
Übertragungen funktionieren wie vorher, auch wenn sie jetzt Byte für Byte mitgezählt werden. Sehen kann man davon noch nichts.
Schritt 2

Ein Balken, der sich bewegt

Jetzt soll der Fährmann während einer Übertragung den Fortschritt anzeigen. Dafür brauchen wir die Uhr von iced:

src/main.rsganz oben, die iced-Zeilen
use iced::keyboard::{self, Key, key::Named};use iced::time::{self, Duration, Instant};use iced::{

Was zu einer laufenden Übertragung gehört, bündeln wir in einem struct, direkt vor Faehrmann:

src/main.rsdirekt vor dem struct Faehrmann
// Eine Übertragung, die gerade läuft.
struct Laufend {
    titel: String,           // zum Beispiel "Lade „Fotos“ hoch"
    zaehler: Arc<AtomicU64>, // bisher übertragene Bytes
    gesamt: Option<u64>,     // so viele werden es insgesamt, falls bekannt
    start: Instant,          // wann es losging, für das Tempo
}// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.

gesamt ist eine Option, weil wir die Gesamtgröße nicht immer kennen. Beim Hochladen zählen wir vorher nach, wie groß Datei oder Ordner sind. Beim Herunterladen einer Datei steht die Größe in der Serverliste. Bei einem ganzen Ordner vom Server wüssten wir es nur, wenn wir vorher jeden Unterordner einzeln abfragen, und das kostet Zeit. Dann zeigen wir statt Prozent einfach, wie viel schon da ist. Der Fährmann merkt sich die laufende Übertragung:

src/main.rsim struct Faehrmann und in neu
    verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt    laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft}    ⋮            verbindungsdialog: None,            laufend: None,        }

Eine laufende Übertragung schickt keine Briefe, und ohne Brief zeichnet iced nicht neu. Also abonnieren wir eine Uhr: time::every schickt in regelmäßigen Abständen ein Ereignis. Mit .map machen wir daraus einen Tick-Brief, und Subscription::batch hört gleichzeitig auf Tastatur und Uhr. Das Schöne: Weil subscription den Zustand anschaut, läuft die Uhr nur, solange übertragen wird.

src/main.rsdas enum Message
    ProtokollWechseln,    Tick,
src/main.rsdie Funktion subscription
    fn subscription(&self) -> Subscription<Message> {        keyboard::listen().filter_map(taste)        let tasten = keyboard::listen().filter_map(taste);
        if self.laufend.is_some() {
            // Während einer Übertragung fünfmal pro Sekunde neu zeichnen
            let uhr = time::every(Duration::from_millis(200)).map(|_| Message::Tick);
            Subscription::batch([tasten, uhr])
        } else {
            tasten
        }    }

Der Tick selbst muss nichts tun. Allein dass er ankommt, sorgt dafür, dass view neu zeichnet:

src/main.rsin der Funktion update
            Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt            Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt
            Message::Tick => {} // nur neu zeichnen

Beim Start einer Übertragung legt kopieren statt der alten Meldung ein Laufend an. Den Klon des Zählers behalten wir, das Original wandert in die Hintergrundaufgabe. Die Größe einer Datei vom Server schreiben wir vorher mit heraus:

src/main.rsin der Funktion kopieren
        let ist_ordner = eintrag.ist_ordner;        let groesse = eintrag.groesse;    ⋮        if let Some(ftp) = server_ziel {            self.meldung = format!("Lade „{}“ hoch …", name);
            let zaehler = Arc::new(AtomicU64::new(0));            let zaehler = Arc::new(AtomicU64::new(0));
            self.laufend = Some(Laufend {
                titel: format!("Lade „{}“ hoch", name),
                zaehler: zaehler.clone(),
                gesamt: Some(lokale_groesse(&von)),
                start: Instant::now(),
            });    ⋮        if let Some(ftp) = server_quelle {            self.meldung = format!("Lade „{}“ herunter …", name);
            let zaehler = Arc::new(AtomicU64::new(0));            let zaehler = Arc::new(AtomicU64::new(0));
            self.laufend = Some(Laufend {
                titel: format!("Lade „{}“ herunter", name),
                zaehler: zaehler.clone(),
                gesamt: if ist_ordner { None } else { Some(groesse) },
                start: Instant::now(),
            });

Ist die Übertragung fertig, verschwindet der Balken wieder:

src/main.rsam Anfang der Funktion fertig
    fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {        self.laufend = None;

Die Meldungszeile zeigt während einer Übertragung den Fortschritt statt der Meldung:

src/main.rsin der Funktion view, die Meldungszeile
        let meldungszeile = container(text(self.meldung.as_str()).color(WEISS))        let meldung = match &self.laufend {
            Some(laufend) => fortschritt_text(laufend),
            None => self.meldung.clone(),
        };
        let meldungszeile = container(text(meldung).color(WEISS))            .width(Fill)

Und die zwei Hilfsfunktionen dafür, hinter groesse_text:

src/main.rshinter der Funktion groesse_text
    text.replace('.', ",")
}// Baut die Fortschrittsanzeige, zum Beispiel: Lade „Fotos“ hoch [██████░░░░]  60 %  3,1 MB/s
fn fortschritt_text(laufend: &Laufend) -> String {
    let bytes = laufend.zaehler.load(Ordering::Relaxed);
    let sekunden = laufend.start.elapsed().as_secs_f64().max(0.1);
    let tempo = groesse_text((bytes as f64 / sekunden) as u64);

    match laufend.gesamt {
        Some(gesamt) if gesamt > 0 => {
            let anteil = (bytes as f64 / gesamt as f64).min(1.0);
            let voll = (anteil * 20.0) as usize;
            let balken = "█".repeat(voll) + &"░".repeat(20 - voll);
            format!("{} [{}] {:3.0} %  {}/s", laufend.titel, balken, anteil * 100.0, tempo)
        }
        _ => format!("{} … bisher {}, {}/s", laufend.titel, groesse_text(bytes), tempo),
    }
}

// Wie viele Bytes stecken in einer Datei oder in einem ganzen Ordner?
fn lokale_groesse(pfad: &Path) -> u64 {
    let Ok(info) = fs::symlink_metadata(pfad) else {
        return 0;
    };
    if !info.is_dir() {
        return info.len();
    }
    let Ok(inhalt) = fs::read_dir(pfad) else {
        return 0;
    };
    inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum()
}
  • fortschritt_text liest den Zähler mit load, rechnet das Tempo aus Bytes durch Sekunden aus und baut einen Balken aus 20 Zeichen: █ für erledigt, ░ für offen. Der Zweig Some(gesamt) if gesamt > 0 hat wieder einen Match-Guard, damit wir nie durch 0 teilen. .max(0.1) sorgt dafür, dass auch in den ersten Millisekunden nicht durch 0 geteilt wird.
  • lokale_groesse ist eine kleine Rekursion wie in Etappe 6: Datei? Größe liefern. Ordner? Größen aller Einträge zusammenzählen, mit .sum().
Probelauf Speichern, dann im Terminal:
cargo run
Leg dir für einen schönen Test eine große Datei an, unter Linux etwa mit head -c 500M /dev/urandom > gross.bin, verbinde dich und lade sie mit F5 hoch. Unten läuft der Balken von links nach rechts, mit Prozent und Tempo. Auf dem eigenen Rechner geht es sehr schnell. Gegen einen Server im Internet hast du Zeit zum Zuschauen.
Fortschrittsbalken in der Meldungszeile
Stand nach Schritt 2: der Balken bei der Arbeit
Schritt 3

Fehler, die man versteht

Wenn etwas schiefgeht, meldet der Fährmann bisher, was die Crates ihm sagen, etwa Connection error: Connection refused (os error 111). Ein Programmierer weiß, was gemeint ist. Alle anderen nicht. Wir bauen deshalb einen kleinen Übersetzer und heben Fehler rot hervor, wie es der Commander mit seinen roten Fehlerfenstern tat. Zuerst die Farbe:

src/main.rsbei den Farben
const GRAU: Color = Color::from_rgb8(170, 170, 170);const DUNKELROT: Color = Color::from_rgb8(170, 0, 0);

Der Fährmann merkt sich, ob die aktuelle Meldung ein Fehler ist:

src/main.rsim struct Faehrmann und in neu
    laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft    fehler: bool, // ist die Meldung ein Fehler? Dann rot}    ⋮            laufend: None,            fehler: false,        }

Rot soll die Zeile bleiben, bis der Benutzer etwas tut. Also nimmt jede Nachricht außer dem Uhr-Tick den Fehler wieder zurück. matches! ist ein Makro, das fragt: „Passt der Wert zu diesem Muster?“ und true oder false liefert:

src/main.rsin update, direkt hinter der Antwort-Weiche
            Message::Fertig(ergebnis) => return self.fertig(ergebnis),
            andere => andere,
        };        // Jede Taste nimmt die rote Fehlermeldung wieder zurück
        if !matches!(message, Message::Tick) {
            self.fehler = false;
        }

Eine kleine Hilfsfunktion setzt Meldung und Fehler auf einmal, vor fertig:

src/main.rsin impl Faehrmann, direkt vor der Funktion fertig
    // Zeigt eine Fehlermeldung, rot hinterlegt bis zur nächsten Taste.
    fn fehler_melden(&mut self, text: String) {
        self.meldung = text;
        self.fehler = true;
    }    // Eine Übertragung oder Änderung auf dem Server ist fertig.

Die drei Stellen, an denen Fehler vom Server ankommen, benutzen sie und schicken den Text vorher durch den Übersetzer:

src/main.rsin verbunden, gelesen und fertig
            Err(fehler) => self.meldung = format!("Verbindung fehlgeschlagen: {}", fehler),            Err(fehler) => {
                self.fehler_melden(format!("Verbindung fehlgeschlagen: {}", freundlich(&fehler)))
            }    ⋮            Err(fehler) => self.meldung = format!("Der Server sagt: {}", fehler),            Err(fehler) => self.fehler_melden(format!("Der Server sagt: {}", freundlich(&fehler))),    ⋮        self.laufend = None;        self.meldung = match ergebnis {
            Ok(text) => text,
            Err(fehler) => fehler,
        };        match ergebnis {
            Ok(text) => self.meldung = text,
            Err(fehler) => self.fehler_melden(freundlich(&fehler)),
        }

In view hängt die Farbe der Meldungszeile jetzt vom Zustand ab. Weil die Closure dafür hintergrund braucht, bekommt sie ein move. Außerdem darf eine lange Fehlermeldung nicht mehr in eine zweite Zeile umbrechen, sonst schiebt sie die ganze Oberfläche durcheinander. Was nicht passt, wird am rechten Rand abgeschnitten, und das Wichtigste steht ja vorne:

src/main.rsin view, beim Stil der Meldungszeile
        let meldungszeile = container(text(meldung).color(WEISS))        let meldungszeile = container(text(meldung).color(WEISS).wrapping(text::Wrapping::None))            .width(Fill)
            .padding([0, 6])    ⋮            .width(Fill)
            .padding([0, 6])            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(SCHWARZ)),            .style(move |_theme: &Theme| container::Style {
                background: Some(Background::Color(hintergrund)),                ..container::Style::default()    ⋮            None => self.meldung.clone(),
        };        let hintergrund = if self.fehler { DUNKELROT } else { SCHWARZ };

Und der Übersetzer selbst, hinter lokale_groesse:

src/main.rshinter der Funktion lokale_groesse
    inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum()
}// Übersetzt technische Fehlermeldungen in verständliche Sätze. Das Original steht in Klammern dahinter.
fn freundlich(fehler: &str) -> String {
    let klein = fehler.to_lowercase();
    let erklaerung = if klein.contains("connection refused") {
        "Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?"
    } else if klein.contains("lookup") || klein.contains("not known") || klein.contains("no such host") {
        "Einen Server mit diesem Namen gibt es nicht. Ist die Adresse richtig geschrieben?"
    } else if klein.contains("connection reset") || klein.contains("broken pipe") {
        "Die Verbindung ist mittendrin abgerissen. Ist der Server noch da, oder ist sein Speicher voll?"
    } else if klein.contains("timed out") {
        "Der Server antwortet nicht. Vielleicht blockiert eine Firewall die Verbindung."
    } else if klein.contains("530") {
        "Benutzername oder Passwort stimmt nicht."
    } else if klein.contains("permission denied") || klein.contains("550") || klein.contains("privileges") {
        "Dafür fehlen die Rechte, oder die Datei gibt es nicht."
    } else if klein.contains("no such file") {
        "Die Datei oder der Ordner existiert nicht (mehr)."
    } else if klein.contains("no space") {
        "Der Speicherplatz ist voll."
    } else {
        return fehler.to_string(); // unbekannt: lieber das Original als eine falsche Erklärung
    };
    format!("{} ({})", erklaerung, fehler)
}

Er sucht mit contains nach typischen Stichwörtern, nachdem alles in Kleinbuchstaben verwandelt wurde. Die Zahlen 530 und 550 sind FTP-Antwortcodes: 530 heißt „nicht angemeldet“, 550 „Aktion nicht möglich“. Wichtig ist der letzte Zweig: Kennt der Übersetzer einen Fehler nicht, gibt er lieber das Original zurück als eine Erklärung, die vielleicht falsch ist. Und auch bei bekannten Fehlern hängt das Original in Klammern dran, denn wer Hilfe sucht, braucht oft genau diesen Wortlaut.

Probelauf Speichern, dann im Terminal:
cargo run
Beende deinen Testserver und versuch, dich zu verbinden. Die Meldungszeile wird rot und sagt: Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port? Drück eine beliebige Taste, und die Zeile wird wieder schwarz. Probier auch einen falschen Servernamen wie gibtsnicht.invalid oder ein falsches Passwort.
Rote Fehlermeldung
Stand nach Schritt 3: ein verständlicher Fehler, rot hinterlegt
Zwischenstand

Der komplette Code nach Etappe 12

Hier ist der vollständige Stand nach Etappe 12. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.

Cargo.toml und src/main.rs anzeigen

Cargo.toml

[package]
name = "faehrmann"
version = "0.1.0"
edition = "2024"

[dependencies]
iced = { version = "0.14", features = ["tokio"] }
suppaftp = { version = "12", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "3"

src/main.rs

use std::fmt;
use std::fs;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

use russh::keys::{HashAlg, PublicKeyOrCertificate};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::FileAttributes;
use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::Mutex;

use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::time::{self, Duration, Instant};
use iced::{
    Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};

// Die Farben des Norton Commander
const BLAU: Color = Color::from_rgb8(0, 0, 170);
const TUERKIS: Color = Color::from_rgb8(0, 170, 170);
const SCHWARZ: Color = Color::BLACK;
const WEISS: Color = Color::WHITE;
const HELLTUERKIS: Color = Color::from_rgb8(85, 255, 255);
const GELB: Color = Color::from_rgb8(255, 255, 85);
const GRAU: Color = Color::from_rgb8(170, 170, 170);
const DUNKELROT: Color = Color::from_rgb8(170, 0, 0);

// So viele Einträge passen untereinander in ein Panel
const ZEILEN: usize = 24;

// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.
#[derive(Debug, Clone)]
struct Eintrag {
    name: String,
    ist_ordner: bool,
    groesse: u64,
    rechte: u32, // Zugriffsrechte, zum Beispiel 0o644
}

fn main() -> iced::Result {
    iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)
        .subscription(Faehrmann::subscription)
        .title("Fährmann")
        .default_font(Font::MONOSPACE)
        .window_size((1000.0, 640.0))
        .run()
}

// Eine Übertragung, die gerade läuft.
struct Laufend {
    titel: String,           // zum Beispiel "Lade „Fotos“ hoch"
    zaehler: Arc<AtomicU64>, // bisher übertragene Bytes
    gesamt: Option<u64>,     // so viele werden es insgesamt, falls bekannt
    start: Instant,          // wann es losging, für das Tempo
}

// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
struct Faehrmann {
    links: Panel,
    rechts: Panel,
    aktiv: Seite,
    meldung: String, // was der Fährmann zuletzt zu sagen hatte
    dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt
    verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt
    laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft
    fehler: bool, // ist die Meldung ein Fehler? Dann rot
}

// Welches der beiden Panels ist gerade aktiv?
#[derive(PartialEq)]
enum Seite {
    Links,
    Rechts,
}

// Der Dialog zum Ändern der Rechte: für welche Datei, welche Rechte, welcher Schalter gewählt ist.
struct RechteDialog {
    name: String,
    pfad: PathBuf,
    rechte: u32,
    auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
    server: Option<Server>, // Some, wenn die Datei auf dem Server liegt
}

// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
    sftp: bool, // true: SFTP, false: FTP
    host: String,
    port: String,
    benutzer: String,
    passwort: String,
}

// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
    Host,
    Port,
    Benutzer,
    Passwort,
}

// Eine offene Verbindung zu einem Server. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Server {
    strom: Arc<Mutex<Strom>>,
    name: String, // zum Beispiel "fahrgast@localhost"
    fingerabdruck: Option<String>, // nur bei SFTP
}

// Nachrichten müssen sich mit {:?} ausgeben lassen. Die Verbindung selbst kann das
// nicht, also schreiben wir es für Server von Hand.
impl fmt::Debug for Server {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Server({})", self.name)
    }
}

#[derive(Debug, Clone)]
enum Message {
    Hoch,
    Runter,
    Oeffnen,
    Wechseln,
    Kopieren,
    RechteZeigen,
    PfeilLinks,
    PfeilRechts,
    Umschalten,
    Abbrechen,
    VerbindenZeigen,
    Eingabe(Feld, String),
    Verbinden,
    ProtokollWechseln,
    Tick,
    Verbunden(Result<(Server, PathBuf, Vec<Eintrag>), String>),
    Gelesen(Result<(PathBuf, Vec<Eintrag>), String>),
    Fertig(Result<String, String>),
    Beenden,
}

impl Faehrmann {
    // Startet im Ordner, aus dem das Programm aufgerufen wurde.
    fn neu() -> Self {
        let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
        Faehrmann {
            links: Panel::neu(ordner.clone()),
            rechts: Panel::neu(ordner),
            aktiv: Seite::Links,
            meldung: String::from("Willkommen an Bord! Tab wechselt das Panel, F5 kopiert, F10 beendet."),
            dialog: None,
            verbindungsdialog: None,
            laufend: None,
            fehler: false,
        }
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        // Antworten vom Server kommen immer an, egal ob gerade ein Dialog offen ist
        let message = match message {
            Message::Verbunden(ergebnis) => return self.verbunden(ergebnis),
            Message::Gelesen(ergebnis) => return self.gelesen(ergebnis),
            Message::Fertig(ergebnis) => return self.fertig(ergebnis),
            andere => andere,
        };

        // Jede Taste nimmt die rote Fehlermeldung wieder zurück
        if !matches!(message, Message::Tick) {
            self.fehler = false;
        }

        // Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
        if let Some(zugang) = &mut self.verbindungsdialog {
            match message {
                Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
                Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
                Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
                Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
                Message::ProtokollWechseln => {
                    zugang.sftp = !zugang.sftp;
                    if zugang.sftp {
                        zugang.port = String::from("22");
                        zugang.benutzer = std::env::var("USER")
                            .or_else(|_| std::env::var("USERNAME"))
                            .unwrap_or_default();
                        zugang.passwort = String::new();
                    } else {
                        zugang.port = String::from("2121");
                    }
                }
                Message::Wechseln => return operation::focus_next(),
                Message::Verbinden => return self.verbinden(),
                Message::Abbrechen => self.verbindungsdialog = None,
                Message::Beenden => return iced::exit(),
                _ => {}
            }
            return Task::none();
        }

        // Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
        if let Some(dialog) = &mut self.dialog {
            match message {
                Message::Hoch => dialog.auswahl = (dialog.auswahl + 6) % 9,
                Message::Runter => dialog.auswahl = (dialog.auswahl + 3) % 9,
                Message::PfeilLinks => dialog.auswahl = (dialog.auswahl + 8) % 9,
                Message::PfeilRechts => dialog.auswahl = (dialog.auswahl + 1) % 9,
                Message::Umschalten => dialog.rechte ^= bit(dialog.auswahl),
                Message::Oeffnen => return self.rechte_anwenden(),
                Message::Abbrechen => self.dialog = None,
                Message::Beenden => return iced::exit(),
                _ => {}
            }
            return Task::none();
        }

        match message {
            Message::Hoch => self.aktives_panel().hoch(),
            Message::Runter => self.aktives_panel().runter(),
            Message::Oeffnen => return self.oeffnen(),
            Message::Wechseln => {
                self.aktiv = match self.aktiv {
                    Seite::Links => Seite::Rechts,
                    Seite::Rechts => Seite::Links,
                };
            }
            Message::Kopieren => return self.kopieren(),
            Message::RechteZeigen => self.rechte_zeigen(),
            Message::VerbindenZeigen => return self.verbindung_zeigen(),
            Message::PfeilLinks
            | Message::PfeilRechts
            | Message::Umschalten
            | Message::Abbrechen
            | Message::Eingabe(..)
            | Message::Verbinden
            | Message::ProtokollWechseln => {} // ohne Dialog ohne Bedeutung
            Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt
            Message::Tick => {} // nur neu zeichnen
            Message::Beenden => return iced::exit(),
        }
        Task::none()
    }

    // Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
    fn subscription(&self) -> Subscription<Message> {
        let tasten = keyboard::listen().filter_map(taste);
        if self.laufend.is_some() {
            // Während einer Übertragung fünfmal pro Sekunde neu zeichnen
            let uhr = time::every(Duration::from_millis(200)).map(|_| Message::Tick);
            Subscription::batch([tasten, uhr])
        } else {
            tasten
        }
    }

    // Liefert das Panel, das gerade aktiv ist, zum Verändern.
    fn aktives_panel(&mut self) -> &mut Panel {
        match self.aktiv {
            Seite::Links => &mut self.links,
            Seite::Rechts => &mut self.rechts,
        }
    }

    // Liefert das Panel, das gerade NICHT aktiv ist, zum Verändern.
    fn inaktives_panel(&mut self) -> &mut Panel {
        match self.aktiv {
            Seite::Links => &mut self.rechts,
            Seite::Rechts => &mut self.links,
        }
    }

    // Liefert beide Panels zum Anschauen: zuerst das aktive, dann das andere.
    fn quelle_und_ziel(&self) -> (&Panel, &Panel) {
        match self.aktiv {
            Seite::Links => (&self.links, &self.rechts),
            Seite::Rechts => (&self.rechts, &self.links),
        }
    }

    // Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
    fn verbindung_zeigen(&mut self) -> Task<Message> {
        // Zeigt rechts schon ein Server, trennt F2 die Verbindung wieder
        if let Some(ftp) = &self.rechts.server {
            self.meldung = format!("Verbindung zu {} getrennt.", ftp.name);
            let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
            self.rechts = Panel::neu(ordner);
            return Task::none();
        }

        self.verbindungsdialog = Some(Zugang {
            sftp: false,
            host: String::from("localhost"),
            port: String::from("2121"),
            benutzer: String::from("fahrgast"),
            passwort: String::from("geheim"),
        });
        operation::focus("host")
    }

    // Baut die Verbindung im Hintergrund auf.
    fn verbinden(&mut self) -> Task<Message> {
        let Some(zugang) = self.verbindungsdialog.take() else {
            return Task::none();
        };
        self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);
        if zugang.sftp {
            Task::perform(sftp_verbinden(zugang), Message::Verbunden)
        } else {
            Task::perform(ftp_verbinden(zugang), Message::Verbunden)
        }
    }

    // Die Antwort auf den Verbindungsaufbau ist da.
    fn verbunden(&mut self, ergebnis: Result<(Server, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
        match ergebnis {
            Ok((ftp, ordner, eintraege)) => {
                self.meldung = match &ftp.fingerabdruck {
                    Some(abdruck) => format!("Verbunden mit {} per SFTP. Schlüssel: {}", ftp.name, abdruck),
                    None => format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name),
                };
                self.rechts = Panel::entfernt(ftp, ordner, eintraege);
                self.aktiv = Seite::Rechts;
            }
            Err(fehler) => {
                self.fehler_melden(format!("Verbindung fehlgeschlagen: {}", freundlich(&fehler)))
            }
        }
        Task::none()
    }

    // Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
    fn gelesen(&mut self, ergebnis: Result<(PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
        match ergebnis {
            Ok((ordner, eintraege)) => self.rechts.eintraege_setzen(ordner, eintraege),
            Err(fehler) => self.fehler_melden(format!("Der Server sagt: {}", freundlich(&fehler))),
        }
        Task::none()
    }

    // Zeigt eine Fehlermeldung, rot hinterlegt bis zur nächsten Taste.
    fn fehler_melden(&mut self, text: String) {
        self.meldung = text;
        self.fehler = true;
    }

    // Eine Übertragung oder Änderung auf dem Server ist fertig.
    fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {
        self.laufend = None;
        match ergebnis {
            Ok(text) => self.meldung = text,
            Err(fehler) => self.fehler_melden(freundlich(&fehler)),
        }
        self.links.neu_lesen();
        self.rechts_neu_lesen()
    }

    // Liest das rechte Panel neu ein, vom Server im Hintergrund, falls es verbunden ist.
    fn rechts_neu_lesen(&mut self) -> Task<Message> {
        match &self.rechts.server {
            Some(ftp) => Task::perform(
                server_lesen(ftp.clone(), self.rechts.ordner.clone()),
                Message::Gelesen,
            ),
            None => {
                self.rechts.neu_lesen();
                Task::none()
            }
        }
    }

    // Enter: in den markierten Ordner gehen. Auf dem Server passiert das im Hintergrund.
    fn oeffnen(&mut self) -> Task<Message> {
        let panel = self.aktives_panel();
        match &panel.server {
            Some(ftp) => match panel.ziel_ordner() {
                Some(ziel) => Task::perform(server_lesen(ftp.clone(), ziel), Message::Gelesen),
                None => Task::none(),
            },
            None => {
                panel.oeffnen();
                Task::none()
            }
        }
    }

    // Öffnet den Rechte-Dialog für den markierten Eintrag.
    fn rechte_zeigen(&mut self) {
        let panel = self.aktives_panel();
        let Some(eintrag) = panel.markierter_eintrag() else {
            return;
        };
        if eintrag.name == ".." {
            return;
        }

        let dialog = RechteDialog {
            name: eintrag.name.clone(),
            pfad: panel.unterpfad(&eintrag.name),
            rechte: eintrag.rechte,
            auswahl: 0,
            server: panel.server.clone(),
        };
        self.dialog = Some(dialog);
    }

    // Setzt die im Dialog gewählten Rechte und schließt den Dialog.
    fn rechte_anwenden(&mut self) -> Task<Message> {
        let Some(dialog) = self.dialog.take() else {
            return Task::none();
        };

        if let Some(ftp) = dialog.server {
            self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name);
            let aufgabe = server_rechte_setzen(ftp, dialog.pfad, dialog.rechte, dialog.name);
            return Task::perform(aufgabe, Message::Fertig);
        }

        self.meldung = match rechte_setzen(&dialog.pfad, dialog.rechte) {
            Ok(()) => format!(
                "Rechte von „{}“ sind jetzt {} ({:03o}).",
                dialog.name,
                rechte_text(dialog.rechte),
                dialog.rechte
            ),
            Err(fehler) => format!("Rechte ändern fehlgeschlagen: {}", fehler),
        };

        self.links.neu_lesen();
        self.rechts.neu_lesen();
        Task::none()
    }

    // Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
    fn kopieren(&mut self) -> Task<Message> {
        let (quelle, ziel) = self.quelle_und_ziel();
        let Some(eintrag) = quelle.markierter_eintrag() else {
            return Task::none();
        };
        if eintrag.name == ".." {
            return Task::none();
        }

        // Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
        let name = eintrag.name.clone();
        let ist_ordner = eintrag.ist_ordner;
        let groesse = eintrag.groesse;
        let rechte = eintrag.rechte;
        let von = quelle.unterpfad(&name);
        let nach = ziel.unterpfad(&name);
        let zielordner = ziel.ordner.display().to_string();
        let schon_da = ziel.eintraege.iter().any(|anderer| anderer.name == name);
        let server_ziel = ziel.server.clone();
        let server_quelle = quelle.server.clone();

        if schon_da {
            self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner);
            return Task::none();
        }
        if let Some(ftp) = server_ziel {
            let zaehler = Arc::new(AtomicU64::new(0));
            self.laufend = Some(Laufend {
                titel: format!("Lade „{}“ hoch", name),
                zaehler: zaehler.clone(),
                gesamt: Some(lokale_groesse(&von)),
                start: Instant::now(),
            });
            return Task::perform(hochladen(ftp, von, nach, name, zaehler), Message::Fertig);
        }
        if let Some(ftp) = server_quelle {
            let zaehler = Arc::new(AtomicU64::new(0));
            self.laufend = Some(Laufend {
                titel: format!("Lade „{}“ herunter", name),
                zaehler: zaehler.clone(),
                gesamt: if ist_ordner { None } else { Some(groesse) },
                start: Instant::now(),
            });
            let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name, zaehler);
            return Task::perform(aufgabe, Message::Fertig);
        }

        // Beide Panels auf diesem Rechner: kopieren wie bisher

        self.meldung = if nach.exists() {
            format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner)
        } else if ist_ordner && nach.starts_with(&von) {
            format!("„{}“ kann nicht in sich selbst kopiert werden.", name)
        } else if ist_ordner {
            match ordner_kopieren(&von, &nach) {
                Ok(anzahl) => format!(
                    "Ordner kopiert: {} mit {} Dateien → {}",
                    name, anzahl, zielordner
                ),
                Err(fehler) => format!("Ordner kopieren fehlgeschlagen: {}", fehler),
            }
        } else {
            match fs::copy(&von, &nach) {
                Ok(_) => format!("Kopiert: {} → {}", name, zielordner),
                Err(fehler) => format!("Kopieren fehlgeschlagen: {}", fehler),
            }
        };

        self.inaktives_panel().neu_lesen();
        Task::none()
    }

    fn view(&self) -> Element<'_, Message> {
        let menuzeile = container(text("  Links   Datei   Befehle   Optionen   Rechts"))
            .width(Fill)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(TUERKIS)),
                text_color: Some(SCHWARZ),
                ..container::Style::default()
            });

        let panels = row![
            self.links.ansicht(self.aktiv == Seite::Links),
            self.rechts.ansicht(self.aktiv == Seite::Rechts),
        ]
        .spacing(6);

        let arbeitsflaeche = container(panels)
            .width(Fill)
            .height(Fill)
            .padding(6)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(BLAU)),
                text_color: Some(WEISS),
                ..container::Style::default()
            });

        let meldung = match &self.laufend {
            Some(laufend) => fortschritt_text(laufend),
            None => self.meldung.clone(),
        };
        let hintergrund = if self.fehler { DUNKELROT } else { SCHWARZ };
        let meldungszeile = container(text(meldung).color(WEISS).wrapping(text::Wrapping::None))
            .width(Fill)
            .padding([0, 6])
            .style(move |_theme: &Theme| container::Style {
                background: Some(Background::Color(hintergrund)),
                ..container::Style::default()
            });

        let tastenleiste = row![
            f_taste(1, "Hilfe"),
            f_taste(2, "Verbind."),
            f_taste(3, "Ansicht"),
            f_taste(4, "Bearb."),
            f_taste(5, "Kopie"),
            f_taste(6, "Versch."),
            f_taste(7, "Ordner"),
            f_taste(8, "Lösch."),
            f_taste(9, "Rechte"),
            f_taste(10, "Ende"),
        ]
        .spacing(4);

        let tastenleiste = container(tastenleiste)
            .width(Fill)
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(SCHWARZ)),
                ..container::Style::default()
            });

        let hauptteil = column![menuzeile, arbeitsflaeche, meldungszeile, tastenleiste];

        // Ist ein Dialog offen, liegt er mittig über allem anderen
        if let Some(zugang) = &self.verbindungsdialog {
            stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()
        } else if let Some(dialog) = &self.dialog {
            stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
        } else {
            hauptteil.into()
        }
    }
}

// Ein Panel: ein Ordner, seine Einträge und der Auswahlbalken.
struct Panel {
    ordner: PathBuf,
    eintraege: Vec<Eintrag>,
    cursor: usize,      // Nummer des markierten Eintrags
    erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
    server: Option<Server>,   // Some, wenn das Panel einen Server zeigt
}

impl Panel {
    // Legt ein Panel an, das den angegebenen Ordner zeigt.
    fn neu(ordner: PathBuf) -> Self {
        let eintraege = ordner_lesen(&ordner);
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
            server: None,
        }
    }

    // Legt ein Panel an, das einen Ordner auf dem Server zeigt.
    fn entfernt(ftp: Server, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
            server: Some(ftp),
        }
    }

    // Balken eine Zeile nach oben
    fn hoch(&mut self) {
        if self.cursor > 0 {
            self.cursor -= 1;
        }
        self.sichtbar_halten();
    }

    // Balken eine Zeile nach unten
    fn runter(&mut self) {
        if self.cursor + 1 < self.eintraege.len() {
            self.cursor += 1;
        }
        self.sichtbar_halten();
    }

    // Liefert den Eintrag, auf dem der Balken steht, falls es einen gibt.
    fn markierter_eintrag(&self) -> Option<&Eintrag> {
        self.eintraege.get(self.cursor)
    }

    // Liest den Ordner neu ein, zum Beispiel nachdem eine Datei dazugekommen ist.
    fn neu_lesen(&mut self) {
        if self.server.is_some() {
            return; // Server-Ordner liest der Fährmann im Hintergrund, nicht hier
        }
        self.eintraege = ordner_lesen(&self.ordner);
        if self.cursor >= self.eintraege.len() {
            self.cursor = self.eintraege.len().saturating_sub(1);
        }
        self.sichtbar_halten();
    }

    // Schiebt den sichtbaren Ausschnitt so, dass der Balken immer zu sehen ist.
    fn sichtbar_halten(&mut self) {
        if self.cursor < self.erste_zeile {
            self.erste_zeile = self.cursor;
        }
        if self.cursor >= self.erste_zeile + ZEILEN {
            self.erste_zeile = self.cursor + 1 - ZEILEN;
        }
    }

    // Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.
    // (Nur für Ordner auf diesem Rechner. Server-Ordner liest der Fährmann im Hintergrund.)
    fn oeffnen(&mut self) {
        let Some(ziel) = self.ziel_ordner() else {
            return;
        };
        let eintraege = ordner_lesen(&ziel);
        self.eintraege_setzen(ziel, eintraege);
    }

    // Wohin führt Enter? Liefert den Ordner, falls der Balken auf einem Ordner steht.
    fn ziel_ordner(&self) -> Option<PathBuf> {
        let eintrag = self.markierter_eintrag()?;
        if !eintrag.ist_ordner {
            return None; // Dateien öffnen wir (noch) nicht
        }
        if eintrag.name == ".." {
            self.ordner.parent().map(|oben| oben.to_path_buf())
        } else {
            Some(self.unterpfad(&eintrag.name))
        }
    }

    // Der Pfad eines Eintrags in diesem Panel. Auf dem Server immer mit "/".
    fn unterpfad(&self, name: &str) -> PathBuf {
        if self.server.is_some() {
            let ordner = pfad_text(&self.ordner);
            PathBuf::from(format!("{}/{}", ordner.trim_end_matches('/'), name))
        } else {
            self.ordner.join(name)
        }
    }

    // Zeigt neue Einträge an. Bleibt der Ordner derselbe, bleibt auch der Balken stehen.
    fn eintraege_setzen(&mut self, ordner: PathBuf, eintraege: Vec<Eintrag>) {
        if ordner != self.ordner {
            self.cursor = 0;
            self.erste_zeile = 0;
        }
        self.ordner = ordner;
        self.eintraege = eintraege;
        if self.cursor >= self.eintraege.len() {
            self.cursor = self.eintraege.len().saturating_sub(1);
        }
        self.sichtbar_halten();
    }

    // Zeichnet das Panel. Nur das aktive Panel zeigt den Balken und einen hellen Kopf.
    fn ansicht(&self, aktiv: bool) -> Element<'_, Message> {
        let kopffarbe = if aktiv { SCHWARZ } else { GELB };
        let titel = match &self.server {
            Some(ftp) => format!("{}:{}", ftp.name, pfad_text(&self.ordner)),
            None => self.ordner.display().to_string(),
        };
        let kopf = container(text(titel).color(kopffarbe))
            .style(move |_theme: &Theme| container::Style {
                background: if aktiv {
                    Some(Background::Color(TUERKIS))
                } else {
                    None
                },
                ..container::Style::default()
            });

        let mut liste = column![];
        for (nr, eintrag) in self
            .eintraege
            .iter()
            .enumerate()
            .skip(self.erste_zeile)
            .take(ZEILEN)
        {
            liste = liste.push(zeile(eintrag, aktiv && nr == self.cursor));
        }

        container(column![kopf, liste].spacing(6))
            .width(Fill)
            .height(Fill)
            .padding(8)
            .clip(true)
            .style(|_theme: &Theme| container::Style {
                border: Border {
                    color: TUERKIS,
                    width: 2.0,
                    radius: 0.0.into(),
                },
                ..container::Style::default()
            })
            .into()
    }
}

// Baut eine einzelne Taste der Funktionstastenleiste, z. B. "5 Kopie".
fn f_taste(nummer: u8, beschriftung: &'static str) -> Element<'static, Message> {
    let zahl = text(nummer).color(WEISS);

    let feld = container(text(beschriftung))
        .width(Fill)
        .style(|_theme: &Theme| container::Style {
            background: Some(Background::Color(TUERKIS)),
            text_color: Some(SCHWARZ),
            ..container::Style::default()
        });

    row![zahl, feld].width(Fill).into()
}

// Liest einen Ordner und liefert seine Einträge: oben "..", dann Ordner, dann Dateien.
fn ordner_lesen(pfad: &Path) -> Vec<Eintrag> {
    let mut eintraege = Vec::new();

    let Ok(inhalt) = fs::read_dir(pfad) else {
        return eintraege; // Ordner nicht lesbar: leere Liste
    };

    for eintrag in inhalt.flatten() {
        let name = eintrag.file_name().to_string_lossy().to_string();
        if name.starts_with('.') {
            continue; // versteckte Dateien überspringen
        }
        let Ok(info) = eintrag.metadata() else {
            continue; // keine Infos lesbar: Eintrag überspringen
        };
        eintraege.push(Eintrag {
            name,
            ist_ordner: info.is_dir(),
            groesse: info.len(),
            rechte: rechte_lesen(&info),
        });
    }

    liste_fertigmachen(&mut eintraege, pfad.parent().is_some());

    eintraege
}

// Sortiert eine Liste (Ordner zuerst, dann alphabetisch) und setzt ".." an den Anfang.
fn liste_fertigmachen(eintraege: &mut Vec<Eintrag>, hat_eltern: bool) {
    eintraege.sort_by(|a, b| {
        b.ist_ordner
            .cmp(&a.ist_ordner)
            .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
    });

    if hat_eltern {
        let hoch = Eintrag {
            name: String::from(".."),
            ist_ordner: true,
            groesse: 0,
            rechte: 0,
        };
        eintraege.insert(0, hoch);
    }
}

// Fragt den Server nach dem Inhalt eines Ordners (ohne "..").
async fn ftp_eintraege(strom: &mut AsyncFtpStream, pfad: &str) -> Result<Vec<Eintrag>, String> {
    let zeilen = strom.list(Some(pfad)).await.map_err(|e| e.to_string())?;

    let mut eintraege = Vec::new();
    for zeile in zeilen {
        let Ok(datei) = File::try_from(zeile.as_str()) else {
            continue; // eine Zeile, die wir nicht verstehen: überspringen
        };
        if datei.name().starts_with('.') {
            continue;
        }
        eintraege.push(Eintrag {
            name: datei.name().to_string(),
            ist_ordner: datei.is_directory(),
            groesse: datei.size() as u64,
            rechte: ftp_rechte(&datei),
        });
    }
    Ok(eintraege)
}

// Liest die Rechte aus einer Zeile der Serverliste.
fn ftp_rechte(datei: &File) -> u32 {
    let mut rechte = 0;
    let gruppen = [PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others];
    for (reihe, wer) in gruppen.into_iter().enumerate() {
        if datei.can_read(wer) {
            rechte |= bit(reihe * 3);
        }
        if datei.can_write(wer) {
            rechte |= bit(reihe * 3 + 1);
        }
        if datei.can_execute(wer) {
            rechte |= bit(reihe * 3 + 2);
        }
    }
    rechte
}

// Macht aus einem Pfad einen Text mit "/" als Trenner, so wie Server ihn erwarten.
fn pfad_text(pfad: &Path) -> String {
    pfad.to_string_lossy().replace('\\', "/")
}

// Verbindet sich mit dem Server, meldet sich an und liest den Startordner.
async fn ftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
    let adresse = format!("{}:{}", zugang.host.trim(), zugang.port.trim());
    let mut strom = AsyncFtpStream::connect(adresse.as_str())
        .await
        .map_err(|e| e.to_string())?;
    strom
        .login(&zugang.benutzer, &zugang.passwort)
        .await
        .map_err(|e| e.to_string())?;
    strom
        .transfer_type(FileType::Binary)
        .await
        .map_err(|e| e.to_string())?;
    let start = strom.pwd().await.map_err(|e| e.to_string())?;

    let mut eintraege = ftp_eintraege(&mut strom, &start).await?;
    liste_fertigmachen(&mut eintraege, start != "/");

    let ftp = Server {
        strom: Arc::new(Mutex::new(Strom::Ftp(strom))),
        name: format!("{}@{}", zugang.benutzer, zugang.host),
        fingerabdruck: None,
    };
    Ok((ftp, PathBuf::from(start), eintraege))
}

// Verbindet sich per SSH, meldet sich an, öffnet SFTP und liest den Startordner.
async fn sftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
    let port: u16 = zugang
        .port
        .trim()
        .parse()
        .map_err(|_| format!("„{}“ ist kein gültiger Port.", zugang.port))?;
    let fingerabdruck = Arc::new(std::sync::Mutex::new(String::new()));
    let pruefer = Pruefer {
        fingerabdruck: fingerabdruck.clone(),
    };

    let einstellungen = Arc::new(russh::client::Config::default());
    let mut sitzung = russh::client::connect(einstellungen, (zugang.host.trim(), port), pruefer)
        .await
        .map_err(als_text)?;
    let anmeldung = sitzung
        .authenticate_password(&zugang.benutzer, &zugang.passwort)
        .await
        .map_err(als_text)?;
    if !anmeldung.success() {
        return Err(String::from("Benutzername oder Passwort stimmt nicht."));
    }

    let kanal = sitzung.channel_open_session().await.map_err(als_text)?;
    kanal.request_subsystem(true, "sftp").await.map_err(als_text)?;
    let sftp = SftpSession::new(kanal.into_stream()).await.map_err(als_text)?;
    let start = sftp.canonicalize(".").await.map_err(als_text)?;

    let mut strom = Strom::Sftp {
        sftp,
        _sitzung: sitzung,
    };
    let mut eintraege = strom.eintraege(&start).await?;
    liste_fertigmachen(&mut eintraege, start != "/");

    let server = Server {
        strom: Arc::new(Mutex::new(strom)),
        name: format!("{}@{}", zugang.benutzer, zugang.host),
        fingerabdruck: Some(fingerabdruck.lock().unwrap().clone()),
    };
    Ok((server, PathBuf::from(start), eintraege))
}

// Liest einen Ordner auf dem Server.
async fn server_lesen(ftp: Server, pfad: PathBuf) -> Result<(PathBuf, Vec<Eintrag>), String> {
    let mut strom = ftp.strom.lock().await;
    let mut eintraege = strom.eintraege(&pfad_text(&pfad)).await?;
    liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
    Ok((pfad, eintraege))
}

// Beantwortet beim SSH-Verbindungsaufbau die Frage: "Ist das wirklich der richtige Server?"
// Wir merken uns dabei den Fingerabdruck des Servers, um ihn anzuzeigen.
struct Pruefer {
    fingerabdruck: Arc<std::sync::Mutex<String>>,
}

impl russh::client::Handler for Pruefer {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        schluessel: &PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        if let PublicKeyOrCertificate::PublicKey { key, .. } = schluessel {
            *self.fingerabdruck.lock().unwrap() = key.fingerprint(HashAlg::Sha256).to_string();
        }
        Ok(true) // Wir vertrauen jedem Server. Mehr dazu im Kasten in Schritt 5!
    }
}

// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache.
enum Strom {
    Ftp(AsyncFtpStream),
    Sftp {
        sftp: SftpSession,
        _sitzung: russh::client::Handle<Pruefer>, // hält die SSH-Verbindung am Leben
    },
}

// Was man mit jeder Leitung tun kann, egal welche Sprache der Server spricht.
impl Strom {
    // Liest einen Ordner (ohne "..").
    async fn eintraege(&mut self, pfad: &str) -> Result<Vec<Eintrag>, String> {
        match self {
            Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await,
            Strom::Sftp { sftp, .. } => {
                let mut eintraege = Vec::new();
                for eintrag in sftp.read_dir(pfad).await.map_err(als_text)? {
                    let name = eintrag.file_name();
                    if name.starts_with('.') {
                        continue;
                    }
                    let info = eintrag.metadata();
                    eintraege.push(Eintrag {
                        name,
                        ist_ordner: eintrag.file_type().is_dir(),
                        groesse: info.size.unwrap_or(0),
                        rechte: info.permissions.unwrap_or(0) & 0o777,
                    });
                }
                Ok(eintraege)
            }
        }
    }

    // Legt einen Ordner an.
    async fn ordner_anlegen(&mut self, pfad: &str) -> Result<(), String> {
        match self {
            Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text),
            Strom::Sftp { sftp, .. } => sftp.create_dir(pfad).await.map_err(als_text),
        }
    }

    // Schickt eine Datei von diesem Rechner zum Server.
    async fn datei_hoch(
        &mut self,
        von: &Path,
        nach: &str,
        zaehler: &AtomicU64,
    ) -> Result<(), String> {
        let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;
        match self {
            Strom::Ftp(ftp) => {
                let mut ziel = ftp.put_with_stream(nach).await.map_err(als_text)?;
                zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
                ziel.finish().await.map_err(als_text)?;
            }
            Strom::Sftp { sftp, .. } => {
                let mut ziel = sftp.create(nach).await.map_err(als_text)?;
                zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
                ziel.shutdown().await.map_err(als_text)?;
            }
        }
        Ok(())
    }

    // Holt eine Datei vom Server auf diesen Rechner.
    async fn datei_runter(
        &mut self,
        von: &str,
        nach: &Path,
        zaehler: &AtomicU64,
    ) -> Result<(), String> {
        let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;
        match self {
            Strom::Ftp(ftp) => {
                let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;
                zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
                quelle.finish().await.map_err(als_text)?;
            }
            Strom::Sftp { sftp, .. } => {
                let mut quelle = sftp.open(von).await.map_err(als_text)?;
                zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
            }
        }
        Ok(())
    }

    // Setzt die Rechte. FTP braucht dafür SITE CHMOD, das nicht jeder Server kann.
    async fn rechte_setzen(&mut self, pfad: &str, rechte: u32) -> Result<(), String> {
        match self {
            Strom::Ftp(ftp) => {
                let befehl = format!("CHMOD {:03o} {}", rechte, pfad);
                ftp.site(befehl).await.map(|_| ()).map_err(als_text)
            }
            Strom::Sftp { sftp, .. } => {
                let mut attribute = FileAttributes::empty();
                attribute.permissions = Some(rechte);
                sftp.set_metadata(pfad, attribute).await.map_err(als_text)
            }
        }
    }
}

// Was bei einer Übertragung zusammengekommen ist.
#[derive(Default)]
struct Bilanz {
    dateien: u64,
    ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließen
    zaehler: Arc<AtomicU64>, // übertragene Bytes, laufend mitgezählt
}

impl Bilanz {
    // Macht aus der Bilanz eine Meldung für die Meldungszeile.
    fn text(&self, name: &str, wie: &str) -> String {
        let mut text = format!("„{}“ {}: {} Datei(en).", name, wie, self.dateien);
        if self.ohne_rechte > 0 {
            text += &format!(
                " Bei {} Einträgen ließen sich die Rechte nicht übernehmen.",
                self.ohne_rechte
            );
        }
        text
    }
}

// Macht aus jedem Fehler einen Text. So haben alle Fehler dieselbe Form.
fn als_text<E: fmt::Display>(fehler: E) -> String {
    fehler.to_string()
}

// Kopiert von einer Quelle in ein Ziel und zählt dabei jedes Byte mit.
async fn zaehlend_kopieren<Q, Z>(quelle: &mut Q, ziel: &mut Z, zaehler: &AtomicU64) -> Result<(), String>
where
    Q: AsyncRead + Unpin,
    Z: AsyncWrite + Unpin,
{
    let mut puffer = vec![0u8; 64 * 1024];
    loop {
        let gelesen = quelle.read(&mut puffer).await.map_err(als_text)?;
        if gelesen == 0 {
            return Ok(()); // Quelle leer: fertig
        }
        ziel.write_all(&puffer[..gelesen]).await.map_err(als_text)?;
        zaehler.fetch_add(gelesen as u64, Ordering::Relaxed);
    }
}

// Der Rückgabetyp einer Übertragung: eine Aufgabe, die später fertig wird.
type Uebertragung<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;

// Bringt eine Datei oder einen ganzen Ordner auf den Server, samt Rechten.
fn hoch<'a>(
    strom: &'a mut Strom,
    von: &'a Path,
    nach: &'a str,
    bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
    Box::pin(async move {
        let info = fs::metadata(von).map_err(als_text)?;

        if info.is_dir() {
            strom.ordner_anlegen(nach).await?;
            for eintrag in fs::read_dir(von).map_err(als_text)? {
                let eintrag = eintrag.map_err(als_text)?;
                if eintrag.file_type().map_err(als_text)?.is_symlink() {
                    continue; // Verknüpfungen lassen wir aus
                }
                let name = eintrag.file_name().to_string_lossy().to_string();
                hoch(strom, &eintrag.path(), &format!("{}/{}", nach, name), bilanz).await?;
            }
        } else {
            strom.datei_hoch(von, nach, &bilanz.zaehler).await?;
            bilanz.dateien += 1;
        }

        // Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen.
        if strom.rechte_setzen(nach, rechte_lesen(&info)).await.is_err() {
            bilanz.ohne_rechte += 1;
        }
        Ok(())
    })
}

// Lädt hoch und meldet das Ergebnis.
async fn hochladen(
    ftp: Server,
    von: PathBuf,
    nach: PathBuf,
    name: String,
    zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz {
        zaehler,
        ..Bilanz::default()
    };
    hoch(&mut strom, &von, &pfad_text(&nach), &mut bilanz)
        .await
        .map_err(|fehler| format!("Hochladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
    Ok(bilanz.text(&name, "hochgeladen"))
}

// Holt eine Datei oder einen ganzen Ordner vom Server, samt Rechten.
fn runter<'a>(
    strom: &'a mut Strom,
    von: &'a str,
    nach: &'a Path,
    ist_ordner: bool,
    rechte: u32,
    bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
    Box::pin(async move {
        if ist_ordner {
            fs::create_dir(nach).map_err(als_text)?;
            for eintrag in strom.eintraege(von).await? {
                let unter_von = format!("{}/{}", von, eintrag.name);
                let unter_nach = nach.join(&eintrag.name);
                runter(strom, &unter_von, &unter_nach, eintrag.ist_ordner, eintrag.rechte, bilanz)
                    .await?;
            }
        } else {
            strom.datei_runter(von, nach, &bilanz.zaehler).await?;
            bilanz.dateien += 1;
        }

        // Die Rechte vom Server übernehmen, bei Ordnern erst ganz zum Schluss
        if rechte_setzen(nach, rechte).is_err() {
            bilanz.ohne_rechte += 1;
        }
        Ok(())
    })
}

// Lädt herunter und meldet das Ergebnis.
async fn herunterladen(
    ftp: Server,
    von: PathBuf,
    nach: PathBuf,
    ist_ordner: bool,
    rechte: u32,
    name: String,
    zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    let mut bilanz = Bilanz {
        zaehler,
        ..Bilanz::default()
    };
    runter(&mut strom, &pfad_text(&von), &nach, ist_ordner, rechte, &mut bilanz)
        .await
        .map_err(|fehler| format!("Herunterladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
    Ok(bilanz.text(&name, "heruntergeladen"))
}

// Ändert die Rechte einer Datei oder eines Ordners auf dem Server.
async fn server_rechte_setzen(ftp: Server, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> {
    let mut strom = ftp.strom.lock().await;
    match strom.rechte_setzen(&pfad_text(&pfad), rechte).await {
        Ok(()) => Ok(format!(
            "Rechte von „{}“ auf dem Server sind jetzt {} ({:03o}).",
            name,
            rechte_text(rechte),
            rechte
        )),
        Err(fehler) => Err(format!("Der Server lehnt neue Rechte für „{}“ ab: {}", name, fehler)),
    }
}

// Baut eine Zeile im Panel: links der Name, rechts die Größe.
fn zeile(eintrag: &Eintrag, markiert: bool) -> Element<'_, Message> {
    let farbe = if markiert {
        SCHWARZ
    } else if eintrag.ist_ordner {
        WEISS
    } else {
        HELLTUERKIS
    };
    let groesse = if eintrag.ist_ordner {
        String::from("<ORDNER>")
    } else {
        groesse_text(eintrag.groesse)
    };

    let rechte = if eintrag.name == ".." {
        String::new()
    } else {
        rechte_text(eintrag.rechte)
    };

    let inhalt = row![
        text(eintrag.name.as_str())
            .color(farbe)
            .width(Fill)
            .wrapping(text::Wrapping::None),
        text(rechte).color(farbe),
        text(groesse).color(farbe).width(90).align_x(Right),
    ]
    .spacing(12);

    // Der markierte Eintrag bekommt den türkisen Balken
    container(inhalt)
        .width(Fill)
        .style(move |_theme: &Theme| container::Style {
            background: if markiert {
                Some(Background::Color(TUERKIS))
            } else {
                None
            },
            ..container::Style::default()
        })
        .into()
}

// Macht aus einer Zahl von Bytes etwas Lesbares, zum Beispiel "12,3 KB".
fn groesse_text(bytes: u64) -> String {
    let b = bytes as f64;
    let text = if bytes < 1024 {
        format!("{} B", bytes)
    } else if b < 1024.0 * 1024.0 {
        format!("{:.1} KB", b / 1024.0)
    } else if b < 1024.0 * 1024.0 * 1024.0 {
        format!("{:.1} MB", b / 1024.0 / 1024.0)
    } else {
        format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0)
    };
    text.replace('.', ",")
}

// Baut die Fortschrittsanzeige, zum Beispiel: Lade „Fotos“ hoch [██████░░░░]  60 %  3,1 MB/s
fn fortschritt_text(laufend: &Laufend) -> String {
    let bytes = laufend.zaehler.load(Ordering::Relaxed);
    let sekunden = laufend.start.elapsed().as_secs_f64().max(0.1);
    let tempo = groesse_text((bytes as f64 / sekunden) as u64);

    match laufend.gesamt {
        Some(gesamt) if gesamt > 0 => {
            let anteil = (bytes as f64 / gesamt as f64).min(1.0);
            let voll = (anteil * 20.0) as usize;
            let balken = "█".repeat(voll) + &"░".repeat(20 - voll);
            format!("{} [{}] {:3.0} %  {}/s", laufend.titel, balken, anteil * 100.0, tempo)
        }
        _ => format!("{} … bisher {}, {}/s", laufend.titel, groesse_text(bytes), tempo),
    }
}

// Wie viele Bytes stecken in einer Datei oder in einem ganzen Ordner?
fn lokale_groesse(pfad: &Path) -> u64 {
    let Ok(info) = fs::symlink_metadata(pfad) else {
        return 0;
    };
    if !info.is_dir() {
        return info.len();
    }
    let Ok(inhalt) = fs::read_dir(pfad) else {
        return 0;
    };
    inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum()
}

// Übersetzt technische Fehlermeldungen in verständliche Sätze. Das Original steht in Klammern dahinter.
fn freundlich(fehler: &str) -> String {
    let klein = fehler.to_lowercase();
    let erklaerung = if klein.contains("connection refused") {
        "Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?"
    } else if klein.contains("lookup") || klein.contains("not known") || klein.contains("no such host") {
        "Einen Server mit diesem Namen gibt es nicht. Ist die Adresse richtig geschrieben?"
    } else if klein.contains("connection reset") || klein.contains("broken pipe") {
        "Die Verbindung ist mittendrin abgerissen. Ist der Server noch da, oder ist sein Speicher voll?"
    } else if klein.contains("timed out") {
        "Der Server antwortet nicht. Vielleicht blockiert eine Firewall die Verbindung."
    } else if klein.contains("530") {
        "Benutzername oder Passwort stimmt nicht."
    } else if klein.contains("permission denied") || klein.contains("550") || klein.contains("privileges") {
        "Dafür fehlen die Rechte, oder die Datei gibt es nicht."
    } else if klein.contains("no such file") {
        "Die Datei oder der Ordner existiert nicht (mehr)."
    } else if klein.contains("no space") {
        "Der Speicherplatz ist voll."
    } else {
        return fehler.to_string(); // unbekannt: lieber das Original als eine falsche Erklärung
    };
    format!("{} ({})", erklaerung, fehler)
}

// Kopiert einen ganzen Ordner mit allem, was darin liegt, und liefert die Zahl der Dateien.
// Die Rechte jeder Datei und jedes Ordners werden dabei mitgenommen.
fn ordner_kopieren(von: &Path, nach: &Path) -> io::Result<u64> {
    fs::create_dir(nach)?;
    let mut dateien = 0;

    for eintrag in fs::read_dir(von)? {
        let eintrag = eintrag?;
        let art = eintrag.file_type()?;
        let ziel = nach.join(eintrag.file_name());

        if art.is_symlink() {
            continue; // Verknüpfungen lassen wir aus
        } else if art.is_dir() {
            dateien += ordner_kopieren(&eintrag.path(), &ziel)?;
        } else {
            fs::copy(eintrag.path(), &ziel)?; // nimmt die Rechte der Datei mit
            dateien += 1;
        }
    }

    // Die Rechte des Ordners erst zum Schluss setzen: Ein schreibgeschützter
    // Ordner ließe sich sonst gar nicht mehr befüllen.
    fs::set_permissions(nach, fs::metadata(von)?.permissions())?;
    Ok(dateien)
}

// Liefert die Nummer eines Rechte-Bits: 0 ist "Besitzer darf lesen", 8 ist "Andere dürfen ausführen".
fn bit(nr: usize) -> u32 {
    1 << (8 - nr)
}

// Macht aus den Rechte-Bits einen Text wie "rwxr-xr-x".
fn rechte_text(rechte: u32) -> String {
    let zeichen = ['r', 'w', 'x'];
    let mut text = String::new();
    for nr in 0..9 {
        if rechte & bit(nr) != 0 {
            text.push(zeichen[nr % 3]);
        } else {
            text.push('-');
        }
    }
    text
}

// Liest die Rechte aus den Infos einer Datei.
fn rechte_lesen(info: &fs::Metadata) -> u32 {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        info.permissions().mode() & 0o777
    }
    #[cfg(not(unix))]
    {
        // Windows kennt nur "schreibgeschützt" oder nicht
        if info.permissions().readonly() { 0o444 } else { 0o666 }
    }
}

// Setzt die Rechte einer Datei oder eines Ordners.
fn rechte_setzen(pfad: &Path, rechte: u32) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(pfad, fs::Permissions::from_mode(rechte))
    }
    #[cfg(not(unix))]
    {
        let mut erlaubnis = fs::metadata(pfad)?.permissions();
        erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
        fs::set_permissions(pfad, erlaubnis)
    }
}

// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
    container::Style {
        background: Some(Background::Color(GRAU)),
        text_color: Some(SCHWARZ),
        border: Border {
            color: SCHWARZ,
            width: 2.0,
            radius: 0.0.into(),
        },
        ..container::Style::default()
    }
}

// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {
    let inhalt = column![
        text("Mit einem Server verbinden"),
        text(if zugang.sftp {
            "Protokoll: SFTP (verschlüsselt) · F3 wechselt"
        } else {
            "Protokoll: FTP (unverschlüsselt) · F3 wechselt"
        }),
        eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
        eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
        eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
        eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
        text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
    ]
    .spacing(12);

    container(inhalt)
        .padding(18)
        .width(480)
        .style(dialog_stil)
        .into()
}

// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
    beschriftung: &'a str,
    wert: &'a str,
    feld: Feld,
    id: &'static str,
    geheim: bool,
) -> Element<'a, Message> {
    row![
        text(beschriftung).width(110),
        text_input("", wert)
            .id(id)
            .secure(geheim)
            .on_input(move |neu| Message::Eingabe(feld, neu))
            .on_submit(Message::Verbinden)
            .padding(4),
    ]
    .align_y(Center)
    .into()
}

// Zeichnet den Rechte-Dialog als graue Box im Commander-Stil.
fn dialog_ansicht(dialog: &RechteDialog) -> Element<'_, Message> {
    let mut gitter = column![row![
        text("").width(110),
        text("Lesen").width(110),
        text("Schreiben").width(110),
        text("Ausführen").width(110),
    ]]
    .spacing(4);

    for (reihe_nr, wer) in ["Besitzer", "Gruppe", "Andere"].iter().enumerate() {
        let mut reihe = row![text(*wer).width(110)];
        for spalte in 0..3 {
            let nr = reihe_nr * 3 + spalte;
            let an = dialog.rechte & bit(nr) != 0;
            let gewaehlt = nr == dialog.auswahl;

            let kaestchen = text(if an { "[x]" } else { "[ ]" })
                .color(if gewaehlt { WEISS } else { SCHWARZ });
            reihe = reihe.push(container(kaestchen).width(110).style(move |_theme: &Theme| {
                container::Style {
                    background: if gewaehlt {
                        Some(Background::Color(SCHWARZ))
                    } else {
                        None
                    },
                    ..container::Style::default()
                }
            }));
        }
        gitter = gitter.push(reihe);
    }

    let inhalt = column![
        text(format!("Rechte für „{}“", dialog.name)),
        gitter,
        text(format!("Zahlencode: {:03o}", dialog.rechte)),
        text("Pfeile: wählen · Leertaste: an/aus · Enter: übernehmen · Esc: abbrechen").size(13),
    ]
    .spacing(14);

    container(inhalt)
        .padding(18)
        .style(dialog_stil)
        .into()
}

// Übersetzt einen Tastendruck in eine Nachricht, oder in gar nichts.
fn taste(ereignis: keyboard::Event) -> Option<Message> {
    let keyboard::Event::KeyPressed { key, .. } = ereignis else {
        return None; // Taste losgelassen oder Ähnliches: interessiert uns nicht
    };

    match key {
        Key::Named(Named::ArrowUp) => Some(Message::Hoch),
        Key::Named(Named::ArrowDown) => Some(Message::Runter),
        Key::Named(Named::Enter) => Some(Message::Oeffnen),
        Key::Named(Named::Tab) => Some(Message::Wechseln),
        Key::Named(Named::F5) => Some(Message::Kopieren),
        Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
        Key::Named(Named::F3) => Some(Message::ProtokollWechseln),
        Key::Named(Named::F9) => Some(Message::RechteZeigen),
        Key::Named(Named::ArrowLeft) => Some(Message::PfeilLinks),
        Key::Named(Named::ArrowRight) => Some(Message::PfeilRechts),
        Key::Named(Named::Space) => Some(Message::Umschalten),
        Key::Named(Named::Escape) => Some(Message::Abbrechen),
        Key::Named(Named::F10) => Some(Message::Beenden),
        _ => None,
    }
}
ZusammenfassungEin atomarer Zähler (AtomicU64) lässt mehrere Aufgaben gleichzeitig dieselbe Zahl benutzen, ohne Mutex. Eine eigene Kopierschleife mit Puffer und Slice zählt jedes Byte mit. Generische Funktionen mit where-Bedingungen funktionieren für alles, was die Bedingungen erfüllt. time::every ist ein Uhr-Abonnement, und Subscription::batch hört auf mehrere Quellen. Fehler übersetzt man am besten vorsichtig: bekannte erklären, unbekannte unverändert lassen, das Original immer zeigen.
Übungen

Zum Ausprobieren

Die Übungen sind freiwillig, aber sie helfen enorm. Mach nach jeder Übung einen Probelauf, und stell danach den alten Zustand wieder her, damit die nächste Etappe bei dir genauso weitergeht wie bei allen anderen.

  1. Restzeit: Ergänze die Anzeige um eine geschätzte Restzeit, etwa „noch 12 s“. Tipp: Restliche Bytes durch Tempo.
  2. Wörterbuch: Schreib eine absichtlich falsche Adresse wie localhost:99999 in den Dialog. Was meldet der Fährmann? Bring dem Übersetzer einen passenden Satz bei.
  3. Auch lokal: Lokales Kopieren meldet Fehler noch schwarz. Lass auch diese Fehler rot erscheinen.
Lösungen anzeigen

1. Im Zweig mit bekannter Gesamtgröße: let rest = (gesamt.saturating_sub(bytes) as f64 / (bytes as f64 / sekunden).max(1.0)) as u64; und dann format!("… noch {} s", rest) anhängen. Das .max(1.0) verhindert eine Division durch 0, solange noch nichts übertragen ist.

2. Bei FTP scheitert das Umwandeln des Ports in der Bibliothek, bei SFTP schon in unserem eigenen parse(), das „ist kein gültiger Port“ meldet. Für FTP könnte der Übersetzer etwa auf invalid port reagieren. Noch besser: Auch ftp_verbinden prüft den Port vorher mit parse::<u16>(), genau wie sftp_verbinden.

3. In kopieren im lokalen Teil die Ergebnisse unterscheiden: Statt self.meldung = if … die Fehlerzweige mit self.fehler_melden(freundlich(&fehler.to_string())) behandeln. Am übersichtlichsten wird es, wenn der lokale Teil ein Result<String, String> baut und am Ende genau wie fertig entscheidet.

Etappe 12 geschafft 📊

Der Fährmann zeigt jetzt, wie weit die Fahrt ist, und spricht im Fehlerfall verständlich mit dir. In der letzten Etappe merkt er sich deine Verbindungen, damit du nicht jedes Mal alles neu eintippen musst.

Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.

☕ Kaffee spendieren