Fortschritt und Fehlermeldungen
Lädst du ein Urlaubsvideo mit einem Gigabyte hoch, steht bisher eine Minute lang nur „Lade … hoch“ da. Läuft es noch? Wie lange dauert es? Heute bekommt der Fährmann einen Fortschrittsbalken mit Prozentzahl und Tempo, der sich mehrmals pro Sekunde aktualisiert. Und wenn etwas schiefgeht, übersetzt er die technischen Fehlermeldungen in verständliche Sätze und hebt sie rot hervor.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 11 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand.
Für einen Fortschrittsbalken braucht man zwei Zahlen: wie viel schon übertragen ist und wie
viel es insgesamt wird. Die erste muss die Übertragung im Hintergrund laufend mitzählen,
während view sie gleichzeitig liest. Zwei Seiten, die gleichzeitig dieselbe Zahl
anfassen: Das ist ein Fall für einen ganz besonderen Zahlentyp, den
atomaren Zähler. Und damit der Balken sich bewegt, obwohl niemand eine Taste
drückt, abonnieren wir zusätzlich eine Uhr.
Jedes Byte mitzählen
Bisher kopiert tokio::io::copy eine Datei in einem Rutsch, und wir erfahren
erst am Ende, wie viel es war. Für einen Fortschrittsbalken brauchen wir eine eigene Kopierschleife,
die nach jedem Häppchen Bescheid sagt. Zuerst die Werkzeuge:
use std::pin::Pin;use std::sync::Arc;use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering};⋮use tokio::io::AsyncWriteExt;use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
AtomicU64 ist eine Zahl, die mehrere Aufgaben gleichzeitig anfassen dürfen, ohne
sich in die Quere zu kommen und ohne Mutex. „Atomar“ heißt: Jede Änderung
passiert in einem einzigen, unteilbaren Schritt. Niemand kann eine halb geänderte Zahl lesen.
Für einen einfachen Zähler ist das genau richtig, und viel schneller als ein Mikrofon, das man
herumreichen muss. Die Bilanz bekommt so einen Zähler, in einem Arc,
damit auch view ihn lesen kann:
ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließenzaehler: Arc<AtomicU64>, // übertragene Bytes, laufend mitgezählt}
Jetzt die Kopierschleife, hinter als_text:
fn als_text<E: fmt::Display>(fehler: E) -> String { fehler.to_string() }// Kopiert von einer Quelle in ein Ziel und zählt dabei jedes Byte mit. async fn zaehlend_kopieren<Q, Z>(quelle: &mut Q, ziel: &mut Z, zaehler: &AtomicU64) -> Result<(), String> where Q: AsyncRead + Unpin, Z: AsyncWrite + Unpin, { let mut puffer = vec![0u8; 64 * 1024]; loop { let gelesen = quelle.read(&mut puffer).await.map_err(als_text)?; if gelesen == 0 { return Ok(()); // Quelle leer: fertig } ziel.write_all(&puffer[..gelesen]).await.map_err(als_text)?; zaehler.fetch_add(gelesen as u64, Ordering::Relaxed); } }
- Die Funktion ist generisch über Quelle
Qund ZielZ. Daswheredarunter sagt, was sie können müssen: Aus der Quelle muss man asynchron lesen können (AsyncRead), ins Ziel schreiben (AsyncWrite). So funktioniert dieselbe Schleife für lokale Dateien, FTP-Datenströme und SFTP-Dateien.Unpinist eine technische Bedingung für das Lesen und Schreiben, die all diese Typen erfüllen. - Der Puffer ist ein Eimer mit 64 Kilobyte.
readfüllt ihn so weit es geht und sagt, wie viel hineingekommen ist. Sind es 0 Bytes, ist die Quelle leer. write_allleert genau diese Bytes ins Ziel.&puffer[..gelesen]ist ein Slice: der Anfang des Eimers bis zur gelesenen Menge.fetch_addzählt atomar hoch.Ordering::Relaxedheißt: Es reicht, dass die Zahl stimmt, die genaue Reihenfolge gegenüber anderen Speicherzugriffen ist uns egal. Für einen Fortschrittszähler ist das immer die richtige Wahl.
Die Leitung benutzt jetzt diese Schleife statt tokio::io::copy. Bei FTP brauchen wir
dafür put_with_stream statt put_file, denn das liefert einen
Datenstrom, in den wir selbst schreiben können:
async fn datei_hoch(&mut self, von: &Path, nach: &str) -> Result<(), String> {async fn datei_hoch( &mut self, von: &Path, nach: &str, zaehler: &AtomicU64, ) -> Result<(), String> {let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;⋮Strom::Ftp(ftp) => {ftp.put_file(nach, &mut datei).await.map_err(als_text)?;let mut ziel = ftp.put_with_stream(nach).await.map_err(als_text)?; zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?; ziel.finish().await.map_err(als_text)?;}⋮let mut ziel = sftp.create(nach).await.map_err(als_text)?;tokio::io::copy(&mut datei, &mut ziel).await.map_err(als_text)?;zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;ziel.shutdown().await.map_err(als_text)?;⋮async fn datei_runter(&mut self, von: &str, nach: &Path) -> Result<(), String> {async fn datei_runter( &mut self, von: &str, nach: &Path, zaehler: &AtomicU64, ) -> Result<(), String> {let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;⋮let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;quelle.finish().await.map_err(als_text)?;⋮let mut quelle = sftp.open(von).await.map_err(als_text)?;tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;}
hoch und runter geben den Zähler aus der Bilanz weiter:
strom.datei_hoch(von, nach).await?;strom.datei_hoch(von, nach, &bilanz.zaehler).await?;⋮strom.datei_runter(von, nach).await?;strom.datei_runter(von, nach, &bilanz.zaehler).await?;
Und die beiden Hintergrundaufgaben bekommen den Zähler von außen, damit der Fährmann ihn
behalten und anschauen kann. ..Bilanz::default() heißt wieder: „Alle anderen Felder
wie üblich“, also 0 Dateien und 0 Fehlschläge:
async fn hochladen(ftp: Server, von: PathBuf, nach: PathBuf, name: String) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = Bilanz::default();async fn hochladen( ftp: Server, von: PathBuf, nach: PathBuf, name: String, zaehler: Arc<AtomicU64>, ) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = Bilanz { zaehler, ..Bilanz::default() };⋮rechte: u32, name: String,) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = Bilanz::default();zaehler: Arc<AtomicU64>, ) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = Bilanz { zaehler, ..Bilanz::default() };
Vorerst legt kopieren einfach einen neuen Zähler an. Angezeigt wird er im
nächsten Schritt:
return Task::perform(hochladen(ftp, von, nach, name), Message::Fertig);let zaehler = Arc::new(AtomicU64::new(0)); return Task::perform(hochladen(ftp, von, nach, name, zaehler), Message::Fertig);⋮let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name);let zaehler = Arc::new(AtomicU64::new(0)); let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name, zaehler);
cargo run
Übertragungen funktionieren wie vorher, auch wenn sie jetzt Byte für Byte mitgezählt
werden. Sehen kann man davon noch nichts.Ein Balken, der sich bewegt
Jetzt soll der Fährmann während einer Übertragung den Fortschritt anzeigen. Dafür brauchen wir die Uhr von iced:
use iced::keyboard::{self, Key, key::Named};use iced::time::{self, Duration, Instant};use iced::{
Was zu einer laufenden Übertragung gehört, bündeln wir in einem struct, direkt vor
Faehrmann:
// Eine Übertragung, die gerade läuft. struct Laufend { titel: String, // zum Beispiel "Lade „Fotos“ hoch" zaehler: Arc<AtomicU64>, // bisher übertragene Bytes gesamt: Option<u64>, // so viele werden es insgesamt, falls bekannt start: Instant, // wann es losging, für das Tempo }// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
gesamt ist eine Option, weil wir die Gesamtgröße nicht immer kennen.
Beim Hochladen zählen wir vorher nach, wie groß Datei oder Ordner sind. Beim Herunterladen
einer Datei steht die Größe in der Serverliste. Bei einem ganzen Ordner vom Server wüssten wir
es nur, wenn wir vorher jeden Unterordner einzeln abfragen, und das kostet Zeit. Dann zeigen wir
statt Prozent einfach, wie viel schon da ist. Der Fährmann merkt sich die laufende
Übertragung:
verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibtlaufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft}⋮verbindungsdialog: None,laufend: None,}
Eine laufende Übertragung schickt keine Briefe, und ohne Brief zeichnet iced nicht neu. Also
abonnieren wir eine Uhr: time::every schickt in regelmäßigen
Abständen ein Ereignis. Mit .map machen wir daraus einen Tick-Brief,
und Subscription::batch hört gleichzeitig auf Tastatur und Uhr. Das Schöne: Weil
subscription den Zustand anschaut, läuft die Uhr nur, solange übertragen wird.
ProtokollWechseln,Tick,
fn subscription(&self) -> Subscription<Message> {keyboard::listen().filter_map(taste)let tasten = keyboard::listen().filter_map(taste); if self.laufend.is_some() { // Während einer Übertragung fünfmal pro Sekunde neu zeichnen let uhr = time::every(Duration::from_millis(200)).map(|_| Message::Tick); Subscription::batch([tasten, uhr]) } else { tasten }}
Der Tick selbst muss nichts tun. Allein dass er ankommt, sorgt dafür, dass
view neu zeichnet:
Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigtMessage::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt Message::Tick => {} // nur neu zeichnen
Beim Start einer Übertragung legt kopieren statt der alten Meldung ein
Laufend an. Den Klon des Zählers behalten wir, das Original wandert in die
Hintergrundaufgabe. Die Größe einer Datei vom Server schreiben wir vorher mit heraus:
let ist_ordner = eintrag.ist_ordner;let groesse = eintrag.groesse;⋮if let Some(ftp) = server_ziel {self.meldung = format!("Lade „{}“ hoch …", name); let zaehler = Arc::new(AtomicU64::new(0));let zaehler = Arc::new(AtomicU64::new(0)); self.laufend = Some(Laufend { titel: format!("Lade „{}“ hoch", name), zaehler: zaehler.clone(), gesamt: Some(lokale_groesse(&von)), start: Instant::now(), });⋮if let Some(ftp) = server_quelle {self.meldung = format!("Lade „{}“ herunter …", name); let zaehler = Arc::new(AtomicU64::new(0));let zaehler = Arc::new(AtomicU64::new(0)); self.laufend = Some(Laufend { titel: format!("Lade „{}“ herunter", name), zaehler: zaehler.clone(), gesamt: if ist_ordner { None } else { Some(groesse) }, start: Instant::now(), });
Ist die Übertragung fertig, verschwindet der Balken wieder:
fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {self.laufend = None;
Die Meldungszeile zeigt während einer Übertragung den Fortschritt statt der Meldung:
let meldungszeile = container(text(self.meldung.as_str()).color(WEISS))let meldung = match &self.laufend { Some(laufend) => fortschritt_text(laufend), None => self.meldung.clone(), }; let meldungszeile = container(text(meldung).color(WEISS)).width(Fill)
Und die zwei Hilfsfunktionen dafür, hinter groesse_text:
text.replace('.', ",") }// Baut die Fortschrittsanzeige, zum Beispiel: Lade „Fotos“ hoch [██████░░░░] 60 % 3,1 MB/s fn fortschritt_text(laufend: &Laufend) -> String { let bytes = laufend.zaehler.load(Ordering::Relaxed); let sekunden = laufend.start.elapsed().as_secs_f64().max(0.1); let tempo = groesse_text((bytes as f64 / sekunden) as u64); match laufend.gesamt { Some(gesamt) if gesamt > 0 => { let anteil = (bytes as f64 / gesamt as f64).min(1.0); let voll = (anteil * 20.0) as usize; let balken = "█".repeat(voll) + &"░".repeat(20 - voll); format!("{} [{}] {:3.0} % {}/s", laufend.titel, balken, anteil * 100.0, tempo) } _ => format!("{} … bisher {}, {}/s", laufend.titel, groesse_text(bytes), tempo), } } // Wie viele Bytes stecken in einer Datei oder in einem ganzen Ordner? fn lokale_groesse(pfad: &Path) -> u64 { let Ok(info) = fs::symlink_metadata(pfad) else { return 0; }; if !info.is_dir() { return info.len(); } let Ok(inhalt) = fs::read_dir(pfad) else { return 0; }; inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum() }
fortschritt_textliest den Zähler mitload, rechnet das Tempo aus Bytes durch Sekunden aus und baut einen Balken aus 20 Zeichen:█für erledigt,░für offen. Der ZweigSome(gesamt) if gesamt > 0hat wieder einen Match-Guard, damit wir nie durch 0 teilen..max(0.1)sorgt dafür, dass auch in den ersten Millisekunden nicht durch 0 geteilt wird.lokale_groesseist eine kleine Rekursion wie in Etappe 6: Datei? Größe liefern. Ordner? Größen aller Einträge zusammenzählen, mit.sum().
cargo run
Leg dir für einen schönen Test eine große Datei an, unter Linux etwa mit
head -c 500M /dev/urandom > gross.bin, verbinde dich und lade sie mit
F5 hoch. Unten läuft der Balken von links nach rechts, mit Prozent und Tempo. Auf dem
eigenen Rechner geht es sehr schnell. Gegen einen Server im Internet hast du Zeit zum
Zuschauen.
Fehler, die man versteht
Wenn etwas schiefgeht, meldet der Fährmann bisher, was die Crates ihm sagen, etwa
Connection error: Connection refused (os error 111). Ein Programmierer weiß, was
gemeint ist. Alle anderen nicht. Wir bauen deshalb einen kleinen Übersetzer und heben
Fehler rot hervor, wie es der Commander mit seinen roten Fehlerfenstern tat. Zuerst die
Farbe:
const GRAU: Color = Color::from_rgb8(170, 170, 170);const DUNKELROT: Color = Color::from_rgb8(170, 0, 0);
Der Fährmann merkt sich, ob die aktuelle Meldung ein Fehler ist:
laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuftfehler: bool, // ist die Meldung ein Fehler? Dann rot}⋮laufend: None,fehler: false,}
Rot soll die Zeile bleiben, bis der Benutzer etwas tut. Also nimmt jede Nachricht außer dem
Uhr-Tick den Fehler wieder zurück. matches! ist ein Makro, das fragt: „Passt der
Wert zu diesem Muster?“ und true oder false liefert:
Message::Fertig(ergebnis) => return self.fertig(ergebnis), andere => andere, };// Jede Taste nimmt die rote Fehlermeldung wieder zurück if !matches!(message, Message::Tick) { self.fehler = false; }
Eine kleine Hilfsfunktion setzt Meldung und Fehler auf einmal, vor fertig:
// Zeigt eine Fehlermeldung, rot hinterlegt bis zur nächsten Taste. fn fehler_melden(&mut self, text: String) { self.meldung = text; self.fehler = true; }// Eine Übertragung oder Änderung auf dem Server ist fertig.
Die drei Stellen, an denen Fehler vom Server ankommen, benutzen sie und schicken den Text vorher durch den Übersetzer:
Err(fehler) => self.meldung = format!("Verbindung fehlgeschlagen: {}", fehler),Err(fehler) => { self.fehler_melden(format!("Verbindung fehlgeschlagen: {}", freundlich(&fehler))) }⋮Err(fehler) => self.meldung = format!("Der Server sagt: {}", fehler),Err(fehler) => self.fehler_melden(format!("Der Server sagt: {}", freundlich(&fehler))),⋮self.laufend = None;self.meldung = match ergebnis { Ok(text) => text, Err(fehler) => fehler, };match ergebnis { Ok(text) => self.meldung = text, Err(fehler) => self.fehler_melden(freundlich(&fehler)), }
In view hängt die Farbe der Meldungszeile jetzt vom Zustand ab. Weil die Closure
dafür hintergrund braucht, bekommt sie ein move. Außerdem darf eine lange
Fehlermeldung nicht mehr in eine zweite Zeile umbrechen, sonst schiebt sie die ganze
Oberfläche durcheinander. Was nicht passt, wird am rechten Rand abgeschnitten, und das
Wichtigste steht ja vorne:
let meldungszeile = container(text(meldung).color(WEISS))let meldungszeile = container(text(meldung).color(WEISS).wrapping(text::Wrapping::None)).width(Fill) .padding([0, 6])⋮.width(Fill) .padding([0, 6]).style(|_theme: &Theme| container::Style { background: Some(Background::Color(SCHWARZ)),.style(move |_theme: &Theme| container::Style { background: Some(Background::Color(hintergrund)),..container::Style::default()⋮None => self.meldung.clone(), };let hintergrund = if self.fehler { DUNKELROT } else { SCHWARZ };
Und der Übersetzer selbst, hinter lokale_groesse:
inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum() }// Übersetzt technische Fehlermeldungen in verständliche Sätze. Das Original steht in Klammern dahinter. fn freundlich(fehler: &str) -> String { let klein = fehler.to_lowercase(); let erklaerung = if klein.contains("connection refused") { "Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?" } else if klein.contains("lookup") || klein.contains("not known") || klein.contains("no such host") { "Einen Server mit diesem Namen gibt es nicht. Ist die Adresse richtig geschrieben?" } else if klein.contains("connection reset") || klein.contains("broken pipe") { "Die Verbindung ist mittendrin abgerissen. Ist der Server noch da, oder ist sein Speicher voll?" } else if klein.contains("timed out") { "Der Server antwortet nicht. Vielleicht blockiert eine Firewall die Verbindung." } else if klein.contains("530") { "Benutzername oder Passwort stimmt nicht." } else if klein.contains("permission denied") || klein.contains("550") || klein.contains("privileges") { "Dafür fehlen die Rechte, oder die Datei gibt es nicht." } else if klein.contains("no such file") { "Die Datei oder der Ordner existiert nicht (mehr)." } else if klein.contains("no space") { "Der Speicherplatz ist voll." } else { return fehler.to_string(); // unbekannt: lieber das Original als eine falsche Erklärung }; format!("{} ({})", erklaerung, fehler) }
Er sucht mit contains nach typischen Stichwörtern, nachdem alles in
Kleinbuchstaben verwandelt wurde. Die Zahlen 530 und 550 sind
FTP-Antwortcodes: 530 heißt „nicht angemeldet“, 550 „Aktion nicht möglich“. Wichtig ist der
letzte Zweig: Kennt der Übersetzer einen Fehler nicht, gibt er lieber das Original zurück als
eine Erklärung, die vielleicht falsch ist. Und auch bei bekannten Fehlern hängt das Original in
Klammern dran, denn wer Hilfe sucht, braucht oft genau diesen Wortlaut.
cargo run
Beende deinen Testserver und versuch, dich zu verbinden. Die Meldungszeile wird rot
und sagt: Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?
Drück eine beliebige Taste, und die Zeile wird wieder schwarz. Probier auch einen falschen
Servernamen wie gibtsnicht.invalid oder ein falsches Passwort.
Der komplette Code nach Etappe 12
Hier ist der vollständige Stand nach Etappe 12. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.
Cargo.toml und src/main.rs anzeigen
Cargo.toml
[package]
name = "faehrmann"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.14", features = ["tokio"] }
suppaftp = { version = "12", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "3"
src/main.rs
use std::fmt;
use std::fs;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use russh::keys::{HashAlg, PublicKeyOrCertificate};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::FileAttributes;
use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::Mutex;
use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::time::{self, Duration, Instant};
use iced::{
Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};
// Die Farben des Norton Commander
const BLAU: Color = Color::from_rgb8(0, 0, 170);
const TUERKIS: Color = Color::from_rgb8(0, 170, 170);
const SCHWARZ: Color = Color::BLACK;
const WEISS: Color = Color::WHITE;
const HELLTUERKIS: Color = Color::from_rgb8(85, 255, 255);
const GELB: Color = Color::from_rgb8(255, 255, 85);
const GRAU: Color = Color::from_rgb8(170, 170, 170);
const DUNKELROT: Color = Color::from_rgb8(170, 0, 0);
// So viele Einträge passen untereinander in ein Panel
const ZEILEN: usize = 24;
// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.
#[derive(Debug, Clone)]
struct Eintrag {
name: String,
ist_ordner: bool,
groesse: u64,
rechte: u32, // Zugriffsrechte, zum Beispiel 0o644
}
fn main() -> iced::Result {
iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)
.subscription(Faehrmann::subscription)
.title("Fährmann")
.default_font(Font::MONOSPACE)
.window_size((1000.0, 640.0))
.run()
}
// Eine Übertragung, die gerade läuft.
struct Laufend {
titel: String, // zum Beispiel "Lade „Fotos“ hoch"
zaehler: Arc<AtomicU64>, // bisher übertragene Bytes
gesamt: Option<u64>, // so viele werden es insgesamt, falls bekannt
start: Instant, // wann es losging, für das Tempo
}
// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
struct Faehrmann {
links: Panel,
rechts: Panel,
aktiv: Seite,
meldung: String, // was der Fährmann zuletzt zu sagen hatte
dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt
verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt
laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft
fehler: bool, // ist die Meldung ein Fehler? Dann rot
}
// Welches der beiden Panels ist gerade aktiv?
#[derive(PartialEq)]
enum Seite {
Links,
Rechts,
}
// Der Dialog zum Ändern der Rechte: für welche Datei, welche Rechte, welcher Schalter gewählt ist.
struct RechteDialog {
name: String,
pfad: PathBuf,
rechte: u32,
auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
server: Option<Server>, // Some, wenn die Datei auf dem Server liegt
}
// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
sftp: bool, // true: SFTP, false: FTP
host: String,
port: String,
benutzer: String,
passwort: String,
}
// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
Host,
Port,
Benutzer,
Passwort,
}
// Eine offene Verbindung zu einem Server. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Server {
strom: Arc<Mutex<Strom>>,
name: String, // zum Beispiel "fahrgast@localhost"
fingerabdruck: Option<String>, // nur bei SFTP
}
// Nachrichten müssen sich mit {:?} ausgeben lassen. Die Verbindung selbst kann das
// nicht, also schreiben wir es für Server von Hand.
impl fmt::Debug for Server {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Server({})", self.name)
}
}
#[derive(Debug, Clone)]
enum Message {
Hoch,
Runter,
Oeffnen,
Wechseln,
Kopieren,
RechteZeigen,
PfeilLinks,
PfeilRechts,
Umschalten,
Abbrechen,
VerbindenZeigen,
Eingabe(Feld, String),
Verbinden,
ProtokollWechseln,
Tick,
Verbunden(Result<(Server, PathBuf, Vec<Eintrag>), String>),
Gelesen(Result<(PathBuf, Vec<Eintrag>), String>),
Fertig(Result<String, String>),
Beenden,
}
impl Faehrmann {
// Startet im Ordner, aus dem das Programm aufgerufen wurde.
fn neu() -> Self {
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
Faehrmann {
links: Panel::neu(ordner.clone()),
rechts: Panel::neu(ordner),
aktiv: Seite::Links,
meldung: String::from("Willkommen an Bord! Tab wechselt das Panel, F5 kopiert, F10 beendet."),
dialog: None,
verbindungsdialog: None,
laufend: None,
fehler: false,
}
}
fn update(&mut self, message: Message) -> Task<Message> {
// Antworten vom Server kommen immer an, egal ob gerade ein Dialog offen ist
let message = match message {
Message::Verbunden(ergebnis) => return self.verbunden(ergebnis),
Message::Gelesen(ergebnis) => return self.gelesen(ergebnis),
Message::Fertig(ergebnis) => return self.fertig(ergebnis),
andere => andere,
};
// Jede Taste nimmt die rote Fehlermeldung wieder zurück
if !matches!(message, Message::Tick) {
self.fehler = false;
}
// Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
if let Some(zugang) = &mut self.verbindungsdialog {
match message {
Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
Message::ProtokollWechseln => {
zugang.sftp = !zugang.sftp;
if zugang.sftp {
zugang.port = String::from("22");
zugang.benutzer = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default();
zugang.passwort = String::new();
} else {
zugang.port = String::from("2121");
}
}
Message::Wechseln => return operation::focus_next(),
Message::Verbinden => return self.verbinden(),
Message::Abbrechen => self.verbindungsdialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
// Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
if let Some(dialog) = &mut self.dialog {
match message {
Message::Hoch => dialog.auswahl = (dialog.auswahl + 6) % 9,
Message::Runter => dialog.auswahl = (dialog.auswahl + 3) % 9,
Message::PfeilLinks => dialog.auswahl = (dialog.auswahl + 8) % 9,
Message::PfeilRechts => dialog.auswahl = (dialog.auswahl + 1) % 9,
Message::Umschalten => dialog.rechte ^= bit(dialog.auswahl),
Message::Oeffnen => return self.rechte_anwenden(),
Message::Abbrechen => self.dialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
match message {
Message::Hoch => self.aktives_panel().hoch(),
Message::Runter => self.aktives_panel().runter(),
Message::Oeffnen => return self.oeffnen(),
Message::Wechseln => {
self.aktiv = match self.aktiv {
Seite::Links => Seite::Rechts,
Seite::Rechts => Seite::Links,
};
}
Message::Kopieren => return self.kopieren(),
Message::RechteZeigen => self.rechte_zeigen(),
Message::VerbindenZeigen => return self.verbindung_zeigen(),
Message::PfeilLinks
| Message::PfeilRechts
| Message::Umschalten
| Message::Abbrechen
| Message::Eingabe(..)
| Message::Verbinden
| Message::ProtokollWechseln => {} // ohne Dialog ohne Bedeutung
Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt
Message::Tick => {} // nur neu zeichnen
Message::Beenden => return iced::exit(),
}
Task::none()
}
// Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
fn subscription(&self) -> Subscription<Message> {
let tasten = keyboard::listen().filter_map(taste);
if self.laufend.is_some() {
// Während einer Übertragung fünfmal pro Sekunde neu zeichnen
let uhr = time::every(Duration::from_millis(200)).map(|_| Message::Tick);
Subscription::batch([tasten, uhr])
} else {
tasten
}
}
// Liefert das Panel, das gerade aktiv ist, zum Verändern.
fn aktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.links,
Seite::Rechts => &mut self.rechts,
}
}
// Liefert das Panel, das gerade NICHT aktiv ist, zum Verändern.
fn inaktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.rechts,
Seite::Rechts => &mut self.links,
}
}
// Liefert beide Panels zum Anschauen: zuerst das aktive, dann das andere.
fn quelle_und_ziel(&self) -> (&Panel, &Panel) {
match self.aktiv {
Seite::Links => (&self.links, &self.rechts),
Seite::Rechts => (&self.rechts, &self.links),
}
}
// Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
fn verbindung_zeigen(&mut self) -> Task<Message> {
// Zeigt rechts schon ein Server, trennt F2 die Verbindung wieder
if let Some(ftp) = &self.rechts.server {
self.meldung = format!("Verbindung zu {} getrennt.", ftp.name);
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
self.rechts = Panel::neu(ordner);
return Task::none();
}
self.verbindungsdialog = Some(Zugang {
sftp: false,
host: String::from("localhost"),
port: String::from("2121"),
benutzer: String::from("fahrgast"),
passwort: String::from("geheim"),
});
operation::focus("host")
}
// Baut die Verbindung im Hintergrund auf.
fn verbinden(&mut self) -> Task<Message> {
let Some(zugang) = self.verbindungsdialog.take() else {
return Task::none();
};
self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);
if zugang.sftp {
Task::perform(sftp_verbinden(zugang), Message::Verbunden)
} else {
Task::perform(ftp_verbinden(zugang), Message::Verbunden)
}
}
// Die Antwort auf den Verbindungsaufbau ist da.
fn verbunden(&mut self, ergebnis: Result<(Server, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ftp, ordner, eintraege)) => {
self.meldung = match &ftp.fingerabdruck {
Some(abdruck) => format!("Verbunden mit {} per SFTP. Schlüssel: {}", ftp.name, abdruck),
None => format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name),
};
self.rechts = Panel::entfernt(ftp, ordner, eintraege);
self.aktiv = Seite::Rechts;
}
Err(fehler) => {
self.fehler_melden(format!("Verbindung fehlgeschlagen: {}", freundlich(&fehler)))
}
}
Task::none()
}
// Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
fn gelesen(&mut self, ergebnis: Result<(PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ordner, eintraege)) => self.rechts.eintraege_setzen(ordner, eintraege),
Err(fehler) => self.fehler_melden(format!("Der Server sagt: {}", freundlich(&fehler))),
}
Task::none()
}
// Zeigt eine Fehlermeldung, rot hinterlegt bis zur nächsten Taste.
fn fehler_melden(&mut self, text: String) {
self.meldung = text;
self.fehler = true;
}
// Eine Übertragung oder Änderung auf dem Server ist fertig.
fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {
self.laufend = None;
match ergebnis {
Ok(text) => self.meldung = text,
Err(fehler) => self.fehler_melden(freundlich(&fehler)),
}
self.links.neu_lesen();
self.rechts_neu_lesen()
}
// Liest das rechte Panel neu ein, vom Server im Hintergrund, falls es verbunden ist.
fn rechts_neu_lesen(&mut self) -> Task<Message> {
match &self.rechts.server {
Some(ftp) => Task::perform(
server_lesen(ftp.clone(), self.rechts.ordner.clone()),
Message::Gelesen,
),
None => {
self.rechts.neu_lesen();
Task::none()
}
}
}
// Enter: in den markierten Ordner gehen. Auf dem Server passiert das im Hintergrund.
fn oeffnen(&mut self) -> Task<Message> {
let panel = self.aktives_panel();
match &panel.server {
Some(ftp) => match panel.ziel_ordner() {
Some(ziel) => Task::perform(server_lesen(ftp.clone(), ziel), Message::Gelesen),
None => Task::none(),
},
None => {
panel.oeffnen();
Task::none()
}
}
}
// Öffnet den Rechte-Dialog für den markierten Eintrag.
fn rechte_zeigen(&mut self) {
let panel = self.aktives_panel();
let Some(eintrag) = panel.markierter_eintrag() else {
return;
};
if eintrag.name == ".." {
return;
}
let dialog = RechteDialog {
name: eintrag.name.clone(),
pfad: panel.unterpfad(&eintrag.name),
rechte: eintrag.rechte,
auswahl: 0,
server: panel.server.clone(),
};
self.dialog = Some(dialog);
}
// Setzt die im Dialog gewählten Rechte und schließt den Dialog.
fn rechte_anwenden(&mut self) -> Task<Message> {
let Some(dialog) = self.dialog.take() else {
return Task::none();
};
if let Some(ftp) = dialog.server {
self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name);
let aufgabe = server_rechte_setzen(ftp, dialog.pfad, dialog.rechte, dialog.name);
return Task::perform(aufgabe, Message::Fertig);
}
self.meldung = match rechte_setzen(&dialog.pfad, dialog.rechte) {
Ok(()) => format!(
"Rechte von „{}“ sind jetzt {} ({:03o}).",
dialog.name,
rechte_text(dialog.rechte),
dialog.rechte
),
Err(fehler) => format!("Rechte ändern fehlgeschlagen: {}", fehler),
};
self.links.neu_lesen();
self.rechts.neu_lesen();
Task::none()
}
// Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
fn kopieren(&mut self) -> Task<Message> {
let (quelle, ziel) = self.quelle_und_ziel();
let Some(eintrag) = quelle.markierter_eintrag() else {
return Task::none();
};
if eintrag.name == ".." {
return Task::none();
}
// Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
let name = eintrag.name.clone();
let ist_ordner = eintrag.ist_ordner;
let groesse = eintrag.groesse;
let rechte = eintrag.rechte;
let von = quelle.unterpfad(&name);
let nach = ziel.unterpfad(&name);
let zielordner = ziel.ordner.display().to_string();
let schon_da = ziel.eintraege.iter().any(|anderer| anderer.name == name);
let server_ziel = ziel.server.clone();
let server_quelle = quelle.server.clone();
if schon_da {
self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner);
return Task::none();
}
if let Some(ftp) = server_ziel {
let zaehler = Arc::new(AtomicU64::new(0));
self.laufend = Some(Laufend {
titel: format!("Lade „{}“ hoch", name),
zaehler: zaehler.clone(),
gesamt: Some(lokale_groesse(&von)),
start: Instant::now(),
});
return Task::perform(hochladen(ftp, von, nach, name, zaehler), Message::Fertig);
}
if let Some(ftp) = server_quelle {
let zaehler = Arc::new(AtomicU64::new(0));
self.laufend = Some(Laufend {
titel: format!("Lade „{}“ herunter", name),
zaehler: zaehler.clone(),
gesamt: if ist_ordner { None } else { Some(groesse) },
start: Instant::now(),
});
let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name, zaehler);
return Task::perform(aufgabe, Message::Fertig);
}
// Beide Panels auf diesem Rechner: kopieren wie bisher
self.meldung = if nach.exists() {
format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner)
} else if ist_ordner && nach.starts_with(&von) {
format!("„{}“ kann nicht in sich selbst kopiert werden.", name)
} else if ist_ordner {
match ordner_kopieren(&von, &nach) {
Ok(anzahl) => format!(
"Ordner kopiert: {} mit {} Dateien → {}",
name, anzahl, zielordner
),
Err(fehler) => format!("Ordner kopieren fehlgeschlagen: {}", fehler),
}
} else {
match fs::copy(&von, &nach) {
Ok(_) => format!("Kopiert: {} → {}", name, zielordner),
Err(fehler) => format!("Kopieren fehlgeschlagen: {}", fehler),
}
};
self.inaktives_panel().neu_lesen();
Task::none()
}
fn view(&self) -> Element<'_, Message> {
let menuzeile = container(text(" Links Datei Befehle Optionen Rechts"))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
let panels = row![
self.links.ansicht(self.aktiv == Seite::Links),
self.rechts.ansicht(self.aktiv == Seite::Rechts),
]
.spacing(6);
let arbeitsflaeche = container(panels)
.width(Fill)
.height(Fill)
.padding(6)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(BLAU)),
text_color: Some(WEISS),
..container::Style::default()
});
let meldung = match &self.laufend {
Some(laufend) => fortschritt_text(laufend),
None => self.meldung.clone(),
};
let hintergrund = if self.fehler { DUNKELROT } else { SCHWARZ };
let meldungszeile = container(text(meldung).color(WEISS).wrapping(text::Wrapping::None))
.width(Fill)
.padding([0, 6])
.style(move |_theme: &Theme| container::Style {
background: Some(Background::Color(hintergrund)),
..container::Style::default()
});
let tastenleiste = row![
f_taste(1, "Hilfe"),
f_taste(2, "Verbind."),
f_taste(3, "Ansicht"),
f_taste(4, "Bearb."),
f_taste(5, "Kopie"),
f_taste(6, "Versch."),
f_taste(7, "Ordner"),
f_taste(8, "Lösch."),
f_taste(9, "Rechte"),
f_taste(10, "Ende"),
]
.spacing(4);
let tastenleiste = container(tastenleiste)
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(SCHWARZ)),
..container::Style::default()
});
let hauptteil = column![menuzeile, arbeitsflaeche, meldungszeile, tastenleiste];
// Ist ein Dialog offen, liegt er mittig über allem anderen
if let Some(zugang) = &self.verbindungsdialog {
stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()
} else if let Some(dialog) = &self.dialog {
stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
} else {
hauptteil.into()
}
}
}
// Ein Panel: ein Ordner, seine Einträge und der Auswahlbalken.
struct Panel {
ordner: PathBuf,
eintraege: Vec<Eintrag>,
cursor: usize, // Nummer des markierten Eintrags
erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
server: Option<Server>, // Some, wenn das Panel einen Server zeigt
}
impl Panel {
// Legt ein Panel an, das den angegebenen Ordner zeigt.
fn neu(ordner: PathBuf) -> Self {
let eintraege = ordner_lesen(&ordner);
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: None,
}
}
// Legt ein Panel an, das einen Ordner auf dem Server zeigt.
fn entfernt(ftp: Server, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: Some(ftp),
}
}
// Balken eine Zeile nach oben
fn hoch(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
self.sichtbar_halten();
}
// Balken eine Zeile nach unten
fn runter(&mut self) {
if self.cursor + 1 < self.eintraege.len() {
self.cursor += 1;
}
self.sichtbar_halten();
}
// Liefert den Eintrag, auf dem der Balken steht, falls es einen gibt.
fn markierter_eintrag(&self) -> Option<&Eintrag> {
self.eintraege.get(self.cursor)
}
// Liest den Ordner neu ein, zum Beispiel nachdem eine Datei dazugekommen ist.
fn neu_lesen(&mut self) {
if self.server.is_some() {
return; // Server-Ordner liest der Fährmann im Hintergrund, nicht hier
}
self.eintraege = ordner_lesen(&self.ordner);
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Schiebt den sichtbaren Ausschnitt so, dass der Balken immer zu sehen ist.
fn sichtbar_halten(&mut self) {
if self.cursor < self.erste_zeile {
self.erste_zeile = self.cursor;
}
if self.cursor >= self.erste_zeile + ZEILEN {
self.erste_zeile = self.cursor + 1 - ZEILEN;
}
}
// Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.
// (Nur für Ordner auf diesem Rechner. Server-Ordner liest der Fährmann im Hintergrund.)
fn oeffnen(&mut self) {
let Some(ziel) = self.ziel_ordner() else {
return;
};
let eintraege = ordner_lesen(&ziel);
self.eintraege_setzen(ziel, eintraege);
}
// Wohin führt Enter? Liefert den Ordner, falls der Balken auf einem Ordner steht.
fn ziel_ordner(&self) -> Option<PathBuf> {
let eintrag = self.markierter_eintrag()?;
if !eintrag.ist_ordner {
return None; // Dateien öffnen wir (noch) nicht
}
if eintrag.name == ".." {
self.ordner.parent().map(|oben| oben.to_path_buf())
} else {
Some(self.unterpfad(&eintrag.name))
}
}
// Der Pfad eines Eintrags in diesem Panel. Auf dem Server immer mit "/".
fn unterpfad(&self, name: &str) -> PathBuf {
if self.server.is_some() {
let ordner = pfad_text(&self.ordner);
PathBuf::from(format!("{}/{}", ordner.trim_end_matches('/'), name))
} else {
self.ordner.join(name)
}
}
// Zeigt neue Einträge an. Bleibt der Ordner derselbe, bleibt auch der Balken stehen.
fn eintraege_setzen(&mut self, ordner: PathBuf, eintraege: Vec<Eintrag>) {
if ordner != self.ordner {
self.cursor = 0;
self.erste_zeile = 0;
}
self.ordner = ordner;
self.eintraege = eintraege;
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Zeichnet das Panel. Nur das aktive Panel zeigt den Balken und einen hellen Kopf.
fn ansicht(&self, aktiv: bool) -> Element<'_, Message> {
let kopffarbe = if aktiv { SCHWARZ } else { GELB };
let titel = match &self.server {
Some(ftp) => format!("{}:{}", ftp.name, pfad_text(&self.ordner)),
None => self.ordner.display().to_string(),
};
let kopf = container(text(titel).color(kopffarbe))
.style(move |_theme: &Theme| container::Style {
background: if aktiv {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
});
let mut liste = column![];
for (nr, eintrag) in self
.eintraege
.iter()
.enumerate()
.skip(self.erste_zeile)
.take(ZEILEN)
{
liste = liste.push(zeile(eintrag, aktiv && nr == self.cursor));
}
container(column![kopf, liste].spacing(6))
.width(Fill)
.height(Fill)
.padding(8)
.clip(true)
.style(|_theme: &Theme| container::Style {
border: Border {
color: TUERKIS,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
})
.into()
}
}
// Baut eine einzelne Taste der Funktionstastenleiste, z. B. "5 Kopie".
fn f_taste(nummer: u8, beschriftung: &'static str) -> Element<'static, Message> {
let zahl = text(nummer).color(WEISS);
let feld = container(text(beschriftung))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
row![zahl, feld].width(Fill).into()
}
// Liest einen Ordner und liefert seine Einträge: oben "..", dann Ordner, dann Dateien.
fn ordner_lesen(pfad: &Path) -> Vec<Eintrag> {
let mut eintraege = Vec::new();
let Ok(inhalt) = fs::read_dir(pfad) else {
return eintraege; // Ordner nicht lesbar: leere Liste
};
for eintrag in inhalt.flatten() {
let name = eintrag.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue; // versteckte Dateien überspringen
}
let Ok(info) = eintrag.metadata() else {
continue; // keine Infos lesbar: Eintrag überspringen
};
eintraege.push(Eintrag {
name,
ist_ordner: info.is_dir(),
groesse: info.len(),
rechte: rechte_lesen(&info),
});
}
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
eintraege
}
// Sortiert eine Liste (Ordner zuerst, dann alphabetisch) und setzt ".." an den Anfang.
fn liste_fertigmachen(eintraege: &mut Vec<Eintrag>, hat_eltern: bool) {
eintraege.sort_by(|a, b| {
b.ist_ordner
.cmp(&a.ist_ordner)
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
if hat_eltern {
let hoch = Eintrag {
name: String::from(".."),
ist_ordner: true,
groesse: 0,
rechte: 0,
};
eintraege.insert(0, hoch);
}
}
// Fragt den Server nach dem Inhalt eines Ordners (ohne "..").
async fn ftp_eintraege(strom: &mut AsyncFtpStream, pfad: &str) -> Result<Vec<Eintrag>, String> {
let zeilen = strom.list(Some(pfad)).await.map_err(|e| e.to_string())?;
let mut eintraege = Vec::new();
for zeile in zeilen {
let Ok(datei) = File::try_from(zeile.as_str()) else {
continue; // eine Zeile, die wir nicht verstehen: überspringen
};
if datei.name().starts_with('.') {
continue;
}
eintraege.push(Eintrag {
name: datei.name().to_string(),
ist_ordner: datei.is_directory(),
groesse: datei.size() as u64,
rechte: ftp_rechte(&datei),
});
}
Ok(eintraege)
}
// Liest die Rechte aus einer Zeile der Serverliste.
fn ftp_rechte(datei: &File) -> u32 {
let mut rechte = 0;
let gruppen = [PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others];
for (reihe, wer) in gruppen.into_iter().enumerate() {
if datei.can_read(wer) {
rechte |= bit(reihe * 3);
}
if datei.can_write(wer) {
rechte |= bit(reihe * 3 + 1);
}
if datei.can_execute(wer) {
rechte |= bit(reihe * 3 + 2);
}
}
rechte
}
// Macht aus einem Pfad einen Text mit "/" als Trenner, so wie Server ihn erwarten.
fn pfad_text(pfad: &Path) -> String {
pfad.to_string_lossy().replace('\\', "/")
}
// Verbindet sich mit dem Server, meldet sich an und liest den Startordner.
async fn ftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let adresse = format!("{}:{}", zugang.host.trim(), zugang.port.trim());
let mut strom = AsyncFtpStream::connect(adresse.as_str())
.await
.map_err(|e| e.to_string())?;
strom
.login(&zugang.benutzer, &zugang.passwort)
.await
.map_err(|e| e.to_string())?;
strom
.transfer_type(FileType::Binary)
.await
.map_err(|e| e.to_string())?;
let start = strom.pwd().await.map_err(|e| e.to_string())?;
let mut eintraege = ftp_eintraege(&mut strom, &start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let ftp = Server {
strom: Arc::new(Mutex::new(Strom::Ftp(strom))),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: None,
};
Ok((ftp, PathBuf::from(start), eintraege))
}
// Verbindet sich per SSH, meldet sich an, öffnet SFTP und liest den Startordner.
async fn sftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let port: u16 = zugang
.port
.trim()
.parse()
.map_err(|_| format!("„{}“ ist kein gültiger Port.", zugang.port))?;
let fingerabdruck = Arc::new(std::sync::Mutex::new(String::new()));
let pruefer = Pruefer {
fingerabdruck: fingerabdruck.clone(),
};
let einstellungen = Arc::new(russh::client::Config::default());
let mut sitzung = russh::client::connect(einstellungen, (zugang.host.trim(), port), pruefer)
.await
.map_err(als_text)?;
let anmeldung = sitzung
.authenticate_password(&zugang.benutzer, &zugang.passwort)
.await
.map_err(als_text)?;
if !anmeldung.success() {
return Err(String::from("Benutzername oder Passwort stimmt nicht."));
}
let kanal = sitzung.channel_open_session().await.map_err(als_text)?;
kanal.request_subsystem(true, "sftp").await.map_err(als_text)?;
let sftp = SftpSession::new(kanal.into_stream()).await.map_err(als_text)?;
let start = sftp.canonicalize(".").await.map_err(als_text)?;
let mut strom = Strom::Sftp {
sftp,
_sitzung: sitzung,
};
let mut eintraege = strom.eintraege(&start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let server = Server {
strom: Arc::new(Mutex::new(strom)),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: Some(fingerabdruck.lock().unwrap().clone()),
};
Ok((server, PathBuf::from(start), eintraege))
}
// Liest einen Ordner auf dem Server.
async fn server_lesen(ftp: Server, pfad: PathBuf) -> Result<(PathBuf, Vec<Eintrag>), String> {
let mut strom = ftp.strom.lock().await;
let mut eintraege = strom.eintraege(&pfad_text(&pfad)).await?;
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
Ok((pfad, eintraege))
}
// Beantwortet beim SSH-Verbindungsaufbau die Frage: "Ist das wirklich der richtige Server?"
// Wir merken uns dabei den Fingerabdruck des Servers, um ihn anzuzeigen.
struct Pruefer {
fingerabdruck: Arc<std::sync::Mutex<String>>,
}
impl russh::client::Handler for Pruefer {
type Error = russh::Error;
async fn check_server_key(
&mut self,
schluessel: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
if let PublicKeyOrCertificate::PublicKey { key, .. } = schluessel {
*self.fingerabdruck.lock().unwrap() = key.fingerprint(HashAlg::Sha256).to_string();
}
Ok(true) // Wir vertrauen jedem Server. Mehr dazu im Kasten in Schritt 5!
}
}
// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache.
enum Strom {
Ftp(AsyncFtpStream),
Sftp {
sftp: SftpSession,
_sitzung: russh::client::Handle<Pruefer>, // hält die SSH-Verbindung am Leben
},
}
// Was man mit jeder Leitung tun kann, egal welche Sprache der Server spricht.
impl Strom {
// Liest einen Ordner (ohne "..").
async fn eintraege(&mut self, pfad: &str) -> Result<Vec<Eintrag>, String> {
match self {
Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await,
Strom::Sftp { sftp, .. } => {
let mut eintraege = Vec::new();
for eintrag in sftp.read_dir(pfad).await.map_err(als_text)? {
let name = eintrag.file_name();
if name.starts_with('.') {
continue;
}
let info = eintrag.metadata();
eintraege.push(Eintrag {
name,
ist_ordner: eintrag.file_type().is_dir(),
groesse: info.size.unwrap_or(0),
rechte: info.permissions.unwrap_or(0) & 0o777,
});
}
Ok(eintraege)
}
}
}
// Legt einen Ordner an.
async fn ordner_anlegen(&mut self, pfad: &str) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text),
Strom::Sftp { sftp, .. } => sftp.create_dir(pfad).await.map_err(als_text),
}
}
// Schickt eine Datei von diesem Rechner zum Server.
async fn datei_hoch(
&mut self,
von: &Path,
nach: &str,
zaehler: &AtomicU64,
) -> Result<(), String> {
let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
let mut ziel = ftp.put_with_stream(nach).await.map_err(als_text)?;
zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
ziel.finish().await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut ziel = sftp.create(nach).await.map_err(als_text)?;
zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
ziel.shutdown().await.map_err(als_text)?;
}
}
Ok(())
}
// Holt eine Datei vom Server auf diesen Rechner.
async fn datei_runter(
&mut self,
von: &str,
nach: &Path,
zaehler: &AtomicU64,
) -> Result<(), String> {
let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;
zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
quelle.finish().await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut quelle = sftp.open(von).await.map_err(als_text)?;
zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
}
}
Ok(())
}
// Setzt die Rechte. FTP braucht dafür SITE CHMOD, das nicht jeder Server kann.
async fn rechte_setzen(&mut self, pfad: &str, rechte: u32) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => {
let befehl = format!("CHMOD {:03o} {}", rechte, pfad);
ftp.site(befehl).await.map(|_| ()).map_err(als_text)
}
Strom::Sftp { sftp, .. } => {
let mut attribute = FileAttributes::empty();
attribute.permissions = Some(rechte);
sftp.set_metadata(pfad, attribute).await.map_err(als_text)
}
}
}
}
// Was bei einer Übertragung zusammengekommen ist.
#[derive(Default)]
struct Bilanz {
dateien: u64,
ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließen
zaehler: Arc<AtomicU64>, // übertragene Bytes, laufend mitgezählt
}
impl Bilanz {
// Macht aus der Bilanz eine Meldung für die Meldungszeile.
fn text(&self, name: &str, wie: &str) -> String {
let mut text = format!("„{}“ {}: {} Datei(en).", name, wie, self.dateien);
if self.ohne_rechte > 0 {
text += &format!(
" Bei {} Einträgen ließen sich die Rechte nicht übernehmen.",
self.ohne_rechte
);
}
text
}
}
// Macht aus jedem Fehler einen Text. So haben alle Fehler dieselbe Form.
fn als_text<E: fmt::Display>(fehler: E) -> String {
fehler.to_string()
}
// Kopiert von einer Quelle in ein Ziel und zählt dabei jedes Byte mit.
async fn zaehlend_kopieren<Q, Z>(quelle: &mut Q, ziel: &mut Z, zaehler: &AtomicU64) -> Result<(), String>
where
Q: AsyncRead + Unpin,
Z: AsyncWrite + Unpin,
{
let mut puffer = vec![0u8; 64 * 1024];
loop {
let gelesen = quelle.read(&mut puffer).await.map_err(als_text)?;
if gelesen == 0 {
return Ok(()); // Quelle leer: fertig
}
ziel.write_all(&puffer[..gelesen]).await.map_err(als_text)?;
zaehler.fetch_add(gelesen as u64, Ordering::Relaxed);
}
}
// Der Rückgabetyp einer Übertragung: eine Aufgabe, die später fertig wird.
type Uebertragung<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
// Bringt eine Datei oder einen ganzen Ordner auf den Server, samt Rechten.
fn hoch<'a>(
strom: &'a mut Strom,
von: &'a Path,
nach: &'a str,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
let info = fs::metadata(von).map_err(als_text)?;
if info.is_dir() {
strom.ordner_anlegen(nach).await?;
for eintrag in fs::read_dir(von).map_err(als_text)? {
let eintrag = eintrag.map_err(als_text)?;
if eintrag.file_type().map_err(als_text)?.is_symlink() {
continue; // Verknüpfungen lassen wir aus
}
let name = eintrag.file_name().to_string_lossy().to_string();
hoch(strom, &eintrag.path(), &format!("{}/{}", nach, name), bilanz).await?;
}
} else {
strom.datei_hoch(von, nach, &bilanz.zaehler).await?;
bilanz.dateien += 1;
}
// Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen.
if strom.rechte_setzen(nach, rechte_lesen(&info)).await.is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt hoch und meldet das Ergebnis.
async fn hochladen(
ftp: Server,
von: PathBuf,
nach: PathBuf,
name: String,
zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz {
zaehler,
..Bilanz::default()
};
hoch(&mut strom, &von, &pfad_text(&nach), &mut bilanz)
.await
.map_err(|fehler| format!("Hochladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "hochgeladen"))
}
// Holt eine Datei oder einen ganzen Ordner vom Server, samt Rechten.
fn runter<'a>(
strom: &'a mut Strom,
von: &'a str,
nach: &'a Path,
ist_ordner: bool,
rechte: u32,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
if ist_ordner {
fs::create_dir(nach).map_err(als_text)?;
for eintrag in strom.eintraege(von).await? {
let unter_von = format!("{}/{}", von, eintrag.name);
let unter_nach = nach.join(&eintrag.name);
runter(strom, &unter_von, &unter_nach, eintrag.ist_ordner, eintrag.rechte, bilanz)
.await?;
}
} else {
strom.datei_runter(von, nach, &bilanz.zaehler).await?;
bilanz.dateien += 1;
}
// Die Rechte vom Server übernehmen, bei Ordnern erst ganz zum Schluss
if rechte_setzen(nach, rechte).is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt herunter und meldet das Ergebnis.
async fn herunterladen(
ftp: Server,
von: PathBuf,
nach: PathBuf,
ist_ordner: bool,
rechte: u32,
name: String,
zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz {
zaehler,
..Bilanz::default()
};
runter(&mut strom, &pfad_text(&von), &nach, ist_ordner, rechte, &mut bilanz)
.await
.map_err(|fehler| format!("Herunterladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "heruntergeladen"))
}
// Ändert die Rechte einer Datei oder eines Ordners auf dem Server.
async fn server_rechte_setzen(ftp: Server, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
match strom.rechte_setzen(&pfad_text(&pfad), rechte).await {
Ok(()) => Ok(format!(
"Rechte von „{}“ auf dem Server sind jetzt {} ({:03o}).",
name,
rechte_text(rechte),
rechte
)),
Err(fehler) => Err(format!("Der Server lehnt neue Rechte für „{}“ ab: {}", name, fehler)),
}
}
// Baut eine Zeile im Panel: links der Name, rechts die Größe.
fn zeile(eintrag: &Eintrag, markiert: bool) -> Element<'_, Message> {
let farbe = if markiert {
SCHWARZ
} else if eintrag.ist_ordner {
WEISS
} else {
HELLTUERKIS
};
let groesse = if eintrag.ist_ordner {
String::from("<ORDNER>")
} else {
groesse_text(eintrag.groesse)
};
let rechte = if eintrag.name == ".." {
String::new()
} else {
rechte_text(eintrag.rechte)
};
let inhalt = row![
text(eintrag.name.as_str())
.color(farbe)
.width(Fill)
.wrapping(text::Wrapping::None),
text(rechte).color(farbe),
text(groesse).color(farbe).width(90).align_x(Right),
]
.spacing(12);
// Der markierte Eintrag bekommt den türkisen Balken
container(inhalt)
.width(Fill)
.style(move |_theme: &Theme| container::Style {
background: if markiert {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
})
.into()
}
// Macht aus einer Zahl von Bytes etwas Lesbares, zum Beispiel "12,3 KB".
fn groesse_text(bytes: u64) -> String {
let b = bytes as f64;
let text = if bytes < 1024 {
format!("{} B", bytes)
} else if b < 1024.0 * 1024.0 {
format!("{:.1} KB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MB", b / 1024.0 / 1024.0)
} else {
format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0)
};
text.replace('.', ",")
}
// Baut die Fortschrittsanzeige, zum Beispiel: Lade „Fotos“ hoch [██████░░░░] 60 % 3,1 MB/s
fn fortschritt_text(laufend: &Laufend) -> String {
let bytes = laufend.zaehler.load(Ordering::Relaxed);
let sekunden = laufend.start.elapsed().as_secs_f64().max(0.1);
let tempo = groesse_text((bytes as f64 / sekunden) as u64);
match laufend.gesamt {
Some(gesamt) if gesamt > 0 => {
let anteil = (bytes as f64 / gesamt as f64).min(1.0);
let voll = (anteil * 20.0) as usize;
let balken = "█".repeat(voll) + &"░".repeat(20 - voll);
format!("{} [{}] {:3.0} % {}/s", laufend.titel, balken, anteil * 100.0, tempo)
}
_ => format!("{} … bisher {}, {}/s", laufend.titel, groesse_text(bytes), tempo),
}
}
// Wie viele Bytes stecken in einer Datei oder in einem ganzen Ordner?
fn lokale_groesse(pfad: &Path) -> u64 {
let Ok(info) = fs::symlink_metadata(pfad) else {
return 0;
};
if !info.is_dir() {
return info.len();
}
let Ok(inhalt) = fs::read_dir(pfad) else {
return 0;
};
inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum()
}
// Übersetzt technische Fehlermeldungen in verständliche Sätze. Das Original steht in Klammern dahinter.
fn freundlich(fehler: &str) -> String {
let klein = fehler.to_lowercase();
let erklaerung = if klein.contains("connection refused") {
"Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?"
} else if klein.contains("lookup") || klein.contains("not known") || klein.contains("no such host") {
"Einen Server mit diesem Namen gibt es nicht. Ist die Adresse richtig geschrieben?"
} else if klein.contains("connection reset") || klein.contains("broken pipe") {
"Die Verbindung ist mittendrin abgerissen. Ist der Server noch da, oder ist sein Speicher voll?"
} else if klein.contains("timed out") {
"Der Server antwortet nicht. Vielleicht blockiert eine Firewall die Verbindung."
} else if klein.contains("530") {
"Benutzername oder Passwort stimmt nicht."
} else if klein.contains("permission denied") || klein.contains("550") || klein.contains("privileges") {
"Dafür fehlen die Rechte, oder die Datei gibt es nicht."
} else if klein.contains("no such file") {
"Die Datei oder der Ordner existiert nicht (mehr)."
} else if klein.contains("no space") {
"Der Speicherplatz ist voll."
} else {
return fehler.to_string(); // unbekannt: lieber das Original als eine falsche Erklärung
};
format!("{} ({})", erklaerung, fehler)
}
// Kopiert einen ganzen Ordner mit allem, was darin liegt, und liefert die Zahl der Dateien.
// Die Rechte jeder Datei und jedes Ordners werden dabei mitgenommen.
fn ordner_kopieren(von: &Path, nach: &Path) -> io::Result<u64> {
fs::create_dir(nach)?;
let mut dateien = 0;
for eintrag in fs::read_dir(von)? {
let eintrag = eintrag?;
let art = eintrag.file_type()?;
let ziel = nach.join(eintrag.file_name());
if art.is_symlink() {
continue; // Verknüpfungen lassen wir aus
} else if art.is_dir() {
dateien += ordner_kopieren(&eintrag.path(), &ziel)?;
} else {
fs::copy(eintrag.path(), &ziel)?; // nimmt die Rechte der Datei mit
dateien += 1;
}
}
// Die Rechte des Ordners erst zum Schluss setzen: Ein schreibgeschützter
// Ordner ließe sich sonst gar nicht mehr befüllen.
fs::set_permissions(nach, fs::metadata(von)?.permissions())?;
Ok(dateien)
}
// Liefert die Nummer eines Rechte-Bits: 0 ist "Besitzer darf lesen", 8 ist "Andere dürfen ausführen".
fn bit(nr: usize) -> u32 {
1 << (8 - nr)
}
// Macht aus den Rechte-Bits einen Text wie "rwxr-xr-x".
fn rechte_text(rechte: u32) -> String {
let zeichen = ['r', 'w', 'x'];
let mut text = String::new();
for nr in 0..9 {
if rechte & bit(nr) != 0 {
text.push(zeichen[nr % 3]);
} else {
text.push('-');
}
}
text
}
// Liest die Rechte aus den Infos einer Datei.
fn rechte_lesen(info: &fs::Metadata) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
info.permissions().mode() & 0o777
}
#[cfg(not(unix))]
{
// Windows kennt nur "schreibgeschützt" oder nicht
if info.permissions().readonly() { 0o444 } else { 0o666 }
}
}
// Setzt die Rechte einer Datei oder eines Ordners.
fn rechte_setzen(pfad: &Path, rechte: u32) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(pfad, fs::Permissions::from_mode(rechte))
}
#[cfg(not(unix))]
{
let mut erlaubnis = fs::metadata(pfad)?.permissions();
erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
fs::set_permissions(pfad, erlaubnis)
}
}
// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(GRAU)),
text_color: Some(SCHWARZ),
border: Border {
color: SCHWARZ,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
}
}
// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {
let inhalt = column![
text("Mit einem Server verbinden"),
text(if zugang.sftp {
"Protokoll: SFTP (verschlüsselt) · F3 wechselt"
} else {
"Protokoll: FTP (unverschlüsselt) · F3 wechselt"
}),
eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
]
.spacing(12);
container(inhalt)
.padding(18)
.width(480)
.style(dialog_stil)
.into()
}
// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
beschriftung: &'a str,
wert: &'a str,
feld: Feld,
id: &'static str,
geheim: bool,
) -> Element<'a, Message> {
row![
text(beschriftung).width(110),
text_input("", wert)
.id(id)
.secure(geheim)
.on_input(move |neu| Message::Eingabe(feld, neu))
.on_submit(Message::Verbinden)
.padding(4),
]
.align_y(Center)
.into()
}
// Zeichnet den Rechte-Dialog als graue Box im Commander-Stil.
fn dialog_ansicht(dialog: &RechteDialog) -> Element<'_, Message> {
let mut gitter = column![row![
text("").width(110),
text("Lesen").width(110),
text("Schreiben").width(110),
text("Ausführen").width(110),
]]
.spacing(4);
for (reihe_nr, wer) in ["Besitzer", "Gruppe", "Andere"].iter().enumerate() {
let mut reihe = row![text(*wer).width(110)];
for spalte in 0..3 {
let nr = reihe_nr * 3 + spalte;
let an = dialog.rechte & bit(nr) != 0;
let gewaehlt = nr == dialog.auswahl;
let kaestchen = text(if an { "[x]" } else { "[ ]" })
.color(if gewaehlt { WEISS } else { SCHWARZ });
reihe = reihe.push(container(kaestchen).width(110).style(move |_theme: &Theme| {
container::Style {
background: if gewaehlt {
Some(Background::Color(SCHWARZ))
} else {
None
},
..container::Style::default()
}
}));
}
gitter = gitter.push(reihe);
}
let inhalt = column![
text(format!("Rechte für „{}“", dialog.name)),
gitter,
text(format!("Zahlencode: {:03o}", dialog.rechte)),
text("Pfeile: wählen · Leertaste: an/aus · Enter: übernehmen · Esc: abbrechen").size(13),
]
.spacing(14);
container(inhalt)
.padding(18)
.style(dialog_stil)
.into()
}
// Übersetzt einen Tastendruck in eine Nachricht, oder in gar nichts.
fn taste(ereignis: keyboard::Event) -> Option<Message> {
let keyboard::Event::KeyPressed { key, .. } = ereignis else {
return None; // Taste losgelassen oder Ähnliches: interessiert uns nicht
};
match key {
Key::Named(Named::ArrowUp) => Some(Message::Hoch),
Key::Named(Named::ArrowDown) => Some(Message::Runter),
Key::Named(Named::Enter) => Some(Message::Oeffnen),
Key::Named(Named::Tab) => Some(Message::Wechseln),
Key::Named(Named::F5) => Some(Message::Kopieren),
Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
Key::Named(Named::F3) => Some(Message::ProtokollWechseln),
Key::Named(Named::F9) => Some(Message::RechteZeigen),
Key::Named(Named::ArrowLeft) => Some(Message::PfeilLinks),
Key::Named(Named::ArrowRight) => Some(Message::PfeilRechts),
Key::Named(Named::Space) => Some(Message::Umschalten),
Key::Named(Named::Escape) => Some(Message::Abbrechen),
Key::Named(Named::F10) => Some(Message::Beenden),
_ => None,
}
}
AtomicU64) lässt mehrere Aufgaben gleichzeitig dieselbe Zahl benutzen, ohne
Mutex. Eine eigene Kopierschleife mit Puffer und Slice zählt jedes
Byte mit. Generische Funktionen mit where-Bedingungen funktionieren für alles,
was die Bedingungen erfüllt. time::every ist ein Uhr-Abonnement, und
Subscription::batch hört auf mehrere Quellen. Fehler übersetzt man am besten
vorsichtig: bekannte erklären, unbekannte unverändert lassen, das Original immer zeigen.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.
- Restzeit: Ergänze die Anzeige um eine geschätzte Restzeit, etwa „noch 12 s“. Tipp: Restliche Bytes durch Tempo.
- Wörterbuch: Schreib eine absichtlich falsche Adresse wie
localhost:99999in den Dialog. Was meldet der Fährmann? Bring dem Übersetzer einen passenden Satz bei. - Auch lokal: Lokales Kopieren meldet Fehler noch schwarz. Lass auch diese Fehler rot erscheinen.
Lösungen anzeigen
1. Im Zweig mit bekannter Gesamtgröße: let rest = (gesamt.saturating_sub(bytes) as f64 /
(bytes as f64 / sekunden).max(1.0)) as u64; und dann format!("… noch {} s", rest)
anhängen. Das .max(1.0) verhindert eine Division durch 0, solange noch nichts
übertragen ist.
2. Bei FTP scheitert das Umwandeln des Ports in der Bibliothek, bei SFTP schon in unserem
eigenen parse(), das „ist kein gültiger Port“ meldet. Für FTP könnte der
Übersetzer etwa auf invalid port reagieren. Noch besser: Auch
ftp_verbinden prüft den Port vorher mit parse::<u16>(), genau
wie sftp_verbinden.
3. In kopieren im lokalen Teil die Ergebnisse unterscheiden: Statt
self.meldung = if … die Fehlerzweige mit
self.fehler_melden(freundlich(&fehler.to_string())) behandeln. Am
übersichtlichsten wird es, wenn der lokale Teil ein Result<String, String>
baut und am Ende genau wie fertig entscheidet.
Etappe 12 geschafft 📊
Der Fährmann zeigt jetzt, wie weit die Fahrt ist, und spricht im Fehlerfall verständlich mit dir. In der letzten Etappe merkt er sich deine Verbindungen, damit du nicht jedes Mal alles neu eintippen musst.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.
☕ Kaffee spendieren