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.
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.
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:
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:
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. EinArczählt mit, wie viele Besitzer es gibt, und erst wenn der letzte weg ist, wird die Verbindung geschlossen. Darum darfFtpClonesein: 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.
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.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:
// 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:
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:
use suppaftp::tokio::AsyncFtpStream;use suppaftp::list::{File, PosixPexQuery}; use suppaftp::tokio::AsyncFtpStream;
Und dann vier Funktionen, direkt hinter 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_fertigmachenist 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_eintraegeist unsere ersteasync fn.strom.list(…).awaitschickt 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 zerlegtFile::try_fromjede Zeile.ftp_rechtefragt 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_textsorgt 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.
cargo run
Äußerlich ändert sich nichts. Rust warnt, dass ftp_eintraege und
Ftp nie benutzt werden. Die Zutaten liegen bereit.Verbinden und lesen
Jetzt die zwei Funktionen, die wirklich mit dem Server sprechen. Für die eine brauchen
wir noch FileType:
use suppaftp::tokio::AsyncFtpStream;use suppaftp::types::FileType;
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::connectbaut die Verbindung zulocalhost:2121auf.trim()entfernt versehentlich getippte Leerzeichen. - Anmelden:
loginschickt 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
ArcundMutex, 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.
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.cargo run
Äußerlich noch nichts Neues, dafür eine Warnung mehr: Auch die beiden neuen
Funktionen ruft noch niemand auf.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:
struct Panel { ordner: PathBuf, eintraege: Vec<Eintrag>, cursor: usize, // Nummer des markierten Eintrags erste_zeile: usize, // Nummer des obersten sichtbaren Eintragsftp: 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:
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:
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:
// 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_ordnerrechnet nur aus, wohin Enter führen würde, ohne etwas zu lesen. Das funktioniert für beide Ufer gleich. Neu ist das?hintermarkierter_eintrag(): Bei einerOptionbedeutet es „Ist da nichts, liefere sofortNone.“ Und.map(…)verwandelt den Inhalt einerOption, falls einer da ist.unterpfadhängt einen Namen an den Ordner an. Lokal mitjoinwie 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.oeffnensetzt 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:
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))
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.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.
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:
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:
Message::Oeffnen => self.aktives_panel().oeffnen(),Message::Oeffnen => return self.oeffnen(),⋮| Message::Verbinden => {} // ohne Dialog ohne BedeutungMessage::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:
// 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:
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() } } }
verbundenbaut im Erfolgsfall rechts ein Server-Panel und macht es aktiv. Im Fehlerfall, etwa bei falschem Passwort, sagt es das in der Meldungszeile.ftp_gelesenzeigt einen frisch gelesenen Server-Ordner an.oeffnenentscheidet: 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:
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:
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();
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.
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,
}
}
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.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.
- Wo bin ich? Lass den Fährmann nach dem Verbinden in der Meldungszeile zusätzlich anzeigen, wie viele Einträge der Startordner hat.
- 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?
- Eingefroren? Ersetze in
ftp_lesentestweise.lock().awaitdurch eine absichtlich langsame Variante: Füg davortokio::time::sleep(std::time::Duration::from_secs(3)).await;ein (dafür braucht tokio das Featuretime). 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