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
data/lib/picoride.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "picoride/version"
|
|
4
|
+
|
|
5
|
+
module Picoride
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "picoride/background_job"
|
|
10
|
+
require_relative "picoride/r2p2_state"
|
|
11
|
+
require_relative "picoride/project_store"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# web/funicular
|
|
2
|
+
|
|
3
|
+
PicoRuby.wasm + [Funicular](https://github.com/picoruby/funicular)(hasumikin作)で書いた
|
|
4
|
+
エディタのフロントエンド。**JavaScriptを書かずに、ブラウザ側のロジックを全部Rubyで書く**構成。
|
|
5
|
+
|
|
6
|
+
バックエンド(Sinatra)は `lib/picoride/server.rb`、CSSは `../public/` (= `web/public/`)のものをそのまま使う。
|
|
7
|
+
このディレクトリにはフロントエンドのRubyコードとHTMLだけを置く。
|
|
8
|
+
|
|
9
|
+
## 構成
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
web/funicular/
|
|
13
|
+
├── index.html # PicoRuby.wasm と Prism.js の読み込み + マウント先
|
|
14
|
+
└── ruby/
|
|
15
|
+
├── main.rb # Funicular.start(EditorApp, container: 'app')
|
|
16
|
+
└── components/
|
|
17
|
+
├── tree_view.rb # 汎用ツリー表示(表示専用。ファイル一覧に限らない)
|
|
18
|
+
├── toolbar.rb # ファイル名・保存ボタン・ステータス(表示専用)
|
|
19
|
+
└── editor_app.rb # ルート。状態・サーバ通信・ターミナルはすべてここ
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
(この一覧はサンプルの主要ファイルのみ。実際には `menu_bar.rb` `log_panel.rb`
|
|
23
|
+
`project_settings_dialog.rb` 等もある。最新の一覧は `views/index.erb` の
|
|
24
|
+
`<script type="text/ruby">` タグの並びを見るのが確実)
|
|
25
|
+
|
|
26
|
+
`index.html` の `<script type="text/ruby" src="...">` は**書いた順に実行される**ので、
|
|
27
|
+
子コンポーネント → ルート → `main.rb` の順に並べること。
|
|
28
|
+
|
|
29
|
+
## 起動
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ bundle install
|
|
33
|
+
$ ruby app/app.rb
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `/` … Funicular版エディタ
|
|
37
|
+
- `/legacy` … 旧エディタ(素のJavaScript + Prism.js版)。比較用に残してある
|
|
38
|
+
|
|
39
|
+
`lib/picoride/server.rb` 側に、`/ruby/**/*.rb` を `web/funicular/ruby/` から配信するルートを足してある。
|
|
40
|
+
|
|
41
|
+
## 設計メモ
|
|
42
|
+
|
|
43
|
+
### コンポーネント分割
|
|
44
|
+
|
|
45
|
+
状態(ファイル一覧・編集中パス・バッファ・保存済み内容・ターミナル接続状態)は
|
|
46
|
+
`EditorApp` が一手に持ち、`TreeView` と `Toolbar` は props を受け取って描画するだけの
|
|
47
|
+
表示専用にしている。子から親へは props で渡した lambda (`on_select` / `on_save`) を
|
|
48
|
+
呼び返す。
|
|
49
|
+
|
|
50
|
+
**注意**: 子コンポーネント(`component(SomeClass, ...)`)は、呼び出す親が
|
|
51
|
+
再描画されるたびに `initialize_state` からもう一度作り直される(ルートコンポーネント
|
|
52
|
+
だけがマウント1回を保証される)。そのため「一度だけ初期化して使い回したい副作用」
|
|
53
|
+
(外部ライブラリのインスタンス化、接続、イベントリスナー登録など)を持つロジックは
|
|
54
|
+
子コンポーネントに切り出さず、`EditorApp` 側に書くこと。実際にターミナル機能
|
|
55
|
+
(xterm.js + Web Serial)をこのルールに反して別コンポーネントに切り出した結果、
|
|
56
|
+
「タブを行き来すると接続が切れる」不具合を起こした(詳細は `CLAUDE.md` 参照)。
|
|
57
|
+
|
|
58
|
+
Funicularのライフサイクルは `initialize_state` → `render` → `component_mounted` →
|
|
59
|
+
(state更新のたび `render` / `component_updated`) → `component_unmounted`。
|
|
60
|
+
|
|
61
|
+
### textarea は「非制御」にしている
|
|
62
|
+
|
|
63
|
+
`render` で `value:` を出力していない。VDOMが入力のたびに value を書き戻すと
|
|
64
|
+
キャレットが末尾に飛ぶため。ファイルを切り替えたときだけ
|
|
65
|
+
`refs[:editor][:value] = content` で流し込んでいる。
|
|
66
|
+
|
|
67
|
+
### ハイライト層は ref + innerHTML
|
|
68
|
+
|
|
69
|
+
Prismが返すのはHTML文字列なので、VDOMでは表現できない。
|
|
70
|
+
ハイライト用の `<code>` は**子を持たない空要素**としてVDOMに描画し、
|
|
71
|
+
`refs[:highlight][:innerHTML]` に直接書き込む。
|
|
72
|
+
子を持たない要素は再描画時の差分がゼロなので、書き込んだHTMLが消されることはない。
|
|
73
|
+
|
|
74
|
+
同期のタイミングは `component_updated` で、
|
|
75
|
+
`content` か `current_path` が変わったときだけ再ハイライトする
|
|
76
|
+
(`Funicular::Component#patch` は `component_updated` を引数なしで呼ぶ仕様なので、
|
|
77
|
+
前回描画時の値は `@prev_highlight_content` / `@prev_highlight_path` に自前で持っている)。
|
|
78
|
+
|
|
79
|
+
Prism固有の作法(文法オブジェクトの取り出し、末尾改行の補正)は `index.html` の
|
|
80
|
+
`window.funicularHighlight(code, lang)` に閉じ込めてあり、Ruby側は
|
|
81
|
+
`JS.global.funicularHighlight(...)` を呼ぶだけ。
|
|
82
|
+
|
|
83
|
+
### 行番号はVDOMで描画
|
|
84
|
+
|
|
85
|
+
行番号は単なるテキストなので innerHTML を使わず、`div { n.to_s }` の素直な
|
|
86
|
+
VDOM描画にしている。スクロール追従は `onscroll:` ハンドラで
|
|
87
|
+
`refs[:lines][:scrollTop]` を同期。
|
|
88
|
+
|
|
89
|
+
### スクロールのズレ対策
|
|
90
|
+
|
|
91
|
+
`.editor-scroll-area` に `display: grid` を指定し、ハイライト層と textarea を
|
|
92
|
+
`grid-area: 1 / 1` で重ねる方式(`web/public/css/style.css`)。
|
|
93
|
+
これは旧エディタから引き継いだCSSで、textareaが要素内部で独自スクロールして
|
|
94
|
+
キャレットとハイライトがズレる問題への対処。
|
|
95
|
+
|
|
96
|
+
## 動作確認済み
|
|
97
|
+
|
|
98
|
+
ブラウザで一覧表示・ファイル選択・シンタックスハイライト・保存を確認済み。
|
|
99
|
+
実装時に不明だった点は以下の通りだった:
|
|
100
|
+
|
|
101
|
+
- `component_updated` は `Funicular::Component#patch` から**引数なし**で呼ばれる
|
|
102
|
+
(`component_updated(prev_state)` ではなく `component_updated`)。差分検知は
|
|
103
|
+
インスタンス変数で自前管理する必要がある
|
|
104
|
+
|
|
105
|
+
## 既知の制約(旧エディタから引き継ぎ)
|
|
106
|
+
|
|
107
|
+
- 自動インデント未実装(Tabはスペース2つを挿入するだけ)
|
|
108
|
+
- 新規作成/削除/リネームAPIなし。既存ファイルの上書き保存のみ
|
|
109
|
+
- 同時編集の競合制御なし(後勝ち)
|
|
110
|
+
- 対応拡張子は `.rb` `.c` `.h`。増やす場合は `app/app.rb` の `ALLOWED_EXTENSIONS`、
|
|
111
|
+
`index.html` のPrismコンポーネント読み込み、`editor_app.rb` の `LANGUAGES` の3箇所
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# 「プロジェクトを開く」のディレクトリ選択UI。絶対パスを直接テキスト入力させる
|
|
2
|
+
# 代わりに、よくあるファイルビューアのようにパンくず("/"区切りのクリック可能な
|
|
3
|
+
# セグメント)とサブディレクトリの一覧でファイルシステムを辿れるようにしてある。
|
|
4
|
+
#
|
|
5
|
+
# EditorAppの「子コンポーネントは親の再描画のたびに作り直される」制約により、
|
|
6
|
+
# このコンポーネント自身はinitialize_stateを持たない(表示専用)。現在地・
|
|
7
|
+
# 一覧・読み込み中/エラー状態は全部EditorApp側のstate[:project_browser]に
|
|
8
|
+
# 持たせ、propsとして受け取るだけにしてある(TreeViewと同じ設計)。新規フォルダ名の
|
|
9
|
+
# 入力欄だけはPromptDialogと同じ理由(ポーリング中の親再描画で入力中の文字が
|
|
10
|
+
# 消える)でrefベースの非制御コンポーネントにしてある。
|
|
11
|
+
#
|
|
12
|
+
# props:
|
|
13
|
+
# path: 現在表示しているディレクトリの絶対パス(初回ロード前はnil)
|
|
14
|
+
# parent: 1つ上のディレクトリの絶対パス(これ以上は上がれないならnil)
|
|
15
|
+
# entries: [{name:, path:}, ...] サブディレクトリの一覧
|
|
16
|
+
# loading: 一覧取得中かどうか
|
|
17
|
+
# error: ナビゲーション(移動)時のエラーメッセージ(あれば)
|
|
18
|
+
# creating: 「新しいフォルダ」の名前入力欄を表示するかどうか
|
|
19
|
+
# create_error: フォルダ作成時のエラーメッセージ(あれば)
|
|
20
|
+
# open_error: 「このディレクトリを開く」実行時のエラーメッセージ(あれば)
|
|
21
|
+
# on_navigate: 移動先の絶対パスを渡して呼ばれるlambda(パンくず・一覧項目・上へ)
|
|
22
|
+
# on_open: 「このディレクトリを開く」ボタン
|
|
23
|
+
# on_start_create: 「新しいフォルダ」ボタン(名前入力欄を開く)
|
|
24
|
+
# on_confirm_create: 入力された名前(String)を渡して呼ばれるlambda
|
|
25
|
+
# on_cancel_create: 新規フォルダ名入力のキャンセル
|
|
26
|
+
# on_cancel: ダイアログ全体のキャンセル(閉じる)
|
|
27
|
+
class DirectoryBrowserDialog < Funicular::Component
|
|
28
|
+
def render
|
|
29
|
+
div(class: 'dialog-overlay') do
|
|
30
|
+
div(class: 'dialog directory-browser-dialog') do
|
|
31
|
+
h2 { 'プロジェクトを開く' }
|
|
32
|
+
render_breadcrumb
|
|
33
|
+
div(class: 'directory-browser-toolbar') do
|
|
34
|
+
render_up_button
|
|
35
|
+
render_new_folder_button
|
|
36
|
+
end
|
|
37
|
+
render_navigate_error
|
|
38
|
+
render_entry_list
|
|
39
|
+
render_create_folder_row if props[:creating]
|
|
40
|
+
render_open_error
|
|
41
|
+
div(class: 'dialog-actions') do
|
|
42
|
+
button(class: 'dialog-cancel', onclick: :handle_cancel) { 'キャンセル' }
|
|
43
|
+
render_open_button
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle_cancel(event)
|
|
50
|
+
event.preventDefault
|
|
51
|
+
on_cancel = props[:on_cancel]
|
|
52
|
+
on_cancel.call if on_cancel
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_open(event)
|
|
56
|
+
event.preventDefault
|
|
57
|
+
on_open = props[:on_open]
|
|
58
|
+
on_open.call if on_open
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_up(event)
|
|
62
|
+
event.preventDefault
|
|
63
|
+
navigate_to(props[:parent])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def handle_start_create(event)
|
|
67
|
+
event.preventDefault
|
|
68
|
+
on_start_create = props[:on_start_create]
|
|
69
|
+
on_start_create.call if on_start_create
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_cancel_create(event)
|
|
73
|
+
event.preventDefault
|
|
74
|
+
on_cancel_create = props[:on_cancel_create]
|
|
75
|
+
on_cancel_create.call if on_cancel_create
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_confirm_create(event)
|
|
79
|
+
event.preventDefault
|
|
80
|
+
confirm_create
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Enterキーでも作成を確定できるようにする(PromptDialogと同じ)。
|
|
84
|
+
def handle_create_keydown(event)
|
|
85
|
+
confirm_create if event[:key].to_s == 'Enter'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def navigate_to(path)
|
|
91
|
+
return if path.to_s.empty?
|
|
92
|
+
on_navigate = props[:on_navigate]
|
|
93
|
+
on_navigate.call(path) if on_navigate
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def confirm_create
|
|
97
|
+
node = refs[:new_folder_input]
|
|
98
|
+
return unless node
|
|
99
|
+
|
|
100
|
+
on_confirm_create = props[:on_confirm_create]
|
|
101
|
+
on_confirm_create.call(node[:value].to_s) if on_confirm_create
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 例: "/home/youchan/Projects" ->
|
|
105
|
+
# [{label: '/', path: '/'}, {label: 'home', path: '/home'},
|
|
106
|
+
# {label: 'youchan', path: '/home/youchan'}, {label: 'Projects', path: '/home/youchan/Projects'}]
|
|
107
|
+
# PicoRuby.wasm上ではFile系メソッドの対応が不確かなため、素の文字列分割で求める
|
|
108
|
+
# (menu_bar.rbのproject_labelと同じ理由)。
|
|
109
|
+
def breadcrumb_segments
|
|
110
|
+
path = props[:path].to_s
|
|
111
|
+
return [] if path.empty?
|
|
112
|
+
|
|
113
|
+
parts = path.split('/').reject { |p| p.empty? }
|
|
114
|
+
crumbs = [{ label: '/', path: '/' }]
|
|
115
|
+
acc = ''
|
|
116
|
+
parts.each do |part|
|
|
117
|
+
acc = "#{acc}/#{part}"
|
|
118
|
+
crumbs << { label: part, path: acc }
|
|
119
|
+
end
|
|
120
|
+
crumbs
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def render_breadcrumb
|
|
124
|
+
div(class: 'directory-browser-breadcrumb') do
|
|
125
|
+
breadcrumb_segments.each_with_index do |segment, index|
|
|
126
|
+
span(class: 'breadcrumb-sep') { '/' } if index.positive?
|
|
127
|
+
render_breadcrumb_segment(segment)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def render_breadcrumb_segment(segment)
|
|
133
|
+
if segment[:path] == props[:path]
|
|
134
|
+
span(class: 'breadcrumb-segment breadcrumb-current') { segment[:label] }
|
|
135
|
+
else
|
|
136
|
+
span(class: 'breadcrumb-segment', onclick: -> { navigate_to(segment[:path]) }) { segment[:label] }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_up_button
|
|
141
|
+
if props[:parent].to_s.empty?
|
|
142
|
+
button(class: 'directory-browser-up', disabled: true) { '↑ 上の階層へ' }
|
|
143
|
+
else
|
|
144
|
+
button(class: 'directory-browser-up', onclick: :handle_up) { '↑ 上の階層へ' }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def render_new_folder_button
|
|
149
|
+
button(class: 'directory-browser-new-folder', onclick: :handle_start_create) { '新しいフォルダ…' }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def render_navigate_error
|
|
153
|
+
return if props[:error].to_s.empty?
|
|
154
|
+
div(class: 'prompt-error') { props[:error].to_s }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def render_entry_list
|
|
158
|
+
div(class: 'directory-browser-list') do
|
|
159
|
+
if props[:loading]
|
|
160
|
+
div(class: 'directory-browser-message') { '読み込み中…' }
|
|
161
|
+
elsif (props[:entries] || []).empty?
|
|
162
|
+
div(class: 'directory-browser-message') { '(サブフォルダがありません)' }
|
|
163
|
+
else
|
|
164
|
+
(props[:entries] || []).each { |entry| render_entry(entry) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# entryはJSON(GET /api/browseのentries)をそのまま渡しているため、
|
|
170
|
+
# パース結果がシンボルキーになるとは限らない(文字列キーのHashになりうる)。
|
|
171
|
+
# value_of(menu_bar.rbのproject_label付近と同じ理由)で両対応させる。
|
|
172
|
+
def render_entry(entry)
|
|
173
|
+
path = value_of(entry, 'path')
|
|
174
|
+
div(class: 'directory-browser-item', key: path, onclick: -> { navigate_to(path) }) do
|
|
175
|
+
span(class: 'directory-browser-item-icon') { '📁' }
|
|
176
|
+
span(class: 'directory-browser-item-name') { value_of(entry, 'name') }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def render_create_folder_row
|
|
181
|
+
div(class: 'directory-browser-create') do
|
|
182
|
+
tag(:input, type: 'text', ref: :new_folder_input, placeholder: '新しいフォルダ名',
|
|
183
|
+
onkeydown: :handle_create_keydown)
|
|
184
|
+
button(class: 'dialog-confirm', onclick: :handle_confirm_create) { '作成' }
|
|
185
|
+
button(class: 'dialog-cancel', onclick: :handle_cancel_create) { 'キャンセル' }
|
|
186
|
+
render_create_error
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def render_create_error
|
|
191
|
+
return if props[:create_error].to_s.empty?
|
|
192
|
+
div(class: 'prompt-error') { props[:create_error].to_s }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def render_open_error
|
|
196
|
+
return if props[:open_error].to_s.empty?
|
|
197
|
+
div(class: 'prompt-error') { props[:open_error].to_s }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def render_open_button
|
|
201
|
+
if props[:path].to_s.empty?
|
|
202
|
+
button(class: 'dialog-confirm', disabled: true) { 'このディレクトリを開く' }
|
|
203
|
+
else
|
|
204
|
+
button(class: 'dialog-confirm', onclick: :handle_open) { 'このディレクトリを開く' }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def value_of(data, key)
|
|
209
|
+
return nil unless data
|
|
210
|
+
data[key] || data[key.to_sym]
|
|
211
|
+
end
|
|
212
|
+
end
|