chocomint 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/public/edit/edit.js CHANGED
@@ -121,35 +121,6 @@ let selectedPaths = new Set();
121
121
  // Shift 範囲選択の起点 (直前に通常/Ctrl/Shift クリックした行)。
122
122
  let lastClickedPath = null;
123
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
124
  // 拡張子ごとのファイルアイコン (絵文字)。未知の拡張子は汎用の📄。
154
125
  const FILE_ICONS = {
155
126
  py: '🐍',
@@ -194,12 +165,35 @@ const FILE_ICONS = {
194
165
  jpg: '🖼️',
195
166
  jpeg: '🖼️',
196
167
  gif: '🖼️',
168
+ webp: '🖼️',
169
+ bmp: '🖼️',
197
170
  svg: '🖼️',
198
171
  ico: '🖼️',
172
+ avif: '🖼️',
173
+ tif: '🖼️',
174
+ tiff: '🖼️',
199
175
  pdf: '📕',
176
+ mp4: '🎬',
177
+ m4v: '🎬',
178
+ webm: '🎬',
179
+ mkv: '🎬',
180
+ mov: '🎬',
181
+ avi: '🎬',
182
+ ogv: '🎬',
183
+ mp3: '🎵',
184
+ wav: '🎵',
185
+ ogg: '🎵',
186
+ m4a: '🎵',
187
+ aac: '🎵',
188
+ flac: '🎵',
189
+ opus: '🎵',
200
190
  zip: '📦',
201
191
  gz: '📦',
202
192
  tar: '📦',
193
+ tgz: '📦',
194
+ bz2: '📦',
195
+ tbz: '📦',
196
+ tbz2: '📦',
203
197
  lock: '🔒',
204
198
  gitignore: '🚫',
205
199
  dockerfile: '🐳',
@@ -215,51 +209,128 @@ function fileIcon(name) {
215
209
  return FILE_ICONS[ext] || '📄';
216
210
  }
217
211
 
218
- // ツリーノードを <ul> にレンダリング。prefix は現ディレクトリの相対パス。
219
- function renderTree(node, prefix) {
212
+ // ---- 遅延展開ツリー -------------------------------------------------------
213
+ // ツリー全体を一括取得すると巨大ホーム (C:\Users\<name> ) で壊れるため、
214
+ // 1 階層ずつ /edit/dir?path= で取得して描画する。フォルダを開いたときに初めて
215
+ // その中身を取得する。
216
+
217
+ // 各展開済みディレクトリの「直下 entries の署名」(変化検知用)。dir 相対パス → 署名文字列。
218
+ // ルートは "" をキーにする。ポーリング/更新時に署名が変わった階層だけ描き直す。
219
+ const dirSigs = new Map();
220
+
221
+ // /edit/dir?path=dir を取得して { entries, capped, absRoot } を返す。失敗時は null。
222
+ async function fetchDir(dir) {
223
+ try {
224
+ const q = dir ? '?path=' + encodeURIComponent(dir) : '';
225
+ const r = await fetch('/edit/dir' + q);
226
+ if (!r.ok) return null;
227
+ const data = await r.json();
228
+ if (data.abs_root) absRoot = data.abs_root;
229
+ return data;
230
+ } catch (e) { return null; } // ポーリング中の一時的な失敗は無視する。
231
+ }
232
+
233
+ // entries の署名 (name+dir をソート結合)。直下の顔ぶれが変わったかだけを見る。
234
+ function entriesSig(entries) {
235
+ return (entries || []).map(e => (e.dir ? 'd:' : 'f:') + e.name).sort().join('\n');
236
+ }
237
+
238
+ // 1 階層分の entries を <ul> に描画して返す。dir は親ディレクトリの相対パス ("" ならルート)。
239
+ // ディレクトリは (openDirs に含まれれば) 展開状態で子 <ul> も再帰的に埋める。
240
+ function renderEntries(entries, dir) {
220
241
  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
- });
242
+ for (const ent of entries) {
243
+ if (ent.dir) ul.appendChild(makeDirLi(ent));
244
+ else ul.appendChild(makeFileLi(ent));
245
+ }
260
246
  return ul;
261
247
  }
262
248
 
249
+ // ディレクトリ <li> を作る。展開中 (openDirs) なら子 <ul> を非同期に埋める。
250
+ function makeDirLi(ent) {
251
+ const dirPath = ent.path;
252
+ const li = document.createElement('li');
253
+ li.className = 'dir';
254
+ li.dataset.path = dirPath;
255
+ const row = document.createElement('div');
256
+ row.className = 'row';
257
+ row.innerHTML = '<span class="twist">▶</span><span class="icon">📁</span>';
258
+ const nameSpan = document.createElement('span');
259
+ nameSpan.className = 'name';
260
+ nameSpan.textContent = ent.name;
261
+ row.appendChild(nameSpan);
262
+ row.onclick = (e) => {
263
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, dirPath); return; }
264
+ if (openDirs.has(dirPath)) collapseDir(li, dirPath);
265
+ else expandDir(li, dirPath);
266
+ selectDir(dirPath);
267
+ };
268
+ row.oncontextmenu = (e) => showContextMenu(e, dirPath, true);
269
+ li.appendChild(row);
270
+ // 子を入れる <ul> を用意しておく (閉じているときは空)。
271
+ const childUl = document.createElement('ul');
272
+ childUl.className = 'children';
273
+ li.appendChild(childUl);
274
+ // 描画時点で開いている扱いなら、その場で中身を取得して展開する。
275
+ if (openDirs.has(dirPath)) {
276
+ li.classList.add('open');
277
+ fillDirChildren(li, dirPath);
278
+ }
279
+ return li;
280
+ }
281
+
282
+ // ファイル <li> を作る。
283
+ function makeFileLi(ent) {
284
+ const li = document.createElement('li');
285
+ li.className = 'file';
286
+ li.dataset.path = ent.path;
287
+ const row = document.createElement('div');
288
+ row.className = 'row';
289
+ row.innerHTML = `<span class="twist"></span><span class="icon">${fileIcon(ent.name)}</span>`;
290
+ const nameSpan = document.createElement('span');
291
+ nameSpan.className = 'name';
292
+ nameSpan.textContent = ent.name;
293
+ row.appendChild(nameSpan);
294
+ row.onclick = (e) => {
295
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, ent.path); return; }
296
+ openFile(ent.path, li);
297
+ };
298
+ row.oncontextmenu = (e) => showContextMenu(e, ent.path, false);
299
+ li.appendChild(row);
300
+ return li;
301
+ }
302
+
303
+ // li.children (子 <ul>) を /edit/dir で取得して描画する。dir は li の相対パス。
304
+ async function fillDirChildren(li, dir) {
305
+ const data = await fetchDir(dir);
306
+ const childUl = li.querySelector(':scope > ul.children');
307
+ if (!childUl) return; // 取得中に折りたたまれた等
308
+ if (!data) { childUl.replaceChildren(); dirSigs.delete(dir); return; }
309
+ dirSigs.set(dir, entriesSig(data.entries));
310
+ const fresh = renderEntries(data.entries, dir);
311
+ childUl.replaceChildren(...fresh.childNodes);
312
+ restoreHighlights();
313
+ }
314
+
315
+ // ディレクトリを展開する。openDirs に加え、子 <ul> を取得して埋める。
316
+ function expandDir(li, dir) {
317
+ openDirs.add(dir);
318
+ li.classList.add('open');
319
+ fillDirChildren(li, dir);
320
+ }
321
+
322
+ // ディレクトリを折りたたむ。子 <ul> を空にして状態を捨てる。
323
+ function collapseDir(li, dir) {
324
+ openDirs.delete(dir);
325
+ li.classList.remove('open');
326
+ const childUl = li.querySelector(':scope > ul.children');
327
+ if (childUl) childUl.replaceChildren();
328
+ // 配下の署名も破棄する (再展開時は取り直す)。
329
+ for (const key of [...dirSigs.keys()]) {
330
+ if (key === dir || key.startsWith(dir + '/')) dirSigs.delete(key);
331
+ }
332
+ }
333
+
263
334
  // ディレクトリを「新規作成の基準」として選択状態にする (ファイル選択のハイライトは解除する)。
264
335
  function selectDir(dirPath) {
265
336
  selectedDir = dirPath;
@@ -326,52 +397,95 @@ function handleMultiSelectClick(e, path) {
326
397
  renderMultiSelection();
327
398
  }
328
399
 
329
- // 直近に描画したファイル一覧の署名 (変化検知用)。ポーリングで無駄な再描画を避ける。
330
- let lastFilesSig = null;
331
- // workspace ルートの絶対パス (「絶対パスをコピー」用)。/edit/files のたびに更新する。
400
+ // workspace ルートの絶対パス (「絶対パスをコピー」用)。/edit/dir のたびに更新する。
332
401
  let absRoot = null;
333
402
 
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
-
403
+ // 再描画後、単一選択・複数選択のハイライトを DOM に復元する。
404
+ // 消えたパスは複数選択からも取り除く。遅延展開では階層描画のたびに呼ぶ。
405
+ function restoreHighlights() {
350
406
  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
407
  if (currentPath) {
360
408
  const active = container.querySelector('li.file[data-path="' + cssEscape(currentPath) + '"]');
361
409
  if (active) active.classList.add('active');
362
- } else if (selectedDir) {
410
+ }
411
+ if (selectedDir) {
363
412
  const selected = container.querySelector('li.dir[data-path="' + cssEscape(selectedDir) + '"]');
364
413
  if (selected) selected.classList.add('selected');
365
414
  }
366
- // 消えたパスは複数選択からも取り除いてから、残りのハイライトを復元する。
367
415
  const stillExists = (p) => container.querySelector('li[data-path="' + cssEscape(p) + '"]') != null;
368
416
  [...selectedPaths].forEach(p => { if (!stillExists(p)) selectedPaths.delete(p); });
369
417
  renderMultiSelection();
370
418
  }
371
419
 
372
- // 作業ディレクトリの内容を定期的に監視し、変化があれば一覧を更新する。
420
+ // ルート階層を取得して #file-list を描き直す。openDirs に残っている階層は
421
+ // renderEntries → makeDirLi 内で再帰的に取得・展開される。
422
+ async function renderRoot() {
423
+ const container = document.getElementById('file-list');
424
+ const data = await fetchDir('');
425
+ if (!data) return;
426
+ dirSigs.set('', entriesSig(data.entries));
427
+ const fresh = renderEntries(data.entries, '');
428
+ container.replaceChildren(...fresh.childNodes);
429
+ // 空白部の右クリックは workspace ルートへの新規作成メニューを出す。
430
+ container.oncontextmenu = (e) => {
431
+ if (e.target === container) showContextMenu(e, '', true);
432
+ };
433
+ restoreHighlights();
434
+ }
435
+
436
+ // ルートから描き直し、開いている階層も取り直す (更新ボタン・fs 操作・AI 実行後・初回)。
437
+ async function refreshTree() {
438
+ await renderRoot();
439
+ }
440
+
441
+ // dir の階層だけを取り直して差し替える (fs 操作後の局所更新)。
442
+ // dir が現在展開中 (ルート or openDirs) でなければ何もしない。
443
+ async function refreshDir(dir) {
444
+ const container = document.getElementById('file-list');
445
+ if (dir === '' || dir == null) { await renderRoot(); return; }
446
+ if (!openDirs.has(dir)) return; // 閉じている階層は描画対象外。
447
+ const li = container.querySelector('li.dir[data-path="' + cssEscape(dir) + '"]');
448
+ if (li) await fillDirChildren(li, dir);
449
+ }
450
+
451
+ // 展開中の各階層 (ルート + openDirs) を再取得し、直下の顔ぶれ (署名) が変わった階層だけ
452
+ // 描き直す。外部からのファイル追加/削除を自動反映しつつ、巨大ホームでも軽い。
453
+ async function refreshOpenDirs() {
454
+ const container = document.getElementById('file-list');
455
+ // ルート
456
+ const rootData = await fetchDir('');
457
+ if (rootData) {
458
+ const sig = entriesSig(rootData.entries);
459
+ if (sig !== dirSigs.get('')) {
460
+ dirSigs.set('', sig);
461
+ const fresh = renderEntries(rootData.entries, '');
462
+ container.replaceChildren(...fresh.childNodes);
463
+ restoreHighlights();
464
+ return; // ルートを描き直すと配下も makeDirLi 経由で取り直されるため以降は不要。
465
+ }
466
+ }
467
+ // 開いている各サブディレクトリ (浅い順に処理して親の再描画で子を巻き込まない)。
468
+ const openList = [...openDirs].sort((a, b) => a.split('/').length - b.split('/').length);
469
+ for (const dir of openList) {
470
+ const li = container.querySelector('li.dir[data-path="' + cssEscape(dir) + '"]');
471
+ if (!li) continue; // 親が閉じている等で DOM 上に無い。
472
+ const data = await fetchDir(dir);
473
+ if (!data) continue;
474
+ const sig = entriesSig(data.entries);
475
+ if (sig === dirSigs.get(dir)) continue; // 変化なし。
476
+ dirSigs.set(dir, sig);
477
+ const childUl = li.querySelector(':scope > ul.children');
478
+ if (childUl) {
479
+ const fresh = renderEntries(data.entries, dir);
480
+ childUl.replaceChildren(...fresh.childNodes);
481
+ }
482
+ }
483
+ restoreHighlights();
484
+ }
485
+
486
+ // 作業ディレクトリの内容を定期的に監視し、変化があれば展開中の階層だけ更新する。
373
487
  const FILES_POLL_MS = 3000;
374
- setInterval(() => loadFiles(false), FILES_POLL_MS);
488
+ setInterval(() => refreshOpenDirs(), FILES_POLL_MS);
375
489
 
376
490
  // querySelector 用に属性値をエスケープ (パスに特殊文字が入っても安全に)。
377
491
  function cssEscape(s) {
@@ -460,7 +574,7 @@ async function createEntry(dir, isDir) {
460
574
  try {
461
575
  await fsPost(isDir ? '/edit/fs/mkdir' : '/edit/fs/touch', { path });
462
576
  if (dir) openDirs.add(dir); // 作成先を開いて見せる
463
- await loadFiles();
577
+ await refreshDir(dir); // 作成先の階層だけ描き直す (dir="" ならルート)
464
578
  if (!isDir) openFileByPath(path); // 作ったファイルを開く
465
579
  } catch (err) { alert(err.message); }
466
580
  }
@@ -483,7 +597,11 @@ async function renameEntry(path) {
483
597
  }
484
598
  if (selectedDir === path) selectedDir = to;
485
599
  renderEditorTabs();
486
- await loadFiles();
600
+ // 移動元・移動先の親階層を描き直す (同じ親なら1回で済む)
601
+ const fromParent = parentDir(path);
602
+ const toParent = parentDir(to);
603
+ await refreshDir(fromParent);
604
+ if (toParent !== fromParent) await refreshDir(toParent);
487
605
  } catch (err) { alert(err.message); }
488
606
  }
489
607
 
@@ -494,7 +612,7 @@ async function deleteEntry(path, isDir) {
494
612
  try {
495
613
  await fsPost('/edit/fs/delete', { path });
496
614
  forgetDeletedPath(path);
497
- await loadFiles();
615
+ await refreshDir(parentDir(path)); // 削除対象の親階層だけ描き直す
498
616
  } catch (err) { alert(err.message); }
499
617
  }
500
618
 
@@ -517,18 +635,22 @@ function removeTab(path) {
517
635
  const wasActive = currentPath === path;
518
636
  if (wasActive && autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
519
637
  openTabs.splice(idx, 1);
520
- tab.model.dispose();
638
+ // メディアタブは Monaco モデルを持たない (model=null)。テキスト/バイナリのみ破棄する。
639
+ if (tab.model) tab.model.dispose();
521
640
  if (wasActive) {
522
641
  currentPath = null;
523
642
  const next = openTabs[idx] || openTabs[idx - 1];
524
643
  if (next) {
525
644
  activateEditorTab(next.path);
526
645
  } else {
646
+ hideMediaView();
647
+ hideArchiveView();
527
648
  editor.setModel(null);
528
649
  document.getElementById('app').classList.add('no-tabs');
529
650
  setPathLabel('(ファイル未選択)');
530
651
  setDirty(false);
531
652
  setSaveStatus('');
653
+ updateEolStatus();
532
654
  document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
533
655
  }
534
656
  }
@@ -545,16 +667,21 @@ async function deleteSelectedPaths(paths) {
545
667
  if (!ok) return;
546
668
 
547
669
  const errors = [];
670
+ const parents = new Set();
548
671
  for (const path of paths) {
549
672
  try {
550
673
  await fsPost('/edit/fs/delete', { path });
551
674
  forgetDeletedPath(path);
675
+ parents.add(parentDir(path));
552
676
  } catch (err) {
553
677
  errors.push(path + ': ' + err.message);
554
678
  }
555
679
  }
556
680
  clearMultiSelection();
557
- await loadFiles();
681
+ // 影響した親階層を浅い順に描き直す (親を描き直すと子は取り直されるため重複しても安全)
682
+ for (const dir of [...parents].sort((a, b) => a.split('/').length - b.split('/').length)) {
683
+ await refreshDir(dir);
684
+ }
558
685
  if (errors.length > 0) alert('一部の削除に失敗しました:\n' + errors.join('\n'));
559
686
  }
560
687
 
@@ -577,6 +704,199 @@ function openFileByPath(path) {
577
704
  return openFile(path, li);
578
705
  }
579
706
 
707
+ // ---- メディアプレビュー (画像 / PDF / 動画 / 音声) ----
708
+ // メディアタブがアクティブなときだけ #media-view を表示し、Monaco を隠す。
709
+ // 生ファイルは /edit/raw?path= から取得する (種別ごとの Content-Type / Range 対応)。
710
+
711
+ // 現在 #media-view に載せている <video>/<audio> を止めるために保持する。
712
+ let currentMediaEl = null;
713
+
714
+ function rawUrl(path) {
715
+ return '/edit/raw?path=' + encodeURIComponent(path);
716
+ }
717
+
718
+ function showMediaView(tab) {
719
+ const app = document.getElementById('app');
720
+ const view = document.getElementById('media-view');
721
+ stopCurrentMedia();
722
+ view.innerHTML = '';
723
+ app.classList.add('show-media');
724
+ // PDF は縁まで使いたいので専用クラスで padding を外す (CSS 側で制御)。
725
+ app.classList.toggle('media-pdf', tab.media === 'pdf');
726
+
727
+ const url = rawUrl(tab.path);
728
+ const name = tab.path.split('/').pop();
729
+ let el;
730
+ switch (tab.media) {
731
+ case 'image':
732
+ el = document.createElement('img');
733
+ el.className = 'media-image';
734
+ el.alt = name;
735
+ el.src = url;
736
+ el.onerror = () => showMediaError(view, '画像を読み込めませんでした');
737
+ view.appendChild(el);
738
+ break;
739
+ case 'pdf':
740
+ el = document.createElement('iframe');
741
+ el.className = 'media-pdf';
742
+ el.title = name;
743
+ el.src = url;
744
+ view.appendChild(el);
745
+ break;
746
+ case 'video':
747
+ el = document.createElement('video');
748
+ el.className = 'media-video';
749
+ el.src = url;
750
+ el.controls = true;
751
+ el.onerror = () => showMediaError(view,
752
+ 'この動画はブラウザで再生できない形式の可能性があります (' + name + ')');
753
+ view.appendChild(el);
754
+ currentMediaEl = el;
755
+ break;
756
+ case 'audio': {
757
+ const card = document.createElement('div');
758
+ card.className = 'media-audio-card';
759
+ const label = document.createElement('div');
760
+ label.className = 'media-audio-name';
761
+ label.textContent = '🎵 ' + name;
762
+ el = document.createElement('audio');
763
+ el.className = 'media-audio';
764
+ el.src = url;
765
+ el.controls = true;
766
+ el.onerror = () => showMediaError(view,
767
+ 'この音声はブラウザで再生できない形式の可能性があります (' + name + ')');
768
+ card.appendChild(label);
769
+ card.appendChild(el);
770
+ view.appendChild(card);
771
+ currentMediaEl = el;
772
+ break;
773
+ }
774
+ default:
775
+ showMediaError(view, 'このファイルはプレビューできません');
776
+ }
777
+ }
778
+
779
+ function showMediaError(view, message) {
780
+ const p = document.createElement('div');
781
+ p.className = 'media-error';
782
+ p.textContent = message;
783
+ view.appendChild(p);
784
+ }
785
+
786
+ // 再生中のメディアを止める (タブ切り替え/クローズ時に音が鳴り続けないように)。
787
+ function stopCurrentMedia() {
788
+ if (currentMediaEl) {
789
+ try { currentMediaEl.pause(); } catch (e) { /* 既に破棄済み等は無視 */ }
790
+ currentMediaEl = null;
791
+ }
792
+ }
793
+
794
+ function hideMediaView() {
795
+ const app = document.getElementById('app');
796
+ if (!app.classList.contains('show-media')) return;
797
+ stopCurrentMedia();
798
+ app.classList.remove('show-media', 'media-pdf');
799
+ document.getElementById('media-view').innerHTML = '';
800
+ }
801
+
802
+ // ---- アーカイブプレビュー (zip / tar / tar.gz / bz2 等) ----
803
+ // 第一階層 (トップレベル) のエントリとメタデータを /edit/archive?path= から取得して表示する。
804
+
805
+ // バイト数を人間可読 (KB/MB 等) に整形する。null/未定義は空文字。
806
+ function formatBytes(n) {
807
+ if (n == null || isNaN(n)) return '';
808
+ if (n < 1024) return n + ' B';
809
+ const units = ['KB', 'MB', 'GB', 'TB'];
810
+ let v = n / 1024, i = 0;
811
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
812
+ return (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ' + units[i];
813
+ }
814
+
815
+ // アーカイブ形式のバッジ表示名。
816
+ const ARCHIVE_KIND_LABELS = {
817
+ tar_or_zip: 'ARCHIVE', gz: 'GZIP', bz2: 'BZIP2'
818
+ };
819
+
820
+ async function showArchiveView(tab) {
821
+ const app = document.getElementById('app');
822
+ const view = document.getElementById('archive-view');
823
+ app.classList.add('show-archive');
824
+ const name = tab.path.split('/').pop();
825
+ view.innerHTML = '<div class="archive-empty">読み込み中…</div>';
826
+
827
+ let data;
828
+ try {
829
+ const r = await fetch('/edit/archive?path=' + encodeURIComponent(tab.path));
830
+ data = await r.json();
831
+ if (!r.ok) throw new Error(data.error || 'アーカイブを読み取れませんでした');
832
+ } catch (err) {
833
+ // 取得中にタブが切り替わっていたら描画しない。
834
+ if (currentPath !== tab.path) return;
835
+ view.innerHTML = '';
836
+ view.appendChild(el('div', 'archive-error', '⚠ ' + err.message));
837
+ return;
838
+ }
839
+ if (currentPath !== tab.path) return; // 取得中に別タブへ切り替わっていたら破棄。
840
+ renderArchive(view, name, data);
841
+ }
842
+
843
+ // 単純な要素生成ヘルパ (class と textContent 付き)。
844
+ function el(tag, cls, text) {
845
+ const e = document.createElement(tag);
846
+ if (cls) e.className = cls;
847
+ if (text != null) e.textContent = text;
848
+ return e;
849
+ }
850
+
851
+ function renderArchive(view, name, data) {
852
+ view.innerHTML = '';
853
+
854
+ // ヘッダー (ファイル名 + 形式バッジ)。
855
+ const head = el('div', 'archive-head');
856
+ head.appendChild(el('span', 'archive-name', '📦 ' + name));
857
+ head.appendChild(el('span', 'archive-kind', ARCHIVE_KIND_LABELS[data.archive] || 'ARCHIVE'));
858
+ view.appendChild(head);
859
+
860
+ // メタデータ行 (エントリ数 / 合計サイズ / 圧縮サイズ)。
861
+ const meta = el('div', 'archive-meta');
862
+ const entryCount = data.total_entries != null ? data.total_entries : (data.entries || []).length;
863
+ meta.appendChild(el('span', null, entryCount + ' エントリ'));
864
+ if (data.total_size != null) meta.appendChild(el('span', null, '展開後 ' + formatBytes(data.total_size)));
865
+ if (data.compressed_size != null) meta.appendChild(el('span', null, '圧縮 ' + formatBytes(data.compressed_size)));
866
+ if (data.capped) meta.appendChild(el('span', 'archive-capped', '(一部のみ表示)'));
867
+ view.appendChild(meta);
868
+
869
+ // エントリ一覧 (第一階層)。
870
+ const entries = data.entries || [];
871
+ if (entries.length === 0) {
872
+ view.appendChild(el('div', 'archive-empty', '(エントリがありません)'));
873
+ return;
874
+ }
875
+ const ul = el('ul', 'archive-list');
876
+ for (const ent of entries) {
877
+ const li = el('li');
878
+ li.appendChild(el('span', 'a-icon', ent.dir ? '📁' : fileIcon(ent.name)));
879
+ li.appendChild(el('span', 'a-name', ent.name + (ent.dir ? '/' : '')));
880
+ // ディレクトリは配下件数、ファイルはサイズを右側に添える。
881
+ let sub = '';
882
+ if (ent.dir) {
883
+ if (ent.child_count != null) sub = ent.child_count + ' 項目';
884
+ } else if (ent.size != null) {
885
+ sub = formatBytes(ent.size);
886
+ }
887
+ if (sub) li.appendChild(el('span', 'a-sub', sub));
888
+ ul.appendChild(li);
889
+ }
890
+ view.appendChild(ul);
891
+ }
892
+
893
+ function hideArchiveView() {
894
+ const app = document.getElementById('app');
895
+ if (!app.classList.contains('show-archive')) return;
896
+ app.classList.remove('show-archive');
897
+ document.getElementById('archive-view').innerHTML = '';
898
+ }
899
+
580
900
  async function openFile(path, li) {
581
901
  if (!editor) return;
582
902
  // 既に開いているファイルなら、そのタブへ切り替えるだけ (再フェッチしない)。
@@ -586,6 +906,24 @@ async function openFile(path, li) {
586
906
  const data = await r.json();
587
907
  if (!r.ok) { alert(data.error || 'open failed'); return; }
588
908
 
909
+ // 画像 / PDF / 動画 / 音声はメディアタブとして開く (Monaco モデルは持たない)。
910
+ if (data.media) {
911
+ const tab = { path, model: null, viewState: null, dirty: false,
912
+ binary: false, media: data.media, archive: false };
913
+ openTabs.push(tab);
914
+ activateEditorTab(path, li);
915
+ return;
916
+ }
917
+
918
+ // zip / tar 等のアーカイブは第一階層一覧タブとして開く (Monaco モデルは持たない)。
919
+ if (data.archive) {
920
+ const tab = { path, model: null, viewState: null, dirty: false,
921
+ binary: false, media: null, archive: true };
922
+ openTabs.push(tab);
923
+ activateEditorTab(path, li);
924
+ return;
925
+ }
926
+
589
927
  // 新しいモデルを作ってタブに紐づける (以後このモデルが内容・undo 履歴を保持する)。
590
928
  const binary = !!data.binary;
591
929
  const content = binary
@@ -593,11 +931,45 @@ async function openFile(path, li) {
593
931
  : (data.content || '');
594
932
  const lang = binary ? 'plaintext' : langOf(path);
595
933
  const model = monaco.editor.createModel(content, lang);
596
- const tab = { path, model, viewState: null, dirty: false, binary };
934
+ // 元ファイルの改行コードをモデルへ反映する (Monaco は既定で LF に正規化してしまうため、
935
+ // CRLF のファイルを開いて保存すると LF に化ける。開いた時点の実際の改行を尊重する)。
936
+ if (!binary) {
937
+ const crlf = /\r\n/.test(content);
938
+ model.setEOL(crlf ? monaco.editor.EndOfLineSequence.CRLF
939
+ : monaco.editor.EndOfLineSequence.LF);
940
+ }
941
+ const tab = { path, model, viewState: null, dirty: false, binary, media: null, archive: false };
597
942
  openTabs.push(tab);
598
943
  activateEditorTab(path, li);
599
944
  }
600
945
 
946
+ // ステータスバーの改行コード表示を更新する。テキストを編集できるタブ (通常タブ) の
947
+ // ときだけ CRLF/LF を表示し、それ以外 (未選択・メディア・アーカイブ・バイナリ) では隠す。
948
+ function updateEolStatus() {
949
+ const btn = document.getElementById('statusbar-eol');
950
+ const nameEl = document.getElementById('statusbar-eol-name');
951
+ if (!btn || !nameEl) return;
952
+ const tab = currentPath ? findTab(currentPath) : null;
953
+ const editable = tab && tab.model && !tab.media && !tab.archive && !tab.binary;
954
+ if (!editable) { btn.hidden = true; return; }
955
+ btn.hidden = false;
956
+ nameEl.textContent = tab.model.getEOL() === '\r\n' ? 'CRLF' : 'LF';
957
+ }
958
+
959
+ // ステータスバーの改行コードをクリックしたときに CRLF ⇄ LF を切り替える。
960
+ // 切り替えは内容の編集にあたるので dirty 扱いにし、自動保存の有無はエディタ設定に従う
961
+ // (setDirty → scheduleAutoSave。自動保存が OFF なら手動 Ctrl+S まで保存されない)。
962
+ function toggleEol() {
963
+ const tab = currentPath ? findTab(currentPath) : null;
964
+ if (!tab || !tab.model || tab.media || tab.archive || tab.binary) return;
965
+ const toCrlf = tab.model.getEOL() !== '\r\n';
966
+ tab.model.setEOL(toCrlf ? monaco.editor.EndOfLineSequence.CRLF
967
+ : monaco.editor.EndOfLineSequence.LF);
968
+ updateEolStatus();
969
+ setDirty(true);
970
+ scheduleAutoSave();
971
+ }
972
+
601
973
  // 指定パスのエディタタブをアクティブにする。Monaco のモデルを差し替え、ビュー状態を復元する。
602
974
  // (ターミナルタブ用の activateTab とは別関数なので名前を分けている)
603
975
  function activateEditorTab(path, li) {
@@ -617,15 +989,34 @@ function activateEditorTab(path, li) {
617
989
  setPathLabel(path);
618
990
 
619
991
  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();
992
+ if (tab.media) {
993
+ // メディアタブ: Monaco はモデルを外して隠し、メディアビューアを表示する。
994
+ hideArchiveView();
995
+ suppressChangeEvent = true;
996
+ editor.setModel(null);
997
+ suppressChangeEvent = false;
998
+ showMediaView(tab);
999
+ } else if (tab.archive) {
1000
+ // アーカイブタブ: Monaco を隠し、第一階層一覧を表示する。
1001
+ hideMediaView();
1002
+ suppressChangeEvent = true;
1003
+ editor.setModel(null);
1004
+ suppressChangeEvent = false;
1005
+ showArchiveView(tab);
1006
+ } else {
1007
+ hideMediaView();
1008
+ hideArchiveView();
1009
+ suppressChangeEvent = true;
1010
+ editor.setModel(tab.model);
1011
+ suppressChangeEvent = false;
1012
+ if (tab.viewState) editor.restoreViewState(tab.viewState);
1013
+ editor.updateOptions({ readOnly: tab.binary });
1014
+ editor.focus();
1015
+ }
626
1016
 
627
1017
  isDirty = tab.dirty;
628
1018
  setSaveStatus('');
1019
+ updateEolStatus();
629
1020
  renderEditorTabs();
630
1021
 
631
1022
  // ファイル一覧側のハイライトを同期する (li が渡されなければパスから探す)。
@@ -693,6 +1084,9 @@ async function saveCurrent(opts) {
693
1084
  // ファイル未選択で手動保存 (Ctrl+S / メニュー) されたときは、保存先を尋ねる
694
1085
  // ダイアログを開く (自動保存はそもそも未選択時は動かないので auto では出さない)。
695
1086
  if (!currentPath) { if (!auto) openSaveAsDialog(); return; }
1087
+ // メディア/アーカイブタブは編集対象ではないので保存しない。
1088
+ const cur = findTab(currentPath);
1089
+ if (cur && (cur.media || cur.archive)) return;
696
1090
  // バイナリ表示中 (readOnly) は保存しない (プレースホルダで上書きしないため)。
697
1091
  if (editor.getRawOptions && editor.getRawOptions().readOnly) return;
698
1092
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
@@ -764,7 +1158,7 @@ async function confirmSaveAs() {
764
1158
  return;
765
1159
  }
766
1160
  closeSaveAsDialog();
767
- await loadFiles(true);
1161
+ await refreshTree();
768
1162
  // 新規タブとして開く (currentPath/パス表示/dirty は openFile 内で設定される)。
769
1163
  await openFileByPath(path);
770
1164
  setSaveStatus('saved');
@@ -892,7 +1286,7 @@ initDraggableWindow(errorDialog);
892
1286
 
893
1287
  document.getElementById('btn-save').onclick = () => { closeAllMenus(); saveCurrent(); };
894
1288
  document.getElementById('btn-autosave').onclick = () => { closeAllMenus(); setAutoSave(!autoSaveEnabled); };
895
- document.getElementById('btn-reload').onclick = () => loadFiles(true);
1289
+ document.getElementById('btn-reload').onclick = () => refreshTree();
896
1290
  document.getElementById('btn-new-file').onclick = () => createEntry(selectedDir, false);
897
1291
  document.getElementById('btn-new-dir').onclick = () => createEntry(selectedDir, true);
898
1292
  document.getElementById('btn-menu-new-file').onclick = () => { closeAllMenus(); createEntry(selectedDir, false); };
@@ -1439,12 +1833,14 @@ function addCompactSummary(text) {
1439
1833
 
1440
1834
  // 最終結果に伴うエディタ反映とファイル一覧更新 (描画とは分離した副作用処理)。
1441
1835
  function applyResultSideEffects(data) {
1442
- if (data.content != null && editor && currentPath) {
1836
+ const curTab = currentPath ? findTab(currentPath) : null;
1837
+ // メディア/アーカイブタブ表示中は Monaco にモデルが無いので content を流し込まない。
1838
+ if (data.content != null && editor && currentPath && !(curTab && (curTab.media || curTab.archive))) {
1443
1839
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
1444
1840
  setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
1445
1841
  setDirty(false);
1446
1842
  }
1447
- loadFiles(true); // AI 実行後は確実に反映する (新規ファイルが増えている可能性)
1843
+ refreshTree(); // AI 実行後は確実に反映する (新規ファイルが増えている可能性)
1448
1844
  }
1449
1845
 
1450
1846
  // pending 行に既にツール実行の進捗が描画済みのとき、それを消さずにそのまま
@@ -2072,6 +2468,7 @@ async function gitCheckout(branch, create) {
2072
2468
  }
2073
2469
 
2074
2470
  document.getElementById('statusbar-branch').onclick = () => openBranchDialog();
2471
+ document.getElementById('statusbar-eol').onclick = () => toggleEol();
2075
2472
  document.getElementById('branch-cancel').onclick = closeBranchDialog;
2076
2473
  document.getElementById('branch-close').onclick = closeBranchDialog;
2077
2474
  document.getElementById('branch-create').onclick = () => {
@@ -2122,7 +2519,7 @@ remoteUrlInput.addEventListener('keydown', (e) => {
2122
2519
  initDraggableWindow(remoteDialog);
2123
2520
 
2124
2521
  // ---- 起動 ----
2125
- loadFiles(true);
2522
+ refreshTree();
2126
2523
  initConsole();
2127
2524
  initResizers();
2128
2525
  initMenu();