Skip to content
Select theme

File system and drag-and-drop

Step 9 of 13. Step 8 gave the app search; this step gives it intake. Until now every note began life in the title input, and the only way to get a file into rustnote is to retype it. This step adds an Import .md button and drag-and-drop, both ending in the same place: a file’s first # heading becomes the note title, everything after it becomes the body, and the note lands in the SQLite database you already have, full-text index included. It starts from tauri-ch07, the tag step 8 left behind.

Tauri’s core covers windows and IPC; everything that touches the outside world ships as a plugin, and one plugin touches four files. The file-system plugin (tauri-plugin-fs) and the native dialog plugin (tauri-plugin-dialog) each need a Rust crate, an npm package, a .plugin() registration, and a line in the permissions file. Skip any one of the four and you get a different failure, which is why plugin setup feels harder than it is: there are four checks, not one.

The crates go into src-tauri/Cargo.toml:

src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"

The npm packages go the usual way — they are the TypeScript side of the same two plugins, and the versions resolve to the same numbers on both sides (2.7.1 for fs, 2.8.3 for dialog at the time of writing):

Terminal window
$ npm install @tauri-apps/plugin-dialog @tauri-apps/plugin-fs

Registration is two lines in run(), next to the opener plugin the scaffold installed:

src-tauri/src/lib.rs
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())

The fourth registration is the subject of the next two sections.

src-tauri/capabilities/default.json is the gate between your front end and every plugin command. It has been in the project since the scaffold created it, and until now it read:

src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

Commands you wrote yourself — create_note, search_notes, the rest of the generate_handler! list — are not gated by this file. It gates the plugin surface: every invoke() that reaches a plugin or a core command is checked against the permissions listed here for that window, and rejected otherwise. The rejection is a rejected promise with a message that names the command and every permission that would have allowed it. Call open() before adding dialog:default and you get:

dialog.open not allowed. Permissions associated with this command: dialog:allow-open, dialog:default

The fs message is the same shape with a longer tail — fs.read_text_file not allowed. Permissions associated with this command: followed by every fs: permission that grants a read, from fs:allow-app-read through fs:read-all. Both lists are the answer to the obvious follow-up question (“what was I supposed to add?”), so read them before searching the docs.

dialog:default is the bundle of dialog permissions a normal app wants: open, save, ask, confirm, message. The fs side needs more care, because granting a command is not the same as granting paths.

This is the file after this step:

src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"fs:allow-read-text-file",
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/**" }]
}
]
}

fs:allow-read-text-file says the front end may call readTextFile. It says nothing about which files. The paths live in a second layer, the scope, and with the permission alone in the file the call still fails:

forbidden path: /tmp/rustnote-import/heading-test.md, maybe it is not allowed on the scope for `allow-read-text-file` permission in your capability file

Two mechanisms put paths into the scope, and they are not symmetric. When the user picks a file through the dialog plugin, the plugin adds that path to the fs scope at runtime, on the spot. That is why the Import button below needs no declared scope at all: whatever the user picks, the plugin just authorised. The extension lasts for the app’s lifetime — it is not persisted, so a restarted app has a fresh, empty scope again.

A drop does not get this treatment. The runtime sees the drop event and extends the asset-protocol scope, which serves <img>-style asset URLs — not the fs plugin’s scope. Without a declared scope, readTextFile on a dropped path fails with the same forbidden path error, which we will watch happen in the verification below. Hence the last entry in the capability file: fs:scope with an allow list. $HOME/** covers every file under the home directory, which is where picks and drops come from in practice, and leaves the rest of the disk alone.

One sharp edge in that glob, verified the hard way: $HOME/** did not match a test file placed in ~/.rustnote-import/ — a dot-directory — but matched the same file in ~/rustnote-import/ immediately. The same file, the same capability file, only the leading dot different. Keep import test files out of dot-directories.

All three pieces live in src/App.tsx. The imports:

import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";

The common half of the feature — read a path, split it into a title and a body, store it through the commands from steps 5 and 7. A heading is a line starting with # ; the title is that line minus the marker, trimmed. With no heading, the filename (minus .md) becomes the title and the whole file the body. create_note inserts the row and update_note fills the body, reusing the write path that autosave and the FTS triggers already maintain — one import, and the search index is up to date with no extra code:

async function importNote(path: string) {
try {
const content = await readTextFile(path);
const lines = content.split("\n");
const heading = lines.findIndex((line) => line.startsWith("# "));
const name = path.split(/[\\/]/).pop() ?? path;
const title =
heading >= 0 ? lines[heading].slice(2).trim() : name.replace(/\.md$/, "");
const body = heading >= 0 ? lines.slice(heading + 1).join("\n").trimStart() : content;
const id = await invoke<number>("create_note", { title });
await invoke("update_note", { id, title, body });
await loadNotes();
setSaved(`imported ${name} as note #${id}`);
} catch (e) {
setSaved(`Error: ${e}`);
}
}

The button half calls the dialog. open() resolves to the chosen path, or to null when the window was cancelled — the if handles the cancel. The filters array populates the dialog’s file-type dropdown; it steers the user but does not enforce anything, so importNote still handles a file that turns out not to be Markdown (the OS may hand back a path to anything):

async function importFile() {
try {
const path = await open({
filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (path === null) return;
await importNote(path);
} catch (e) {
setSaved(`Error: ${e}`);
}
}

and a row between the search results and the notes list:

<div className="row">
<button onClick={importFile}>Import .md</button>
<span>or drag a .md file into the window</span>
</div>

The webview owns drag-and-drop by default, which is why there is no onDrop on a div here: Tauri’s window intercepts the system drop before HTML5 drag events ever reach the page, and re-emits them as tauri://drag-drop events with the file paths attached. (The HTML5 API comes back only if you disable the handler in the window builder; then you get dataTransfer but no paths.) The @tauri-apps/api/webview module turns the stream into one subscription with four payload types — enter when files hover in with their paths, over as they move (position only), drop when they land, leave when the drag exits:

useEffect(() => {
const unlisten = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "drop") {
for (const path of event.payload.paths) {
if (path.toLowerCase().endsWith(".md")) importNote(path);
}
}
});
return () => {
unlisten.then((stop) => stop());
};
}, []);

The subscription is a promise resolving to an unlisten function, so the cleanup hands it to .then() — the usual shape for listeners in Tauri front ends. The .md filter is this app’s policy: the scope now allows reading any file under $HOME, and the drop handler declines everything that is not a Markdown file rather than importing it and letting the title heuristic produce nonsense.

Two importable files, one inside the scope and one outside it:

Terminal window
$ mkdir -p ~/rustnote-import /tmp/rustnote-import
$ printf '# Trip plan\n\npack the charger\npassport\n' > ~/rustnote-import/heading-test.md
$ printf 'no heading here\njust text\n' > ~/rustnote-import/plain-notes.md
$ cp ~/rustnote-import/heading-test.md /tmp/rustnote-import/

A real pick needs a human at the dialog and a real drag needs a hand on the mouse, so drive the drop handler with the exact event the runtime posts: same event name, same payload — paths plus a position in physical pixels. Add a temporary command in src-tauri/src/lib.rs that emits it, and register it next to the others in generate_handler!:

src-tauri/src/lib.rs
#[tauri::command]
fn probe(msg: String) {
eprintln!("PROBE {msg}");
}
#[tauri::command]
fn probe_drop(app: tauri::AppHandle, paths: Vec<String>) {
let webview = app.get_webview_window("main").expect("no main window");
let payload = serde_json::json!({
"paths": paths,
"position": { "x": 40.0, "y": 60.0 }
});
use tauri::Emitter;
let _ = webview.emit_to(
tauri::EventTarget::Webview {
label: "main".into(),
},
"tauri://drag-drop",
&payload,
);
eprintln!("PROBE drop emitted");
}

In src/App.tsx, add a probe line at the top of the drop handler’s body, plus one throwaway effect that reads the out-of-scope file and a second that fires the fake drop six seconds after mount (paths under your own home directory — /Users/rango is this machine’s):

invoke("probe", { msg: `event ${JSON.stringify(event.payload)}` });
useEffect(() => {
(async () => {
try {
await readTextFile("/tmp/rustnote-import/heading-test.md");
await invoke("probe", { msg: "read /tmp ok" });
} catch (e) {
await invoke("probe", { msg: `read /tmp: ${e}` });
}
})();
}, []);
useEffect(() => {
const timer = setTimeout(async () => {
await invoke("probe_drop", {
paths: [
"/Users/rango/rustnote-import/heading-test.md",
"/Users/rango/rustnote-import/plain-notes.md",
],
});
}, 6000);
return () => clearTimeout(timer);
}, []);

Run npm run tauri dev. The terminal prints — unique lines; the /tmp probe runs under StrictMode, so its failure prints twice, and your home directory replaces /Users/rango:

PROBE drop emitted
PROBE event {"type":"drop","paths":["/Users/rango/rustnote-import/heading-test.md","/Users/rango/rustnote-import/plain-notes.md"],"position":{"x":40,"y":60}}
PROBE imported heading-test.md as #1
PROBE imported plain-notes.md as #2
PROBE read /tmp: forbidden path: /tmp/rustnote-import/heading-test.md, maybe it is not allowed on the scope for `allow-read-text-file` permission in your capability file

Line by line: the fake drop was emitted; the handler received it with both paths and the position; both files imported, the heading file as “Trip plan” and the headingless one after its filename; and the /tmp read stayed forbidden — the scope allows $HOME/** and nothing else, which is the restriction doing its job. The database agrees:

Terminal window
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT id, title FROM notes ORDER BY id;"
1|Trip plan
2|plain-notes
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT body FROM notes WHERE id = 1;"
pack the charger
passport

On macOS that database path is under ~/Library/Application Support/com.rustnote.app/, the identifier the project was created with in step 2. Delete the probe command, the probe lines, and the two throwaway effects afterwards; they are scaffolding, not a feature. The button is then worth one manual pass — click Import .md, pick a file, watch it land — because the dialog’s half of the scope story, the runtime extension on pick, is the one part no synthetic event exercises.

The step is tagged tauri-ch08 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/tauri-desktop-app
git checkout tauri-ch08
npm install
npm run tauri dev

If you have been following along, your tree is current: this step touched src-tauri/Cargo.toml, src-tauri/capabilities/default.json, src-tauri/src/lib.rs, src/App.tsx, and package.json.

Step 10 adds native chrome: the system tray, menus, and global keyboard shortcuts. The track overview keeps the full list current as articles land.