作業フォルダや書き出し先、まとめて処理する画像のフォルダを選ばせるには、dialog プラグインの open() に directory: true を付けます。戻り値はフォルダのパス(キャンセル時は null)で、選ばれたフォルダはその起動中に限り fs プラグインで扱えるようになります。どこまで扱えるかは recursive で変わり、指定しないとサブフォルダの中は読めません。
前提条件
dialog プラグインと fs プラグインを追加します。
npm run tauri add dialog
npm run tauri add fs
フォルダの選択も open() なので、権限は dialog:allow-open です。中身の一覧(fs:allow-read-dir)、読み込み(fs:allow-read-text-file)、存在確認(fs:allow-exists)は fs:default に含まれますが、書き込み(fs:allow-write-text-file)は含まれません。選んだフォルダは自動で扱える範囲(スコープ)に加わるので、fs:scope でフォルダを許可する必要はありません。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-exists",
"fs:allow-write-text-file"
]
}
recursive の有無で、JS から扱える範囲は次のように変わります。
| 対象 | 指定なし | recursive: true |
|---|---|---|
| 選んだフォルダの一覧 | 〇 | 〇 |
| 直下のファイルの読み書き | 〇 | 〇 |
| サブフォルダの中のファイル | ✕ | 〇 |
ファイルを選ばせるなら ファイルを開くダイアログ、保存するファイルの名前まで決めさせるなら 保存する場所を選ばせる を使います。
1. フロントエンドから実装する (TypeScript)
フォルダを選んで直下を一覧する
defaultPath に既存のフォルダを渡すと、そこから開きます。前回のフォルダを初期値にすると便利ですが、パスを保存しても再起動後にそのまま読めるわけではありません(後述)。一覧の取り方は フォルダ内のファイル一覧を取得する で詳しく扱います。
import { open } from '@tauri-apps/plugin-dialog';
import { readDir } from '@tauri-apps/plugin-fs';
import { documentDir } from '@tauri-apps/api/path';
const KEY = 'last-folder';
export async function pickAndList(): Promise<string | null> {
const dir = await open({
directory: true,
title: '作業フォルダを選択',
defaultPath: localStorage.getItem(KEY) ?? (await documentDir()), // 前回のフォルダから開く
});
if (dir === null) return null; // キャンセル
localStorage.setItem(KEY, dir); // 次回の初期値にだけ使う
const entries = await readDir(dir); // fs:allow-read-dir(fs:default に含まれる)
for (const e of entries) console.log(e.isDirectory ? '[DIR]' : ' ', e.name);
return dir;
}
サブフォルダまで読むなら recursive: true
recursive: true は、選んだフォルダの配下すべてを JS に開放します。ユーザーがホームフォルダやドライブ全体を選ぶと、その起動中は配下のすべてのファイルが、capability で許可した操作の対象になります。必要なときだけ付けます。macOS / Linux では .git などの隠しフォルダが範囲に入らないので(「OS ごとの違い」を参照)、たどるときは飛ばします。深い階層のたどり方は サブフォルダも含めて再帰的に一覧取得する で扱います。
import { open } from '@tauri-apps/plugin-dialog';
import { readDir, readTextFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
// サブフォルダも含めて .md ファイルを集める
async function collectMarkdown(dir: string, out: string[] = []): Promise<string[]> {
for (const entry of await readDir(dir)) {
if (entry.name.startsWith('.')) continue; // 隠しファイル・フォルダは飛ばす
const path = await join(dir, entry.name);
if (entry.isDirectory) await collectMarkdown(path, out);
else if (entry.isFile && entry.name.toLowerCase().endsWith('.md')) out.push(path);
}
return out;
}
export async function openNotesFolder() {
const root = await open({ directory: true, recursive: true }); // 配下すべてを範囲に入れる
if (root === null) return;
const files = await collectMarkdown(root);
console.log(`${files.length} 件の Markdown`);
if (files.length > 0) console.log((await readTextFile(files[0])).slice(0, 100)); // サブフォルダの中でも読める
}
書き出し先のフォルダとして使う
直下に書くだけなら recursive は要りません。日付ごとのサブフォルダを作ってその中に書くなら recursive: true が必要です。既存のファイルを黙って上書きしないよう、exists() で確かめてから書きます。
import { open } from '@tauri-apps/plugin-dialog';
import { exists, writeTextFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
export async function exportToFolder(files: Record<string, string>): Promise<number> {
const dir = await open({ directory: true, title: '書き出し先のフォルダを選択' });
if (dir === null) return 0;
let written = 0;
for (const [name, text] of Object.entries(files)) {
const path = await join(dir, name);
if (await exists(path)) {
console.warn('既にあるので飛ばします:', path);
continue;
}
await writeTextFile(path, text); // 直下なので recursive は不要。fs:allow-write-text-file が必要
written++;
}
return written;
}
選んだフォルダを次回も使う
許可はその起動中だけなので、保存したパスを再起動後に readDir() へ渡すと失敗します。保存したパスを defaultPath にして選び直してもらうのが簡単です。黙って使い続けたいなら Persisted Scope プラグイン で許可を保存します。
2. バックエンドから実装する (Rust)
Rust では blocking_pick_folder()(複数なら blocking_pick_folders())を async コマンドで呼びます。メインスレッドで動く setup やメニューのイベントからは、コールバックで受ける pick_folder() を使います。これらはデスクトップ専用の API です。
Rust で選んだフォルダはスコープに加わりません。Rust の中で std::fs を使って処理するだけなら不要ですが、パスを JS に返して fs プラグインで扱わせるなら fs_scope().allow_directory() で加えます。第 2 引数が recursive に当たります。
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_fs::FsExt;
#[derive(serde::Serialize)]
struct FolderSummary {
path: String,
files: usize,
bytes: u64,
}
/// フォルダを選ばせ、直下のファイル数と合計サイズを Rust で数える(スコープは関係ない)
#[tauri::command]
async fn summarize_folder(window: tauri::WebviewWindow) -> Result<Option<FolderSummary>, String> {
let picked = window
.dialog()
.file()
.set_title("集計するフォルダを選択")
.set_parent(&window) // 呼び出し元のウィンドウに結び付ける
.blocking_pick_folder();
let Some(folder) = picked else {
return Ok(None); // キャンセル
};
let dir = folder.into_path().map_err(|e| e.to_string())?;
let (mut files, mut bytes) = (0usize, 0u64);
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
let meta = entry.and_then(|e| e.metadata()).map_err(|e| e.to_string())?;
if meta.is_file() {
files += 1;
bytes += meta.len();
}
}
Ok(Some(FolderSummary { path: dir.display().to_string(), files, bytes }))
}
/// フォルダを選ばせ、JS の fs プラグインから配下すべてを扱えるようにしてパスを返す
#[tauri::command]
async fn pick_workspace(app: tauri::AppHandle) -> Result<Option<String>, String> {
let Some(folder) = app.dialog().file().blocking_pick_folder() else {
return Ok(None);
};
let dir = folder.into_path().map_err(|e| e.to_string())?;
app.fs_scope()
.allow_directory(&dir, true) // true でサブフォルダも含める(JS の recursive: true と同じ)
.map_err(|e| e.to_string())?;
Ok(Some(dir.display().to_string()))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![summarize_folder, pick_workspace])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
import { readDir } from '@tauri-apps/plugin-fs';
type FolderSummary = { path: string; files: number; bytes: number };
const summary = await invoke<FolderSummary | null>('summarize_folder');
if (summary) console.log(`${summary.path}: ${summary.files} 件 / ${summary.bytes} バイト`);
const workspace = await invoke<string | null>('pick_workspace');
if (workspace) console.log(await readDir(workspace)); // Rust 側でスコープに加えたので読める
動作確認
npm run tauri dev で起動して pickAndList() を呼び、フォルダを選ぶと直下の一覧が出ます。
[DIR] images
memo.txt
todo.md
openNotesFolder() はサブフォルダの .md まで数え、最初のファイルの冒頭を表示します。recursive: true を消してから、サブフォルダの中の .md を readTextFile() で読むと「forbidden path: 」で始まるエラーになり、範囲の違いを確かめられます。
よくあるエラーと対処法
- 「dialog.open not allowed. Permissions associated with this command: dialog:allow-open, dialog:default」: 権限の追加漏れです。リリースビルドでは「Command plugin:dialog|open not allowed by ACL」だけになります。
- 「forbidden path: 」で始まるエラー: 範囲の外を扱おうとしています。
recursiveを付けずにサブフォルダの中を読んだ、再起動後に保存したパスを使った、macOS / Linux で隠しファイルを読んだ、のいずれかです。デバッグビルドでは後ろに「maybe it is not allowed on the scope forallow-read-dirpermission in your capability file」のように使った権限の名前が付きます。 - 「fs.write_text_file not allowed. Permissions associated with this command:」の後に候補が 50 以上並ぶ: 書き込みの権限がありません。
fs:allow-write-text-fileを追加します。 - モバイルで「Folder picker is not implemented on mobile」: フォルダの選択は iOS / Android では使えません。
- Rust から開くとアプリが固まる: メインスレッドで
blocking_pick_folder()を呼んでいます。asyncコマンドにするかpick_folder()に替えます。
OS ごとの違いと注意点
- iOS / Android: フォルダの選択は非対応です。Rust の
pick_folder系もデスクトップ向けのビルドにしか無いので、モバイルもビルドするならコマンドごと#[cfg(desktop)]で分けます。 - macOS / Linux の隠しファイル: 名前が
.で始まるファイルやフォルダは、既定では選んだフォルダの範囲に含まれません。JS からは読めないので、飛ばすか Rust で扱います。Windows では読めます。 - macOS: ダイアログの中で新しいフォルダを作れます(既定で有効)。
canCreateDirectories: falseで止められます。 title: デスクトップ専用です。- 複数のフォルダ:
multiple: trueを併用すると戻り値は配列になります。
