chocomint 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1e486a61190ec1d3548de09e65d74ea64d42a664c3d536c0e8013f61e6e2c1c
4
- data.tar.gz: b7e23eedd909b6d2dffaeaa341721e7ccbd3bc91a2556a87d2654991c9475443
3
+ metadata.gz: 43fbe75bcf172909a002314dbbd673417c25e2ab667f3e401eacb026988743e4
4
+ data.tar.gz: 68e6a6bf6ecd91251f5311a6c03742353e17ac4689c51b64cd35fe1d602e80f2
5
5
  SHA512:
6
- metadata.gz: 5234597d4d58068f04609037aaf53d058b48a0ddfea9a12240cc64c84ace0371b5277aee1442dcea8e1b8239449a7da0e32f976aca1672f4da3b5dfa90ee6645
7
- data.tar.gz: e42a3f878d613ce09722db0929d745d3aaf3fdda93698eeb691203834063a9bf3090d10bf5aca20f7797391b76305885fcf89c63e6c03c67ae58580520a46704
6
+ metadata.gz: ec1a8f844e984a0387bd6d2a22d66af477b99c6d99320904cc870a2bc115b7b9a3dd21135cab14a966920634f957f9b65657b1abe376d05c28b6e28a113408da
7
+ data.tar.gz: 9b8726ba1954c4eded4eaf811ed58c6c6db2ecb239286cb85b98be6263aefe11cdbea98e017a90b8a843495834ae385690b7cc0d3e0ccd00299c2f56b4e2846d
@@ -23,76 +23,81 @@ module Chocomint
23
23
  @path_guard.allowed_roots.first
24
24
  end
25
25
 
26
- # ---- ファイル一覧 -------------------------------------------------------
26
+ # ---- ファイル一覧 (遅延展開: 1階層ずつ返す) -----------------------------
27
27
 
28
- def handle_edit_files(req, res)
28
+ # 中身を展開しない VCS メタデータ・依存パッケージ等 (一覧を埋め尽くすため除外する)
29
+ # ディレクトリ名 (basename) で判定するため、"vendor/bundle" のようなパス階層は含めない。
30
+ EXCLUDED_TREE_DIRS = %w[.git .hg .svn .venv venv node_modules .bundle].freeze
31
+
32
+ # 1ディレクトリあたりの列挙上限。通常は到達しないが、数万エントリを持つ異常な
33
+ # ディレクトリでブラウザを固めないための安全弁。到達時は capped=true を返す。
34
+ MAX_DIR_ENTRIES = 5000
35
+
36
+ # WORKING DIRECTORY を遅延展開するためのエンドポイント。指定ディレクトリの
37
+ # 「直下 1 階層だけ」を列挙して返す。ツリー全体を一括列挙すると巨大ホーム
38
+ # (例: C:\Users\<name>) で応答が返らず表示が壊れるため、階層ごとに取得する。
39
+ #
40
+ # GET /edit/dir → ルート (workspace 直下) を列挙
41
+ # GET /edit/dir?path=src → src/ の直下を列挙
42
+ #
43
+ # path・返す entries[].path はいずれも base_dir 基準の相対パス (relative_root を含む)。
44
+ # /edit/file や /edit/fs/* と同じ表現なので、フロントは data-path をそのまま渡せる。
45
+ def handle_edit_dir(req, res)
29
46
  return edit_method_guard(res) unless req.request_method == "GET"
30
47
 
31
- files, empty_dirs = walk_workspace_tree
32
- edit_json(res, 200, "files" => files, "dirs" => empty_dirs,
33
- "root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
48
+ rel = req.query["path"].to_s.tr("\\", "/")
49
+ rel = "" if rel == "." || rel == relative_root
50
+ abs = rel.empty? ? edit_root : @path_guard.resolve(rel)
51
+ return edit_json(res, 404, "error" => "no such directory") unless File.directory?(abs)
52
+
53
+ entries, capped = list_dir_entries(abs, rel)
54
+ edit_json(res, 200, "path" => rel, "entries" => entries, "capped" => capped,
55
+ "root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
56
+ rescue Chocomint::PathAccessError => e
57
+ edit_json(res, 403, "error" => e.message)
58
+ rescue SystemCallError => e
59
+ # 権限不足などで開けないディレクトリ。空扱いにしてツリーを壊さない。
60
+ edit_json(res, 403, "error" => e.message)
34
61
  rescue Chocomint::Error => e
35
62
  edit_json(res, 500, "error" => e.message)
36
63
  end
37
64
 
38
- # workspace 配下を再帰列挙し、相対パスの配列で返す (ディレクトリは除く)。
39
- MAX_LISTED_FILES = 2000
65
+ # abs_dir の直下 1 階層を列挙し、[entries, capped] を返す。
66
+ # entries は { "name", "path", "dir" } の配列 (ディレクトリ→ファイルの順、各名前順)。
67
+ # rel_dir は abs_dir の base_dir 基準の相対パス ("" ならルート = edit_root)。
68
+ def list_dir_entries(abs_dir, rel_dir)
69
+ dirs = []
70
+ files = []
71
+ capped = false
40
72
 
41
- # 中身を展開しない VCS メタデータ・依存パッケージ等 (数千件で一覧を埋め尽くすため除外する)。
42
- # ディレクトリ名 (basename) で判定するため、"vendor/bundle" のようなパス階層は含めない。
43
- EXCLUDED_TREE_DIRS = %w[.git .hg .svn .venv venv node_modules .bundle].freeze
73
+ # ルート (rel_dir 空) の直下は base_dir 基準にするため relative_root を前置する
74
+ # (本番は "." のためプレフィックスなし、テスト等 edit_root != base_dir では "workspace" 等)。
75
+ prefix = rel_dir.empty? ? (relative_root == "." ? "" : relative_root) : rel_dir
44
76
 
45
- # workspace ツリーを1度だけ手動再帰し、[ファイル相対パス, 空ディレクトリ相対パス] を返す。
46
- #
47
- # Dir.glob("**/*") はツリー全体を舐めてから除外判定するため、node_modules を大量に
48
- # 含むホームディレクトリ (例: C:\Users\<name>) を起点にすると応答が返らず WORKING
49
- # DIRECTORY が空のままになる。除外ディレクトリはその配下へ降りる前に枝刈りし、
50
- # MAX_LISTED_FILES 到達で打ち切ることで巨大ツリーでも実用的な時間で応答する。
51
- def walk_workspace_tree
52
- root = edit_root
53
- return [[], []] unless File.directory?(root)
77
+ Dir.each_child(abs_dir) do |name|
78
+ next if name == "." || name == ".."
54
79
 
55
- files = []
56
- # 中身を1件も持たないディレクトリ (files からツリー復元できない) を別途フロントに伝える。
57
- empty_dirs = []
58
- stack = [""] # root からの相対ディレクトリ (root 自身は "")
59
-
60
- until stack.empty?
61
- break if files.size >= MAX_LISTED_FILES
80
+ rel = prefix.empty? ? name : "#{prefix}/#{name}"
81
+ abs = File.join(abs_dir, name)
62
82
 
63
- rel_dir = stack.pop
64
- abs_dir = rel_dir.empty? ? root : File.join(root, rel_dir)
83
+ if File.directory?(abs)
84
+ # 除外ディレクトリは一覧に出さない (中身も見せない)
85
+ next if EXCLUDED_TREE_DIRS.include?(name)
65
86
 
66
- child_count = 0
67
- begin
68
- Dir.each_child(abs_dir) do |name|
69
- next if name == "." || name == ".."
70
-
71
- rel = rel_dir.empty? ? name : "#{rel_dir}/#{name}"
72
- abs = File.join(abs_dir, name)
73
-
74
- if File.directory?(abs)
75
- child_count += 1
76
- # 除外ディレクトリは配下へ降りない (中身も一覧に出さない)。
77
- next if EXCLUDED_TREE_DIRS.include?(name)
78
-
79
- stack.push(rel)
80
- elsif File.file?(abs)
81
- child_count += 1
82
- files << rel
83
- break if files.size >= MAX_LISTED_FILES
84
- end
85
- end
86
- rescue SystemCallError
87
- # 権限不足などで開けないディレクトリはスキップする (ホーム配下で発生しうる)。
88
- next
87
+ dirs << { "name" => name, "path" => rel, "dir" => true }
88
+ elsif File.file?(abs)
89
+ files << { "name" => name, "path" => rel, "dir" => false }
89
90
  end
90
91
 
91
- # 子を1件も持たないディレクトリはツリーに現れないため、明示的に伝える (root は除く)。
92
- empty_dirs << rel_dir if child_count.zero? && !rel_dir.empty?
92
+ if dirs.size + files.size >= MAX_DIR_ENTRIES
93
+ capped = true
94
+ break
95
+ end
93
96
  end
94
97
 
95
- [files.sort, empty_dirs.sort]
98
+ dirs.sort_by! { |e| e["name"] }
99
+ files.sort_by! { |e| e["name"] }
100
+ [dirs + files, capped]
96
101
  end
97
102
 
98
103
  # ---- ファイル取得 -------------------------------------------------------
@@ -61,7 +61,7 @@ module Chocomint
61
61
  server.mount_proc("/edit") { |req, res| handle_edit(req, res) }
62
62
  server.mount_proc("/edit/assets") { |req, res| handle_edit_asset(req, res) }
63
63
  server.mount_proc("/edit/static") { |req, res| handle_edit_static(req, res) }
64
- server.mount_proc("/edit/files") { |req, res| handle_edit_files(req, res) }
64
+ server.mount_proc("/edit/dir") { |req, res| handle_edit_dir(req, res) }
65
65
  server.mount_proc("/edit/file") { |req, res| handle_edit_file(req, res) }
66
66
  server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
67
67
  server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
data/lib/chocomint.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chocomint
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.3"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
data/public/edit/edit.css CHANGED
@@ -106,7 +106,10 @@ html, body { margin: 0; height: 100%;
106
106
  #files li.dir.open > ul { display: block; }
107
107
  #files .row { display: flex; align-items: center; gap: 0.2rem;
108
108
  padding: 0.25rem 0.7rem; font-size: 0.83rem; cursor: pointer;
109
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
109
+ min-width: 0; white-space: nowrap; overflow: hidden; }
110
+ /* ファイル名だけを省略 (…) する。twist / icon は縮めない。 */
111
+ #files .row > span:not(.name) { flex: none; }
112
+ #files .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
110
113
  #files .row:hover { background: #e3f4f6; }
111
114
  #files li.file.active > .row { background: #a2d7dd; font-weight: 600; }
112
115
  #files li.dir.selected > .row { background: #d3ecee; font-weight: 600; }
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: '🐍',
@@ -215,51 +186,128 @@ function fileIcon(name) {
215
186
  return FILE_ICONS[ext] || '📄';
216
187
  }
217
188
 
218
- // ツリーノードを <ul> にレンダリング。prefix は現ディレクトリの相対パス。
219
- function renderTree(node, prefix) {
189
+ // ---- 遅延展開ツリー -------------------------------------------------------
190
+ // ツリー全体を一括取得すると巨大ホーム (C:\Users\<name> ) で壊れるため、
191
+ // 1 階層ずつ /edit/dir?path= で取得して描画する。フォルダを開いたときに初めて
192
+ // その中身を取得する。
193
+
194
+ // 各展開済みディレクトリの「直下 entries の署名」(変化検知用)。dir 相対パス → 署名文字列。
195
+ // ルートは "" をキーにする。ポーリング/更新時に署名が変わった階層だけ描き直す。
196
+ const dirSigs = new Map();
197
+
198
+ // /edit/dir?path=dir を取得して { entries, capped, absRoot } を返す。失敗時は null。
199
+ async function fetchDir(dir) {
200
+ try {
201
+ const q = dir ? '?path=' + encodeURIComponent(dir) : '';
202
+ const r = await fetch('/edit/dir' + q);
203
+ if (!r.ok) return null;
204
+ const data = await r.json();
205
+ if (data.abs_root) absRoot = data.abs_root;
206
+ return data;
207
+ } catch (e) { return null; } // ポーリング中の一時的な失敗は無視する。
208
+ }
209
+
210
+ // entries の署名 (name+dir をソート結合)。直下の顔ぶれが変わったかだけを見る。
211
+ function entriesSig(entries) {
212
+ return (entries || []).map(e => (e.dir ? 'd:' : 'f:') + e.name).sort().join('\n');
213
+ }
214
+
215
+ // 1 階層分の entries を <ul> に描画して返す。dir は親ディレクトリの相対パス ("" ならルート)。
216
+ // ディレクトリは (openDirs に含まれれば) 展開状態で子 <ul> も再帰的に埋める。
217
+ function renderEntries(entries, dir) {
220
218
  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
- });
219
+ for (const ent of entries) {
220
+ if (ent.dir) ul.appendChild(makeDirLi(ent));
221
+ else ul.appendChild(makeFileLi(ent));
222
+ }
260
223
  return ul;
261
224
  }
262
225
 
226
+ // ディレクトリ <li> を作る。展開中 (openDirs) なら子 <ul> を非同期に埋める。
227
+ function makeDirLi(ent) {
228
+ const dirPath = ent.path;
229
+ const li = document.createElement('li');
230
+ li.className = 'dir';
231
+ li.dataset.path = dirPath;
232
+ const row = document.createElement('div');
233
+ row.className = 'row';
234
+ row.innerHTML = '<span class="twist">▶</span><span class="icon">📁</span>';
235
+ const nameSpan = document.createElement('span');
236
+ nameSpan.className = 'name';
237
+ nameSpan.textContent = ent.name;
238
+ row.appendChild(nameSpan);
239
+ row.onclick = (e) => {
240
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, dirPath); return; }
241
+ if (openDirs.has(dirPath)) collapseDir(li, dirPath);
242
+ else expandDir(li, dirPath);
243
+ selectDir(dirPath);
244
+ };
245
+ row.oncontextmenu = (e) => showContextMenu(e, dirPath, true);
246
+ li.appendChild(row);
247
+ // 子を入れる <ul> を用意しておく (閉じているときは空)。
248
+ const childUl = document.createElement('ul');
249
+ childUl.className = 'children';
250
+ li.appendChild(childUl);
251
+ // 描画時点で開いている扱いなら、その場で中身を取得して展開する。
252
+ if (openDirs.has(dirPath)) {
253
+ li.classList.add('open');
254
+ fillDirChildren(li, dirPath);
255
+ }
256
+ return li;
257
+ }
258
+
259
+ // ファイル <li> を作る。
260
+ function makeFileLi(ent) {
261
+ const li = document.createElement('li');
262
+ li.className = 'file';
263
+ li.dataset.path = ent.path;
264
+ const row = document.createElement('div');
265
+ row.className = 'row';
266
+ row.innerHTML = `<span class="twist"></span><span class="icon">${fileIcon(ent.name)}</span>`;
267
+ const nameSpan = document.createElement('span');
268
+ nameSpan.className = 'name';
269
+ nameSpan.textContent = ent.name;
270
+ row.appendChild(nameSpan);
271
+ row.onclick = (e) => {
272
+ if (e.ctrlKey || e.metaKey || e.shiftKey) { handleMultiSelectClick(e, ent.path); return; }
273
+ openFile(ent.path, li);
274
+ };
275
+ row.oncontextmenu = (e) => showContextMenu(e, ent.path, false);
276
+ li.appendChild(row);
277
+ return li;
278
+ }
279
+
280
+ // li.children (子 <ul>) を /edit/dir で取得して描画する。dir は li の相対パス。
281
+ async function fillDirChildren(li, dir) {
282
+ const data = await fetchDir(dir);
283
+ const childUl = li.querySelector(':scope > ul.children');
284
+ if (!childUl) return; // 取得中に折りたたまれた等
285
+ if (!data) { childUl.replaceChildren(); dirSigs.delete(dir); return; }
286
+ dirSigs.set(dir, entriesSig(data.entries));
287
+ const fresh = renderEntries(data.entries, dir);
288
+ childUl.replaceChildren(...fresh.childNodes);
289
+ restoreHighlights();
290
+ }
291
+
292
+ // ディレクトリを展開する。openDirs に加え、子 <ul> を取得して埋める。
293
+ function expandDir(li, dir) {
294
+ openDirs.add(dir);
295
+ li.classList.add('open');
296
+ fillDirChildren(li, dir);
297
+ }
298
+
299
+ // ディレクトリを折りたたむ。子 <ul> を空にして状態を捨てる。
300
+ function collapseDir(li, dir) {
301
+ openDirs.delete(dir);
302
+ li.classList.remove('open');
303
+ const childUl = li.querySelector(':scope > ul.children');
304
+ if (childUl) childUl.replaceChildren();
305
+ // 配下の署名も破棄する (再展開時は取り直す)。
306
+ for (const key of [...dirSigs.keys()]) {
307
+ if (key === dir || key.startsWith(dir + '/')) dirSigs.delete(key);
308
+ }
309
+ }
310
+
263
311
  // ディレクトリを「新規作成の基準」として選択状態にする (ファイル選択のハイライトは解除する)。
264
312
  function selectDir(dirPath) {
265
313
  selectedDir = dirPath;
@@ -326,52 +374,95 @@ function handleMultiSelectClick(e, path) {
326
374
  renderMultiSelection();
327
375
  }
328
376
 
329
- // 直近に描画したファイル一覧の署名 (変化検知用)。ポーリングで無駄な再描画を避ける。
330
- let lastFilesSig = null;
331
- // workspace ルートの絶対パス (「絶対パスをコピー」用)。/edit/files のたびに更新する。
377
+ // workspace ルートの絶対パス (「絶対パスをコピー」用)。/edit/dir のたびに更新する。
332
378
  let absRoot = null;
333
379
 
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
-
380
+ // 再描画後、単一選択・複数選択のハイライトを DOM に復元する。
381
+ // 消えたパスは複数選択からも取り除く。遅延展開では階層描画のたびに呼ぶ。
382
+ function restoreHighlights() {
350
383
  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
384
  if (currentPath) {
360
385
  const active = container.querySelector('li.file[data-path="' + cssEscape(currentPath) + '"]');
361
386
  if (active) active.classList.add('active');
362
- } else if (selectedDir) {
387
+ }
388
+ if (selectedDir) {
363
389
  const selected = container.querySelector('li.dir[data-path="' + cssEscape(selectedDir) + '"]');
364
390
  if (selected) selected.classList.add('selected');
365
391
  }
366
- // 消えたパスは複数選択からも取り除いてから、残りのハイライトを復元する。
367
392
  const stillExists = (p) => container.querySelector('li[data-path="' + cssEscape(p) + '"]') != null;
368
393
  [...selectedPaths].forEach(p => { if (!stillExists(p)) selectedPaths.delete(p); });
369
394
  renderMultiSelection();
370
395
  }
371
396
 
372
- // 作業ディレクトリの内容を定期的に監視し、変化があれば一覧を更新する。
397
+ // ルート階層を取得して #file-list を描き直す。openDirs に残っている階層は
398
+ // renderEntries → makeDirLi 内で再帰的に取得・展開される。
399
+ async function renderRoot() {
400
+ const container = document.getElementById('file-list');
401
+ const data = await fetchDir('');
402
+ if (!data) return;
403
+ dirSigs.set('', entriesSig(data.entries));
404
+ const fresh = renderEntries(data.entries, '');
405
+ container.replaceChildren(...fresh.childNodes);
406
+ // 空白部の右クリックは workspace ルートへの新規作成メニューを出す。
407
+ container.oncontextmenu = (e) => {
408
+ if (e.target === container) showContextMenu(e, '', true);
409
+ };
410
+ restoreHighlights();
411
+ }
412
+
413
+ // ルートから描き直し、開いている階層も取り直す (更新ボタン・fs 操作・AI 実行後・初回)。
414
+ async function refreshTree() {
415
+ await renderRoot();
416
+ }
417
+
418
+ // dir の階層だけを取り直して差し替える (fs 操作後の局所更新)。
419
+ // dir が現在展開中 (ルート or openDirs) でなければ何もしない。
420
+ async function refreshDir(dir) {
421
+ const container = document.getElementById('file-list');
422
+ if (dir === '' || dir == null) { await renderRoot(); return; }
423
+ if (!openDirs.has(dir)) return; // 閉じている階層は描画対象外。
424
+ const li = container.querySelector('li.dir[data-path="' + cssEscape(dir) + '"]');
425
+ if (li) await fillDirChildren(li, dir);
426
+ }
427
+
428
+ // 展開中の各階層 (ルート + openDirs) を再取得し、直下の顔ぶれ (署名) が変わった階層だけ
429
+ // 描き直す。外部からのファイル追加/削除を自動反映しつつ、巨大ホームでも軽い。
430
+ async function refreshOpenDirs() {
431
+ const container = document.getElementById('file-list');
432
+ // ルート
433
+ const rootData = await fetchDir('');
434
+ if (rootData) {
435
+ const sig = entriesSig(rootData.entries);
436
+ if (sig !== dirSigs.get('')) {
437
+ dirSigs.set('', sig);
438
+ const fresh = renderEntries(rootData.entries, '');
439
+ container.replaceChildren(...fresh.childNodes);
440
+ restoreHighlights();
441
+ return; // ルートを描き直すと配下も makeDirLi 経由で取り直されるため以降は不要。
442
+ }
443
+ }
444
+ // 開いている各サブディレクトリ (浅い順に処理して親の再描画で子を巻き込まない)。
445
+ const openList = [...openDirs].sort((a, b) => a.split('/').length - b.split('/').length);
446
+ for (const dir of openList) {
447
+ const li = container.querySelector('li.dir[data-path="' + cssEscape(dir) + '"]');
448
+ if (!li) continue; // 親が閉じている等で DOM 上に無い。
449
+ const data = await fetchDir(dir);
450
+ if (!data) continue;
451
+ const sig = entriesSig(data.entries);
452
+ if (sig === dirSigs.get(dir)) continue; // 変化なし。
453
+ dirSigs.set(dir, sig);
454
+ const childUl = li.querySelector(':scope > ul.children');
455
+ if (childUl) {
456
+ const fresh = renderEntries(data.entries, dir);
457
+ childUl.replaceChildren(...fresh.childNodes);
458
+ }
459
+ }
460
+ restoreHighlights();
461
+ }
462
+
463
+ // 作業ディレクトリの内容を定期的に監視し、変化があれば展開中の階層だけ更新する。
373
464
  const FILES_POLL_MS = 3000;
374
- setInterval(() => loadFiles(false), FILES_POLL_MS);
465
+ setInterval(() => refreshOpenDirs(), FILES_POLL_MS);
375
466
 
376
467
  // querySelector 用に属性値をエスケープ (パスに特殊文字が入っても安全に)。
377
468
  function cssEscape(s) {
@@ -460,7 +551,7 @@ async function createEntry(dir, isDir) {
460
551
  try {
461
552
  await fsPost(isDir ? '/edit/fs/mkdir' : '/edit/fs/touch', { path });
462
553
  if (dir) openDirs.add(dir); // 作成先を開いて見せる
463
- await loadFiles();
554
+ await refreshDir(dir); // 作成先の階層だけ描き直す (dir="" ならルート)
464
555
  if (!isDir) openFileByPath(path); // 作ったファイルを開く
465
556
  } catch (err) { alert(err.message); }
466
557
  }
@@ -483,7 +574,11 @@ async function renameEntry(path) {
483
574
  }
484
575
  if (selectedDir === path) selectedDir = to;
485
576
  renderEditorTabs();
486
- await loadFiles();
577
+ // 移動元・移動先の親階層を描き直す (同じ親なら1回で済む)
578
+ const fromParent = parentDir(path);
579
+ const toParent = parentDir(to);
580
+ await refreshDir(fromParent);
581
+ if (toParent !== fromParent) await refreshDir(toParent);
487
582
  } catch (err) { alert(err.message); }
488
583
  }
489
584
 
@@ -494,7 +589,7 @@ async function deleteEntry(path, isDir) {
494
589
  try {
495
590
  await fsPost('/edit/fs/delete', { path });
496
591
  forgetDeletedPath(path);
497
- await loadFiles();
592
+ await refreshDir(parentDir(path)); // 削除対象の親階層だけ描き直す
498
593
  } catch (err) { alert(err.message); }
499
594
  }
500
595
 
@@ -545,16 +640,21 @@ async function deleteSelectedPaths(paths) {
545
640
  if (!ok) return;
546
641
 
547
642
  const errors = [];
643
+ const parents = new Set();
548
644
  for (const path of paths) {
549
645
  try {
550
646
  await fsPost('/edit/fs/delete', { path });
551
647
  forgetDeletedPath(path);
648
+ parents.add(parentDir(path));
552
649
  } catch (err) {
553
650
  errors.push(path + ': ' + err.message);
554
651
  }
555
652
  }
556
653
  clearMultiSelection();
557
- await loadFiles();
654
+ // 影響した親階層を浅い順に描き直す (親を描き直すと子は取り直されるため重複しても安全)
655
+ for (const dir of [...parents].sort((a, b) => a.split('/').length - b.split('/').length)) {
656
+ await refreshDir(dir);
657
+ }
558
658
  if (errors.length > 0) alert('一部の削除に失敗しました:\n' + errors.join('\n'));
559
659
  }
560
660
 
@@ -764,7 +864,7 @@ async function confirmSaveAs() {
764
864
  return;
765
865
  }
766
866
  closeSaveAsDialog();
767
- await loadFiles(true);
867
+ await refreshTree();
768
868
  // 新規タブとして開く (currentPath/パス表示/dirty は openFile 内で設定される)。
769
869
  await openFileByPath(path);
770
870
  setSaveStatus('saved');
@@ -892,7 +992,7 @@ initDraggableWindow(errorDialog);
892
992
 
893
993
  document.getElementById('btn-save').onclick = () => { closeAllMenus(); saveCurrent(); };
894
994
  document.getElementById('btn-autosave').onclick = () => { closeAllMenus(); setAutoSave(!autoSaveEnabled); };
895
- document.getElementById('btn-reload').onclick = () => loadFiles(true);
995
+ document.getElementById('btn-reload').onclick = () => refreshTree();
896
996
  document.getElementById('btn-new-file').onclick = () => createEntry(selectedDir, false);
897
997
  document.getElementById('btn-new-dir').onclick = () => createEntry(selectedDir, true);
898
998
  document.getElementById('btn-menu-new-file').onclick = () => { closeAllMenus(); createEntry(selectedDir, false); };
@@ -1444,7 +1544,7 @@ function applyResultSideEffects(data) {
1444
1544
  setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
1445
1545
  setDirty(false);
1446
1546
  }
1447
- loadFiles(true); // AI 実行後は確実に反映する (新規ファイルが増えている可能性)
1547
+ refreshTree(); // AI 実行後は確実に反映する (新規ファイルが増えている可能性)
1448
1548
  }
1449
1549
 
1450
1550
  // pending 行に既にツール実行の進捗が描画済みのとき、それを消さずにそのまま
@@ -2122,7 +2222,7 @@ remoteUrlInput.addEventListener('keydown', (e) => {
2122
2222
  initDraggableWindow(remoteDialog);
2123
2223
 
2124
2224
  // ---- 起動 ----
2125
- loadFiles(true);
2225
+ refreshTree();
2126
2226
  initConsole();
2127
2227
  initResizers();
2128
2228
  initMenu();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chocomint
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - ひかり (Himeyama)