openclacky 1.3.3 → 1.3.4
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/CHANGELOG.md +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- data/lib/clacky/media/output_dir.rb +0 -43
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -124,6 +124,16 @@ const Sessions = (() => {
|
|
|
124
124
|
|
|
125
125
|
let html;
|
|
126
126
|
if (typeof marked !== "undefined") {
|
|
127
|
+
// Restore KTX placeholders that ended up inside code regions back to
|
|
128
|
+
// their original literal — those weren't math, just text that happened
|
|
129
|
+
// to look like math (C-5635). Marked alone decides what's a code region.
|
|
130
|
+
const restoreMathInCode = (s) =>
|
|
131
|
+
s.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
|
|
132
|
+
const m = math[+i];
|
|
133
|
+
if (m) m.disabled = true; // suppress later KaTeX render
|
|
134
|
+
return m ? m.raw : "";
|
|
135
|
+
});
|
|
136
|
+
|
|
127
137
|
const renderer = new marked.Renderer();
|
|
128
138
|
renderer.link = function({ href, title, text }) {
|
|
129
139
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
@@ -132,6 +142,7 @@ const Sessions = (() => {
|
|
|
132
142
|
// Override code block rendering: apply syntax highlighting + header with
|
|
133
143
|
// language label and copy button.
|
|
134
144
|
renderer.code = function({ text: code, lang }) {
|
|
145
|
+
code = restoreMathInCode(code);
|
|
135
146
|
const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
|
|
136
147
|
const highlighted = _highlightCode(code, language);
|
|
137
148
|
const displayLang = language || "text";
|
|
@@ -152,6 +163,10 @@ const Sessions = (() => {
|
|
|
152
163
|
`</div>`
|
|
153
164
|
);
|
|
154
165
|
};
|
|
166
|
+
// Inline code: same restoration, then plain <code> with HTML escaping.
|
|
167
|
+
renderer.codespan = function({ text }) {
|
|
168
|
+
return `<code>${escapeHtml(restoreMathInCode(text))}</code>`;
|
|
169
|
+
};
|
|
155
170
|
try {
|
|
156
171
|
html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
|
|
157
172
|
} catch (_) {
|
|
@@ -163,7 +178,11 @@ const Sessions = (() => {
|
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
if (math.length) {
|
|
166
|
-
html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) =>
|
|
181
|
+
html = html.replace(/\u0000KTX(\d+)\u0000/g, (_, i) => {
|
|
182
|
+
const m = math[+i];
|
|
183
|
+
if (!m || m.disabled) return ""; // already restored by renderer
|
|
184
|
+
return _renderMath(m);
|
|
185
|
+
});
|
|
167
186
|
}
|
|
168
187
|
return html;
|
|
169
188
|
}
|
|
@@ -187,22 +206,30 @@ const Sessions = (() => {
|
|
|
187
206
|
|
|
188
207
|
// Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
|
|
189
208
|
// sentinel placeholder so marked won't mangle the LaTeX source. The matched
|
|
190
|
-
// segments are pushed
|
|
209
|
+
// segments are pushed onto `out` as { body, display, raw } for later KaTeX
|
|
210
|
+
// rendering or — if the placeholder ends up landing inside a code block /
|
|
211
|
+
// code span — restoration to the original literal by the renderer (C-5635).
|
|
212
|
+
//
|
|
213
|
+
// Why no code-block detection here: we don't want a second, parallel notion
|
|
214
|
+
// of "what counts as code" living next to marked's. Instead we extract math
|
|
215
|
+
// unconditionally, then let marked be the single arbiter — its
|
|
216
|
+
// renderer.code / renderer.codespan hooks restore any placeholder that
|
|
217
|
+
// turns out to be inside a code region (see _markedParse).
|
|
191
218
|
function _extractMath(text, out, placeholder) {
|
|
192
219
|
// Order matters: longest/most-specific delimiters first.
|
|
193
220
|
const patterns = [
|
|
194
|
-
{ re: /\$\$([\s\S]+?)\$\$/g,
|
|
195
|
-
{ re: /\\\[([\s\S]+?)\\\]/g,
|
|
196
|
-
{ re: /\\\(([\s\S]+?)\\\)/g,
|
|
221
|
+
{ re: /\$\$([\s\S]+?)\$\$/g, display: true, wrap: (b) => `$$${b}$$` },
|
|
222
|
+
{ re: /\\\[([\s\S]+?)\\\]/g, display: true, wrap: (b) => `\\[${b}\\]` },
|
|
223
|
+
{ re: /\\\(([\s\S]+?)\\\)/g, display: false, wrap: (b) => `\\(${b}\\)` },
|
|
197
224
|
// Inline $...$: avoid $$, escaped \$, and prevent crossing newlines/blanks.
|
|
198
|
-
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true },
|
|
225
|
+
{ re: /(^|[^\$])\$(?!\s)([^\$\n]+?)(?<!\s)\$(?!\d)/g, display: false, hasPrefix: true, wrap: (b) => `$${b}$` },
|
|
199
226
|
];
|
|
200
227
|
let result = text;
|
|
201
|
-
for (const { re, display, hasPrefix } of patterns) {
|
|
228
|
+
for (const { re, display, hasPrefix, wrap } of patterns) {
|
|
202
229
|
result = result.replace(re, (m, a, b) => {
|
|
203
230
|
const body = hasPrefix ? b : a;
|
|
204
231
|
const idx = out.length;
|
|
205
|
-
out.push({ body, display });
|
|
232
|
+
out.push({ body, display, raw: wrap(body) });
|
|
206
233
|
return (hasPrefix ? a : "") + placeholder(idx);
|
|
207
234
|
});
|
|
208
235
|
}
|
|
@@ -827,7 +854,7 @@ const Sessions = (() => {
|
|
|
827
854
|
_imageSeq = 0;
|
|
828
855
|
_renderAttachmentPreviews();
|
|
829
856
|
|
|
830
|
-
WS.send({ type: "message", session_id: Sessions.activeId, content, files });
|
|
857
|
+
WS.send({ type: "message", session_id: Sessions.activeId, content, files, lang: I18n.lang() });
|
|
831
858
|
|
|
832
859
|
// Disable any pending feedback cards — user has replied (either by clicking
|
|
833
860
|
// an option button or by typing directly). The backend has already consumed
|
|
@@ -1360,11 +1387,11 @@ const Sessions = (() => {
|
|
|
1360
1387
|
bubbleHtml += escapeHtml(ev.content || "");
|
|
1361
1388
|
el.innerHTML = bubbleHtml;
|
|
1362
1389
|
if (ev.created_at) el.dataset.createdAt = ev.created_at;
|
|
1363
|
-
_appendMsgTime(el, ev.created_at);
|
|
1364
1390
|
const wrap = document.createElement("div");
|
|
1365
1391
|
wrap.className = "msg-user-wrap";
|
|
1366
1392
|
wrap.appendChild(el);
|
|
1367
1393
|
_appendUserActionBar(el, wrap);
|
|
1394
|
+
_appendMsgTime(wrap, ev.created_at);
|
|
1368
1395
|
container.appendChild(wrap);
|
|
1369
1396
|
break;
|
|
1370
1397
|
}
|
|
@@ -1616,6 +1643,7 @@ const Sessions = (() => {
|
|
|
1616
1643
|
// If no more history remains, insert a "beginning of conversation" marker at the top.
|
|
1617
1644
|
// Remove any existing marker first to avoid duplicates.
|
|
1618
1645
|
messages.querySelector(".history-start-marker")?.remove();
|
|
1646
|
+
_refreshEditButtons(messages);
|
|
1619
1647
|
if (!state.hasMore) {
|
|
1620
1648
|
const marker = document.createElement("div");
|
|
1621
1649
|
marker.className = "history-start-marker";
|
|
@@ -1663,9 +1691,11 @@ const Sessions = (() => {
|
|
|
1663
1691
|
if (!createdAt) return I18n.t("sessions.untitled") || "Untitled";
|
|
1664
1692
|
const d = new Date(createdAt);
|
|
1665
1693
|
const now = new Date();
|
|
1666
|
-
const diffDays = Math.floor((now - d) / 86400000);
|
|
1667
1694
|
const pad = n => String(n).padStart(2, "0");
|
|
1668
1695
|
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1696
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
1697
|
+
const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
1698
|
+
const diffDays = Math.round((today - dDay) / 86400000);
|
|
1669
1699
|
if (diffDays === 0) return `Today ${hhmm}`;
|
|
1670
1700
|
if (diffDays === 1) return `Yesterday ${hhmm}`;
|
|
1671
1701
|
return `${d.getMonth() + 1}/${d.getDate()} ${hhmm}`;
|
|
@@ -1738,12 +1768,18 @@ const Sessions = (() => {
|
|
|
1738
1768
|
|
|
1739
1769
|
const editBtn = document.createElement("button");
|
|
1740
1770
|
editBtn.type = "button";
|
|
1741
|
-
editBtn.className = "msg-user-action-btn";
|
|
1771
|
+
editBtn.className = "msg-user-action-btn msg-edit-btn";
|
|
1742
1772
|
editBtn.setAttribute("aria-label", I18n.t("chat.edit"));
|
|
1743
1773
|
editBtn.title = I18n.t("chat.edit");
|
|
1744
1774
|
editBtn.innerHTML = EDIT_SVG;
|
|
1745
|
-
editBtn.addEventListener("click", (e) => {
|
|
1775
|
+
editBtn.addEventListener("click", async (e) => {
|
|
1746
1776
|
e.stopPropagation();
|
|
1777
|
+
const ok = await Modal.confirmOnce(
|
|
1778
|
+
"clacky-edit-warn-dismissed",
|
|
1779
|
+
I18n.t("chat.edit.warn"),
|
|
1780
|
+
I18n.t("chat.edit.warnSkip")
|
|
1781
|
+
);
|
|
1782
|
+
if (!ok) return;
|
|
1747
1783
|
_enterEditMode(el);
|
|
1748
1784
|
});
|
|
1749
1785
|
|
|
@@ -1752,6 +1788,13 @@ const Sessions = (() => {
|
|
|
1752
1788
|
wrap.appendChild(bar);
|
|
1753
1789
|
}
|
|
1754
1790
|
|
|
1791
|
+
function _refreshEditButtons(container) {
|
|
1792
|
+
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
1793
|
+
btns.forEach((btn, i) => {
|
|
1794
|
+
btn.style.display = i === btns.length - 1 ? "" : "none";
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1755
1798
|
function _extractUserBubbleText(el) {
|
|
1756
1799
|
const clone = el.cloneNode(true);
|
|
1757
1800
|
clone.querySelectorAll(".msg-user-actions, .msg-time").forEach(n => n.remove());
|
|
@@ -1821,9 +1864,14 @@ const Sessions = (() => {
|
|
|
1821
1864
|
});
|
|
1822
1865
|
}
|
|
1823
1866
|
|
|
1824
|
-
function _exitEditMode(el) {
|
|
1867
|
+
function _exitEditMode(el, newContent) {
|
|
1825
1868
|
el.classList.remove("editing");
|
|
1826
|
-
|
|
1869
|
+
if (newContent) {
|
|
1870
|
+
el.dataset.originalHtml = escapeHtml(newContent);
|
|
1871
|
+
el.innerHTML = escapeHtml(newContent);
|
|
1872
|
+
} else {
|
|
1873
|
+
el.innerHTML = el.dataset.originalHtml || "";
|
|
1874
|
+
}
|
|
1827
1875
|
}
|
|
1828
1876
|
|
|
1829
1877
|
function _submitEdit(el, newContent) {
|
|
@@ -1843,7 +1891,7 @@ const Sessions = (() => {
|
|
|
1843
1891
|
}
|
|
1844
1892
|
}
|
|
1845
1893
|
|
|
1846
|
-
_exitEditMode(el);
|
|
1894
|
+
_exitEditMode(el, newContent);
|
|
1847
1895
|
|
|
1848
1896
|
WS.send({ type: "edit_message", session_id: Sessions.activeId, content: newContent, created_at: createdAt });
|
|
1849
1897
|
|
|
@@ -3428,6 +3476,16 @@ const Sessions = (() => {
|
|
|
3428
3476
|
}
|
|
3429
3477
|
},
|
|
3430
3478
|
|
|
3479
|
+
stampLastUserBubble(createdAt) {
|
|
3480
|
+
const messages = RenderTarget.outer();
|
|
3481
|
+
const wraps = messages.querySelectorAll(".msg-user-wrap");
|
|
3482
|
+
if (!wraps.length) return;
|
|
3483
|
+
const el = wraps[wraps.length - 1].querySelector(".msg-user");
|
|
3484
|
+
if (el) el.dataset.createdAt = createdAt;
|
|
3485
|
+
const dedup = _renderedCreatedAt[_activeId] || (_renderedCreatedAt[_activeId] = new Set());
|
|
3486
|
+
dedup.add(createdAt);
|
|
3487
|
+
},
|
|
3488
|
+
|
|
3431
3489
|
appendMsg(type, html, { time } = {}) {
|
|
3432
3490
|
// Starting a new assistant/user/info message: close any open tool group
|
|
3433
3491
|
if (type !== "tool") Sessions.collapseToolGroup();
|
|
@@ -3453,14 +3511,15 @@ const Sessions = (() => {
|
|
|
3453
3511
|
} else {
|
|
3454
3512
|
el.innerHTML = html;
|
|
3455
3513
|
}
|
|
3456
|
-
if (type === "user" && time) _appendMsgTime(el, time);
|
|
3457
3514
|
|
|
3458
3515
|
if (type === "user") {
|
|
3459
3516
|
const wrap = document.createElement("div");
|
|
3460
3517
|
wrap.className = "msg-user-wrap";
|
|
3461
3518
|
wrap.appendChild(el);
|
|
3462
3519
|
_appendUserActionBar(el, wrap);
|
|
3520
|
+
if (time) _appendMsgTime(wrap, time);
|
|
3463
3521
|
messages.appendChild(wrap);
|
|
3522
|
+
_refreshEditButtons(messages);
|
|
3464
3523
|
} else {
|
|
3465
3524
|
// For error messages, add a retry button
|
|
3466
3525
|
if (type === "error") {
|
|
@@ -4613,7 +4672,7 @@ const Sessions = (() => {
|
|
|
4613
4672
|
// ── Tree-based directory picker ─────────────────────────────────────────
|
|
4614
4673
|
const ICON_FOLDER_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
4615
4674
|
const ICON_CARET_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
|
4616
|
-
function showDirectoryPicker(currentDir, sessionId
|
|
4675
|
+
function showDirectoryPicker(currentDir, sessionId) {
|
|
4617
4676
|
return new Promise((resolve) => {
|
|
4618
4677
|
const t = (key, fallback) => {
|
|
4619
4678
|
const s = I18n.t(key);
|
|
@@ -4626,7 +4685,8 @@ const Sessions = (() => {
|
|
|
4626
4685
|
|
|
4627
4686
|
let selectedPath = currentDir;
|
|
4628
4687
|
let rootDir = ""; // absolute path of the session's working directory
|
|
4629
|
-
let homeDir = ""; // user home, used as
|
|
4688
|
+
let homeDir = ""; // user home, used as a quick preset
|
|
4689
|
+
let defaultDir = ""; // default workspace from agent_config (or fallback)
|
|
4630
4690
|
let showHidden = false;
|
|
4631
4691
|
|
|
4632
4692
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
@@ -4640,6 +4700,7 @@ const Sessions = (() => {
|
|
|
4640
4700
|
const data = await resp.json();
|
|
4641
4701
|
rootDir = data.root || rootDir;
|
|
4642
4702
|
homeDir = data.home || homeDir;
|
|
4703
|
+
defaultDir = data.default || defaultDir;
|
|
4643
4704
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4644
4705
|
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
4645
4706
|
return dirs;
|
|
@@ -4652,6 +4713,8 @@ const Sessions = (() => {
|
|
|
4652
4713
|
const data = await resp.json();
|
|
4653
4714
|
// Only update rootDir in relative mode; absolute mode would overwrite it with "/"
|
|
4654
4715
|
if (!absolute) rootDir = data.root || rootDir;
|
|
4716
|
+
homeDir = data.home || homeDir;
|
|
4717
|
+
defaultDir = data.default || defaultDir;
|
|
4655
4718
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
4656
4719
|
// Convert relative paths to absolute
|
|
4657
4720
|
dirs.forEach(d => {
|
|
@@ -4717,6 +4780,12 @@ const Sessions = (() => {
|
|
|
4717
4780
|
// Enter this directory - reload tree with this as root
|
|
4718
4781
|
loadTreeForPath(entry.absPath, entry.absolute);
|
|
4719
4782
|
});
|
|
4783
|
+
|
|
4784
|
+
// Make the row addressable for inline-edit invoked elsewhere
|
|
4785
|
+
// (e.g. from the "+ New folder" button placing its placeholder).
|
|
4786
|
+
node._dpEntry = entry;
|
|
4787
|
+
node._dpName = name;
|
|
4788
|
+
node._dpRow = row;
|
|
4720
4789
|
return node;
|
|
4721
4790
|
}
|
|
4722
4791
|
|
|
@@ -4765,10 +4834,9 @@ const Sessions = (() => {
|
|
|
4765
4834
|
// Title
|
|
4766
4835
|
const title = document.createElement("div");
|
|
4767
4836
|
title.className = "modal-title";
|
|
4768
|
-
title.textContent =
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
: t("sib.dir.changePrompt", "切换工作目录"));
|
|
4837
|
+
title.textContent = sessionLess
|
|
4838
|
+
? t("sessions.modal.dirpicker.title", "选择工作目录")
|
|
4839
|
+
: t("sib.dir.changePrompt", "切换工作目录");
|
|
4772
4840
|
modal.appendChild(title);
|
|
4773
4841
|
|
|
4774
4842
|
// Modal body
|
|
@@ -4809,18 +4877,206 @@ const Sessions = (() => {
|
|
|
4809
4877
|
loadTreeForPath(parent, true);
|
|
4810
4878
|
});
|
|
4811
4879
|
|
|
4880
|
+
// ── "+ New folder" ────────────────────────────────────────────────
|
|
4881
|
+
// Drops an editable placeholder row at the top of the current view
|
|
4882
|
+
// and POSTs to the backend on commit. Confirm = Enter / blur, cancel
|
|
4883
|
+
// = Escape. The placeholder is purely visual; nothing is created on
|
|
4884
|
+
// disk until the user commits a non-empty name.
|
|
4885
|
+
const newFolderBtn = document.createElement("button");
|
|
4886
|
+
newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
|
|
4887
|
+
newFolderBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>${t("sib.dir.newFolder", "新建文件夹")}</span>`;
|
|
4888
|
+
newFolderBtn.addEventListener("click", () => beginCreateFolder());
|
|
4889
|
+
|
|
4890
|
+
function beginCreateFolder() {
|
|
4891
|
+
const parentDir = (pathInput.value || "").trim();
|
|
4892
|
+
if (!parentDir.startsWith("/")) {
|
|
4893
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
|
|
4894
|
+
return;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// Locate the parent node in the current tree, if it's rendered.
|
|
4898
|
+
// The path input may point at any expanded descendant of the
|
|
4899
|
+
// tree root, so we can't assume parentDir == tree root.
|
|
4900
|
+
// Walk every rendered .dp-node and match by entry.absPath.
|
|
4901
|
+
let parentNode = null;
|
|
4902
|
+
treeContainer.querySelectorAll(".dp-node").forEach(n => {
|
|
4903
|
+
if (parentNode) return;
|
|
4904
|
+
if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
|
|
4905
|
+
});
|
|
4906
|
+
|
|
4907
|
+
// Pick where to insert + what depth the placeholder lives at.
|
|
4908
|
+
// - Tree root (parentDir is the root) → depth 0, host = treeContainer
|
|
4909
|
+
// - Rendered subtree → depth = parent.depth + 1,
|
|
4910
|
+
// host = parent's .dp-children
|
|
4911
|
+
let host, depth, parentRow, parentCaret, parentChildren;
|
|
4912
|
+
if (parentNode) {
|
|
4913
|
+
parentRow = parentNode._dpRow;
|
|
4914
|
+
parentCaret = parentRow.querySelector(".dp-caret");
|
|
4915
|
+
parentChildren = parentNode.querySelector(":scope > .dp-children");
|
|
4916
|
+
depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
|
|
4917
|
+
host = parentChildren;
|
|
4918
|
+
} else {
|
|
4919
|
+
depth = 0;
|
|
4920
|
+
host = treeContainer;
|
|
4921
|
+
}
|
|
4922
|
+
|
|
4923
|
+
// Build a placeholder node and seed it with an inline editor.
|
|
4924
|
+
const placeholder = document.createElement("div");
|
|
4925
|
+
placeholder.className = "dp-node dp-node-pending";
|
|
4926
|
+
placeholder.dataset.depth = depth;
|
|
4927
|
+
|
|
4928
|
+
const row = document.createElement("div");
|
|
4929
|
+
row.className = "dp-row dp-row-editing";
|
|
4930
|
+
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
4931
|
+
|
|
4932
|
+
const caret = document.createElement("span");
|
|
4933
|
+
caret.className = "dp-caret";
|
|
4934
|
+
caret.innerHTML = ICON_CARET_SVG;
|
|
4935
|
+
|
|
4936
|
+
const icon = document.createElement("span");
|
|
4937
|
+
icon.className = "dp-icon";
|
|
4938
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
4939
|
+
|
|
4940
|
+
const nameSpan = document.createElement("span");
|
|
4941
|
+
nameSpan.className = "dp-name";
|
|
4942
|
+
|
|
4943
|
+
row.appendChild(caret);
|
|
4944
|
+
row.appendChild(icon);
|
|
4945
|
+
row.appendChild(nameSpan);
|
|
4946
|
+
placeholder.appendChild(row);
|
|
4947
|
+
|
|
4948
|
+
// If we're inserting under a parent node, make sure the parent is
|
|
4949
|
+
// visually expanded so the user sees the placeholder. Mark the
|
|
4950
|
+
// children container as loaded so we don't trigger a refetch on
|
|
4951
|
+
// first toggleExpand (we'll keep our placeholder + inserted node).
|
|
4952
|
+
if (parentNode) {
|
|
4953
|
+
parentCaret.classList.add("open");
|
|
4954
|
+
parentChildren.style.display = "flex";
|
|
4955
|
+
// Drop "loading" / "empty" / "error" stub if present.
|
|
4956
|
+
const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
|
|
4957
|
+
if (stub) stub.remove();
|
|
4958
|
+
parentChildren.dataset.loaded = "1";
|
|
4959
|
+
parentChildren.insertBefore(placeholder, parentChildren.firstChild);
|
|
4960
|
+
} else {
|
|
4961
|
+
// Root level: drop the empty/loading/error stub, then prepend.
|
|
4962
|
+
const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
|
|
4963
|
+
if (emptyEl) emptyEl.remove();
|
|
4964
|
+
treeContainer.insertBefore(placeholder, treeContainer.firstChild);
|
|
4965
|
+
}
|
|
4966
|
+
|
|
4967
|
+
startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
|
|
4968
|
+
onCommit: async (newName) => {
|
|
4969
|
+
const trimmed = (newName || "").trim();
|
|
4970
|
+
if (!trimmed) { placeholder.remove(); return; }
|
|
4971
|
+
try {
|
|
4972
|
+
const resp = await fetch("/api/dirs/mkdir", {
|
|
4973
|
+
method: "POST",
|
|
4974
|
+
headers: { "Content-Type": "application/json" },
|
|
4975
|
+
body: JSON.stringify({ parent: parentDir, name: trimmed })
|
|
4976
|
+
});
|
|
4977
|
+
const data = await resp.json().catch(() => ({}));
|
|
4978
|
+
if (!resp.ok || !data.ok) {
|
|
4979
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}")
|
|
4980
|
+
.replace("{{msg}}", data.error || `HTTP ${resp.status}`));
|
|
4981
|
+
placeholder.remove();
|
|
4982
|
+
return;
|
|
4983
|
+
}
|
|
4984
|
+
// Replace placeholder with a real, fully-wired node so it can
|
|
4985
|
+
// be expanded / right-clicked exactly like any sibling.
|
|
4986
|
+
const realEntry = {
|
|
4987
|
+
name: data.name,
|
|
4988
|
+
path: data.path,
|
|
4989
|
+
absPath: data.path,
|
|
4990
|
+
absolute: true,
|
|
4991
|
+
type: "dir"
|
|
4992
|
+
};
|
|
4993
|
+
const realNode = buildDirNode(realEntry, depth);
|
|
4994
|
+
placeholder.replaceWith(realNode);
|
|
4995
|
+
// Auto-select the new folder so the path input updates.
|
|
4996
|
+
modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
4997
|
+
realNode._dpRow.classList.add("selected");
|
|
4998
|
+
selectedPath = data.path;
|
|
4999
|
+
pathInput.value = data.path;
|
|
5000
|
+
refreshUpBtn();
|
|
5001
|
+
} catch (err) {
|
|
5002
|
+
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
|
|
5003
|
+
placeholder.remove();
|
|
5004
|
+
}
|
|
5005
|
+
},
|
|
5006
|
+
onCancel: () => placeholder.remove()
|
|
5007
|
+
});
|
|
5008
|
+
}
|
|
5009
|
+
|
|
5010
|
+
// ── Inline rename editor (shared by mkdir + rename) ───────────────
|
|
5011
|
+
// Replaces a name span with an <input> seeded by `initialValue`,
|
|
5012
|
+
// commits on Enter/blur, cancels on Escape. The caller wires the
|
|
5013
|
+
// backend round-trip via onCommit (it receives the new name string).
|
|
5014
|
+
function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
|
|
5015
|
+
const input = document.createElement("input");
|
|
5016
|
+
input.type = "text";
|
|
5017
|
+
input.className = "dp-name-input";
|
|
5018
|
+
input.value = initialValue;
|
|
5019
|
+
input.spellcheck = false;
|
|
5020
|
+
input.autocomplete = "off";
|
|
5021
|
+
input.setAttribute("autocapitalize", "off");
|
|
5022
|
+
|
|
5023
|
+
// Stop click-through so editing doesn't trigger row selection /
|
|
5024
|
+
// expand / collapse.
|
|
5025
|
+
["click", "dblclick", "mousedown"].forEach(ev =>
|
|
5026
|
+
input.addEventListener(ev, (e) => e.stopPropagation())
|
|
5027
|
+
);
|
|
5028
|
+
|
|
5029
|
+
// Replace span with input. Track via parent so we can swap back.
|
|
5030
|
+
const parent = nameSpan.parentNode;
|
|
5031
|
+
parent.replaceChild(input, nameSpan);
|
|
5032
|
+
// Defer focus until the next tick so the caller's surrounding DOM
|
|
5033
|
+
// mutations (e.g. inserting a placeholder row) finish first.
|
|
5034
|
+
setTimeout(() => {
|
|
5035
|
+
input.focus();
|
|
5036
|
+
input.select();
|
|
5037
|
+
}, 0);
|
|
5038
|
+
|
|
5039
|
+
let finished = false;
|
|
5040
|
+
const finish = (commit) => {
|
|
5041
|
+
if (finished) return;
|
|
5042
|
+
finished = true;
|
|
5043
|
+
const value = input.value;
|
|
5044
|
+
if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
|
|
5045
|
+
if (commit) {
|
|
5046
|
+
try { onCommit && onCommit(value); } catch (e) { console.error(e); }
|
|
5047
|
+
} else {
|
|
5048
|
+
try { onCancel && onCancel(); } catch (e) { console.error(e); }
|
|
5049
|
+
}
|
|
5050
|
+
};
|
|
5051
|
+
|
|
5052
|
+
// Enter commits; Escape cancels. Use IME.bindEnter so that the
|
|
5053
|
+
// Enter that confirms a Chinese / Japanese / Korean IME candidate
|
|
5054
|
+
// does NOT commit the rename. Mirrors Sessions._startRename.
|
|
5055
|
+
IME.bindEnter(input, () => finish(true));
|
|
5056
|
+
input.addEventListener("keydown", (e) => {
|
|
5057
|
+
if (e.key === "Escape") { e.preventDefault(); finish(false); }
|
|
5058
|
+
});
|
|
5059
|
+
input.addEventListener("blur", () => finish(true));
|
|
5060
|
+
}
|
|
5061
|
+
|
|
4812
5062
|
function setupPresets() {
|
|
4813
5063
|
presets.innerHTML = "";
|
|
4814
5064
|
presets.appendChild(upBtn);
|
|
4815
5065
|
const presetDirs = sessionLess
|
|
4816
5066
|
? [
|
|
4817
|
-
{ value:
|
|
4818
|
-
{ value:
|
|
5067
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5068
|
+
{ value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
|
|
5069
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
4819
5070
|
]
|
|
4820
5071
|
: [
|
|
4821
|
-
{ value:
|
|
4822
|
-
{ value: "/",
|
|
4823
|
-
];
|
|
5072
|
+
{ value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
|
|
5073
|
+
{ value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
|
|
5074
|
+
];
|
|
5075
|
+
presetDirs.forEach(p => {
|
|
5076
|
+
// Skip preset whose absolute path isn't known yet (defensive: the
|
|
5077
|
+
// backend always returns it, but a malformed deploy shouldn't render
|
|
5078
|
+
// a button that navigates to "").
|
|
5079
|
+
if (!p.value) return;
|
|
4824
5080
|
const btn = document.createElement("button");
|
|
4825
5081
|
btn.className = "btn btn-secondary btn-sm";
|
|
4826
5082
|
btn.textContent = p.text;
|
|
@@ -4832,6 +5088,8 @@ const Sessions = (() => {
|
|
|
4832
5088
|
});
|
|
4833
5089
|
presets.appendChild(btn);
|
|
4834
5090
|
});
|
|
5091
|
+
// "+ New folder" trailing action on the same toolbar.
|
|
5092
|
+
presets.appendChild(newFolderBtn);
|
|
4835
5093
|
}
|
|
4836
5094
|
|
|
4837
5095
|
// Path input
|