tmuxのセッション管理を簡単にする「tmuxist」の紹介

こんにちは、K@zuki.です。

tmuxを使った開発環境の構築は便利ですが、毎回手動でウィンドウやペインを設定するのは面倒です。 実は約7年前から自分用に開発・使用している設定ファイルからtmuxセッションを自動生成できるtmuxistというツールがあります。

github.com

Claude Codeでtmuxを使う人が増えてきているので、改めて簡単に紹介できたらなと思っています。

tmuxistとは

tmuxistは、YAMLまたはTOMLの設定ファイルからtmuxセッションを自動的に作成・管理するツールです。
元々は2017年頃から自分の開発環境を効率化するために作り始めたツールで、長年使い続けながら改良を重ねてきました。

特徴としては、

  • 単一バイナリで配布可能、Ruby/Pythonのランタイム不要
  • 必要十分な機能に絞り、使いやすさを重視
  • 7年間の実使用で洗練された機能セット

といった形です。
tmuxinatorやtmuxpなどと比べると圧倒的に機能は劣りますが、必要最小限の機能で構成されていて、単一バイナリで動作させられるのでサーバで作業する場合でも便利です。
start というセッションを管理するコマンドがありますが、すでに起動しているセッションがあれば、簡単にセッションにアタッチもできるので、急にエディタが終了した場合にも重宝します。

インストール

Homebrew(macOS/Linux

brew tap corrupt952/tmuxist
brew install tmuxist

手動インストール

Releasesから各OS向けのバイナリをダウンロードしてください。単一バイナリなので、パスを通すだけで使えます。

機能

何度も紹介してるので詳細はGitHubを見てもらったらいいですが、簡単に説明します。

  • tmuxist init ... 設定ファイルの雛形を生成(YAML/TOML形式を選択可能)
  • tmuxist start ... 設定ファイルを読み込んでtmuxセッションを起動・もしくは起動済みのセッションへのアタッチ
  • tmuxist kill ... 設定ファイルで定義されたセッションを終了

特に重宝するのはtmuxist startでして、何度も説明しますがセッションへのアタッチが比較的容易であるのが便利です。

Claude Codeを利用する場合にここ数ヶ月私がどのプロジェクトにも入れているテンプレ設定ファイルは👇になるので、参考にしてみてください。 github.com

Grid記法によるレイアウト設定

さっき実装してきたんですが、従来のtmuxレイアウト設定はmain-verticaltiledなど、名前から実際の配置をイメージしづらいものでした。
tmuxist v1.2.0から、直感的なGrid記法が使えるようになりました。

name: development
windows:
  # 2x2のグリッドレイアウト
  - layout: "2x2"
    panes:
      - command: htop
      - command: docker stats
      - command: tail -f app.log
      - command: watch date

これは以下のようなレイアウトになります。

┌────────┬────────┐
│ htop   │ docker │
├────────┼────────┤
│ tail   │ watch  │
└────────┴────────┘

対応しているGrid記法は他にも以下のようなものがあります。

  • "2x2" - 2列×2行(4ペイン)
  • "3x2" - 3列×2行(6ペイン)
  • "4x1" - 4列×1行(横に4分割)
  • "1x4" - 1列×4行(縦に4分割)

また割合指定なども対応しているので、詳細はGitHubのREAMDEを見てみてください。

github.com

実際の使用例

基本的な設定(.tmuxist.yaml

name: my-project
root: ~/projects/myapp
windows:
  # エディタとサーバー
  - layout: main-vertical
    panes:
      - command: nvim
      - command: npm run dev
        size: "25%"

  # モニタリング
  - layout: "2x2"
    panes:
      - command: htop
      - command: docker stats
      - command: tail -f logs/app.log
      - command: watch -n 1 'netstat -tuln'

セッションの起動

# プロジェクトディレクトリで
tmuxist start

# カスタム設定ファイルを指定
tmuxist start -f custom-config.yaml

なぜ自作しているのか

7年前、既存のツール(tmuxinator等)を試しましたが、自作することに決めました。
理由はとても単純で

  • Ruby/Pythonの依存関係が煩わしい ... サーバー環境やCIでの利用時に問題になることが多かった

だけですね。
概ねどの環境でもRuby/Pythonが入ってるケースもあるんですが、コンテナ環境やCI、一部のminimalなサーバではツールのためだけにインストールするのが癪だったのでこのような形でやっています。

結果として、Go言語で必要最小限の機能に絞ったツールを作ることで、これらの問題を解決できいます。

競合ツールとの比較

tmuxistの最大の利点は、依存関係なしで動作することです。
CIパイプラインやDockerコンテナでも、バイナリを配置するだけで使えます。
逆にいえばそれ以外で秀でている点は皆無なので、人にtmux周りで聞かれた時は別のツールをおすすめすることが多いですね。

ツール 言語 依存関係 特徴
tmuxist Go なし シンプル
tmuxinator Ruby Ruby必須 最も人気
tmuxp Python Python必須 高機能

おわりに

tmuxistは「シンプルで高速、でも必要な機能は押さえている」をコンセプトに、7年間の実使用を通じて自分のために磨き上げてきたツールです。
特にGrid記法による直感的なレイアウト設定は、tmux初心者の方でも簡単にペインのレイアウトを組みやすくなっているかなと思います。
また、今後も時間があればアップデートしていくと思います。

自分用に作ったツールですが、同じような悩みを持つ方の役に立てば幸いです。
もしtmuxでの開発環境構築を効率化したい方は、ぜひ試してみてください。

GitHubでのフィードバック、コントリビューションもお待ちしています!

Vue.jsコンポーネントの依存関係を可視化する「Voyager」を暫定公開しました

約半年ぐらい前にVoyagerというツールの開発を始めたことを書きました。

khasegawa.hatenablog.com

全然進められていなかったんですが、少しだけ時間ができたので暫定的に公開しました、という紹介です。

github.com

Voyager

改めてVoyagerについて紹介すると、Vueのコンポーネントの依存関係を簡単に可視化するためのツールです。

インストール

インストールはすごく簡単で npm install -g @voyager-vue/cli でインストールすることができます。
GitHubのREADMEを読んでもらえるといいかもしれません。

www.npmjs.com

機能

現時点の機能としては3つしかありません。
どれも完成度は低いのであまり期待しないでください。

graph(HTMLで可視化)

Vueコンポーネントの依存関係をインタラクティブなHTMLとして可視化する機能です。 単一HTMLを出力するので、CIのアーティファクトで保存した結果などを確認することも可能です。

vue-element-adminを可視化した例

deps(依存関係の表示)

Terminal上で特定ファイルの依存関係をツリー形式で表示する機能です。 どのファイルをインポートしているか、どのファイルからインポートされているかを確認可能することができ、影響範囲を特定することができます。

$ voyager deps vue-element-admin -t src/components/Breadcrumb/index.vue

index.vue
├── imports (1):
│   └── path-to-regexp
└── imported by (1):
    └── src/layout/components/Navbar.vue

stats(統計情報の表示)

プロジェクト全体の統計情報(ファイル数、循環参照、孤立コンポーネント、最も多くインポートされているファイルなど)を表示する機能です。
現状だとあまり意味のない数値も混じっているので、今後アップデートで強化予定です。

$ voyager stats vue-element-admin
📊 Component Statistics

Files:
  Total files: 210
  Vue components: 131
  Script files: 82
  Type definitions: 0

Dependencies:
  Total imports: 195
  Circular dependencies: 0
  Orphaned components: 93

Most imported files:
  src/api/article.js - 11 imports
  src/utils/index.js - 8 imports
  src/store/index.js - 7 imports
  src/api/qiniu.js - 6 imports
  src/utils/request.js - 6 imports
  src/utils/validate.js - 6 imports
  src/layout/index.vue - 5 imports
  src/components/PanThumb/index.vue - 5 imports
  src/router/index.js - 4 imports
  src/views/dashboard/admin/components/mixins/resize.js - 4 imports

今後の予定

あまり時間がとれないので、時間がある時に少しずつにはなりますが、

といった前回からの機能に加え、

  • VSCode拡張
  • ファイルごとの詳細なStats
  • junit系ツールへの対応

なども検討はしています。

おわりに

まだ開発初期段階ですが、より良いVue.jsの開発体験を提供できるよう、機能を充実させていきたいと考えています。

興味を持っていただけた方は、GitHubをチェックしていただけると嬉しいです。 また、フィードバックやコントリビューションも大歓迎です!

2分の壁を突破する!Claude Codeで長時間実行コマンドのタイムアウト解決法

英語で書いた記事をまだ日本語にし直していない K@zuki. です。

Claude Codeを使っていて、テストの実行やビルドが途中でタイムアウトで止まってしまった経験はありませんか?
Claude Codeにはデフォルトで2分のタイムアウトが設定されています。
今回は、この制限を回避する3つの方法を紹介します。

TL;DR

なぜタイムアウトが必要なのか

Claude Codeは対話型のAIツールですが、実行中のコマンドが無限ループに陥ったり、応答しなくなったりする可能性があります。
デフォルトの2分という制限は、多くのコマンドには十分に長いですが、以下のような場合では全然足りません。

私は大規模なテストの実行でしばしば遭遇しています。

その1 個別のコマンドでタイムアウトを指定

最も簡単な方法は、長時間実行されるコマンドに対して個別にタイムアウトを指定することです。

docker compose run --rm -it app bin/rspec # timeout 600000ms

Bashツールで実行するコマンドの前か後ろにtimeoutをミリ秒単位で時間を指定します。
最大値は600000ms(10分)です。

ミリ秒でなくてもいいのですがコマンドで実行している場合は、 timeout xms の形式で書いてる方が安定してTimeoutを変更してくれています。

その2 環境変数で全体のデフォルトを変更

頻繁に長時間のコマンドを実行する場合は、環境変数でデフォルトタイムアウトを変更できます。

export BASH_DEFAULT_TIMEOUT_MS=300000

これにより、全てのBashコマンドのデフォルトタイムアウトが5分になります。

docs.anthropic.com

その3 settings.json環境変数を設定

Claude Codeの設定ファイルにその2で指定した環境変数を設定しておくことも可能なようです。
※ こちらは動作確認しておらず、公式ドキュメントによる記述のため注意してください。

{
  "env": {
    "BASH_DEFAULT_TIMEOUT_MS": "300000"
  }
}

この設定により、Claude Codeを起動するたびに自動的にタイムアウトが5分に設定されます。

docs.anthropic.com

注意点

  • タイムアウトを長くしすぎると、問題のあるコマンドの検出が遅れる可能性があります。
  • 10分が最大値なので、それ以上の処理は分割を検討しましょう
  • CI/CDパイプラインでの実行とは異なり、対話的な使用を前提としています

短くする

逆に短くすることも可能です。

sleep 5 # timeout 1000ms

とやると書き、タイムアウトを短くすることも可能です。

まとめ

Claude Codeのタイムアウト設定は、開発効率を大きく左右する重要な要素です。
デフォルトの2分では不十分な場合は、今回紹介した方法で適切に調整しましょう。
特に環境変数やsettings.jsonでの設定は、一度設定すれば忘れることができるのでおすすめです。

皆さんもClaude Codeで快適な開発ライフを送ってください!

macOS用常駐アプリにMCP Serverを実装する

この記事は部分的にLLMで生成している箇所があります

ネタはたまっているので、どこかのタイミングで放出していきたい K@zuki.です。

今回は久しぶりの投稿になりますが、自作しているmacOSのメニューバー常駐アプリにMCP Serverを実装する話ですが、とにもかくにも見てもらった方が早いので以下をご覧ください。

元々、常駐アプリのフルスクリーン通知で任意の文字列に置き換えれる実装を用意していました。
それをMCP Serverとして利用可能にし、Claude Codeで作業が終わった時の通知として利用可能な状態にしています。

TL;DR

  • CLIツールではなくHTTP Serverを常駐アプリに実装
  • セキュリティリスクはそこそこあるので対策は検討する必要あり

既存のアプリにMCP Serverを組み込む理由

シンプルに興味関心があったからでしかないです。
一般的なMCP ServerであればCLIツールを書けばいいだけですが、今回のケースは既存のアプリにSSEなMCP Serverを組み込むことで、

  • アプリ自体の操作
  • アプリの設定変更

といったことをClaude CodeやCursor、Claude DesktopなどのMCP Clinetから呼び出すことができます。
他にもアプリの特定部分のみを流用(フルスクリーン通知など)することができるので、場合によっては便利です。

実装方針

MCP Serverを実装するにはいくつかのパターンがありますが、現時点だと大まかに

の2種類が存在します。
一般的にはCLIでServerを実装しますが、今になって思いましたがCLIで実装しておくのが無難だと思います。 が、常駐アプリ側でListenするのが面倒くさかったのでサクッと作れるHTTP Serverを立てることにしました。

仕組み

大まかな仕組みとしては

  1. mcp-remoteを呼び出し
  2. mcp-remote経由で常駐アプリで起動しているHTTP Serverへリクエス
  3. 常駐アプリで何らかの処理を行なう

といった流れになっています。 シーケンス図で表現すると以下のような形です。

Claude CodeやClaude Desktopでは、CLIしか対応していないため、mcp-remoteを使ってHTTP Serverと通信する方式をとっています。

実装

SwiftUIで自動起動する場合には以下のように定義しておき、

  import SwiftUI

  @main
  struct MyApp: App {
      @StateObject private var mcpServer = SimpleMCPServerWrapper()

      var body: some Scene {
          WindowGroup {
              ContentView()
          }
      }
  }

  class SimpleMCPServerWrapper: ObservableObject {
      private let server = SimpleMCPServer()

      init() {
          server.start()
      }

      deinit {
          server.stop()
      }
  }
  */

MCP Serverを以下のように定義しておくと動かすことができます。
コードを見てもらえるとわかると思いますが、想定よりも多くの作業が必要になるので、適宜AIに頼るといいでしょう。

 import Foundation
  import Network

  // シンプルなMCPサーバーの実装例
  class SimpleMCPServer {
      private var listener: NWListener?
      private let port: UInt16 = 8080

      func start() {
          let parameters = NWParameters.tcp
          parameters.allowLocalEndpointReuse = true

          guard let listener = try? NWListener(using: parameters, on:
  NWEndpoint.Port(integerLiteral: port)) else {
              print("Failed to create listener")
              return
          }

          self.listener = listener

          listener.newConnectionHandler = { [weak self] connection in
              self?.handleConnection(connection)
          }

          listener.start(queue: .main)
          print("MCP Server started on port \(port)")
      }

      func stop() {
          listener?.cancel()
          listener = nil
          print("MCP Server stopped")
      }

      private func handleConnection(_ connection: NWConnection) {
          connection.start(queue: .main)

          // HTTPリクエストを読み取る
          connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak
  self] data, _, isComplete, error in
              guard let data = data, error == nil else {
                  connection.cancel()
                  return
              }

              if let request = String(data: data, encoding: .utf8) {
                  print("Received request:\n\(request)")

                  // SSE接続の場合
                  if request.contains("GET /sse") {
                      self?.handleSSEConnection(connection)
                  }
                  // JSON-RPCリクエストの場合
                  else if request.contains("POST /") {
                      self?.handleJSONRPCRequest(connection, requestData: data)
                  }
              }

              if isComplete {
                  connection.cancel()
              }
          }
      }

      private func handleSSEConnection(_ connection: NWConnection) {
          // SSEヘッダーを送信
          let headers = """
          HTTP/1.1 200 OK\r
          Content-Type: text/event-stream\r
          Cache-Control: no-cache\r
          Connection: keep-alive\r
          Access-Control-Allow-Origin: *\r
          \r

          """

          connection.send(content: headers.data(using: .utf8), completion:
  .contentProcessed { _ in
              print("SSE connection established")
          })

          // 初期接続イベントを送信
          let connectEvent = "event: connected\ndata: {\"status\": \"connected\"}\n\n"
          connection.send(content: connectEvent.data(using: .utf8), completion:
  .contentProcessed { _ in })
      }

      private func handleJSONRPCRequest(_ connection: NWConnection, requestData: Data)
  {
          // HTTPボディを抽出(簡易的な実装)
          if let requestString = String(data: requestData, encoding: .utf8),
             let bodyStart = requestString.range(of: "\r\n\r\n") {
              let bodyData = String(requestString[bodyStart.upperBound...]).data(using:
   .utf8) ?? Data()

              // JSON-RPCリクエストをパース
              if let json = try? JSONSerialization.jsonObject(with: bodyData) as?
  [String: Any],
                 let method = json["method"] as? String,
                 let id = json["id"] {

                  let response: [String: Any]

                  switch method {
                  case "initialize":
                      response = [
                          "jsonrpc": "2.0",
                          "result": [
                              "protocolVersion": "2024-11-05",
                              "serverInfo": [
                                  "name": "simple-mcp-server",
                                  "version": "1.0.0"
                              ],
                              "capabilities": [
                                  "tools": [:],
                                  "resources": [:]
                              ]
                          ],
                          "id": id
                      ]

                  case "tools/list":
                      response = [
                          "jsonrpc": "2.0",
                          "result": [
                              "tools": [[
                                  "name": "hello",
                                  "description": "Returns Hello, World!",
                                  "inputSchema": [
                                      "type": "object",
                                      "properties": [:]
                                  ]
                              ]]
                          ],
                          "id": id
                      ]

                  case "tools/call":
                      if let params = json["params"] as? [String: Any],
                         let toolName = params["name"] as? String,
                         toolName == "hello" {
                          response = [
                              "jsonrpc": "2.0",
                              "result": [
                                  "content": [[
                                      "type": "text",
                                      "text": "Hello, World! from MCP Server"
                                  ]]
                              ],
                              "id": id
                          ]
                      } else {
                          response = [
                              "jsonrpc": "2.0",
                              "error": [
                                  "code": -32601,
                                  "message": "Method not found"
                              ],
                              "id": id
                          ]
                      }

                  default:
                      response = [
                          "jsonrpc": "2.0",
                          "error": [
                              "code": -32601,
                              "message": "Method not found"
                          ],
                          "id": id
                      ]
                  }

                  // レスポンスを送信
                  if let responseData = try? JSONSerialization.data(withJSONObject:
  response),
                     let responseString = String(data: responseData, encoding: .utf8) {

                      let httpResponse = """
                      HTTP/1.1 200 OK\r
                      Content-Type: application/json\r
                      Content-Length: \(responseData.count)\r
                      Access-Control-Allow-Origin: *\r
                      \r
                      \(responseString)
                      """

                      connection.send(content: httpResponse.data(using: .utf8),
  completion: .contentProcessed { _ in
                          connection.cancel()
                      })
                  }
              }
          }
      }
  }

Claude Codeから呼び出す

Claude Codeから呼び出す場合には、以下のような設定をします。

  {
    "mcpServers": {
      "hello-world": {
        "command": "npx",
        "args": [
          "mcp-remote",
          "http://localhost:8080/sse"
        ]
      }
    }
  }

あとは適切に設定されていれば呼び出すことが可能になり、冒頭でも紹介したようにアプリで用意した機能を利用することができます。

セキュリティ

HTTP Serverとして公開ということはある程度のセキュリティリスクがあることに注意が必要です。

1. ローカルホストバインディング

デフォルトではlocalhost127.0.0.1)にバインドしていますが、誤って0.0.0.0にバインドすると外部からアクセス可能になります。

// 安全:ローカルホストのみ
let listener = try? NWListener(using: parameters, on: NWEndpoint.Port(integerLiteral: port))

// 危険:全てのインターフェース
// let listener = try? NWListener(using: parameters, on: NWEndpoint.Port(rawValue: port)!)

2. 認証の欠如

現在の実装では認証機能がないため、ローカルの任意のプロセスがMCPサーバーにアクセスできます。
必要に応じていくつかの認証を検討してください。

  • APIキーベースの認証
  • トークンベースの認証
  • 接続元プロセスの検証

3. CORS設定

今回のケースでは考慮しなくても問題ないですが、場合によってはCORSを考える必要があります。
Access-Control-Allow-Origin*は開発時は便利ですが、本番環境では特定のオリジンのみ許可すべきで全開放するのは望ましくありません。

4. 入力検証

JSON-RPCリクエストの入力値を適切に検証し、インジェクション攻撃を防ぐ必要があります。

5. リソース制限

他にもアプリやPCによっては負荷が高まるので注意が必要です。

  • 同時接続数の制限
  • リクエストサイズの制限
  • レート制限

さいごに

今回はmacOS用常駐アプリにMCP Serverを組み込む話でした。
恐らくリリースする頃にはCLIに切り替えているかと思いますが、既存の常駐アプリでMCP ServerのI/Fを用意しておきたい場合の一例にしておいてください。
今回のように実装しておくことで、比較的簡単に自分のアプリをMCP Serverとすることが可能になります。
設定変更の容易化も狙えますし、現段階ではかゆいところに手が届かない機能を利用することや、アプリの特定機能操作も行えるので非常に便利です。

MCP Serverを組み込んでいるアプリは、週末にはベータテスターを募集する予定なので、興味がある人はTwitterかブログをウォッチしておいてください。

近況

雑に近況についてまとめていく。

会社員

役割の変化

主要事業の技術責任者としてのロールをやっていたけど、気づいたら全社を見ることになってた(≠ CTO)。
徐々にこなす業務の範囲が広がり、今では技術面全体に関わる立場になっています。

んで、結局お前何やってんの?みたいな話になると、中長期の方針や計画について考えたり、まとめたり、説明したりすることもあれば、普通にコード書いてる時もまだまだある。
一番やってることとしては、大まかな方針を決めて明文化したり、落とし所が必要そうなところに割って入って落とし所を見つけたりと、いろいろ。

対外発信への意欲

まあさすがに面倒とはいえ仕事なので、対外的な発信するかという気持ちになってきました。
これまでは内部での活動が中心でしたが、外部向けの情報発信も役割上必要なので重い腰をあげてやりはじめています。

個人事業主

仕事量

今年からはほぼゼロぐらいまでに一時的に業務をなくしました。
理由は自分の会社の方と、プライベートでの勉強や開発時間を作りたいのが大きい理由です。
時間は有限なので、優先したい活動に集中するための選択でした。

商工会議所への加入

まあいろんな目的があって地元の商工会議所に入ったよーぐらいとかかな。
地域とのつながりや、新たなネットワーク構築のきっかけとして始めました。

八王子なんだかんだ結構な年月いるし、好きだしね。

コワーキングスペース

一部の人は知っているかもしれませんが、一旦頓挫しています。
というのも今住んでいる箇所からいつ引っ越すのか私がコントロールできない状況にあるというのが強い理由です。
自分で開設することは当分ないかもしれませんが、どこかで関わっているかもしれませんね。

自分の会社

アプリの移管

元々個人で作ってたアプリとかを移管しといた。
これで個人事業主ではなく、会社として公開できる。

現状のサービス展開

サービスを作って公開はしてるけど、本格的なマーケティングをしたいプロダクトではないのでどこかで見かけたら「あ、これかな」と思ってもらえれば👌 基本的に自分があると便利なサービスやアプリを作るのは当分メインにする予定。

プライベート

ストレングスファインダー

あくまで自己診断の心理テストみたいなもんなんで「だからなに?」と思ってますが、個人のWillというか方向性を確認するためには良かったとは思う。

数年前の自己分析でも似たような結論にいたったので、結構近いように思える。
この結果をまとめると、

バラバラ・複雑な情報を構造化し、すぐ行動につなげる人

というのが比較的近いかもしれない。
実際、私の業務スタイルとしては、「あらゆる情報を整理し、他の人と共有できる形へ落とし込み、動かしながら仕組み化する」なので、自己分析としてはズレていないというこが改めて分かりましたね。
仕事したくないので仕組み化だけして、自分がいつでも最速でやめていいようにしてます。

開発ツールの制作

Vueのコンポーネント依存関係の可視化ツールとか、アーキテクチャ図とりくえすとふろーをなんかいい感じにできるツールとか便利ツールを例年通り作ってる。自分が使いたいツールを作りながら、他の開発者にも役立つものを提供しています。

Snack Timeの反響

Snack Timeが思ったよりも使われてる(20人ぐらいだと思ってた)。
自分のためのツールなので、予想以上のユーザー数で驚いてるだけなんですが、バグあったら気軽に言ってね。

ゲーム開発の進捗

元々仕組んでたものが動き始められそうなので、ゲーム開発も徐々にやってる。
長い間温めていたアイデアが形になりつつあり、時間もできてようやく実装フェーズに入りました。
また、こっちとは別の垢でゲーム関連のコミュニティ活動もやっています。

Vue.jsコンポーネントの依存関係を可視化する「Voyager」の開発をはじめました

昨日、Vue.jsのコンポーネント依存関係を可視化するツール「Voyager」の開発をし始めました。
このツールは、Vue.jsプロジェクトのコンポーネント間の関係性を簡単に、インタラクティブに表示することを目指しています。

CodePenで動作確認できるようにしています。

codepen.io

このサンプルは、vue-element-adminに対して実行した場合の結果になります。

なぜ作り始めたのか?

大規模なVue.jsアプリケーションを開発していると、コンポーネント間の依存関係が複雑になってきます。
新しいメンバーがプロジェクトに参加した時や、リファクタリングを行う際に、この依存関係を把握するのは大変な作業です。

この課題を解決するため、コンポーネントの依存関係を視覚的に表現するツールがあれば便利だと考え、開発を始めました。

例えばこのようにPanThumbというコンポーネントを使っているコンポーネントは何があるのかを知れるようにするためのツールです。

既存のツールとの違い

Vue.jsのコンポーネント依存関係を可視化するツールは、すでに多く存在します。
しかし、それらには以下のような課題がありました。

  • 静的な画像出力のみで、インタラクティブな操作ができない
  • 大規模プロジェクトでの表示が煩雑になりやすい
  • リアルタイムな依存関係の探索が困難

Voyagerの特徴

Voyagerには以下のような特徴があります。

  • 🎨 美しく直感的なUI
  • 📊 シンプルな依存関係の解析
  • 🔍 Atomic Designベースの階層表示
  • 📁 ディレクトリ構造に基づいたグループ化
  • 🔄 インタラクティブな依存関係の探索機能

とはいっても、現状だとまだまだできていないことが多く、とりあえず可視化できるところだけ作成しています。

開発状況

現在は初期開発フェーズで、以下の機能を実装中です。

GitHubでプロジェクトは公開しているので、興味があればのぞいて見てください。

github.com

今後の展開

将来的には以下のような機能の追加を予定しています。

また、このツールはCLIツールとして展開していく予定なので、まずはそこを目ざいしていきたいですね。

ローカルで実行

現時点でもローカルで試すことはできるので簡単な手順について紹介します。

  1. voyagerをクローン
  2. asdf installなどでNode.jsをインストール
  3. pnpm install
  4. pnpm -r build && pnpm voyager ${PROJECT_DIR} --ignore "**/*.spec.{ts,js}"

このような手順を実行して成功すると、voyager-graph.html という単一で動作するHTMLファイルが出力されます。
これを閲覧すれば指定したプロジェクトを可視化することができます。

--ignore オプションで解析対象に含めないこともできるので適宜利用してください。
複数指定する場合には --ignore "**/*.spec.{ts,js}" "**/*.stories.ts" のように続けて定義すれば除外されます。

おわりに

まだ開発初期段階ですが、より良いVue.jsの開発体験を提供できるよう、機能を充実させていきたいと考えています。

興味を持っていただけた方は、GitHubをチェックしていただけると嬉しいです。
また、フィードバックやコントリビューションも大歓迎です!

またねMUI、ようこそshadcn/ui

noteに最近のSnack Timeのアップデートについて書いたんですが、そこで脱MUIをしたことについて書きました。

note.com

今回は、MUIを導入していた経緯から脱MUIに至る理由、そして脱MUIでの具体的な作業について紹介します。

TL;DR

  • 当時、Reactで書く場合にMUIを使った方が個人的には早かったので導入した
  • 以下の理由でやめたい気持ちがでてきた
    • MUIがボトルネックでアップデートできないライブラリが一部あった
    • Shadow DOMで埋め込んでいるが、全体的にもっさりとした動きになっている
  • MUIに依存する処理はなかったのでshadcn/uiに切り替えた

差分は以下になります。

github.com

MUIを導入した経緯

自分のサイトでもある zuki.dev といった自分だけで開発しているものでは、昔からMUIを使っていることが多く、開発する場合の速度は一番ありました。
そのため、CRXJSを使った開発する場合でもとりあえずMUIでやるかという気持ちで導入しました。

MUIを使っていて出てきた課題

MUIを使ったExtensionをリリースして、しばらく使っていたんですが、以下のような問題が出てきました。

  • 開発・運用面
    • MUIが原因でReact周辺のライブラリをアップデートできない
  • プロダクト面
    • Shadow DOMで埋め込んでいるとはいえ、なんかもっさりする

前者は特にいうこともありませんが、後者のもっさり感についてはMUIを使っている人たちは知っている、もしくは体感したことがあると思います。
MUIは非常に便利なUIライブラリな一方で動きも多くあり、全体的にもっさりしがちということがあります。
これがこの拡張でも出るようになりました。

また、この時期から自分の会社で開発しているWeb系はshadcn/uiを使うようになり始めてたので、思い切って切り替えようと思いました。

またねMUI、ようこそshadcn/ui

shadcn/uiに切り替えた形跡は、タグ同士の比較を見た方が差分がわかりやすいかと思います。

github.com

大まか流れとしては=以下のような流れでやっていました。

  1. shadcn/uiの導入とセットアップ
    • globals.cssなども用意
  2. ThemaProviderやCacheProviderを削除
  3. Popup,Options,Contentといったように順番に切り替える
  4. Shadow DOMとして埋め込んでいるsrc/content/index.tsxglobals.cssをViteの静的アセットのインポートを使って埋め込み

定型作業に関してはCursor Composerに進めてもらいつつ、並行して作業を進めていました。
それぞれに関してはコミットを見てもらった方が良いんですが、Shadow DOMで埋め込んでいるContent.tsxにTailwind CSSを使っているCSSを適用させるにはひと悶着ありました。

Shadow DOMでTailwind CSSを使う

Shadow DOMでTailwindを使っているCSSを読み込ませるには、Viteの静的アセットのインポートを使って、直接埋め込まないとうまく動作しませんでした。

import styles from "@/styles/globals.css?inline";

...

  createRoot(shadowContainer).render(
    <React.StrictMode>
      <style>{styles}</style>
      <Content initialTime={duration} close={deleteRoot} soundEnabled={soundEnabled} />
    </React.StrictMode>,
  );
});

深ぼってはいないので、実際には他の方法でもうまく動作させることはできるかもしれません。

リリース後

リリース用のzipファイルを確認すると、脱MUI前後で30KBほど容量が削減されました。

また、動作のもっさり感もなくなり大分使いやすくなりました。
そして、Tailwind CSSを導入したこともあり、カラーテーマの実装もTailwindの設定変更だけでよくなり、すぐにリリースすることができました。

さいごに

MUIは非常に便利な一方で、その分依存関係での課題や動作のもっさり感に課題が起きやすいです。
もし誰かの参考になれば幸いです。