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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Picoride
4
+ VERSION = "0.1.0"
5
+ end
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,8 @@
1
+ module Picoruby
2
+ module Esp32
3
+ module Ide
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ end
@@ -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