Files: native open & save pickers

Day opens and saves files through each platform’s native file-interaction UI, using the same imperative request→response model as dialogs: an action opens a picker and .awaits the result:

button(tr("open")).action(|| day::task(async move {
    if let Some(file) = open_file().filter("Text", &["txt", "md"]).await {
        let text = file.read_to_string()?;   // FileUrl::read_to_string
        editor.set(text);
    }
}));

button(tr("save")).action(|| day::task(async move {
    let saved: Option<FileUrl> = save_file(editor.get_untracked().into_bytes())
        .suggested_name("notes.txt")
        .filter("Text", &["txt"])
        .await;
}));

The path type: FileUrl

A file location crosses back as a FileUrl, a newtype wrapping a single locator string. This is a deliberate choice over the obvious alternatives:

  • Not std::path::PathBuf: on Android the Storage Access Framework returns a content:// URI rather than a filesystem path; a PathBuf cannot represent it and std::fs cannot open it.
  • Not a bare String: no type-safety, and every call site would re-implement the same parsing.
  • Not url::Url: Its parsing normalizes/validates in ways that mangle content:// authorities, and it pulls in a heavy dependency for no benefit.

FileUrl is the lossless union (a filesystem path on desktop/iOS, a content:// URI on Android) with accessors:

methodresult
as_str()the raw locator
local_path() -> Option<PathBuf>Some for filesystem paths (and file://), None for content://
file_name() -> Option<String>the last path component, for display
read() / read_to_string()the bytes / UTF-8 text (local paths; content:// errors)

Opened files are always readable. Where a platform doesn’t hand back a usable path, the backend materializes one first: Android copies the picked document into the app cache, and iOS imports it into the app sandbox. So open_file().await?.read_to_string() works on every target.

The builders (day-pieces, in the prelude)

  • open_file()OpenFile: .title(..), .filter(name, &["ext", …]), .await → Option<FileUrl>.
  • save_file(data)SaveFile: .title(..), .suggested_name(..), .filter(..), .await → Option<FileUrl>. The bytes are staged to an app-writable temp file that the backend hands to the native save UI; the pieces layer delivers them to a chosen local destination and cleans up.

Per-toolkit native mapping

ToolkitOpenSave
appkitNSOpenPanel (sheet)NSSavePanel (sheet)
uikitUIDocumentPickerViewController (.import)UIDocumentPickerViewController (export)
gtkGtkFileDialog.open (GTK 4.10+)GtkFileDialog.save
qtQFileDialog (ExistingFile) via the C++ shimQFileDialog (AnyFile/AcceptSave)
androidACTION_OPEN_DOCUMENT + ContentResolver (copy → cache)ACTION_CREATE_DOCUMENT + ContentResolver
arkui (HarmonyOS)ArkTS DocumentViewPicker.select + @ohos.file.fs (copy → cache)DocumentViewPicker.save + @ohos.file.fs
mockrecords the spec; resolved programmaticallysame
xamlnot yet implemented (like its alert dialogs)

On HarmonyOS the picker lives in the ArkTS @kit.CoreFileKit layer, not the native NodeAPI, so the day-arkui backend calls up into its ArkTS host over NAPI (safe: Day’s loop runs on the JS thread); the host drives DocumentViewPicker and answers via a registered onFileResult callback, wired in the ArkTS host’s Index.ets (Day-Showcase/platform/ohos/entry/src/main/ets/pages/).

All backends present the picker non-blocking (sheet / open() / delegate / Activity result), so the main loop keeps running and dayscript stays live while a picker is up.

Plumbing

Files ride the existing present seam (docs/dialogs.md) rather than adding new Toolkit methods:

  • day_spec::present::PresentSpec::{OpenFile, SaveFile} + FileFilter { name, extensions }.
  • PresentResult::Files(Vec<String>): the chosen locators, crossing the C ABI (Qt shim / Android JNI) as tag 3 with the paths joined by the unit separator.
  • Cap::FileDialogs advertises native support.
  • day_spec::present::app_temp_dir(): the app-writable staging dir; Android sets it to getCacheDir() (the OS temp dir isn’t app-writable there).

dayscript

A file picker is a presentation, so a script answers it with a path (docs/dialogs.md):

- tap: { id: btn-save-file }
- assert_presented: {}
- respond: { path: "notes.txt" }        # relative → the app temp dir (writable on every target)
- tap: { id: btn-open-file }
- assert_presented: {}
- respond: { path: "notes.txt" }         # reads the file just written: a real round-trip

This makes open/save flows headless-testable and screenshot-able on every backend without touching the machine’s real filesystem. See Day-Showcase (the Platform services page’s Files section) and Day-Showcase/dayscript/files.yaml.