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.
@@ -0,0 +1,1560 @@
1
+ require 'js'
2
+ require 'crc'
3
+
4
+ # エディタ全体のルートコンポーネント。
5
+ #
6
+ # 状態(ファイル一覧・編集中のパス・バッファ・保存済み内容)はすべてここが持ち、
7
+ # TreeView / Toolbar には props で流すだけの構成にしている。
8
+ #
9
+ # ■ textarea と Prism ハイライトの扱いについて
10
+ #
11
+ # textarea は VDOM の制御下に置かず(value を render で出力せず)、
12
+ # ファイルを切り替えたときだけ ref 経由で value を差し替える「非制御」にしている。
13
+ # 入力のたびに VDOM が value を書き戻すとキャレットが末尾に飛ぶため。
14
+ #
15
+ # ハイライト層 <code> も同様に、VDOM 上は「子を持たない空要素」として描画し、
16
+ # Prism が生成したHTMLを innerHTML に直接流し込む。
17
+ # 子を持たない要素は再描画時の差分がゼロなので、innerHTML が消されることはない。
18
+ #
19
+ # ■ ターミナル(xterm.js + Web Serial)を別コンポーネントに切り出さない理由
20
+ #
21
+ # 当初は TerminalPanel という別コンポーネントに分けていたが、「ファイルペインと
22
+ # ターミナルペインを行き来すると接続が切れる」不具合が発生した。原因はFunicularの
23
+ # 子コンポーネント(component(...)で呼び出す側)が、**親が再レンダリングされる
24
+ # たびに initialize_state / component_mounted をもう一度呼び直す**という挙動
25
+ # だったこと(実際にカウンタを仕込んでブラウザで確認した。EditorAppのような
26
+ # Funicular.startに渡すルートコンポーネントだけがマウント1回を保証される)。
27
+ # xterm.jsのインスタンスやシリアルポートの接続(@port)のような「1回だけ
28
+ # 初期化して使い回したい副作用」を持つ以上、それらは全部ルートコンポーネントである
29
+ # このEditorAppの中に置く必要がある。textarea/ハイライト層を子コンポーネントに
30
+ # 切り出さず、非制御textarea+refで直接扱っているのと同じ理由・同じ対処。
31
+ class EditorApp < Funicular::Component
32
+ # プロジェクト内の特別なディレクトリ名。app.rb側のAPP_DIRNAME/MRBGEMS_DIRNAMEと
33
+ # 対応(サーバとフロントで別プロセス=別Rubyランタイムなので定数は共有できず、
34
+ # 両側に定義してある)。
35
+ APP_DIRNAME = 'app'
36
+ MRBGEMS_DIRNAME = 'mrbgems'
37
+
38
+ # 拡張子 → Prism の言語名
39
+ LANGUAGES = { 'rb' => 'ruby', 'c' => 'c', 'h' => 'c' }
40
+
41
+ # PicoModemプロトコル定数(picoruby-picomodemのmrblib/picomodem.rbと対応)
42
+ TERM_FILE_WRITE = 0x02
43
+ TERM_CHUNK = 0x04
44
+ TERM_FILE_ACK = 0x82
45
+ TERM_CHUNK_ACK = 0x84
46
+ TERM_DONE_ACK = 0x8F
47
+ TERM_ERROR = 0xFE
48
+ TERM_OK = 0x00
49
+
50
+ TERM_CHUNK_SIZE = 480 # PicoModemの1フレームあたりの最大ペイロード
51
+ TERM_TX_CHUNK_SIZE = 32 # シリアル書き込み自体を分割するサイズ(USB-CDCの安定性のため)
52
+ TERM_TX_CHUNK_GAP_MS = 20
53
+ TERM_TIMEOUT_MS = 5000
54
+
55
+ def initialize_state
56
+ {
57
+ project: nil, # 開いているプロジェクトの絶対パス。未設定ならnil
58
+ known_projects: [], # このセッションで一度でも開いたプロジェクトの絶対パス配列
59
+ loading_project: true,
60
+ files: [],
61
+ dirs: [], # プロジェクト内の(空フォルダも含む)ディレクトリ一覧。file_tree_nodes参照
62
+ loading_files: false,
63
+ collapsed_dirs: [],
64
+ current_path: nil,
65
+ content: '',
66
+ saved_content: '',
67
+ status: '',
68
+ status_kind: '',
69
+ build_status: 'idle',
70
+ build_log: '',
71
+ build_log_truncated: false,
72
+ building: false,
73
+ settings_dialog_open: false,
74
+ project_config: default_project_config,
75
+ file_menu_open: false,
76
+ prompt_dialog: nil, # { kind:, title:, label:, placeholder:, confirm_label:, error:, context_dir: } または nil
77
+ # { path:, parent:, entries:, loading:, error:, creating:, create_error:, open_error: } または nil(閉じている)。
78
+ # 「プロジェクトを開く」のディレクトリブラウザ(DirectoryBrowserDialog)の状態。
79
+ project_browser: nil,
80
+ context_menu: nil, # { kind:, path:, x:, y: } または nil。open_tree_context_menu参照
81
+ settings_platform: nil,
82
+ settings_vm: '',
83
+ settings_usb_console: false,
84
+ platform_status: 'idle',
85
+ platform_log: '',
86
+ platform_log_truncated: false,
87
+ platform_building: false,
88
+ log_tab: 'build',
89
+ editor_tab: 'code',
90
+ terminal_status: 'disconnected', # 'disconnected' | 'connecting' | 'connected'
91
+ terminal_status_message: '未接続',
92
+ terminal_uploading: false,
93
+ terminal_upload_log: []
94
+ }
95
+ end
96
+
97
+ def default_project_config
98
+ { 'platform' => nil, 'vm' => nil, 'usb_console' => false }
99
+ end
100
+
101
+ def component_mounted
102
+ load_project
103
+ setup_terminal
104
+ watch_for_terminal_reconnect
105
+ end
106
+
107
+ # state が変わるたびに呼ばれる(Funicular::Component#patch から引数なしで呼ばれる)。
108
+ # ハイライトは VDOM の外側(ref + innerHTML)で管理しているので、ここで同期する。
109
+ # 前回描画時との差分は自前で覚えておく必要がある。
110
+ def component_updated
111
+ return if @prev_highlight_content == state[:content] &&
112
+ @prev_highlight_path == state[:current_path]
113
+
114
+ @prev_highlight_content = state[:content]
115
+ @prev_highlight_path = state[:current_path]
116
+ refresh_highlight
117
+ end
118
+
119
+ def render
120
+ div(class: 'app') do
121
+ # 最上部のメニュー/グローバルステータスバー。プロジェクトの切り替えも
122
+ # ここのドロップダウンに集約している(サイドバーはファイル一覧専用)。
123
+ component(MenuBar,
124
+ project: state[:project],
125
+ known_projects: state[:known_projects],
126
+ project_config: state[:project_config],
127
+ build_status: state[:build_status],
128
+ building: state[:building],
129
+ platform_building: state[:platform_building],
130
+ file_menu_open: state[:file_menu_open],
131
+ on_project_select: ->(path) { open_project(path) },
132
+ on_settings: -> { open_settings_dialog },
133
+ on_setup: -> { start_platform_setup },
134
+ on_build: -> { start_build },
135
+ on_file_menu_toggle: -> { toggle_file_menu },
136
+ on_open_project: -> { open_project_browser },
137
+ on_add_mrbgem: -> { open_add_mrbgem_dialog },
138
+ on_new_file: -> { open_new_file_dialog },
139
+ on_new_folder: -> { open_new_folder_dialog }
140
+ )
141
+
142
+ if state[:prompt_dialog]
143
+ component(PromptDialog,
144
+ title: state[:prompt_dialog][:title],
145
+ label: state[:prompt_dialog][:label],
146
+ placeholder: state[:prompt_dialog][:placeholder],
147
+ confirm_label: state[:prompt_dialog][:confirm_label],
148
+ error: state[:prompt_dialog][:error],
149
+ on_confirm: ->(value) { handle_prompt_confirm(value) },
150
+ on_cancel: -> { patch(prompt_dialog: nil) }
151
+ )
152
+ end
153
+
154
+ if state[:project_browser]
155
+ browser = state[:project_browser]
156
+ component(DirectoryBrowserDialog,
157
+ path: browser[:path],
158
+ parent: browser[:parent],
159
+ entries: browser[:entries],
160
+ loading: browser[:loading],
161
+ error: browser[:error],
162
+ creating: browser[:creating],
163
+ create_error: browser[:create_error],
164
+ open_error: browser[:open_error],
165
+ on_navigate: ->(path) { load_browse_dir(path) },
166
+ on_open: -> { open_project(browser[:path]) },
167
+ on_start_create: -> { start_create_folder },
168
+ on_confirm_create: ->(name) { confirm_create_folder(name) },
169
+ on_cancel_create: -> { cancel_create_folder },
170
+ on_cancel: -> { patch(project_browser: nil) }
171
+ )
172
+ end
173
+
174
+ if state[:settings_dialog_open]
175
+ component(ProjectSettingsDialog,
176
+ project_path: state[:project],
177
+ platform: state[:settings_platform],
178
+ vm: state[:settings_vm],
179
+ usb_console: state[:settings_usb_console],
180
+ # ターゲットを切り替えて今のVMが選べなくなった(例: pico2でPicoRubyを
181
+ # 選んでからpicoへ変えた)ら、VMをデフォルトに戻す。
182
+ on_platform_change: ->(platform) {
183
+ vm = state[:settings_vm]
184
+ vm = '' unless ProjectSettingsDialog.vm_allowed?(platform, vm)
185
+ patch(settings_platform: platform, settings_vm: vm)
186
+ },
187
+ on_vm_change: ->(vm) { patch(settings_vm: vm) },
188
+ on_usb_console_change: ->(enabled) { patch(settings_usb_console: enabled) },
189
+ on_save: -> { save_project_settings },
190
+ on_cancel: -> { patch(settings_dialog_open: false) }
191
+ )
192
+ end
193
+
194
+ render_tree_context_menu if state[:context_menu]
195
+
196
+ div(class: 'app-body') do
197
+ div(class: 'sidebar') do
198
+ h1 { 'Project Files' }
199
+ render_file_tree
200
+ end
201
+
202
+ div(class: 'editor-area') do
203
+ render_editor_tabs
204
+
205
+ # コード編集タブとターミナルタブは常に両方マウントしたままにし、
206
+ # 非表示時は class(hidden)でCSS上隠すだけにしてある。ターミナルタブを
207
+ # 条件付きレンダリングでVDOMから外し入れすると、タブを切り替えるたびに
208
+ # TerminalPanelがunmount/remountされ、シリアル接続やxterm.jsの状態が
209
+ # 消えてしまうため。
210
+ div(class: tab_pane_class('code')) do
211
+ component(Toolbar,
212
+ current_path: state[:current_path],
213
+ dirty: dirty?,
214
+ status: state[:status],
215
+ status_kind: state[:status_kind],
216
+ on_save: -> { save_file }
217
+ )
218
+
219
+ div(class: 'editor-wrapper') do
220
+ # 行番号は innerHTML を使わず、そのまま VDOM で描画する
221
+ div(class: 'line-numbers', ref: :lines) do
222
+ (1..line_count).each do |n|
223
+ div { n.to_s }
224
+ end
225
+ end
226
+
227
+ # スクロールする要素をこのコンテナ一箇所に集約し、
228
+ # ハイライト層と textarea は CSS Grid で同じセルに重ねる(style.css 参照)
229
+ div(class: 'editor-scroll-area', ref: :scroll, onscroll: :handle_scroll) do
230
+ tag(:pre, class: 'highlight-layer') do
231
+ tag(:code, ref: :highlight)
232
+ end
233
+ render_textarea
234
+ end
235
+ end
236
+ end
237
+
238
+ div(class: tab_pane_class('terminal')) { render_terminal_panel }
239
+
240
+ component(LogPanel,
241
+ active_tab: state[:log_tab],
242
+ build_status: state[:build_status],
243
+ build_log: state[:build_log],
244
+ build_log_truncated: state[:build_log_truncated],
245
+ platform_status: state[:platform_status],
246
+ platform_log: state[:platform_log],
247
+ platform_log_truncated: state[:platform_log_truncated],
248
+ selected_platform: value_of(state[:project_config], 'platform'),
249
+ on_tab_change: ->(tab) { patch(log_tab: tab) },
250
+ on_build_refresh: -> { refresh_build_status },
251
+ on_platform_refresh: -> { refresh_platform_status }
252
+ )
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ # --- イベントハンドラ ------------------------------------------------
259
+
260
+ def handle_input(event)
261
+ node = refs[:editor]
262
+ return unless node
263
+ patch(content: node[:value].to_s)
264
+ end
265
+
266
+ def handle_keydown(event)
267
+ key = event[:key].to_s
268
+
269
+ # Ctrl+S / Cmd+S で保存
270
+ if key == 's' && (truthy?(event[:ctrlKey]) || truthy?(event[:metaKey]))
271
+ event.preventDefault
272
+ save_file
273
+ return
274
+ end
275
+
276
+ # Tab はフォーカス移動ではなくスペース2つの挿入にする
277
+ if key == 'Tab'
278
+ event.preventDefault
279
+ insert_indent
280
+ end
281
+ end
282
+
283
+ def handle_select_code_tab(event)
284
+ event.preventDefault
285
+ patch(editor_tab: 'code')
286
+ end
287
+
288
+ def handle_select_terminal_tab(event)
289
+ event.preventDefault
290
+ patch(editor_tab: 'terminal')
291
+ end
292
+
293
+ def handle_terminal_connect(event)
294
+ event.preventDefault
295
+ terminal_connected? ? terminal_disconnect : terminal_connect
296
+ end
297
+
298
+ def handle_terminal_upload(event)
299
+ event.preventDefault
300
+ upload_app_directory
301
+ end
302
+
303
+ # 行番号カラムを本文のスクロールに追従させる
304
+ def handle_scroll(event)
305
+ scroll = refs[:scroll]
306
+ lines = refs[:lines]
307
+ return unless scroll && lines
308
+ lines[:scrollTop] = scroll[:scrollTop]
309
+ end
310
+
311
+ private
312
+
313
+ def render_editor_tabs
314
+ div(class: 'editor-tabs') do
315
+ render_editor_tab_button('code', 'エディタ', :handle_select_code_tab)
316
+ render_editor_tab_button('terminal', 'ターミナル', :handle_select_terminal_tab)
317
+ end
318
+ end
319
+
320
+ def render_editor_tab_button(key, label, handler)
321
+ classes = key == state[:editor_tab] ? 'editor-tab active' : 'editor-tab'
322
+ button(class: classes, onclick: handler) { label }
323
+ end
324
+
325
+ def tab_pane_class(key)
326
+ key == state[:editor_tab] ? 'tab-pane' : 'tab-pane hidden'
327
+ end
328
+
329
+ # --- サイドバー(ファイルツリー) ----------------------------------------
330
+ #
331
+ # ツリー表示自体は汎用コンポーネント TreeView (tree_view.rb) に任せてあり、
332
+ # ここでは state[:files](プロジェクト直下からの相対パスのフラットな配列)を
333
+ # TreeView が期待するノード構造(name/path/type/childrenを持つHash)に
334
+ # 組み立てる部分と、どのディレクトリを折りたたむかの状態(collapsed_dirs)
335
+ # だけを持つ。
336
+
337
+ def render_file_tree
338
+ if state[:project].nil?
339
+ div(class: 'sidebar-message') { 'プロジェクトが開かれていません。メニューバーの「プロジェクトを開く」から開いてください' }
340
+ elsif state[:loading_files]
341
+ div(class: 'sidebar-message') { '読み込み中…' }
342
+ elsif state[:files].empty? && state[:dirs].empty?
343
+ div(class: 'sidebar-message') { '編集できるファイルがありません' }
344
+ else
345
+ component(TreeView,
346
+ nodes: file_tree_nodes,
347
+ collapsed: state[:collapsed_dirs],
348
+ selected: state[:current_path],
349
+ icon_for: ->(node) { file_tree_icon(node) },
350
+ on_select: ->(path) { open_file(path) },
351
+ on_toggle: ->(path) { toggle_tree_dir(path) },
352
+ on_context_menu: ->(node, event) { open_tree_context_menu(node, event) }
353
+ )
354
+ end
355
+ end
356
+
357
+ def toggle_tree_dir(path)
358
+ collapsed = state[:collapsed_dirs]
359
+ if collapsed.include?(path)
360
+ patch(collapsed_dirs: collapsed - [path])
361
+ else
362
+ patch(collapsed_dirs: collapsed + [path])
363
+ end
364
+ end
365
+
366
+ # 拡張子で色分けする既存のバッジ表示(style.cssの.file-icon.rb等)をそのまま使う。
367
+ # ディレクトリにはアイコンを付けない(TreeView側のキャレットだけで十分なため)。
368
+ def file_tree_icon(node)
369
+ return nil if node[:type] == :dir
370
+
371
+ ext = extension(node[:name])
372
+ { class: ext, label: ext }
373
+ end
374
+
375
+ # state[:files] (例: ["app/app.rb", "mrbgems/picoruby_hello_world/mrbgem.rake"]) を
376
+ # "/" 区切りで分解し、ディレクトリはまとめてネストしたノード配列に組み立てる。
377
+ # 各階層でディレクトリを先に、それぞれ名前順に並べる。
378
+ #
379
+ # state[:dirs](GET /api/dirsで取得した、中身が空のものも含む全ディレクトリ)も
380
+ # 合わせて挿入する。state[:files]だけからではファイルを1つも含まない空の
381
+ # フォルダを表現できない(パスの並びにディレクトリ単体のエントリが出てこないため)。
382
+ def file_tree_nodes
383
+ root = {}
384
+ state[:files].each { |path| insert_file_tree_path(root, path.split('/'), '') }
385
+ state[:dirs].each { |path| insert_file_tree_dir_path(root, path.split('/'), '') }
386
+ sorted_file_tree_nodes(root)
387
+ end
388
+
389
+ # root は { セグメント名 => { node:, children: {セグメント名 => ...} } } という
390
+ # 中間表現(最終的な配列に組み立てる前の、パス分解の途中経過を持つ入れ物)。
391
+ # prefix は「ここまでのセグメントを"/"で連結したパス」で、再帰のたびに伸びていく。
392
+ def insert_file_tree_path(root, segments, prefix)
393
+ name = segments[0]
394
+ path = prefix.empty? ? name : "#{prefix}/#{name}"
395
+ entry = (root[name] ||= { children: {} })
396
+
397
+ if segments.length == 1
398
+ entry[:node] = { name: name, path: path, type: :file }
399
+ else
400
+ entry[:node] ||= { name: name, path: path, type: :dir }
401
+ insert_file_tree_path(entry[:children], segments[1, segments.length - 1], path)
402
+ end
403
+ end
404
+
405
+ # insert_file_tree_pathのディレクトリ専用版。末尾のセグメントも:dirとして
406
+ # 挿入する(ファイルの経路の途中に出てくる中間ディレクトリと違い、ここでは
407
+ # そのパスそのものがディレクトリであることが分かっている)。既にファイル経由で
408
+ # 同じパスにノードが作られていれば(`entry[:node] ||=`により)上書きしない。
409
+ def insert_file_tree_dir_path(root, segments, prefix)
410
+ name = segments[0]
411
+ path = prefix.empty? ? name : "#{prefix}/#{name}"
412
+ entry = (root[name] ||= { children: {} })
413
+ entry[:node] ||= { name: name, path: path, type: :dir }
414
+
415
+ return if segments.length == 1
416
+ insert_file_tree_dir_path(entry[:children], segments[1, segments.length - 1], path)
417
+ end
418
+
419
+ def sorted_file_tree_nodes(root)
420
+ names = root.keys.sort
421
+ dirs = names.select { |name| root[name][:node][:type] == :dir }
422
+ files = names.select { |name| root[name][:node][:type] == :file }
423
+
424
+ (dirs + files).map do |name|
425
+ node = root[name][:node]
426
+ if node[:type] == :dir
427
+ { name: node[:name], path: node[:path], type: :dir, children: sorted_file_tree_nodes(root[name][:children]) }
428
+ else
429
+ node
430
+ end
431
+ end
432
+ end
433
+
434
+ # --- ターミナル(xterm.js + Web Serial) --------------------------------
435
+ #
436
+ # R2P2-ESP32のUSBシリアルに、ブラウザのWeb Serial API (JS::WebSerial、
437
+ # picoruby-wasm本体が提供する。picoruby.org/terminal と同じ仕組み)経由で
438
+ # 直接つなぎ、xterm.jsでターミナルとして表示する。ビルド/インストールとは違い
439
+ # サーバは一切関与しない(ブラウザ⇔実機のUSBが直結)。
440
+ #
441
+ # 「app/ をアップロード」ボタンは、R2P2のシェル(picoruby-shell)がプロンプトで
442
+ # Ctrl-B(STX, 0x02)を受け取るとPicoModemセッション(picoruby-picomodem。
443
+ # picoruby-shellのデフォルト依存gemなので、R2P2-ESP32の標準ビルドには常に
444
+ # 含まれている。R2P2-ESP32/components/picoruby-esp32/picoruby/mrbgems/
445
+ # picoruby-shell/mrbgem.rake 参照)に入る仕組みを使い、現在のプロジェクトの
446
+ # app/以下をファイルごとに実機の/home/以下へ書き込む。PicoModemは1セッションに
447
+ # つき1ファイルのFILE_WRITEしか処理せずシェルへ戻るため、複数ファイルは
448
+ # 「Ctrl-B送信→ACK待ち→FILE_WRITE」をファイルごとに繰り返す。
449
+ #
450
+ # フレームの組み立て・CRC計算・エラー処理・自動再接続は picoruby/picoruby.github.io
451
+ # の pages/r2p2/terminal.rb (picoruby.org/terminal の実装)にあるPicoModem
452
+ # ホストクライアント/自動再接続をほぼそのまま移植したもの。
453
+
454
+ def render_terminal_panel
455
+ div(class: 'terminal-panel') do
456
+ div(class: 'terminal-controls') do
457
+ render_terminal_connect_button
458
+ render_terminal_upload_button
459
+ span(class: "terminal-status #{state[:terminal_status]}") { state[:terminal_status_message] }
460
+ end
461
+ div(class: 'terminal-container', ref: :terminal_container)
462
+ render_terminal_upload_log
463
+ end
464
+ end
465
+
466
+ def terminal_connected?
467
+ state[:terminal_status] == 'connected'
468
+ end
469
+
470
+ def render_terminal_connect_button
471
+ if state[:terminal_status] == 'connecting'
472
+ button(class: 'terminal-connect', disabled: true) { '接続中…' }
473
+ else
474
+ button(class: 'terminal-connect', onclick: :handle_terminal_connect) { terminal_connected? ? '切断' : 'デバイスに接続' }
475
+ end
476
+ end
477
+
478
+ def render_terminal_upload_button
479
+ if terminal_connected? && !state[:terminal_uploading]
480
+ button(class: 'terminal-upload', onclick: :handle_terminal_upload) { 'app/ をアップロード' }
481
+ else
482
+ button(class: 'terminal-upload', disabled: true) { state[:terminal_uploading] ? 'アップロード中…' : 'app/ をアップロード' }
483
+ end
484
+ end
485
+
486
+ def render_terminal_upload_log
487
+ return if state[:terminal_upload_log].empty?
488
+ tag(:pre, class: 'terminal-upload-log') { state[:terminal_upload_log].join("\n") }
489
+ end
490
+
491
+ # xterm.jsの初期化。EditorApp(ルートコンポーネント)のcomponent_mountedから
492
+ # 一度だけ呼ばれる。まだターミナルタブが非表示(class=hidden、display:none)の
493
+ # 間はコンテナの寸法が0になるが、`terminal.open`自体は非表示要素に対しても
494
+ # 問題なく行える。実際の行数/桁数はタブを表示したときにResizeObserverが
495
+ # コンテナの寸法変化を検知して`fit`し直すので、正しいサイズに追従する。
496
+ def setup_terminal
497
+ container = refs[:terminal_container]
498
+ return unless container
499
+
500
+ opts = JS.global.create_object
501
+ opts[:scrollback] = 1000
502
+ opts[:cursorBlink] = true
503
+ opts[:convertEol] = true
504
+ opts[:fontFamily] = '"SF Mono", Menlo, Consolas, "Courier New", monospace'
505
+ opts[:fontSize] = 14
506
+ theme = JS.global.create_object
507
+ theme[:background] = '#1a1a1a'
508
+ opts[:theme] = theme
509
+
510
+ xterm = JS.global[:Terminal].new(opts)
511
+ @terminal_fit_addon = JS.global[:FitAddon][:FitAddon].new
512
+ xterm.loadAddon(@terminal_fit_addon)
513
+ xterm.open(container)
514
+ @xterm = xterm
515
+ fit_terminal
516
+
517
+ JS.global[:ResizeObserver].new { |_entries| fit_terminal }.observe(container)
518
+
519
+ # Escapeキーをブラウザに奪われないようにする(全画面解除等のショートカットと衝突するため)
520
+ xterm.attachCustomKeyEventHandler do |event|
521
+ event.preventDefault if event[:key].to_s == 'Escape'
522
+ true
523
+ end
524
+
525
+ # 入力はそのままシリアルへ流す(R2P2のシェルが側でエコーバックする)
526
+ xterm.onData { |data| terminal_send_bytes(data.to_s) }
527
+ end
528
+
529
+ def fit_terminal
530
+ @terminal_fit_addon&.fit
531
+ end
532
+
533
+ def terminal_connect
534
+ unless JS::WebSerial.supported?
535
+ patch(terminal_status: 'disconnected', terminal_status_message: 'Web Serial APIに対応していないブラウザです(Chrome/Edge推奨)')
536
+ return
537
+ end
538
+
539
+ patch(terminal_status: 'connecting', terminal_status_message: '接続中…')
540
+
541
+ begin
542
+ JS::WebSerial.connect(baud_rate: 115_200) do |ws|
543
+ @terminal_port = ws
544
+ ws.start_terminal_read(@xterm) if @xterm
545
+ ws.on_disconnect { handle_terminal_port_disconnected }
546
+ end
547
+ # ユーザーが明示的に「切断」するまでは、デバイス側の再起動等で
548
+ # ポートが一旦消えても自動再接続を試みる(watch_for_terminal_reconnect参照)。
549
+ @terminal_auto_reconnect = true
550
+ patch(terminal_status: 'connected', terminal_status_message: '接続済み')
551
+ @xterm&.focus
552
+ rescue => e
553
+ @terminal_port = nil
554
+ patch(terminal_status: 'disconnected', terminal_status_message: "接続エラー: #{e.message}")
555
+ end
556
+ end
557
+
558
+ # ユーザーがボタンを押しての明示的な切断。以後は自動再接続もしない。
559
+ def terminal_disconnect
560
+ @terminal_auto_reconnect = false
561
+ port = @terminal_port
562
+ @terminal_port = nil
563
+ patch(terminal_status: 'disconnected', terminal_status_message: '未接続')
564
+ return unless port
565
+
566
+ port.close rescue nil
567
+ end
568
+
569
+ # ポート側からの切断通知(_set_on_disconnect経由、または後述の
570
+ # navigator.serialのdisconnectイベント)。ユーザーの明示的な切断とは違い
571
+ # @terminal_auto_reconnectはそのままにしておく(自動再接続待ちの状態にする)。
572
+ def handle_terminal_port_disconnected
573
+ return unless @terminal_port
574
+
575
+ @terminal_port = nil
576
+ if @terminal_auto_reconnect
577
+ patch(terminal_status: 'connecting', terminal_status_message: 'デバイスが再起動中です…自動的に再接続します')
578
+ else
579
+ patch(terminal_status: 'disconnected', terminal_status_message: 'デバイスが切断されました')
580
+ end
581
+ end
582
+
583
+ # デバイスがCPUリセット等で再起動すると、USBの列挙が一瞬切れてから
584
+ # 同じ物理ポートとして再度現れる(Web Serial的には対象ポートの'disconnect'に
585
+ # 続けて、ブラウザ全体に'connect'イベントが飛んでくる)。
586
+ # picoruby.org/terminal (pages/r2p2/terminal.rb)のApp#connect/bind_eventsと
587
+ # 同じ仕組みで、権限を与え済みのポートが再度現れたら(ユーザー操作なしに)
588
+ # 自動で開き直す。
589
+ #
590
+ # JS::WebSerial._watch_connect_events / _take_last_connected_port は
591
+ # npmパッケージのwasmにC拡張として直接コンパイルされているが、mrblib側に
592
+ # 高レベルのラッパーメソッドが無い(webserial.rbのRuby側APIには存在しない)
593
+ # ため、"_"付きのまま直接呼び出している。実際に呼べることは
594
+ # `JS::WebSerial.methods(false)`をブラウザのコンソールに出して確認済み。
595
+ def watch_for_terminal_reconnect
596
+ return unless JS::WebSerial.supported?
597
+
598
+ JS::WebSerial._watch_connect_events
599
+ JS.global.addEventListener('serial-port-connect') { attempt_terminal_auto_reconnect }
600
+
601
+ # 個別ポートのon_disconnect(_set_on_disconnect)がブラウザ/デバイスの
602
+ # 組み合わせによっては発火しないことがあるための保険。terminal.rbでも
603
+ # navigator.serial自体のdisconnectイベントを別途見ている。
604
+ JS.global[:navigator][:serial].addEventListener('disconnect') { handle_terminal_port_disconnected }
605
+ end
606
+
607
+ def attempt_terminal_auto_reconnect
608
+ return unless @terminal_auto_reconnect
609
+ return if @terminal_port
610
+
611
+ raw_port = JS::WebSerial._take_last_connected_port
612
+ return unless raw_port
613
+
614
+ patch(terminal_status: 'connecting', terminal_status_message: '再接続中…')
615
+ # 再起動直後はまだUSBの列挙が安定していないことがあるため、
616
+ # terminal.rbに倣って少し待ってから開く。
617
+ sleep_ms 1500
618
+
619
+ begin
620
+ ws = JS::WebSerial.new(raw_port)
621
+ ws.open(baud_rate: 115_200)
622
+ ws.start_terminal_read(@xterm) if @xterm
623
+ ws.on_disconnect { handle_terminal_port_disconnected }
624
+ @terminal_port = ws
625
+ patch(terminal_status: 'connected', terminal_status_message: '再接続しました')
626
+ rescue => e
627
+ patch(terminal_status: 'disconnected', terminal_status_message: "再接続に失敗しました: #{e.message}")
628
+ end
629
+ end
630
+
631
+ def terminal_send_bytes(str)
632
+ @terminal_port&.write(str)
633
+ end
634
+
635
+ # --- app/ のアップロード(PicoModemプロトコル) ---------------------------
636
+
637
+ def upload_app_directory
638
+ return if state[:terminal_uploading]
639
+ return unless terminal_connected?
640
+
641
+ files = terminal_app_files
642
+ if files.empty?
643
+ patch(terminal_upload_log: ['app/ 以下にアップロード可能なファイルがありません'])
644
+ return
645
+ end
646
+
647
+ patch(terminal_uploading: true, terminal_upload_log: ["#{files.length}個のファイルをアップロードします…"])
648
+ upload_next_terminal_file(files, 0)
649
+ end
650
+
651
+ # state[:files](現在のプロジェクトのALLOWED_EXTENSIONSファイル一覧)から
652
+ # "app/"配下だけを取り出し、[プロジェクト内の相対パス, 実機上のパス]の
653
+ # ペアにする。POST /api/buildがapp/以下をstorage/home/へコピーする
654
+ # (=実機からは/home/以下に見える)のと対応を合わせてある。
655
+ def terminal_app_files
656
+ result = []
657
+ (state[:files] || []).each do |path|
658
+ next unless path.start_with?('app/')
659
+ relative = path.sub('app/', '')
660
+ next if relative.empty?
661
+ result << [path, "/home/#{relative}"]
662
+ end
663
+ result
664
+ end
665
+
666
+ # ファイルを1つずつ順番に処理する(継続渡し)。Funicular::HTTPのコールバックが
667
+ # 同期/非同期どちらであっても、必ず1ファイルの転送が完全に終わってから
668
+ # 次のCtrl-B送信に進むようにするため、Enumerable#eachではなくこの形にしてある
669
+ # (PicoModemは1本のシリアル接続を排他的に使うプロトコルなので、複数ファイルの
670
+ # 転送が重なるとフレームが混ざって壊れる)。
671
+ def upload_next_terminal_file(files, index)
672
+ if index >= files.length
673
+ patch(terminal_uploading: false)
674
+ append_terminal_upload_log('アップロードが完了しました')
675
+ return
676
+ end
677
+
678
+ relative_path, device_path = files[index]
679
+ upload_one_terminal_file(relative_path, device_path) { upload_next_terminal_file(files, index + 1) }
680
+ end
681
+
682
+ def upload_one_terminal_file(relative_path, device_path, &done)
683
+ append_terminal_upload_log("#{relative_path} -> #{device_path} …")
684
+
685
+ Funicular::HTTP.get("/api/file?path=#{encode(relative_path)}") do |response|
686
+ unless response.ok
687
+ append_terminal_upload_log(' 読み込みに失敗しました')
688
+ done.call
689
+ next
690
+ end
691
+
692
+ content = value_of(response.data, 'content').to_s
693
+ begin
694
+ enter_picomodem_mode
695
+ write_picomodem_file(device_path, content)
696
+ append_terminal_upload_log(" 完了 (#{content.bytesize} bytes)")
697
+ rescue => e
698
+ append_terminal_upload_log(" 失敗: #{e.message}")
699
+ ensure
700
+ exit_picomodem_mode
701
+ done.call
702
+ end
703
+ end
704
+ end
705
+
706
+ # --- PicoModemクライアント(ホスト側) -----------------------------------
707
+ # 実装は picoruby.org/terminal (picoruby/picoruby.github.io の
708
+ # pages/r2p2/terminal.rb)のPicoModemクライアント部分に準拠している。
709
+
710
+ def enter_picomodem_mode
711
+ raise 'デバイスに接続されていません' unless @terminal_port
712
+ js_port = @terminal_port.instance_variable_get(:@js_port)
713
+
714
+ JS::WebSerial.binary_capture_start(js_port)
715
+ @terminal_port.write("\x02")
716
+ @terminal_port.drain.await
717
+
718
+ waited = 0
719
+ while waited < TERM_TIMEOUT_MS
720
+ b = JS::WebSerial.binary_capture_read(js_port, 1)
721
+ if b && 0 < b.bytesize
722
+ return if b.getbyte(0) == 0x06
723
+ next
724
+ end
725
+ sleep_ms 10
726
+ waited += 10
727
+ end
728
+
729
+ JS::WebSerial.binary_capture_stop(js_port) rescue nil
730
+ raise 'デバイスからの応答がありません(R2P2のプロンプトが表示されているか確認してください)'
731
+ end
732
+
733
+ def exit_picomodem_mode
734
+ return unless @terminal_port
735
+ js_port = @terminal_port.instance_variable_get(:@js_port)
736
+ JS::WebSerial.binary_capture_stop(js_port) rescue nil
737
+ end
738
+
739
+ def write_picomodem_file(path, content)
740
+ js_port = @terminal_port.instance_variable_get(:@js_port)
741
+
742
+ send_picomodem_frame(TERM_FILE_WRITE, [content.bytesize].pack('N') + path)
743
+
744
+ frame = recv_picomodem_frame(js_port)
745
+ raise 'タイムアウト(書き込み開始の応答待ち)' unless frame
746
+ raise "デバイスエラー: #{frame[1]}" if frame[0] == TERM_ERROR
747
+ raise "想定外の応答: 0x#{frame[0].to_s(16)}" unless frame[0] == TERM_FILE_ACK
748
+
749
+ offset = 0
750
+ while offset < content.bytesize
751
+ remain = content.bytesize - offset
752
+ size = remain < TERM_CHUNK_SIZE ? remain : TERM_CHUNK_SIZE
753
+ chunk = content.byteslice(offset, size)
754
+ send_picomodem_frame(TERM_CHUNK, chunk)
755
+
756
+ ack = recv_picomodem_frame(js_port)
757
+ raise "タイムアウト(#{offset}バイト目)" unless ack
758
+ raise "デバイスエラー: #{ack[1]}" if ack[0] == TERM_ERROR
759
+ raise "想定外の応答: 0x#{ack[0].to_s(16)}" unless ack[0] == TERM_CHUNK_ACK
760
+
761
+ offset += size
762
+ end
763
+
764
+ frame = recv_picomodem_frame(js_port)
765
+ raise 'タイムアウト(完了待ち)' unless frame
766
+ raise "デバイスエラー: #{frame[1]}" if frame[0] == TERM_ERROR
767
+ raise "想定外の応答: 0x#{frame[0].to_s(16)}" unless frame[0] == TERM_DONE_ACK
768
+
769
+ payload = frame[1]
770
+ return unless 5 <= payload.bytesize
771
+
772
+ status = payload.getbyte(0)
773
+ remote_crc = payload.byteslice(1, 4).unpack('N')[0]
774
+ local_crc = CRC.crc32(content)
775
+ raise 'CRC32が一致しません(転送中にデータが壊れた可能性があります)' unless status == TERM_OK && local_crc == remote_crc
776
+ end
777
+
778
+ def send_picomodem_frame(cmd, payload = '')
779
+ body = cmd.chr + payload.to_s
780
+ crc = CRC.crc16(body)
781
+ frame = [0x02, body.bytesize].pack('Cn') + body + [crc].pack('n')
782
+ send_picomodem_bytes(frame)
783
+ end
784
+
785
+ def send_picomodem_bytes(data)
786
+ return unless @terminal_port
787
+ off = 0
788
+ while off < data.bytesize
789
+ remain = data.bytesize - off
790
+ size = remain < TERM_TX_CHUNK_SIZE ? remain : TERM_TX_CHUNK_SIZE
791
+ @terminal_port.write(data.byteslice(off, size))
792
+ off += size
793
+ sleep_ms(TERM_TX_CHUNK_GAP_MS) if off < data.bytesize
794
+ end
795
+ @terminal_port.drain.await
796
+ end
797
+
798
+ def recv_picomodem_frame(js_port)
799
+ stx = read_picomodem_exact(js_port, 1)
800
+ return nil unless stx
801
+ return nil unless stx.getbyte(0) == 0x02
802
+
803
+ len_bytes = read_picomodem_exact(js_port, 2)
804
+ return nil unless len_bytes
805
+ length = len_bytes.unpack('n')[0]
806
+
807
+ rest = read_picomodem_exact(js_port, length + 2)
808
+ return nil unless rest
809
+ body = rest.byteslice(0, length)
810
+ expected_crc = rest.byteslice(length, 2).unpack('n')[0]
811
+ return nil unless CRC.crc16(body) == expected_crc
812
+
813
+ cmd = body.getbyte(0)
814
+ payload = 1 < length ? body.byteslice(1, length - 1) : ''
815
+ [cmd, payload]
816
+ end
817
+
818
+ def read_picomodem_exact(js_port, n)
819
+ buf = ''
820
+ waited = 0
821
+ while buf.bytesize < n
822
+ chunk = JS::WebSerial.binary_capture_read(js_port, n - buf.bytesize)
823
+ if chunk && 0 < chunk.bytesize
824
+ buf << chunk
825
+ waited = 0
826
+ else
827
+ return nil if TERM_TIMEOUT_MS <= waited
828
+ sleep_ms 10
829
+ waited += 10
830
+ end
831
+ end
832
+ buf
833
+ end
834
+
835
+ def append_terminal_upload_log(line)
836
+ patch(terminal_upload_log: state[:terminal_upload_log] + [line])
837
+ end
838
+
839
+ def render_textarea
840
+ if state[:current_path]
841
+ tag(:textarea,
842
+ id: 'editor',
843
+ ref: :editor,
844
+ spellcheck: 'false',
845
+ oninput: :handle_input,
846
+ onkeydown: :handle_keydown
847
+ )
848
+ else
849
+ tag(:textarea,
850
+ id: 'editor',
851
+ ref: :editor,
852
+ spellcheck: 'false',
853
+ disabled: true,
854
+ placeholder: '左のファイル一覧から編集するファイルを選んでください'
855
+ )
856
+ end
857
+ end
858
+
859
+ # --- ファイルメニュー(プロジェクトを開く/mrbgemを追加/新規ファイル/新しいフォルダ) --
860
+
861
+ def toggle_file_menu
862
+ patch(file_menu_open: !state[:file_menu_open])
863
+ end
864
+
865
+ # 「プロジェクトを開く」ダイアログを開く。常にホームディレクトリから始める
866
+ # (「最初はホームディレクトリが開いている状態で」という要望)。pathを渡さずに
867
+ # load_browse_dirを呼ぶと、サーバ側(GET /api/browse)がDir.homeへ解決する。
868
+ def open_project_browser
869
+ patch(file_menu_open: false, context_menu: nil, project_browser: {
870
+ path: nil, parent: nil, entries: [], loading: true, error: nil,
871
+ creating: false, create_error: nil, open_error: nil
872
+ })
873
+ load_browse_dir(nil)
874
+ end
875
+
876
+ # ディレクトリブラウザで別の場所へ移動する(パンくず・一覧項目クリック・
877
+ # 「上へ」ボタン、いずれもこれを通る)。失敗しても現在表示中の一覧は保持し
878
+ # (壊れたリンクで迷子にならないように)、エラーメッセージだけ出す。
879
+ def load_browse_dir(path)
880
+ return unless state[:project_browser]
881
+
882
+ patch(project_browser: state[:project_browser].merge(loading: true, error: nil))
883
+
884
+ query = path.to_s.empty? ? '' : "?path=#{encode(path)}"
885
+ Funicular::HTTP.get("/api/browse#{query}") do |response|
886
+ next unless state[:project_browser]
887
+
888
+ if response.ok
889
+ data = response.data || {}
890
+ patch(project_browser: state[:project_browser].merge(
891
+ path: value_of(data, 'path'),
892
+ parent: value_of(data, 'parent'),
893
+ entries: value_of(data, 'entries') || [],
894
+ loading: false,
895
+ error: nil
896
+ ))
897
+ else
898
+ patch(project_browser: state[:project_browser].merge(loading: false, error: response.error_message))
899
+ end
900
+ end
901
+ end
902
+
903
+ def start_create_folder
904
+ return unless state[:project_browser]
905
+ patch(project_browser: state[:project_browser].merge(creating: true, create_error: nil))
906
+ end
907
+
908
+ def cancel_create_folder
909
+ return unless state[:project_browser]
910
+ patch(project_browser: state[:project_browser].merge(creating: false, create_error: nil))
911
+ end
912
+
913
+ def confirm_create_folder(name)
914
+ browser = state[:project_browser]
915
+ return unless browser
916
+
917
+ cleaned = name.to_s.strip
918
+ if cleaned.empty?
919
+ patch(project_browser: browser.merge(create_error: '入力してください'))
920
+ return
921
+ end
922
+
923
+ Funicular::HTTP.post('/api/browse', { path: browser[:path], name: cleaned }) do |response|
924
+ next unless state[:project_browser]
925
+
926
+ if response.ok
927
+ data = response.data || {}
928
+ patch(project_browser: state[:project_browser].merge(
929
+ path: value_of(data, 'path'),
930
+ parent: value_of(data, 'parent'),
931
+ entries: value_of(data, 'entries') || [],
932
+ loading: false,
933
+ error: nil,
934
+ creating: false,
935
+ create_error: nil
936
+ ))
937
+ else
938
+ patch(project_browser: state[:project_browser].merge(create_error: response.error_message))
939
+ end
940
+ end
941
+ end
942
+
943
+ def open_add_mrbgem_dialog
944
+ return if state[:project].nil?
945
+
946
+ patch(file_menu_open: false, context_menu: nil, prompt_dialog: {
947
+ kind: :add_mrbgem,
948
+ title: 'mrbgemを追加',
949
+ label: 'mrbgem名(英数字・_・-のみ)',
950
+ placeholder: 'picoruby_my_gem',
951
+ confirm_label: '追加',
952
+ error: nil
953
+ })
954
+ end
955
+
956
+ # 新規ファイル・新しいフォルダは、実機起動スクリプトの置き場であるapp/配下だけを
957
+ # 対象にしている(app.rb側のunder_app_dir?と対応)。「ファイル」メニューからは
958
+ # app/を省いた相対パス(ネストしたパスも可)を、ツリーのコンテキストメニューからは
959
+ # 右クリックしたディレクトリ内でのファイル名だけを入力させる。どちらもcontext_dir
960
+ # (前者は固定で'app'、後者は右クリックしたディレクトリのpath)を基準にした相対パス
961
+ # という点は同じなので、show_new_file_dialog/show_new_folder_dialogに集約している。
962
+ def open_new_file_dialog
963
+ return if state[:project].nil?
964
+ show_new_file_dialog('app')
965
+ end
966
+
967
+ def open_new_folder_dialog
968
+ return if state[:project].nil?
969
+ show_new_folder_dialog('app')
970
+ end
971
+
972
+ def show_new_file_dialog(context_dir)
973
+ patch(file_menu_open: false, context_menu: nil, prompt_dialog: {
974
+ kind: :new_file,
975
+ title: '新規ファイル',
976
+ label: context_dir_label(context_dir, '拡張子は.rb'),
977
+ placeholder: context_dir == 'app' ? 'utils/foo.rb' : 'foo.rb',
978
+ confirm_label: '作成',
979
+ error: nil,
980
+ context_dir: context_dir
981
+ })
982
+ end
983
+
984
+ def show_new_folder_dialog(context_dir)
985
+ patch(file_menu_open: false, context_menu: nil, prompt_dialog: {
986
+ kind: :new_folder,
987
+ title: '新しいフォルダ',
988
+ label: context_dir_label(context_dir, nil),
989
+ placeholder: 'utils',
990
+ confirm_label: '作成',
991
+ error: nil,
992
+ context_dir: context_dir
993
+ })
994
+ end
995
+
996
+ def context_dir_label(context_dir, note)
997
+ base = context_dir == 'app' ? 'app/ からの相対パス' : "#{context_dir}/ 内の名前"
998
+ note ? "#{base}。#{note}" : base
999
+ end
1000
+
1001
+ # PromptDialogの確定ボタン(または入力欄でのEnter)から呼ばれる。
1002
+ # kindによってmrbgem追加/新規ファイル作成/新規フォルダ作成のどれを行うか分岐する。
1003
+ def handle_prompt_confirm(value)
1004
+ dialog = state[:prompt_dialog]
1005
+ return unless dialog
1006
+
1007
+ name = value.to_s.strip
1008
+ if name.empty?
1009
+ patch(prompt_dialog: dialog.merge(error: '入力してください'))
1010
+ return
1011
+ end
1012
+
1013
+ case dialog[:kind]
1014
+ when :add_mrbgem then add_mrbgem(name)
1015
+ when :new_file then create_file(to_full_path(dialog[:context_dir], name))
1016
+ when :new_folder then create_folder(to_full_path(dialog[:context_dir], name))
1017
+ end
1018
+ end
1019
+
1020
+ # ダイアログに入力された相対パスの先頭にcontext_dir(基準ディレクトリ)を補う。
1021
+ # ユーザーが誤って基準ディレクトリ自体を書いてしまっても二重にはならないようにする。
1022
+ #
1023
+ # 正規表現(\Aアンカー)は使わない。PicoRuby.wasm上のRegexpはJSのRegExpへ
1024
+ # そのまま委譲される作りで、JSは\Aをサポートしないため
1025
+ # 「Invalid regular expression」でArgumentErrorになった(実際にブラウザで
1026
+ # 踏んで発覚。この例外はコールバック内で発生するため画面上には何も表示されず、
1027
+ # ブラウザのコンソールにだけ出る=「ボタンを押しても何も起きない」ように見える、
1028
+ # setTimeoutの罠と同じ性質の落とし穴)。
1029
+ def to_full_path(context_dir, rel)
1030
+ cleaned = rel.to_s
1031
+ cleaned = cleaned[1, cleaned.length - 1].to_s while cleaned.start_with?('/')
1032
+ prefix = "#{context_dir}/"
1033
+ cleaned.start_with?(prefix) ? cleaned : "#{prefix}#{cleaned}"
1034
+ end
1035
+
1036
+ def prompt_dialog_error(message)
1037
+ dialog = state[:prompt_dialog]
1038
+ return unless dialog
1039
+ patch(prompt_dialog: dialog.merge(error: (message && !message.empty?) ? message : '失敗しました'))
1040
+ end
1041
+
1042
+ # プロジェクトを開く(新規にダイアログから指定した場合も、メニューバーの
1043
+ # ドロップダウンでknown_projectsの中から切り替えた場合も、どちらもこれを通る)。
1044
+ # 既に開いているプロジェクトを選び直した場合は何もしない。
1045
+ #
1046
+ # 切り替え前のファイル関連のstateは全部そのプロジェクトに属さないものなので、
1047
+ # 作業中の編集状態(textareaの内容含む)をクリアしてから、新しいプロジェクトの
1048
+ # ファイル一覧・設定を取り直す。
1049
+ def open_project(path)
1050
+ if path == state[:project]
1051
+ # 既に開いているプロジェクトをブラウザ経由で選び直した場合は、
1052
+ # 何もせずダイアログだけ閉じる(ドロップダウン経由なら元々何もしない)。
1053
+ patch(project_browser: nil) if state[:project_browser]
1054
+ return
1055
+ end
1056
+
1057
+ Funicular::HTTP.post('/api/project', { path: path }) do |response|
1058
+ if response.ok
1059
+ patch(
1060
+ project_browser: nil,
1061
+ project: value_of(response.data, 'path'),
1062
+ known_projects: value_of(response.data, 'known') || [],
1063
+ files: [],
1064
+ dirs: [],
1065
+ loading_files: true,
1066
+ collapsed_dirs: [],
1067
+ current_path: nil,
1068
+ content: '',
1069
+ saved_content: '',
1070
+ status: '',
1071
+ status_kind: '',
1072
+ settings_dialog_open: false,
1073
+ project_config: default_project_config
1074
+ )
1075
+ sync_textarea('')
1076
+ load_file_list
1077
+ load_dir_list
1078
+ load_project_config
1079
+ elsif state[:project_browser]
1080
+ # ダイアログ(DirectoryBrowserDialog)経由ならダイアログ内にエラーを出す。
1081
+ patch(project_browser: state[:project_browser].merge(open_error: response.error_message))
1082
+ else
1083
+ # ドロップダウンでの切り替え(ダイアログを経由しない)はトップレベルの
1084
+ # ステータス表示でエラーを伝える。
1085
+ patch(status: 'プロジェクトを開けませんでした', status_kind: 'error')
1086
+ end
1087
+ end
1088
+ end
1089
+
1090
+ def add_mrbgem(name)
1091
+ Funicular::HTTP.post('/api/mrbgems', { name: name }) do |response|
1092
+ if response.ok
1093
+ patch(prompt_dialog: nil, status: 'mrbgemを追加しました', status_kind: 'ok')
1094
+ load_file_list
1095
+ else
1096
+ prompt_dialog_error(response.error_message)
1097
+ end
1098
+ end
1099
+ end
1100
+
1101
+ def create_file(path)
1102
+ Funicular::HTTP.post('/api/files', { path: path }) do |response|
1103
+ if response.ok
1104
+ patch(prompt_dialog: nil, status: 'ファイルを作成しました', status_kind: 'ok')
1105
+ load_file_list
1106
+ load_dir_list # ネストしたパスなら中間フォルダも新しくできているため
1107
+ open_file(path)
1108
+ else
1109
+ prompt_dialog_error(response.error_message)
1110
+ end
1111
+ end
1112
+ end
1113
+
1114
+ def create_folder(path)
1115
+ Funicular::HTTP.post('/api/folders', { path: path }) do |response|
1116
+ if response.ok
1117
+ patch(prompt_dialog: nil, status: 'フォルダを作成しました', status_kind: 'ok')
1118
+ load_dir_list
1119
+ else
1120
+ prompt_dialog_error(response.error_message)
1121
+ end
1122
+ end
1123
+ end
1124
+
1125
+ # --- ツリーのコンテキストメニュー ---------------------------------------
1126
+ #
1127
+ # TreeView自体はファイル/プロジェクトの概念を知らない汎用コンポーネントなので、
1128
+ # 右クリックされたnode(type/path)を受け取ってメニューの中身を決めるのはこちら側の
1129
+ # 責務にしてある。出す項目はnodeの種類によって変える:
1130
+ # - ファイル: 削除
1131
+ # - "app" 自身: ファイルを作成/フォルダを作成(削除は出さない。プロジェクトの
1132
+ # 実行スクリプト置き場である app/ 自体を消せてしまうと壊れるため)
1133
+ # - "app" 配下のディレクトリ: ファイルを作成/フォルダを作成/削除
1134
+ # - "mrbgems" 自身: mrbgemを追加(専用の雛形を作る、他のディレクトリとは別メニュー)
1135
+ # - それ以外のディレクトリ(mrbgems/<gem>やそのサブディレクトリ等): 削除のみ
1136
+ # (ファイル/フォルダの新規作成はapp/配下限定のため、ここでは作成系を出さない)
1137
+
1138
+ def open_tree_context_menu(node, event)
1139
+ kind = context_menu_kind(node)
1140
+ return unless kind
1141
+
1142
+ patch(context_menu: {
1143
+ kind: kind,
1144
+ path: node[:path],
1145
+ x: event[:clientX].to_i,
1146
+ y: event[:clientY].to_i
1147
+ })
1148
+ end
1149
+
1150
+
1151
+ def context_menu_kind(node)
1152
+ return :file if node[:type] == :file
1153
+ return :mrbgems_dir if node[:path] == MRBGEMS_DIRNAME
1154
+ return :app_dir if node[:path] == APP_DIRNAME
1155
+ return :app_subdir if node[:path].start_with?("#{APP_DIRNAME}/")
1156
+
1157
+ :other_dir
1158
+ end
1159
+
1160
+ def close_tree_context_menu
1161
+ patch(context_menu: nil)
1162
+ end
1163
+
1164
+ def render_tree_context_menu
1165
+ menu = state[:context_menu]
1166
+ # 全画面の透明なオーバーレイでメニュー以外へのクリック/右クリックを拾い、
1167
+ # 「メニューを出したまま別の操作をされて閉じ忘れる」ことがないようにする。
1168
+ div(class: 'context-menu-overlay', onclick: -> { close_tree_context_menu },
1169
+ oncontextmenu: ->(event) { event.preventDefault; close_tree_context_menu }) do
1170
+ div(class: 'context-menu', style: "left: #{menu[:x]}px; top: #{menu[:y]}px;") do
1171
+ render_context_menu_items(menu)
1172
+ end
1173
+ end
1174
+ end
1175
+
1176
+ def render_context_menu_items(menu)
1177
+ case menu[:kind]
1178
+ when :file
1179
+ context_menu_item('削除…') { confirm_delete_file(menu[:path]) }
1180
+ when :app_dir
1181
+ context_menu_item('ファイルを作成…') { show_new_file_dialog(menu[:path]) }
1182
+ context_menu_item('フォルダを作成…') { show_new_folder_dialog(menu[:path]) }
1183
+ when :app_subdir
1184
+ context_menu_item('ファイルを作成…') { show_new_file_dialog(menu[:path]) }
1185
+ context_menu_item('フォルダを作成…') { show_new_folder_dialog(menu[:path]) }
1186
+ context_menu_item('削除…') { confirm_delete_folder(menu[:path]) }
1187
+ when :mrbgems_dir
1188
+ context_menu_item('mrbgemを追加…') { open_add_mrbgem_dialog }
1189
+ when :other_dir
1190
+ context_menu_item('削除…') { confirm_delete_folder(menu[:path]) }
1191
+ end
1192
+ end
1193
+
1194
+ # メニュー項目を選んだら、まずメニュー自体を閉じてから実際の処理(ダイアログを
1195
+ # 開く/削除するなど)を実行する。
1196
+ def context_menu_item(label, &action)
1197
+ button(class: 'context-menu-item', onclick: -> { close_tree_context_menu; action.call }) { label }
1198
+ end
1199
+
1200
+ # --- 削除 --------------------------------------------------------------
1201
+
1202
+ # window.confirmはブラウザ標準のブロッキングダイアログ。Funicular本体の
1203
+ # Funicular.confirmも既定ではこれに委譲する作りになっている
1204
+ # (picoruby-funicular/mrblib/funicular.rb 参照。`!!JS.global.confirm(message)`)。
1205
+ # 削除は取り消せない操作なので、実行前に必ずここで確認する。
1206
+ def confirm_delete_file(path)
1207
+ return unless JS.global.confirm("#{path} を削除しますか?この操作は取り消せません。")
1208
+ delete_file(path)
1209
+ end
1210
+
1211
+ def confirm_delete_folder(path)
1212
+ return unless JS.global.confirm("#{path} を中身ごと削除しますか?この操作は取り消せません。")
1213
+ delete_folder(path)
1214
+ end
1215
+
1216
+ def delete_file(path)
1217
+ Funicular::HTTP.delete("/api/files?path=#{encode(path)}") do |response|
1218
+ if response.ok
1219
+ patch(status: 'ファイルを削除しました', status_kind: 'ok')
1220
+ close_open_file if state[:current_path] == path
1221
+ load_file_list
1222
+ load_dir_list
1223
+ else
1224
+ message = response.error_message
1225
+ patch(status: (message && !message.empty?) ? message : '削除に失敗しました', status_kind: 'error')
1226
+ end
1227
+ end
1228
+ end
1229
+
1230
+ def delete_folder(path)
1231
+ Funicular::HTTP.delete("/api/folders?path=#{encode(path)}") do |response|
1232
+ if response.ok
1233
+ patch(status: 'フォルダを削除しました', status_kind: 'ok')
1234
+ close_open_file if current_path_under?(path)
1235
+ load_file_list
1236
+ load_dir_list
1237
+ else
1238
+ message = response.error_message
1239
+ patch(status: (message && !message.empty?) ? message : '削除に失敗しました', status_kind: 'error')
1240
+ end
1241
+ end
1242
+ end
1243
+
1244
+ # 削除されたファイル(またはその親フォルダごと削除された場合)が現在エディタで
1245
+ # 開いたままになっていると、実体の無いファイルを編集し続けてしまうので閉じる。
1246
+ def close_open_file
1247
+ patch(current_path: nil, content: '', saved_content: '')
1248
+ sync_textarea('')
1249
+ end
1250
+
1251
+ def current_path_under?(dir_path)
1252
+ current = state[:current_path]
1253
+ return false unless current
1254
+ current == dir_path || current.start_with?("#{dir_path}/")
1255
+ end
1256
+
1257
+ # --- サーバとのやりとり ----------------------------------------------
1258
+
1259
+ # 起動時にサーバが(同じプロセスのまま前のブラウザセッションなどで)既に
1260
+ # プロジェクトを開いているかどうか・これまでに開いたプロジェクト一覧
1261
+ # (known_projects、メニューバーのドロップダウンの選択肢)を確認する。
1262
+ # 未設定の間はメニューバーのドロップダウンが「プロジェクトを開く」ボタンとして
1263
+ # 表示される(menu_bar.rb参照)ので、ユーザーがそれを押すまで何もしない
1264
+ # (自動でダイアログを表示したりはしない)。
1265
+ def load_project
1266
+ Funicular::HTTP.get('/api/project') do |response|
1267
+ path = response.ok ? value_of(response.data, 'path') : nil
1268
+ known = response.ok ? (value_of(response.data, 'known') || []) : []
1269
+ patch(project: path, known_projects: known, loading_project: false)
1270
+ if path
1271
+ load_file_list
1272
+ load_dir_list
1273
+ load_project_config
1274
+ end
1275
+ end
1276
+ end
1277
+
1278
+ # 現在のプロジェクトの設定(ターゲット・VM・USB Console)を取得する。
1279
+ # load_project(起動時)・open_project(切り替え時)から呼ばれる。
1280
+ def load_project_config
1281
+ Funicular::HTTP.get('/api/project/config') do |response|
1282
+ next unless response.ok
1283
+
1284
+ data = response.data || {}
1285
+ patch(
1286
+ project_config: {
1287
+ 'platform' => value_of(data, 'platform'),
1288
+ 'vm' => value_of(data, 'vm'),
1289
+ 'usb_console' => truthy?(value_of(data, 'usb_console'))
1290
+ }
1291
+ )
1292
+ end
1293
+ end
1294
+
1295
+ def load_file_list
1296
+ Funicular::HTTP.get('/api/files') do |response|
1297
+ if response.ok
1298
+ patch(files: response.data || [], loading_files: false)
1299
+ else
1300
+ patch(
1301
+ files: [],
1302
+ loading_files: false,
1303
+ status: 'ファイル一覧の取得に失敗しました',
1304
+ status_kind: 'error'
1305
+ )
1306
+ end
1307
+ end
1308
+ end
1309
+
1310
+ # 空フォルダもツリーに表示するための、ファイル一覧とは別のディレクトリ一覧取得。
1311
+ # file_tree_nodes参照。
1312
+ def load_dir_list
1313
+ Funicular::HTTP.get('/api/dirs') do |response|
1314
+ patch(dirs: response.data || []) if response.ok
1315
+ end
1316
+ end
1317
+
1318
+ def open_file(path)
1319
+ return if path == state[:current_path]
1320
+
1321
+ patch(status: '読み込み中…', status_kind: '')
1322
+
1323
+ Funicular::HTTP.get("/api/file?path=#{encode(path)}") do |response|
1324
+ if response.ok
1325
+ content = value_of(response.data, 'content').to_s
1326
+ patch(
1327
+ current_path: path,
1328
+ content: content,
1329
+ saved_content: content,
1330
+ status: '',
1331
+ status_kind: ''
1332
+ )
1333
+ # 非制御の textarea なので、ファイル切り替え時はここで流し込む
1334
+ sync_textarea(content)
1335
+ else
1336
+ patch(status: '読み込みに失敗しました', status_kind: 'error')
1337
+ end
1338
+ end
1339
+ end
1340
+
1341
+ def save_file
1342
+ path = state[:current_path]
1343
+ return unless path
1344
+ return unless dirty?
1345
+
1346
+ # 送信時点の内容を控えておく(レスポンスが返る頃には編集が進んでいる可能性がある)
1347
+ sending = state[:content]
1348
+
1349
+ Funicular::HTTP.post('/api/file', { path: path, content: sending }) do |response|
1350
+ if response.ok
1351
+ patch(saved_content: sending, status: '保存しました', status_kind: 'ok')
1352
+ else
1353
+ message = response.error_message
1354
+ patch(
1355
+ status: (message && !message.empty?) ? message : '保存に失敗しました',
1356
+ status_kind: 'error'
1357
+ )
1358
+ end
1359
+ end
1360
+ end
1361
+
1362
+ def start_build
1363
+ return if state[:building]
1364
+
1365
+ patch(building: true, build_status: 'running', build_log: '')
1366
+
1367
+ Funicular::HTTP.post('/api/build', {}) do |response|
1368
+ if response.ok
1369
+ schedule_build_poll
1370
+ else
1371
+ message = response.error_message
1372
+ patch(
1373
+ building: false,
1374
+ build_status: 'failed',
1375
+ build_log: (message && !message.empty?) ? message : 'ビルドの開始に失敗しました'
1376
+ )
1377
+ end
1378
+ end
1379
+ end
1380
+
1381
+ # ビルド中は一定間隔で自動的にログを取りに行く。完了(running以外)になったら止める。
1382
+ #
1383
+ # PicoRubyの JS::Object#setTimeout は setTimeout(delay_ms, &block) というシグネチャ
1384
+ # (picoruby-wasm の mrblib/js.rb 参照)で、コールバックは第一引数ではなくブロックとして
1385
+ # 渡す。JS.global.setTimeout(callback, delay) のように2引数で渡すと
1386
+ # 「ArgumentError: wrong number of arguments (given 2, expected 1)」になる。
1387
+ def schedule_build_poll
1388
+ JS.global.setTimeout(3000) { refresh_build_status }
1389
+ end
1390
+
1391
+ def refresh_build_status
1392
+ Funicular::HTTP.get('/api/build') do |response|
1393
+ next unless response.ok
1394
+
1395
+ data = response.data || {}
1396
+ status = value_of(data, 'status').to_s
1397
+ log = value_of(data, 'log').to_s
1398
+ log_truncated = truthy?(value_of(data, 'log_truncated'))
1399
+ still_running = status == 'running'
1400
+
1401
+ patch(
1402
+ build_status: status,
1403
+ build_log: log,
1404
+ build_log_truncated: log_truncated,
1405
+ building: still_running
1406
+ )
1407
+
1408
+ # running の間だけポーリングを継続する。手動の「ログを更新」クリックと
1409
+ # ポーリングのタイマーが両方生きていると呼び出しが二重になりうるが、
1410
+ # 単なる冗長リクエストで実害はないため許容している
1411
+ schedule_build_poll if still_running
1412
+ end
1413
+ end
1414
+
1415
+ def start_platform_setup
1416
+ return if state[:platform_building]
1417
+ return if value_of(state[:project_config], 'platform').to_s.empty?
1418
+
1419
+ patch(platform_building: true, platform_status: 'running', platform_log: '')
1420
+
1421
+ Funicular::HTTP.post('/api/platform', {}) do |response|
1422
+ if response.ok
1423
+ schedule_platform_poll
1424
+ else
1425
+ message = response.error_message
1426
+ patch(
1427
+ platform_building: false,
1428
+ platform_status: 'failed',
1429
+ platform_log: (message && !message.empty?) ? message : 'セットアップの開始に失敗しました'
1430
+ )
1431
+ end
1432
+ end
1433
+ end
1434
+
1435
+ # 仕組みはビルドのポーリングと同じ(schedule_build_poll参照)。
1436
+ def schedule_platform_poll
1437
+ JS.global.setTimeout(3000) { refresh_platform_status }
1438
+ end
1439
+
1440
+ def refresh_platform_status
1441
+ Funicular::HTTP.get('/api/platform') do |response|
1442
+ next unless response.ok
1443
+
1444
+ data = response.data || {}
1445
+ status = value_of(data, 'status').to_s
1446
+ log = value_of(data, 'log').to_s
1447
+ log_truncated = truthy?(value_of(data, 'log_truncated'))
1448
+ still_running = status == 'running'
1449
+
1450
+ patch(
1451
+ platform_status: status,
1452
+ platform_log: log,
1453
+ platform_log_truncated: log_truncated,
1454
+ platform_building: still_running
1455
+ )
1456
+
1457
+ schedule_platform_poll if still_running
1458
+ end
1459
+ end
1460
+
1461
+ # --- プロジェクト設定ダイアログ -----------------------------------------
1462
+ # ターゲット・VM・USB Consoleは`~/.picoride/project.toml`(server.rb /
1463
+ # project_store.rb参照)に永続化しているプロジェクト単位の設定。
1464
+
1465
+ # 現在保存されている設定(state[:project_config])を作業用のフィールドへ
1466
+ # コピーしてダイアログを開く。キャンセルすれば作業用フィールドは捨てられる。
1467
+ def open_settings_dialog
1468
+ config = state[:project_config] || {}
1469
+ patch(
1470
+ settings_dialog_open: true,
1471
+ settings_platform: value_of(config, 'platform'),
1472
+ settings_vm: value_of(config, 'vm').to_s,
1473
+ settings_usb_console: truthy?(value_of(config, 'usb_console'))
1474
+ )
1475
+ end
1476
+
1477
+ def save_project_settings
1478
+ platform = state[:settings_platform]
1479
+ vm = state[:settings_vm].to_s.empty? ? nil : state[:settings_vm]
1480
+ usb_console = state[:settings_usb_console]
1481
+
1482
+ payload = { platform: platform, vm: vm, usb_console: usb_console }
1483
+ Funicular::HTTP.post('/api/project/config', payload) do |response|
1484
+ if response.ok
1485
+ patch(
1486
+ settings_dialog_open: false,
1487
+ project_config: { 'platform' => platform, 'vm' => vm, 'usb_console' => usb_console },
1488
+ status: 'プロジェクト設定を保存しました',
1489
+ status_kind: 'ok'
1490
+ )
1491
+ else
1492
+ message = response.error_message
1493
+ patch(status: (message && !message.empty?) ? message : '設定の保存に失敗しました', status_kind: 'error')
1494
+ end
1495
+ end
1496
+ end
1497
+
1498
+ # --- 表示まわりのヘルパ ----------------------------------------------
1499
+
1500
+ def dirty?
1501
+ !state[:current_path].nil? && state[:content] != state[:saved_content]
1502
+ end
1503
+
1504
+ def line_count
1505
+ count = 1
1506
+ state[:content].each_char { |ch| count += 1 if ch == "\n" }
1507
+ count
1508
+ end
1509
+
1510
+ def refresh_highlight
1511
+ node = refs[:highlight]
1512
+ return unless node
1513
+ html = JS.global.funicularHighlight(state[:content], language).to_s
1514
+ node[:innerHTML] = html
1515
+ end
1516
+
1517
+ def sync_textarea(content)
1518
+ node = refs[:editor]
1519
+ return unless node
1520
+ node[:value] = content
1521
+ end
1522
+
1523
+ def insert_indent
1524
+ node = refs[:editor]
1525
+ return unless node
1526
+
1527
+ value = node[:value].to_s
1528
+ from = node[:selectionStart].to_i
1529
+ to = node[:selectionEnd].to_i
1530
+
1531
+ node[:value] = value[0, from].to_s + ' ' + value[to, value.length - to].to_s
1532
+ node[:selectionStart] = from + 2
1533
+ node[:selectionEnd] = from + 2
1534
+
1535
+ patch(content: node[:value].to_s)
1536
+ end
1537
+
1538
+ def language
1539
+ LANGUAGES[extension(state[:current_path])] || 'ruby'
1540
+ end
1541
+
1542
+ def extension(path)
1543
+ parts = path.to_s.split('.')
1544
+ parts.length > 1 ? parts.last.to_s : ''
1545
+ end
1546
+
1547
+ def encode(path)
1548
+ JS.global.encodeURIComponent(path).to_s
1549
+ end
1550
+
1551
+ # JSONのパース結果がキーを String / Symbol どちらで持つかは環境差があるため両対応にする
1552
+ def value_of(data, key)
1553
+ return nil unless data
1554
+ data[key] || data[key.to_sym]
1555
+ end
1556
+
1557
+ def truthy?(js_value)
1558
+ js_value.to_s == 'true'
1559
+ end
1560
+ end