フォルダ内のファイル一覧を取得する

fs プラグインの readDir()(fs:default に含まれる)でフォルダー直下の名前と種類を取る。並び順が決まっていない点、空・無い・スコープ外の見分け方、サイズも返す Rust 版も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-013
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 並べ替えて表示する
  4. ユーザーが選んだフォルダーを一覧する
  5. 空・無い・範囲外を見分ける
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

画像のギャラリーやファイルブラウザーのような画面では、フォルダーの中身を一覧します。File System プラグインの readDir() は、指定したフォルダーの直下だけを、名前と種類(ファイル・フォルダー・シンボリックリンク)の配列で返します。サイズや日時は含まれず、並び順も決まっていません。サブフォルダーの中まで一度にたどる方法は サブフォルダも含めて再帰的に一覧取得する で扱います。

前提条件

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

npm run tauri add fs
npm run tauri add dialog

readDir() の権限 fs:allow-read-dir は fs:default に含まれ、アプリ用フォルダー($APPDATA など)の中と、ダイアログで選ばれたフォルダーならそのまま使えます。ほかの場所は範囲(スコープ)を付けて足します。次の例は、ピクチャフォルダー($PICTURE)とその直下のフォルダーの一覧だけを許可します。fs:allow-read-dir に付けた範囲は一覧にだけ効き、ファイルの中身は読めません。

{
  "$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": "$PICTURE" }, { "path": "$PICTURE/*" }]
    }
  ]
}

範囲の判定は readDir() に渡したフォルダーにだけ行われ、一覧に出てくる名前は判定されません。そのため「一覧には出るのに読めない」ことがあります。たとえば Windows で $APPLOCALDATA を一覧すると WebView のデータ用の EBWebView が出てきますが、fs:default がその下を拒否しているので中は読めません。範囲の書き方の基本は ファイルやディレクトリを削除する にまとめています。

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

並べ替えて表示する

readDir() の順番は OS やファイルシステムによって違い、名前順とは限りません。画面に出すなら自分で並べ替えます。Intl.Collator の numeric: true を使うと「img2」が「img10」より前に来て、エクスプローラーや Finder の並びに近くなります。拡張子で絞るときは .PNG のような大文字も考えます。

import { readDir, BaseDirectory, type DirEntry } from '@tauri-apps/plugin-fs';

const collator = new Intl.Collator('ja', { numeric: true, sensitivity: 'base' });

// フォルダーを先に、同じ種類どうしは名前の中の数字を数として並べる
export function sortEntries(entries: DirEntry[]): DirEntry[] {
  return [...entries].sort(
    (a, b) => Number(b.isDirectory) - Number(a.isDirectory) || collator.compare(a.name, b.name),
  );
}

// アプリデータの photos の直下にある画像の名前を返す
export async function listImages(): Promise<string[]> {
  const entries = await readDir('photos', { baseDir: BaseDirectory.AppData });
  return sortEntries(entries)
    .filter((e) => e.isFile && /\.(png|jpe?g|webp)$/i.test(e.name)) // i で大文字の拡張子も拾う
    .map((e) => e.name);
}

DirEntry にあるのは name・isDirectory・isFile・isSymlink だけで、name はパスではなく名前です。シンボリックリンクは isSymlink だけが true になり、リンク先がフォルダーでも isDirectory は false です。

ユーザーが選んだフォルダーを一覧する

ダイアログで選ばれたフォルダーと直下は、その起動中だけ自動で範囲に加わります。直下のファイルは読めますが、サブフォルダーの中のファイルは読めません(フォルダ(ディレクトリ)を選択させる)。名前からパスを作るとき、join() を名前ごとに呼ぶと 1 件ごとにプロセス間通信(IPC)が起きるので、すぐに値が返る sep() でつなぎます。

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

// フォルダーを選ばせ、直下のファイルのフルパスを返す
export async function pickAndListFiles(): Promise<string[]> {
  const dir = await open({ directory: true });
  if (!dir) return []; // キャンセル
  const entries = await readDir(dir);
  return entries
    .filter((e) => e.isFile && !e.name.startsWith('.')) // 隠しファイルを除く
    .map((e) => `${dir}${sep()}${e.name}`);
}

空・無い・範囲外を見分ける

空のフォルダーでは [] が返り、エラーにはなりません。フォルダーが無い、ファイルを渡した、OS に読み取りを拒否された、のいずれかでは「failed to read directory at path: …」で始まるエラーに、範囲の外では「forbidden path: …」で始まるエラーになります。どれも catch で [] にしてしまうと「空のフォルダー」と区別できず、capability の書き漏れに気付けません。範囲外は別の状態として画面に出します。初回起動時はアプリ用フォルダー自体が無いことにも注意します(新しいディレクトリ(フォルダ)を作成する)。

import { readDir, BaseDirectory, type DirEntry } from '@tauri-apps/plugin-fs';

export type Listing =
  | { status: 'ok'; entries: DirEntry[] } // 空のフォルダーなら entries が []
  | { status: 'forbidden' | 'failed'; message: string };

export async function tryListDir(dir: string, baseDir?: BaseDirectory): Promise<Listing> {
  try {
    return { status: 'ok', entries: await readDir(dir, { baseDir }) };
  } catch (e) {
    const message = String(e);
    // 範囲外は「forbidden path」、無い・ファイルだった・拒否されたは「failed to read directory」
    return { status: message.startsWith('forbidden path') ? 'forbidden' : 'failed', message };
  }
}

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

一覧にサイズや更新日時も出すとき、JS で名前ごとに stat() を呼ぶと件数分の IPC が起きます。Rust の std::fs::read_dir() なら、1 回のコマンドで名前・種類・サイズ・更新日時をまとめて返せます。また readDir() は、名前が UTF-8 として正しくないファイルを一覧に含めませんが、Rust で to_string_lossy() を使えば置き換え文字にして残せます。Rust の std::fs にはスコープが効かないので、受け取ったフォルダー名は確かめてから使います。

use serde::Serialize;
use std::io::ErrorKind;
use std::path::{Component, Path};
use std::time::UNIX_EPOCH;
use tauri::Manager;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ListedEntry {
    name: String,
    is_dir: bool,
    is_symlink: bool,
    size: u64,
    modified_ms: Option<u64>, // 更新日時(UNIX エポックからのミリ秒)
}

/// アプリデータの中の、名前 1 つのフォルダーを一覧する。無ければ空の一覧
#[tauri::command]
fn list_app_folder(app: tauri::AppHandle, name: String) -> Result<Vec<ListedEntry>, String> {
    let mut parts = Path::new(&name).components();
    if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
        return Err(format!("invalid folder name: {name}"));
    }
    let dir = app.path().app_data_dir().map_err(|e| e.to_string())?.join(&name);
    let entries = match std::fs::read_dir(&dir) {
        Ok(entries) => entries,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(format!("{}: {e}", dir.display())),
    };
    let mut out = Vec::new();
    for entry in entries.flatten() {
        let Ok(kind) = entry.file_type() else { continue };
        let meta = entry.metadata().ok(); // リンクはたどらない
        out.push(ListedEntry {
            name: entry.file_name().to_string_lossy().into_owned(),
            is_dir: kind.is_dir(),
            is_symlink: kind.is_symlink(),
            size: meta.as_ref().map_or(0, |m| m.len()),
            modified_ms: meta
                .and_then(|m| m.modified().ok())
                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                .map(|d| d.as_millis() as u64),
        });
    }
    // フォルダーを先に、同じ種類どうしは名前順(大文字小文字を無視)
    out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
    Ok(out)
}

#[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![list_app_folder])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

type ListedEntry = { name: string; isDir: boolean; isSymlink: boolean; size: number; modifiedMs: number | null };

const list = await invoke<ListedEntry[]>('list_app_folder', { name: 'exports' });
for (const e of list) {
  const date = e.modifiedMs === null ? '-' : new Date(e.modifiedMs).toLocaleString();
  console.log(`${e.isDir ? '[DIR] ' : ''}${e.name}  ${e.size} bytes  ${date}`);
}

動作確認

npm run tauri dev で起動し、アプリデータの photos に img10.jpg・img2.PNG・img1.png・memo.txt を置いてから listImages() を呼ぶと、数字の順に並び、memo.txt は除かれます。続けて tryListDir('missing', BaseDirectory.AppData) と tryListDir('C:/Windows') の status を表示すると、無いフォルダーと範囲の外が別の結果になります。

[ "img1.png", "img2.PNG", "img10.jpg" ]
failed
forbidden

Rust の list_app_folder は、exports が無ければエラーではなく空の配列を返します。

よくあるエラーと対処法

  • 「forbidden path: <パス>, maybe it is not allowed on the scope for allow-read-dir permission in your capability file」: 一覧しようとしたフォルダーが範囲の外です。$PICTURE/* だけでは $PICTURE 自体は一覧できないので、フォルダー自体も書きます。baseDir の付け忘れも確かめます。リリースビルドでは「forbidden path: <パス>」だけになります。
  • 「failed to read directory at path: <パス> with error: …」: フォルダーが無い、ファイルのパスを渡した、OS に読み取りを拒否された、のどれかです。続く OS のメッセージで見分けます。
  • 「fs.read_dir not allowed. Permissions associated with this command: fs:allow-app-meta, …」: capability に fs:default も fs:allow-read-dir もありません(リリースビルドでは「Command plugin:fs|read_dir not allowed by ACL」)。
  • 一覧に出たファイルを開くと forbidden path になる: 一覧と読み取りの範囲は別です。fs:allow-read-dir だけに付けた範囲、隠しファイル、deny に書いた名前は、一覧に出ても読めません。

OS ごとの違いと注意点

  • macOS / Linux: . で始まる名前も一覧には出ますが、範囲の * と ** に一致しないので、開こうとすると forbidden path になります。一覧の段階で除くと扱いがそろいます。macOS では Finder が作る .DS_Store もよく混ざります。
  • Windows: 隠し属性のファイル(desktop.ini など)もそのまま含まれ、DirEntry からは隠しかどうか分かりません。
  • 共通: 表示中の一覧を最新に保つなら、定期的に読み直すより ファイルの変更をリアルタイムで監視する を使います。1 つのパスの有無だけなら ファイルやフォルダが存在するか確認する の exists() で足ります。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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