🐼

最強のCSVエディタ「SmoothCSV」を支える技術

に公開
2

自作の CSV エディタ SmoothCSV (v3) が Generally Available になったので、技術的な工夫とかを書きます。

また7/1 16時からの24時間、Product Hunt でローンチするので応援よろしくお願いします。
https://www.producthunt.com/products/smoothcsv

About Me

株式会社ヘンリーでエンジニア的なことをしつつ、個人開発してます。

About SmoothCSV

SmoothCSV は、macOS と Windows 向けの CSV エディタです。(Linux も近々)
初代 SmoothCSV は15年前に作っていて、昨年 v3 の開発を始めました。

https://smoothcsv.com


Excel ライクな操作感で、直感的に使える


CSVを扱うのに必要な、基本的〜応用的なツールが搭載されている


様々なフォーマットや文字コードに対応。列数が異なるCSVでも扱える


高速!大きなファイルでもスムーズに扱える

開発の経緯はこちら:

https://zenn.dev/kohii/articles/079c73ab14856f

技術的要素

主な技術スタック

技術構成

デスクトップアプリなので、技術的な構成としてはとてもシンプルです。更新の確認・ダウンロード以外で外部との通信はありません。

1. 複雑なデスクトップアプリを作る技術

SmoothCSV は多機能でありながらもシンプルで拡張性高いアプリケーションを目指しています。

Tauri はいいぞ

Tauri は Electron 代替のデスクトップアプリフレームワークです。(Tauri v2 からモバイルアプリにも対応しています。)

Electron がバックエンドに Node.js、フロントエンドに Chromium を使うのに対し、Tauri はバックエンドに Rust、フロントエンドに OS 同梱の Web レンダラーを使います。そのため配布サイズが小さく、メモリ使用量が少ないという特徴があります。

Tauri は各種 API や プラグインが充実していて、やりたいことがすぐに実現できます。

パッケージ切り出しによる関心の分離

pnpm workspaces + Turborepo でモノレポとして管理しています。

(root)
├─ apps
│  └─ desktop             # SmoothCSVのデスクトップアプリ本体 (packages/*に依存)
└─ packages
   ├─ csv                 # CSVのパース、文字列化
   ├─ grid-editor         # 表形式エディタのReactコンポーネントライブラリ
   ├─ result              # Result型のTypeScript実装
   ├─ utils               # 汎用ユーティリティ
   ├─ js-sql              # TypeScript製SQLパーサ・評価エンジン
   ├─ framework           # 汎用的なデスクトップアプリの基盤。コマンドシステムやキーバインディング、設定など
   ├─ context-expression  # VSCode互換の条件式パーサ
   └─ l10n                # VSCode互換のローカライズAPI

packages 以下には、SmoothCSV 固有の文脈に依存しない、独立した関心事をライブラリとして切り出しています。これにより個々の関心事を扱いやすくしつつ、desktop 本体の複雑さを軽減します。
desktop 内でも、なるべく独立した関心事毎にコードを構成するようにしています。(package by feature 的な)

コマンドを中心とした仕組み

SmoothCSV ではユーザーがアプリケーションに対して実行可能な操作をコマンドとして定義します。これ自体は前バージョンの SmoothCSV でも同様でしたが、今回は VS Code に似せた API として作っています。

コマンドはハンドラー関数とメタデータ(ID、タイトル、利用可能な条件など)から構成されます。

タブを閉じるコマンドのイメージ:

aCommand = {
  command: "view.closeEditor",
  title: "Close Editor",
  category: "View",
  handler: async () => {
    // コマンドが呼び出されたときの処理
  }
}

コマンドは、キーボードショートカットやコンテキストメニュー、ボタンなどの UI 要素にマッピングされ、トリガーされます。また「コマンドパレット」から任意のコマンドを検索し実行することもできます。

タブを閉じるコマンドにショートカットキーを割り当てる例:

{
  "keybindings": [{
    "command": "view.closeEditor",
    "key": "ctrl+w",
    "mac": "cmd+w"
  }]
}

コマンドパレットから実行する例:

一定程度の複雑さを持つアプリケーションであれば、コマンドを中心とする拡張ポイントを用意しておくことで機能を足しやすくなります。

コマンドやメニューの有効化条件を宣言する仕組み

これも VS Code の when clause contexts を真似た仕組みです。

各コマンドやメニュー、キーバインドは、それが使えない状況があります。
例えばセルの編集を開始するコマンドは、Grid エディタがフォーカスを持ち、セル編集前である必要があります。
SmoothCSV ではこのような条件を宣言的に表現する記法を用意しています。

{
  "command": "cellEditor.startEditingCell", // セルの編集を開始するコマンド
  "enablement": "gridEditorCellFocus && inReadyMode", // Gridエディタがフォーカスされていて、セル編集中でない ときにコマンドを使える
  ...
}

パース処理の実装はやや難易度が高いものの、このような仕組みがあることで要件を設定化でき、将来的な拡張やカスタマイズが容易になります。

初期段階から拡張 API を用意

コマンドの話もそうですが、いきなり個別の機能を作らずに、拡張 API を先行して作っています。

将来的にユーザーが拡張を作れるように...というものありますが、標準機能も作りやすくするのがメインの目的です。安定した API があることで開発が楽になりますし、実際にいくつかの機能はシステム拡張として作っています。

SmoothCSV 内部で拡張として作っているものたち:

利点

  • 関心の分離
    • 関心事が各拡張のディレクトリに閉じる
    • 整頓されるので読みやすい
  • AI に作らせやすい
    • 設計やコード構成が多少雑でも、拡張内に閉じているのであまり気にならない
    • 気に入らなくなったら拡張ごと捨てて作り直せばいい
  • 遅延ロード
    • 拡張の機能が実際に使われるまでコードの読み込みを遅延 → パフォーマンス向上

ただし、この方法が上手くいく状況は限定的かもしれません。初期の段階でどのような API が必要か見えず、時間を浪費する可能性が高いためです。(SmoothCSV はリプレイス開発なので比較的見通しが立ちやすかった)

状態管理に MobX を採用

フロントエンドでの状態管理は、自作のシンプルな Store → Zustand → また自作…といろいろ試した後に MobX に落ち着きました。
開発初期には、状態をプレーンなオブジェクトとして扱えることにこだわっていましたが、そうすると、各ユースケースの処理を実装するための手数が多くなったり、computed な値の管理が難しかったりしました。

これらの問題は MobX を使うことでうまく解決されました。
MobX では class ベースのオブジェクトで状態を管理し、React コンポーネントにリアクティブに反映させられます。

// 状態を保持するMobXオブジェクト
class FindWidget { // 検索・置換のためのウィジェットを司るモデル
  @observable mode: "find" | "replace" | "hidden"; // @observable をつけることで、プロパティの変化が観測可能になる
  @observable.ref findParams: FindParams; // @observable.ref は参照の変化のみを観測する。(FindParamsはreadonlyなobjectなので更新時はまるごと置き換え)
  @observable.ref replaceParams: ReplaceParams;

  @computed // 導出プロパティ (値はキャッシュされる)
  get isVisible() { return this.mode !== "hidden" }

  @action.bound // 状態を変化させるには @action をつける
  hide() { this.mode = "hidden" }
}

// Reactコンポーネントから利用 (observer でラップすることで、状態の変化に対してリアクティブに描画される)
const FindWidgetComponent = observer(() => {
  const model: FindWidget = ...
  if (!model.isVisible) return null;
  return <FindWidgetForm findParams={model.findParams} onHide={model.hide} />; // ピュアなコンポーネントを呼び出す
});

一方で、MobX への依存範囲は最小限に抑えるようにしています。

  • Container/Presentational Pattern 的な
    • 基本的にコンポーネントはピュアかつコントローラブルな関数コンポーネントとして作る (Presenter)
    • トップレベルのコンポーネント (Container) だけが MobX に依存し、MobX オブジェクトから Presenter コンポーネントの props にマッピングする
  • 重要・複雑なロジックもなるべく独立した純粋関数として作る
    • MobX オブジェクトのメソッドとして作ると、不要なプロパティへの依存が生まれてしまう (ほとんどの関数はオブジェクト全体を必要としない)

不要な依存がないコンポーネントや関数は、安定性が高く資産となります。SmoothCSV では資産を積み上げていくことにこだわっています。

自動更新

SmoothCSV は新しいバージョンがリリースされたら自動で検知し、ユーザーに知らせ、そのままダウンロード・インストールできる仕組みがあります。
機能追加やバグ修正を高速に行うために、この仕組みは非常に重要です。

Tauri には updater というプラグインがあり、これらの処理を簡単に実装できるようになっています。
SmoothCSV では、更新情報を記述した JSON と配布物、署名を GitHub Releases にアップロードしておき、アプリケーション起動時に新しいバージョンがないか見に行きます。

2. 表形式エディタを作る技術

SmoothCSV では Excel ライクな表形式エディタを実装しています。中核の React コンポーネントは、SmoothCSV から独立したプライベートライブラリとして作っています。

セルの描画

セルの描画は div を並べて表示していますが、CSV ファイルは数百万行のような大量のデータになる場合があります。すべてのセルをそのまま描画するのは非現実的なので、SmoothCSV ではバーチャルスクロールを行っています。が、普通のバーチャルスクロールにも実は限界があり、スクロールの仕組みから自作しています。
詳しくはこちら:

https://zenn.dev/kohii/articles/50e0bf572aac0b

セル編集

Excel や Google スプレッドシートでは、何か文字を入力すると、そのままセル編集モードになります。
世の中の表形式エディタでもこの挙動はよく見られますが、日本語入力でも正しく動作するものは少数です。

よくある実装

  1. keydown を監視してセル編集を開始
  2. 入力された文字をセルエディタの初期値としてセットして表示

これだと日本語入力で「あ」と入力したときに、「a」が入力されてしまいます。

SmoothCSV の実装

  • 見えない textarea を配置しフォーカス状態にする
  • 何か文字が入力されたら、この textarea をそのままセルエディタとして表示する


セル編集前から<textarea>が存在していてフォーカスを持っている

入力内容に合わせてセルエディタを動的に伸縮

セルエディタにテキストを入力すると、その内容に合わせてセルエディタの幅・高さを変化させます。
仕組みとしては、textarea と全く同じスタイルを付与した見えない span を置いておき、textarea に入力された内容をコピーして入れます。その span の幅・高さを取得し、textareastyle としてセットします。

3. SQL 統合機能を作る技術

SmoothCSV では、SQL を利用した機能が2つあります。

フィルタ

指定した条件に一致する行だけを表示し、他を非表示にする機能です。Excel や Google スプレッドシートにもフィルタ機能は備わっていますが、SmoothCSV ではより柔軟なフィルタリングができるように、条件を SQL の WHERE 句で指定できるようにしています。

主目的は、文字列でフィルタ条件を表現できることであり、そのために広く親しまれている SQL の記法を借りました。

  • フィルタ条件が文字列なのでコピー&ペーストで保存や再利用しやすい
  • AI に生成させやすい
  • パースすれば構造化データとしても扱える

また、条件を GUI で編集する機能もあり、非技術者でも利用しやすくしています。


内部では SQL ↔ AST ↔ GUI用モデル みたいな変換をしています

SQL コンソール

SQL コンソールは、SmoothCSV 上で開いているファイルもしくはローカルの CSV ファイルに対して SQL の SELECT 文を発行する機能です。
"@file:/Path/To/File.csv" のような記法で、CSV ファイルをテーブルとして扱えます。

仕組み

SQLite を組み込んで使用しています。SQL を入力し実行すると、SQL からテーブル記法を抽出し、対応する一時テーブルを作り、データをロードした後に、実際の SQL を実行します。

CREATE TEMPORARY TABLE "@file:/Path/To/File.csv" ( -- SQLiteでは""で囲めばどんなテーブル名でも利用可
  /* columns */
)

おわりに

最後まで読んでいただきありがとうございました🙇‍♂️
書きたいことが多く、簡単な説明になってしまっているので、ここどうなってるの?とかあればお答えするのでお気軽に聞いてください。

質問や要望、フィードバック等があれば、コメントや GitHub Issues でお知らせください。

おまけ

開発を支援して頂けると嬉しいです。

https://buymeacoffee.com/kohii

Discussion

UU

パッケージ切り出しによる関心の分離や、コマンドを中心とした仕組みってどうやって学びましたか?

kohiikohii

IDE の設計に影響受けてると思います。(15年ほど前ですがプラグインシステムには感動した覚えがあります。)
あとは大規模なコードを自分で作って自分で運用するなかで、いろいろ工夫して行き着いた感じだと思います。