プロジェクトフォルダーの全ファイルを検索の対象にする、選ばれたフォルダーの画像をまとめて取り込む、といった処理では、サブフォルダーの中まで一覧が要ります。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がそこを拒否しているためです。アプリのファイルは専用のサブフォルダーに置き、そこからたどります。 - 共通: 走査には時間がかかるので、その間にファイルが増えたり消えたりします。結果は走査を始めた時点の完全な写しではありません。変化を追い続けるなら、定期的に全部を読み直すより ファイルの変更をリアルタイムで監視する で変わった所だけを直します。
