chocomint 1.0.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.
Files changed (158) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +224 -0
  4. data/THIRD_PARTY_LICENSES.md +45 -0
  5. data/bin/chocomint +37 -0
  6. data/bin/chocomint-console-worker +35 -0
  7. data/bin/chocomint-server +43 -0
  8. data/config/config.yml +37 -0
  9. data/lib/chocomint/config.rb +74 -0
  10. data/lib/chocomint/console/conpty.rb +296 -0
  11. data/lib/chocomint/console/server.rb +164 -0
  12. data/lib/chocomint/console/worker.rb +88 -0
  13. data/lib/chocomint/console/worker_process.rb +254 -0
  14. data/lib/chocomint/edit_git_handlers.rb +397 -0
  15. data/lib/chocomint/edit_handlers.rb +696 -0
  16. data/lib/chocomint/edit_html.rb +9 -0
  17. data/lib/chocomint/errors.rb +18 -0
  18. data/lib/chocomint/factory.rb +145 -0
  19. data/lib/chocomint/llm/base_client.rb +113 -0
  20. data/lib/chocomint/llm/chat_client.rb +206 -0
  21. data/lib/chocomint/llm/primary_client.rb +320 -0
  22. data/lib/chocomint/llm/verifier_client.rb +127 -0
  23. data/lib/chocomint/logger/sqlite_logger.rb +245 -0
  24. data/lib/chocomint/orchestrator.rb +175 -0
  25. data/lib/chocomint/planner.rb +241 -0
  26. data/lib/chocomint/security/path_guard.rb +84 -0
  27. data/lib/chocomint/server.rb +863 -0
  28. data/lib/chocomint/tools/append_file.rb +50 -0
  29. data/lib/chocomint/tools/base.rb +88 -0
  30. data/lib/chocomint/tools/delete_file.rb +42 -0
  31. data/lib/chocomint/tools/edit.rb +65 -0
  32. data/lib/chocomint/tools/fetch_url.rb +156 -0
  33. data/lib/chocomint/tools/file_info.rb +54 -0
  34. data/lib/chocomint/tools/glob.rb +58 -0
  35. data/lib/chocomint/tools/grep.rb +78 -0
  36. data/lib/chocomint/tools/list_dir.rb +41 -0
  37. data/lib/chocomint/tools/ls.rb +51 -0
  38. data/lib/chocomint/tools/make_dir.rb +44 -0
  39. data/lib/chocomint/tools/read_file.rb +45 -0
  40. data/lib/chocomint/tools/registry.rb +29 -0
  41. data/lib/chocomint/tools/run_command.rb +96 -0
  42. data/lib/chocomint/tools/shell.rb +169 -0
  43. data/lib/chocomint/tools/write_file.rb +54 -0
  44. data/lib/chocomint/validator/machine_validator.rb +65 -0
  45. data/lib/chocomint/validator/semantic_validator.rb +52 -0
  46. data/lib/chocomint.rb +51 -0
  47. data/public/edit/edit.css +414 -0
  48. data/public/edit/edit.js +2137 -0
  49. data/public/edit/index.html +251 -0
  50. data/public/vendor/monaco/LICENSE.txt +23 -0
  51. data/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. data/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. data/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. data/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. data/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. data/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. data/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. data/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. data/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. data/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. data/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. data/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. data/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. data/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. data/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. data/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. data/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. data/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. data/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. data/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. data/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. data/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. data/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. data/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. data/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. data/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. data/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. data/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. data/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. data/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. data/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. data/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. data/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. data/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. data/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. data/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. data/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. data/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. data/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. data/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. data/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. data/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. data/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. data/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. data/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. data/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. data/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. data/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. data/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. data/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. data/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. data/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. data/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. data/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. data/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. data/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. data/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. data/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. data/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. data/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. data/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. data/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. data/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. data/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. data/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. data/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. data/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. data/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. data/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. data/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. data/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. data/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. data/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. data/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. data/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. data/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. data/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. data/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. data/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. data/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. data/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. data/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. data/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. data/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. data/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. data/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. data/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. data/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. data/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. data/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. data/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. data/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. data/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. data/public/vendor/monaco/vs/loader.js +11 -0
  145. data/public/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. data/public/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. data/public/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. data/public/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. data/public/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. data/public/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. data/public/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. data/public/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. data/public/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. data/public/vendor/xterm/LICENSE +25 -0
  155. data/public/vendor/xterm/addon-fit.js +2 -0
  156. data/public/vendor/xterm/xterm.css +218 -0
  157. data/public/vendor/xterm/xterm.js +2 -0
  158. metadata +318 -0
@@ -0,0 +1,2137 @@
1
+ let currentPath = null; // アクティブなタブのパス (未選択時 null)。既存コードとの互換のため維持する。
2
+ // 新規作成の基準ディレクトリ。ファイル選択時はその親、ディレクトリクリック時はそのディレクトリ自体 ('' はルート)。
3
+ let selectedDir = '';
4
+ let editor = null;
5
+ let isDirty = false; // 現在開いているファイルに未保存の変更があるか
6
+ let suppressChangeEvent = false; // setValue によるプログラム的な変更中は onDidChangeModelContent を無視する
7
+ let autoSaveTimer = null;
8
+ const AUTO_SAVE_DELAY_MS = 1500;
9
+
10
+ // ---- タブ管理 ----
11
+ // Monaco エディタは 1 インスタンスだけ使い回し、開いているファイルごとに ITextModel を
12
+ // 1 つ保持する。タブ切り替えは editor.setModel() でモデルを差し替えるだけ。モデルには
13
+ // 内容・undo 履歴・カーソル/スクロール位置が紐づくため、切り替えても状態が保たれる。
14
+ // tab = { path, model, viewState, dirty, binary }
15
+ const openTabs = [];
16
+ function findTab(path) { return openTabs.find(t => t.path === path); }
17
+
18
+ // 自動保存の ON/OFF。localStorage に永続化し、リロード後も維持する (既定: ON)。
19
+ const AUTOSAVE_PREF_KEY = 'chocomint.edit.autosave';
20
+ let autoSaveEnabled = localStorage.getItem(AUTOSAVE_PREF_KEY) !== 'off';
21
+
22
+ // 保存状態インジケーターの表示を切り替える。
23
+ // state: '' (非表示) | 'saving' | 'saved' | 'error'
24
+ let savedFlashTimer = null;
25
+ function setSaveStatus(state, message) {
26
+ const el = document.getElementById('save-status');
27
+ if (!el) return;
28
+ if (savedFlashTimer) { clearTimeout(savedFlashTimer); savedFlashTimer = null; }
29
+ el.className = 'save-status' + (state ? ' ' + state : '');
30
+ const label = message || {
31
+ saving: '保存中…', saved: '✓ 保存しました', error: '⚠ 保存に失敗'
32
+ }[state] || '';
33
+ el.textContent = label;
34
+ // 「保存しました」は主張しすぎないよう数秒で自然に消す。
35
+ if (state === 'saved') {
36
+ savedFlashTimer = setTimeout(() => {
37
+ if (!isDirty) { el.className = 'save-status'; el.textContent = ''; }
38
+ }, 2000);
39
+ }
40
+ }
41
+
42
+ // dirty 状態を反映する。表示はアクティブタブの●で行うので、ここではフラグと
43
+ // タブ状態の更新だけを担う。
44
+ function setDirty(v) {
45
+ isDirty = v;
46
+ const tab = currentPath ? findTab(currentPath) : null;
47
+ if (tab) { tab.dirty = v; renderEditorTabs(); }
48
+ // 未保存に変わったら、直前の「保存しました」表示は用済みなので消す。
49
+ if (v) setSaveStatus('');
50
+ }
51
+
52
+ // 現在のファイル名/パスの表示先はエディタのタブなので、ヘッダー側では何もしない。
53
+ // (呼び出し側の互換のために関数は残す。)
54
+ function setPathLabel(_text) {}
55
+
56
+ // 編集のたびに呼ばれ、一定時間操作が止まったら自動保存する (debounce)。
57
+ // 自動保存が OFF のときは何もしない (未保存のまま。手動 Ctrl+S で保存)。
58
+ function scheduleAutoSave() {
59
+ if (!autoSaveEnabled) return;
60
+ if (autoSaveTimer) clearTimeout(autoSaveTimer);
61
+ autoSaveTimer = setTimeout(() => { autoSaveTimer = null; saveCurrent({ auto: true }); }, AUTO_SAVE_DELAY_MS);
62
+ }
63
+
64
+ // 自動保存の ON/OFF を切り替える (メニューのチェック表示と localStorage を更新)。
65
+ // ON に切り替えたとき未保存の変更があれば、すぐ保存をスケジュールする。
66
+ function setAutoSave(enabled) {
67
+ autoSaveEnabled = enabled;
68
+ localStorage.setItem(AUTOSAVE_PREF_KEY, enabled ? 'on' : 'off');
69
+ const btn = document.getElementById('btn-autosave');
70
+ if (btn) {
71
+ btn.setAttribute('aria-checked', String(enabled));
72
+ btn.classList.toggle('checked', enabled);
73
+ }
74
+ if (enabled && isDirty) scheduleAutoSave();
75
+ }
76
+
77
+ // setValue でエディタ内容をプログラム的に書き換える (dirty イベントを発生させない)。
78
+ function setEditorValue(value) {
79
+ suppressChangeEvent = true;
80
+ editor.setValue(value);
81
+ suppressChangeEvent = false;
82
+ }
83
+
84
+ // ---- Monaco 読み込み (AMD loader) ----
85
+ const loaderScript = document.createElement('script');
86
+ loaderScript.src = '/edit/assets/monaco/vs/loader.js';
87
+ loaderScript.onload = () => {
88
+ require.config({ paths: { vs: '/edit/assets/monaco/vs' } });
89
+ require(['vs/editor/editor.main'], () => {
90
+ editor = monaco.editor.create(document.getElementById('editor'), {
91
+ // model は開いたファイルごとに差し替える。初期状態はタブ無し (プレースホルダー表示)。
92
+ model: null, theme: 'vs',
93
+ automaticLayout: true, minimap: { enabled: false }
94
+ });
95
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveCurrent);
96
+ editor.onDidChangeModelContent(() => {
97
+ if (suppressChangeEvent) return; // setValue によるプログラム的な変更は無視する
98
+ setDirty(true);
99
+ scheduleAutoSave();
100
+ });
101
+ document.getElementById('app').classList.add('no-tabs');
102
+ });
103
+ };
104
+ document.head.appendChild(loaderScript);
105
+
106
+ // ---- 言語推定 ----
107
+ function langOf(path) {
108
+ const ext = (path.split('.').pop() || '').toLowerCase();
109
+ const map = { rb:'ruby', js:'javascript', ts:'typescript', py:'python',
110
+ json:'json', md:'markdown', html:'html', css:'css', yml:'yaml', yaml:'yaml',
111
+ sh:'shell', txt:'plaintext', c:'c', cpp:'cpp', go:'go', rs:'rust' };
112
+ return map[ext] || 'plaintext';
113
+ }
114
+
115
+ // ---- ファイル一覧 (ディレクトリを入れ子ツリーで表示) ----
116
+ let openDirs = new Set(); // 展開中ディレクトリの相対パス (再描画で状態維持)
117
+
118
+ // Ctrl/Shift 修飾クリックによる複数選択 (ファイル/ディレクトリ問わず相対パスを保持)。
119
+ // 通常クリックでは常に空になり、単一選択 (currentPath / selectedDir) のみで動作する。
120
+ let selectedPaths = new Set();
121
+ // Shift 範囲選択の起点 (直前に通常/Ctrl/Shift クリックした行)。
122
+ let lastClickedPath = null;
123
+
124
+ // フラットな相対パス配列を { dirs, files } のツリーに組み立てる。
125
+ // emptyDirPaths: 中身が空 (または空ディレクトリのみ) のディレクトリの相対パス。
126
+ // ファイルパスからは辿り着けないため、明示的にノードを掘る。
127
+ function buildTree(paths, emptyDirPaths) {
128
+ const root = { dirs: new Map(), files: [] };
129
+ const ensureDir = (path) => {
130
+ const parts = path.split('/');
131
+ let node = root;
132
+ parts.forEach(part => {
133
+ if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
134
+ node = node.dirs.get(part);
135
+ });
136
+ };
137
+ paths.forEach(p => {
138
+ const parts = p.split('/');
139
+ let node = root;
140
+ parts.forEach((part, i) => {
141
+ if (i === parts.length - 1) {
142
+ node.files.push({ name: part, path: p });
143
+ } else {
144
+ if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
145
+ node = node.dirs.get(part);
146
+ }
147
+ });
148
+ });
149
+ (emptyDirPaths || []).forEach(ensureDir);
150
+ return root;
151
+ }
152
+
153
+ // 拡張子ごとのファイルアイコン (絵文字)。未知の拡張子は汎用の📄。
154
+ const FILE_ICONS = {
155
+ py: '🐍',
156
+ rb: '💎',
157
+ js: '📜',
158
+ mjs: '📜',
159
+ cjs: '📜',
160
+ ts: '📘',
161
+ tsx: '📘',
162
+ jsx: '📜',
163
+ json: '🗂️',
164
+ html: '🌐',
165
+ htm: '🌐',
166
+ css: '🎨',
167
+ scss: '🎨',
168
+ sass: '🎨',
169
+ md: '📝',
170
+ markdown: '📝',
171
+ txt: '📄',
172
+ yml: '⚙️',
173
+ yaml: '⚙️',
174
+ toml: '⚙️',
175
+ ini: '⚙️',
176
+ env: '⚙️',
177
+ sh: '🐚',
178
+ bash: '🐚',
179
+ zsh: '🐚',
180
+ ps1: '💻',
181
+ bat: '💻',
182
+ cmd: '💻',
183
+ go: '🐹',
184
+ rs: '🦀',
185
+ java: '☕',
186
+ c: '🔧',
187
+ h: '🔧',
188
+ cpp: '🔧',
189
+ hpp: '🔧',
190
+ cs: '🔷',
191
+ php: '🐘',
192
+ sql: '🗃️',
193
+ png: '🖼️',
194
+ jpg: '🖼️',
195
+ jpeg: '🖼️',
196
+ gif: '🖼️',
197
+ svg: '🖼️',
198
+ ico: '🖼️',
199
+ pdf: '📕',
200
+ zip: '📦',
201
+ gz: '📦',
202
+ tar: '📦',
203
+ lock: '🔒',
204
+ gitignore: '🚫',
205
+ dockerfile: '🐳',
206
+ };
207
+
208
+ function fileIcon(name) {
209
+ const lower = name.toLowerCase();
210
+ if (lower === 'dockerfile') return FILE_ICONS.dockerfile;
211
+ if (lower === '.gitignore') return FILE_ICONS.gitignore;
212
+ const dot = lower.lastIndexOf('.');
213
+ if (dot === -1) return '📄';
214
+ const ext = lower.slice(dot + 1);
215
+ return FILE_ICONS[ext] || '📄';
216
+ }
217
+
218
+ // ツリーノードを <ul> にレンダリング。prefix は現ディレクトリの相対パス。
219
+ function renderTree(node, prefix) {
220
+ const ul = document.createElement('ul');
221
+ // ディレクトリを名前順に先に、その後ファイルを名前順に並べる。
222
+ [...node.dirs.keys()].sort().forEach(name => {
223
+ const dirPath = prefix ? prefix + '/' + name : name;
224
+ const li = document.createElement('li');
225
+ li.className = 'dir';
226
+ li.dataset.path = dirPath;
227
+ if (openDirs.has(dirPath)) li.classList.add('open');
228
+ const row = document.createElement('div');
229
+ row.className = 'row';
230
+ row.innerHTML = '<span class="twist">▶</span><span class="icon">📁</span>';
231
+ row.appendChild(document.createTextNode(name));
232
+ row.onclick = (e) => {
233
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, dirPath); return; }
234
+ if (openDirs.has(dirPath)) openDirs.delete(dirPath);
235
+ else openDirs.add(dirPath);
236
+ li.classList.toggle('open');
237
+ selectDir(dirPath);
238
+ };
239
+ row.oncontextmenu = (e) => showContextMenu(e, dirPath, true);
240
+ li.appendChild(row);
241
+ li.appendChild(renderTree(node.dirs.get(name), dirPath));
242
+ ul.appendChild(li);
243
+ });
244
+ node.files.sort((a, b) => a.name.localeCompare(b.name)).forEach(f => {
245
+ const li = document.createElement('li');
246
+ li.className = 'file';
247
+ li.dataset.path = f.path;
248
+ const row = document.createElement('div');
249
+ row.className = 'row';
250
+ row.innerHTML = `<span class="twist"></span><span class="icon">${fileIcon(f.name)}</span>`;
251
+ row.appendChild(document.createTextNode(f.name));
252
+ row.onclick = (e) => {
253
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, f.path); return; }
254
+ openFile(f.path, li);
255
+ };
256
+ row.oncontextmenu = (e) => showContextMenu(e, f.path, false);
257
+ li.appendChild(row);
258
+ ul.appendChild(li);
259
+ });
260
+ return ul;
261
+ }
262
+
263
+ // ディレクトリを「新規作成の基準」として選択状態にする (ファイル選択のハイライトは解除する)。
264
+ function selectDir(dirPath) {
265
+ selectedDir = dirPath;
266
+ clearMultiSelection();
267
+ lastClickedPath = dirPath;
268
+ document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
269
+ document.querySelectorAll('#file-list li.dir.selected').forEach(x => x.classList.remove('selected'));
270
+ const container = document.getElementById('file-list');
271
+ const li = dirPath
272
+ ? container.querySelector('li.dir[data-path="' + cssEscape(dirPath) + '"]')
273
+ : null;
274
+ if (li) li.classList.add('selected');
275
+ }
276
+
277
+ // ---- 複数選択 (Ctrl/Shift クリック) ----
278
+
279
+ // 現在ツリーに描画されている行を DOM 表示順 (= 見た目の上から下) でフラットに列挙する。
280
+ // Shift 範囲選択の範囲決定に使う。折りたたまれた子は非表示だが DOM 上には存在するため、
281
+ // 表示中の行だけに絞る (offsetParent で非表示判定)。
282
+ function visibleTreeRows() {
283
+ return [...document.querySelectorAll('#file-list li[data-path]')]
284
+ .filter(li => li.offsetParent !== null)
285
+ .map(li => ({ path: li.dataset.path, isDir: li.classList.contains('dir') }));
286
+ }
287
+
288
+ function clearMultiSelection() {
289
+ if (selectedPaths.size === 0) return;
290
+ selectedPaths.clear();
291
+ document.querySelectorAll('#file-list li.multi-selected').forEach(x => x.classList.remove('multi-selected'));
292
+ }
293
+
294
+ function renderMultiSelection() {
295
+ const container = document.getElementById('file-list');
296
+ container.querySelectorAll('li.multi-selected').forEach(x => x.classList.remove('multi-selected'));
297
+ selectedPaths.forEach(p => {
298
+ const li = container.querySelector('li[data-path="' + cssEscape(p) + '"]');
299
+ if (li) li.classList.add('multi-selected');
300
+ });
301
+ }
302
+
303
+ // Ctrl(+Shift)/Shift 修飾クリックの複数選択処理。通常クリック (開く/展開) は呼び出し元で処理済み。
304
+ function handleMultiSelectClick(e, path) {
305
+ // 複数選択中は「単一選択のハイライト」と混同しないよう解除する
306
+ // (currentPath/selectedDir 自体は新規作成の基準として保持し続ける)。
307
+ document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
308
+ document.querySelectorAll('#file-list li.dir.selected').forEach(x => x.classList.remove('selected'));
309
+
310
+ if (e.shiftKey && lastClickedPath) {
311
+ const rows = visibleTreeRows();
312
+ const from = rows.findIndex(r => r.path === lastClickedPath);
313
+ const to = rows.findIndex(r => r.path === path);
314
+ if (from !== -1 && to !== -1) {
315
+ const [lo, hi] = from < to ? [from, to] : [to, from];
316
+ for (let i = lo; i <= hi; i++) selectedPaths.add(rows[i].path);
317
+ } else {
318
+ selectedPaths.add(path);
319
+ }
320
+ } else {
321
+ // Ctrl(+クリック): 個別トグル。
322
+ if (selectedPaths.has(path)) selectedPaths.delete(path);
323
+ else selectedPaths.add(path);
324
+ lastClickedPath = path;
325
+ }
326
+ renderMultiSelection();
327
+ }
328
+
329
+ // 直近に描画したファイル一覧の署名 (変化検知用)。ポーリングで無駄な再描画を避ける。
330
+ let lastFilesSig = null;
331
+ // workspace ルートの絶対パス (「絶対パスをコピー」用)。/edit/files のたびに更新する。
332
+ let absRoot = null;
333
+
334
+ // force=true なら署名が同じでも必ず再描画する (初回・手動更新用)。
335
+ async function loadFiles(force) {
336
+ let data;
337
+ try {
338
+ const r = await fetch('/edit/files');
339
+ if (!r.ok) return;
340
+ data = await r.json();
341
+ } catch (e) { return; } // ポーリング中の一時的な失敗は無視する。
342
+
343
+ absRoot = data.abs_root || null;
344
+ const files = data.files || [];
345
+ const emptyDirs = data.dirs || [];
346
+ const sig = files.join('\n') + '\n\0\n' + emptyDirs.join('\n');
347
+ if (!force && sig === lastFilesSig) return; // 変化なし: 何もしない。
348
+ lastFilesSig = sig;
349
+
350
+ const container = document.getElementById('file-list');
351
+ container.innerHTML = '';
352
+ const tree = buildTree(files, emptyDirs);
353
+ container.appendChild(renderTree(tree, ''));
354
+ // 空白部の右クリックは workspace ルートへの新規作成メニューを出す。
355
+ container.oncontextmenu = (e) => {
356
+ if (e.target === container) showContextMenu(e, '', true);
357
+ };
358
+ // 再描画後も選択中ファイル/ディレクトリのハイライトを維持する。
359
+ if (currentPath) {
360
+ const active = container.querySelector('li.file[data-path="' + cssEscape(currentPath) + '"]');
361
+ if (active) active.classList.add('active');
362
+ } else if (selectedDir) {
363
+ const selected = container.querySelector('li.dir[data-path="' + cssEscape(selectedDir) + '"]');
364
+ if (selected) selected.classList.add('selected');
365
+ }
366
+ // 消えたパスは複数選択からも取り除いてから、残りのハイライトを復元する。
367
+ const stillExists = (p) => container.querySelector('li[data-path="' + cssEscape(p) + '"]') != null;
368
+ [...selectedPaths].forEach(p => { if (!stillExists(p)) selectedPaths.delete(p); });
369
+ renderMultiSelection();
370
+ }
371
+
372
+ // 作業ディレクトリの内容を定期的に監視し、変化があれば一覧を更新する。
373
+ const FILES_POLL_MS = 3000;
374
+ setInterval(() => loadFiles(false), FILES_POLL_MS);
375
+
376
+ // querySelector 用に属性値をエスケープ (パスに特殊文字が入っても安全に)。
377
+ function cssEscape(s) {
378
+ return (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/["\\]/g, '\\$&');
379
+ }
380
+
381
+ // ---- 右クリックのコンテキストメニュー ----
382
+ const ctxMenu = document.getElementById('ctx-menu');
383
+
384
+ function hideContextMenu() { ctxMenu.classList.remove('open'); }
385
+ document.addEventListener('click', hideContextMenu);
386
+ document.addEventListener('scroll', hideContextMenu, true);
387
+ window.addEventListener('blur', hideContextMenu);
388
+
389
+ // path: 対象の相対パス ('' は workspace ルート)。isDir: ディレクトリか。
390
+ function showContextMenu(e, path, isDir) {
391
+ e.preventDefault();
392
+ e.stopPropagation();
393
+ // 複数選択に含まれていない項目を右クリックしたときは、Explorer 等と同様その項目単独の選択に切り替える。
394
+ if (path !== '' && !selectedPaths.has(path)) clearMultiSelection();
395
+ // 新規作成は「対象がディレクトリならその中、ファイルなら同じ親」に作る。
396
+ const baseDir = isDir ? path : parentDir(path);
397
+
398
+ const items = [];
399
+ items.push({ label: '📄 新規ファイル…', fn: () => createEntry(baseDir, false) });
400
+ items.push({ label: '📁 新規ディレクトリ…', fn: () => createEntry(baseDir, true) });
401
+ if (path !== '') {
402
+ items.push({ sep: true });
403
+ // 右クリックした対象がすでに複数選択に含まれていれば選択全体をまとめて削除する
404
+ // (Explorer 等と同様、含まれていなければ単一選択扱いに切り替えてそれだけ削除する)。
405
+ const inMultiSelection = selectedPaths.has(path) && selectedPaths.size > 1;
406
+ if (!inMultiSelection) {
407
+ items.push({ label: '✏️ 名前を変更…', fn: () => renameEntry(path) });
408
+ items.push({ label: '📋 パスをコピー', fn: () => copyPath(path) });
409
+ items.push({ label: '📋 絶対パスをコピー', fn: () => copyPath(absRoot ? absRoot + '/' + path : path) });
410
+ items.push({ sep: true });
411
+ }
412
+ const deleteLabel = inMultiSelection ? `🗑 選択した ${selectedPaths.size} 件を削除` : '🗑 削除';
413
+ items.push({
414
+ label: deleteLabel, danger: true,
415
+ fn: () => inMultiSelection ? deleteSelectedPaths([...selectedPaths]) : deleteEntry(path, isDir)
416
+ });
417
+ }
418
+
419
+ ctxMenu.innerHTML = '';
420
+ items.forEach(it => {
421
+ if (it.sep) { const s = document.createElement('div'); s.className = 'sep'; ctxMenu.appendChild(s); return; }
422
+ const d = document.createElement('div');
423
+ d.className = 'item' + (it.danger ? ' danger' : '');
424
+ d.textContent = it.label;
425
+ d.onclick = (ev) => { ev.stopPropagation(); hideContextMenu(); it.fn(); };
426
+ ctxMenu.appendChild(d);
427
+ });
428
+
429
+ // 画面外にはみ出さない位置に表示する。
430
+ ctxMenu.classList.add('open');
431
+ const mw = ctxMenu.offsetWidth, mh = ctxMenu.offsetHeight;
432
+ const x = Math.min(e.clientX, window.innerWidth - mw - 4);
433
+ const y = Math.min(e.clientY, window.innerHeight - mh - 4);
434
+ ctxMenu.style.left = x + 'px';
435
+ ctxMenu.style.top = y + 'px';
436
+ }
437
+
438
+ function parentDir(path) {
439
+ const i = path.lastIndexOf('/');
440
+ return i < 0 ? '' : path.slice(0, i);
441
+ }
442
+ function joinPath(dir, name) { return dir ? dir + '/' + name : name; }
443
+
444
+ async function fsPost(url, body) {
445
+ const r = await fetch(url, {
446
+ method: 'POST', headers: { 'content-type': 'application/json' },
447
+ body: JSON.stringify(body)
448
+ });
449
+ const data = await r.json().catch(() => ({}));
450
+ if (!r.ok) throw new Error(data.error || (r.status + ' error'));
451
+ return data;
452
+ }
453
+
454
+ // 新規ファイル/ディレクトリ作成。dir は作成先ディレクトリ (相対)。
455
+ async function createEntry(dir, isDir) {
456
+ const name = await showPrompt(isDir ? '新規ディレクトリ' : '新規ファイル',
457
+ isDir ? 'ディレクトリ名' : 'ファイル名');
458
+ if (!name) return;
459
+ const path = joinPath(dir, name.trim());
460
+ try {
461
+ await fsPost(isDir ? '/edit/fs/mkdir' : '/edit/fs/touch', { path });
462
+ if (dir) openDirs.add(dir); // 作成先を開いて見せる
463
+ await loadFiles();
464
+ if (!isDir) openFileByPath(path); // 作ったファイルを開く
465
+ } catch (err) { alert(err.message); }
466
+ }
467
+
468
+ async function renameEntry(path) {
469
+ const cur = path.split('/').pop();
470
+ const name = await showPrompt('名前を変更', '新しい名前', cur);
471
+ if (!name || name.trim() === cur) return;
472
+ const to = joinPath(parentDir(path), name.trim());
473
+ try {
474
+ await fsPost('/edit/fs/rename', { from: path, to });
475
+ // リネーム対象 (自身 or 配下) に該当する開きタブのパスを付け替える。
476
+ for (const tab of openTabs) {
477
+ if (tab.path === path) tab.path = to;
478
+ else if (tab.path.startsWith(path + '/')) tab.path = to + tab.path.slice(path.length);
479
+ }
480
+ if (currentPath === path) { currentPath = to; setPathLabel(to); }
481
+ else if (currentPath && currentPath.startsWith(path + '/')) {
482
+ currentPath = to + currentPath.slice(path.length); setPathLabel(currentPath);
483
+ }
484
+ if (selectedDir === path) selectedDir = to;
485
+ renderEditorTabs();
486
+ await loadFiles();
487
+ } catch (err) { alert(err.message); }
488
+ }
489
+
490
+ async function deleteEntry(path, isDir) {
491
+ const what = isDir ? 'ディレクトリ (中身ごと)' : 'ファイル';
492
+ const ok = await showConfirm('削除の確認', what + ' を削除しますか?\n' + path);
493
+ if (!ok) return;
494
+ try {
495
+ await fsPost('/edit/fs/delete', { path });
496
+ forgetDeletedPath(path);
497
+ await loadFiles();
498
+ } catch (err) { alert(err.message); }
499
+ }
500
+
501
+ // 削除されたパス (自身またはその配下) を参照しているタブと選択状態をクリアする。
502
+ function forgetDeletedPath(path) {
503
+ // 削除対象 (自身 or 配下) に該当する開きタブをすべて閉じる。
504
+ const affected = openTabs.filter(t => t.path === path || t.path.startsWith(path + '/'));
505
+ for (const tab of affected) removeTab(tab.path);
506
+ if (selectedDir && (selectedDir === path || selectedDir.startsWith(path + '/'))) {
507
+ selectedDir = '';
508
+ }
509
+ }
510
+
511
+ // 確認なしでタブを閉じてモデルを破棄する (削除/リネームに伴う内部操作用)。
512
+ // アクティブタブが消えた場合の後始末は closeTab と同様。
513
+ function removeTab(path) {
514
+ const idx = openTabs.findIndex(t => t.path === path);
515
+ if (idx === -1) return;
516
+ const tab = openTabs[idx];
517
+ const wasActive = currentPath === path;
518
+ if (wasActive && autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
519
+ openTabs.splice(idx, 1);
520
+ tab.model.dispose();
521
+ if (wasActive) {
522
+ currentPath = null;
523
+ const next = openTabs[idx] || openTabs[idx - 1];
524
+ if (next) {
525
+ activateEditorTab(next.path);
526
+ } else {
527
+ editor.setModel(null);
528
+ document.getElementById('app').classList.add('no-tabs');
529
+ setPathLabel('(ファイル未選択)');
530
+ setDirty(false);
531
+ setSaveStatus('');
532
+ document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
533
+ }
534
+ }
535
+ renderEditorTabs();
536
+ }
537
+
538
+ // 複数選択 (Ctrl/Shift クリックで集めたパス、または Delete キー押下時) を一括削除する。
539
+ // 親子関係にあるパスが混在していても、それぞれ個別に削除を試みて構わない
540
+ // (子が先に消えても親の rm_rf で結局まとめて消えるだけで、エラーにはならない)。
541
+ async function deleteSelectedPaths(paths) {
542
+ if (paths.length === 0) return;
543
+ const label = paths.length === 1 ? paths[0] : `${paths.length} 件`;
544
+ const ok = await showConfirm('削除の確認', `選択した ${label} を削除しますか?\n` + paths.join('\n'));
545
+ if (!ok) return;
546
+
547
+ const errors = [];
548
+ for (const path of paths) {
549
+ try {
550
+ await fsPost('/edit/fs/delete', { path });
551
+ forgetDeletedPath(path);
552
+ } catch (err) {
553
+ errors.push(path + ': ' + err.message);
554
+ }
555
+ }
556
+ clearMultiSelection();
557
+ await loadFiles();
558
+ if (errors.length > 0) alert('一部の削除に失敗しました:\n' + errors.join('\n'));
559
+ }
560
+
561
+ async function copyPath(path) {
562
+ try {
563
+ if (navigator.clipboard && navigator.clipboard.writeText) {
564
+ await navigator.clipboard.writeText(path);
565
+ } else {
566
+ // 非セキュアコンテキスト用フォールバック。
567
+ const ta = document.createElement('textarea');
568
+ ta.value = path; document.body.appendChild(ta); ta.select();
569
+ document.execCommand('copy'); ta.remove();
570
+ }
571
+ } catch (err) { alert('コピーに失敗: ' + err.message); }
572
+ }
573
+
574
+ // 相対パスから該当する li を探して開く (一覧再描画後の選択用)。
575
+ function openFileByPath(path) {
576
+ const li = document.querySelector('#file-list li.file[data-path="' + cssEscape(path) + '"]');
577
+ return openFile(path, li);
578
+ }
579
+
580
+ async function openFile(path, li) {
581
+ if (!editor) return;
582
+ // 既に開いているファイルなら、そのタブへ切り替えるだけ (再フェッチしない)。
583
+ if (findTab(path)) { activateEditorTab(path, li); return; }
584
+
585
+ const r = await fetch('/edit/file?path=' + encodeURIComponent(path));
586
+ const data = await r.json();
587
+ if (!r.ok) { alert(data.error || 'open failed'); return; }
588
+
589
+ // 新しいモデルを作ってタブに紐づける (以後このモデルが内容・undo 履歴を保持する)。
590
+ const binary = !!data.binary;
591
+ const content = binary
592
+ ? '(このファイルはバイナリのため表示・編集できません: ' + path + ')'
593
+ : (data.content || '');
594
+ const lang = binary ? 'plaintext' : langOf(path);
595
+ const model = monaco.editor.createModel(content, lang);
596
+ const tab = { path, model, viewState: null, dirty: false, binary };
597
+ openTabs.push(tab);
598
+ activateEditorTab(path, li);
599
+ }
600
+
601
+ // 指定パスのエディタタブをアクティブにする。Monaco のモデルを差し替え、ビュー状態を復元する。
602
+ // (ターミナルタブ用の activateTab とは別関数なので名前を分けている)
603
+ function activateEditorTab(path, li) {
604
+ const tab = findTab(path);
605
+ if (!tab || !editor) return;
606
+
607
+ // 別ファイルに切り替える前に、直前のファイルの保留中の自動保存があれば即座に確定し、
608
+ // 現在のビュー状態 (カーソル/スクロール位置) を退避しておく。
609
+ if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; if (isDirty) saveCurrent(); }
610
+ const prev = currentPath ? findTab(currentPath) : null;
611
+ if (prev && prev !== tab) prev.viewState = editor.saveViewState();
612
+
613
+ currentPath = path;
614
+ selectedDir = parentDir(path);
615
+ clearMultiSelection();
616
+ lastClickedPath = path;
617
+ setPathLabel(path);
618
+
619
+ document.getElementById('app').classList.remove('no-tabs');
620
+ suppressChangeEvent = true;
621
+ editor.setModel(tab.model);
622
+ suppressChangeEvent = false;
623
+ if (tab.viewState) editor.restoreViewState(tab.viewState);
624
+ editor.updateOptions({ readOnly: tab.binary });
625
+ editor.focus();
626
+
627
+ isDirty = tab.dirty;
628
+ setSaveStatus('');
629
+ renderEditorTabs();
630
+
631
+ // ファイル一覧側のハイライトを同期する (li が渡されなければパスから探す)。
632
+ if (!li) li = document.querySelector('#file-list li.file[data-path="' + cssEscape(path) + '"]');
633
+ document.querySelectorAll('#file-list li.file').forEach(x => x.classList.remove('active'));
634
+ document.querySelectorAll('#file-list li.dir.selected').forEach(x => x.classList.remove('selected'));
635
+ if (li) li.classList.add('active');
636
+ }
637
+
638
+ // タブを閉じる。未保存の変更があれば確認する。閉じた結果アクティブタブが消えたら
639
+ // 隣のタブへ切り替える (無ければ空状態へ)。
640
+ async function closeEditorTab(path) {
641
+ const tab = findTab(path);
642
+ if (!tab) return;
643
+ if (tab.dirty) {
644
+ const ok = await showConfirm('未保存の変更', tab.path + ' には未保存の変更があります。閉じますか?');
645
+ if (!ok) return;
646
+ }
647
+ removeTab(path);
648
+ }
649
+
650
+ // エディタのタブバーを再描画する。
651
+ function renderEditorTabs() {
652
+ const bar = document.getElementById('editor-tabs');
653
+ if (!bar) return;
654
+ bar.innerHTML = '';
655
+ for (const tab of openTabs) {
656
+ const name = tab.path.split('/').pop();
657
+ const el = document.createElement('div');
658
+ el.className = 'editor-tab' + (tab.path === currentPath ? ' active' : '') + (tab.dirty ? ' dirty' : '');
659
+ el.title = tab.path;
660
+ el.setAttribute('role', 'tab');
661
+
662
+ const dot = document.createElement('span');
663
+ dot.className = 'editor-tab-dot';
664
+ dot.textContent = '●';
665
+
666
+ const nameEl = document.createElement('span');
667
+ nameEl.className = 'editor-tab-name';
668
+ nameEl.textContent = name;
669
+
670
+ const close = document.createElement('span');
671
+ close.className = 'editor-tab-close';
672
+ close.textContent = '×';
673
+ close.title = '閉じる';
674
+ close.onclick = (e) => { e.stopPropagation(); closeEditorTab(tab.path); };
675
+
676
+ el.appendChild(nameEl);
677
+ el.appendChild(dot);
678
+ el.appendChild(close);
679
+ el.onclick = () => activateEditorTab(tab.path);
680
+ // 中クリックでも閉じる (ブラウザのタブ操作に倣う)。
681
+ el.onauxclick = (e) => { if (e.button === 1) { e.preventDefault(); closeEditorTab(tab.path); } };
682
+ bar.appendChild(el);
683
+ }
684
+ }
685
+
686
+ // ---- 保存 ----
687
+ // opts.auto=true は自動保存 (debounce 経由)。失敗しても alert で作業を止めず、
688
+ // ヘッダーのインジケーターにエラーを出すだけにする。手動保存 (Ctrl+S / メニュー) は
689
+ // 失敗を alert で明示する。
690
+ async function saveCurrent(opts) {
691
+ const auto = opts && opts.auto;
692
+ if (!editor) return;
693
+ // ファイル未選択で手動保存 (Ctrl+S / メニュー) されたときは、保存先を尋ねる
694
+ // ダイアログを開く (自動保存はそもそも未選択時は動かないので auto では出さない)。
695
+ if (!currentPath) { if (!auto) openSaveAsDialog(); return; }
696
+ // バイナリ表示中 (readOnly) は保存しない (プレースホルダで上書きしないため)。
697
+ if (editor.getRawOptions && editor.getRawOptions().readOnly) return;
698
+ if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
699
+ const path = currentPath;
700
+ const content = editor.getValue();
701
+ setSaveStatus('saving');
702
+ let r, data;
703
+ try {
704
+ r = await fetch('/edit/save', {
705
+ method: 'POST', headers: { 'content-type': 'application/json' },
706
+ body: JSON.stringify({ path, content })
707
+ });
708
+ data = await r.json();
709
+ } catch (err) {
710
+ // 通信自体の失敗 (サーバー停止など)。dirty のまま残し、後で再試行できるようにする。
711
+ setSaveStatus('error', '⚠ 保存できません (通信エラー)');
712
+ if (!auto) alert('保存に失敗しました: ' + err.message);
713
+ return;
714
+ }
715
+ if (!r.ok) {
716
+ setSaveStatus('error', '⚠ ' + (data.error || '保存に失敗'));
717
+ if (!auto) alert(data.error || 'save failed');
718
+ return;
719
+ }
720
+ // 保存中にファイルが切り替わっていなければ dirty 表示を解除し、保存済みを示す。
721
+ if (currentPath === path) { setDirty(false); setSaveStatus('saved'); }
722
+ }
723
+
724
+ // ---- 「名前を付けて保存」ダイアログ (ファイル未選択で Ctrl+S / 保存メニュー時) ----
725
+ // ブラウザ内で完結する仮想ウィンドウ。タイトルバーをドラッグして移動できる。
726
+ // 保存先は実際の作業ディレクトリ (path_guard の allowed_dirs) 基準の相対パス。
727
+ const saveAsDialog = document.getElementById('save-as-dialog');
728
+ const saveAsPathInput = document.getElementById('save-as-path');
729
+ const saveAsError = document.getElementById('save-as-error');
730
+
731
+ function openSaveAsDialog() {
732
+ saveAsError.textContent = '';
733
+ saveAsPathInput.value = '';
734
+ saveAsDialog.hidden = false;
735
+ // 中央寄せ (初回・毎回開くたびに)。ドラッグ後の位置は次回開いたときリセットされる。
736
+ const w = saveAsDialog.offsetWidth, h = saveAsDialog.offsetHeight;
737
+ saveAsDialog.style.left = Math.max(0, (window.innerWidth - w) / 2) + 'px';
738
+ saveAsDialog.style.top = Math.max(0, (window.innerHeight - h) / 3) + 'px';
739
+ saveAsPathInput.focus();
740
+ }
741
+
742
+ function closeSaveAsDialog() {
743
+ saveAsDialog.hidden = true;
744
+ }
745
+
746
+ async function confirmSaveAs() {
747
+ const path = saveAsPathInput.value.trim();
748
+ if (!path) { saveAsError.textContent = '保存先パスを入力してください'; return; }
749
+ const content = editor ? editor.getValue() : '';
750
+ saveAsError.textContent = '';
751
+ let r, data;
752
+ try {
753
+ r = await fetch('/edit/save', {
754
+ method: 'POST', headers: { 'content-type': 'application/json' },
755
+ body: JSON.stringify({ path, content })
756
+ });
757
+ data = await r.json().catch(() => ({}));
758
+ } catch (err) {
759
+ saveAsError.textContent = '保存できません (通信エラー): ' + err.message;
760
+ return;
761
+ }
762
+ if (!r.ok) {
763
+ saveAsError.textContent = data.error || '保存に失敗しました';
764
+ return;
765
+ }
766
+ closeSaveAsDialog();
767
+ await loadFiles(true);
768
+ // 新規タブとして開く (currentPath/パス表示/dirty は openFile 内で設定される)。
769
+ await openFileByPath(path);
770
+ setSaveStatus('saved');
771
+ }
772
+
773
+ document.getElementById('save-as-ok').onclick = confirmSaveAs;
774
+ document.getElementById('save-as-cancel').onclick = closeSaveAsDialog;
775
+ document.getElementById('save-as-close').onclick = closeSaveAsDialog;
776
+ saveAsPathInput.addEventListener('keydown', (e) => {
777
+ if (e.key === 'Enter') { e.preventDefault(); confirmSaveAs(); }
778
+ else if (e.key === 'Escape') { e.preventDefault(); closeSaveAsDialog(); }
779
+ });
780
+
781
+ // タイトルバーのドラッグで仮想ウィンドウを移動する (initResizers と同様のドラッグ実装)。
782
+ function initDraggableWindow(win) {
783
+ const bar = win.querySelector('.vwin-titlebar');
784
+ bar.addEventListener('mousedown', (e) => {
785
+ if (e.target.closest('.vwin-close')) return;
786
+ e.preventDefault();
787
+ const startX = e.clientX, startY = e.clientY;
788
+ const rect = win.getBoundingClientRect();
789
+ const startLeft = rect.left, startTop = rect.top;
790
+ document.body.style.userSelect = 'none';
791
+ const move = (ev) => {
792
+ const nx = clamp(startLeft + (ev.clientX - startX), 0, window.innerWidth - win.offsetWidth);
793
+ const ny = clamp(startTop + (ev.clientY - startY), 0, window.innerHeight - win.offsetHeight);
794
+ win.style.left = nx + 'px';
795
+ win.style.top = ny + 'px';
796
+ };
797
+ const up = () => {
798
+ document.body.style.userSelect = '';
799
+ window.removeEventListener('mousemove', move);
800
+ window.removeEventListener('mouseup', up);
801
+ };
802
+ window.addEventListener('mousemove', move);
803
+ window.addEventListener('mouseup', up);
804
+ });
805
+ }
806
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
807
+ initDraggableWindow(saveAsDialog);
808
+
809
+ // 仮想ウィンドウを画面中央寄りに配置して表示する (毎回開くたびに位置をリセット)。
810
+ function centerWindow(win) {
811
+ win.hidden = false;
812
+ const w = win.offsetWidth, h = win.offsetHeight;
813
+ win.style.left = Math.max(0, (window.innerWidth - w) / 2) + 'px';
814
+ win.style.top = Math.max(0, (window.innerHeight - h) / 3) + 'px';
815
+ }
816
+
817
+ // ---- 汎用の入力ダイアログ (新規作成 / 名前変更で共用)。Promise<string|null> を返す。 ----
818
+ const promptDialog = document.getElementById('prompt-dialog');
819
+ const promptTitle = document.getElementById('prompt-title');
820
+ const promptLabel = document.getElementById('prompt-label');
821
+ const promptInput = document.getElementById('prompt-input');
822
+ const promptError = document.getElementById('prompt-error');
823
+ let promptResolve = null;
824
+
825
+ function showPrompt(title, label, defaultValue) {
826
+ promptTitle.textContent = title;
827
+ promptLabel.textContent = label;
828
+ promptInput.value = defaultValue || '';
829
+ promptError.textContent = '';
830
+ centerWindow(promptDialog);
831
+ promptInput.focus();
832
+ promptInput.select();
833
+ return new Promise(resolve => { promptResolve = resolve; });
834
+ }
835
+
836
+ function closePrompt(result) {
837
+ promptDialog.hidden = true;
838
+ if (promptResolve) { const r = promptResolve; promptResolve = null; r(result); }
839
+ }
840
+
841
+ document.getElementById('prompt-ok').onclick = () => {
842
+ const v = promptInput.value.trim();
843
+ if (!v) { promptError.textContent = '入力してください'; return; }
844
+ closePrompt(v);
845
+ };
846
+ document.getElementById('prompt-cancel').onclick = () => closePrompt(null);
847
+ document.getElementById('prompt-close').onclick = () => closePrompt(null);
848
+ promptInput.addEventListener('keydown', (e) => {
849
+ if (e.key === 'Enter') { e.preventDefault(); document.getElementById('prompt-ok').click(); }
850
+ else if (e.key === 'Escape') { e.preventDefault(); closePrompt(null); }
851
+ });
852
+ initDraggableWindow(promptDialog);
853
+
854
+ // ---- 汎用の確認ダイアログ (削除確認で使用)。Promise<boolean> を返す。 ----
855
+ const confirmDialog = document.getElementById('confirm-dialog');
856
+ const confirmTitle = document.getElementById('confirm-title');
857
+ const confirmMessage = document.getElementById('confirm-message');
858
+ let confirmResolve = null;
859
+
860
+ function showConfirm(title, message) {
861
+ confirmTitle.textContent = title;
862
+ confirmMessage.textContent = message;
863
+ centerWindow(confirmDialog);
864
+ return new Promise(resolve => { confirmResolve = resolve; });
865
+ }
866
+
867
+ function closeConfirm(result) {
868
+ confirmDialog.hidden = true;
869
+ if (confirmResolve) { const r = confirmResolve; confirmResolve = null; r(result); }
870
+ }
871
+
872
+ document.getElementById('confirm-ok').onclick = () => closeConfirm(true);
873
+ document.getElementById('confirm-cancel').onclick = () => closeConfirm(false);
874
+ document.getElementById('confirm-close').onclick = () => closeConfirm(false);
875
+ initDraggableWindow(confirmDialog);
876
+
877
+ // ---- 汎用のエラーダイアログ (操作失敗を知らせる。alert の代わりに使う)。 ----
878
+ const errorDialog = document.getElementById('error-dialog');
879
+ const errorMessage = document.getElementById('error-message');
880
+
881
+ function showError(message, title) {
882
+ document.getElementById('error-title').textContent = title || 'エラー';
883
+ errorMessage.textContent = message;
884
+ centerWindow(errorDialog);
885
+ }
886
+
887
+ function closeErrorDialog() { errorDialog.hidden = true; }
888
+
889
+ document.getElementById('error-ok').onclick = closeErrorDialog;
890
+ document.getElementById('error-close').onclick = closeErrorDialog;
891
+ initDraggableWindow(errorDialog);
892
+
893
+ document.getElementById('btn-save').onclick = () => { closeAllMenus(); saveCurrent(); };
894
+ document.getElementById('btn-autosave').onclick = () => { closeAllMenus(); setAutoSave(!autoSaveEnabled); };
895
+ document.getElementById('btn-reload').onclick = () => loadFiles(true);
896
+ document.getElementById('btn-new-file').onclick = () => createEntry(selectedDir, false);
897
+ document.getElementById('btn-new-dir').onclick = () => createEntry(selectedDir, true);
898
+ document.getElementById('btn-menu-new-file').onclick = () => { closeAllMenus(); createEntry(selectedDir, false); };
899
+ document.getElementById('btn-menu-new-dir').onclick = () => { closeAllMenus(); createEntry(selectedDir, true); };
900
+
901
+ // WORKING DIRECTORY で選択中のファイル/ディレクトリを Delete キーで削除する。
902
+ // テキスト入力欄・Monaco エディタにフォーカスがあるときは、通常の文字削除を優先し何もしない。
903
+ document.addEventListener('keydown', (e) => {
904
+ if (e.key !== 'Delete') return;
905
+ const active = document.activeElement;
906
+ const tag = active && active.tagName;
907
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (active && active.isContentEditable)) return;
908
+ if (active && active.closest('.monaco-editor')) return;
909
+
910
+ let paths = selectedPaths.size > 0 ? [...selectedPaths] : [];
911
+ if (paths.length === 0 && currentPath) paths = [currentPath];
912
+ else if (paths.length === 0 && selectedDir) paths = [selectedDir];
913
+ if (paths.length === 0) return;
914
+ e.preventDefault();
915
+ deleteSelectedPaths(paths);
916
+ });
917
+
918
+ // ---- メニューバー (ファイル/編集/…/ヘルプ のドロップダウン) ----
919
+ let closeAllMenus = () => {}; // initMenu で実装を差し替える (他関数から閉じられるように)
920
+
921
+ function initMenu() {
922
+ const items = [...document.querySelectorAll('#menu .menu-item')];
923
+
924
+ closeAllMenus = () => items.forEach(it => it.classList.remove('open'));
925
+
926
+ items.forEach(item => {
927
+ const btn = item.querySelector('.menu-btn');
928
+ btn.addEventListener('click', (e) => {
929
+ e.stopPropagation();
930
+ const wasOpen = item.classList.contains('open');
931
+ closeAllMenus();
932
+ if (!wasOpen) item.classList.add('open');
933
+ });
934
+ // メニューを開いた状態で隣に移ると、クリックせずとも追従して切り替わる (VSCode 同様の挙動)。
935
+ item.addEventListener('mouseenter', () => {
936
+ if (items.some(it => it.classList.contains('open'))) {
937
+ closeAllMenus();
938
+ item.classList.add('open');
939
+ }
940
+ });
941
+ // ドロップダウン内のクリックはメニューを閉じてから処理させる。
942
+ item.querySelector('.menu-dropdown').addEventListener('click', () => closeAllMenus());
943
+ });
944
+
945
+ document.addEventListener('click', closeAllMenus);
946
+ window.addEventListener('blur', closeAllMenus);
947
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAllMenus(); });
948
+ }
949
+
950
+ // ---- 「表示」メニュー: パネル (WORKING DIRECTORY / ターミナル / AI チャット) の表示切替 ----
951
+ // 状態は localStorage に永続化し、リロード後も維持する (既定: すべて表示)。
952
+ const PANEL_VIEW_PREFS = [
953
+ { key: 'chocomint.edit.view.files', cls: 'hide-files', btnId: 'btn-view-files' },
954
+ { key: 'chocomint.edit.view.console', cls: 'hide-console', btnId: 'btn-view-console' },
955
+ { key: 'chocomint.edit.view.chat', cls: 'hide-chat', btnId: 'btn-view-chat' }
956
+ ];
957
+
958
+ function setPanelVisible(pref, visible) {
959
+ const app = document.getElementById('app');
960
+ localStorage.setItem(pref.key, visible ? 'on' : 'off');
961
+ app.classList.toggle(pref.cls, !visible);
962
+ const btn = document.getElementById(pref.btnId);
963
+ if (btn) {
964
+ btn.setAttribute('aria-checked', String(visible));
965
+ btn.classList.toggle('checked', visible);
966
+ }
967
+ // 非表示パネル分をエディタ/ターミナルが埋めるため、レイアウト再計算が必要。
968
+ if (editor) editor.layout();
969
+ if (termFit) termFit();
970
+ }
971
+
972
+ function initPanelView() {
973
+ PANEL_VIEW_PREFS.forEach(pref => {
974
+ const visible = localStorage.getItem(pref.key) !== 'off';
975
+ setPanelVisible(pref, visible);
976
+ const btn = document.getElementById(pref.btnId);
977
+ if (btn) {
978
+ btn.onclick = () => {
979
+ closeAllMenus();
980
+ const isHidden = document.getElementById('app').classList.contains(pref.cls);
981
+ setPanelVisible(pref, isHidden);
982
+ };
983
+ }
984
+ });
985
+ }
986
+
987
+ // ---- WORKING DIRECTORY (ファイルツリー) と ソース管理 (Git パネル) の排他表示 ----
988
+ // 「表示」メニューの 2 項目は排他: 一方を ON にするともう一方は自動で OFF になる。
989
+ // #app に show-git を付けている間だけ Git パネルを表示する (CSS 側で切り替え)。
990
+ const PANEL_VIEW_TAB_KEY = 'chocomint.edit.view.filesTab'; // 'files' | 'git'
991
+
992
+ function setFilesTab(tab) {
993
+ const app = document.getElementById('app');
994
+ localStorage.setItem(PANEL_VIEW_TAB_KEY, tab);
995
+ app.classList.toggle('show-git', tab === 'git');
996
+
997
+ const filesBtn = document.getElementById('btn-view-files');
998
+ const gitBtn = document.getElementById('btn-view-git');
999
+ filesBtn.setAttribute('aria-checked', String(tab === 'files'));
1000
+ filesBtn.classList.toggle('checked', tab === 'files');
1001
+ gitBtn.setAttribute('aria-checked', String(tab === 'git'));
1002
+ gitBtn.classList.toggle('checked', tab === 'git');
1003
+
1004
+ if (tab === 'git') refreshGitPanel();
1005
+ }
1006
+
1007
+ function initFilesTab() {
1008
+ const saved = localStorage.getItem(PANEL_VIEW_TAB_KEY);
1009
+ setFilesTab(saved === 'git' ? 'git' : 'files');
1010
+
1011
+ document.getElementById('btn-view-files').onclick = () => {
1012
+ closeAllMenus();
1013
+ // WORKING DIRECTORY 側は既存の表示/非表示トグルの意味も兼ねる。
1014
+ // 現在 Git パネル表示中ならツリー表示に戻すだけ、既にツリー表示中なら
1015
+ // 従来どおりパネル自体の表示/非表示を切り替える。
1016
+ const app = document.getElementById('app');
1017
+ if (app.classList.contains('show-git')) { setFilesTab('files'); return; }
1018
+ const pref = PANEL_VIEW_PREFS.find(p => p.btnId === 'btn-view-files');
1019
+ const isHidden = app.classList.contains(pref.cls);
1020
+ setPanelVisible(pref, isHidden);
1021
+ };
1022
+ document.getElementById('btn-view-git').onclick = () => {
1023
+ closeAllMenus();
1024
+ const app = document.getElementById('app');
1025
+ const filesPref = PANEL_VIEW_PREFS.find(p => p.btnId === 'btn-view-files');
1026
+ // パネル自体が非表示なら先に表示する (中身が見えない状態を避ける)。
1027
+ if (app.classList.contains(filesPref.cls)) setPanelVisible(filesPref, true);
1028
+ setFilesTab(app.classList.contains('show-git') ? 'files' : 'git');
1029
+ };
1030
+ }
1031
+
1032
+ // ---- AI チャット ----
1033
+ const chatLog = document.getElementById('chat-log');
1034
+ const chatForm = document.getElementById('chat-form');
1035
+ const chatInput = document.getElementById('chat-input');
1036
+ const chatSend = document.getElementById('chat-send');
1037
+ const spinnerEl = chatSend.querySelector('.send-spinner');
1038
+
1039
+ // 点字ブロックによる回転スピナー。busy の間だけ、登録された全要素を同期して回す。
1040
+ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
1041
+ let spinnerTimer = null;
1042
+ const spinnerTargets = new Set([spinnerEl]);
1043
+ function startSpinner() {
1044
+ let i = 0;
1045
+ spinnerTargets.forEach(el => { el.textContent = SPINNER_FRAMES[0]; });
1046
+ spinnerTimer = setInterval(() => {
1047
+ i = (i + 1) % SPINNER_FRAMES.length;
1048
+ spinnerTargets.forEach(el => { el.textContent = SPINNER_FRAMES[i]; });
1049
+ }, 80);
1050
+ }
1051
+ function stopSpinner() {
1052
+ if (spinnerTimer) { clearInterval(spinnerTimer); spinnerTimer = null; }
1053
+ }
1054
+
1055
+ // 「(点字スピナー) AI 考え中...」の pending 行を作る。スピナー部分は回転対象に登録。
1056
+ // ツール実行の進捗行 (SSE) を差し込めるよう、行を追加する API を返り値に生やす。
1057
+ function addPendingMsg() {
1058
+ const div = document.createElement('div');
1059
+ div.className = 'msg ai';
1060
+ const b = document.createElement('div');
1061
+ b.className = 'bubble';
1062
+ const head = document.createElement('div');
1063
+ head.className = 'pending-head';
1064
+ const sp = document.createElement('span');
1065
+ sp.className = 'pending-spinner';
1066
+ sp.textContent = SPINNER_FRAMES[0];
1067
+ head.appendChild(sp);
1068
+ head.appendChild(document.createTextNode(' AI 考え中...'));
1069
+ b.appendChild(head);
1070
+ // ツール実行の進捗行を並べるコンテナ (SSE の tool_start / tool_done で更新)。
1071
+ const tools = document.createElement('div');
1072
+ tools.className = 'pending-tools';
1073
+ b.appendChild(tools);
1074
+ div.appendChild(b);
1075
+ chatLog.appendChild(div);
1076
+ chatLog.scrollTop = chatLog.scrollHeight;
1077
+ spinnerTargets.add(sp);
1078
+ // remove 時に回転対象からも外す (ツール行のスピナーも含めて)。
1079
+ const origRemove = div.remove.bind(div);
1080
+ div.remove = () => {
1081
+ spinnerTargets.delete(sp);
1082
+ tools.querySelectorAll('.pending-spinner').forEach(s => spinnerTargets.delete(s));
1083
+ origRemove();
1084
+ };
1085
+ div._toolsEl = tools;
1086
+ return div;
1087
+ }
1088
+
1089
+ // pending 行に「実行中のツール」を追加する。step 番号で行を識別し、
1090
+ // 実行中は点字スピナー、完了後は ✓ / ✕ に差し替える。
1091
+ function pendingToolStart(pending, ev) {
1092
+ const tools = pending._toolsEl;
1093
+ if (!tools) return;
1094
+ let row = tools.querySelector('.ptool[data-step="' + ev.step + '"]');
1095
+ if (!row) {
1096
+ row = document.createElement('div');
1097
+ row.className = 'ptool';
1098
+ row.dataset.step = ev.step;
1099
+ const mark = document.createElement('span');
1100
+ mark.className = 'ptool-mark pending-spinner';
1101
+ mark.textContent = SPINNER_FRAMES[0];
1102
+ const label = document.createElement('span');
1103
+ label.className = 'ptool-label';
1104
+ const tgt = document.createElement('code');
1105
+ tgt.className = 'ptool-target';
1106
+ row.appendChild(mark);
1107
+ row.appendChild(label);
1108
+ row.appendChild(tgt);
1109
+ tools.appendChild(row);
1110
+ spinnerTargets.add(mark); // 実行中は回転対象に加える。
1111
+ }
1112
+ row.querySelector('.ptool-label').textContent = ev.label || ev.tool;
1113
+ row.querySelector('.ptool-target').textContent = ev.target || '';
1114
+ chatLog.scrollTop = chatLog.scrollHeight;
1115
+ }
1116
+
1117
+ // 実行中ツール行を完了状態 (✓ / ✕) に切り替え、スピナーを止める。
1118
+ // 実行結果 (output) があれば、その行の下に等幅ブロックで都度表示する。
1119
+ function pendingToolDone(pending, ev) {
1120
+ const tools = pending._toolsEl;
1121
+ if (!tools) return;
1122
+ const row = tools.querySelector('.ptool[data-step="' + ev.step + '"]');
1123
+ if (!row) return;
1124
+ const mark = row.querySelector('.ptool-mark');
1125
+ spinnerTargets.delete(mark);
1126
+ mark.classList.remove('pending-spinner');
1127
+ const ok = !!ev.ok;
1128
+ mark.classList.add(ok ? 'ok' : 'ng');
1129
+ mark.textContent = ok ? '✓' : ('✕' + (ev.exit_code ? ' (' + ev.exit_code + ')' : ''));
1130
+
1131
+ // ツール出力を行の直後に表示 (renderStep の .out と同じ体裁)。
1132
+ let out = row.nextElementSibling;
1133
+ if (!out || !out.classList.contains('ptool-out')) {
1134
+ out = document.createElement('code');
1135
+ out.className = 'ptool-out';
1136
+ row.after(out);
1137
+ }
1138
+ if (ev.output && String(ev.output).trim() !== '') {
1139
+ out.textContent = ev.output;
1140
+ out.classList.remove('empty');
1141
+ } else {
1142
+ out.textContent = '(出力なし)';
1143
+ out.classList.add('empty');
1144
+ }
1145
+ chatLog.scrollTop = chatLog.scrollHeight;
1146
+ }
1147
+
1148
+ // 軽量 Markdown → HTML 変換 (外部ライブラリ非依存 / オフライン用)。
1149
+ // XSS 防止のため、まず全体を HTML エスケープしてから Markdown 記法だけを HTML 化する。
1150
+ function escapeHtml(s) {
1151
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1152
+ }
1153
+ function renderMarkdown(src) {
1154
+ const codeBlocks = [];
1155
+ // ``` フェンスコードブロックを先に退避 (中身は変換しない)。
1156
+ let text = escapeHtml(src).replace(/```([\s\S]*?)```/g, (_m, code) => {
1157
+ codeBlocks.push(code.replace(/^\n/, ''));
1158
+ return ' CB' + (codeBlocks.length - 1) + ' ';
1159
+ });
1160
+ const lines = text.split('\n');
1161
+ let html = '';
1162
+ let inList = false;
1163
+ const closeList = () => { if (inList) { html += '</ul>'; inList = false; } };
1164
+ for (let i = 0; i < lines.length; i++) {
1165
+ const line = lines[i];
1166
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
1167
+ const li = line.match(/^\s*[-*]\s+(.*)$/);
1168
+ // GFM テーブル: 「| 見出し |」+ 次行が区切り (|---|---|) なら表として処理。
1169
+ if (isTableRow(line) && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
1170
+ closeList();
1171
+ const body = [];
1172
+ let j = i + 2;
1173
+ while (j < lines.length && isTableRow(lines[j])) { body.push(lines[j]); j++; }
1174
+ html += renderTable(line, body);
1175
+ i = j - 1;
1176
+ } else if (h) {
1177
+ closeList();
1178
+ const lvl = h[1].length;
1179
+ html += '<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>';
1180
+ } else if (li) {
1181
+ if (!inList) { html += '<ul>'; inList = true; }
1182
+ html += '<li>' + inline(li[1]) + '</li>';
1183
+ } else if (line.trim() === '') {
1184
+ closeList();
1185
+ } else if (/^ CB\d+ $/.test(line)) {
1186
+ // コードブロックのプレースホルダは段落で包まず、そのまま出す。
1187
+ closeList();
1188
+ html += line;
1189
+ } else {
1190
+ closeList();
1191
+ html += '<p>' + inline(line) + '</p>';
1192
+ }
1193
+ }
1194
+ closeList();
1195
+ // 退避したコードブロックを戻す。
1196
+ html = html.replace(/ CB(\d+) /g, (_m, i) => '<pre><code>' + codeBlocks[+i] + '</code></pre>');
1197
+ return html;
1198
+ }
1199
+ // インライン記法: 太字 / 斜体 / インラインコード / リンク。
1200
+ function inline(s) {
1201
+ return s
1202
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1203
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
1204
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
1205
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
1206
+ '<a href="$2" target="_blank" rel="noopener">$1</a>');
1207
+ }
1208
+ // GFM テーブル対応 ----
1209
+ function isTableRow(line) { return line.indexOf('|') !== -1 && line.trim() !== ''; }
1210
+ function isTableDivider(line) {
1211
+ return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(line);
1212
+ }
1213
+ // 1 行を | でセル分割する (先頭・末尾の | は除去し、各セルはインライン変換)。
1214
+ function tableCells(line) {
1215
+ return line.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim()));
1216
+ }
1217
+ function renderTable(header, bodyLines) {
1218
+ const head = tableCells(header).map(c => '<th>' + c + '</th>').join('');
1219
+ const rows = bodyLines.map(l =>
1220
+ '<tr>' + tableCells(l).map(c => '<td>' + c + '</td>').join('') + '</tr>').join('');
1221
+ return '<table><thead><tr>' + head + '</tr></thead><tbody>' + rows + '</tbody></table>';
1222
+ }
1223
+
1224
+ // ユーザー発言: 吹き出しのみ (ラベルなし)。
1225
+ function addUserMsg(text) {
1226
+ const div = document.createElement('div');
1227
+ div.className = 'msg user';
1228
+ const b = document.createElement('div');
1229
+ b.className = 'bubble';
1230
+ b.textContent = text;
1231
+ div.appendChild(b);
1232
+ chatLog.appendChild(div);
1233
+ chatLog.scrollTop = chatLog.scrollHeight;
1234
+ return div;
1235
+ }
1236
+
1237
+ // AI 発言: 枠なしの地の文 (ラベルなし)。回答は Markdown として描画する。
1238
+ function addAiMsg(text) {
1239
+ const div = document.createElement('div');
1240
+ div.className = 'msg ai';
1241
+ const b = document.createElement('div');
1242
+ b.className = 'bubble markdown';
1243
+ b.innerHTML = renderMarkdown(text);
1244
+ div.appendChild(b);
1245
+ chatLog.appendChild(div);
1246
+ chatLog.scrollTop = chatLog.scrollHeight;
1247
+ return div;
1248
+ }
1249
+
1250
+ // 実行ステップ 1 件を「番号・ツールラベル・対象・成否・出力」の構造で描画する。
1251
+ // 生 JSON をそのまま出さず、人間が追える形にするのが目的。
1252
+ function renderStep(s, no) {
1253
+ const st = document.createElement('div');
1254
+ st.className = 'step' + (s.status === 'PASS' ? '' : ' failed');
1255
+
1256
+ const head = document.createElement('div');
1257
+ head.className = 'step-head';
1258
+
1259
+ const num = document.createElement('span');
1260
+ num.className = 'step-no';
1261
+ num.textContent = no;
1262
+ head.appendChild(num);
1263
+
1264
+ const label = document.createElement('span');
1265
+ label.className = 'step-label';
1266
+ label.textContent = s.label || s.tool;
1267
+ head.appendChild(label);
1268
+
1269
+ // 対象 (ファイル名 / コマンド等) があれば等幅で添える。
1270
+ if (s.target) {
1271
+ const tgt = document.createElement('code');
1272
+ tgt.className = 'step-target';
1273
+ tgt.textContent = s.target;
1274
+ head.appendChild(tgt);
1275
+ }
1276
+
1277
+ // 成否バッジ (✓ / ✕)。exit_code があれば失敗時に添える。
1278
+ const ok = s.status === 'PASS' && (s.exit_code == null || s.exit_code === 0);
1279
+ const mark = document.createElement('span');
1280
+ mark.className = 'step-mark ' + (ok ? 'ok' : 'ng');
1281
+ mark.textContent = ok ? '✓' : ('✕' + (s.exit_code ? ' (' + s.exit_code + ')' : ''));
1282
+ head.appendChild(mark);
1283
+
1284
+ st.appendChild(head);
1285
+
1286
+ // 出力は改行を保った等幅ブロックで表示。空なら控えめに「(出力なし)」。
1287
+ const out = document.createElement('code');
1288
+ out.className = 'out';
1289
+ if (s.output && s.output.trim() !== '') {
1290
+ out.textContent = s.output;
1291
+ } else {
1292
+ out.classList.add('empty');
1293
+ out.textContent = '(出力なし)';
1294
+ }
1295
+ st.appendChild(out);
1296
+ return st;
1297
+ }
1298
+
1299
+ // AI の実行結果 (ステータスバッジ + 返信 + 各ツールの出力) を描画する。
1300
+ function renderAiResult(data) {
1301
+ // 会話応答 (ツール実行なし) は自然文だけを地の文として表示する。
1302
+ if (data.reply) return addAiMsg(data.reply);
1303
+
1304
+ const status = data.status || 'FAIL';
1305
+ const div = document.createElement('div');
1306
+ div.className = 'msg ai status-' + status;
1307
+ const b = document.createElement('div');
1308
+ b.className = 'bubble';
1309
+
1310
+ const badge = document.createElement('span');
1311
+ badge.className = 'badge';
1312
+ const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1313
+ // attempts は「LLM への試行回数」。1 回なら冗長なので添えない。
1314
+ badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1315
+ b.appendChild(badge);
1316
+
1317
+ // AI による実行結果の要約 (Markdown)。生のツール出力より上に目立たせて表示。
1318
+ if (data.summary) {
1319
+ const sum = document.createElement('div');
1320
+ sum.className = 'summary markdown';
1321
+ sum.innerHTML = renderMarkdown(data.summary);
1322
+ b.appendChild(sum);
1323
+ }
1324
+
1325
+ if (data.error) {
1326
+ const err = document.createElement('div');
1327
+ err.textContent = data.error;
1328
+ b.appendChild(err);
1329
+ }
1330
+
1331
+ // 未完了の可能性など、要約とは別に添える注記 (控えめに表示)。
1332
+ if (data.note) {
1333
+ const note = document.createElement('div');
1334
+ note.className = 'result-note';
1335
+ note.textContent = '⚠ ' + data.note;
1336
+ b.appendChild(note);
1337
+ }
1338
+
1339
+ const steps = data.steps || [];
1340
+ if (steps.length) {
1341
+ const wrap = document.createElement('div');
1342
+ wrap.className = 'steps';
1343
+ steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1)));
1344
+ // 要約があるときは実行の詳細を折りたたむ (まとめを主役にする)。
1345
+ if (data.summary) {
1346
+ const det = document.createElement('details');
1347
+ const sm = document.createElement('summary');
1348
+ sm.textContent = '実行の詳細 (' + steps.length + ' ステップ)';
1349
+ det.appendChild(sm);
1350
+ det.appendChild(wrap);
1351
+ b.appendChild(det);
1352
+ } else {
1353
+ b.appendChild(wrap);
1354
+ }
1355
+ }
1356
+
1357
+ div.appendChild(b);
1358
+ chatLog.appendChild(div);
1359
+ chatLog.scrollTop = chatLog.scrollHeight;
1360
+ return div;
1361
+ }
1362
+
1363
+ // チャットのスラッシュコマンド。入力欄の中身が /clear /compact だけの行なら、
1364
+ // AI へは送らずローカルで処理する。true を返したら送信を打ち切る。
1365
+ function handleSlashCommand(text) {
1366
+ const cmd = text.replace(/\s+/g, ' ').trim().toLowerCase();
1367
+ if (cmd === '/clear') { clearChat(); return true; }
1368
+ if (cmd === '/compact') { compactChat(); return true; }
1369
+ return false;
1370
+ }
1371
+
1372
+ // チャットログを全消去する。
1373
+ function clearChat() {
1374
+ chatLog.innerHTML = '';
1375
+ chatInput.value = '';
1376
+ }
1377
+
1378
+ // 現在のチャット表示テキストを AI に要約させ、ログを短い要約 1 件に畳み込む。
1379
+ // サーバー側は会話履歴を保持しないため、これは表示の圧縮のみ (AI の文脈には影響しない)。
1380
+ async function compactChat() {
1381
+ const transcript = collectTranscript();
1382
+ if (!transcript.trim()) { clearChat(); return; }
1383
+ chatInput.value = '';
1384
+ chatForm.classList.add('busy');
1385
+ chatSend.disabled = true;
1386
+ const pending = addPendingMsg();
1387
+ startSpinner();
1388
+ try {
1389
+ const r = await fetch('/edit/compact', {
1390
+ method: 'POST', headers: { 'content-type': 'application/json' },
1391
+ body: JSON.stringify({ transcript })
1392
+ });
1393
+ const data = await r.json();
1394
+ pending.remove();
1395
+ if (!r.ok) { renderAiResult({ status: 'FAIL', error: data.error || '要約に失敗しました' }); return; }
1396
+ // ログを一掃し、要約 1 件だけを「これまでの会話のまとめ」として残す。
1397
+ chatLog.innerHTML = '';
1398
+ addCompactSummary(data.summary || '');
1399
+ } catch (err) {
1400
+ pending.remove();
1401
+ renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
1402
+ } finally {
1403
+ stopSpinner();
1404
+ chatForm.classList.remove('busy');
1405
+ chatSend.disabled = false;
1406
+ }
1407
+ }
1408
+
1409
+ // チャットログを AI へ渡す 1 本のプレーンテキストに書き出す (要約入力用)。
1410
+ // ユーザー発言 / AI 発言 / 実行要約 を role 付きの行にして連結する。
1411
+ function collectTranscript() {
1412
+ const lines = [];
1413
+ chatLog.querySelectorAll('.msg').forEach(m => {
1414
+ const role = m.classList.contains('user') ? 'ユーザー' : 'アシスタント';
1415
+ const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
1416
+ if (text) lines.push(role + ': ' + text);
1417
+ });
1418
+ return lines.join('\n');
1419
+ }
1420
+
1421
+ // /compact の結果 (これまでの会話のまとめ) を専用の見出し付きで表示する。
1422
+ function addCompactSummary(text) {
1423
+ const div = document.createElement('div');
1424
+ div.className = 'msg ai compact-summary';
1425
+ const b = document.createElement('div');
1426
+ b.className = 'bubble';
1427
+ const head = document.createElement('div');
1428
+ head.className = 'compact-head';
1429
+ head.textContent = '🗜 これまでの会話のまとめ';
1430
+ b.appendChild(head);
1431
+ const body = document.createElement('div');
1432
+ body.className = 'markdown';
1433
+ body.innerHTML = renderMarkdown(text);
1434
+ b.appendChild(body);
1435
+ div.appendChild(b);
1436
+ chatLog.appendChild(div);
1437
+ chatLog.scrollTop = chatLog.scrollHeight;
1438
+ }
1439
+
1440
+ // 最終結果に伴うエディタ反映とファイル一覧更新 (描画とは分離した副作用処理)。
1441
+ function applyResultSideEffects(data) {
1442
+ if (data.content != null && editor && currentPath) {
1443
+ if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
1444
+ setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
1445
+ setDirty(false);
1446
+ }
1447
+ loadFiles(true); // AI 実行後は確実に反映する (新規ファイルが増えている可能性)
1448
+ }
1449
+
1450
+ // pending 行に既にツール実行の進捗が描画済みのとき、それを消さずにそのまま
1451
+ // 「完了した実行結果」として仕上げる。ツール行と各出力は残したまま、
1452
+ // 「AI 考え中」ヘッダーをステータスバッジに差し替え、要約/注記/エラーを追記する。
1453
+ function finalizePending(pending, data) {
1454
+ const b = pending.querySelector('.bubble');
1455
+ const head = pending.querySelector('.pending-head');
1456
+ const status = data.status || 'PASS';
1457
+ pending.classList.add('status-' + status);
1458
+ // ヘッダーをステータスバッジに置き換える (スピナーはもう不要)。
1459
+ const badge = document.createElement('span');
1460
+ badge.className = 'badge';
1461
+ const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1462
+ badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1463
+ if (head) head.replaceWith(badge); else b.insertBefore(badge, b.firstChild);
1464
+
1465
+ // 要約 (Markdown) はツール一覧の上に目立たせて置く。
1466
+ const tools = pending._toolsEl;
1467
+ if (data.summary) {
1468
+ const sum = document.createElement('div');
1469
+ sum.className = 'summary markdown';
1470
+ sum.innerHTML = renderMarkdown(data.summary);
1471
+ b.insertBefore(sum, tools);
1472
+ }
1473
+ if (data.error) {
1474
+ const err = document.createElement('div');
1475
+ err.textContent = data.error;
1476
+ b.insertBefore(err, tools);
1477
+ }
1478
+ if (data.note) {
1479
+ const note = document.createElement('div');
1480
+ note.className = 'result-note';
1481
+ note.textContent = '⚠ ' + data.note;
1482
+ b.appendChild(note);
1483
+ }
1484
+ chatLog.scrollTop = chatLog.scrollHeight;
1485
+ }
1486
+
1487
+ // SSE の 1 イベント ({ event, data }) を処理する。data は JSON 文字列。
1488
+ // pending 行はツール進捗の描画先。result / error を受けたら最終描画する。
1489
+ // _hadTool は「この応答でツール実行が 1 つでもあったか」を pending に記録する。
1490
+ function handleChatEvent(pending, evName, dataStr) {
1491
+ let data;
1492
+ try { data = dataStr ? JSON.parse(dataStr) : {}; } catch (e) { return; }
1493
+ if (evName === 'tool_start') {
1494
+ pending._hadTool = true;
1495
+ pendingToolStart(pending, data);
1496
+ } else if (evName === 'tool_done') {
1497
+ pendingToolDone(pending, data);
1498
+ } else if (evName === 'result') {
1499
+ // 会話応答 (reply) はツール実行が無いので、pending を消して地の文で表示。
1500
+ // ツール実行があった場合は、進捗表示を消さずにその場で仕上げる。
1501
+ if (data.reply || !pending._hadTool) {
1502
+ pending.remove();
1503
+ renderAiResult(data);
1504
+ } else {
1505
+ finalizePending(pending, data);
1506
+ }
1507
+ applyResultSideEffects(data);
1508
+ } else if (evName === 'error') {
1509
+ // ツール実行済みなら進捗を残してエラーを添える。未実行なら通常のエラー表示。
1510
+ if (pending._hadTool) {
1511
+ finalizePending(pending, { status: 'FAIL', error: data.error || 'エラー' });
1512
+ } else {
1513
+ pending.remove();
1514
+ renderAiResult({ status: 'FAIL', error: data.error || 'エラー' });
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ async function sendChat() {
1520
+ const instruction = chatInput.value.trim();
1521
+ if (!instruction) return;
1522
+ // /clear /compact はローカルコマンド (AI へは送らない)。
1523
+ if (handleSlashCommand(instruction)) return;
1524
+ chatInput.value = '';
1525
+ addUserMsg(instruction);
1526
+ chatForm.classList.add('busy');
1527
+ chatSend.disabled = true;
1528
+ const pending = addPendingMsg();
1529
+ startSpinner();
1530
+ try {
1531
+ const r = await fetch('/edit/chat', {
1532
+ method: 'POST', headers: { 'content-type': 'application/json' },
1533
+ body: JSON.stringify({ path: currentPath || '', instruction, stream: true })
1534
+ });
1535
+ if (!r.ok || !r.body) {
1536
+ // SSE を開始できなかった場合は本文を JSON として読んでフォールバック。
1537
+ const data = await r.json().catch(() => ({ status: 'FAIL', error: r.status + ' error' }));
1538
+ pending.remove();
1539
+ renderAiResult(data);
1540
+ applyResultSideEffects(data);
1541
+ return;
1542
+ }
1543
+ // SSE ストリームを読み、イベント境界 (空行) ごとにパースして処理する。
1544
+ const reader = r.body.getReader();
1545
+ const decoder = new TextDecoder();
1546
+ let buf = '';
1547
+ for (;;) {
1548
+ const { value, done } = await reader.read();
1549
+ if (done) break;
1550
+ buf += decoder.decode(value, { stream: true });
1551
+ let sep;
1552
+ while ((sep = buf.indexOf('\n\n')) !== -1) {
1553
+ const chunk = buf.slice(0, sep);
1554
+ buf = buf.slice(sep + 2);
1555
+ let evName = 'message', dataStr = '';
1556
+ chunk.split('\n').forEach(line => {
1557
+ if (line.startsWith('event:')) evName = line.slice(6).trim();
1558
+ else if (line.startsWith('data:')) dataStr += line.slice(5).trim();
1559
+ });
1560
+ handleChatEvent(pending, evName, dataStr);
1561
+ }
1562
+ }
1563
+ } catch (err) {
1564
+ pending.remove();
1565
+ renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
1566
+ } finally {
1567
+ stopSpinner();
1568
+ chatForm.classList.remove('busy');
1569
+ chatSend.disabled = false;
1570
+ }
1571
+ }
1572
+
1573
+ chatForm.onsubmit = (e) => { e.preventDefault(); sendChat(); };
1574
+ // Enter で送信、Shift+Enter で改行 (IME 変換確定中の Enter は無視)。
1575
+ chatInput.addEventListener('keydown', (e) => {
1576
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
1577
+ e.preventDefault();
1578
+ sendChat();
1579
+ }
1580
+ });
1581
+
1582
+ // Base64 文字列を UTF-8 バイト列 (Uint8Array) に変換する。
1583
+ function b64ToBytes(b64) {
1584
+ const bin = atob(b64);
1585
+ const bytes = new Uint8Array(bin.length);
1586
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1587
+ return bytes;
1588
+ }
1589
+
1590
+ // ---- コンソール (xterm + WebSocket、複数タブ + タブ内 1 回の左右分割) ----
1591
+ // tab = { id, label, panes: [pane, ...] } / pane = { id, term, fit, ws, el }
1592
+ let terminals = [];
1593
+ let activeTabId = null;
1594
+ let nextTermSeq = 1;
1595
+
1596
+ function newTermId() { return 'term-' + (nextTermSeq++); }
1597
+
1598
+ // 1 ペイン (xterm + WebSocket 接続) を作り、DOM に差し込む。
1599
+ // tab を渡すと、シェルが exit した際にそのペインを自動で閉じる (分割解除)。
1600
+ function createPane(container, tab) {
1601
+ const wrap = document.createElement('div');
1602
+ wrap.className = 'term-pane';
1603
+ const xtermEl = document.createElement('div');
1604
+ xtermEl.className = 'xterm-wrap';
1605
+ wrap.appendChild(xtermEl);
1606
+ container.appendChild(wrap);
1607
+
1608
+ const term = new Terminal({ fontSize: 13, cursorBlink: true,
1609
+ fontFamily: '"Cascadia Code", "Noto Sans JP", monospace',
1610
+ theme: { background: '#1e1e1e' } });
1611
+ const fit = new FitAddon.FitAddon();
1612
+ term.loadAddon(fit);
1613
+ term.open(xtermEl);
1614
+
1615
+ const pane = { id: newTermId(), term, fit, ws: null, el: wrap };
1616
+
1617
+ if (!WS_CONFIG) {
1618
+ term.write('\r\n[コンソールは無効です (ws_port 未設定)]\r\n');
1619
+ return pane;
1620
+ }
1621
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1622
+ let url = proto + '://' + location.hostname + ':' + WS_CONFIG.port + '/';
1623
+ if (WS_CONFIG.token) url += '?token=' + encodeURIComponent(WS_CONFIG.token);
1624
+ const ws = new WebSocket(url);
1625
+ pane.ws = ws;
1626
+
1627
+ ws.onopen = () => {
1628
+ const send = () => ws.send(JSON.stringify(
1629
+ { type: 'resize', cols: term.cols, rows: term.rows }));
1630
+ send();
1631
+ term.onData(d => ws.send(JSON.stringify({ type: 'input', data: d })));
1632
+ pane.sendResize = send;
1633
+ };
1634
+ ws.onmessage = (ev) => {
1635
+ const msg = JSON.parse(ev.data);
1636
+ // 出力は UTF-8 バイト列。atob の結果を文字列で渡すと Latin-1 扱いになり
1637
+ // 日本語が âââ に化けるため、Uint8Array に変換して xterm へ渡す
1638
+ // (xterm は Uint8Array を UTF-8 としてデコードする)。
1639
+ if (msg.type === 'output') term.write(b64ToBytes(msg.data));
1640
+ else if (msg.type === 'exit') { term.write('\r\n[プロセス終了: ' + msg.code + ']\r\n'); if (tab) onPaneExited(tab, pane); }
1641
+ else if (msg.type === 'error') term.write('\r\n[エラー: ' + msg.message + ']\r\n');
1642
+ };
1643
+ ws.onclose = () => term.write('\r\n[接続が閉じられました]\r\n');
1644
+
1645
+ return pane;
1646
+ }
1647
+
1648
+ // シェルが exit したペインを片付ける。分割中 (2 ペイン) ならそのペインだけ閉じて分割解除、
1649
+ // 単独ペインならタブごと閉じる。
1650
+ function onPaneExited(tab, pane) {
1651
+ const idx = tab.panes.indexOf(pane);
1652
+ if (idx === -1) return;
1653
+ closePane(pane);
1654
+ tab.panes.splice(idx, 1);
1655
+ pane.el.remove();
1656
+ if (tab.panes.length === 0) {
1657
+ closeTab(tab.id);
1658
+ } else if (tab.id === activeTabId) {
1659
+ fitActiveTab();
1660
+ }
1661
+ }
1662
+
1663
+ function closePane(pane) {
1664
+ try { pane.ws && pane.ws.close(); } catch (e) {}
1665
+ try { pane.term.dispose(); } catch (e) {}
1666
+ }
1667
+
1668
+ // 新しいタブ (ペイン 1 つ) を作って表示する。
1669
+ function createTerminalTab() {
1670
+ const row = document.createElement('div');
1671
+ row.className = 'term-pane-row';
1672
+ document.getElementById('term-panes').appendChild(row);
1673
+
1674
+ const tab = { id: newTermId(), label: 'ターミナル ' + (terminals.length + 1), panes: [], row };
1675
+ tab.panes.push(createPane(row, tab));
1676
+ terminals.push(tab);
1677
+ activateTab(tab.id);
1678
+ renderTermTabs();
1679
+ fitActiveTab();
1680
+ }
1681
+
1682
+ // アクティブなタブを現在のペイン構成のまま「左右 2 ペイン」に分割する (既に 2 ペインなら何もしない)。
1683
+ function splitActiveTerminal() {
1684
+ const tab = terminals.find(t => t.id === activeTabId);
1685
+ if (!tab || tab.panes.length >= 2) return;
1686
+ const pane = createPane(tab.row, tab);
1687
+ tab.panes.push(pane);
1688
+ fitActiveTab();
1689
+ }
1690
+
1691
+ function activateTab(id) {
1692
+ activeTabId = id;
1693
+ terminals.forEach(t => t.row.classList.toggle('active', t.id === id));
1694
+ renderTermTabs();
1695
+ fitActiveTab();
1696
+ }
1697
+
1698
+ function closeTab(id) {
1699
+ const idx = terminals.findIndex(t => t.id === id);
1700
+ if (idx === -1) return;
1701
+ const tab = terminals[idx];
1702
+ tab.panes.forEach(closePane);
1703
+ tab.row.remove();
1704
+ terminals.splice(idx, 1);
1705
+ if (activeTabId === id) {
1706
+ const next = terminals[idx] || terminals[idx - 1];
1707
+ activeTabId = next ? next.id : null;
1708
+ }
1709
+ renderTermTabs();
1710
+ if (activeTabId) { terminals.forEach(t => t.row.classList.toggle('active', t.id === activeTabId)); fitActiveTab(); }
1711
+ }
1712
+
1713
+ function renderTermTabs() {
1714
+ const bar = document.getElementById('term-tabs');
1715
+ bar.innerHTML = '';
1716
+ terminals.forEach(t => {
1717
+ const el = document.createElement('div');
1718
+ el.className = 'term-tab' + (t.id === activeTabId ? ' active' : '');
1719
+ el.textContent = t.label;
1720
+ el.onclick = () => activateTab(t.id);
1721
+ // 中クリックでも閉じる (ブラウザのタブ操作に倣う)。mousedown 側でも既定動作
1722
+ // (中ボタンのオートスクロール) を抑止しておく。
1723
+ el.onmousedown = (e) => { if (e.button === 1) e.preventDefault(); };
1724
+ el.onauxclick = (e) => { if (e.button === 1) { e.preventDefault(); closeTab(t.id); } };
1725
+ const close = document.createElement('span');
1726
+ close.className = 'term-tab-close';
1727
+ close.textContent = '×';
1728
+ close.title = 'ターミナルを閉じる';
1729
+ close.onclick = (e) => { e.stopPropagation(); closeTab(t.id); };
1730
+ el.appendChild(close);
1731
+ bar.appendChild(el);
1732
+ });
1733
+
1734
+ const add = document.createElement('button');
1735
+ add.id = 'term-tab-add';
1736
+ add.type = 'button';
1737
+ add.title = '新しいターミナル';
1738
+ add.setAttribute('aria-label', '新しいターミナル');
1739
+ add.textContent = '+';
1740
+ add.onclick = () => createTerminalTab();
1741
+ bar.appendChild(add);
1742
+ }
1743
+
1744
+ // アクティブタブの全ペインを fit() し、リサイズを WebSocket へ伝える。
1745
+ function fitActiveTab() {
1746
+ const tab = terminals.find(t => t.id === activeTabId);
1747
+ if (!tab) return;
1748
+ tab.panes.forEach(p => {
1749
+ try { p.fit.fit(); } catch (e) {}
1750
+ if (p.sendResize) p.sendResize();
1751
+ });
1752
+ }
1753
+
1754
+ function initConsole() {
1755
+ document.getElementById('btn-term-new').onclick = () => { closeAllMenus(); createTerminalTab(); };
1756
+ document.getElementById('btn-term-split').onclick = () => { closeAllMenus(); splitActiveTerminal(); };
1757
+ createTerminalTab();
1758
+ window.addEventListener('resize', fitActiveTab);
1759
+ }
1760
+
1761
+ // ---- パネルのリサイズ (境界ドラッグ) ----
1762
+ let termFit = fitActiveTab; // initResizers から呼ぶ (リサイザードラッグ時に全ペインを fit)
1763
+
1764
+ function initResizers() {
1765
+ const app = document.getElementById('app');
1766
+
1767
+ // ドラッグ中に xterm を追従させる。window resize を発火して
1768
+ // initConsole 内の fit+send ハンドラも同時に呼ぶ。
1769
+ const reflow = () => {
1770
+ if (termFit) termFit();
1771
+ window.dispatchEvent(new Event('resize'));
1772
+ };
1773
+
1774
+ // 汎用ドラッグ: axis='x' は列幅、'y' は行高を CSS 変数へ書き込む。
1775
+ function drag(handle, axis, apply) {
1776
+ handle.addEventListener('mousedown', (e) => {
1777
+ e.preventDefault();
1778
+ handle.classList.add('active');
1779
+ document.body.style.userSelect = 'none';
1780
+ document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize';
1781
+ const move = (ev) => apply(ev);
1782
+ const up = () => {
1783
+ handle.classList.remove('active');
1784
+ document.body.style.userSelect = '';
1785
+ document.body.style.cursor = '';
1786
+ window.removeEventListener('mousemove', move);
1787
+ window.removeEventListener('mouseup', up);
1788
+ reflow();
1789
+ };
1790
+ window.addEventListener('mousemove', move);
1791
+ window.addEventListener('mouseup', up);
1792
+ });
1793
+ }
1794
+
1795
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
1796
+
1797
+ // files 幅: app 左端からマウス X までを files-w にする。
1798
+ drag(document.getElementById('rc-l'), 'x', (ev) => {
1799
+ const r = app.getBoundingClientRect();
1800
+ const w = clamp(ev.clientX - r.left, 120, r.width - 400);
1801
+ app.style.setProperty('--files-w', w + 'px');
1802
+ reflow();
1803
+ });
1804
+
1805
+ // chat 幅: app 右端からマウス X までを chat-w にする。
1806
+ drag(document.getElementById('rc-r'), 'x', (ev) => {
1807
+ const r = app.getBoundingClientRect();
1808
+ const w = clamp(r.right - ev.clientX, 200, r.width - 400);
1809
+ app.style.setProperty('--chat-w', w + 'px');
1810
+ reflow();
1811
+ });
1812
+
1813
+ // editor/console 高さ: editor 行の上端からマウス Y までを editor-h にする。
1814
+ drag(document.getElementById('rr'), 'y', (ev) => {
1815
+ const top = document.getElementById('editor').getBoundingClientRect().top;
1816
+ const bottom = document.getElementById('console-wrap').getBoundingClientRect().bottom;
1817
+ const h = clamp(ev.clientY - top, 80, bottom - top - 80);
1818
+ app.style.setProperty('--editor-h', h + 'px');
1819
+ reflow();
1820
+ });
1821
+ }
1822
+
1823
+ // ---- Git (ステージング / コミット / push / ブランチ切り替え) ----
1824
+
1825
+ async function gitFetch(url, opts) {
1826
+ const r = await fetch(url, opts);
1827
+ const data = await r.json().catch(() => ({}));
1828
+ if (!r.ok) {
1829
+ const err = new Error(data.error || (r.status + ' error'));
1830
+ // サーバーが reason (例: "no_remote") を返した場合はエラーオブジェクトに引き継ぎ、
1831
+ // 呼び出し側でエラーダイアログ以外の分岐 (入力ダイアログなど) をできるようにする。
1832
+ if (data.reason) err.reason = data.reason;
1833
+ throw err;
1834
+ }
1835
+ return data;
1836
+ }
1837
+
1838
+ async function gitPost(url, body) {
1839
+ return gitFetch(url, {
1840
+ method: 'POST', headers: { 'content-type': 'application/json' },
1841
+ body: JSON.stringify(body || {})
1842
+ });
1843
+ }
1844
+
1845
+ // git status のステータス文字 (M/A/D/R/?) を CSS クラスと表示ラベルに変換する。
1846
+ function gitStatusInfo(status) {
1847
+ const map = {
1848
+ M: { cls: 'status-M', label: 'M' },
1849
+ A: { cls: 'status-A', label: 'A' },
1850
+ D: { cls: 'status-D', label: 'D' },
1851
+ R: { cls: 'status-R', label: 'R' },
1852
+ '?': { cls: 'status-untracked', label: 'U' }
1853
+ };
1854
+ return map[status] || { cls: 'status-M', label: status };
1855
+ }
1856
+
1857
+ // actions: [{ label, icon, fn, danger? }, ...] を左から順にボタンとして並べる。
1858
+ function renderGitFileList(container, files, actions) {
1859
+ container.innerHTML = '';
1860
+ if (!files.length) {
1861
+ const li = document.createElement('li');
1862
+ li.className = 'git-empty';
1863
+ li.textContent = '(なし)';
1864
+ container.appendChild(li);
1865
+ return;
1866
+ }
1867
+ files.forEach(f => {
1868
+ const li = document.createElement('li');
1869
+ li.className = 'git-file-row';
1870
+ const info = gitStatusInfo(f.status);
1871
+ const st = document.createElement('span');
1872
+ st.className = 'git-file-status ' + info.cls;
1873
+ st.textContent = info.label;
1874
+ const path = document.createElement('span');
1875
+ path.className = 'git-file-path';
1876
+ path.textContent = f.path;
1877
+ path.title = f.path;
1878
+ li.appendChild(st);
1879
+ li.appendChild(path);
1880
+ actions.forEach(action => {
1881
+ const btn = document.createElement('button');
1882
+ btn.type = 'button';
1883
+ btn.className = 'git-file-toggle' + (action.danger ? ' danger' : '');
1884
+ btn.title = action.label;
1885
+ btn.setAttribute('aria-label', action.label);
1886
+ btn.textContent = action.icon;
1887
+ btn.onclick = () => action.fn(f.path);
1888
+ li.appendChild(btn);
1889
+ });
1890
+ container.appendChild(li);
1891
+ });
1892
+ }
1893
+
1894
+ async function refreshGitPanel() {
1895
+ let data;
1896
+ try {
1897
+ data = await gitFetch('/edit/git/status');
1898
+ } catch (err) {
1899
+ return; // ポーリング中の一時的な失敗は無視する。
1900
+ }
1901
+
1902
+ const uninit = document.getElementById('git-uninit');
1903
+ const initView = document.getElementById('git-init-view');
1904
+ const branchName = document.getElementById('statusbar-branch-name');
1905
+
1906
+ if (!data.initialized) {
1907
+ uninit.hidden = false;
1908
+ initView.hidden = true;
1909
+ branchName.textContent = '(Git 未初期化)';
1910
+ return;
1911
+ }
1912
+
1913
+ uninit.hidden = true;
1914
+ initView.hidden = false;
1915
+ branchName.textContent = data.branch || '(不明)';
1916
+
1917
+ const staged = data.staged || [];
1918
+ const unstaged = data.unstaged || [];
1919
+ document.getElementById('git-staged-count').textContent = staged.length ? String(staged.length) : '';
1920
+ document.getElementById('git-unstaged-count').textContent = unstaged.length ? String(unstaged.length) : '';
1921
+
1922
+ renderGitFileList(document.getElementById('git-staged-list'), staged,
1923
+ [{ label: 'ステージを取り消す', icon: '−', fn: gitUnstage }]);
1924
+ renderGitFileList(document.getElementById('git-unstaged-list'), unstaged,
1925
+ [{ label: '変更を破棄', icon: '↺', fn: gitDiscard, danger: true },
1926
+ { label: 'ステージに追加', icon: '+', fn: gitStage }]);
1927
+ }
1928
+
1929
+ async function gitStage(path) {
1930
+ try {
1931
+ await gitPost('/edit/git/stage', { path });
1932
+ await refreshGitPanel();
1933
+ } catch (err) { showError(err.message, 'ステージに失敗しました'); }
1934
+ }
1935
+
1936
+ async function gitUnstage(path) {
1937
+ try {
1938
+ await gitPost('/edit/git/unstage', { path });
1939
+ await refreshGitPanel();
1940
+ } catch (err) { showError(err.message, 'ステージ取り消しに失敗しました'); }
1941
+ }
1942
+
1943
+ async function gitStageAll() {
1944
+ try {
1945
+ await gitPost('/edit/git/stage_all');
1946
+ await refreshGitPanel();
1947
+ } catch (err) { showError(err.message, 'ステージに失敗しました'); }
1948
+ }
1949
+
1950
+ async function gitUnstageAll() {
1951
+ try {
1952
+ await gitPost('/edit/git/unstage_all');
1953
+ await refreshGitPanel();
1954
+ } catch (err) { showError(err.message, 'ステージ取り消しに失敗しました'); }
1955
+ }
1956
+
1957
+ // 変更の破棄は元に戻せない (未追跡ファイルは削除される) ため確認を挟む。
1958
+ async function gitDiscard(path) {
1959
+ const ok = await showConfirm('変更を破棄', 'この変更を破棄しますか? この操作は元に戻せません。\n' + path);
1960
+ if (!ok) return;
1961
+ try {
1962
+ await gitPost('/edit/git/discard', { path });
1963
+ await refreshGitPanel();
1964
+ } catch (err) { showError(err.message, '変更の破棄に失敗しました'); }
1965
+ }
1966
+
1967
+ async function gitDiscardAll() {
1968
+ const unstaged = [...document.querySelectorAll('#git-unstaged-list .git-file-path')]
1969
+ .map(el => el.textContent);
1970
+ if (!unstaged.length) return;
1971
+ const ok = await showConfirm('すべての変更を破棄',
1972
+ 'すべての変更 (' + unstaged.length + ' 件) を破棄しますか? この操作は元に戻せません。');
1973
+ if (!ok) return;
1974
+ try {
1975
+ await gitPost('/edit/git/discard', { paths: unstaged });
1976
+ await refreshGitPanel();
1977
+ } catch (err) { showError(err.message, '変更の破棄に失敗しました'); }
1978
+ }
1979
+
1980
+ async function gitInit() {
1981
+ try {
1982
+ await gitPost('/edit/git/init');
1983
+ await refreshGitPanel();
1984
+ } catch (err) { showError(err.message, 'Git の初期化に失敗しました'); }
1985
+ }
1986
+
1987
+ async function gitCommit() {
1988
+ const input = document.getElementById('git-commit-message');
1989
+ const message = input.value.trim();
1990
+ if (!message) { showError('コミットメッセージを入力してください', 'コミットできません'); return; }
1991
+ try {
1992
+ await gitPost('/edit/git/commit', { message });
1993
+ input.value = '';
1994
+ await refreshGitPanel();
1995
+ } catch (err) { showError(err.message, 'コミットに失敗しました'); }
1996
+ }
1997
+
1998
+ async function gitPush() {
1999
+ const btn = document.getElementById('btn-git-push');
2000
+ btn.disabled = true;
2001
+ try {
2002
+ await gitPost('/edit/git/push');
2003
+ } catch (err) {
2004
+ // リモート/上流ブランチ未設定はエラーダイアログではなく URL 入力ダイアログを開く。
2005
+ if (err.reason === 'no_remote') { openRemoteDialog(); return; }
2006
+ showError(err.message, 'プッシュに失敗しました');
2007
+ } finally {
2008
+ btn.disabled = false;
2009
+ }
2010
+ }
2011
+
2012
+ document.getElementById('btn-git-refresh').onclick = () => refreshGitPanel();
2013
+ document.getElementById('btn-git-init').onclick = () => gitInit();
2014
+ document.getElementById('git-commit-form').onsubmit = (e) => { e.preventDefault(); gitCommit(); };
2015
+ document.getElementById('btn-git-push').onclick = () => gitPush();
2016
+ document.getElementById('btn-git-stage-all').onclick = () => gitStageAll();
2017
+ document.getElementById('btn-git-unstage-all').onclick = () => gitUnstageAll();
2018
+ document.getElementById('btn-git-discard-all').onclick = () => gitDiscardAll();
2019
+
2020
+ // 作業ディレクトリの Git 状態を定期的に監視する (ファイル一覧のポーリングと同様)。
2021
+ const GIT_POLL_MS = 3000;
2022
+ setInterval(refreshGitPanel, GIT_POLL_MS);
2023
+
2024
+ // ---- ブランチ切り替え/作成ダイアログ ----
2025
+ const branchDialog = document.getElementById('branch-dialog');
2026
+ const branchList = document.getElementById('branch-list');
2027
+ const branchNewName = document.getElementById('branch-new-name');
2028
+ const branchError = document.getElementById('branch-error');
2029
+
2030
+ async function openBranchDialog() {
2031
+ branchError.textContent = '';
2032
+ branchNewName.value = '';
2033
+ branchList.innerHTML = '';
2034
+ centerWindow(branchDialog);
2035
+ branchNewName.focus();
2036
+ try {
2037
+ const data = await gitFetch('/edit/git/branches');
2038
+ renderBranchList(data.branches || [], data.current);
2039
+ } catch (err) {
2040
+ branchError.textContent = err.message;
2041
+ }
2042
+ }
2043
+
2044
+ function renderBranchList(branches, current) {
2045
+ branchList.innerHTML = '';
2046
+ branches.forEach(name => {
2047
+ const li = document.createElement('li');
2048
+ li.className = 'branch-item' + (name === current ? ' current' : '');
2049
+ const check = document.createElement('span');
2050
+ check.className = 'branch-check';
2051
+ check.textContent = name === current ? '✓' : '';
2052
+ const label = document.createElement('span');
2053
+ label.textContent = name;
2054
+ li.appendChild(check);
2055
+ li.appendChild(label);
2056
+ li.onclick = () => gitCheckout(name, false);
2057
+ branchList.appendChild(li);
2058
+ });
2059
+ }
2060
+
2061
+ function closeBranchDialog() { branchDialog.hidden = true; }
2062
+
2063
+ async function gitCheckout(branch, create) {
2064
+ branchError.textContent = '';
2065
+ try {
2066
+ await gitPost('/edit/git/checkout', { branch, create });
2067
+ closeBranchDialog();
2068
+ await refreshGitPanel();
2069
+ } catch (err) {
2070
+ branchError.textContent = err.message;
2071
+ }
2072
+ }
2073
+
2074
+ document.getElementById('statusbar-branch').onclick = () => openBranchDialog();
2075
+ document.getElementById('branch-cancel').onclick = closeBranchDialog;
2076
+ document.getElementById('branch-close').onclick = closeBranchDialog;
2077
+ document.getElementById('branch-create').onclick = () => {
2078
+ const name = branchNewName.value.trim();
2079
+ if (!name) { branchError.textContent = '新しいブランチ名を入力してください'; return; }
2080
+ gitCheckout(name, true);
2081
+ };
2082
+ branchNewName.addEventListener('keydown', (e) => {
2083
+ if (e.key === 'Enter') { e.preventDefault(); document.getElementById('branch-create').click(); }
2084
+ else if (e.key === 'Escape') { e.preventDefault(); closeBranchDialog(); }
2085
+ });
2086
+ initDraggableWindow(branchDialog);
2087
+
2088
+ // ---- リモート URL 入力ダイアログ (プッシュ先未設定時に開く) ----
2089
+ const remoteDialog = document.getElementById('remote-dialog');
2090
+ const remoteUrlInput = document.getElementById('remote-url');
2091
+ const remoteError = document.getElementById('remote-error');
2092
+
2093
+ function openRemoteDialog() {
2094
+ remoteError.textContent = '';
2095
+ remoteUrlInput.value = '';
2096
+ centerWindow(remoteDialog);
2097
+ remoteUrlInput.focus();
2098
+ }
2099
+
2100
+ function closeRemoteDialog() { remoteDialog.hidden = true; }
2101
+
2102
+ async function confirmRemoteSet() {
2103
+ const url = remoteUrlInput.value.trim();
2104
+ if (!url) { remoteError.textContent = 'リモート URL を入力してください'; return; }
2105
+ remoteError.textContent = '';
2106
+ try {
2107
+ await gitPost('/edit/git/remote', { url });
2108
+ closeRemoteDialog();
2109
+ await refreshGitPanel();
2110
+ } catch (err) {
2111
+ remoteError.textContent = err.message;
2112
+ }
2113
+ }
2114
+
2115
+ document.getElementById('remote-ok').onclick = () => confirmRemoteSet();
2116
+ document.getElementById('remote-cancel').onclick = closeRemoteDialog;
2117
+ document.getElementById('remote-close').onclick = closeRemoteDialog;
2118
+ remoteUrlInput.addEventListener('keydown', (e) => {
2119
+ if (e.key === 'Enter') { e.preventDefault(); confirmRemoteSet(); }
2120
+ else if (e.key === 'Escape') { e.preventDefault(); closeRemoteDialog(); }
2121
+ });
2122
+ initDraggableWindow(remoteDialog);
2123
+
2124
+ // ---- 起動 ----
2125
+ loadFiles(true);
2126
+ initConsole();
2127
+ initResizers();
2128
+ initMenu();
2129
+ initPanelView();
2130
+ initFilesTab();
2131
+ setAutoSave(autoSaveEnabled); // メニューのチェック表示を保存済み設定に合わせる
2132
+ refreshGitPanel();
2133
+
2134
+ // 自動保存 (debounce) が確定する前にタブを閉じようとした場合に警告する。
2135
+ window.addEventListener('beforeunload', (e) => {
2136
+ if (isDirty) { e.preventDefault(); e.returnValue = ''; }
2137
+ });