フォルダ(ディレクトリ)を選択させる

dialog プラグインの open({ directory: true }) でフォルダを選ばせ、fs プラグインで中身を扱う。recursive で変わる読み書きの範囲、Rust の pick_folder、モバイル非対応の注意も示す。

ダイアログ・通知 対象: Tauri 2.x 更新日: 読了目安: 約10分 dlg-006
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. フォルダを選んで直下を一覧する
  4. サブフォルダまで読むなら recursive: true
  5. 書き出し先のフォルダとして使う
  6. 選んだフォルダを次回も使う
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

作業フォルダや書き出し先、まとめて処理する画像のフォルダを選ばせるには、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 for allow-read-dir permission 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 を併用すると戻り値は配列になります。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する