サブフォルダも含めて再帰的に一覧取得する

readDir() を繰り返してサブフォルダーの中までたどる。スコープに ** が要る点、隠しフォルダーとリンクの扱い、大量のファイルで IPC を抑える並列化と Rust での走査も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-014
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 素直な再帰
  4. 大量のファイルで気を付けること
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

プロジェクトフォルダーの全ファイルを検索の対象にする、選ばれたフォルダーの画像をまとめて取り込む、といった処理では、サブフォルダーの中まで一覧が要ります。readDir() は直下しか返さないので(フォルダ内のファイル一覧を取得する)、フォルダーが見つかるたびに readDir() を呼び直してたどります。数百件なら素直な再帰で足りますが、数万件になると呼び出しの回数と待ち時間が問題になるため、JS での並列化と Rust での走査を示します。

前提条件

fs プラグインを追加します。フォルダーを選ばせる例ではダイアログプラグインも使います。

npm run tauri add fs
npm run tauri add dialog

readDir() の権限は fs:default に含まれます。再帰で問題になるのは範囲(スコープ)の書き方で、* は直下の 1 階層、** は下の階層すべてに一致します。$DOCUMENT/MyProjects と $DOCUMENT/MyProjects/* だけでは、直下のサブフォルダー(MyProjects/a)までは一覧できても、その下(MyProjects/a/b)で「forbidden path」になります。ダイアログで選ばせる場合も同じで、open() に recursive: true を付けないと 2 階層目から先は範囲に入りません(フォルダ(ディレクトリ)を選択させる)。範囲の基本は ファイルやディレクトリを削除する を参照してください。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "dialog:default",
    {
      "identifier": "fs:allow-read-dir",
      "allow": [{ "path": "$DOCUMENT/MyProjects" }, { "path": "$DOCUMENT/MyProjects/**" }]
    }
  ]
}

1. フロントエンドから実装する (TypeScript)

素直な再帰

フォルダーなら自分自身を呼び、ファイルなら結果に足します。シンボリックリンクと . で始まる名前は飛ばします。フォルダーへのリンクが親を指していると、たどり終わらなくなるためです。.git のような隠しフォルダーは大量のファイルを含むことが多く、macOS / Linux ではそもそも範囲に入りません。

import { readDir } from '@tauri-apps/plugin-fs';
import { sep } from '@tauri-apps/api/path';

// dir 以下にあるファイルのパスをすべて集める(フォルダーは結果に入れない)
export async function listAllFiles(dir: string, out: string[] = []): Promise<string[]> {
  for (const entry of await readDir(dir)) {
    if (entry.isSymlink || entry.name.startsWith('.')) continue; // リンクと隠しフォルダーは飛ばす
    const path = `${dir}${sep()}${entry.name}`; // join() は 1 回ごとに IPC が起きるので使わない
    if (entry.isDirectory) await listAllFiles(path, out);
    else if (entry.isFile) out.push(path);
  }
  return out;
}

大量のファイルで気を付けること

readDir() は 1 回ごとに Rust とのプロセス間通信(IPC)が起きるので、フォルダーが 1 万個あれば 1 万回の往復です。上の再帰は 1 つずつ待つので時間がかかり、逆に Promise.all で全部を一度に投げると、何千もの読み取りが同時に走ってほかの処理まで待たされます。次の例は、階層ごとに決まった数ずつ並べて読み、件数と深さに上限を設けます。読めなかったフォルダーは記録して続けます。黙って飛ばすと、範囲の書き漏れで深い階層が丸ごと抜けても気付けないので、failed として返して件数を画面に出します。

import { readDir } from '@tauri-apps/plugin-fs';
import { sep } from '@tauri-apps/api/path';

export type WalkResult = { files: string[]; failed: { dir: string; error: string }[]; truncated: boolean };

export async function walk(
  root: string,
  { maxFiles = 50000, maxDepth = 30, parallel = 8, onProgress = (_count: number) => {} } = {},
): Promise<WalkResult> {
  const result: WalkResult = { files: [], failed: [], truncated: false };
  const s = sep();
  let level = [root];
  for (let depth = 0; level.length > 0; depth++) {
    if (depth > maxDepth) {
      result.truncated = true; // 深すぎる分は読まない
      break;
    }
    const next: string[] = [];
    for (let i = 0; i < level.length; i += parallel) {
      // 同時に読むのは parallel 個まで
      await Promise.all(level.slice(i, i + parallel).map(async (dir) => {
        try {
          for (const e of await readDir(dir)) {
            if (e.isSymlink || e.name.startsWith('.')) continue;
            const path = `${dir}${s}${e.name}`;
            if (e.isDirectory) next.push(path);
            else if (e.isFile) result.files.push(path);
          }
        } catch (err) {
          result.failed.push({ dir, error: String(err) }); // 範囲外や読めないフォルダーを記録
        }
      }));
      onProgress(result.files.length);
      if (result.files.length >= maxFiles) {
        result.files.length = maxFiles;
        result.truncated = true;
        return result;
      }
    }
    level = next;
  }
  return result;
}

フォルダーの合計サイズだけが欲しいなら、再帰を書かずに size() を 1 回呼べば、Rust 側でたどって合計のバイト数を返します(権限 fs:allow-size は fs:default に含まれないので追加します)。ツリー表示のように全体を一度に見せない画面では、先に全部を読まず、ユーザーがフォルダーを開いたときにその階層だけを readDir() する方が速く表示できます。

2. バックエンドから実装する (Rust)

Rust でたどれば readDir() の往復が無くなるので、件数が多いほど JS で繰り返すより速く終わります。走査中にほかのコマンドを止めないよう spawn_blocking で別スレッドに移し(重い処理を別スレッドで実行する)、進み具合は Channel で JS に送ります。Rust の std::fs にはスコープが効かないので、JS から任意のパスを受け取ると何でも一覧できる穴になります。例では FsExt の fs_scope().is_allowed() で、ダイアログで選ばれて範囲に加わったフォルダーかを確かめています。capability に書いた範囲はここには含まれない点に注意します。

use serde::Serialize;
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri_plugin_fs::FsExt;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ScanResult {
    files: Vec<String>,
    failed: Vec<String>,
    truncated: bool,
}

fn scan(root: PathBuf, max_files: usize, progress: &Channel<usize>) -> ScanResult {
    let mut result = ScanResult { files: Vec::new(), failed: Vec::new(), truncated: false };
    let mut stack = vec![root]; // 再帰ではなくスタックで回すので、深い階層でもあふれない
    while let Some(dir) = stack.pop() {
        let entries = match std::fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(e) => {
                result.failed.push(format!("{}: {e}", dir.display()));
                continue;
            }
        };
        for entry in entries.flatten() {
            if entry.file_name().to_string_lossy().starts_with('.') {
                continue; // 隠しファイル・フォルダーを飛ばす
            }
            let Ok(kind) = entry.file_type() else { continue }; // リンクはたどらない
            if kind.is_dir() {
                stack.push(entry.path());
            } else if kind.is_file() {
                if result.files.len() >= max_files {
                    result.truncated = true;
                    return result;
                }
                result.files.push(entry.path().display().to_string());
                if result.files.len() % 1000 == 0 {
                    let _ = progress.send(result.files.len()); // 1,000 件ごとに進み具合を送る
                }
            }
        }
    }
    result
}

#[tauri::command]
async fn scan_folder(
    app: tauri::AppHandle,
    root: String,
    max_files: usize,
    on_progress: Channel<usize>,
) -> Result<ScanResult, String> {
    let root = PathBuf::from(root);
    // ダイアログで選ばれて fs の範囲に加わったフォルダーだけを受け付ける
    if !app.fs_scope().is_allowed(&root) {
        return Err(format!("not allowed: {}", root.display()));
    }
    tauri::async_runtime::spawn_blocking(move || scan(root, max_files, &on_progress))
        .await
        .map_err(|e| e.to_string())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![scan_folder])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { Channel, invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';

type ScanResult = { files: string[]; failed: string[]; truncated: boolean };

export async function scanWithRust(): Promise<ScanResult | null> {
  const root = await open({ directory: true, recursive: true });
  if (!root) return null;
  const onProgress = new Channel<number>();
  onProgress.onmessage = (count) => console.log(`${count} 件…`);
  return invoke<ScanResult>('scan_folder', { root, maxFiles: 200000, onProgress });
}

動作確認

npm run tauri dev で起動し、scanWithRust() でファイルの多いフォルダー(ソースコードのフォルダーなど)を選ぶと、コンソールに進み具合が流れます。最後に結果の件数を表示すると次のようになります。続けて同じパスを walk() に渡せば、同じ条件で飛ばすので件数がそろい、かかった時間を比べられます。

1000 件…
2000 件…
files: 2481 / failed: 0 / truncated: false

再起動して、recursive を付けない open({ directory: true }) で選んだフォルダーを walk() に渡すと、孫の階層のフォルダーが「forbidden path」で failed に入り、範囲の違いを確かめられます。

よくあるエラーと対処法

  • 深い階層でだけ「forbidden path: …」になる: 範囲が *(直下だけ)になっています。capability なら **、ダイアログなら recursive: true にします。macOS / Linux の隠しフォルダーでも起きます。
  • 「failed to read directory at path: <パス> with error: …」: 走査中にフォルダーが消えた、読み取り権の無いフォルダーがある、などです。1 つの失敗で全体を止めないよう、記録して続けます。
  • 画面が固まる・ほかの操作が遅れる: Promise.all で全フォルダーを一度に読んでいるか、結果が大きすぎます。同時に読む数を絞るか、Rust の走査に移して上限を設けます。
  • Rust の scan_folder が「not allowed: …」を返す: ダイアログを通していないパスか、前回の起動で保存したパスです。ダイアログで選ばれた範囲はその起動中だけなので、再起動後も使うなら Persisted Scope で権限状態を維持する を使います。

OS ごとの違いと注意点

  • macOS / Linux: 範囲の * と ** は . で始まる名前に一致しないので、隠しフォルダーに入ると forbidden path になります。Windows では入れてしまうため、例のように名前で飛ばして結果をそろえます。
  • Windows: $APPLOCALDATA($APPCACHE も同じフォルダー)をたどると、WebView のデータ(EBWebView)の下の階層で forbidden path になります。fs:default がそこを拒否しているためです。アプリのファイルは専用のサブフォルダーに置き、そこからたどります。
  • 共通: 走査には時間がかかるので、その間にファイルが増えたり消えたりします。結果は走査を始めた時点の完全な写しではありません。変化を追い続けるなら、定期的に全部を読み直すより ファイルの変更をリアルタイムで監視する で変わった所だけを直します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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