diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 9c801370fa..76f3f90e57 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -25,6 +25,7 @@ dependencies = [
"parking_lot",
"rand 0.9.5",
"reqwest 0.12.28",
+ "rfd",
"rusqlite",
"rustls",
"sentry",
@@ -187,6 +188,27 @@ dependencies = [
"password-hash",
]
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.5",
+ "raw-window-handle",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "zbus",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -1544,6 +1566,15 @@ dependencies = [
"syn 3.0.4",
]
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading 0.7.4",
+]
+
[[package]]
name = "dlopen2"
version = "0.8.2"
@@ -1597,6 +1628,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
[[package]]
name = "dpi"
version = "0.1.2"
@@ -4680,7 +4717,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.1",
- "quick-xml",
+ "quick-xml 0.38.4",
"serde",
"time",
]
@@ -4899,6 +4936,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.11"
@@ -5255,6 +5301,31 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfd"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
+dependencies = [
+ "ashpd",
+ "block2 0.6.2",
+ "dispatch2",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -5516,6 +5587,12 @@ dependencies = [
"syn 3.0.4",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -7381,6 +7458,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
+ "tracing",
"windows-sys 0.61.2",
]
@@ -8161,6 +8239,66 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags 2.13.1",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.41.0",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.104"
@@ -9156,6 +9294,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
+ "tokio",
"tracing",
"uds_windows",
"uuid",
@@ -9372,6 +9511,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
+ "url",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 96ac256f75..632385c0fc 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -80,6 +80,47 @@ hex = "0.4"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
log = "0.4"
+# Native directory chooser for the folder memory-source (#5831). A directory
+# picked in the renderer carries no filesystem path — `File.path` is an
+# Electron extension that neither Wry's WKWebView/WebView2/WebKitGTK nor
+# plain Chromium implements — so the only way to learn where a chosen folder
+# lives is to ask the OS from the host process.
+#
+# `default-features = false` is what makes this a different proposition from
+# the `rfd` that `artifact_commands` shed in #5541. That removal counted 13
+# packages: `ashpd`, `zbus`, and the `async-io`/`polling` executor stack.
+# Every one of those arrives through `rfd`'s **default** `xdg-portal` feature
+# (`xdg-portal = ["ashpd", "urlencoding", "pollster"]`), which is not enabled
+# here. `gtk3` binds the GTK that Wry already links on Linux instead —
+# `gtk-sys` is in that graph via `tray-icon` -> `tauri` whether or not this
+# entry exists — and on macOS/Windows `rfd` compiles against `objc2-app-kit`
+# / `windows-sys`, both already present. The marginal cost is `rfd` itself.
+#
+# `Cargo.lock` will nonetheless gain `ashpd` and a wayland cluster, and that
+# is expected: the lockfile records the **maximal** resolution graph,
+# including optional and target-gated edges nothing enables. Read the real
+# graph, not the lock —
+#
+# cargo tree --target x86_64-unknown-linux-gnu -e normal -i ashpd
+# # -> warning: nothing to print.
+#
+# Prefer this over `tauri-plugin-dialog`: that plugin depends on the same
+# `rfd` AND drags `tauri-plugin-fs` in behind it, for one command that needs
+# no filesystem permission surface at all.
+#
+# The feature list is `tauri-plugin-dialog`'s own, verbatim — that plugin is
+# the reference integration of `rfd` into a Tauri app, and each of the three
+# matters here. `gtk3` is the right Linux backend precisely *because* the host
+# is GTK-based (rfd steers non-GTK apps to the portal instead), `tokio` drives
+# the async dialog on the runtime a `#[tauri::command]` already runs on, and
+# `common-controls-v6` mirrors the feature `tauri` itself enables above, so
+# the Windows dialog matches the rest of the app's chrome.
+rfd = { version = "0.15", default-features = false, features = [
+ "gtk3",
+ "tokio",
+ "common-controls-v6",
+] }
+
# Sentry for the Tauri shell (desktop host) process — separate Sentry project
# from the React frontend and the Rust core sidecar. DSN is baked at compile
# time via `option_env!("OPENHUMAN_TAURI_SENTRY_DSN")` in `lib.rs::run()` and
diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json
index c3c89b7ef3..e540b87f4e 100644
--- a/app/src-tauri/capabilities/default.json
+++ b/app/src-tauri/capabilities/default.json
@@ -33,6 +33,7 @@
"allow-workspace-files",
"allow-artifact-download",
"allow-artifact-save",
+ "allow-directory-picker",
"allow-app-update",
"allow-loopback-oauth"
]
diff --git a/app/src-tauri/permissions/allow-directory-picker.toml b/app/src-tauri/permissions/allow-directory-picker.toml
new file mode 100644
index 0000000000..c4d10285e5
--- /dev/null
+++ b/app/src-tauri/permissions/allow-directory-picker.toml
@@ -0,0 +1,11 @@
+[[permission]]
+identifier = "allow-directory-picker"
+description = "Allow opening the OS-native directory chooser so the folder memory-source can store the absolute path the user picked (#5831)"
+
+[permission.commands]
+
+allow = [
+ "pick_directory_via_dialog",
+]
+
+deny = []
diff --git a/app/src-tauri/src/directory_picker.rs b/app/src-tauri/src/directory_picker.rs
new file mode 100644
index 0000000000..baed8f1316
--- /dev/null
+++ b/app/src-tauri/src/directory_picker.rs
@@ -0,0 +1,153 @@
+//! Native directory chooser for memory-source configuration (#5831).
+//!
+//! The folder memory-source used to be picked with an
+//! `` in the renderer. That element hands
+//! back `File` objects, and a `File` carries no filesystem location: the
+//! `File.path` attribute the old handler read is an Electron extension that
+//! no web engine implements. Wry's WKWebView, WebView2 and WebKitGTK do not,
+//! and neither does plain Chromium, so the handler always fell through to
+//! `webkitRelativePath.split('/')[0]` — the chosen directory's **name**, with
+//! its location discarded — and stored that.
+//!
+//! The resulting source looked configured and could never sync. Because the
+//! reader anchors a relative path on the workspace, it failed once per sync
+//! cycle, forever, with `folder does not exist: docs (resolved to
+//! /docs)`. Nothing downstream could repair it: `docs` is not a
+//! relative path to the chosen directory, it is a name whose location was
+//! thrown away.
+//!
+//! A host-side dialog closes that gap by construction. It returns an absolute
+//! path on every platform, in every renderer, because the OS — not the web
+//! engine — owns the selection.
+//!
+//! ## Trust boundary
+//!
+//! Deliberately none. Unlike [`crate::artifact_commands`], which re-validates
+//! that a renderer-supplied path sits inside the artifacts tree because there
+//! the renderer *supplies* the path, this command takes no input at all and
+//! returns only what the user chose in an OS-owned dialog. The renderer
+//! cannot steer it at a directory, and choosing a folder to index is the
+//! user's decision to make anywhere on their own disk.
+
+use std::path::Path;
+
+/// Render a chosen directory as the string the renderer will store, refusing
+/// anything that cannot serve as a path.
+///
+/// Split from the command so it is testable: the dialog itself needs a user
+/// and a window server, but these two rules are the part worth pinning. Both
+/// exist for the same reason — a value that reaches the store and cannot
+/// resolve is the entire defect this command was written to remove, and an
+/// error here is recoverable and visible where a stored bad path is neither.
+///
+/// - **Not absolute.** Belt-and-braces: the OS choosers all hand back
+/// absolute paths, so this is not a branch we expect to take.
+/// - **Not UTF-8.** This one is reachable. A Unix directory name is bytes,
+/// not text, and `Path::display()` would substitute U+FFFD for anything
+/// that is not valid UTF-8 and hand back the corrupted result as a
+/// success — recreating the failing-sync behaviour by a different route.
+/// `to_str()` refuses instead.
+fn absolute_path_string(path: &Path) -> Result {
+ if !path.is_absolute() {
+ return Err(format!(
+ "the directory chooser returned a non-absolute path: {}",
+ path.display()
+ ));
+ }
+ // The lossy rendering is deliberately not echoed back here: it would be
+ // mangled by definition, and a directory path carries the user's login
+ // name (see the logging note in `pick_directory_via_dialog`).
+ path.to_str()
+ .map(str::to_owned)
+ .ok_or_else(|| "the directory chooser returned a path that is not valid UTF-8".to_string())
+}
+
+/// Open the OS-native directory chooser and return the absolute path of the
+/// directory the user selected.
+///
+/// Returns:
+/// - `Ok(Some(path))` — the absolute path chosen.
+/// - `Ok(None)` — the user dismissed the dialog. Not an error; the caller
+/// leaves the field exactly as it was.
+/// - `Err(_)` — the dialog could not run, or it yielded something that cannot
+/// serve as a path (see [`absolute_path_string`]). The caller surfaces this
+/// rather than storing anything.
+#[tauri::command]
+pub async fn pick_directory_via_dialog() -> Result