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.
- checksums.yaml +7 -0
- data/.claude/launch.json +11 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +1518 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/Dockerfile +76 -0
- data/LICENSE.txt +21 -0
- data/README.md +76 -0
- data/Rakefile +12 -0
- data/docker/esp32/Dockerfile +82 -0
- data/docker/pico/Dockerfile +77 -0
- data/exe/picoride +16 -0
- data/lib/picoride/background_job.rb +71 -0
- data/lib/picoride/project_store.rb +133 -0
- data/lib/picoride/r2p2_state.rb +137 -0
- data/lib/picoride/server.rb +1156 -0
- data/lib/picoride/version.rb +5 -0
- data/lib/picoride.rb +11 -0
- data/sig/picoruby/esp32/ide.rbs +8 -0
- data/web/funicular/README.md +111 -0
- data/web/funicular/ruby/components/directory_browser_dialog.rb +212 -0
- data/web/funicular/ruby/components/editor_app.rb +1560 -0
- data/web/funicular/ruby/components/log_panel.rb +102 -0
- data/web/funicular/ruby/components/menu_bar.rb +266 -0
- data/web/funicular/ruby/components/project_settings_dialog.rb +203 -0
- data/web/funicular/ruby/components/prompt_dialog.rb +76 -0
- data/web/funicular/ruby/components/toolbar.rb +30 -0
- data/web/funicular/ruby/components/tree_view.rb +112 -0
- data/web/funicular/ruby/main.rb +3 -0
- data/web/public/css/style.css +886 -0
- data/web/public/js/editor.js +257 -0
- data/web/views/index.erb +94 -0
- metadata +132 -0
|
@@ -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
|