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 +4 -4
- data/lib/chocomint/edit_handlers.rb +59 -54
- data/lib/chocomint/server.rb +1 -1
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +4 -1
- data/public/edit/edit.js +209 -109
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43fbe75bcf172909a002314dbbd673417c25e2ab667f3e401eacb026988743e4
|
|
4
|
+
data.tar.gz: 68e6a6bf6ecd91251f5311a6c03742353e17ac4689c51b64cd35fe1d602e80f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
#
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
83
|
+
if File.directory?(abs)
|
|
84
|
+
# 除外ディレクトリは一覧に出さない (中身も見せない)。
|
|
85
|
+
next if EXCLUDED_TREE_DIRS.include?(name)
|
|
65
86
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
92
|
-
|
|
92
|
+
if dirs.size + files.size >= MAX_DIR_ENTRIES
|
|
93
|
+
capped = true
|
|
94
|
+
break
|
|
95
|
+
end
|
|
93
96
|
end
|
|
94
97
|
|
|
95
|
-
|
|
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
|
# ---- ファイル取得 -------------------------------------------------------
|
data/lib/chocomint/server.rb
CHANGED
|
@@ -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/
|
|
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
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;
|
|
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
|
-
//
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
}
|
|
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(() =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 = () =>
|
|
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
|
-
|
|
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
|
-
|
|
2225
|
+
refreshTree();
|
|
2126
2226
|
initConsole();
|
|
2127
2227
|
initResizers();
|
|
2128
2228
|
initMenu();
|