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,257 @@
1
+ // Prism.js + textarea オーバーレイ方式のシンプルなコードエディタ。
2
+ // picoruby.org/terminal の File Editor (PicoModem) と同じ構造:
3
+ // textarea(透明・キャレットのみ表示) の下に、Prismでハイライトした <pre><code> を重ねて表示する。
4
+ // ビルドステップ不要(Prism.jsは <script> タグ読み込みでグローバルに window.Prism を公開する)。
5
+
6
+ (function () {
7
+ "use strict";
8
+
9
+ const fileListEl = document.getElementById("file-list");
10
+ const currentFileEl = document.getElementById("current-file");
11
+ const saveBtn = document.getElementById("save-btn");
12
+ const statusEl = document.getElementById("status");
13
+
14
+ const textarea = document.getElementById("editor");
15
+ const highlightContent = document.getElementById("highlight-content");
16
+ const lineNumbersInner = document.getElementById("line-numbers-inner");
17
+ const scrollArea = document.querySelector(".editor-scroll-area");
18
+ const lineNumbers = document.getElementById("line-numbers");
19
+
20
+ let currentPath = null;
21
+ let savedContent = "";
22
+ let currentLanguage = null; // Prism.languages.xxx
23
+ let currentLanguageName = null; // "ruby" | "c"
24
+
25
+ // 拡張子 -> Prism言語のマッピング
26
+ function languageFor(path) {
27
+ const ext = path.split(".").pop().toLowerCase();
28
+ switch (ext) {
29
+ case "rb":
30
+ return { grammar: Prism.languages.ruby, name: "ruby" };
31
+ case "c":
32
+ case "h":
33
+ return { grammar: Prism.languages.c, name: "c" };
34
+ default:
35
+ return { grammar: Prism.languages.plain || {}, name: "none" };
36
+ }
37
+ }
38
+
39
+ function iconFor(path) {
40
+ return path.split(".").pop().toLowerCase();
41
+ }
42
+
43
+ function setStatus(message, kind) {
44
+ statusEl.textContent = message;
45
+ statusEl.className = "status" + (kind ? " " + kind : "");
46
+ if (message) {
47
+ setTimeout(() => {
48
+ if (statusEl.textContent === message) {
49
+ statusEl.textContent = "";
50
+ statusEl.className = "status";
51
+ }
52
+ }, 2500);
53
+ }
54
+ }
55
+
56
+ // ---- ハイライト & 行番号の再描画 ----
57
+
58
+ function escapeHtml(text) {
59
+ return text
60
+ .replace(/&/g, "&amp;")
61
+ .replace(/</g, "&lt;")
62
+ .replace(/>/g, "&gt;");
63
+ }
64
+
65
+ function renderHighlight() {
66
+ const code = textarea.value;
67
+
68
+ if (currentLanguage && currentLanguageName !== "none") {
69
+ highlightContent.innerHTML = Prism.highlight(code, currentLanguage, currentLanguageName);
70
+ } else {
71
+ highlightContent.innerHTML = escapeHtml(code);
72
+ }
73
+
74
+ // textareaの末尾に改行がある場合、<pre>の高さがずれないよう調整用の改行を足す
75
+ if (code.endsWith("\n")) {
76
+ highlightContent.innerHTML += "\n";
77
+ }
78
+
79
+ renderLineNumbers(code);
80
+ }
81
+
82
+ function renderLineNumbers(code) {
83
+ const lineCount = code.length === 0 ? 1 : code.split("\n").length;
84
+ const currentCount = lineNumbersInner.childElementCount;
85
+
86
+ if (currentCount < lineCount) {
87
+ const fragment = document.createDocumentFragment();
88
+ for (let i = currentCount + 1; i <= lineCount; i++) {
89
+ const div = document.createElement("div");
90
+ div.textContent = String(i);
91
+ fragment.appendChild(div);
92
+ }
93
+ lineNumbersInner.appendChild(fragment);
94
+ } else if (currentCount > lineCount) {
95
+ for (let i = currentCount; i > lineCount; i--) {
96
+ lineNumbersInner.removeChild(lineNumbersInner.lastChild);
97
+ }
98
+ }
99
+ }
100
+
101
+ function syncScroll() {
102
+ // highlight-layer(<pre>)は#editor(<textarea>)と同じグリッドセルに重なっており、
103
+ // どちらも .editor-scroll-area のスクロールに自動で追従するため、JSでの同期は不要。
104
+ // 行番号だけは独立したカラムなので、ここでスクロール位置を合わせる。
105
+ lineNumbers.scrollTop = scrollArea.scrollTop;
106
+ }
107
+
108
+ function updateDirtyState() {
109
+ const dirty = textarea.value !== savedContent;
110
+ saveBtn.disabled = !currentPath || !dirty;
111
+ }
112
+
113
+ textarea.addEventListener("input", () => {
114
+ renderHighlight();
115
+ updateDirtyState();
116
+ });
117
+
118
+ scrollArea.addEventListener("scroll", syncScroll);
119
+
120
+ // Tabキーでインデント挿入(デフォルトのフォーカス移動を止める)
121
+ textarea.addEventListener("keydown", (e) => {
122
+ if (e.key === "Tab") {
123
+ e.preventDefault();
124
+ const start = textarea.selectionStart;
125
+ const end = textarea.selectionEnd;
126
+ textarea.value = textarea.value.slice(0, start) + " " + textarea.value.slice(end);
127
+ textarea.selectionStart = textarea.selectionEnd = start + 2;
128
+ renderHighlight();
129
+ updateDirtyState();
130
+ }
131
+ });
132
+
133
+ // ---- ファイル一覧・読み込み・保存 ----
134
+
135
+ async function loadFileList() {
136
+ try {
137
+ const res = await fetch("/api/files");
138
+ if (!res.ok) throw new Error("failed to load file list");
139
+ const files = await res.json();
140
+
141
+ fileListEl.innerHTML = "";
142
+
143
+ if (files.length === 0) {
144
+ const li = document.createElement("li");
145
+ li.className = "empty";
146
+ li.textContent = "編集可能なファイルがありません";
147
+ fileListEl.appendChild(li);
148
+ return;
149
+ }
150
+
151
+ for (const path of files) {
152
+ const li = document.createElement("li");
153
+ li.dataset.path = path;
154
+
155
+ const icon = document.createElement("span");
156
+ icon.className = `file-icon ${iconFor(path)}`;
157
+ icon.textContent = iconFor(path).slice(0, 1).toUpperCase();
158
+
159
+ const label = document.createElement("span");
160
+ label.textContent = path;
161
+
162
+ li.appendChild(icon);
163
+ li.appendChild(label);
164
+ li.addEventListener("click", () => openFile(path));
165
+
166
+ fileListEl.appendChild(li);
167
+ }
168
+ } catch (err) {
169
+ fileListEl.innerHTML = "";
170
+ const li = document.createElement("li");
171
+ li.className = "empty";
172
+ li.textContent = "ファイル一覧の取得に失敗しました";
173
+ fileListEl.appendChild(li);
174
+ console.error(err);
175
+ }
176
+ }
177
+
178
+ async function openFile(path) {
179
+ try {
180
+ setStatus("読み込み中...", "");
181
+ const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
182
+ if (!res.ok) {
183
+ const err = await res.json();
184
+ throw new Error(err.error || "failed to load file");
185
+ }
186
+ const data = await res.json();
187
+
188
+ currentPath = data.path;
189
+ savedContent = data.content;
190
+
191
+ const lang = languageFor(data.path);
192
+ currentLanguage = lang.grammar;
193
+ currentLanguageName = lang.name;
194
+
195
+ textarea.value = data.content;
196
+ textarea.disabled = false;
197
+ renderHighlight();
198
+
199
+ currentFileEl.textContent = data.path;
200
+ updateDirtyState();
201
+ setStatus("", "");
202
+
203
+ for (const li of fileListEl.querySelectorAll("li")) {
204
+ li.classList.toggle("active", li.dataset.path === path);
205
+ }
206
+
207
+ textarea.focus();
208
+ } catch (err) {
209
+ setStatus("読み込みエラー", "error");
210
+ console.error(err);
211
+ }
212
+ }
213
+
214
+ async function saveFile() {
215
+ if (!currentPath) return;
216
+
217
+ const content = textarea.value;
218
+
219
+ try {
220
+ saveBtn.disabled = true;
221
+ setStatus("保存中...", "");
222
+
223
+ const res = await fetch("/api/file", {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ body: JSON.stringify({ path: currentPath, content }),
227
+ });
228
+
229
+ if (!res.ok) {
230
+ const err = await res.json();
231
+ throw new Error(err.error || "failed to save file");
232
+ }
233
+
234
+ savedContent = content;
235
+ setStatus("保存しました", "ok");
236
+ updateDirtyState();
237
+ } catch (err) {
238
+ setStatus("保存に失敗しました", "error");
239
+ saveBtn.disabled = false;
240
+ console.error(err);
241
+ }
242
+ }
243
+
244
+ saveBtn.addEventListener("click", saveFile);
245
+
246
+ // Ctrl/Cmd + S で保存
247
+ window.addEventListener("keydown", (e) => {
248
+ const isSaveShortcut = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s";
249
+ if (isSaveShortcut) {
250
+ e.preventDefault();
251
+ saveFile();
252
+ }
253
+ });
254
+
255
+ renderLineNumbers("");
256
+ loadFileList();
257
+ })();
@@ -0,0 +1,94 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>PicoRuby Editor (Funicular)</title>
7
+
8
+ <!-- スタイルは web/public/css/style.css をそのまま流用する -->
9
+ <link rel="stylesheet" href="/css/style.css">
10
+
11
+ <!--
12
+ Prism.js (シンタックスハイライト)
13
+ CDNのESM importはバージョン解決が不安定なので、
14
+ バージョン固定の <script> タグで読み込む方針。
15
+ -->
16
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-core.min.js"></script>
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-clike.min.js"></script>
18
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-c.min.js"></script>
19
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-ruby.min.js"></script>
20
+
21
+ <!--
22
+ ESP Web Tools (https://esphome.github.io/esp-web-tools/)。
23
+ <esp-web-install-button> カスタム要素を登録する。ブラウザのWeb Serial API経由で
24
+ ESP32にファームウェアを書き込む処理はすべてこのライブラリに任せている
25
+ (サーバ側は /api/firmware/manifest.json でマニフェストを返すだけ)。
26
+ バージョン固定のCDN読み込み方針は他のライブラリと同様。
27
+ -->
28
+ <script type="module" src="https://cdn.jsdelivr.net/npm/esp-web-tools@10.4.0/dist/web/install-button.js"></script>
29
+
30
+ <!--
31
+ xterm.js (ターミナル画面の描画)。
32
+ PicoRuby Web Terminal (picoruby.org/terminal) と同じくWeb Serial API経由で
33
+ 実機のUSBシリアルに直結し、ここに表示する。バージョン固定のCDN読み込み方針は
34
+ 他のライブラリと同様。
35
+ -->
36
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
37
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/lib/xterm.js"></script>
38
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.js"></script>
39
+
40
+ <!--
41
+ Ruby(Funicular)側から呼ぶための薄いJSラッパ。
42
+ Prismの細かい作法(文法オブジェクトの取り出し、末尾改行の補正)はここに閉じ込めて、
43
+ Ruby側は funicularHighlight(code, lang) だけ知っていればよい状態にする。
44
+ -->
45
+ <script>
46
+ window.funicularHighlight = function (code, lang) {
47
+ var grammar = Prism.languages[lang] || Prism.languages.clike;
48
+ // 末尾が改行だと最終行の高さが潰れてキャレットとズレるため空白を足す
49
+ var source = /\n$/.test(code) ? code + ' ' : code;
50
+ return Prism.highlight(source, grammar, lang);
51
+ };
52
+
53
+ // サイドバー(ファイルツリー)の自前コンテキストメニューとブラウザ標準の
54
+ // 右クリックメニューが同時に出てしまう問題への対処。
55
+ // tree_view.rb / editor_app.rb 側でもFunicularのDSL(oncontextmenu:)経由で
56
+ // event.preventDefaultを呼んでいるが、Funicularの合成イベントはPicoRuby.wasmを
57
+ // 経由する分、ブラウザが「このcontextmenuイベントのデフォルト動作をまだ
58
+ // キャンセルできる」タイミングに間に合わないことがある(実機でのユーザー確認で
59
+ // 発覚)。preventDefaultはネイティブのJSイベントハンドラ内で同期的に呼ぶ必要が
60
+ // あるため、ここだけはPicoRuby/Funicularのブリッジを経由しない素のJSで
61
+ // 直接止める(funicularHighlightと同じ「確実性が要る部分はプレーンJSに
62
+ // 任せる」方針)。サイドバーの外(エディタのtextarea等)は標準メニューを
63
+ // 残したいので、.sidebar配下かどうかをclosestで判定してそこだけ抑止する。
64
+ document.addEventListener('contextmenu', function (event) {
65
+ if (event.target.closest('.sidebar')) {
66
+ event.preventDefault();
67
+ }
68
+ });
69
+ </script>
70
+ </head>
71
+ <body>
72
+ <div id="app"><!-- Funicular がここにマウントされる --></div>
73
+
74
+ <!--
75
+ Ruby ソースは読み込み順がそのまま依存順になる。
76
+ 子コンポーネント → ルートコンポーネント → 起動 の順に並べること。
77
+ -->
78
+ <script type="text/ruby" src="/ruby/components/tree_view.rb"></script>
79
+ <script type="text/ruby" src="/ruby/components/toolbar.rb"></script>
80
+ <script type="text/ruby" src="/ruby/components/project_settings_dialog.rb"></script>
81
+ <script type="text/ruby" src="/ruby/components/prompt_dialog.rb"></script>
82
+ <script type="text/ruby" src="/ruby/components/directory_browser_dialog.rb"></script>
83
+ <script type="text/ruby" src="/ruby/components/log_panel.rb"></script>
84
+ <script type="text/ruby" src="/ruby/components/menu_bar.rb"></script>
85
+ <script type="text/ruby" src="/ruby/components/editor_app.rb"></script>
86
+ <script type="text/ruby" src="/ruby/main.rb"></script>
87
+
88
+ <!--
89
+ PicoRuby.wasm 本体。Funicular は同梱されている。
90
+ text/ruby スクリプトを走査する側なので、必ず最後に置く。
91
+ -->
92
+ <script src="https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@4.0.2/dist/init.iife.js"></script>
93
+ </body>
94
+ </html>
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: picoride
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - youchan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sinatra
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: puma
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rackup
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: perfect_toml
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: A Integrated develop emvironment for PicoRuby ESP32
69
+ email:
70
+ - youchan@gmail.com
71
+ executables:
72
+ - picoride
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".claude/launch.json"
77
+ - CHANGELOG.md
78
+ - CLAUDE.md
79
+ - CODE_OF_CONDUCT.md
80
+ - Dockerfile
81
+ - LICENSE.txt
82
+ - README.md
83
+ - Rakefile
84
+ - docker/esp32/Dockerfile
85
+ - docker/pico/Dockerfile
86
+ - exe/picoride
87
+ - lib/picoride.rb
88
+ - lib/picoride/background_job.rb
89
+ - lib/picoride/project_store.rb
90
+ - lib/picoride/r2p2_state.rb
91
+ - lib/picoride/server.rb
92
+ - lib/picoride/version.rb
93
+ - sig/picoruby/esp32/ide.rbs
94
+ - web/funicular/README.md
95
+ - web/funicular/ruby/components/directory_browser_dialog.rb
96
+ - web/funicular/ruby/components/editor_app.rb
97
+ - web/funicular/ruby/components/log_panel.rb
98
+ - web/funicular/ruby/components/menu_bar.rb
99
+ - web/funicular/ruby/components/project_settings_dialog.rb
100
+ - web/funicular/ruby/components/prompt_dialog.rb
101
+ - web/funicular/ruby/components/toolbar.rb
102
+ - web/funicular/ruby/components/tree_view.rb
103
+ - web/funicular/ruby/main.rb
104
+ - web/public/css/style.css
105
+ - web/public/js/editor.js
106
+ - web/views/index.erb
107
+ homepage: https://github.com/youchan/picoride
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ allowed_push_host: https://rubygems.org
112
+ homepage_uri: https://github.com/youchan/picoride
113
+ source_code_uri: https://github.com/youchan/picoride
114
+ changelog_uri: https://github.com/youchan/picoride/CHANGELOG.md
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: 3.2.0
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 4.0.6
130
+ specification_version: 4
131
+ summary: PicoRuby ESP32 IDE
132
+ test_files: []