CollupをElectronか
らTauriで作り直した

以前に作ったCollupという自作のファイル整理ツール。
ファイルをドロップしてフォルダ名を入力すると、指定したフォルダへまとめて移動してくれる、シンプルなものです。

しかし、Electronアプリのため起動するたびにやや重さを感じて、「これ、もっと軽くできるんじゃないか」と思い続けていました。

機能は単純なのに、Electronだとメモリを300MB以上使います1
そこで Tauri(タウリ)で作り直すことにしました。

関連記事

1. なぜElectronからTauriへ移行するのか

TauriはRustで書いたバックエンドとOSのWebViewを組み合わせてデスクトップアプリを作るフレームワークで、バイナリサイズがElectronの数分の1になります。

1. なぜElectronからTauriへ移行するのか

Electronだと、ChromiumとNode.jsを丸ごとバンドルするので、どんなに小さなアプリでも最終バイナリが150MB前後になります2
しかし、Tauriはその代わりにOSが持っているWebViewを使うので、バイナリが数MB〜十数MBに収まります。

もちろん、「OSのWebViewを使う」ことにはデメリットもあります。
macOS(WebKit)、Windows(WebView2)、Linuxでレンダリングの挙動が微妙に違う3
それでもCollupのような単機能ツールなら、バイナリの軽さのほうが価値があります。

2. Rust + バニラJSの2層構造

Collup2のコードは大きく2層に分かれています。

アーキテクチャ — Rust + バニラJSの2層構造 UI層 HTML + JavaScript invoke バックエンド層 Rust IPC = プロセス間通信でJS→Rustを呼び出す
UI層 (HTML + JavaScript)
    ↕ Tauri invoke(IPC)
バックエンド層 (Rust)

IPC(Inter-Process Communication)とはプロセス間通信のことで、ここではJavaScriptからRustの関数を呼び出す仕組みを指します。
Tauriではinvokeという関数でこれを実現します。

ui/
  index.html          # レイアウト
  app.js              # UI状態管理・イベント処理
  history.js          # 履歴ロジック
  path-utils.js       # パス正規化
  path-autocomplete.js # Tab補完Code language: PHP (php)

2.1. モジュールをどうつなぐか

フレームワークがないので、モジュール間の依存をどう解決するかが問題になります。
採用したのはUMD(Universal Module Definition)パターンです。

(function (root, factory) {
  if (typeof module === "object" && module.exports) {
    module.exports = factory(); // Node.js(テスト用)
    return;
  }
  root.Collup2History = factory(); // ブラウザ(本番用)
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
  // モジュール本体
});Code language: JavaScript (javascript)

このパターンで書くと、同じファイルをブラウザでもNode.jsでもそのまま動かせます4

3. Rustでファイル移動を実装する

バックエンドのコアはfile_manage_processという1つのコマンドです。

Rustでファイル移動を実装する file_manage_process #[tauri::command]でJSから呼び出し可能に 1 検証 2 作成 3 移動 先頭ファイルの親の親 + フォルダ名 fs::renameで一括移動
#[tauri::command]
fn file_manage_process(file_list: Vec<String>, folder_name: String) -> Result<(), String> {
    let folder_name = folder_name.trim();
    if file_list.is_empty() || folder_name.is_empty() {
        return Ok(());
    }

    let new_folder = compute_new_folder(&file_list, folder_name)?;
    if !new_folder.exists() {
        fs::create_dir_all(&new_folder).map_err(|e| format!("failed to create folder: {e}"))?;
    }

    for old_path in &file_list {
        let file_name = Path::new(old_path)
            .file_name()
            .ok_or("failed to get file name")?;
        let new_path = new_folder.join(file_name);
        fs::rename(old_path, &new_path)
            .map_err(|e| format!("failed to move {}: {e}", old_path))?;
    }

    Ok(())
}Code language: PHP (php)

#[tauri::command]というアトリビュート(属性指定子)を付けることで、JavaScriptからinvoke("file_manage_process", payload)で呼び出せるようになります5

移動先パスの計算は関数を分けています。

fn compute_new_folder(file_list: &[String], folder_name: &str) -> Result<PathBuf, String> {
    let first_path = Path::new(&file_list[0]);
    let current_folder = first_path
        .parent()
        .ok_or("failed to resolve current folder")?;
    let parent_folder = current_folder
        .parent()
        .ok_or("failed to resolve parent folder")?;

    Ok(parent_folder.join(folder_name))
}Code language: JavaScript (javascript)

先頭ファイルの.parent()(親フォルダ)の.parent()(その親)に、指定フォルダ名を結合する。
それだけです。

3.1. Tauriのファイルドロップ問題

実装中でいちばん時間を取られたのは、ファイルドロップのパス取得でした。

詰まったところ — ファイルドロップ問題 DOM API dataTransfer .files file.path が空 Tauri API onDragDropEvent event.payload .paths 絶対パスが取得できる Tauri独自のイベントを使う必要がある

ブラウザのDOM APIにはdataTransfer.filesというプロパティがあり、ドロップされたファイルの情報を取得できます。
ところがTauriのWebViewでは、このプロパティからファイルの絶対パスが取れません。
file.pathが空になってしまう6

// これはTauriのWebViewでは動かない
dragFile.addEventListener("drop", function (e) {
  const files = e.dataTransfer.files;
  for (const file of files) {
    console.log(file.path); // → undefined or ""
  }
});Code language: JavaScript (javascript)

解決策はTauri独自のドロップイベントを使うことです。

const webviewWindow = window.__TAURI__?.webviewWindow?.getCurrentWebviewWindow?.();
const unlisten = await webviewWindow.onDragDropEvent((event) => {
  if (event.payload.type !== "drop") {
    return;
  }
  addFilePaths(event.payload.paths); // ここに絶対パスが入っている
});Code language: JavaScript (javascript)

onDragDropEventはTauri側でファイルシステムのパスを解決してくれるので、絶対パスが得られます7
awaitで登録して、unlistenbeforeunloadで呼ぶのがリソースリークを防ぐ正しい書き方です。

DOMのドロップAPIに慣れていると、このTauri固有の方式は最初気づきにくい。
ドキュメントを読むよりも「動かして観察する」のほうが早かったです。

4. 履歴機能

フォルダ名の入力履歴はhistory.jsが担います。
仕様は、

  • 新しいものを先頭に追加
  • 同名が既にあれば古いほうを削除して先頭に移動
  • 上限50件
  • localStorageに永続化

これだけです。
コアロジックはnormalizeHistoryという関数1つに集約しています。

function normalizeHistory(list, limit) {
  const max = Number.isInteger(limit) && limit > 0 ? limit : 50;
  const result = [];
  for (const item of list) {
    const name = String(item || "").trim();
    if (name === "") continue;
    if (!result.includes(name)) {
      result.push(name);
    }
    if (result.length >= max) break;
  }
  return result;
}Code language: JavaScript (javascript)

pushHistoryはこの関数を呼ぶだけです。

function pushHistory(list, name, limit) {
  const next = String(name || "").trim();
  if (next === "") return normalizeHistory(list, limit);

  const filtered = normalizeHistory(list, limit).filter((item) => item !== next);
  return normalizeHistory([next, ...filtered], limit);
}Code language: JavaScript (javascript)

新しい値を先頭に置いて、同名を除いた残りをつなぎ、もう一度normalizeに通す。
ロジックがnormalizeHistoryに集まっているので、pushHistoryは「先頭に追加してから整理する」という意図がそのまま読めます。

4.1. Tab補完

新しいcollup2の目玉機能は、Tab補完。
ターミナルのように過去に使った移動先を途中で補完できるようにしました。

Tab補完 — クロージャでstateを閉じ込める createCompleter関数 lastRaw matches index クロージャで状態を保持 外から直接触れない→安全 0 1 2 0 Tab連打で循環

これはpath-autocomplete.jsが担います。

function createCompleter(getCandidates) {
  let lastRaw = null;
  let matches = [];
  let index = -1;

  function complete(rawValue) {
    const value = String(rawValue || "");
    if (value !== lastRaw) {
      matches = readCandidates().filter((item) => item.startsWith(value));
      index = -1;
    }
    lastRaw = value;
    if (matches.length === 0) return { value, matched: false };
    index = (index + 1) % matches.length;
    return { value: matches[index], matched: true };
  }
  // ...
}Code language: JavaScript (javascript)

lastRawmatchesindexはクロージャで閉じ込めたプライベートなstateです。
クロージャとは、関数が自分が定義されたスコープの変数を「覚えている」仕組みです。
外から直接触れないので、状態が意図しない場所で書き換わる心配がありません。

completeを同じ入力で連続して呼ぶとindexが0→1→2→0と循環するので、Tabを連打するたびに次の候補に進みます。

ただ、ひとつ面倒だったのは、ブラウザがTabキーをフォーカス移動に使う問題です。
input要素のkeydownだけでpreventDefault()しても、ブラウザによっては拾いきれない。
最終的に、captureフェーズ(イベントが下に伝わる前の段階)でもdocumentkeydownリスナーを追加して二重に抑止しています8

document.addEventListener(
  "keydown",
  function (e) {
    if (!isTabKeyEvent(e) || document.activeElement !== nameInput) return;
    e.preventDefault();
    e.stopPropagation();
    completeByTab();
  },
  true, // ← captureフェーズで捕捉
);Code language: JavaScript (javascript)
  1. メモリ使用量はアプリの複雑さや起動状態によって変わる。複数のベンチマークによるとElectronのアイドル時のメモリ使用量は150〜300MB程度が実測値の中心帯で、Tauriは30〜50MB前後。ただしTauriの公式ベンチマークはChromiumの共有メモリを考慮していないという指摘もあり、macOS環境ではWebKitがElectronより多くRAMを消費するケースも報告されている。 – Tauri vs. Electron: performance, bundle size, and the real trade-offs
  2. 実測値ではElectronのインストーラサイズは80〜120MB程度が中心で、150MBは上限寄りの値。Tauriのバイナリは2.5〜10MB前後になることが多い。 – Tauri VS. Electron – Real world application
  3. WindowsのWebView2はChromiumベースのため挙動の差は小さいが、macOSのWebKitとLinuxのWebKitGTKはCSSベンダープレフィックスの扱いやフォントレンダリングで差が出やすい。特に-webkitプレフィックスの付け忘れでレイアウトが崩れる事例が報告されている。TailwindCSSのようなCSSフレームワークを使うとベンダープレフィックスが自動付与されるため対策になる。 – Tauri vs Electron: The best Electron alternative created yet
  4. UMDはCommonJS・AMD(RequireJS)・グローバル変数の三環境に対応することが本来の定義で、ここではNode.js(テスト)とブラウザ(本番)の両立に限定して活用している。なおUMDはESモジュール(import/export構文)には対応しないため、ESM環境で使う場合は別途対応が必要になる。 – What are UMD modules?
  5. Rustのfs::renameは同一ファイルシステム内の移動しか保証しない。移動元と移動先が別ドライブや別パーティションにまたがる場合はエラーになる。異なるドライブ間での使用を想定する場合はコピー後に元ファイルを削除する実装が必要になる。 – std::fs::rename – Rust documentation
  6. Tauri 2.x系ではwindow.TAURI経由のアクセスは非推奨で、@tauri-apps/api/webviewからgetCurrentWebview()をインポートして使う形が公式のAPIとなっている。使用するTauriのバージョンによって記法が異なるため、プロジェクトのバージョンに合わせて確認が必要。 – webview | Tauri
  7. DOMのFile APIがWebViewで絶対パスを返さない理由はセキュリティ上の制約によるもので、ブラウザ環境でも同様の制限がある。TauriがonDragDropEventで絶対パスを提供できるのは、ファイルシステムへのアクセスをRustレイヤーで処理しているため。 – drag and drop file and get full path · tauri-apps/tauri
  8. 「イベントが下に伝わる前の段階」という表現は厳密には逆で、captureフェーズはDOMツリーの上(document)から下(target要素)へイベントが伝播する段階を指す。通常のbubblingフェーズはtargetからdocumentへ向かって上に伝播する。addEventListener の第3引数にtrueを渡すとcaptureフェーズで捕捉でき、bubblingより先に処理が走るためデフォルト動作をより確実に抑止できる。