picoride 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CLAUDE.md ADDED
@@ -0,0 +1,1518 @@
1
+ # プロジェクトの引き継ぎコンテキスト
2
+
3
+ このファイルはClaude Codeがこのリポジトリで作業する際に自動的に読み込む前提のメモです。
4
+ プロジェクトルート(`app.rb`と同じ階層)に置いてください。
5
+
6
+ ## プロジェクト概要
7
+
8
+ Rubyプロジェクトの中で、Ruby(`.rb`)とC(`.c` `.h`)のソースコードをブラウザ上で
9
+ 編集できるようにする最小構成のサンプルアプリ。
10
+
11
+ - サーバー: Sinatra (Ruby)。Railsは不要という前提で選定
12
+ - フロントエンド: ビルドステップなしのvanilla JS + Prism.js(CDN読み込み)
13
+ - 目的はあくまで「サンプル/たたき台」。本番投入前提の機能(認証、複数人編集の競合制御など)は未実装
14
+
15
+ ## 技術選定の経緯(重要)
16
+
17
+ エディタ部分は当初 CodeMirror 6 → 最終的に「textarea + Prism.jsオーバーレイ」方式に
18
+ 変更している。この経緯を知らずに再度CodeMirrorへ戻そうとすると同じ問題を踏むので記録しておく。
19
+
20
+ 1. **CodeMirror 6 (esm.sh CDN経由のESM import)**
21
+ `https://esm.sh/codemirror@6` 等から直接importする構成で開始したが、
22
+ `Uncaught SyntaxError: The requested module 'https://esm.sh/codemirror@6' does not
23
+ provide an export named 'basicSetup'` というエラーが発生。esm.sh側での
24
+ パッケージ間バージョン解決のズレが原因。CDN上のESMは複数パッケージ構成のライブラリと
25
+ 相性が悪い場合がある、という教訓。
26
+
27
+ 2. **CodeMirror 6 (npm + esbuildでローカルバンドル)**
28
+ 1の問題を受けてnpm管理+esbuildバンドルに変更し、正常動作を確認。
29
+ ただしRubyプロジェクトにNode.js/npmのビルド環境を持ち込むことになり、
30
+ 「Railsも要らないくらいシンプルにしたい」という当初の方針とはやや矛盾していた。
31
+
32
+ 3. **picoruby.org/terminal の実装調査**
33
+ ユーザーからの質問で https://picoruby.org/terminal (PicoRuby公式のWebターミナル)の
34
+ エディタ実装を調査。ソースは
35
+ https://github.com/picoruby/picoruby.github.io/blob/main/pages/r2p2/terminal.html
36
+ にあり、CodeMirrorやMonacoではなく **`<textarea>` + 行番号レイヤー + ハイライトレイヤーの
37
+ 透明重ね合わせ + Prism.js(字句解析) + PicoRuby.wasm(ロジックをRubyで記述)** という
38
+ 自作の軽量構成だと判明。ターミナル部分は `@xterm/xterm` (xterm.js) を使用。
39
+
40
+ 4. **現在の実装: textarea + Prism.jsオーバーレイ方式**
41
+ 3を参考に、CodeMirrorとnpm/esbuildを完全に廃止し、Prism.js(cdnjs、バージョン固定の
42
+ `<script>`タグ読み込み)による軽量構成に置き換えた。これにより:
43
+ - npm/Node.js環境が不要になり、`bundle install && ruby app.rb` だけで動くように
44
+ - 引き換えに、補完・複数カーソル・折りたたみ等の高度な編集機能は失った
45
+ - シンタックスハイライトのみのシンプルなエディタという位置づけ
46
+
47
+ 5. **キャレット/ハイライトのズレ修正**
48
+ textarea+オーバーレイ方式に切り替えた直後、「表示文字列とキャレット行がズレる」不具合が
49
+ 発生。原因は `<textarea>` が内容あふれ時に**要素内部で独自にスクロールする**性質を持つため、
50
+ `position: absolute` で単純に重ねると親のスクロールとtextarea自身のスクロールが
51
+ ズレること。**CSS Grid (`display: grid` + 両要素に `grid-area: 1 / 1`)** で
52
+ 重ねる方式に変更し、スクロール可能な要素を親の `.editor-scroll-area` ひとつに
53
+ 集約することで解消した。この手法は react-simple-code-editor や CodeJar など
54
+ 同種のライブラリでも採用されている標準的な解決策。
55
+
56
+ ## 現在のアーキテクチャ
57
+
58
+ ```
59
+ .
60
+ ├── app.rb # Sinatraアプリ本体。ファイル一覧/読込/保存のJSON APIのみ
61
+ ├── Gemfile # sinatra, puma, rackup
62
+ ├── views/index.erb # エディタ画面のHTML。Prism.jsをcdnjs(バージョン固定)から読込
63
+ ├── public/
64
+ │ ├── css/style.css # textareaオーバーレイ(CSS Grid)・行番号・Prismトークン配色
65
+ │ └── js/editor.js # エディタ本体ロジック。ビルド不要のvanilla JS(IIFE)
66
+ └── project/ # 編集対象のサンプルファイル群(.rb / .c / .h)
67
+ ```
68
+
69
+ ### API (`app.rb`)
70
+
71
+ - `GET /api/files` — `project/` 以下の `.rb` `.c` `.h` ファイル一覧(相対パス)を返す
72
+ - `GET /api/file?path=...` — 指定ファイルの内容を返す
73
+ - `POST /api/file` — `{path, content}` を受け取り、既存ファイルを上書き保存する
74
+ (新規作成は不可。`ALLOWED_EXTENSIONS` にないファイルや `project/` 外は拒否)
75
+ - `safe_path` ヘルパーでパストラバーサル対策済み
76
+ - `GET /api/build` / `POST /api/build` — R2P2-ESP32 のビルド(`idf.py build`)
77
+ - `POST`のbodyで `vm`(`"femtoruby"`|`"picoruby"`、`BUILD_VM_FLAGS`で
78
+ `-DPICORB_VM=mrubyc`|`mruby`に変換)と `usb_console`(true/false)を選べる。
79
+ 公式の[R2P2-ESP32-installer](https://github.com/picoruby/R2P2-ESP32-installer)は
80
+ CIで全組み合わせ(VM×usb_console×チップ)を事前ビルドしてGitHub Releaseで配布し、
81
+ ブラウザ側でファイル名をパースして選ばせる方式だが、こちらは「今コンテナ内で
82
+ 1本だけビルドする」設計なので、オプションはビルド開始時にリクエストで渡す形にした
83
+ - `usb_console` は `R2P2-ESP32/sdkconfigs/usb_console`
84
+ (`CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y`。外部USB-UART変換チップを持たない
85
+ ボード向け)を `SDKCONFIG_DEFAULTS` にマージする。ESP-IDFは
86
+ `SDKCONFIG_DEFAULTS` を **`sdkconfig` ファイルが存在しないときしか読まない**ため、
87
+ 設定を反映させるには `sdkconfig` を消してからビルドし直す必要がある
88
+ (`rake deep_clean`/`idf.py fullclean` でも `sdkconfig` 自体は消えない。
89
+ README.md「If you change SDKCONFIG_DEFAULTS, delete the sdkconfig file and
90
+ rebuild from scratch」参照)
91
+ - **踏んだ罠**: 最初「`usb_console: true`のときだけ`sdkconfig`を消す」という
92
+ 実装にしたら、一度trueにした後falseに戻しても`sdkconfig`にUSB Console設定が
93
+ 残ったままになるバグを作ってしまった(実機で試すまで気づかなかった類の話ではなく、
94
+ このセッション内でAPIを叩いて`grep CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG sdkconfig`
95
+ で確認して発覚した)。正しくは `sdkconfig_has_usb_console?` ヘルパーで
96
+ **今の`sdkconfig`の実際の中身**を見て、リクエストされた値と食い違うときだけ
97
+ `rm -f sdkconfig && SDKCONFIG_DEFAULTS=... idf.py build` する。一致していれば
98
+ 従来通りの高速な差分ビルドのまま(オプション未指定の通常ビルドが遅くならない)
99
+ - VM(`-DPICORB_VM=`)の切り替えは`sdkconfig`と無関係(CMakeのキャッシュ変数)なので
100
+ 上記のクリーン処理は不要。ビルドログの早い段階に出る
101
+ `-- PICORB_VM is set to: mrubyc` のような行は、キャッシュがまだ更新される前の
102
+ 表示に見えることがあるので、本当に反映されたかは
103
+ `build/CMakeCache.txt` の `PICORB_VM:STRING=...` や、
104
+ `components/picoruby-esp32/picoruby/build/` 配下に
105
+ `esp32-{femtoruby,picoruby}` のディレクトリが増えているかで確認するのが確実
106
+ - `GET /api/platform` / `POST /api/platform` — プラットフォーム(ターゲットチップ)
107
+ セットアップ(`rake setup_#{platform}`)。`platform` は
108
+ `PLATFORM_TARGETS`(`esp32` `esp32c3` `esp32c6` `esp32h2` `esp32p4` `esp32s3`。
109
+ `R2P2-ESP32/rakelib/setup.rake` 参照)にあるものだけ許可
110
+ (コマンドインジェクション対策とrakeタスク名の妥当性確認を兼ねる)
111
+ - `setup_esp32xxx` は `deep_clean` + `setup`(mrubyの再ビルド) +
112
+ `idf.py set-target` という重い処理の直列実行なので、実行に数分かかる
113
+
114
+ この2つはどちらも同じ形の非同期ジョブ(GETでポーリング、POSTで開始、実行中の
115
+ POSTは409)なので、共通処理を `BackgroundJob` クラス(`app.rb`)に抽出してある。
116
+ `BUILD_JOB` / `PLATFORM_JOB` という2つのインスタンスがそれぞれの状態
117
+ (`idle`/`running`/`success`/`failed`、ログ)を持つ。ログは末尾
118
+ `BackgroundJob::LOG_TAIL_LIMIT`(8,000文字)のみをレスポンスに含め、
119
+ 切り詰めた場合は `log_truncated: true` を付ける
120
+ (ブラウザ内のPicoRuby.wasmで全量(100KB超になりうる)をJSONパース/描画すると
121
+ 数秒〜十秒近くかかり、「反応がない」ように見えてしまう問題への対処)。
122
+ `idf.py` / `rake` はESP-IDFの `export.sh` を読み込んだシェルでないと使えないため、
123
+ 両ジョブとも実行コマンドは `r2p2_shell_command` ヘルパーで
124
+ `$IDF_PATH/export.sh` を明示的にsourceしてから組み立てている
125
+ (Dockerのentrypoint(`R2P2-ESP32/docker/Dockerfile`)は起動時に一度export済みだが、
126
+ `docker exec` 等で入った場合はexportされていないことがあり、それに頼らない実装にした)。
127
+
128
+ UI側(`web/funicular/ruby/components/editor_app.rb`)は実行中、
129
+ `JS.global.setTimeout(3000) { refresh_xxx_status }` で3秒おきに自動的にログを
130
+ 取りに行く(runningでなくなったら止まる)。「ログを更新」ボタンはこれとは別に、
131
+ すぐ最新状態を見たいときの手動トリガーとして残してある。
132
+ ビルドパネル(`build_panel.rb`)とプラットフォームパネル(`platform_panel.rb`)は
133
+ 表示ロジックがほぼ同じ(ステータスラベル・ログ切り詰め表示・自動ポーリング)だが、
134
+ まだ2つ書いているだけの重複度なので共通化はしていない。3つ目の非同期ジョブUIが
135
+ 必要になったら抽象化を検討する。
136
+
137
+ **ここで踏んだ罠**: PicoRuby.wasmの `JS::Object#setTimeout` は Ruby標準の
138
+ `Kernel#sleep` 的な感覚で `JS.global.setTimeout(callback_proc, delay_ms)` のように
139
+ 2引数で呼びたくなるが、実際のシグネチャは `setTimeout(delay_ms, &block)`
140
+ (picoruby本体 `mrbgems/picoruby-wasm/mrblib/js.rb` 参照)で、コールバックは
141
+ **ブロックとして**渡す必要がある。2引数で呼ぶと
142
+ `ArgumentError: wrong number of arguments (given 2, expected 1)` になり、
143
+ しかもこの例外はブラウザの `console.error` に `Callback <id>: ArgumentError: ...`
144
+ という形で出るだけで、Rubyコード上は握りつぶされて画面には何も出ない
145
+ (=「ボタンを押しても何も起きない」ように見える)。正しくは
146
+ `JS.global.setTimeout(3000) { ... }` のようにブロックで渡す。
147
+ 同様のAPIを追加するときは `js.rb` のソース(`gh api
148
+ repos/picoruby/picoruby/contents/mrbgems/picoruby-wasm/mrblib/js.rb`)を
149
+ 先に確認したほうが早い。
150
+
151
+ ### エディタ (`public/js/editor.js` + `views/index.erb` + `style.css`)
152
+
153
+ - `<textarea id="editor">`: 文字色を透明にしキャレットのみ見せる
154
+ - `<pre id="highlight-layer"><code id="highlight-content"></code></pre>`: Prism.jsで
155
+ ハイライトしたHTMLをここに描画。`.editor-scroll-area` 内で `#editor` と
156
+ `grid-area: 1 / 1` により完全に重なる
157
+ - `#line-numbers`: 別カラム。`.editor-scroll-area` の `scroll` イベントで
158
+ `scrollTop` を同期
159
+ - 拡張子→Prism言語のマッピングは `languageFor()` にハードコード(`rb`→ruby, `c`/`h`→c)
160
+ - Tabキーはスペース2つを挿入するだけ(自動インデント等は未実装)
161
+ - 保存のたびに `savedContent` と比較して未保存差分の有無を判定し、保存ボタンの
162
+ 活性/非活性を制御
163
+
164
+ ## 既知の制約・today's TODO候補
165
+
166
+ READMEにも記載しているが、Claude Codeで次に着手する際の候補:
167
+
168
+ - **自動インデント未実装**: 改行時に直前行のインデント幅を引き継ぐ処理がない
169
+ (picoruby側の `auto_indent.rb` が参考になる)
170
+ - **大きいファイルでの性能**: 入力のたびに全文を `Prism.highlight()` で再トークナイズしている。
171
+ デバウンス処理を入れるか、変更行のみ再描画する差分更新に変えると改善する
172
+ - **新規ファイル作成/削除/リネームAPIがない**: 既存ファイルの上書き保存のみ対応
173
+ - **同時編集の競合制御がない**: 複数人が同時に同じファイルを開いた場合、後勝ちで上書きされる
174
+ - **対応拡張子が `.rb` `.c` `.h` のみ**: 増やす場合は `app.rb` の `ALLOWED_EXTENSIONS`、
175
+ `views/index.erb` のPrismコンポーネント読み込み、`editor.js` の `languageFor` /
176
+ `iconFor` の3箇所を対応させる必要がある
177
+ ### 実機への書き込み(インストール)機能
178
+
179
+ ビルド(`idf.py build`)・プラットフォームセットアップ(`rake setup_xxx`)とは違い、
180
+ 書き込みだけは**サーバ側で`rake flash`を実行する方式にしなかった**。理由は、
181
+ サーバがDockerコンテナ内で動く前提だと、コンテナにUSBシリアルデバイスを渡す
182
+ (`docker run --device /dev/ttyUSB0` 等)必要があり、`bin/dev`の変更やホスト環境の
183
+ デバイスパス依存が増えて複雑になるため。代わりに公式の
184
+ [R2P2-ESP32-installer](https://picoruby.org/R2P2-ESP32-installer/)と同じ、
185
+ **ブラウザのWeb Serial API経由([ESP Web Tools](https://esphome.github.io/esp-web-tools/)、
186
+ `esp-web-install-button`カスタム要素)でブラウザから直接USBに書き込む方式**を採用した。
187
+ これによりサーバは「ビルド成果物を配信するだけ」でよくなり、USBデバイスの取り回しを
188
+ 気にする必要がなくなる(ただしChrome/Edge/OperaなどWeb Serial API対応ブラウザが必須)。
189
+
190
+ - `GET /api/firmware/manifest.json` — ESP Web Tools用のマニフェストを、直近のビルド
191
+ 成果物(`R2P2-ESP32/build/project_description.json` の `target` と
192
+ `R2P2-ESP32/build/flash_args`)から動的に組み立てて返す。ビルド未実行なら404
193
+ - `flash_args` は `idf.py build` が生成する、esptoolの`write_flash`にそのまま渡せる
194
+ `<オフセット(16進)> <binへの相対パス>` の行の並び(1行目は`--flash_mode`等の
195
+ オプション行なので読み飛ばす)。実際の中身の例:
196
+ ```
197
+ --flash_mode dio --flash_freq 40m --flash_size 4MB
198
+ 0x1000 bootloader/bootloader.bin
199
+ 0x10000 R2P2-ESP32.bin
200
+ 0x8000 partition_table/partition-table.bin
201
+ 0x210000 storage.bin
202
+ ```
203
+ - `target`(`esp32`等、`idf.py set-target`の引数と同じ)→ESP Web Toolsの`chipFamily`
204
+ (`ESP32`等)への変換テーブルが `CHIP_FAMILY_MAP`。ESP Web Tools側の対応チップ一覧は
205
+ `gh api repos/esphome/esp-web-tools/contents/src/const.ts` の `Build#chipFamily`
206
+ で確認した(`PLATFORM_TARGETS` にある6種は全部サポートされている)
207
+ - `GET /api/firmware/:filename` — 上記マニフェストが指す`.bin`を配信する
208
+ (拡張子とパストラバーサル対策あり)
209
+ - UI側(`install_panel.rb`)は状態を持たない。`<esp-web-install-button manifest="...">`
210
+ を配置するだけで、実際の書き込み処理・進捗ダイアログはESP Web Tools側が全部担う。
211
+ ボタンのコールバックは `manifest` を**HTML属性**として読む
212
+ (`button.manifest || button.getAttribute("manifest")`、
213
+ `esp-web-tools/src/connect.ts` 参照)ので、Funicularの`tag`で属性として渡せばよい
214
+ - `index.html` で `<script type="module" src=".../esp-web-tools@10.4.0/dist/web/
215
+ install-button.js">` をCDN読み込み。バージョン固定の方針は他ライブラリと同様
216
+ - 実機なしでも「No port selected」ダイアログ(ESP Web Tools自身が出す、Linuxの
217
+ dialoutグループ設定などのトラブルシューティング付き)が出ることをブラウザで確認済み
218
+
219
+ ## 開発環境まわりの注意点
220
+
221
+ - Rubyの実行環境がこちらのサンドボックスに無かったため、`app.rb` 等は目視レビューのみで
222
+ 実機での起動確認はできていない(ユーザー側の `ruby app.rb` で都度確認してもらっている)
223
+ - Prism.jsのCDN URL (`cdnjs.cloudflare.com`) はサンドボックスのネットワーク制限で
224
+ 直接疎通確認できなかったため、`npm pack prismjs@1.30.0` でnpmレジストリ経由で
225
+ ファイル名(`prism-core.min.js` 等)の実在のみ検証した
226
+ - esm.sh経由のESM CDN importは、今回のようなバージョン解決の不安定さが起きやすいので、
227
+ 今後もCDN経由でJSライブラリを追加する場合は、安定版の `<script>` タグ+バージョン固定URLを
228
+ 優先する方針でよい
229
+
230
+ ### `bin/dev`は廃止した(`bin/server`に置き換え)
231
+
232
+ **この節は古い(Dockerオンリー時代の)記述で、現在は成り立たない。** `bin/dev`は
233
+ Sinatraアプリ自体をDockerコンテナの中で動かす前提のスクリプトだったが、後述の
234
+ 「Sinatraのホスト外出し」でSinatra自体はDockerを使わずホストでネイティブに動く
235
+ ようになったため、このマウントの工夫は丸ごと不要になり`bin/dev`は削除した。
236
+ 開発時の起動は`bin/server`(`bundle exec exe/picoride`をラップするだけ)を使う。
237
+ 過去に踏んだ罠の記録として残しておくと: 当時`app/`を`/root/app`のように1階層
238
+ 深くマウントしようとして、`PROJECTS_ROOT`が`__dir__`からの相対パス解決だった
239
+ ために`../projects`の解決先がズレる問題があった。今は`PROJECTS_ROOT`自体が
240
+ 環境変数で上書き可能になっているので、同種の問題はもう起きない。
241
+
242
+ ## プロジェクト管理・mrbgemのビルド組み込み方針
243
+
244
+ 複数のプロジェクト(1つのアプリと、それが使う自作mrbgem群)を`projects/`配下で
245
+ どう管理し、どうビルドに組み込むかを検討して決めた内容。**この設計は2回作り直して
246
+ いる**ので、経緯も含めて残しておく。
247
+
248
+ ### 現在のプロジェクト構造
249
+
250
+ 1プロジェクト = `projects/<project名>/`の中に`app/`・`mrbgems/`・`build_config.rb`を
251
+ まとめて持つディレクトリ、という単位にしてある。
252
+
253
+ ```
254
+ projects/
255
+ hello_world_project/
256
+ app/ # ビルド時にR2P2-ESP32のstorage/home/へまるごとコピーされる
257
+ app.rb # main_task.rbが起動時に自動loadする(後述)
258
+ mrbgems/ # ここにあるものは全部自動でビルドに含まれる
259
+ picoruby_hello_world/
260
+ mrbgem.rake
261
+ mrblib/...
262
+ src/...
263
+ build_config.rb # デフォルトgemを外したい場合などに直接編集する普通のファイル
264
+ ```
265
+
266
+ プロジェクトの選択は画面上部のメニューバーのドロップダウン(`MenuBar`の
267
+ `render_project_select`)から行う(以前はサイドバーに一覧を出す`ProjectList`
268
+ コンポーネントだったが、上部に移した)。マウントは`projects/`を1つだけbind mountする
269
+ 方針は変わっていない(mrbgemを継続的に追加してもコンテナ再起動が要らないように)。
270
+ IDEはgit操作(clone/pull/push/commit)に一切ノータッチで、プロジェクトディレクトリが
271
+ 独立したgitリポジトリかどうかもIDEは関知しない。
272
+
273
+ **ここに至るまでの変遷**: 最初は「`projects/`直下の各ディレクトリを1プロジェクトとし、
274
+ `mrbgem.rake`の有無で中身を見てapp/mrbgem種別を自動判定する」フラットな構成にして
275
+ いた(mrbgemが「あるappプロジェクトの一部」ではなく独立した存在という前提)。
276
+ この場合「どのmrbgemをどのappに含めるか」を選ぶ必要が生じ、Gemsダイアログ
277
+ (チェックボックスでmrbgemを選ぶUI)とその永続化(`.gems_prefs.json`、
278
+ opt-in→opt-out方式への変更を経た)を作り込んだ。しかし実際にサンプルを作ってみると、
279
+ 「1つのappとそれが使うmrbgemは元々セットで管理したい」という要望が出てきて、
280
+ mrbgemをapp側のディレクトリの中に物理的に含める今の構造に変更した。これにより
281
+ 「mrbgemを選ぶ」という概念自体が不要になり(ディレクトリの中に置く=そのプロジェクトで
282
+ 使う、という構造そのものが選択を兼ねる)、Gemsダイアログ・`.gems_prefs.json`は
283
+ まるごと削除した。`build_config.rb`も「ダイアログが生成する専用ファイル」から
284
+ 「プロジェクトの中にある普通の編集可能ファイル」に位置づけが変わっている。
285
+
286
+ ### mrbgemのビルドへの組み込み
287
+
288
+ mrubyのビルド設定(`MRuby::CrossBuild.new do |conf| ... end`、`conf.gem`)は
289
+ `conf.gem gemdir: '<path>'`で任意のローカルディレクトリをgemとして追加でき、
290
+ `conf.gems.reject! { |g| ... }`で既に登録済みのデフォルトgemを除外できる
291
+ (`conf.gems.delete(name)`もあるが、該当gemが無いと`fail`で例外になるため、
292
+ 存在しない場合に何もしない`reject!`のほうが今回の用途には安全)。
293
+
294
+ R2P2-ESP32本体の`components/picoruby-esp32/build_config/*.rb`は
295
+ xtensa/riscv × femtoruby/picoruby の組み合わせで4種類あり、ツールチェイン設定など
296
+ 込み入った内容を持つ。これをプロジェクトごとに複製すると本家の変更に追従できなく
297
+ なる。かといって「project内のbuild_config.rbをMRUBY_CONFIGとして丸ごと差し替える」
298
+ 方式も、`components/picoruby-esp32/CMakeLists.txt`が`MRUBY_CONFIG`のパスを
299
+ `${IDF_TARGET_ARCH}-esp-${vm}.rb`固定で毎回計算し直す実装になっていて外から
300
+ 上書きできないため、そのままでは無理だった。
301
+
302
+ 代わりに採用したのが、**本家4ファイルの`do |conf| ... end`ブロック末尾に1行だけ
303
+ フックを差し込む**方式(`Dockerfile`の`git submodule update`直後の`RUN`)。
304
+
305
+ ```ruby
306
+ conf.instance_eval(File.read(ENV["PROJECT_BUILD_CONFIG"])) if ENV["PROJECT_BUILD_CONFIG"] && File.exist?(ENV["PROJECT_BUILD_CONFIG"])
307
+ ```
308
+
309
+ `PROJECT_BUILD_CONFIG`は`idf.py build`を叩くシェルコマンドの環境変数として
310
+ `app.rb`(`POST /api/build`)が設定する(`cmake -E env A=1 B=2 rake`のような
311
+ 形で最終的に`rake`まで渡っても、その前後で明示的に指定していない環境変数は
312
+ プロセスの環境変数として素通りするので、CMakeLists.txt自体は無改造で済む)。
313
+
314
+ このフックが読むファイルは、プロジェクトの`build_config.rb`そのものではなく、
315
+ **ビルド開始のたびに`generated_build_config_content`が生成する一時ファイル**
316
+ (`projects/<project>/.build_config.generated.rb`)。中身は
317
+ 「`mrbgems/`以下の現在の一覧から自動生成した`conf.gem gemdir:`の並び」+
318
+ 「プロジェクトの`build_config.rb`の内容をそのまま連結したもの」:
319
+
320
+ ```ruby
321
+ # 自動生成部分
322
+ conf.gem gemdir: "/projects/hello_world_project/mrbgems/picoruby_hello_world"
323
+
324
+ # ここから下はbuild_config.rbの内容(ユーザーが直接編集する)
325
+ conf.gems.reject! { |g| g.name == "picoruby-vim" }
326
+ ```
327
+
328
+ ユーザーが書いた`build_config.rb`自体は一切書き換えない(生成物は別ファイルにして
329
+ 連結するだけ)ので、IDEのファイルエディタで見えている内容と実際にビルドされる内容が
330
+ 食い違わない。gemdir(自作mrbgemの絶対パス)は常にコンテナ内の絶対パスで書く
331
+ (相対パス解決の基点があいまいなmruby側の挙動に依存しないため)。
332
+
333
+ **踏んだ罠**: 上記のDockerfileパッチで、シングルクォート文字列の中で`&&`を
334
+ `\&\&`とバックスラッシュエスケープして書いてしまい、`printf`がバックスラッシュを
335
+ 剥がさずそのまま出力した結果、生成されたRubyコードに`\&\&`という不正なトークンが
336
+ 混入してSyntaxErrorになった(実際にビルドを試したユーザーからの報告で発覚)。
337
+ シングルクォートの中では`&`はそもそもシェルにとって特別な文字ではない
338
+ (`&&`はダブルクォート無し/クォート無しの文脈でコマンド連結として解釈されるだけ)ので、
339
+ エスケープ自体が不要だった。シェル文字列に埋め込むコードにshell上の特殊文字が
340
+ 含まれる場合、「クォートの種類によってエスケープが要るかどうかが変わる」ことを
341
+ 都度確認する(今回のように`ruby -c`や実際のcloneに対してパッチを当てて検証していれば
342
+ リリース前に気付けた)。
343
+
344
+ ### プロジェクトのapp/を実機の起動スクリプトにする
345
+
346
+ プロジェクトの`app/`以下は、ビルド時にR2P2-ESP32実機の起動スクリプトとして実行
347
+ されるようにした。これはR2P2-ESP32側の改造は一切不要で、既にある2つの仕組みを
348
+ 組み合わせただけ:
349
+
350
+ 1. `main/CMakeLists.txt`の`littlefs_create_partition_image(storage ../storage
351
+ FLASH_IN_PROJECT)`が、`R2P2-ESP32/storage/`以下をそのまま`storage`パーティション
352
+ (littlefs)のイメージ(`storage.bin`)にする。つまり`storage/home/app.rb`を置けば
353
+ 実機の`/home/app.rb`になる
354
+ 2. 起動スクリプト`components/picoruby-esp32/mrblib/main_task.rb`が起動のたびに
355
+ `File.exist?("/home/app.rb")`をチェックして自動で`load`する処理を最初から持っている
356
+ (`/home/app.mrb`があればそちらを優先。picoruby-esp32本体の元々の仕様で、
357
+ ユーザーアプリを実行する正式な入り口はここ)
358
+
359
+ なので`app.rb`(`POST /api/build`)は、ビルド開始直前に指定projectの`app/`以下を
360
+ `R2P2-ESP32/storage/home/`へまるごとコピーするだけ(`STORAGE_HOME_DIR`)。
361
+ 前回別プロジェクトをビルドしたときの残骸が残らないよう、コピー前に`storage/home/`を
362
+ 空にする。build_config.rbのとき(CMakeLists.txtがパスを決め打ちしていて上書き
363
+ できなかった)とは違い、ここはR2P2-ESP32側が最初から「storage/以下の現在の中身を
364
+ そのまま焼く」という素直な作りだったので、Dockerfileパッチのような迂回策は不要だった。
365
+
366
+ ## storageパーティション作成に使うlittlefs-pythonをオフライン化する
367
+
368
+ `main/CMakeLists.txt`の`littlefs_create_partition_image`(依存する
369
+ `joltwallet/esp_littlefs`コンポーネント、`main/idf_component.yml`で
370
+ `joltwallet/littlefs: "~=1.20.0"`と指定)は、storageパーティションのイメージを
371
+ 作る際に**初回ビルド時にvenvを作ってPyPIから`littlefs-python`をpip installする**。
372
+ これが実際のビルド実行時にネットワークの不調でタイムアウトして失敗する事象が
373
+ 起きた(`pip._vendor.urllib3.exceptions.ReadTimeoutError`)。
374
+
375
+ 最初は「Dockerイメージビルド時にpipキャッシュへ先に取り込んでおく」対処をしたが、
376
+ `"~=1.20.0"`というレンジ指定だと実際に解決されるパッチバージョンが変わりうる
377
+ (1.20.0〜1.20.4の間で`image-building-requirements.txt`の中身
378
+ = 要求される`littlefs-python`のバージョンが`0.13.3`/`0.15.0`の2パターンあることを
379
+ 実際に確認した)ため、「たぶん合っているキャッシュ」止まりだった。
380
+
381
+ そこで一歩進めて、**バージョン解決自体を無くす**方針に変更した:
382
+
383
+ 1. Dockerfileで`main/idf_component.yml`の`joltwallet/littlefs`を`"~=1.20.0"`から
384
+ `"1.20.4"`(Docker build時点で実在を確認できた最新パッチ版。
385
+ https://components.espressif.com/components/joltwallet/littlefs で確認)に
386
+ `sed`で固定する。これで`image-building-requirements.txt`の中身
387
+ (=`littlefs-python==0.15.0`)が一意に確定する
388
+ 2. そのバージョンのwheelを`pip3 download littlefs-python==0.15.0 -d /opt/pip-wheels`
389
+ でDockerイメージビルド時に取得して焼き込む
390
+ 3. `ENV PIP_NO_INDEX=1` / `ENV PIP_FIND_LINKS=/opt/pip-wheels`
391
+ をコンテナ全体に設定する。これにより、コンテナ内のどのpip実行も常に
392
+ `/opt/pip-wheels`だけを見るようになり(PyPIへの通信自体が発生しない)、
393
+ ビルド実行時のvenv内pipもこの設定を継承する(`ENV`はコンテナの環境変数として
394
+ 永続するため、`docker exec`等で入った場合や、`idf.py build`→ninja→cmakeの
395
+ カスタムコマンド経由で起動される孫プロセスにも問題なく伝わる。
396
+ `PROJECT_BUILD_CONFIG`環境変数と同じ伝播の仕組み)
397
+
398
+ 実際に検証もした: `PIP_NO_INDEX=1 PIP_FIND_LINKS=<ローカルディレクトリ>`かつ
399
+ 存在しないインデックスURLを指定した状態で`pip install littlefs-python==0.15.0`を
400
+ 実行し、ローカルのwheelだけから(ネットワーク通信なしで)正常にインストールできる
401
+ ことを確認済み。
402
+
403
+ **トレードオフ**: `PIP_NO_INDEX`/`PIP_FIND_LINKS`はコンテナ全体に効くため、
404
+ 将来他の目的で何かpipインストールが必要になった場合、事前に`/opt/pip-wheels`へ
405
+ 用意しない限り失敗するようになる(ESP-IDFの component manager自体はPyPIではなく
406
+ ESP Component Registryを見る別の仕組みなので影響を受けない)。今のところこの
407
+ コンテナ内でpipを使うのはここだけなので許容している。
408
+
409
+ ## プロジェクト設定(ターゲット・VM・USB Console)
410
+
411
+ ターゲットチップ(`esp32`等)・VM(femtoruby/picoruby)・USB Consoleは、最初は
412
+ メニューバー常設のセレクト(ターゲット)や、ビルド開始のたびに出るダイアログ
413
+ (VM/USB Console)で毎回選ばせる作りだったが、「プロジェクトの設定として
414
+ プロジェクトに含めたい」というフィードバックを受けて、mrbgemのopt-out方式への
415
+ 変更や「プロジェクトのapp.rbを起動スクリプトにする」変更と同じ考え方
416
+ (プロジェクトディレクトリの中に状態を持たせる)で、プロジェクトごとの隠しファイル
417
+ `.config.yml`(`PROJECT_CONFIG_FILENAME`)に永続化する方式に変えた。
418
+
419
+ - `GET`/`POST /api/projects/:name/config` — 読み書き。`read_project_config`は
420
+ ファイルが無い/壊れている場合「未設定」(platform/vmはnil、usb_consoleはfalse)
421
+ として扱う(`YAML.safe_load`が例外を出す壊れたYAMLでも落ちないようにしてある)
422
+ - UI側は`ProjectSettingsDialog`(旧`BuildDialog`を置き換え)でこの3項目をまとめて
423
+ 編集・保存する。保存後は「プラットフォームをセットアップ」
424
+ (`POST /api/platform`、bodyは`{project:}`のみ)「ビルド開始」
425
+ (`POST /api/build`、bodyは`{project:}`のみ)の各ボタンが、リクエストの
426
+ パラメータではなくその時点で保存済みの`.config.yml`の値をサーバ側で読んで
427
+ そのまま使う。都度選び直す必要がなくなった
428
+ - プロジェクトを切り替えたら`load_project_config`でそのプロジェクトの
429
+ `.config.yml`を読み直す(`select_project`から呼ぶ)。切り替え中に古いレスポンスが
430
+ 後から返ってきて新しいプロジェクトの設定を上書きしないよう、レスポンス受信時に
431
+ `state[:current_project]`と一致するかを確認してから反映している
432
+
433
+ ## Sinatraのホスト外出し・Dockerのビルド専用化(大規模アーキテクチャ変更)
434
+
435
+ これまでは「Sinatraアプリ・R2P2-ESP32・ESP-IDFすべてを1つのDockerイメージに
436
+ 焼き込み、そのコンテナ内でアプリごと動かす」という構成だった。これを
437
+ 「**Sinatraはホストでネイティブに動かし、Dockerはビルド専用**」という構成に
438
+ 変更した。GitHubのdocsで実際に議論して固めた方針で、詳しい経緯・比較検討は
439
+ このセッションの会話に残っている。要点だけ書くと:
440
+
441
+ - 対象ユーザーがRuby開発者である以上「Rubyの実行環境がある」ことはハードルに
442
+ ならない。むしろ「アプリ全体がDocker前提」であることのほうがハードルが高い
443
+ - ESP-IDFのビルド環境はセットアップが重く複雑なので、Dockerで隠蔽する価値が高い
444
+ (=ここだけはDockerに残す価値がある)
445
+ - 「ビルドキャッシュ・sdkconfig・managed_componentsをプロジェクトごとに持たせたい」
446
+ (ターゲットやVMが違うプロジェクトを切り替えるたびに実質フルリビルドになる問題)
447
+ という要望から、**ビルドのたびに使い捨てコンテナを起動する**設計になった。
448
+ これによりDockerソケット共有(DooD)のような複雑さも不要になった
449
+ (Sinatraがホストの通常プロセスとして`docker run`を呼ぶだけで済むため)
450
+
451
+ ### 新しい構成
452
+
453
+ - `Dockerfile` — ESP-IDFツールチェインだけを持つビルド専用イメージ(後述)。
454
+ `docker build -t picoride-builder .` で事前にビルドしておく
455
+ - `bin/server` — Sinatraをホストでネイティブに起動するスクリプト
456
+ (`bundle exec exe/picoride`のラッパー)。旧`bin/dev`は削除した
457
+ - `lib/picoride/r2p2_state.rb` — プロジェクトごとのR2P2-ESP32状態を
458
+ 管理するモジュール(詳細後述)
459
+ - `PROJECTS_ROOT` / `R2P2_STATE_ROOT` / `PICORUBY_BUILDER_IMAGE`
460
+ (`lib/picoride/server.rb`) —
461
+ いずれも環境変数で上書き可能。特に`PROJECTS_ROOT`が設定可能になったことで、
462
+ 当初の目的だった「プロジェクトをホストの好きな場所に置きたい」が実現している
463
+
464
+ ### プロジェクトごとのR2P2-ESP32状態(`lib/picoride/r2p2_state.rb`)
465
+
466
+ 以前は1つのR2P2-ESP32チェックアウトを全プロジェクトで共有していたが、これを
467
+ プロジェクトごとに独立させた。状態ディレクトリは`R2P2_STATE_ROOT`
468
+ (既定`~/.picoride/r2p2-esp32/`)配下に`<project名>`で1つずつ持つ
469
+ (プロジェクト本体の外に置く。R2P2-ESP32はサイズが大きく、ユーザーのプロジェクト
470
+ gitリポジトリに混ざるべきではないため)。
471
+
472
+ 以前Dockerfileの`RUN`でイメージビルド時に1回だけ実行していた
473
+
474
+ - R2P2-ESP32のgit clone
475
+ - build_config.rbフックのパッチ(`conf.instance_eval(...)`を4ファイルの`end`直前に挿入)
476
+ - `main/idf_component.yml`のjoltwallet/littlefsバージョン固定
477
+
478
+ は、`R2P2State.ensure_checkout(state_dir)`として**プロジェクトが初めてその
479
+ 状態ディレクトリを使うとき**に動的に実行する処理になった。パッチ処理は
480
+ シェルのワンライナー(`head -n -1` / `printf` / `mv`)から素のRubyコードに
481
+ 書き直した。理由は、プロジェクトごとに動的に実行する処理としてRubyで書く方が
482
+ 自然なのに加え、このセッションで2度踏んだシェルエスケープバグ
483
+ (`\&\&`が生成コードに混入してSyntaxError)を構造的に避けられるため。
484
+
485
+ ### ビルド/セットアップの実行(`r2p2_docker_command`)
486
+
487
+ `POST /api/build` / `POST /api/platform`は、ビルド開始直前に
488
+ `R2P2State.ensure_checkout`(未チェックアウトならここでgit clone、数分かかる)
489
+ してから、`r2p2_docker_command(state_dir, project_root, inner_cmd)`が組み立てる
490
+ `docker run --rm -v <state_dir>:/R2P2-ESP32 -v <project_root>:/project_src
491
+ <BUILDER_IMAGE> bash -c '...'`を`BackgroundJob`(変更なし)で実行する。
492
+
493
+ - `BackgroundJob`(`Open3.popen2e`でサブプロセスの標準出力を逐次読む仕組み)は
494
+ **無変更で流用できた**。`docker run`もただのサブプロセスでしかないので、
495
+ リアルタイムのログストリーミングはそのまま動く
496
+ - `PROJECT_BUILD_CONFIG`環境変数や、mrbgemの`conf.gem gemdir:`に書くパスは、
497
+ **コンテナ内から見たパス**(`/project_src/...`、`CONTAINER_PROJECT_MOUNT`)に
498
+ 変わった。ホスト側の実パスではない点に注意(以前は同じプロセス内で完結していた
499
+ ので気にする必要がなかった)
500
+ - `sdkconfig_has_usb_console?`・ファームウェア配信(`GET /api/firmware/*`)・
501
+ `storage/home/`へのapp.rbコピーは、参照するパスを`state_dir`(プロジェクトごと)
502
+ に差し替えるだけで、**ファイルI/Oのロジック自体は変更していない**。R2P2-ESP32の
503
+ 状態をホストの実ディレクトリにbind mountする設計(named volumeではなく)に
504
+ したことで、Sinatra(ホスト上で動く)は今まで通り`File.read`/`Dir.glob`で
505
+ 直接読める
506
+ - ファームウェア配信系(`GET /api/firmware/manifest.json`・`GET /api/firmware/*`)は
507
+ プロジェクトごとにビルド成果物の場所が変わったので、`project`クエリパラメータが
508
+ 必須になった(以前は不要だった)。マニフェストの`parts[].path`にも
509
+ `?project=...`を埋め込んで、ESP Web Tools側からのファイル取得時にも
510
+ 引き継がれるようにしてある
511
+ - `docker run`前に`builder_image_available?`(`docker image inspect`)で
512
+ ビルド用イメージの存在を確認し、無ければビルド方法を案内するエラーを返す
513
+
514
+ ### `Dockerfile`(ビルド専用イメージ)で実際に踏んだ罠
515
+
516
+ Ruby/rbenv/R2P2-ESP32を全部イメージから追い出して「ESP-IDFツールチェインだけ」
517
+ にすれば十分だろうと考えていたが、実際に`rake setup_esp32`を通しで実行してみたら
518
+ 2つ想定外のエラーが出た(**目視だけでなく実際にビルド→実行して検証したことで
519
+ 発見できた**、この種のバグは動かしてみないと分からない典型):
520
+
521
+ 1. **`git`の`dubious ownership`エラー**: bind mountしたディレクトリはホスト側の
522
+ 実UIDのまま見えるため、コンテナ内のユーザーとの所有者不一致でgit
523
+ 2.35.2以降(CVE-2022-24765対策)が操作を拒否する。
524
+ `git config --system --add safe.directory '*'`をイメージビルド時に実行して解決
525
+ (bind mountされるパスは実行のたびに変わるプロジェクトごとの状態ディレクトリ
526
+ なので、個別許可ではなく全許可にした。ビルド専用の使い捨てコンテナでしか
527
+ 使わないイメージなので安全性への影響は無視できる)
528
+ 2. **`bundle: command not found`(exit 127)**: R2P2-ESP32の`rakelib/setup.rake`は
529
+ 内部で(mrubyをビルドするために)picorubyサブモジュール内で`bundle install`を
530
+ 呼ぶ。ベースイメージ(`espressif/idf`)にはシステムRubyがあり`bundler`ライブラリも
531
+ デフォルトgemとして入っているが、`bundle`コマンドの実行ファイルは生成されて
532
+ いなかった。`gem install bundler --no-document`で解決
533
+ 3. **ネイティブ拡張のビルド失敗(`mkmf.rb can't find header files for ruby`)**:
534
+ 上記の`bundle install`がracc/ffi/io-console/json等ネイティブ拡張を持つgemを
535
+ ビルドしようとするが、ベースイメージにはコンパイラもRubyのヘッダファイルも
536
+ 無い。`apt-get install build-essential ruby-dev libssl-dev libreadline-dev
537
+ zlib1g-dev libyaml-dev libffi-dev`で解決(奇しくも、Ruby自体をrbenvで
538
+ ソースからビルドしていた旧Dockerfileでは同じパッケージ群が副次的に必要
539
+ だったため、apt-getのパッケージリスト自体は実質同じものが必要だった)
540
+
541
+ いずれも`docker build` → 実際に`rake setup_esp32`を`docker run`経由で実行して
542
+ 初めて見つかったバグ。`ruby -c`のような静的チェックでは検出できない類のもの
543
+ なので、Dockerfileを変更したときは必ず実際にビルド〜実行まで通すこと。
544
+
545
+ ## PicoRuby Web Terminal相当の機能(ソースコードペインのタブ切り替え)
546
+
547
+ ビルド・インストール(ESP Web Tools)とは別に、実機のUSBシリアルに直接つないで
548
+ 対話するターミナル画面を追加した。エディタ(`editor-area`)を「エディタ」/
549
+ 「ターミナル」のタブ切り替えにし(`editor_app.rb`の`editor_tab` state、
550
+ `.tab-pane` / `.tab-pane.hidden`)、現在のプロジェクトの`app/`以下を実機の
551
+ `/home/`以下へ転送する「app/ をアップロード」ボタンも一緒に置いた。
552
+
553
+ **ターミナル関連の状態・ロジックは全部`editor_app.rb`(ルートコンポーネント)の
554
+ 中にある。** 当初は`TerminalPanel`という別コンポーネントに分けていたが、後述の
555
+ 「子コンポーネントは親の再描画のたびに作り直される」問題を踏んで`EditorApp`に
556
+ 統合した。次にこの機能を触るときも、うっかり別コンポーネントに切り出さないこと。
557
+
558
+ ### 設計方針: サーバは一切関与しない
559
+
560
+ ビルド/インストールは「サーバでビルド → ブラウザのWeb Serial API(ESP Web Tools)で
561
+ 書き込み」という2段構えだったが、ターミナルは書き込み後の対話なので
562
+ サーバを経由する理由が無い。ブラウザ⇔実機USBを直結する
563
+ (picoruby.org/terminal https://picoruby.org/terminal 、実装は
564
+ https://github.com/picoruby/picoruby.github.io の pages/r2p2/terminal.rb
565
+ と pages/r2p2/terminal.html 参照)のと同じ構成にした。
566
+
567
+ ### `JS::WebSerial`はpicoruby-wasm本体が既に持っている
568
+
569
+ エディタ画面自体がFunicular(hasumikin作、PicoRuby.wasm上で動くVDOMフレームワーク)
570
+ で動いており、Web Serial APIも「アプリ側でJSを書いて橋渡しする」のではなく
571
+ picoruby-wasm本体(npm `@picoruby/wasm-wasi`)が`JS::WebSerial`として
572
+ 既に提供している。このIDEが依存しているバージョン(`@picoruby/wasm-wasi@4.0.2`、
573
+ `views/index.erb`でCDN読み込み)に実際に入っているかどうかは、この機能を
574
+ 実装する時点でのサンドボックスにRubyはあってもブラウザでの実機テストが
575
+ できなかったため、`npm pack @picoruby/wasm-wasi@4.0.2`して展開した
576
+ `dist/picoruby.wasm`/`dist/picoruby.js`に対して`strings`やNode.jsで文字列検索し、
577
+ 以下を確認して裏付けを取った:
578
+
579
+ - `dist/picoruby.js`に`serial_request_port` `serial_port_open` `serial_start_reading`
580
+ `serial_binary_capture_start/read/stop` 等のJS側関数が実装済みで存在する
581
+ - `dist/picoruby.wasm`(コンパイル済みバイナリ)の文字列に、picoruby本体の
582
+ `mrbgems/picoruby-wasm/mrblib/webserial.rb`(`JS::WebSerial`のRuby側API。
583
+ `supported?` `connect` `open` `on_receive` `on_disconnect` `write_bytes`
584
+ `opened?`、Cで実装される`_request_port` `_open_port` `_start_reading`
585
+ `_close_port_promise`等)や、`crc16` `crc32`(`require 'crc'`、
586
+ `picoruby-crc`)、`pack`/`unpack`(`Array#pack`/`String#unpack`)、
587
+ `start_terminal_read` `binary_capture_read/start/stop` `drain` `@js_port`
588
+ といった、picoruby.org/terminalのterminal.rbが実際に呼んでいるメソッド名が
589
+ ほぼそのまま埋め込まれている
590
+
591
+ 以上から、このnpmパッケージがFunicular同梱・WebSerial対応の(picoruby.org/terminal
592
+ と同系統の)ビルドだと判断し、terminal.rbの実装をほぼそのまま移植する方針にした。
593
+ 実際に`bin/server`相当(`bundle exec ruby app/app.rb`)を起動し、ブラウザで
594
+ 「デバイスに接続」を押したところ、`navigator.serial.requestPort()`が実際に
595
+ 呼ばれ(実機もポート選択もないため`Failed to execute 'requestPort' on 'Serial':
596
+ No port selected by the user.`という本物のブラウザ例外が返り、それを
597
+ Rubyの`rescue`が捕まえてステータス表示に出す、というエンドツーエンドの配線は
598
+ 確認できた。ただし実機(ESP32)を使ったPicoModem転送そのものは、この環境に
599
+ 実機が無いため未検証。実際に試す際は先に実機をUSB接続し、R2P2のプロンプトが
600
+ 出ている状態で試すこと。
601
+
602
+ **罠**: `dist/picoruby.wasm`に対する`strings`でのメソッド名検索は、
603
+ `serial_binary_capture_start`のような長く固有な名前には有効だが、
604
+ `getbyte` `pack` `sub` `each` `ord`のような短くありふれたメソッド名では
605
+ **実際には使えるのに見つからない(偽陰性)**ことがある(既にこのアプリで
606
+ 動いている`.each`ですら`strings`では見つからなかった)。おそらくmrubyの
607
+ シンボルテーブルが短い名前を`strings`が拾えない形式で保持しているため。
608
+ そのため「`strings`で見つからない」ことは「使えない」ことの証拠にはならない。
609
+ `String#getbyte`が実際に使えるかどうかを最終確認したときは、
610
+ `EditorApp#component_mounted`に一時的な自己診断コード(各メソッドを
611
+ `begin/rescue`で呼んで`JS.global[:console].log`に結果を出すだけのもの)を
612
+ 仕込み、`bundle exec ruby app/app.rb`を実際に起動してブラウザの
613
+ コンソールログで確認した上でコードを削除する、という手順を踏んだ。
614
+ この手のAPI有無の確認は静的な文字列探索より、実際に動かして確認するほうが早くて確実。
615
+
616
+ ### デバイス再起動時の自動再接続
617
+
618
+ 「デバイスを再起動すると接続が切れる」というフィードバックを受けて、
619
+ picoruby.org/terminalと同じ自動再接続を実装した。ESP32がリセットされると
620
+ USBの列挙が一瞬切れて同じ物理ポートとして再度現れる(Web Serial的には
621
+ 対象ポートの`disconnect`に続けて、ブラウザ全体に`navigator.serial`の
622
+ `connect`イベントが飛んでくる)。ユーザーが明示的に「切断」ボタンを押した
623
+ のでなければ、ポートが再度現れた時点で確認ダイアログ無しに自動で開き直す
624
+ (`EditorApp#watch_for_terminal_reconnect` / `#attempt_terminal_auto_reconnect`)。
625
+
626
+ これも前節と同じ理由(`strings`は短い名前を拾えないことがある)で、
627
+ `JS::WebSerial.methods(false)` / `.instance_methods(false)`を実際に
628
+ ブラウザのコンソールへ出して初めて全容が分かった。分かったこと:
629
+
630
+ - クラスメソッドに`_watch_connect_events` `_take_last_connected_port`という
631
+ 低レベルAPIは**存在する**(npmパッケージのwasmにC拡張として直接コンパイル
632
+ 済み)。JS側実装(`picoruby.js`)はグローバルに1回だけ`navigator.serial`の
633
+ `connect`イベントを監視し、来たポートを`globalThis.picorubyLastConnectedSerialPort`
634
+ に控えつつ`window`に`serial-port-connect`というCustomEventを飛ばす、という
635
+ 作り
636
+ - ただしmrblib(`webserial.rb`)側には、これらを使う**高レベルのRubyラッパー
637
+ メソッドが無い**(`on_reconnect`のような便利メソッドは無い)。なので
638
+ `"_"`付きのままRubyから直接呼ぶ(`JS::WebSerial._watch_connect_events`
639
+ という具合)。挙動としては`window.picorubySerialConnectWatcherInstalled`
640
+ というグローバルフラグが立つので、ブラウザのコンソールで
641
+ `window.picorubySerialConnectWatcherInstalled === true`を見れば
642
+ 登録できたかどうか確認できる(実機テストできない環境でもこれで配線だけは検証可能)
643
+ - 再接続本体は`JS::WebSerial._take_last_connected_port`で控えておいた
644
+ ポートを取り出し、`JS::WebSerial.new(raw_port)`(`request_port`を経由しない
645
+ コンストラクタ呼び出し)→`ws.open(baud_rate: ...)`で開き直す、という
646
+ terminal.rbのApp#bind_events内`serial.addEventListener('connect')`
647
+ ハンドラとほぼ同じ流れ
648
+ - 個別ポートの`on_disconnect`(`_set_on_disconnect`経由)が発火しない
649
+ ブラウザ/デバイスの組み合わせに備えて、`navigator.serial`自体の
650
+ `disconnect`イベントも保険で見ている(これもterminal.rbと同じ構成)
651
+
652
+ `@auto_reconnect`(インスタンス変数、`state`には入れていない)で
653
+ 「ユーザーが明示的に切断したか」を覚えておき、明示的な切断のときだけ
654
+ 自動再接続を止める。実機での動作(実際にリセットして再接続まで確認)は
655
+ この環境では検証できていないので、次に実機を使うセッションで確認すること。
656
+
657
+ ### 「app/ をアップロード」はPicoModemプロトコル(標準ビルドに元々含まれる)
658
+
659
+ R2P2のシェル(`picoruby-shell`)はプロンプト待機中にCtrl-B(STX, 0x02)を
660
+ 受け取ると`PicoModem.session($stdin, $stdout)`(`picoruby-picomodem`)に入り、
661
+ 1セッションにつき1ファイルのFILE_WRITE/FILE_READ等を処理してシェルへ戻る
662
+ (`picoruby-shell/mrblib/shell.rb`、`require "picomodem"`が最初から書いてある)。
663
+ `picoruby-picomodem`は`picoruby-shell`の`add_dependency`なので、**mrbgemを
664
+ 何も追加しなくても、R2P2-ESP32の標準ビルド(4種のbuild_config全部)に
665
+ 最初から含まれている**(実際に`components/picoruby-esp32/build_config/*.rb`と
666
+ `picoruby-shell/mrbgem.rake`を確認して裏付けた)。そのため、実機ファーム側の
667
+ 変更は一切不要で、ブラウザ側だけでPicoModemクライアントを実装すればよかった。
668
+
669
+ フレーム構造(STX + 長さ + Cmd + Payload + CRC16)・CRC32によるファイル整合性
670
+ 検証・チャンク分割送信などのプロトコル詳細は、terminal.rbのPicoModemクライアント
671
+ 実装をほぼそのまま`editor_app.rb`に移植した。CRC16/CRC32の多項式・初期値も
672
+ picoruby本体`picoruby-crc`のC実装(`crc.c`)から拾って一致させてある。
673
+
674
+ 複数ファイルを送る際、PicoModemは1セッション1ファイルなので
675
+ 「Ctrl-B送信→ACK待ち→FILE_WRITE」をファイルごとに繰り返す必要がある。
676
+ `upload_next_terminal_file`は、ファイル内容の取得(`Funicular::HTTP.get`)の
677
+ コールバックが同期/非同期どちらで呼ばれるか確証が持てなかったため、
678
+ `Enumerable#each`ではなく継続渡し(1ファイル完全に終わってから次を呼ぶ)に
679
+ してある。1本のシリアル接続を複数ファイルの転送が同時に取り合うと、
680
+ PicoModemのフレームが混ざって壊れるため。
681
+
682
+ デバイス上のパスは、`POST /api/build`が`app/`以下を`storage/home/`へコピーする
683
+ (→実機からは`/home/`以下に見える)のと対応を合わせて、`app/foo.rb` →
684
+ `/home/foo.rb`のように変換している。
685
+
686
+ ### xterm.jsは「タブが非表示の間にマウントするとサイズが0になる」問題がある
687
+
688
+ タブが非表示(`.tab-pane.hidden`、`display: none`)の間にxterm.jsを初期化すると
689
+ コンテナの寸法が0になる。ただし`terminal.open(container)`自体は非表示要素に
690
+ 対しても問題なく実行でき、実際の行数/桁数はコンテナに登録した`ResizeObserver`が
691
+ 寸法変化(タブ表示時にdisplay:noneが外れて0以外になる)を検知して`fit`し直す
692
+ ので、タブを表示した時点で正しいサイズに追従する。実際にブラウザで「ターミナル
693
+ タブに切り替えた瞬間に`.xterm-rows`が生成され、寸法も正しく反映される」ことを
694
+ 確認済み。
695
+
696
+ ### 【重大】子コンポーネントは親が再描画されるたびに作り直される
697
+ #
698
+ # このセクションはこのIDE特有の、かつ他の機能にも影響しうる重要な制約なので
699
+ # 太字にしてある。今後 `component(SomeClass, ...)` で切り出した子コンポーネントに
700
+ # 「一度だけ初期化して使い回したい副作用」(外部リソースへの接続、イベント
701
+ # リスナー登録、外部ライブラリのインスタンス化など)を持たせるときは、必ず
702
+ # このセクションを読み返すこと。
703
+
704
+ 最初`TerminalPanel`という独立コンポーネント(`props`に`project`/`files`/`active`を
705
+ 渡し、`state`に接続状態を持たせる作り)にしていたところ、
706
+ 「ファイルペインとターミナルペインを行き来すると接続が切れる。再接続しようとすると
707
+ `すでにオープンされている`と言われて失敗する」という不具合が実際のユーザーから
708
+ 報告された。
709
+
710
+ 原因はFunicularの仕様: **`component(SomeClass, props)`という呼び出しは、呼び出す
711
+ 親(ここではEditorApp)が再描画されるたびに、その子コンポーネントの
712
+ `initialize_state`と`component_mounted`を毎回呼び直す。** ルートコンポーネント
713
+ (`Funicular.start(EditorApp, ...)`に渡すもの)だけがマウント1回を保証される。
714
+ これは`EditorApp`の`initialize_state`/`component_mounted`に呼び出し回数を数える
715
+ 一時的なデバッグコードを仕込んで実際にブラウザで確認した(`TerminalPanel`側は
716
+ 数回のページ操作だけで3回以上呼ばれたのに対し、`EditorApp`側は終始1回だけだった)。
717
+
718
+ この既存コードベースを見ると、**このIDEの子コンポーネント(`FileList` `Toolbar`
719
+ `MenuBar` `ProjectSettingsDialog` `LogPanel`)は元から1つも`initialize_state`を
720
+ 定義していない**、つまり全部「propsを受け取って描画するだけの表示専用」
721
+ コンポーネントだった。これは単なるコーディング規約ではなく、**Funicularのこの
722
+ 挙動に対する回避策として最初から必須の設計**だったということ。`TerminalPanel`は
723
+ この規約を破って唯一`initialize_state`を持つ子コンポーネントにしてしまったために
724
+ 問題が表面化した。
725
+
726
+ 具体的に起きていたこと: EditorAppは`patch()`のたびに再描画される(タブ切り替え・
727
+ ファイルを開く・1文字タイプする、等ほぼ全ての操作で発生)。そのたびに
728
+ `TerminalPanel`の`initialize_state`が再実行され、`state[:status]`が初期値
729
+ `'disconnected'`に戻る。一方`@port`(生のシリアルポート、実際にはbrowserレベルで
730
+ まだopenのまま)はインスタンス変数なので必ずしも即座には失われないが、UI表示上は
731
+ 「未接続」に見える。ユーザーが「接続」ボタンを押すと`JS::WebSerial.connect`が
732
+ また`_request_port`を呼び、同じ物理デバイスを選ぶと(一度も`.close()`されていない
733
+ ため)ブラウザ側ではまだopenなSerialPortオブジェクトが返り、`.open()`が
734
+ `InvalidStateError`(「すでにオープンされている」)で失敗する。
735
+
736
+ 対処: **状態と副作用(xterm.jsインスタンス、シリアルポート、イベントリスナー)を
737
+ `TerminalPanel`から`EditorApp`本体へ丸ごと統合した**(`terminal_panel.rb`は削除、
738
+ ロジックは`editor_app.rb`に`terminal_`プレフィックス付きのメソッド/state key
739
+ として移動)。これは元々あった「textarea/ハイライト層をコンポーネントに切り出さず
740
+ EditorApp直下に置く」設計と全く同じ理由・同じ対処であり、後から振り返れば
741
+ 必然だった。もし将来また「一度だけ初期化する副作用を持つUI」を追加したくなったら、
742
+ 別コンポーネントに切り出さずEditorAppに直接書くか、少なくともこの制約を
743
+ 踏まえた設計にすること。
744
+
745
+ ## サイドバーのファイル一覧をツリー表示にした(TreeView)
746
+
747
+ 「ファイル一覧をツリー表示に、フォルダの開閉もできるように」という要望を受けて、
748
+ `FileList`(フラットな一覧、表示専用)を`TreeView`(`web/funicular/ruby/components/
749
+ tree_view.rb`)に置き換えた。
750
+
751
+ ### 汎用コンポーネントとして設計した
752
+
753
+ 「あとで独立したコンポーネント/gemとして切り出すことを念頭に」という指定だったので、
754
+ `TreeView`は「ファイル」や「プロジェクト」を一切知らない、
755
+ `{name:, path:, type: :dir|:file, children: [...]}`という形のノード配列を
756
+ 描画するだけの汎用コンポーネントにしてある。props(`nodes` `collapsed` `selected`
757
+ `icon_for` `on_select` `on_toggle`)経由でしか外の世界とやり取りしない。
758
+
759
+ 前節の「子コンポーネントは親の再描画のたびに作り直される」という制約があるので、
760
+ **折りたたみ状態(どのディレクトリが閉じているか)は`TreeView`自身のstateには
761
+ 一切持たせず、呼び出し側(`EditorApp`の`state[:collapsed_dirs]`)に持たせて
762
+ propsで渡す**設計にしてある。`TreeView`は`initialize_state`を定義していない
763
+ (=表示専用コンポーネントの規約を守っている)ので、作り直されても実害が無い。
764
+
765
+ `state[:files]`(プロジェクト直下からの相対パスのフラットな配列。例:
766
+ `"mrbgems/picoruby_hello_world/mrbgem.rake"`)をネストしたノード配列に組み立てる
767
+ ロジック(`file_tree_nodes` / `insert_file_tree_path` / `sorted_file_tree_nodes`)は
768
+ 「ファイルパス」というこのアプリ固有の概念を扱うので、`TreeView`側ではなく
769
+ `EditorApp`側に置いてある。
770
+
771
+ ### 踏んだ罠: `TreeView#select`がFunicularの`<select>`タグヘルパーと衝突する
772
+
773
+ ファイルクリック時のハンドラを最初`select(path)`という名前にしたところ、
774
+ ブラウザで即座に
775
+
776
+ ```
777
+ Funicular::DSLCollisionError: TreeView#select collides with the Funicular DSL
778
+ (<select> tag helper). Rename it (e.g. `select_value`), or declare
779
+ `allow_dsl_override :select` and use `tag(:select, ...)` to emit the element.
780
+ ```
781
+
782
+ というエラーが出て、ツリーが「読み込み中…」のまま固まった(例外がrender中に
783
+ 発生すると、そのレンダーパス全体が失敗し、直前の(読み込み中の)DOMが
784
+ そのまま残ってしまう)。Funicularは`div` `span` `button` `ul` `li`等と同じ感覚で
785
+ `select`という**HTMLタグ用のDSLメソッドを標準で生やしている**ため、
786
+ コンポーネント側で同名のメソッド(`select`)を定義すると衝突する。エラーメッセージ
787
+ 自体は非常に分かりやすく、原因の特定に迷うことは無かった。対処は単純に
788
+ `select_file`のような衝突しない名前に変える。
789
+
790
+ **教訓**: Funicularのコンポーネントにメソッドを生やすときは、HTMLタグ名
791
+ (`select` `label` `option` `form` `data` 等、意外と一般的な単語がタグ名として
792
+ 存在する)と衝突しないか一応意識する。衝突した場合はエラーメッセージが
793
+ `Funicular::DSLCollisionError`として明示的に教えてくれるので、実際に動かして
794
+ みればすぐ分かる(今回もブラウザで動かして1回で発見できた)。
795
+
796
+ ## 「ファイル」メニュー(新規プロジェクト/プロジェクトを開く/mrbgemを追加/新規ファイル/新しいフォルダ)
797
+
798
+ 既知の制約に挙げていた「新規ファイル作成/削除/リネームAPIがない」「プロジェクトの
799
+ 新規作成ができない」を一部解消する形で、メニューバー左端に「ファイル」ドロップダウンを
800
+ 追加した(`menu_bar.rb`の`render_file_menu`)。削除/リネームは対象外のまま(スコープ外)。
801
+
802
+ - `POST /api/projects` — 新規プロジェクト作成。`app/app.rb`(空)と`build_config.rb`
803
+ (空)だけを作る。`mrbgems/`は最初のmrbgem追加まで作らない
804
+ (`project_mrbgem_names`がディレクトリ不在でも空配列を返すため不要)
805
+ - `POST /api/projects/:name/mrbgems` — mrbgemの雛形(`mrbgem.rake` +
806
+ `mrblib/<name>.rb`)を作る。ここに置くだけで次回ビルドから自動的に組み込まれる
807
+ 仕組み(`generated_build_config_content`)は変更していない
808
+ - `POST /api/projects/:name/files` — 空の新規ファイルを作る。既存の`POST /api/file`は
809
+ 上書き専用のままにして、新規作成はこちらに分離した
810
+ - `POST /api/projects/:name/folders` — 空のフォルダを作る
811
+ - プロジェクト名・mrbgem名は`PROJECT_NAME_PATTERN`(英数字・`_`・`-`のみ)で
812
+ バリデーションしている。新規ファイル/フォルダのパスは`safe_path`のパストラバーサル
813
+ 対策に加え、`valid_new_file_path?`でドット始まりのセグメント(`.r2p2-esp32`や
814
+ `.config.yml`等、UI上のファイル一覧に出さない前提のものと衝突しうる)を拒否する
815
+
816
+ **新規ファイル・新しいフォルダは`app/`配下だけに制限してある**(「ファイルの作成は
817
+ app以下に制限したい」というフィードバック)。`under_app_dir?`ヘルパーで
818
+ `app/`で始まり、かつ`app/`自身ではないことをチェックする。`mrbgems/`側は
819
+ 「mrbgemを追加」が専用の雛形(`mrbgem.rake` + `mrblib/<name>.rb`)を作るので、
820
+ この2つのエンドポイントの対象外のままでよい。新規ファイルの拡張子も
821
+ `NEW_FILE_EXTENSION`(`.rb`)固定にした(同フィードバックの「rubyファイルだけに
822
+ 制限」より。`app/`はR2P2起動時に`main_task.rb`がloadするRubyスクリプト置き場で
823
+ あり、mrbgemのような`.c`/`.h`/`.rake`を置く場所ではないため)。UI側のダイアログには
824
+ `app/`を省いた相対パス(例: `utils/foo.rb`)を入力させ、送信直前に
825
+ `EditorApp#to_app_path`で`app/`を補っている(サーバ側のチェックは飽くまで防御。
826
+ 「`app/`を書かなくていい」というUXはフロント側の責務)。
827
+
828
+ **空のフォルダをツリーに表示するために`GET /api/dirs`を追加した。**
829
+ 既存の`GET /api/files`はファイルパスの一覧しか返さないため、`file_tree_nodes`が
830
+ そこから組み立てるツリーは「1つもファイルを含まないディレクトリ」を表現できない
831
+ (パスの並びにディレクトリ単体のエントリが出てこないので)。新規フォルダ作成で
832
+ このケースが実際に起きるため、ディレクトリ一覧(空のものも含む、`Dir.glob`ベースで
833
+ `GET /api/files`と同じくドット始まりは自動的に除外)を別エンドポイントで返し、
834
+ フロント側は`state[:dirs]`として保持、`insert_file_tree_dir_path`
835
+ (`insert_file_tree_path`のディレクトリ専用版。末尾セグメントも`:dir`として
836
+ 挿入する点だけが違う)で`state[:files]`由来のツリーにマージしている。
837
+ `GET /api/files`のレスポンス形自体(ファイルパスの配列)は変えていない
838
+ (ターミナルの「app/ をアップロード」機能がこの配列をそのままファイルとして
839
+ 読み込みに行くため、ディレクトリを混ぜると壊れる)。
840
+
841
+ UI側は3つのフォームダイアログ(新規プロジェクト名/mrbgem名/ファイルパス)を
842
+ `PromptDialog`という1つの汎用コンポーネントに集約した(タイトル/ラベル/
843
+ プレースホルダ/確定ボタンのラベル/エラーメッセージをpropsで差し替えるだけ)。
844
+ 「プロジェクトを開く」だけは一覧から選ぶ形なので別コンポーネント`OpenProjectDialog`
845
+ にしている(EditorAppが既に持っている`state[:projects]`をそのまま渡すだけで、
846
+ 追加のAPI呼び出しはしない)。
847
+
848
+ **入力欄はrefで直接DOM値を読む非制御コンポーネントにしてある**(`PromptDialog`)。
849
+ ビルドログのポーリング(3秒おき)でEditorAppが再描画される間もダイアログを開いたままに
850
+ できる設計上、`value`をpropsから毎回書き戻す制御方式にすると、そのたびに入力中の文字が
851
+ 消えてしまう。他のフォーム系コンポーネント(`project_settings_dialog.rb`の
852
+ `<select>`)と同じ理由・同じ対処。
853
+
854
+ 実装時に上の「`TreeView#select`衝突」と全く同じ罠を`OpenProjectDialog#select`でも
855
+ 踏んだ(プロジェクトをクリックして選択するメソッドに`select`と名付けてしまった)。
856
+ ブラウザのコンソールに`Funicular::DSLCollisionError`が出て発覚、`select_project`に
857
+ リネームして解決。**この罠は繰り返し踏みやすいので、Funicularコンポーネントに
858
+ クリックハンドラ用メソッドを生やすときは`select`という名前を反射的に避けること。**
859
+
860
+ ## ファイルツリーの右クリックコンテキストメニュー・削除機能
861
+
862
+ 「ファイルツリーにもコンテキストメニューが欲しい」というフィードバックを受けて、
863
+ `TreeView`(`tree_view.rb`)の各行に`oncontextmenu`を追加した。`TreeView`自体は
864
+ ファイル/プロジェクトの概念を知らない汎用コンポーネントという設計を保つため、
865
+ 右クリックされた`node`(type/path)とDOMの`event`をそのまま`props[:on_context_menu]`
866
+ 経由で呼び出し側(`EditorApp`)に渡すだけにしてある。メニューの中身を何にするかの
867
+ 判断は全部`EditorApp#context_menu_kind`に置いた:
868
+
869
+ - ファイル: 削除のみ
870
+ - `"app"`自身: ファイルを作成/フォルダを作成(削除は出さない。プロジェクトの
871
+ 実行スクリプト置き場であるapp/自体が消えると壊れるため)
872
+ - `"app"`配下のディレクトリ: ファイルを作成/フォルダを作成/削除
873
+ - `"mrbgems"`自身: mrbgemを追加のみ(既存の「ファイル」メニューの項目と同じ
874
+ `open_add_mrbgem_dialog`を呼ぶだけ。専用の雛形を作る仕組みなので他の
875
+ ディレクトリとは別メニューにしてある、という要望通り)
876
+ - それ以外のディレクトリ(`mrbgems/<gem>`やそのサブディレクトリ等): 削除のみ
877
+ (ファイル/フォルダの新規作成はapp/配下限定という既存の制約と矛盾しないよう、
878
+ ここでは作成系のメニュー項目を出さない)
879
+
880
+ **新規ファイル/フォルダ作成ダイアログをcontext_dirで一般化した。** 以前は
881
+ 「app/ からの相対パス」を常に入力させる作りだったが、コンテキストメニューから
882
+ 開く場合は右クリックしたディレクトリの中に作るのが自然なので、
883
+ `PromptDialog`の`state[:prompt_dialog][:context_dir]`(基準ディレクトリの
884
+ プロジェクト内相対パス。「ファイル」メニューからなら常に`'app'`、コンテキスト
885
+ メニューからなら右クリックしたディレクトリの`path`)を基準に、入力欄には
886
+ 「基準ディレクトリの中でのファイル名」だけを入力させ、送信直前に
887
+ `EditorApp#to_full_path(context_dir, rel)`で連結する。基準ディレクトリを
888
+ ダイアログの入力欄の初期値として埋め込む(prefill)方式は採用していない
889
+ ——`PromptDialog`は非制御コンポーネントなので、ポーリング等による親の
890
+ 再描画のたびに初期値を書き戻すと入力中の文字が消える(既存の「入力欄は
891
+ refで直接DOM値を読む」節と同じ理由)。代わりにラベル・タイトル側に
892
+ 基準ディレクトリを文言として表示するだけにして、入力欄自体は常に空から
893
+ 始まる設計にしてある。
894
+
895
+ **削除(`DELETE /api/projects/:name/files` / `DELETE /api/projects/:name/folders`)は
896
+ 作成と違いapp/配下に限定していない**(mrbgemの.c/.hファイルなど、プロジェクト内の
897
+ どこにあるファイル/フォルダでも削除自体は妥当なユースケースがあるため)。
898
+ その代わり、フォルダ削除は`app`自身・`mrbgems`自身を消せないようサーバ側でも
899
+ 明示的にガードしている(UIのコンテキストメニューで出さないのに加えて、
900
+ APIを直接叩かれた場合の防御を二重にしてある)。削除は取り消せない操作なので、
901
+ 実行前に`JS.global.confirm(message)`で確認を挟む(Funicular本体の
902
+ `Funicular.confirm`も既定でこれに委譲する作りになっている。
903
+ `picoruby-funicular/mrblib/funicular.rb`の`!!JS.global.confirm(message)`参照)。
904
+ 削除対象のファイルがエディタで開いたままだった場合(削除されたフォルダの
905
+ 配下に開いていたファイルがあった場合も含む)は、実体の無いファイルを
906
+ 編集し続けないよう`current_path`をクリアする。
907
+
908
+ **踏んだ罠1**: `context_menu_kind`で`APP_DIRNAME`/`MRBGEMS_DIRNAME`を参照したところ
909
+ `NameError: uninitialized constant EditorApp::MRBGEMS_DIRNAME`になった。
910
+ これらの定数は`app.rb`(Sinatra、サーバ側のRubyプロセス)にしか定義しておらず、
911
+ `editor_app.rb`(ブラウザのPicoRuby.wasm、別プロセス・別Rubyランタイム)から
912
+ 見えるわけではない、という当たり前の見落とし。フロント側にも同名の定数を
913
+ 別途定義して解決した(サーバとフロントで定数を共有する仕組みは無いので、
914
+ 今後も両側に同じ文字列リテラルを持つ設計になる)。
915
+
916
+ **踏んだ罠2**: 削除確認の`JS.global.confirm(...)`は、このセッションで使っている
917
+ ブラウザ自動操作ツール(MCP経由)ではネイティブダイアログが自動的に抑制され、
918
+ 常に`false`が返ってくる(「ボタンを押しても削除されない」ように見えた)。
919
+ 実際にはconfirmメッセージの内容自体は正しく渡っており、`curl`で
920
+ `DELETE`エンドポイントを直叩きして削除自体が動くことを別途確認した。
921
+ 実際のブラウザ(自動操作ツール経由でない、人間が操作するブラウザ)では
922
+ ネイティブダイアログが普通に表示される。この種の自動操作ツール特有の
923
+ 制約は、機能自体のバグと混同しないよう注意すること。
924
+
925
+ **踏んだ罠3**: 上記まではこの自動操作ツールで検証していたが、実際にユーザーが
926
+ 自分のブラウザで試したところ「ブラウザ標準の右クリックメニューも自前のメニューと
927
+ 一緒に出てしまう」と報告があった。`tree_view.rb`の各行の`oncontextmenu`(Funicular
928
+ DSL経由)でも`event.preventDefault`は呼んでいたが、それだけでは足りなかった
929
+ (自動操作ツールでは right_click してもブラウザの標準メニュー自体が画面に
930
+ 描画されないため、この不具合はこのツールだけでは検出できず、実際に人間が
931
+ ブラウザで試して初めて発覚した)。
932
+
933
+ 最初の対処として、`EditorApp#component_mounted`から素のJS
934
+ `JS.global[:document].addEventListener('contextmenu') { |event| ... }`
935
+ にRubyのブロックを直接渡す形(xterm.jsの`attachCustomKeyEventHandler`と同じ
936
+ 発想)を試したが、**これでも直らなかった**。`refs[:sidebar]`越しの
937
+ `Node#contains`判定や、そもそもRubyブロック自体がPicoRuby.wasmを経由する
938
+ 呼び出しである以上、`addEventListener`の登録先がJS/Rubyのどちらであっても、
939
+ コールバック本体がRuby(wasm)側にある限り、ブラウザ側から見て
940
+ 「このcontextmenuイベントのデフォルト動作をまだキャンセルできる」同期的な
941
+ タイミングに間に合わない可能性がある、ということが実機での再現から
942
+ 分かった(正確な内部メカニズムは未特定だが、Rubyブロックを経由する時点で
943
+ 何らかの非同期性が生じると考えるのが一番説明がつく)。
944
+
945
+ 最終的に効いたのは、**PicoRuby/Funicularのブリッジを完全に経由しない、
946
+ `views/index.erb`内の素の`<script>`タグ**(`window.funicularHighlight`と同じ、
947
+ 「確実性が要る部分はプレーンJSに任せる」既存の方針):
948
+
949
+ ```js
950
+ document.addEventListener('contextmenu', function (event) {
951
+ if (event.target.closest('.sidebar')) {
952
+ event.preventDefault();
953
+ }
954
+ });
955
+ ```
956
+
957
+ これなら`preventDefault`の呼び出しがブラウザのイベントディスパッチと完全に
958
+ 同一のJS実行コンテキスト・同一のコールスタックで完結するため、タイミングの
959
+ 不確実性が原理的に無くなる。`EditorApp#suppress_sidebar_native_context_menu`
960
+ (Rubyブロック版)と`div(class: 'sidebar', ref: :sidebar)`は不要になったので
961
+ 削除した。実際に`document.querySelector('.tree-row')`へ合成contextmenuイベントを
962
+ `dispatchEvent`し、`event.defaultPrevented === true`になることと、
963
+ `.sidebar`外(エディタの`<textarea>`)では`false`のままになることを
964
+ ブラウザのJS実行で直接検証して確認済み。
965
+
966
+ **教訓**: `preventDefault`が間に合うかどうかが問題になったら、Funicularの
967
+ `onXxx:`経由はもちろん、「素のJS APIにRubyのブロックを直接渡す」形
968
+ (xterm.jsの`attachCustomKeyEventHandler`のような、コールバックの型自体は
969
+ JSネイティブでもRubyブロックである時点でPicoRuby.wasmを経由する)でも
970
+ 確実とは限らない。**本当に確実にしたいなら、Rubyを一切経由しない
971
+ `<script>`タグの中で完結させること。** また、この種のタイミング不具合は
972
+ 自動操作ツールのスクリーンショットだけでは気づけない(ネイティブUIの重なりが
973
+ 写らない)ので、疑わしいときは実際に人間のフィードバックを当てにし、
974
+ `dispatchEvent`+`defaultPrevented`のような形でJS実行から直接検証する。
975
+
976
+ ## gemとして配布する構成への変更(classic → modular Sinatra、`app/`廃止)
977
+
978
+ Sinatraアプリ本体を`gem install`で配布することを前提に、2つの変更をした。
979
+
980
+ ### `lib/picoride/server.rb`: classic Sinatra → modular Sinatra
981
+
982
+ それまで`server.rb`は`require "sinatra"`(classicスタイル)でトップレベルに
983
+ `get`/`post`を書く形だった。classicスタイルは`require`しただけで
984
+ `sinatra/main.rb`の`at_exit`フックが仕込まれ、プロセス終了時に**自動で**
985
+ HTTPサーバーが起動してしまう。これは単発のスクリプトなら問題ないが、
986
+ gemとして配布するライブラリファイルがrequireされただけでサーバーを起動する
987
+ 副作用を持つのは好ましくない(将来テストやirbから`require`したら勝手にサーバーが
988
+ 立ってしまう)。
989
+
990
+ そこで`require "sinatra/base"` + `module Picoride; class Server <
991
+ Sinatra::Base; ... end; end`のmodularスタイルに書き換えた。ルート定義
992
+ (`get`/`post`/`delete`)・`helpers do...end`・トップレベルの`def`(いずれも元々
993
+ Rubyのレキシカルスコープに乗っているだけなので)は、クラス本体の中にそのまま
994
+ 入れ子にするだけで動く(呼び出され方は変わらない)。`PROJECTS_ROOT`等の定数、
995
+ `BackgroundJob.new`・`R2P2State.ensure_checkout`のような他ファイルの定数参照も、
996
+ `module Picoride`の中に入ったことでレキシカルスコープ経由で
997
+ 正しく解決されるようになった(`Picoride::BackgroundJob`等への
998
+ 明示的な修飾は不要)。
999
+
1000
+ **踏んだ罠**: classicスタイルの`-p`/`-o`/`-e`コマンドラインオプション
1001
+ (`ruby app.rb -o 0.0.0.0`のような)は、実は`Sinatra::Base`自体の機能ではなく
1002
+ `sinatra/main.rb`(`require "sinatra"`した時だけ読まれる)がARGVを
1003
+ `OptionParser`で自前パースしているだけ、という実装になっている
1004
+ (`gem which sinatra`で実際のgemソースを確認して判明)。`require "sinatra/base"`
1005
+ に変えるとこの自動パースは丸ごと失われるので、[exe/picoride](exe/picoride)側で
1006
+ 同等の`-p`/`-o`/`-e`を`OptionParser`で自前実装し、
1007
+ `Picoride::Server.run!(options)`を明示的に呼ぶようにした。
1008
+
1009
+ ### `app/public/` `app/funicular/` `app/views/` → `lib/picoride/{public,funicular,views}/`
1010
+
1011
+ gemの`spec.files`は`git ls-files`から`bin/`等を除いたもの(`picoride.gemspec`
1012
+ 参照)で、実質的に`lib/`配下しか配布対象に入らない。`server.rb`の
1013
+ `set :public_folder` / `set :views` / `FUNICULAR_ROOT`はいずれも`__dir__`
1014
+ (= `server.rb`自身のディレクトリ)からの相対パスで組み立てているため、
1015
+ これらの実体(CSS/JS、Funicularの Rubyコンポーネント、`index.erb`)が
1016
+ `lib/picoride/`配下に無いと、gem installした環境では存在しない
1017
+ パスを見てしまい何も配信できなくなる。そのため`git mv`で
1018
+ `app/`配下から`lib/picoride/`配下へ丸ごと移動した
1019
+ (`app/`は空になって消滅)。移動後も`index.erb`内のURLはサーバーのルート
1020
+ (`/css/style.css`・`/ruby/components/*.rb`)への参照であり、物理的な
1021
+ 配置場所には依存しないので書き換え不要だった。
1022
+
1023
+ ### 続き: `lib/picoride/{public,funicular,views}/` → `web/{public,funicular,views}/`
1024
+
1025
+ 上の移動で gem には同梱されるようになったが、`lib/` は `$LOAD_PATH` に入る
1026
+ サーバ側Rubyコードの置き場であり、CSS/JS・ERB・ブラウザ(PicoRuby.wasm)側で
1027
+ 動くFunicularのコードが混ざるのは役割として不自然だったため、gemルート直下の
1028
+ `web/` に移した(Sidekiqの `web/assets` `web/views` と同じ発想)。
1029
+
1030
+ - `exe/` 配下は**不可**: gemspecの`spec.executables = spec.files.grep(%r{\Aexe/})`
1031
+ に引っかかり、`style.css`等が実行ファイルとしてbinstub化されてしまう
1032
+ - `spec.files`は`git ls-files`から`bin/` `test/`等を除いたものなので、
1033
+ ルート直下の`web/`は設定変更なしでgemに同梱される
1034
+ - `server.rb`は`WEB_ROOT = File.expand_path("../../web", __dir__)`を基点に
1035
+ `public_folder` / `views` / `FUNICULAR_ROOT`を組み立てる。配信URL
1036
+ (`/css/...` `/js/...` `/ruby/...`)は変わらない
1037
+
1038
+ ### `exe/picoride`: gem実行ファイルとしての注意点
1039
+
1040
+ gemの`exe/`に置く実行ファイルは、依存関係やロードパスを**RubyGemsが
1041
+ 自動的に**設定してくれる(bin stub経由で起動されるため)。これを踏まえていない
1042
+ 実装が2つ残っていたので直した:
1043
+
1044
+ - `require "bundler/setup"`が書かれていたが、これは`bin/server`のような
1045
+ 「リポジトリ内で開発用に動かすスクリプト」で使うもの。`gem install`後に
1046
+ リポジトリと無関係な場所で`picoride`コマンドだけ叩いた場合、カレント
1047
+ ディレクトリにGemfileが無くて`Bundler::GemfileNotFound`になる(最悪、
1048
+ 別の無関係なGemfileを拾ってしまう)。gem実行ファイルには不要かつ有害。
1049
+ - `require "picoruby-esp32-ide"`(当時のgem名をハイフンのまま)と書かれていたが、
1050
+ `bundle gem`が生成する本体ファイルは常にアンダースコア名
1051
+ (当時は`lib/picoruby_esp32_ide.rb`。現在は`picoride`へのリネームにより
1052
+ `lib/picoride.rb`)なので、これはそもそも存在しないファイルを`require`
1053
+ しようとしていた(`LoadError`)。
1054
+
1055
+ **検証方法**: この種の「gemとして配布されたときにちゃんと動くか」は
1056
+ リポジトリ内で`bundle exec`して動かすだけでは検証できない(Bundlerが
1057
+ Gemfile経由でロードパスを解決してしまうため、上記のバグがあっても気づけない)。
1058
+ 実際に`gem build`→別のGEM_HOMEに`gem install`→リポジトリの外のディレクトリから
1059
+ `picoride`コマンドだけを叩く、という「本当にインストールされた状態」を
1060
+ 再現して初めて上記2つのバグが発覚した。gem化に関わる変更をしたときは、
1061
+ このレベルまで検証するのが確実。
1062
+
1063
+ ## プロジェクトの置き場所(ワークスペース)をUIから指定する方式に変更
1064
+
1065
+ 以前は`PROJECTS_ROOT`(`lib/picoride/server.rb`)という固定パスの定数で、
1066
+ 既定値は`__dir__`からの相対パス(`../projects`)、環境変数で上書き可能という
1067
+ 設計だった。これが実際に「プロジェクトディレクトリがない」というエラーの
1068
+ 原因になっていた: gemとして配布する構成への変更(`app/`→`lib/picoride/`への
1069
+ 移動、上の節参照)で`server.rb`自身の`__dir__`が1階層深くなったのに、
1070
+ 相対パスの計算(`File.join(__dir__, "..", "projects")`)はそのままだったため、
1071
+ 実際には存在しない`lib/projects`を指すようになっていた。環境変数で上書きする
1072
+ 運用に頼っている限り、ディレクトリ構成が変わるたびに同種の問題が再発しうる。
1073
+
1074
+ そこで、固定パス・環境変数での既定値を完全に無くし、**起動直後は「ワークスペース
1075
+ 未設定」から始まり、ブラウザの「ファイル」>「ワークスペースを開く」から
1076
+ 絶対パスを指定して開く**方式に変更した(「プロジェクトディレクトリではなく
1077
+ ワークスペースとしてUIから指定したい」という要望)。用語としては、
1078
+ 従来`PROJECTS_ROOT`が指していたディレクトリ(複数のプロジェクトを
1079
+ サブディレクトリとして持つ場所)がそのまま「ワークスペース」になった。
1080
+ 1プロジェクト = `<workspace>/<name>/`という単位(app/・mrbgems/・
1081
+ build_config.rb)自体は変更していない。「新しいプロジェクト」「プロジェクトを
1082
+ 開く」は今まで通りワークスペース配下のプロジェクトを対象に動く。
1083
+
1084
+ - `Server.workspace_root`(クラスインスタンス変数のアクセサ)がワークスペースの
1085
+ 絶対パスを保持する。サーバプロセスが生きている間だけ保持され、永続化はしない
1086
+ (再起動したら未設定に戻る。複数ワークスペースの履歴・最近開いた一覧も持たない
1087
+ —「常に1つだけ、履歴なし」という要望によるシンプルな設計)
1088
+ - `GET /api/workspace` — 現在のワークスペース(`{path: "..."}`。未設定は`null`)
1089
+ - `POST /api/workspace` — `{path: "/abs/path"}`で開く。`File.expand_path`で
1090
+ 正規化する(`~`展開・相対パスの解決も行うが、相対パスはサーバプロセスの
1091
+ カレントディレクトリ基準になるため分かりにくい。基本は絶対パスを渡す前提)。
1092
+ 既存ディレクトリであることだけをチェックし、作成はしない
1093
+ - `/api/workspace`自身を除く全ての`/api/*`は、ワークスペース未設定だと
1094
+ `project_root`が成立せず処理できないため、`before do ... end`
1095
+ (パターン無し)で`request.path_info`を見て一括チェックしている。
1096
+ **踏んだ罠**: 最初`before %r{\A/api/(?!workspace\z)} do ... end`のように
1097
+ パターン付きbeforeにしたところ、実際に起動したら
1098
+ `Mustermann::CompileError: regular expression should not contain \A`に
1099
+ なった。Sinatraのルート/フィルタにパターンを渡すと内部のMustermannが
1100
+ コンパイルする際に独自に文字列全体へのアンカーを付与する実装になっていて、
1101
+ 渡した正規表現が(`\A`/`\z`はおろか`^`/`$`でも)既にアンカーを含むと
1102
+ コンパイルエラーになる(両方試して両方失敗することを実際の起動で確認した)。
1103
+ この種のエラーは`ruby -c`の静的チェックでは検出できず、実際に
1104
+ `bundle exec exe/picoride`を起動して初めて分かった。対処はパターン無しの
1105
+ `before do ... end`にして、Mustermannを経由しない素のRubyの文字列比較
1106
+ (`request.path_info.start_with?(...)`)でパスを判定する形に変更した
1107
+ - フロント側(`editor_app.rb`)は`component_mounted`で`load_project_list`を
1108
+ 直接呼ばず、まず`load_workspace`(`GET /api/workspace`)を呼ぶ。ワークスペースが
1109
+ 既に開かれていれば従来通り`load_project_list`、未設定なら
1110
+ 「ワークスペースを開く」ダイアログ(`PromptDialog`の`kind: :open_workspace`、
1111
+ 他の用途と同じく使い回し)を自動で表示してユーザーに指定してもらう
1112
+ (VSCodeの「フォルダを開く」相当)。サイドバーにも
1113
+ `state[:workspace].nil?`のときだけ出す専用メッセージを用意した
1114
+ - ワークスペースを切り替えたとき(`open_workspace`)は、`select_project`と
1115
+ 同じ理由で、プロジェクト/ファイル関連のstate(現在選択中のプロジェクト・
1116
+ ファイル一覧・編集中の内容など)を全部クリアしてから新しいワークスペースの
1117
+ プロジェクト一覧を取り直す
1118
+ - `MenuBar`に現在のワークスペースの絶対パス表示を追加した。「新しいプロジェクト」
1119
+ 「プロジェクトを開く」メニュー項目は、ワークスペース未設定の間は押せない
1120
+ (mrbgem追加・新規ファイル作成がプロジェクト未選択の間押せないのと同じ
1121
+ disabledの書き方)
1122
+
1123
+ ### 続き: 起動時の自動ダイアログを廃止し、常設ドロップダウンをワークスペース切り替えに転用
1124
+
1125
+ 上記の初回実装では、(1)起動時にワークスペース未設定なら「ワークスペースを開く」
1126
+ ダイアログを自動表示し、(2)ワークスペースのパスはメニューバーの専用ラベル
1127
+ (`.menu-bar-workspace`)で表示するだけ、(3)メニューバー常設の`<select>`は
1128
+ 従来通りプロジェクト切り替え専用、という作りだった。これをフィードバックで
1129
+ 変更した:
1130
+
1131
+ - **自動ダイアログは廃止**。「それはやめてほしい」という要望により、
1132
+ `component_mounted`→`load_workspace`は`GET /api/workspace`の結果を
1133
+ state(`workspace`・`known_workspaces`)に反映するだけになり、未設定でも
1134
+ 何も自動表示しない。ユーザーがメニューバーのボタン、または「ファイル」>
1135
+ 「ワークスペースを開く…」を明示的に押すまでワークスペースは開かれない
1136
+ - **ワークスペースは複数開けて、メニューバー常設の`<select>`で切り替える**
1137
+ ように変更(以前の「履歴なし、常に1つだけ」という設計判断を上書きする
1138
+ フィードバック)。これに伴い、**この`<select>`はプロジェクト選択ではなく
1139
+ ワークスペース選択に転用した**。プロジェクトの切り替えは「ファイル」>
1140
+ 「プロジェクトを開く…」(`OpenProjectDialog`、変更なし)に一本化している
1141
+ - `Server.known_workspaces`(`workspace_root`と同じくクラスインスタンス変数の
1142
+ アクセサ、配列)が、このセッションで一度でも開いたワークスペースの絶対パスを
1143
+ 開いた順に保持する。`POST /api/workspace`は毎回この配列に(無ければ)追加し、
1144
+ `GET /api/workspace`は`{path:, known: [...]}`の形で両方返す。`workspace_root`
1145
+ 同様、サーバプロセスが生きている間だけ保持しディスクには永続化しない
1146
+ - フロント側の`<select>`(`menu_bar.rb`の`render_workspace_select`)は、
1147
+ `known_workspaces`が空の間は`<select>`の代わりに「ワークスペースを開く…」
1148
+ という`<button>`を描画する(同じ`props[:on_open_workspace]`コールバックを
1149
+ 使い回すだけ)。1つ以上あれば、`known_workspaces`を選択肢に持つ`<select>`になり、
1150
+ 選ぶと即座に`open_workspace(path)`経由で切り替わる(確認ダイアログは無い。
1151
+ 値は常に絶対パスなので取り違えようがないため)
1152
+ - ワークスペース名は既定で「ディレクトリ名(パスの最後のセグメント)」
1153
+ (「ワークスペース名はデフォルトではディレクトリ名でよい」という要望通り)。
1154
+ picoruby.wasm上では`File.basename`等`File`系メソッドの対応が不確かなため
1155
+ (`editor_app.rb`の`extension`ヘルパーと同じ理由)、`path.split('/').last`の
1156
+ ような素の文字列分割で求めている(`menu_bar.rb`の`workspace_label`)
1157
+ - `open_workspace(path)`は「ダイアログから新規に開く」「ドロップダウンで
1158
+ 既知のワークスペースへ切り替える」の両方を1つのメソッドで処理する
1159
+ (どちらも`POST /api/workspace`を叩くだけで、サーバ側のロジックは
1160
+ 「既に知っているパスなら追加しない」で自然に両対応になる)。違いは
1161
+ 失敗時のエラー表示先だけで、`state[:prompt_dialog]`が開いていれば
1162
+ ダイアログ内に、そうでなければ(=ドロップダウン経由)トップレベルの
1163
+ ステータス表示にエラーを出す
1164
+ - 既に選択中のワークスペースをドロップダウンで選び直したとき(ブラウザの
1165
+ `<select>`はユーザーが同じ値を選んでも`change`イベントを発火させない
1166
+ ことが多いが、万一発火しても)`open_workspace`の先頭で
1167
+ `return if path == state[:workspace]`としてプロジェクト一覧の不要な
1168
+ 再取得・編集状態のクリアを避けている
1169
+
1170
+ ### 続き2: 「ワークスペースを開く」を絶対パスのテキスト入力からディレクトリブラウザに変更
1171
+
1172
+ 絶対パスを手で一行入力させる方式は、実際に使うと「正確なパスを覚えていないと
1173
+ 開けない」という使いにくさがあった。「よくあるファイルビューアのような見た目で
1174
+ ディレクトリを探しやすく、最初はホームディレクトリが開いている状態に、
1175
+ ディレクトリを作成できる機能も」という要望を受けて、専用のディレクトリブラウザ
1176
+ ダイアログ(`DirectoryBrowserDialog`)に置き換えた。これに伴い、`PromptDialog`の
1177
+ `kind: :open_workspace`は廃止した(他の`:new_project`等はそのまま)。
1178
+
1179
+ - サーバ側に汎用のディレクトリブラウザAPIを追加した。`workspace`や`projects`とは
1180
+ 別物で、ファイルシステム上の**任意の場所**のサブディレクトリだけを列挙する
1181
+ (どのワークスペースにも属さない、という位置づけ)
1182
+ - `GET /api/browse?path=...` — 指定ディレクトリのサブディレクトリ一覧
1183
+ (`{path:, parent:, entries: [{name:, path:}, ...]}`)。`path`省略時は
1184
+ `Dir.home`(「最初はホームディレクトリが開いている状態で」という要望に
1185
+ 対応)。ドット始まりのディレクトリは`GET /api/files`等と同じ理由で除外する
1186
+ - `POST /api/browse` — `{path:, name:}`で新しいフォルダを作成する。
1187
+ 「新しいフォルダ」を押す動機は大抵そこを開きたいからという想定で、
1188
+ 作成後は親ではなく**作成したフォルダ自身**をGETと同じ形で返す
1189
+ (フロント側はそのままそこへ移動したものとして扱うだけでよい)
1190
+ - どちらも`workspace_root`が無くても使える必要がある(これを使って
1191
+ ワークスペースを選ぶため)ので、`WORKSPACE_EXEMPT_API_PATHS`に
1192
+ `/api/browse`を追加して`before`フィルタの対象外にした
1193
+ - フロント側(`directory_browser_dialog.rb`)は、他の子コンポーネント
1194
+ (`TreeView`・`OpenProjectDialog`等)と同じく`initialize_state`を持たない
1195
+ 表示専用コンポーネントにしてある。現在地・一覧・読み込み中/エラー状態は
1196
+ 全部`EditorApp`の`state[:workspace_browser]`(Hashまたはnil)に持たせ、
1197
+ propsとして渡すだけ(理由はCLAUDE.md内の「子コンポーネントは親が
1198
+ 再描画されるたびに作り直される」の節と同じ)。新規フォルダ名の入力欄だけは
1199
+ `PromptDialog`と同じ理由でrefベースの非制御コンポーネントにしてある
1200
+ - パンくず("/" "home" "youchan" "Projects"のようなクリック可能なセグメント)は
1201
+ `path.split('/')`から累積パスを組み立てて作る。他の箇所
1202
+ (`menu_bar.rb`の`workspace_label`、`editor_app.rb`の`extension`)と同じ理由で
1203
+ `File.basename`等`File`系メソッドは使わず素の文字列分割にしてある
1204
+ - **踏んだ罠**: `GET /api/browse`が返す`entries`(`[{name:, path:}, ...]`の配列)を
1205
+ `DirectoryBrowserDialog`側で`entry[:name]` / `entry[:path]`とシンボルキーで
1206
+ 読んだところ、実際にブラウザで開くとフォルダ一覧はアイコンだけ並んで
1207
+ 名前が全部空欄になり、クリックしても反応しない(`navigate_to(nil)`になり
1208
+ 早期returnするため)という不具合になった。原因はJSONをパースした結果が
1209
+ 文字列キーのHashであること(トップレベルの`path`/`parent`等は元から
1210
+ `value_of`経由で読んでいたので気づかなかったが、配列の中身である`entries`の
1211
+ 各要素までは`value_of`を通していなかった)。`menu_bar.rb`等にある
1212
+ `value_of(data, key) { data[key] || data[key.to_sym] }`と同じヘルパーを
1213
+ `DirectoryBrowserDialog`にも定義し、`entry[:name]`ではなく
1214
+ `value_of(entry, 'name')`のように読むことで解決した。この種のバグは
1215
+ `ruby -c`は素通りし、実際にブラウザで開いて初めて発覚する
1216
+ (今回も実機代わりにブラウザ自動操作ツールで開いて発見した)。**JSON
1217
+ レスポンスの中身をRubyのHashとして読むときは、トップレベルだけでなく
1218
+ ネストした配列/Hashの要素も含めて、必ず`value_of`経由で読むこと。**
1219
+
1220
+ ### 続き3: ワークスペースを`~/.picoride/workspace.toml`に永続化
1221
+
1222
+ ここまでの`workspace_root`/`known_workspaces`はサーバプロセスが生きている間
1223
+ だけのメモリ上の状態で、再起動すると消えていた。「一度開いたら覚えておいて
1224
+ ほしい、再起動しても開かれた状態で起動してほしい」という要望を受けて、
1225
+ `~/.picoride/workspace.toml`への永続化(`lib/picoride/workspace_store.rb`、
1226
+ `Picoride::WorkspaceStore`モジュール)を追加した。保存先をプロジェクト本体の
1227
+ 外(ホームディレクトリ配下)にしたのは`r2p2_state.rb`のR2P2-ESP32状態
1228
+ ディレクトリと同じ理由(プロジェクトのgitリポジトリに混ざるべきではない)。
1229
+
1230
+ ファイル形式(TOML):
1231
+
1232
+ ```toml
1233
+ active = "最後にアクティブだったワークスペースの絶対パス"
1234
+
1235
+ [ワークスペース名]
1236
+ path = "絶対パス"
1237
+ ```
1238
+
1239
+ - 読み書きには[PerfectTOML](https://github.com/mame/perfect_toml)
1240
+ (`gem "perfect_toml"`、`picoride.gemspec`に追加)を使っている。単一ファイル・
1241
+ 無依存でTOML v1.0.0に完全準拠するパーサ/ジェネレータ。**最初は自前で
1242
+ ミニマムなTOMLリーダー/ライターを手書きしていた**(書く分だけ読めれば
1243
+ 十分という割り切りで、シングルクォート文字列のエスケープまで実際に
1244
+ `ruby -e`でラウンドトリップ検証して動くところまで作っていた)が、
1245
+ ユーザーから「mame/perfect_tomlを使ってほしい」とフィードバックがあり
1246
+ 差し替えた。既存の外部ライブラリで十分に要件を満たせる(軽量・無依存で、
1247
+ このプロジェクトの「Railsもnpmも持ち込まない」方針と衝突しない)なら、
1248
+ 自前実装よりそちらを優先するべきだったという教訓
1249
+ - `WorkspaceStore.load`は`{active:, paths:}`を返す。保存されていても
1250
+ **実際に存在しなくなったディレクトリは除外**し、`active`がその除外対象
1251
+ だった場合は`nil`にフォールバックする(ディレクトリが削除/移動された後に
1252
+ 再起動しても、存在しないワークスペースを開こうとして`workspace_root`が
1253
+ 壊れた状態で起動しないようにするため)
1254
+ - `WorkspaceStore.save`は毎回まるごと書き直す(差分更新はしない。開閉は
1255
+ 頻繁な操作ではないので素朴な実装で十分と判断)。ワークスペース名
1256
+ (TOMLのテーブル名)は`display_name`(ディレクトリ名。`menu_bar.rb`の
1257
+ `workspace_label`と同じロジックをサーバ側にも持たせてある)から決め、
1258
+ 違う場所にある同名ディレクトリ(例: 複数の`projects`)が衝突しないよう
1259
+ `unique_name`で連番サフィックスを振る。クォートや空白を含む名前の扱いは
1260
+ PerfectTOML.generateに任せている(自前実装が担っていた部分がまるごと
1261
+ 不要になった)
1262
+ - `server.rb`はクラス定義時(`class Server < Sinatra::Base ... end`の
1263
+ 本体が評価されるとき、Rubyプロセスの起動時に1回)に`WorkspaceStore.load`を
1264
+ 呼んで`workspace_root`/`known_workspaces`の初期値にする。`POST /api/workspace`
1265
+ (ワークスペースを開く/切り替える)のたびに`WorkspaceStore.save`で書き戻す。
1266
+ フロント側(`editor_app.rb`)は元々`component_mounted`で`GET /api/workspace`を
1267
+ 呼んでいて、その結果に応じて動く設計だったので、**フロント側の変更は
1268
+ 一切不要だった**(サーバが起動時点で既に`workspace_root`を持っていれば、
1269
+ フロントから見れば「たまたま誰かが先に開いていた」のと区別が付かない)
1270
+ - 実際に`bundle exec exe/picoride`を起動→ワークスペースを開く→
1271
+ `~/.picoride/workspace.toml`の中身を確認→プロセスを再起動→
1272
+ `GET /api/workspace`が最初から開かれた状態を返す→ブラウザで実際にダイアログ
1273
+ 無しでプロジェクトファイルが表示される、まで一通り確認した
1274
+
1275
+ ### 続き4: ターゲット・VM・USB Console設定をプロジェクト単位からワークスペース単位に変更
1276
+
1277
+ 「設定はワークスペース毎に持たせてほしい。workspace.tomlに保存するように
1278
+ してほしい」という要望を受けて、ターゲットチップ・VM・USB Console
1279
+ (「設定」ダイアログ、旧`ProjectSettingsDialog`)のスコープをプロジェクトから
1280
+ ワークスペースへ変更した。以前はプロジェクトごとの隠しファイル`.config.yml`に
1281
+ 保存していたため、1ワークスペース内の複数プロジェクトがそれぞれ別のターゲットを
1282
+ 持てたが、変更後は1ワークスペース=1つの設定を全プロジェクトが共有する形になる。
1283
+ 既存の`.config.yml`を読む処理は削除した(ファイル自体の削除はしない。移行処理は
1284
+ 行わないクリーンな置き換え)。
1285
+
1286
+ - `WorkspaceStore`(workspace_root/known_workspacesと同じ`~/.picoride/workspace.toml`)の
1287
+ 各ワークスペースのテーブルに`platform`/`vm`/`usb_console`を追加した:
1288
+ ```toml
1289
+ [workspace_name]
1290
+ path = "絶対パス"
1291
+ platform = "esp32" # 未設定なら書かない
1292
+ vm = "femtoruby" # 未設定なら書かない
1293
+ usb_console = false
1294
+ ```
1295
+ TOMLにはnull/nilが無いため(`PerfectTOML.generate`に`nil`値を渡すと
1296
+ `NoMethodError: undefined method 'to_inline_toml' for nil`で落ちることを
1297
+ 実際に確認した)、`platform`/`vm`が未設定の間はキー自体を書かない設計にした
1298
+ (`usb_console`は常にboolなのでnil問題は起きず、常に書く)
1299
+ - `WorkspaceStore.save`は`settings:`をデフォルト値無しのキーワード必須引数に
1300
+ してある。デフォルト値(例: `{}`)を許すと、「ワークスペースを開いた/
1301
+ 切り替えただけ」の呼び出し箇所(`POST /api/workspace`)で、既存の設定を
1302
+ 空のHashで上書きして消してしまうミスを構造的に防げないため
1303
+ (実際にこの形で実装しかけて気付いた。`POST /api/workspace`側で
1304
+ `settings: workspace_settings`を渡し忘れると、ワークスペースを開くたびに
1305
+ 他の全ワークスペースの設定が消える、という静かなデータ破壊になるところだった)
1306
+ - `server.rb`に`Server.workspace_settings`(クラスインスタンス変数、
1307
+ `{絶対パス => {"platform"=>, "vm"=>, "usb_console"=>}}`)を追加。
1308
+ `workspace_root`/`known_workspaces`と同じく起動時に`WorkspaceStore.load`で
1309
+ 復元する。`read_workspace_config`ヘルパーが現在のワークスペース
1310
+ (`workspace_root`)の設定を返す(未設定なら`{"platform"=>nil,"vm"=>nil,
1311
+ "usb_console"=>false}`)
1312
+ - エンドポイントを`GET/POST /api/projects/:name/config`(プロジェクト名が必須)
1313
+ から`GET/POST /api/workspace/config`(現在のワークスペースが暗黙の対象、
1314
+ プロジェクト名不要)に置き換えた。`POST /api/build`・`POST /api/platform`も
1315
+ `read_project_config(root)`ではなく`read_workspace_config`を見るように変更
1316
+ (`root`=プロジェクトの絶対パスは`project_root`で検証済みなので、常に
1317
+ `workspace_root`配下にある=属するワークスペースの設定を見るのと整合する)
1318
+ - フロント側は`ProjectSettingsDialog`を`WorkspaceSettingsDialog`に改名
1319
+ (ファイル名・クラス名とも)。ダイアログのタイトルも
1320
+ `プロジェクト設定(プロジェクト名)`から`ワークスペース設定(ワークスペースの
1321
+ 絶対パス)`に変更した。`EditorApp`側は`state[:project_config]`を
1322
+ `state[:workspace_config]`に改名し、`select_project`からは設定のリセット/
1323
+ 再取得を削除(プロジェクトを切り替えても設定は変わらないため)。代わりに
1324
+ `load_workspace`(起動時)と`open_workspace`(ワークスペース切り替え時)の
1325
+ 両方で`load_workspace_config`(`GET /api/workspace/config`)を呼ぶ
1326
+ - 実際に`bundle exec exe/picoride`を起動→ワークスペースを開く→設定を保存→
1327
+ `workspace.toml`の中身を確認→プロセスを再起動→ブラウザで設定が復元されて
1328
+ いることを確認→別のプロジェクトに切り替えても設定(セットアップボタンの
1329
+ ターゲット表示)が変わらないことを確認、まで一通り検証した
1330
+
1331
+ ### 続き5: 「ワークスペース」=「プロジェクト」だったので2階層構成を1階層に統合
1332
+
1333
+ 続き1〜4までの一連の変更は、「プロジェクトを束ねる"ワークスペース"ディレクトリを
1334
+ UIから開き、その配下の複数プロジェクトから1つを選ぶ」という2階層構成を前提に
1335
+ 進めていた。しかしこれは実際にはユーザーの頭の中にあったモデルと食い違っていて、
1336
+ 「ワークスペース=プロジェクトだった(2階層ではなく1階層でよかった)」という
1337
+ フィードバックを受け、2階層構成を1階層に統合する大きめのリファクタリングを行った。
1338
+
1339
+ **変更の性質**: このフィードバックに従うと「ワークスペース」と(従来の)
1340
+ 「プロジェクト」が同じものを指すことになり、名前が衝突する。衝突の解消方法が
1341
+ 2通り考えられた(1階層に統合する/単に呼び方だけ"プロジェクト"に揃えて2階層構造は
1342
+ 維持する)ため、作業前にユーザーに確認し、「1ディレクトリ=1プロジェクトに統合」を
1343
+ 選んでもらった。大きな構造変更は実装前にユーザーに選択肢を示して確認するのが安全、
1344
+ という教訓(このセッションで既に複数回、作ってから「勘違いだった」という
1345
+ やり取りが発生していたこともあり)。
1346
+
1347
+ **やったこと**(ほぼ全ファイルに影響する全面的なリネーム+簡略化):
1348
+
1349
+ - サーバ側の中心的な状態を`workspace_root`/`known_workspaces`/`workspace_settings`
1350
+ から`Server.project_root`(単一のアクティブなプロジェクトの絶対パス)/
1351
+ `known_projects`/`project_settings`に改名。`project_root(name)`という
1352
+ 「ワークスペース配下のサブディレクトリ名を解決する」メソッドは消え、
1353
+ `project_root`は常に単一の値を返すアクセサになった
1354
+ - `GET /api/projects`(サブプロジェクト一覧)・`POST /api/projects`
1355
+ (ワークスペース配下に名前でサブディレクトリを作る「新しいプロジェクト」)を
1356
+ 丸ごと削除した。これに伴い、プロジェクト名を受け取っていた全エンドポイント
1357
+ (`GET /api/files`・`GET /api/dirs`・`GET/POST /api/file`・
1358
+ `POST/DELETE /api/projects/:name/files`・`POST/DELETE /api/projects/:name/folders`・
1359
+ `POST /api/projects/:name/mrbgems`・`POST /api/build`・`POST /api/platform`・
1360
+ `GET /api/firmware/*`)から`project`パラメータ/`:name`セグメントを削除し、
1361
+ 常に`project_root`(現在開いている唯一のプロジェクト)を対象にするよう
1362
+ 簡略化した(`POST /api/files`・`POST /api/folders`・`POST /api/mrbgems`の
1363
+ ようにパスからプロジェクト名セグメントが消えた)
1364
+ - 「新しいプロジェクト」(名前を入力してワークスペース配下にサブディレクトリを
1365
+ 作る機能)は、`ensure_project_scaffold`に置き換えた:
1366
+ `POST /api/project`(プロジェクトを開く)のたびに、`app/app.rb`・
1367
+ `build_config.rb`が無ければ空で作成する(既存ファイルは上書きしない)。
1368
+ 「新しいプロジェクトを作る」という操作は、実質的に
1369
+ DirectoryBrowserDialogで「新しいフォルダ」を作ってそのまま「このディレクトリを
1370
+ 開く」ことと同じになった(開いた瞬間に雛形が用意される)ので、専用のダイアログ・
1371
+ エンドポイントが丸ごと不要になった
1372
+ - `~/.picoride/workspace.toml`(`WorkspaceStore`)を`~/.picoride/project.toml`
1373
+ (`Picoride::ProjectStore`、`lib/picoride/project_store.rb`)に改名した
1374
+ (旧workspace.tomlからの自動移行は行わない。クリーンな置き換え)
1375
+ - フロント側: `WorkspaceSettingsDialog`を`ProjectSettingsDialog`に戻し(名前が
1376
+ 一周して元に戻った形)、`OpenProjectDialog`(サブプロジェクト一覧から選ぶ
1377
+ ダイアログ)は丸ごと削除した。`MenuBar`の常設ドロップダウンは
1378
+ 「ワークスペース選択」から「プロジェクト選択」に意味が戻り、
1379
+ `current_project`という概念(ワークスペース配下で選択中のサブプロジェクト名)は
1380
+ 消えて、`project`(開いているプロジェクトの絶対パス)1つに統合された。
1381
+ `EditorApp`の`select_project`(サブプロジェクト切り替え)・`load_project_list`
1382
+ (サブプロジェクト一覧取得)は削除し、`load_workspace`/`open_workspace`は
1383
+ それぞれ`load_project`/`open_project`に改名して、ファイル一覧・設定の取得も
1384
+ このタイミングで直接行うようにした(以前は「ワークスペースを開く→サブ
1385
+ プロジェクト一覧取得→最初の1件を自動選択→ファイル一覧取得」という2段階
1386
+ だったのが、「プロジェクトを開く→ファイル一覧取得」の1段階になった)
1387
+ - `DirectoryBrowserDialog`・`project_label`(旧`workspace_label`)等、
1388
+ 「ワークスペースを開く」という文言だった箇所は全部「プロジェクトを開く」に
1389
+ 変更。コンポーネント自体は元々汎用設計だったため、ロジックの変更は不要で
1390
+ 文言とprops名(`workspace_path`→`project_path`等)の変更だけで済んだ
1391
+ - 実際に`bundle exec exe/picoride`で検証: `POST /api/project`で初めて開いた
1392
+ 空ディレクトリに`app/app.rb`・`build_config.rb`が自動生成されること、
1393
+ 既存プロジェクト(`projects/hello_world_project`)をディレクトリブラウザで
1394
+ 辿って開けること、ファイル一覧・ファイル保存・新規ファイル・mrbgem追加・
1395
+ 設定の読み書きが全部`project`パラメータ無しで動くこと、プロセス再起動後に
1396
+ `project.toml`から同じプロジェクトが開かれた状態で起動すること、をブラウザと
1397
+ curl両方で一通り確認した
1398
+
1399
+ ### 続き6: ensure_project_scaffoldは「空のディレクトリのときだけ」に限定
1400
+
1401
+ `ensure_project_scaffold`(続き5参照)は当初「`app/app.rb`・`build_config.rb`が
1402
+ 無ければ(そのディレクトリに他に何があっても)作る」という実装だった。これが
1403
+ 実際に事故を起こした: ユーザーが本来開くべきプロジェクトディレクトリ
1404
+ (`.../esp32-project/`)の**1階層上**(実際には`esp32-project/`をサブディレクトリ
1405
+ として含む、別の`esp32-project`という名前のディレクトリだった)を誤って開いて
1406
+ しまい、そこに無関係な空の`app/`・`build_config.rb`が生成された。結果、
1407
+ ファイルツリーに「本来のプロジェクト(サブディレクトリとして)」と「誤って
1408
+ 生成された空のapp/」が並んで表示され、「ファイルツリーがおかしい」という
1409
+ バグ報告になった。実機のディレクトリ構造を`ls`で確認して原因を特定した
1410
+ (`project.toml`の`active`から実際に開かれているパスが分かるので、そこから
1411
+ 辿った)。
1412
+
1413
+ コード自体にバグがあったわけではなく(ツリー構築ロジックは元々正しい)、
1414
+ 「間違ったディレクトリを開いてしまった」というヒューマンエラーだったが、
1415
+ 自動生成が無条件に効いてしまうことがこの種の事故を助長していた。
1416
+ 「自動生成は空のディレクトリのときだけにしよう」というフィードバックを受け、
1417
+ `Dir.empty?(root)`で判定し、**ディレクトリに何かしら既存の中身がある場合は
1418
+ 何もしない**(=「間違ったディレクトリを開いた可能性がある」とみなして
1419
+ 無関係なファイルを生成しない)方式に変更した。新規にdirectory browserで
1420
+ 「新しいフォルダ」を作ってすぐ開く(=確実に空)という本来の用途は影響を受けない。
1421
+
1422
+ ## Raspberry Pi Pico 対応(ビルド用Dockerイメージをターゲットで切り替え)
1423
+
1424
+ 「ESP32用だけでなくRaspberry Pi Pico用のビルド環境も使えるようにしたい。
1425
+ Dockerfileを分けてイメージを別にし、picorideサーバーからビルドするときに切り替える。
1426
+ 設定でPicoを選べるようにしたい」という要望で追加した。
1427
+
1428
+ ### 構成
1429
+
1430
+ - `Dockerfile` は `docker/esp32/Dockerfile`(中身は従来のESP-IDF用そのまま)と
1431
+ `docker/pico/Dockerfile`(新規)に分けた。イメージ名は
1432
+ `picoride-builder-esp32` / `picoride-builder-pico`
1433
+ (`PICORIDE_ESP32_BUILDER_IMAGE` / `PICORIDE_PICO_BUILDER_IMAGE`で上書き可。
1434
+ ESP32側は旧`PICORUBY_BUILDER_IMAGE`も引き続き効く)。**旧イメージ名
1435
+ `picoride-builder`は既定では使われなくなった**ので、ESP32側も作り直しが要る
1436
+ - 「ターゲット」設定(`platform`)に `pico` / `pico_w` / `pico2` / `pico2_w` を追加。
1437
+ `server.rb`の`PLATFORM_FAMILIES`でターゲット→ファミリー(`esp32`/`pico`)を
1438
+ 引き、`BUILDER_IMAGES`・状態ディレクトリ(`build_state_dir`)・コンテナ内の
1439
+ マウント先(`CONTAINER_STATE_MOUNTS`)・ビルド手順を切り替える。
1440
+ **ターゲット未設定は従来通りESP32扱い**(`DEFAULT_PLATFORM_FAMILY`)
1441
+ - `r2p2_docker_command` は `builder_docker_command(family, ...)` に一般化した。
1442
+ `export.sh` のsourceはESP32のときだけ
1443
+
1444
+ ### R2P2(Pico版)は picoruby 本体に統合されている
1445
+
1446
+ `picoruby/R2P2` リポジトリはアーカイブ扱いで、現在は picoruby 本体の
1447
+ `mrbgems/picoruby-r2p2` gem + `tasks/picoruby/r2p2.rake` になっている。
1448
+ ビルドは picoruby のルートで `rake r2p2:{vm}:{board}:{debug|prod}`
1449
+ (vm は `femtoruby`(mruby/c)/ `picoruby`(mruby)。RP2040 の pico/pico_w は
1450
+ femtoruby のみ — `build_config/r2p2-{vm}-{board}.rb` があるものだけタスクが定義される)。
1451
+ IDEは`prod`だけ使う。成果物は `build/r2p2/{vm}/{board}/prod/R2P2-*.uf2`。
1452
+
1453
+ - 状態ディレクトリは `<project>/.r2p2-pico`(picoruby の `--depth 1` clone)。
1454
+ `R2P2State.ensure_pico_checkout` がサブモジュールを取得するが、
1455
+ `mrbgems/picoruby-r2p2/lib/pico-sdk` と `lib/rp2040js`(エミュレータ)は取らない
1456
+ - pico-sdk と picotool はイメージ側(`/opt/pico-sdk`、`/usr/local`)に焼き込み。
1457
+ pico-sdk 2.x は**SDKと同じバージョンのpicotoolがインストールされていないと、
1458
+ cmake構成時にGitHubからpicotoolをcloneしてビルドしようとする**ので、
1459
+ イメージで同じタグ(`PICO_SDK_VERSION`)をビルド・installしておく。
1460
+ picorubyの`r2p2:check_pico_sdk`は`git describe --tags --exact-match`で
1461
+ `PICO_SDK_TAG`(`mrbgems/picoruby-r2p2/r2p2_config.rb`)と一致することを要求する
1462
+ (`git clone --depth 1 --branch <tag>`でもタグは取れて一致判定は通る)。
1463
+ picoruby側がSDKを上げたら`--build-arg PICO_SDK_VERSION=...`でイメージを作り直す
1464
+ - 完了マーカー `.picoride-ready` で「clone+サブモジュール+パッチまで済んだか」を
1465
+ 判定する(ESP32側の「`.git`があれば済」だと、サブモジュール取得で失敗したときに
1466
+ 二度と再試行されないため)
1467
+ - mrbgem組み込みのフック(`PROJECT_BUILD_CONFIG`)はESP32と同じものを
1468
+ `build_config/r2p2-*.rb` に差し込む(どれも `MRuby::CrossBuild.new(...) do |conf| ... end`)。
1469
+ r2p2.rake は内部で `sh "MRUBY_CONFIG=... rake"` を呼ぶが、環境変数はそのまま
1470
+ 子プロセスへ伝わるので追加の細工は不要
1471
+ - Bundlerは使わない(r2p2.rakeは素の`rake`を呼ぶだけ)。イメージには apt の
1472
+ `ruby` / `rake` だけ入れている
1473
+
1474
+ ### ESP32との違い(UI)
1475
+
1476
+ - Picoには app/ をファームウェアに焼き込む仕組みが無い(R2P2はフラッシュを
1477
+ USBマスストレージとして見せる方式で、ストレージのイメージをビルド時に作らない)。
1478
+ app/ はターミナルタブの「app/ をアップロード」(PicoModem。Pico版R2P2の
1479
+ シェルにも標準で入っている)で送る
1480
+ - 書き込みはESP Web Toolsではなく `.uf2` のダウンロード(`GET /api/firmware/uf2`、
1481
+ メニューバーの「UF2をダウンロード」、`<a download>`をボタン風にしたもの)。
1482
+ BOOTSELを押しながら接続して出てくるドライブへユーザーがコピーする
1483
+ - 「セットアップ」は Pico では `rake r2p2:{vm}:{board}:clean`
1484
+ (ボード×VMごとにビルドディレクトリが分かれているのでターゲット切り替えの準備は不要)
1485
+ - 設定ダイアログ: ターゲットを `<optgroup>` でESP32/Raspberry Pi Picoに分けた。
1486
+ Picoを選ぶとUSB Consoleの代わりに書き込み方法の説明を出す。VMはボードで
1487
+ 選べないもの(pico/pico_wのPicoRuby)を出さず、ターゲット変更で選べなくなったら
1488
+ デフォルトに戻す(`ProjectSettingsDialog.vm_allowed?`)。サーバー側も
1489
+ `PICO_BOARD_VMS`で同じ検証をする(フロントとサーバで同じ表を二重に持っている)
1490
+
1491
+ ### 実際にビルドして踏んだ罠(Pico)
1492
+
1493
+ `docker build` → picorideサーバー経由の`POST /api/build`で実際にビルドを通して
1494
+ 見つけたもの(どちらも静的チェックでは分からない):
1495
+
1496
+ 1. **`/picoruby/bin/mrbc: not found`**: r2p2のcmakeは`main_task.rb`のコンパイルに
1497
+ `<picoruby>/bin/mrbc`を使うが、cloneしたばかりのpicorubyには無く、
1498
+ `rake r2p2:...`自体も作らない(r2p2用のbuild_configが作るのは
1499
+ `build/mrbc/default/bin/mrbc`で、`bin/`には置かれない)。mrbcはVM非依存の
1500
+ スタンドアロンコンパイラなので、`pico_build_inner_command`で
1501
+ `[ -x bin/mrbc ] || MRUBY_CONFIG=build_config/mrbc.rb rake` を先に1回実行する
1502
+ 2. **`zip: No such file or directory`**: r2p2のcmakeはリンク後に`.uf2`を
1503
+ `zip`/`gzip`する。`.uf2`自体はできているのにビルド失敗扱いになるので、
1504
+ イメージに`zip`を入れた
1505
+
1506
+ 3. **`lwip/opt.h: No such file or directory`(pico_w / pico2_w のみ)**:
1507
+ Wi-Fi付きボードのbuild_configが使う`picoruby-socket`/`picoruby-mbedtls`は、
1508
+ lwIP/mbedTLSを`mrbgems/picoruby-r2p2/lib/pico-sdk/lib/...`への**git管理された
1509
+ シンボリックリンク**経由で参照する。そのサブモジュールはcloneしていないので、
1510
+ ビルド・セットアップの前に`PICO_PREPARE_COMMAND`でそのパスをコンテナ内の
1511
+ `$PICO_SDK_PATH`(/opt/pico-sdk)へのリンクに差し替える。`rake ...:clean`も
1512
+ build_configを読み、`picoruby-socket`のmrbgem.rakeはリンク切れだと例外を
1513
+ 投げるので、セットアップ側にも同じ前処理が必要
1514
+
1515
+ 検証済みの組み合わせ: `pico`(FemtoRuby)と`pico2_w`(PicoRuby)。コンテナは
1516
+ インターネット接続なしで実行してもビルドが通る(picotool・pico-sdkともイメージ内で
1517
+ 完結している)。UF2のマジックナンバー・プロジェクトの`mrbgems/`以下のgemが
1518
+ リンクされていることも確認した。