Files
dionotes-client/docs/research/nativer-ordner-dialog.md
MDietermann 8eb0e3325b docs: Rechercheergebnisse zu Ordner-Dialog und Konfigurationsablage uebernehmen
Beide Recherchen lagen bisher nur auf lokalen Wegwerf-Branches. Die
Kurzfassung steht an den Tickets #2 und #3, die Langfassungen sind mit
Quellenangaben und einer Trennung zwischen Belegtem und Vermutetem aber das,
worauf man zurueckkommt, wenn spaeter jemand fragt, warum unter Windows Local
statt Roaming gewaehlt wurde.

Damit sind research/ordner-dialog und research/config-ablageort ueberfluessig
und koennen geloescht werden.
2026-08-27 15:28:28 +02:00

24 KiB

Nativer Ordner-Dialog unter Dioxus 0.7 Desktop

Research zu Gitea-Issue #2. Stand: 2026-08-27. Recherchiert gegen Primärquellen (docs.rs, crates.io-API, Quellcode auf GitHub, offizielle Dioxus-Doku). Nichts davon wurde kompiliert — im Repo wurde bewusst keine Dependency hinzugefügt. Alles unter „Belegt" ist aus Signaturen/Quellcode/Manifesten abgeleitet, nicht aus einem laufenden Build.

Kurzantwort

Die zugrundeliegende Bibliothek ist in beiden Fällen rfd („Rusty File Dialogs"), 0.17.2 — aber es gibt zwei Wege, und der bequemere war zunächst übersehen worden:

Weg A — ohne neue Dependency (offiziell dokumentiert). input { r#type: "file", directory: true, onchange: … } und der Pfad kommt aus evt.files()[0].path() -> PathBuf. dioxus-desktop ruft dahinter intern rfd::AsyncFileDialog::pick_folder() auf. Kostet null zusätzliche Abhängigkeiten. Nachteil: braucht ein DOM-Element und einen echten Nutzer-Klick darauf — nicht aus beliebigem Rust-Code heraus auslösbar.

Weg B — rfd direkt als eigene Dependency. rfd::AsyncFileDialog::new().pick_folder().await in einem onclick-Handler. Der einzige Weg für einen Dialog, den man programmatisch startet. Kostet praktisch nichts, weil rfd 0.17.2 über dioxus-desktop ohnehin schon im Dependency-Graph steht (siehe Abschnitt 2).

Abbruch liefert in beiden Fällen None. Unter Windows läuft beides ohne Extra-Abhängigkeit und ohne Feature-Flag — nur Linux bringt zusätzliche Anforderungen mit.

Korrektur zur ersten Fassung dieses Dokuments: Dort stand „Dioxus selbst bringt keine Datei-/Ordner-Dialog-API mit". Das war falsch — der directory-Attributweg (Weg A) ist in der offiziellen 0.7-Doku beschrieben. Richtig bleibt: dioxus-desktop re-exportiert rfd nicht, und dioxus-sdk hat kein Dialog-Modul.


1. Welche Crate?

Belegt

Was es nicht gibt

  • Das offizielle dioxus-sdk hat kein Dialog-Modul. Aktuell dioxus-sdk 0.7.0 (2025-11-06), Repo https://github.com/DioxusLabs/sdk/. Die vollständige Feature-Liste der Crate lautet: geolocation, notification, storage, sync, time, util, window — kein file, kein dialog, kein fs. → https://crates.io/api/v1/crates/dioxus-sdk
  • dioxus-desktop re-exportiert rfd nicht. Die Crate benutzt rfd intern (siehe Weg A in Abschnitt 3a), aber mod file_upload; ist privat und die Re-Export-Liste in lib.rs enthält nur tao, wry, muda, trayicon, launch, AssetRequest, Config, DesktopContext, DesktopService, WryEventHandler, hooks::*, Shortcut-Typen und wry::RequestAsyncResponder. Ein dioxus_desktop::rfd gibt es also nicht — wer rfd direkt aufrufen will, muss es selbst in die Cargo.toml schreiben. → https://github.com/DioxusLabs/dioxus/blob/v0.7.1/packages/desktop/src/lib.rs

Korrigiert gegenüber der ersten Fassung

Ein reiner Webview-/HTML-Weg existiert doch und ist offiziell dokumentiert — er ist nur kein „Webview-eigener" Dialog, sondern eine Dioxus-Eigenheit, die auf den nativen rfd-Dialog abgebildet wird. Siehe Abschnitt 3a.


2. Kompatibilität — und: rfd ist bereits im Graph

Belegt: die tatsächlich aufgelöste Version ist 0.7.10, nicht 0.7.1

Cargo.toml deklariert dioxus = "0.7.1", das ist ein Caret-Requirement. Aufgelöst wird in Cargo.lock tatsächlich:

Crate Version in Cargo.lock
dioxus 0.7.10
dioxus-desktop 0.7.10
rfd 0.17.2
tao 0.34.8
wry 0.53.5
raw-window-handle 0.5.2 und 0.6.2

→ Cargo.lock im Repo-Root (Zeilen 2337, 2493, 5847, 6521, 5716/5722).

Das ist der wichtigste praktische Befund: dioxus-desktop 0.7.10 hat rfd als nicht-optionale Dependency mit req = "^0.17.2", default-features = false, features = ["xdg-portal"], unter cfg(any(windows, macos, linux, dragonfly, freebsd, netbsd, openbsd)). → https://crates.io/api/v1/crates/dioxus-desktop/0.7.10/dependencies

Ein rfd = "0.17" in unserer Cargo.toml fügt dem Graph also keinen neuen Crate hinzu und kostet keinen zusätzlichen Compile-Unit — es macht nur den bereits gebauten Knoten sichtbar. Cargo vereinigt die Features additiv; unsere Default-Features von rfd sind ohnehin xdg-portal, also genau das, was dioxus-desktop schon setzt.

(Historische Notiz: dioxus-desktop 0.7.1 hatte noch rfd ^0.15.3. Der Sprung auf 0.17 kam zwischen 0.7.1 und 0.7.10. Für uns zählt 0.7.10.)

Belegt: keine Versionskonflikte

Fazit: Es gibt keinen Versionskonflikt. rfd und dioxus-desktop teilen sich raw-window-handle 0.6 und passen semver-kompatibel zusammen.

Vermutung (nicht verifiziert)


3a. Weg A — der offizielle Weg ohne neue Dependency

Belegt

Die offizielle Dioxus-0.7-Doku beschreibt Ordner-Auswahl über ein directory-Attribut am File-Input:

„Dioxus will map this attribute to browser specific attributes, because there is no standardized way to allow a directory to be selected."

input {
    type: "file",
    // Select a folder by setting the directory attribute
    directory: true,
    onchange: move |evt| {
        for file in evt.files() {
            println!("{}", file.name());
        }
    }
}

→ https://dioxuslabs.com/learn/0.7/essentials/basics/event_handlers/

Auf dem Desktop ist das kein Browser-Dialog, sondern feuert intern exakt den nativen rfd-Ordner-Dialog:

// packages/desktop/src/file_upload.rs
if self.directory {
    if self.multiple {
        dialog.pick_folders().await
    } else {
        dialog.pick_folder().await
    }
}

pub struct DesktopFileData(pub(crate) PathBuf);

fn path(&self) -> PathBuf { self.0.clone() }

→ https://github.com/DioxusLabs/dioxus/blob/v0.7.10/packages/desktop/src/file_upload.rs

Und der absolute Pfad ist öffentlich abgreifbar — FileData::path() ist Teil der dioxus-html-API:

pub fn path(&self) -> PathBuf

→ https://docs.rs/dioxus-html/0.7.10/dioxus_html/struct.FileData.html

Snippet für Weg A

use dioxus::prelude::*;
use std::path::PathBuf;

#[component]
fn WorkspaceOrdnerWahlOhneDependency() -> Element {
    let mut ordner = use_signal(|| None::<PathBuf>);

    let label = ordner()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "Noch kein Ordner gewählt".to_string());

    rsx! {
        input {
            r#type: "file",
            directory: true,
            onchange: move |evt| {
                // Bei Abbruch ist die Liste leer -> kein Event-File
                if let Some(file) = evt.files().first() {
                    ordner.set(Some(file.path()));
                }
            },
        }
        p { "{label}" }
    }
}

Grenzen von Weg A

  • Braucht ein DOM-Element und einen echten Nutzer-Klick darauf. Ein Ordner-Dialog, den man aus Rust heraus programmatisch öffnet (z. B. aus einem Menüeintrag, einem Shortcut oder nach einem anderen Ereignis), ist damit nicht möglich.
  • Das Styling ist ein <input type="file"> — für einen hübschen Button muss man das Element verstecken und per Label/JS triggern.
  • Vermutung, nicht verifiziert: wie sich evt.files() bei Abbruch genau verhält (leere Liste vs. gar kein onchange-Event). Das obige if let Some(...) ist gegen beides robust.

3b. Blockierend oder async? — Für Weg B im onclick zwingend async

Belegt: die Signaturen

rfd hat zwei parallele Typen. Beide bauen per Builder, unterscheiden sich nur im Rückgabetyp:

// Synchron — rfd::FileDialog
pub fn pick_folder(self) -> Option<PathBuf>;
pub fn pick_folders(self) -> Option<Vec<PathBuf>>;

// Asynchron — rfd::AsyncFileDialog
pub fn pick_folder(self)  -> impl Future<Output = Option<FileHandle>>;
pub fn pick_folders(self) -> impl Future<Output = Option<Vec<FileHandle>>>;

→ https://docs.rs/rfd/0.17.2/rfd/struct.FileDialog.html, https://docs.rs/rfd/0.17.2/rfd/struct.AsyncFileDialog.html

Belegt: warum die synchrone Variante in Dioxus schadet

  • Die synchrone Windows-Implementierung fährt die modale COM-Dialogschleife auf dem aufrufenden Thread:

    fn pick_folder(self) -> Option<PathBuf> {
        fn run(opt: FileDialog) -> Result<PathBuf> {
            init_com(|| {
                let dialog = IDialog::build_pick_folder(&opt)?;
                dialog.show()?;              // blockiert hier bis der Nutzer fertig ist
                dialog.get_result()
            })?
        }
        run(self).ok()
    }
    

    → https://github.com/PolyMeilex/rfd/blob/master/src/backend/win_cid/file_dialog.rs

  • Dioxus-Event-Handler und via spawn gestartete Tasks laufen auf demselben Thread wie die Event-Loop: pub fn spawn(fut: impl Future<Output = ()> + 'static) -> Task — kein Send-Bound, d. h. die Futures können den Thread gar nicht wechseln. → https://docs.rs/dioxus/0.7.1/dioxus/prelude/fn.spawn.html

    Ein FileDialog::pick_folder() direkt im onclick blockiert also die tao-Event-Loop → Fenster friert ein, solange der Dialog offen ist.

  • Die Crate-Doku selbst empfiehlt, Dialoge auf dem Main-Thread zu starten („It is also recommended to spawn dialogs on your main thread") — genau das tut man mit der async-Variante aus einem Dioxus-Handler heraus. → https://docs.rs/rfd/0.17.2/src/rfd/lib.rs.html

Belegt: warum die async-Variante unter Windows nicht blockiert

Die Windows-async-Variante lagert den modalen Dialog auf einen eigenen OS-Thread aus und liefert das Ergebnis über einen Channel zurück:

// async_thread(..) — Kern der Implementierung
std::thread::spawn(move || {
    tx.send(f()).ok();
});

// pick_folder_async
async_thread(move || Self::pick_folder(self).map(FileHandle::wrap))

→ https://github.com/PolyMeilex/rfd/blob/master/src/backend/win_cid/file_dialog.rs

Die Dioxus-Event-Loop läuft weiter; das .await gibt den Main-Thread frei, bis der Channel liefert. Kein Tokio-/async-std-Feature nötig — rfd hat keine Runtime-Features, die Implementierung nutzt std::thread plus Channel. → https://crates.io/api/v1/crates/rfd/0.17.2/dependencies (keine Runtime-Dependency; nur log, raw-window-handle unbedingt)

Nebenbedingung aus dem offiziellen rfd-Beispiel: der Dialog soll auf dem Main-Thread gestartet (pick_*() aufgerufen) und darf anderswo awaited werden — „Spawn dialog on main thread / Await somewhere else". → https://github.com/PolyMeilex/rfd/blob/master/examples/async.rs Im Dioxus-Fall passiert beides auf dem Main-Thread (spawn hat kein Send-Bound), die Bedingung ist damit erfüllt.


4. Was kommt bei Abbruch zurück?

Belegt

None. Sowohl FileDialog::pick_folder() -> Option<PathBuf> als auch AsyncFileDialog::pick_folder() -> impl Future<Output = Option<FileHandle>> liefern bei Abbruch None. Es gibt keinen Result-Typ und keine Fehlerunterscheidung „abgebrochen vs. fehlgeschlagen" — ein technisch fehlgeschlagener Dialog ist von einem abgebrochenen nicht unterscheidbar (die Windows-Impl macht am Ende run(self).ok(), verwirft den Fehler also). → https://docs.rs/rfd/0.17.2/rfd/struct.FileDialog.html, https://docs.rs/rfd/0.17.2/rfd/struct.AsyncFileDialog.html, https://github.com/PolyMeilex/rfd/blob/master/src/backend/win_cid/file_dialog.rs

Aus dem FileHandle kommt man so an den Pfad:

pub fn path(&self) -> &Path;          // existiert nicht unter WASM32
pub fn file_name(&self) -> String;
// zusätzlich: impl From<FileHandle> for PathBuf

→ https://docs.rs/rfd/0.17.2/rfd/struct.FileHandle.html


5. Plattformen und Feature-Flags

Belegt: Windows — out of the box, ja

Einzige Windows-Dependency von rfd 0.17.2:

Target Crate Version Features
cfg(target_os = "windows") windows-sys ^0.61 Win32_Foundation, Win32_System_Com, Win32_UI_Shell_Common, Win32_UI_Shell, Win32_UI_WindowsAndMessaging

→ https://crates.io/api/v1/crates/rfd/0.17.2/dependencies

windows-sys ist reines Rust-Binding auf die Systembibliotheken — keine C-Header, kein vcpkg, kein Build-Tool, kein Feature-Flag. Zusammen mit der Implementierung über IFileOpenDialog/COM (siehe init_com / IDialog::build_pick_folder oben) heißt das: rfd = "0.17" in die Cargo.toml, fertig.

Optional unter Windows: das Feature common-controls-v6 — betrifft ausschließlich Message-Dialoge (TaskDialogIndirect aus ComCtl32.dll v6, für eigene Button-Texte) und braucht zusätzlich ein Application-Manifest. Für Ordner-Dialoge irrelevant. → https://docs.rs/rfd/0.17.2/src/rfd/lib.rs.html

Belegt: macOS

Reine Rust/objc2-Dependencies (objc2, objc2-app-kit, objc2-foundation, block2, dispatch2), keine Feature-Flags nötig. Einschränkung aus der Crate-Doku:

„macOS async dialogs require an NSApplication instance, so the dialog is only truly async when opened in windowed environment like winit or SDL2. Otherwise, it will fallback to sync dialog."

Eine Dioxus-Desktop-App ist eine windowed App (tao/NSApplication), die Bedingung ist also erfüllt. → https://docs.rs/rfd/0.17.2/src/rfd/lib.rs.html, https://crates.io/api/v1/crates/rfd/0.17.2/dependencies

Belegt: Linux — hier steckt der einzige echte Stolperstein

Zwei Backends, per Cargo-Feature wählbar:

  • xdg-portal (Default) — XDG Desktop Portal über D-Bus. Braucht zur Laufzeit libdbus (fällt sonst auf zenity zurück) und ein installiertes Portal-Backend (GTK, GNOME oder KDE). Das wlroots-Portal-Backend implementiert die benötigte D-Bus-API nicht.
  • gtk3 — default-features = false, features = ["gtk3"]. Braucht die GTK3-Entwicklungs- Header schon zur Build-Zeit (libgtk-3-dev / gtk3-devel / pacman -S gtk3).

→ https://docs.rs/rfd/0.17.2/src/rfd/lib.rs.html

Also: nein, „derselbe Aufruf überall" gilt für den Rust-Code, aber nicht für das Packaging. Der Aufruf im Code ist auf allen drei Plattformen identisch; Linux-Builds brauchen zusätzlich eine Aussage darüber, welches Backend gewählt wird und was beim Nutzer installiert sein muss. Für DioNotes auf Windows 11 ist das derzeit kein Thema.


6. Lauffähiges Beispiel-Snippet für Weg B (Dioxus 0.7 API)

Cargo.toml — nicht angewendet, gehört in ein Implementierungs-Ticket:

[dependencies]
rfd = "0.17"   # bereits transitiv via dioxus-desktop 0.7.10 im Lock-File

Komponente:

use dioxus::prelude::*;
use std::path::PathBuf;

#[component]
fn WorkspaceOrdnerWahl() -> Element {
    let mut ordner = use_signal(|| None::<PathBuf>);

    // Anzeige-Text vorberechnen, damit im rsx! kein Ausdruck nötig ist
    let label = ordner()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "Noch kein Ordner gewählt".to_string());

    rsx! {
        button {
            // Async-Closures in Event-Handlern werden von Dioxus automatisch gespawnt.
            onclick: move |_| async move {
                let auswahl = rfd::AsyncFileDialog::new()
                    .set_title("Workspace-Ordner wählen")
                    .pick_folder()
                    .await;

                match auswahl {
                    // FileHandle::path() -> &Path
                    Some(handle) => ordner.set(Some(handle.path().to_path_buf())),
                    // Nutzer hat abgebrochen -> None; Zustand bewusst unverändert lassen
                    None => {}
                }
            },
            "Ordner wählen…"
        }
        p { "{label}" }
    }
}

Belege für die Dioxus-Seite des Snippets:

Optionale Erweiterung (Vermutung, nicht kompiliert)

Damit der Dialog besitzer-modal am App-Fenster hängt:

let window = use_window();          // DesktopContext = Rc<DesktopService>, Deref -> tao::Window
// ...
rfd::AsyncFileDialog::new()
    .set_parent(&*window)           // &DesktopService --deref--> &tao::window::Window
    .set_title("Workspace-Ordner wählen")
    .pick_folder()
    .await

Typtheoretisch geht das auf (siehe Abschnitt 2), aber ungetestet. Zwei offene Punkte:

  1. Ob die Deref-Kette bis tao::window::Window vom Compiler an set_parents Bound W: HasWindowHandle + HasDisplayHandle + ?Sized angepasst wird, oder ob ein expliziter &*(*window) / Zwischenschritt nötig ist.
  2. Windows führt den async-Dialog auf einem anderen Thread aus (Abschnitt 3). Ein Parent-HWND über Thread-Grenzen hinweg ist unter Win32 grundsätzlich zulässig, das Modalitäts-Verhalten (Deaktivierung des Owner-Fensters) sollte man aber praktisch prüfen.

Empfehlung: erst ohne set_parent implementieren, das ist die belegte Variante.


7. Empfehlung für DioNotes

Für „Ordner beim Anlegen eines Workspace wählen" ist die Auswahl eine reine UX-Frage:

  • Wenn der Dialog aus einem sichtbaren Button/Input im Formular kommt → Weg A, null neue Dependencies, offiziell dokumentiert.
  • Wenn der Dialog programmatisch aufgehen soll (Menü, Shortcut, Folgeschritt eines Wizards) → Weg B mit rfd = "0.17". Kostet dank des schon vorhandenen Lock-Eintrags praktisch nichts.

Beide landen unter Windows im selben nativen IFileOpenDialog.

8. Zusammenfassung der offenen Punkte

Punkt Status
Crate + Version belegt — rfd 0.17.2
rfd bereits transitiv im Lock-File (via dioxus-desktop 0.7.10) belegt (Cargo.lock + crates.io)
Projekt löst dioxus 0.7.1 real auf 0.7.10 auf belegt (Cargo.lock)
Weg A: directory: true + FileData::path() belegt (offizielle Doku + docs.rs + Quellcode)
Weg A nutzt intern rfd::…::pick_folder() belegt (packages/desktop/src/file_upload.rs)
Kein Konflikt mit tao 0.34 / wry 0.53 belegt (Manifeste)
async statt sync in onclick (Weg B) belegt (Signaturen + Quellcode + spawn-Signatur ohne Send)
Abbruch → None belegt
Windows out of the box, kein Feature-Flag belegt (nur windows-sys ^0.61)
Linux braucht Portal-Backend bzw. gtk3-Header belegt
dioxus-sdk hat kein Dialog-Modul belegt (Feature-Liste 0.7.0)
dioxus_desktop::rfd existiert nicht (kein Re-Export) belegt (lib.rs)
set_parent mit dem Dioxus-Fenster Vermutung — plausibel, nicht kompiliert
evt.files()-Verhalten bei Abbruch in Weg A Vermutung — Snippet ist gegen beide Varianten robust
Verhalten bei Netzlaufwerken / UNC-Pfaden nicht recherchiert
Verhalten unter dx serve / Hot-Reload nicht recherchiert

Quellenliste