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

Die erste FTP-Verbindung – Leinen los!

Heute legt die Fähre ab. Der Fährmann verbindet sich mit deinem Testserver, meldet sich an, und im rechten Panel erscheint der Inhalt des Servers. Mit Enter geht es dort genauso in Ordner hinein wie auf deinem Rechner. Dafür lernst du die wichtigste neue Idee des ganzen Projekts kennen: asynchrones Programmieren, also wie ein Programm auf eine langsame Antwort wartet, ohne dabei einzufrieren.

5 Schritte · ca. 60 Minuten · baut auf Etappe 8 auf

Der Fährmann, rechts mit dem Inhalt des FTP-Servers und dem Kopf fahrgast@localhost:/Hafen
So sieht der Fährmann am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

Wir machen da weiter, wo Etappe 8 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand. Starte vorher deinen Testserver aus Etappe 8, Schritt 1, und lass ihn laufen.

Warten, ohne einzufrieren

Eine Datei auf deiner Festplatte ist in Sekundenbruchteilen gelesen. Ein Server dagegen kann eine halbe Sekunde brauchen, bis er antwortet, im Internet auch mal zehn. Würde der Fährmann einfach warten, wäre das Fenster so lange eingefroren: kein Neuzeichnen, keine Taste, nichts. Jeder kennt solche Programme, und niemand mag sie.

Die Lösung heißt asynchron. Stell dir vor, du bestellst im Café einen Kaffee. Du bleibst nicht starr an der Theke stehen, bis er fertig ist, sondern setzt dich hin, liest Zeitung, und wenn der Kaffee fertig ist, wirst du gerufen. In Rust schreibt man solche Bestellungen als async fn. Eine solche Funktion liefert nicht sofort ihr Ergebnis, sondern ein Versprechen darauf, einen Future. An Stellen, wo gewartet wird, steht .await: „Hier warten, aber lass in der Zwischenzeit andere arbeiten.“

Und wer ruft uns, wenn der Kaffee fertig ist? iced! Mit Task::perform geben wir iced eine async fn und sagen dazu, in welche Nachricht das Ergebnis verpackt werden soll. iced erledigt die Arbeit im Hintergrund, und wenn sie fertig ist, kommt das Ergebnis als ganz normaler Brief bei update an. Dazwischen läuft das Fenster munter weiter.

Schritt 1

Neue Kisten an Bord

Für FTP holen wir uns die Crate suppaftp. Sie kann FTP in einer asynchronen Variante, die auf tokio aufbaut. tokio ist die bekannteste Maschinerie in der Rust-Welt, die asynchrone Aufgaben ausführt. Damit iced dieselbe Maschinerie benutzt, schalten wir bei iced das Feature tokio ein. Drei Befehle:

cargo add iced@0.14 --features tokio
cargo add suppaftp@12 --features tokio
cargo add tokio@1 --features sync,fs,io-util

Danach sieht der Abschnitt [dependencies] so aus:

iced = { version = "0.14", features = ["tokio"] }
suppaftp = { version = "12", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }

Von tokio brauchen wir drei Teile: sync für das gemeinsame Benutzen der Verbindung, fs und io-util für Dateien, die wir in Etappe 10 übertragen. Oben in der Datei holen wir die neuen Werkzeuge herein:

src/main.rsganz oben, die Zeilen der Standardbibliothek
use std::fs;
use std::io;
use std::path::{Path, PathBuf};use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use suppaftp::tokio::AsyncFtpStream;
use tokio::sync::Mutex;

Jetzt ein Typ für eine offene Verbindung. Er kommt hinter Feld:

src/main.rshinter dem enum Feld
    Benutzer,
    Passwort,
}// Eine offene FTP-Verbindung. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Ftp {
    strom: Arc<Mutex<AsyncFtpStream>>,
    name: String, // zum Beispiel "fahrgast@localhost"
}

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

Hier stecken zwei wichtige Ideen:

  • Mutex (sprich „Mjutex“, von mutual exclusion, gegenseitiger Ausschluss) ist wie das Mikrofon in einer Talkshow: Wer es hat, darf reden, die anderen warten. Eine FTP-Verbindung kann immer nur eine Sache auf einmal tun. Wollen zwei Aufgaben gleichzeitig etwas vom Server, wartet die zweite, bis die erste fertig ist.
  • Arc (Atomic Reference Counted) erlaubt, dass mehrere Stellen im Programm sich dieselbe Verbindung teilen. Normalerweise hat in Rust jeder Wert genau einen Besitzer. Ein Arc zählt mit, wie viele Besitzer es gibt, und erst wenn der letzte weg ist, wird die Verbindung geschlossen. Darum darf Ftp Clone sein: Ein Klon ist keine zweite Verbindung, sondern ein weiterer Zettel mit derselben Adresse.

Den impl fmt::Debug-Block schreiben wir von Hand, weil #[derive(Debug)] hier nicht geht: Der Verbindungstyp von suppaftp kann sich nicht selbst beschreiben. Also sagen wir einfach, wie ein Ftp aussehen soll, wenn man es ausgibt. So etwas nennt man einen Trait von Hand implementieren.

Probelauf Speichern, dann im Terminal:
cargo run
Der erste Start dauert eine Weile, denn cargo lädt und übersetzt die neuen Kisten. Danach sieht alles aus wie vorher. Rust warnt, dass Ftp nie gebaut wird. Das kommt in Schritt 3.
Schritt 2

Was der Server erzählt

Fragt man einen FTP-Server nach dem Inhalt eines Ordners (der Befehl heißt LIST), antwortet er mit Textzeilen, die genauso aussehen wie ls -l im Terminal:

drwxr-xr-x   2 fahrgast fahrgast   4096 Oct 03 12:00 Hafen
-rw-r--r--   1 fahrgast fahrgast     17 Oct 03 12:00 willkommen.txt

Die gute Nachricht: suppaftp kann solche Zeilen schon zerlegen. Daraus machen wir unsere Eintrag-Werte, und weil die später in Nachrichten verschickt werden, müssen sie sich kopieren und ausgeben lassen:

src/main.rsüber dem struct Eintrag
// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.#[derive(Debug, Clone)]struct Eintrag {

Die Einträge vom Server sollen genauso sortiert werden wie die lokalen, mit .. ganz oben. Statt den Code zu verdoppeln, ziehen wir ihn aus ordner_lesen in eine eigene Funktion. In ordner_lesen bleibt eine einzige Zeile übrig:

src/main.rsam Ende der Funktion ordner_lesen
            rechte: rechte_lesen(&info),
        });
    }    // Ordner vor Dateien, innerhalb davon alphabetisch
    eintraege.sort_by(|a, b| {
        b.ist_ordner
            .cmp(&a.ist_ordner)
            .then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
    });

    // Der Weg nach oben steht immer ganz oben
    if pfad.parent().is_some() {
        let hoch = Eintrag {
            name: String::from(".."),
            ist_ordner: true,
            groesse: 0,
            rechte: 0,
        };
        eintraege.insert(0, hoch);
    }    liste_fertigmachen(&mut eintraege, pfad.parent().is_some());    eintraege
}

Die Werkzeuge für die Serverliste holen wir oben herein:

src/main.rsganz oben, bei suppaftp
use suppaftp::tokio::AsyncFtpStream;use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;

Und dann vier Funktionen, direkt hinter ordner_lesen:

src/main.rsdirekt hinter der Funktion ordner_lesen
    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('\\', "/")
}
  • liste_fertigmachen ist der verschobene Code. &mut Vec<Eintrag> heißt: Die Funktion bekommt die Liste zum Verändern ausgeliehen, sortiert sie an Ort und Stelle und gibt nichts zurück.
  • ftp_eintraege ist unsere erste async fn. strom.list(…).await schickt die Frage an den Server und wartet auf die Antwort, ohne das Programm zu blockieren. suppaftp hat eigene Fehlertypen, wir machen mit .map_err(|e| e.to_string()) einen Text daraus, damit wir am Ende überall dieselbe Fehlerart haben: String. Dann zerlegt File::try_from jede Zeile.
  • ftp_rechte fragt für Besitzer, Gruppe und Andere ab, ob gelesen, geschrieben und ausgeführt werden darf, und setzt mit |= (bitweises Oder) die passenden Bits. So passen die Server-Rechte in dieselbe Zahl wie die lokalen aus Etappe 7.
  • pfad_text sorgt dafür, dass Serverpfade immer mit / geschrieben werden. Unter Windows benutzt Rust nämlich \, ein FTP-Server versteht aber nur /. Im Code steht '\\' mit zwei Backslashes, weil ein einzelner in Rust eine Sonderbedeutung hat.
Probelauf Speichern, dann im Terminal:
cargo run
Äußerlich ändert sich nichts. Rust warnt, dass ftp_eintraege und Ftp nie benutzt werden. Die Zutaten liegen bereit.
Schritt 3

Verbinden und lesen

Jetzt die zwei Funktionen, die wirklich mit dem Server sprechen. Für die eine brauchen wir noch FileType:

src/main.rsganz oben, bei suppaftp
use suppaftp::tokio::AsyncFtpStream;use suppaftp::types::FileType;
src/main.rshinter der Funktion pfad_text
    pfad.to_string_lossy().replace('\\', "/")
}// Verbindet sich mit dem Server, meldet sich an und liest den Startordner.
async fn ftp_verbinden(zugang: Zugang) -> Result<(Ftp, 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 = Ftp {
        strom: Arc::new(Mutex::new(strom)),
        name: format!("{}@{}", zugang.benutzer, zugang.host),
    };
    Ok((ftp, PathBuf::from(start), eintraege))
}

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

ftp_verbinden erzählt die ganze Geschichte einer Anmeldung, und an jeder Stelle, an der auf den Server gewartet wird, steht .await:

  • Anrufen: AsyncFtpStream::connect baut die Verbindung zu localhost:2121 auf. trim() entfernt versehentlich getippte Leerzeichen.
  • Anmelden: login schickt Name und Passwort.
  • Binär übertragen: transfer_type(FileType::Binary) ist wichtig, auch wenn es unscheinbar aussieht. Ohne diese Zeile würde der Server manche Dateien als Text behandeln und Zeilenenden umbauen. Ein Foto wäre danach kaputt.
  • Wo bin ich? pwd() fragt nach dem Startordner, meist /.
  • Erste Liste: Wir lesen gleich den Startordner, damit das Panel sofort etwas zeigen kann.
  • Einpacken: Erst ganz am Ende packen wir die Verbindung in Arc und Mutex, und alles zusammen geht als Tupel zurück.

Jedes ? bricht im Fehlerfall die Anmeldung ab, und der Fehlertext wandert zurück zum Aufrufer. ftp_lesen ist kürzer: ftp.strom.lock().await holt sich das Mikrofon, also den alleinigen Zugriff auf die Verbindung, und wartet, falls gerade jemand anderes spricht. Das Mikrofon wird automatisch zurückgegeben, wenn strom am Ende der Funktion verschwindet.

Worauf du achten musst Beachte, dass ftp_verbinden und ftp_lesen ihre Werte besitzen (zugang: Zugang, ftp: Ftp) statt sie nur auszuleihen. Das muss so sein: Die Funktionen laufen im Hintergrund weiter, während update längst fertig ist. Eine Ausleihe aus dem Fährmann wäre dann womöglich schon ungültig. Rust würde das nicht übersetzen, und es hätte recht.
Probelauf Speichern, dann im Terminal:
cargo run
Äußerlich noch nichts Neues, dafür eine Warnung mehr: Auch die beiden neuen Funktionen ruft noch niemand auf.
Schritt 4

Ein Panel, das auch Server kann

Ein Panel muss jetzt wissen, ob es einen Ordner auf deinem Rechner zeigt oder einen auf dem Server. Dafür bekommt es die Verbindung als Option:

src/main.rsdas struct Panel
struct Panel {
    ordner: PathBuf,
    eintraege: Vec<Eintrag>,
    cursor: usize,      // Nummer des markierten Eintrags
    erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags    ftp: Option<Ftp>,   // Some, wenn das Panel einen Server zeigt}

Ein normales Panel hat keine Verbindung. Für Server-Panels gibt es eine zweite Bau-Funktion, die fertige Einträge bekommt, denn die hat ja schon ftp_verbinden gelesen:

src/main.rsin impl Panel, die Funktion neu, und eine neue dahinter
        let eintraege = ordner_lesen(&ordner);
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,            ftp: None,        }
    }    ⋮    // Legt ein Panel an, das einen Ordner auf dem Server zeigt.
    fn entfernt(ftp: Ftp, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
            ftp: Some(ftp),
        }
    }    // Balken eine Zeile nach oben

neu_lesen liest Ordner von der Festplatte. Bei einem Server-Panel wäre das falsch, also bricht es dort einfach ab:

src/main.rsin impl Panel, am Anfang von neu_lesen
    fn neu_lesen(&mut self) {        if self.ftp.is_some() {
            return; // Server-Ordner liest der Fährmann im Hintergrund, nicht hier
        }        self.eintraege = ordner_lesen(&self.ordner);

Jetzt der größte Umbau: oeffnen wird in Stücke zerlegt, damit wir die Stücke auch für den Server benutzen können. Ersetze die ganze alte Funktion:

src/main.rsin impl Panel, die Funktion oeffnen
    // Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.    fn oeffnen(&mut self) {
        let Some(eintrag) = self.eintraege.get(self.cursor) else {
            return;
        };
        if !eintrag.ist_ordner {
            return; // Dateien öffnen wir (noch) nicht
        }

        let neuer_ordner = if eintrag.name == ".." {
            match self.ordner.parent() {
                Some(oben) => oben.to_path_buf(),
                None => return,
            }
        } else {
            self.ordner.join(&eintrag.name)
        };

        self.eintraege = ordner_lesen(&neuer_ordner);
        self.ordner = neuer_ordner;
        self.cursor = 0;
        self.erste_zeile = 0;
    }    // (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.ftp.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();
    }
  • ziel_ordner rechnet nur aus, wohin Enter führen würde, ohne etwas zu lesen. Das funktioniert für beide Ufer gleich. Neu ist das ? hinter markierter_eintrag(): Bei einer Option bedeutet es „Ist da nichts, liefere sofort None.“ Und .map(…) verwandelt den Inhalt einer Option, falls einer da ist.
  • unterpfad hängt einen Namen an den Ordner an. Lokal mit join wie bisher, auf dem Server immer mit /, egal auf welchem Betriebssystem der Fährmann läuft.
  • eintraege_setzen übernimmt neue Einträge. Bleibt der Ordner derselbe, zum Beispiel nach dem Neueinlesen, bleibt der Balken, wo er war.
  • oeffnen setzt diese Stücke für lokale Ordner wieder zusammen. Für den Server bauen wir gleich etwas Eigenes.

Zum Schluss zeigt der Kopf eines Server-Panels, mit wem wir verbunden sind:

src/main.rsin der Funktion ansicht
        let kopffarbe = if aktiv { SCHWARZ } else { GELB };        let kopf = container(text(self.ordner.display().to_string()).color(kopffarbe))        let titel = match &self.ftp {
            Some(ftp) => format!("{}:{}", ftp.name, pfad_text(&self.ordner)),
            None => self.ordner.display().to_string(),
        };
        let kopf = container(text(titel).color(kopffarbe))
Probelauf Speichern, dann im Terminal:
cargo run
Alles funktioniert wie vorher, Enter geht weiterhin in lokale Ordner. Ein Server-Panel gibt es noch nicht, darum warnt Rust, dass entfernt nie benutzt wird.
Schritt 5

Leinen los!

Jetzt bringen wir alles zusammen. Zuerst zwei neue Briefsorten für die Antworten des Servers. Sie tragen jeweils ein Result: entweder das Ergebnis oder einen Fehlertext.

src/main.rsdas enum Message
    Verbinden,    Verbunden(Result<(Ftp, PathBuf, Vec<Eintrag>), String>),
    FtpGelesen(Result<(PathBuf, Vec<Eintrag>), String>),    Beenden,

Diese Antworten müssen immer ankommen, auch wenn der Benutzer gerade einen Dialog offen hat. Darum fängt update sie ganz am Anfang ab, noch vor den Dialogen:

src/main.rsganz am Anfang der Funktion update
    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::FtpGelesen(ergebnis) => return self.ftp_gelesen(ergebnis),
            andere => andere,
        };        // Ist der Verbindungsdialog offen, kümmert er sich um die Tasten

Ein kleiner Trick: Das match liefert für alle anderen Briefe den Brief selbst wieder zurück (andere => andere), und wir geben ihm den alten Namen message. So geht es darunter ganz normal weiter. Im normalen match leitet Enter jetzt an eine neue Funktion weiter, und die beiden Antwort-Briefe bekommen einen leeren Zweig, weil sie oben schon erledigt wurden:

src/main.rsin der Funktion update, der eigentliche match
            Message::Oeffnen => self.aktives_panel().oeffnen(),            Message::Oeffnen => return self.oeffnen(),    ⋮            | Message::Verbinden => {} // ohne Dialog ohne Bedeutung            Message::Verbunden(_) | Message::FtpGelesen(_) => {} // oben schon erledigt

Und jetzt das Herzstück: verbinden wird echt. Statt der Platzhalter-Meldung gibt es den Verbindungsaufbau als Aufgabe an iced:

src/main.rsin impl Faehrmann, die Funktion verbinden
    // Baut die Verbindung auf. Vorerst nur ein Platzhalter, richtig wird es in Etappe 9.    // 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!(
            "Die Fahrt zu {}@{}:{} beginnt in der nächsten Etappe.",
            zugang.benutzer, zugang.host, zugang.port
        );
        Task::none()        self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);
        Task::perform(ftp_verbinden(zugang), Message::Verbunden)    }

Lies Task::perform(ftp_verbinden(zugang), Message::Verbunden) als: „iced, führe ftp_verbinden im Hintergrund aus, und wenn es fertig ist, steck das Ergebnis in einen Verbunden-Brief.“ Message::Verbunden ohne Klammern ist dabei, genau wie taste beim Abonnement, eine Funktion, die wir weitergeben. Jede Variante mit Wert ist in Rust nämlich auch eine kleine Funktion, die einen Brief baut. Drei neue Funktionen kommen hinter verbinden:

src/main.rsin impl Faehrmann, hinter verbinden
        Task::perform(ftp_verbinden(zugang), Message::Verbunden)
    }    // Die Antwort auf den Verbindungsaufbau ist da.
    fn verbunden(&mut self, ergebnis: Result<(Ftp, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
        match ergebnis {
            Ok((ftp, ordner, eintraege)) => {
                self.meldung = format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name);
                self.rechts = Panel::entfernt(ftp, ordner, eintraege);
                self.aktiv = Seite::Rechts;
            }
            Err(fehler) => self.meldung = format!("Verbindung fehlgeschlagen: {}", fehler),
        }
        Task::none()
    }

    // Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
    fn ftp_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.meldung = format!("Der Server sagt: {}", fehler),
        }
        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.ftp {
            Some(ftp) => match panel.ziel_ordner() {
                Some(ziel) => Task::perform(ftp_lesen(ftp.clone(), ziel), Message::FtpGelesen),
                None => Task::none(),
            },
            None => {
                panel.oeffnen();
                Task::none()
            }
        }
    }
  • verbunden baut im Erfolgsfall rechts ein Server-Panel und macht es aktiv. Im Fehlerfall, etwa bei falschem Passwort, sagt es das in der Meldungszeile.
  • ftp_gelesen zeigt einen frisch gelesenen Server-Ordner an.
  • oeffnen entscheidet: Server-Panel? Dann Ziel ausrechnen und im Hintergrund lesen. Lokales Panel? Dann direkt öffnen wie bisher.

Mit F2 soll man die Verbindung auch wieder trennen können. Dafür prüft verbindung_zeigen zuerst, ob rechts schon ein Server ist:

src/main.rsam Anfang von verbindung_zeigen
    fn verbindung_zeigen(&mut self) -> Task<Message> {        // Zeigt rechts schon ein Server, trennt F2 die Verbindung wieder
        if let Some(ftp) = &self.rechts.ftp {
            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 {

Ersetzen wir das Server-Panel durch ein lokales, verschwindet der letzte Besitzer der Verbindung, und Arc schließt sie von selbst. Bleibt eine Vorsichtsmaßnahme: Kopieren und Rechte ändern funktionieren im Moment nur lokal. Bis Etappe 10 sagt der Fährmann das ehrlich, statt Unsinn zu machen:

src/main.rsam Anfang von rechte_zeigen und kopieren
    fn rechte_zeigen(&mut self) {        if self.rechts.ftp.is_some() {
            self.meldung = String::from("Rechte auf dem Server ändern lernt der Fährmann in Etappe 10.");
            return;
        }        let panel = self.aktives_panel();    ⋮    fn kopieren(&mut self) {        if self.rechts.ftp.is_some() {
            self.meldung = String::from("Fracht übers Netz lernt der Fährmann in Etappe 10.");
            return;
        }        let (quelle, ziel) = self.quelle_und_ziel();
Probelauf Speichern, dann im Terminal:
cargo run
Der große Moment. Prüf, dass dein Testserver läuft, starte den Fährmann und drück F2, dann Enter. Für einen Augenblick steht unten „Verbinde mit …“, dann erscheint rechts der Inhalt deines Servers, oben mit fahrgast@localhost:/. Geh mit Enter in Hafen und mit .. zurück. Im Terminal des Servers siehst du dabei jede Anfrage. Probier auch ein falsches Passwort: Die Meldungszeile sagt, was der Server davon hält. Und mit F2 trennst du die Verbindung wieder.
Rechts der Inhalt des FTP-Servers
Stand nach Schritt 5: rechts liegt das andere Ufer
Zwischenstand

Der komplette Code nach Etappe 9

Hier ist der vollständige Stand nach Etappe 9. 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"] }

src/main.rs

use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::sync::Mutex;

use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
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);

// 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()
}

// 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
}

// 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
}

// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
    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 FTP-Verbindung. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Ftp {
    strom: Arc<Mutex<AsyncFtpStream>>,
    name: String, // zum Beispiel "fahrgast@localhost"
}

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

#[derive(Debug, Clone)]
enum Message {
    Hoch,
    Runter,
    Oeffnen,
    Wechseln,
    Kopieren,
    RechteZeigen,
    PfeilLinks,
    PfeilRechts,
    Umschalten,
    Abbrechen,
    VerbindenZeigen,
    Eingabe(Feld, String),
    Verbinden,
    Verbunden(Result<(Ftp, PathBuf, Vec<Eintrag>), String>),
    FtpGelesen(Result<(PathBuf, Vec<Eintrag>), 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,
        }
    }

    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::FtpGelesen(ergebnis) => return self.ftp_gelesen(ergebnis),
            andere => andere,
        };

        // 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::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 => 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 => 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 => {} // ohne Dialog ohne Bedeutung
            Message::Verbunden(_) | Message::FtpGelesen(_) => {} // oben schon erledigt
            Message::Beenden => return iced::exit(),
        }
        Task::none()
    }

    // Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
    fn subscription(&self) -> Subscription<Message> {
        keyboard::listen().filter_map(taste)
    }

    // 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.ftp {
            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 {
            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);
        Task::perform(ftp_verbinden(zugang), Message::Verbunden)
    }

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

    // Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
    fn ftp_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.meldung = format!("Der Server sagt: {}", fehler),
        }
        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.ftp {
            Some(ftp) => match panel.ziel_ordner() {
                Some(ziel) => Task::perform(ftp_lesen(ftp.clone(), ziel), Message::FtpGelesen),
                None => Task::none(),
            },
            None => {
                panel.oeffnen();
                Task::none()
            }
        }
    }

    // Öffnet den Rechte-Dialog für den markierten Eintrag.
    fn rechte_zeigen(&mut self) {
        if self.rechts.ftp.is_some() {
            self.meldung = String::from("Rechte auf dem Server ändern lernt der Fährmann in Etappe 10.");
            return;
        }
        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.ordner.join(&eintrag.name),
            rechte: eintrag.rechte,
            auswahl: 0,
        };
        self.dialog = Some(dialog);
    }

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

        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();
    }

    // Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
    fn kopieren(&mut self) {
        if self.rechts.ftp.is_some() {
            self.meldung = String::from("Fracht übers Netz lernt der Fährmann in Etappe 10.");
            return;
        }
        let (quelle, ziel) = self.quelle_und_ziel();
        let Some(eintrag) = quelle.markierter_eintrag() else {
            return;
        };
        if eintrag.name == ".." {
            return;
        }

        // Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
        let name = eintrag.name.clone();
        let ist_ordner = eintrag.ist_ordner;
        let von = quelle.ordner.join(&name);
        let nach = ziel.ordner.join(&name);
        let zielordner = ziel.ordner.display().to_string();

        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();
    }

    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 meldungszeile = container(text(self.meldung.as_str()).color(WEISS))
            .width(Fill)
            .padding([0, 6])
            .style(|_theme: &Theme| container::Style {
                background: Some(Background::Color(SCHWARZ)),
                ..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
    ftp: Option<Ftp>,   // 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,
            ftp: None,
        }
    }

    // Legt ein Panel an, das einen Ordner auf dem Server zeigt.
    fn entfernt(ftp: Ftp, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
        Panel {
            ordner,
            eintraege,
            cursor: 0,
            erste_zeile: 0,
            ftp: 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.ftp.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.ftp.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.ftp {
            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<(Ftp, 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 = Ftp {
        strom: Arc::new(Mutex::new(strom)),
        name: format!("{}@{}", zugang.benutzer, zugang.host),
    };
    Ok((ftp, PathBuf::from(start), eintraege))
}

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

// 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('.', ",")
}

// 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"),
        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::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,
    }
}
ZusammenfassungEine async fn liefert einen Future, ein Versprechen auf ein Ergebnis, und mit .await wartet sie, ohne das Programm zu blockieren. Task::perform lässt iced eine solche Funktion im Hintergrund ausführen und verpackt das Ergebnis in eine Nachricht. Damit mehrere Aufgaben eine Verbindung teilen können, steckt sie in Arc (geteilter Besitz) und Mutex (immer nur einer auf einmal). Async-Funktionen besitzen ihre Werte, statt sie auszuleihen. Und ? funktioniert auch bei Option.
Ü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. Wo bin ich? Lass den Fährmann nach dem Verbinden in der Meldungszeile zusätzlich anzeigen, wie viele Einträge der Startordner hat.
  2. Falscher Port: Trag im Dialog Port 2122 ein, auf dem kein Server läuft. Was meldet der Fährmann? Und wie lange dauert es, bis die Meldung kommt?
  3. Eingefroren? Ersetze in ftp_lesen testweise .lock().await durch eine absichtlich langsame Variante: Füg davor tokio::time::sleep(std::time::Duration::from_secs(3)).await; ein (dafür braucht tokio das Feature time). Kannst du während der drei Sekunden im linken Panel weiterarbeiten?
Lösungen anzeigen

1. In verbunden im Ok-Zweig: let anzahl = eintraege.len(); noch vor Panel::entfernt, weil die Einträge danach dem Panel gehören, und dann format!("Verbunden mit {}, {} Einträge. …", ftp.name, anzahl). Aber Achtung: Auch ftp.name muss vor Panel::entfernt benutzt werden. Am einfachsten baust du die Meldung zuerst.

2. Etwas wie Verbindung fehlgeschlagen: Connection error: Connection refused. Auf dem eigenen Rechner kommt die Antwort sofort, denn das Betriebssystem weiß, dass dort niemand lauscht. Bei einem Server im Internet, der nicht antwortet, kann das deutlich länger dauern. Deshalb ist es so wichtig, dass der Fährmann dabei nicht einfriert.

3. Ja! Das linke Panel lässt sich während der drei Sekunden ganz normal bedienen, und dann erscheint rechts plötzlich der neue Ordner. Genau das ist der Sinn von asynchronem Code. Ohne Task::perform wäre das ganze Fenster drei Sekunden lang eingefroren. Vergiss nicht, die Bremse danach wieder zu entfernen.

Etappe 9 geschafft ⚓

Die Fähre hat abgelegt: Der Fährmann verbindet sich mit einem FTP-Server, zeigt dessen Ordner an und bleibt dabei die ganze Zeit bedienbar. Du hast asynchronen Code geschrieben, mit Arc und Mutex eine Verbindung geteilt und Aufgaben an iced übergeben. In der nächsten Etappe nimmt die Fähre Fracht mit: Dateien und ganze Ordner hin und zurück, samt Rechten.

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

☕ Kaffee spendieren