openclacky 1.5.12 → 1.5.13
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 +48 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/message_compressor_helper.rb +52 -1
- data/lib/clacky/agent/session_serializer.rb +35 -2
- data/lib/clacky/agent/time_machine.rb +9 -1
- data/lib/clacky/agent.rb +11 -7
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/platform_billing.rb +84 -1
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +6 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/providers.rb +18 -6
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +147 -103
- data/lib/clacky/tools/terminal.rb +27 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +70 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +412 -108
- data/lib/clacky/web/components/composer.js +45 -7
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +58 -2
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/view.js +20 -6
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +31 -22
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +54 -3
- data/lib/clacky/web/i18n.js +73 -11
- data/lib/clacky/web/index.html +92 -37
- data/lib/clacky/web/sessions.js +550 -592
- data/lib/clacky/web/settings.js +163 -190
- data/lib/clacky.rb +3 -0
- metadata +8 -1
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -26,6 +26,7 @@ const Sessions = (() => {
|
|
|
26
26
|
let _loadingMore = false;
|
|
27
27
|
// Search state
|
|
28
28
|
const _filter = { q: "", date: "", type: "" }; // committed filter (applied to the search overlay)
|
|
29
|
+
let _searchTypeSelect = null;
|
|
29
30
|
let _searchOpen = false; // is the command-palette search overlay visible?
|
|
30
31
|
// Search results live in their own list, rendered into the overlay's
|
|
31
32
|
// #session-search-results — they NEVER replace the sidebar session list.
|
|
@@ -428,12 +429,41 @@ const Sessions = (() => {
|
|
|
428
429
|
|
|
429
430
|
const _EMPTY_HINT_ID = "chat-empty-hint";
|
|
430
431
|
|
|
432
|
+
let _agentName = "";
|
|
433
|
+
let _agentNamePromise = null;
|
|
434
|
+
|
|
435
|
+
function _loadAgentName() {
|
|
436
|
+
if (_agentName) return Promise.resolve(_agentName);
|
|
437
|
+
if (_agentNamePromise) return _agentNamePromise;
|
|
438
|
+
_agentNamePromise = fetch("/api/profile")
|
|
439
|
+
.then(r => (r.ok ? r.json() : {}))
|
|
440
|
+
.then(d => {
|
|
441
|
+
_agentName = (d.soul && d.soul.name) || "";
|
|
442
|
+
return _agentName;
|
|
443
|
+
})
|
|
444
|
+
.catch(() => {
|
|
445
|
+
_agentName = "";
|
|
446
|
+
return "";
|
|
447
|
+
})
|
|
448
|
+
.finally(() => { _agentNamePromise = null; });
|
|
449
|
+
return _agentNamePromise;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _applyAgentName(name) {
|
|
453
|
+
if (!name) return;
|
|
454
|
+
const el = document.querySelector("#" + _EMPTY_HINT_ID + " .chat-empty-title");
|
|
455
|
+
if (el) el.textContent = I18n.t("chat.empty.title.named", { name });
|
|
456
|
+
}
|
|
457
|
+
|
|
431
458
|
function _buildEmptyHintHtml() {
|
|
432
|
-
const title =
|
|
459
|
+
const title = _agentName
|
|
460
|
+
? I18n.t("chat.empty.title.named", { name: _agentName })
|
|
461
|
+
: I18n.t("chat.empty.title");
|
|
433
462
|
const subtitle = I18n.t("chat.empty.subtitle");
|
|
434
463
|
const tip1 = I18n.t("chat.empty.tip1");
|
|
435
464
|
const tip2 = I18n.t("chat.empty.tip2");
|
|
436
465
|
const tip3 = I18n.t("chat.empty.tip3");
|
|
466
|
+
_loadAgentName().then(_applyAgentName);
|
|
437
467
|
return `
|
|
438
468
|
<div class="chat-empty-icon" aria-hidden="true">
|
|
439
469
|
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
@@ -1258,7 +1288,7 @@ const Sessions = (() => {
|
|
|
1258
1288
|
//
|
|
1259
1289
|
// All search-related interactions. The search UI lives in the sessions
|
|
1260
1290
|
// sidebar: a magnifier toggle button, the search panel (q input, type
|
|
1261
|
-
//
|
|
1291
|
+
// dropdown, date <input>), inline ✕ clear, and "clear all filters" button.
|
|
1262
1292
|
//
|
|
1263
1293
|
// Everything uses event delegation because some elements (e.g. the clear
|
|
1264
1294
|
// buttons) are re-rendered as filter state changes.
|
|
@@ -1272,6 +1302,17 @@ const Sessions = (() => {
|
|
|
1272
1302
|
const cmdbarKbd = document.querySelector("#header-cmdbar .cmdbar-kbd");
|
|
1273
1303
|
if (cmdbarKbd) cmdbarKbd.textContent = _sessionSearchShortcutLabel();
|
|
1274
1304
|
|
|
1305
|
+
const typeWrap = document.getElementById("session-search-type");
|
|
1306
|
+
if (typeWrap && window.CustomSelect) {
|
|
1307
|
+
_searchTypeSelect = window.CustomSelect.init({
|
|
1308
|
+
trigger: typeWrap.querySelector(".custom-select-trigger"),
|
|
1309
|
+
dropdown: typeWrap.querySelector(".custom-select-dropdown"),
|
|
1310
|
+
anchor: typeWrap,
|
|
1311
|
+
portal: true,
|
|
1312
|
+
onSelect: () => Sessions.commitSearch()
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1275
1316
|
// Open the palette: top cmdbar button (or the keyboard shortcut, bound below).
|
|
1276
1317
|
document.addEventListener("click", (e) => {
|
|
1277
1318
|
if (e.target && e.target.closest("#header-cmdbar")) {
|
|
@@ -1324,9 +1365,8 @@ const Sessions = (() => {
|
|
|
1324
1365
|
// "Clear all filters" button — resets type + date and re-fetches once
|
|
1325
1366
|
document.addEventListener("click", (e) => {
|
|
1326
1367
|
if (e.target && e.target.id === "btn-search-clear-all") {
|
|
1327
|
-
const typeEl = document.getElementById("session-search-type");
|
|
1328
1368
|
const dateEl = document.getElementById("session-search-date");
|
|
1329
|
-
if (
|
|
1369
|
+
if (_searchTypeSelect) _searchTypeSelect.setValue("");
|
|
1330
1370
|
if (dateEl) DatePicker.clear(dateEl);
|
|
1331
1371
|
Sessions.commitSearch();
|
|
1332
1372
|
}
|
|
@@ -1343,12 +1383,7 @@ const Sessions = (() => {
|
|
|
1343
1383
|
}
|
|
1344
1384
|
});
|
|
1345
1385
|
|
|
1346
|
-
//
|
|
1347
|
-
document.addEventListener("change", (e) => {
|
|
1348
|
-
if (e.target && e.target.id === "session-search-type") {
|
|
1349
|
-
Sessions.commitSearch();
|
|
1350
|
-
}
|
|
1351
|
-
});
|
|
1386
|
+
// Date picker — commit immediately on change
|
|
1352
1387
|
document.addEventListener("datepicker:change", (e) => {
|
|
1353
1388
|
if (e.target && e.target.id === "session-search-date") {
|
|
1354
1389
|
Sessions.commitSearch();
|
|
@@ -2181,6 +2216,7 @@ const Sessions = (() => {
|
|
|
2181
2216
|
editBtn.innerHTML = EDIT_SVG;
|
|
2182
2217
|
editBtn.addEventListener("click", async (e) => {
|
|
2183
2218
|
e.stopPropagation();
|
|
2219
|
+
if (_activeSessionIsRunning()) return;
|
|
2184
2220
|
const ok = await Modal.confirmOnce(
|
|
2185
2221
|
"clacky-edit-warn-dismissed",
|
|
2186
2222
|
I18n.t("chat.edit.warn"),
|
|
@@ -2197,11 +2233,20 @@ const Sessions = (() => {
|
|
|
2197
2233
|
wrap.appendChild(bar);
|
|
2198
2234
|
}
|
|
2199
2235
|
|
|
2200
|
-
function
|
|
2236
|
+
function _activeSessionIsRunning() {
|
|
2237
|
+
return Sessions.find(Sessions.activeId)?.status === "running";
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function _refreshEditButtons(container, status) {
|
|
2241
|
+
const running = (status || Sessions.find(Sessions.activeId)?.status) === "running";
|
|
2201
2242
|
const btns = Array.from(container.querySelectorAll(".msg-edit-btn"));
|
|
2202
2243
|
btns.forEach((btn, i) => {
|
|
2203
|
-
btn.style.display = i === btns.length - 1 ? "" : "none";
|
|
2244
|
+
btn.style.display = !running && i === btns.length - 1 ? "" : "none";
|
|
2204
2245
|
});
|
|
2246
|
+
|
|
2247
|
+
if (running) {
|
|
2248
|
+
container.querySelectorAll(".msg-user.editing").forEach(el => _exitEditMode(el));
|
|
2249
|
+
}
|
|
2205
2250
|
}
|
|
2206
2251
|
|
|
2207
2252
|
function _extractUserBubbleText(el) {
|
|
@@ -2211,7 +2256,7 @@ const Sessions = (() => {
|
|
|
2211
2256
|
}
|
|
2212
2257
|
|
|
2213
2258
|
function _enterEditMode(el) {
|
|
2214
|
-
if (el.classList.contains("editing")) return;
|
|
2259
|
+
if (_activeSessionIsRunning() || el.classList.contains("editing")) return;
|
|
2215
2260
|
el.classList.add("editing");
|
|
2216
2261
|
|
|
2217
2262
|
const originalHtml = el.dataset.originalHtml || "";
|
|
@@ -2287,6 +2332,10 @@ const Sessions = (() => {
|
|
|
2287
2332
|
function _submitEdit(el, newContent) {
|
|
2288
2333
|
if (!newContent) return;
|
|
2289
2334
|
if (!Sessions.activeId) return;
|
|
2335
|
+
if (_activeSessionIsRunning()) {
|
|
2336
|
+
_exitEditMode(el);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2290
2339
|
|
|
2291
2340
|
const createdAt = el.dataset.createdAt || null;
|
|
2292
2341
|
|
|
@@ -2567,8 +2616,16 @@ const Sessions = (() => {
|
|
|
2567
2616
|
// of the raw glyph.
|
|
2568
2617
|
const CRON_NAME_PREFIXES = ["\u23F0 ", "\u25B6 "];
|
|
2569
2618
|
const cronPrefix = CRON_NAME_PREFIXES.find(p => s.name && s.name.startsWith(p)) || "";
|
|
2570
|
-
const
|
|
2619
|
+
const storedName = (cronPrefix ? s.name.slice(cronPrefix.length) : s.name)
|
|
2571
2620
|
|| _relativeTime(s.created_at);
|
|
2621
|
+
// Built-in setup sessions ship with fixed English names; localise their
|
|
2622
|
+
// titles so they follow the UI language (existing sessions included).
|
|
2623
|
+
const BUILTIN_SETUP_NAMES = {
|
|
2624
|
+
"Onboard": "sessions.setup.name",
|
|
2625
|
+
"🌐 Browser Setup": "sessions.setup.browser.name",
|
|
2626
|
+
};
|
|
2627
|
+
const nameKey = s.source === "setup" ? BUILTIN_SETUP_NAMES[storedName] : null;
|
|
2628
|
+
const displayName = nameKey ? I18n.t(nameKey) : storedName;
|
|
2572
2629
|
const cronIcon = cronPrefix
|
|
2573
2630
|
? `<svg class="session-cron-icon" 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"><circle cx="12" cy="13" r="8"/><path d="M5 3 2 6"/><path d="m22 6-3-3"/><path d="M6.38 18.7 4 21"/><path d="M17.64 18.67 20 21"/><path d="m9 13 2 2 4-4"/></svg>`
|
|
2574
2631
|
: "";
|
|
@@ -3087,10 +3144,9 @@ const Sessions = (() => {
|
|
|
3087
3144
|
* search overlay. Never touches the sidebar session list (_sessions). */
|
|
3088
3145
|
async commitSearch() {
|
|
3089
3146
|
const qEl = document.getElementById("session-search-q");
|
|
3090
|
-
const typeEl = document.getElementById("session-search-type");
|
|
3091
3147
|
const dateEl = document.getElementById("session-search-date");
|
|
3092
3148
|
if (qEl) _filter.q = qEl.value.trim();
|
|
3093
|
-
if (
|
|
3149
|
+
if (_searchTypeSelect) _filter.type = _searchTypeSelect.value;
|
|
3094
3150
|
if (dateEl) _filter.date = dateEl.dataset.value || "";
|
|
3095
3151
|
|
|
3096
3152
|
const token = ++_searchToken;
|
|
@@ -3166,6 +3222,7 @@ const Sessions = (() => {
|
|
|
3166
3222
|
const el = document.getElementById(ids[key]);
|
|
3167
3223
|
if (el) {
|
|
3168
3224
|
if (key === "date") DatePicker.clear(el);
|
|
3225
|
+
else if (key === "type" && _searchTypeSelect) _searchTypeSelect.setValue("");
|
|
3169
3226
|
else el.value = "";
|
|
3170
3227
|
}
|
|
3171
3228
|
await Sessions.commitSearch();
|
|
@@ -3252,10 +3309,9 @@ const Sessions = (() => {
|
|
|
3252
3309
|
// Reset inputs + filter state so the next open starts clean.
|
|
3253
3310
|
const qEl = document.getElementById("session-search-q");
|
|
3254
3311
|
const dEl = document.getElementById("session-search-date");
|
|
3255
|
-
const tEl = document.getElementById("session-search-type");
|
|
3256
3312
|
if (qEl) qEl.value = "";
|
|
3257
3313
|
if (dEl) DatePicker.clear(dEl);
|
|
3258
|
-
if (
|
|
3314
|
+
if (_searchTypeSelect) _searchTypeSelect.setValue("");
|
|
3259
3315
|
const qClear = document.getElementById("btn-search-q-clear");
|
|
3260
3316
|
if (qClear) qClear.hidden = true;
|
|
3261
3317
|
_filter.q = _filter.date = _filter.type = "";
|
|
@@ -3464,7 +3520,11 @@ const Sessions = (() => {
|
|
|
3464
3520
|
return !t || t < latest;
|
|
3465
3521
|
};
|
|
3466
3522
|
const renderGroup = source => {
|
|
3467
|
-
|
|
3523
|
+
// Scan _extraSessions too: a session created by the backend (e.g. a
|
|
3524
|
+
// scheduler run) only reaches the client as a session_update, which
|
|
3525
|
+
// patch() stores there — it would otherwise never light the dot.
|
|
3526
|
+
const inGroup = s => s.source === source && !s.project_id;
|
|
3527
|
+
const rows = _sessions.filter(inGroup).concat(_extraSessions.filter(inGroup));
|
|
3468
3528
|
const dotStatus = ["running", "awaiting_feedback"].find(st => rows.some(s => s.status === st))
|
|
3469
3529
|
|| (rows.some(s => _recentlyDone.has(s.id)) ? "done" : null);
|
|
3470
3530
|
_renderGroupItem(list, source, _groups.get(source).count, dotStatus);
|
|
@@ -3599,7 +3659,7 @@ const Sessions = (() => {
|
|
|
3599
3659
|
const iconRename = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>`;
|
|
3600
3660
|
const iconTrash = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
|
|
3601
3661
|
const iconMoveToProject = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/><path d="M8 13h8"/><path d="m13 10 3 3-3 3"/></svg>`;
|
|
3602
|
-
const iconChevronRight = `<svg viewBox="0 0 24 24" width="
|
|
3662
|
+
const iconChevronRight = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>`;
|
|
3603
3663
|
const iconFolderPlus = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>`;
|
|
3604
3664
|
const iconRemoveFromProject = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/><path d="M5 13h8"/><path d="m8 10 -3 3 3 3"/></svg>`;
|
|
3605
3665
|
|
|
@@ -3624,8 +3684,9 @@ const Sessions = (() => {
|
|
|
3624
3684
|
submenuItemsHtml = projects.map(p => {
|
|
3625
3685
|
const colorAttr = p.color ? ` style="color:${escapeHtml(p.color)}"` : "";
|
|
3626
3686
|
const projectIcon = Projects.getIconSvg(p.icon || "folder", 14);
|
|
3627
|
-
const
|
|
3628
|
-
|
|
3687
|
+
const isSelected = session.project_id === p.id;
|
|
3688
|
+
const check = isSelected ? `<span class="session-actions-submenu-check">\u2713</span>` : "";
|
|
3689
|
+
return `<div class="session-actions-submenu-item${isSelected ? " selected" : ""}" data-project-id="${escapeHtml(String(p.id))}">
|
|
3629
3690
|
<span class="session-actions-submenu-icon"${colorAttr}>${projectIcon}</span>
|
|
3630
3691
|
<span class="session-actions-submenu-label">${escapeHtml(p.name)}</span>
|
|
3631
3692
|
${check}
|
|
@@ -3851,9 +3912,10 @@ const Sessions = (() => {
|
|
|
3851
3912
|
|
|
3852
3913
|
updateStatusBar(status) {
|
|
3853
3914
|
// chat-header was removed; status text is now shown in the bottom session-info-bar (#sib-status).
|
|
3854
|
-
// Here we
|
|
3915
|
+
// Here we update controls whose availability follows the active session status.
|
|
3855
3916
|
const interrupt = $("btn-interrupt");
|
|
3856
3917
|
if (interrupt) interrupt.style.display = status === "running" ? "" : "none";
|
|
3918
|
+
_refreshEditButtons(RenderTarget.outer(), status);
|
|
3857
3919
|
|
|
3858
3920
|
// Swap input placeholder so the user knows they can still send extra
|
|
3859
3921
|
// info while the agent is working.
|
|
@@ -4897,42 +4959,6 @@ const Sessions = (() => {
|
|
|
4897
4959
|
// ── Session Info Bar Model Switcher ───────────────────────────────────────
|
|
4898
4960
|
(function() {
|
|
4899
4961
|
let _isOpen = false;
|
|
4900
|
-
// Cache of the most recent benchmark results, keyed by model_id. Kept at
|
|
4901
|
-
// closure scope so the numbers survive closing & reopening the dropdown —
|
|
4902
|
-
// the user shouldn't have to re-run the test just to peek at results. We
|
|
4903
|
-
// intentionally do NOT persist this to disk: latency is a point-in-time
|
|
4904
|
-
// measurement, and yesterday's numbers are misleading.
|
|
4905
|
-
let _benchCache = {}; // { [model_id]: { ttft_ms, ok, error, ts } }
|
|
4906
|
-
let _benchInFlight = false; // prevent double-click spam
|
|
4907
|
-
|
|
4908
|
-
// Price-ratio badges for the submodel rows. Ratios come from
|
|
4909
|
-
// GET /api/model_prices (backed by Clacky::ModelPricing), so the local
|
|
4910
|
-
// pricing table stays the single source of truth - no hardcoded JS prices.
|
|
4911
|
-
const _priceCache = new Map(); // model name -> { in, out, label } | null
|
|
4912
|
-
|
|
4913
|
-
function _fmtRatio(ratio) {
|
|
4914
|
-
if (ratio >= 10) return Math.round(ratio) + "x";
|
|
4915
|
-
const s = ratio >= 1 ? ratio.toFixed(1) : ratio.toFixed(2);
|
|
4916
|
-
return s.replace(/\.?0+$/, "") + "x";
|
|
4917
|
-
}
|
|
4918
|
-
|
|
4919
|
-
// Fetches ratios for names not yet cached. Rows simply stay blank when
|
|
4920
|
-
// the request fails (offline, unknown model).
|
|
4921
|
-
async function _loadPriceRatios(names) {
|
|
4922
|
-
const missing = Array.from(new Set(names.filter(Boolean).filter((n) => !_priceCache.has(n))));
|
|
4923
|
-
if (!missing.length) return;
|
|
4924
|
-
try {
|
|
4925
|
-
const res = await fetch("/api/model_prices?models=" + encodeURIComponent(missing.join(",")));
|
|
4926
|
-
if (!res.ok) return;
|
|
4927
|
-
const data = await res.json();
|
|
4928
|
-
missing.forEach((n) => {
|
|
4929
|
-
const p = data && data.prices && data.prices[n];
|
|
4930
|
-
_priceCache.set(n, p ? { in: p.in, out: p.out, label: _fmtRatio(p.ratio) } : null);
|
|
4931
|
-
});
|
|
4932
|
-
} catch (e) {
|
|
4933
|
-
/* network error - leave prices hidden */
|
|
4934
|
-
}
|
|
4935
|
-
}
|
|
4936
4962
|
|
|
4937
4963
|
// Toggle model dropdown when clicking on model name
|
|
4938
4964
|
document.addEventListener("click", async (e) => {
|
|
@@ -4946,7 +4972,7 @@ const Sessions = (() => {
|
|
|
4946
4972
|
if (_isOpen) {
|
|
4947
4973
|
dropdown.style.display = "none";
|
|
4948
4974
|
_isOpen = false;
|
|
4949
|
-
|
|
4975
|
+
ModelPicker.closeSubmodelPanel();
|
|
4950
4976
|
} else {
|
|
4951
4977
|
let subOptions = [];
|
|
4952
4978
|
try { subOptions = JSON.parse(modelEl.dataset.subModelOptions || "[]"); } catch (_) {}
|
|
@@ -4956,13 +4982,13 @@ const Sessions = (() => {
|
|
|
4956
4982
|
cardModel: modelEl.dataset.cardModel || null
|
|
4957
4983
|
};
|
|
4958
4984
|
await _populateModelDropdown(modelEl.dataset.sessionId, modelEl.dataset.modelId || null, subInfo);
|
|
4959
|
-
|
|
4985
|
+
|
|
4960
4986
|
// Calculate position relative to the model element (fixed positioning)
|
|
4961
4987
|
const rect = modelEl.getBoundingClientRect();
|
|
4962
4988
|
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
4963
4989
|
dropdown.style.top = `${rect.top - 6}px`; // 6px above the element
|
|
4964
4990
|
dropdown.style.transform = "translate(-50%, -100%)"; // Center horizontally, move up by its own height
|
|
4965
|
-
|
|
4991
|
+
|
|
4966
4992
|
dropdown.style.display = "block";
|
|
4967
4993
|
_isOpen = true;
|
|
4968
4994
|
}
|
|
@@ -4974,7 +5000,7 @@ const Sessions = (() => {
|
|
|
4974
5000
|
const dropdown = $("sib-model-dropdown");
|
|
4975
5001
|
if (dropdown) dropdown.style.display = "none";
|
|
4976
5002
|
_isOpen = false;
|
|
4977
|
-
|
|
5003
|
+
ModelPicker.closeSubmodelPanel();
|
|
4978
5004
|
}
|
|
4979
5005
|
});
|
|
4980
5006
|
|
|
@@ -4985,306 +5011,41 @@ const Sessions = (() => {
|
|
|
4985
5011
|
if (!dropdown) return;
|
|
4986
5012
|
|
|
4987
5013
|
try {
|
|
4988
|
-
console.log("[Model Switcher] Fetching /api/config...");
|
|
4989
5014
|
const res = await fetch(`/api/config?session_id=${encodeURIComponent(sessionId)}`);
|
|
4990
5015
|
const data = await res.json();
|
|
4991
|
-
console.log("[Model Switcher] Received data:", data);
|
|
4992
5016
|
const models = data.models || [];
|
|
4993
5017
|
const mediaCaps = data.media_capabilities || {};
|
|
4994
|
-
console.log("[Model Switcher] Models count:", models.length);
|
|
4995
5018
|
|
|
4996
5019
|
if (models.length === 0) {
|
|
4997
5020
|
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
|
|
4998
5021
|
return;
|
|
4999
5022
|
}
|
|
5000
5023
|
|
|
5001
|
-
dropdown
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
bench.className = "sib-model-bench";
|
|
5011
|
-
const btnLabel = (typeof I18n !== "undefined") ? I18n.t("sib.bench.btn") : "Benchmark";
|
|
5012
|
-
const btnTooltip = (typeof I18n !== "undefined") ? I18n.t("sib.bench.tooltip") : "Test response latency for every configured model";
|
|
5013
|
-
bench.innerHTML = `
|
|
5014
|
-
<button type="button" class="sib-bench-btn" title="${btnTooltip}">⚡ <span class="sib-bench-label">${btnLabel}</span></button>
|
|
5015
|
-
<span class="sib-bench-hint"></span>
|
|
5016
|
-
`;
|
|
5017
|
-
dropdown.appendChild(bench);
|
|
5018
|
-
|
|
5019
|
-
const benchBtn = bench.querySelector(".sib-bench-btn");
|
|
5020
|
-
const benchLabel = bench.querySelector(".sib-bench-label");
|
|
5021
|
-
const benchHint = bench.querySelector(".sib-bench-hint");
|
|
5022
|
-
benchBtn.addEventListener("click", (ev) => {
|
|
5023
|
-
ev.stopPropagation();
|
|
5024
|
-
_runBenchmark(sessionId, dropdown, benchBtn, benchLabel, benchHint);
|
|
5024
|
+
ModelPicker.populate(dropdown, {
|
|
5025
|
+
models,
|
|
5026
|
+
currentId: currentModelId,
|
|
5027
|
+
mediaCaps,
|
|
5028
|
+
subInfo,
|
|
5029
|
+
onSelect: (m) => _switchModel(sessionId, m.id, m.model),
|
|
5030
|
+
onSwitchSubModel: (name, displayName) => _switchSubModel(sessionId, name, displayName),
|
|
5031
|
+
onBenchmark: () => _benchmarkSession(sessionId),
|
|
5032
|
+
onConfigureMedia: _goConfigureMedia,
|
|
5025
5033
|
});
|
|
5026
|
-
|
|
5027
|
-
// ── Model rows ─────────────────────────────────────────────────────
|
|
5028
|
-
const _nameCounts = models.reduce((acc, m) => {
|
|
5029
|
-
acc[m.model] = (acc[m.model] || 0) + 1;
|
|
5030
|
-
return acc;
|
|
5031
|
-
}, {});
|
|
5032
|
-
|
|
5033
|
-
models.forEach(m => {
|
|
5034
|
-
console.log("[Model Switcher] Adding model:", m.model, "id:", m.id, "current:", currentModelId);
|
|
5035
|
-
const opt = document.createElement("div");
|
|
5036
|
-
opt.className = "sib-model-option";
|
|
5037
|
-
opt.dataset.modelId = m.id;
|
|
5038
|
-
if (m.id === currentModelId) opt.classList.add("current");
|
|
5039
|
-
|
|
5040
|
-
const left = document.createElement("span");
|
|
5041
|
-
left.className = "sib-model-name";
|
|
5042
|
-
|
|
5043
|
-
const nameLine = document.createElement("span");
|
|
5044
|
-
nameLine.className = "sib-model-name-main";
|
|
5045
|
-
// When a non-default quick-switch model is active for the current row,
|
|
5046
|
-
// show only that model's name (avoid the long "main → quick-switch"
|
|
5047
|
-
// string that gets truncated).
|
|
5048
|
-
const hasActiveOverride =
|
|
5049
|
-
m.id === currentModelId &&
|
|
5050
|
-
subInfo.current &&
|
|
5051
|
-
subInfo.current !== subInfo.cardModel;
|
|
5052
|
-
nameLine.textContent = hasActiveOverride ? subInfo.current : m.model;
|
|
5053
|
-
left.appendChild(nameLine);
|
|
5054
|
-
|
|
5055
|
-
// Vision status for the active model only — follows whichever model is
|
|
5056
|
-
// currently in effect (mediaCaps is computed for that same model).
|
|
5057
|
-
if (m.id === currentModelId && mediaCaps.vision) {
|
|
5058
|
-
const ok = !!mediaCaps.vision.configured;
|
|
5059
|
-
const vis = document.createElement("span");
|
|
5060
|
-
vis.className = "sib-model-vision " + (ok ? "is-ok" : "is-missing");
|
|
5061
|
-
vis.textContent = ok ? I18n.t("sib.vision.ok") : I18n.t("sib.vision.missing");
|
|
5062
|
-
vis.title = ok ? I18n.t("sib.vision.okTip") : I18n.t("sib.vision.missingTip");
|
|
5063
|
-
left.appendChild(vis);
|
|
5064
|
-
}
|
|
5065
|
-
|
|
5066
|
-
// Distinguish rows that would otherwise look identical: user-typed
|
|
5067
|
-
// remark first (most explicit), then host/key when names collide.
|
|
5068
|
-
const remark = (m.remark || "").trim();
|
|
5069
|
-
if (remark || _nameCounts[m.model] > 1) {
|
|
5070
|
-
left.classList.add("has-sub");
|
|
5071
|
-
const host = (() => {
|
|
5072
|
-
try { return new URL(m.base_url).host; } catch { return m.base_url || ""; }
|
|
5073
|
-
})();
|
|
5074
|
-
const subBits = [remark, host, m.api_key_masked].filter(Boolean);
|
|
5075
|
-
if (subBits.length) {
|
|
5076
|
-
const subLine = document.createElement("span");
|
|
5077
|
-
subLine.className = "sib-model-name-sub";
|
|
5078
|
-
subLine.textContent = subBits.join(" · ");
|
|
5079
|
-
left.appendChild(subLine);
|
|
5080
|
-
opt.title = `${m.model} · ${subBits.join(" · ")}`;
|
|
5081
|
-
}
|
|
5082
|
-
}
|
|
5083
|
-
|
|
5084
|
-
opt.appendChild(left);
|
|
5085
|
-
|
|
5086
|
-
const right = document.createElement("span");
|
|
5087
|
-
right.className = "sib-model-right";
|
|
5088
|
-
|
|
5089
|
-
if (m.type === "default") {
|
|
5090
|
-
const badge = document.createElement("span");
|
|
5091
|
-
badge.className = `model-badge ${m.type}`;
|
|
5092
|
-
badge.textContent = m.type;
|
|
5093
|
-
right.appendChild(badge);
|
|
5094
|
-
}
|
|
5095
|
-
|
|
5096
|
-
const lat = document.createElement("span");
|
|
5097
|
-
lat.className = "sib-model-latency";
|
|
5098
|
-
_fillLatencyCell(lat, _benchCache[m.id]);
|
|
5099
|
-
right.appendChild(lat);
|
|
5100
|
-
|
|
5101
|
-
const hasSubModels =
|
|
5102
|
-
m.id === currentModelId &&
|
|
5103
|
-
subInfo.options &&
|
|
5104
|
-
subInfo.options.length > 1;
|
|
5105
|
-
|
|
5106
|
-
if (hasSubModels) {
|
|
5107
|
-
const toggleBtn = document.createElement("button");
|
|
5108
|
-
toggleBtn.type = "button";
|
|
5109
|
-
toggleBtn.className = "sib-submodel-toggle";
|
|
5110
|
-
toggleBtn.title = I18n.t("sib.variant.header");
|
|
5111
|
-
toggleBtn.setAttribute("aria-expanded", "false");
|
|
5112
|
-
toggleBtn.innerHTML =
|
|
5113
|
-
'<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">' +
|
|
5114
|
-
'<path d="M6 3.5L10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
5115
|
-
'</svg>';
|
|
5116
|
-
right.appendChild(toggleBtn);
|
|
5117
|
-
|
|
5118
|
-
toggleBtn.addEventListener("click", (ev) => {
|
|
5119
|
-
ev.stopPropagation();
|
|
5120
|
-
_toggleSubmodelPanel(opt, toggleBtn, sessionId, subInfo);
|
|
5121
|
-
});
|
|
5122
|
-
}
|
|
5123
|
-
|
|
5124
|
-
opt.appendChild(right);
|
|
5125
|
-
|
|
5126
|
-
opt.addEventListener("click", () => _switchModel(sessionId, m.id, m.model));
|
|
5127
|
-
dropdown.appendChild(opt);
|
|
5128
|
-
});
|
|
5129
|
-
|
|
5130
|
-
_appendGenerationFooter(dropdown, mediaCaps);
|
|
5131
|
-
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
5132
5034
|
} catch (e) {
|
|
5133
5035
|
console.error("Failed to load models:", e);
|
|
5134
5036
|
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
|
|
5135
5037
|
}
|
|
5136
5038
|
}
|
|
5137
5039
|
|
|
5138
|
-
//
|
|
5139
|
-
//
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
footer.className = "sib-gen-footer";
|
|
5146
|
-
|
|
5147
|
-
const list = document.createElement("span");
|
|
5148
|
-
list.className = "sib-gen-list";
|
|
5149
|
-
kinds.forEach(k => {
|
|
5150
|
-
const cap = mediaCaps[k] || {};
|
|
5151
|
-
const ok = !!cap.configured;
|
|
5152
|
-
const chip = document.createElement("span");
|
|
5153
|
-
chip.className = "sib-gen-chip " + (ok ? "is-ok" : "is-off");
|
|
5154
|
-
chip.textContent = (ok ? "✓ " : "") + I18n.t(`sib.gen.kind.${k}`);
|
|
5155
|
-
chip.title = ok
|
|
5156
|
-
? I18n.t("sib.gen.okTip", { model: cap.model || "" })
|
|
5157
|
-
: I18n.t("sib.gen.offTip");
|
|
5158
|
-
list.appendChild(chip);
|
|
5159
|
-
});
|
|
5160
|
-
footer.appendChild(list);
|
|
5161
|
-
|
|
5162
|
-
const configBtn = document.createElement("button");
|
|
5163
|
-
configBtn.type = "button";
|
|
5164
|
-
configBtn.className = "sib-gen-config";
|
|
5165
|
-
configBtn.textContent = I18n.t("sib.gen.config");
|
|
5166
|
-
configBtn.title = I18n.t("sib.gen.offTip");
|
|
5167
|
-
configBtn.addEventListener("click", (ev) => {
|
|
5168
|
-
ev.stopPropagation();
|
|
5169
|
-
_goConfigureMedia();
|
|
5170
|
-
});
|
|
5171
|
-
footer.appendChild(configBtn);
|
|
5172
|
-
|
|
5173
|
-
dropdown.appendChild(footer);
|
|
5040
|
+
// Fetch benchmark results for a session. The benchmark UI (pending markers,
|
|
5041
|
+
// latency cells) lives in ModelPicker; this only supplies the request.
|
|
5042
|
+
async function _benchmarkSession(sessionId) {
|
|
5043
|
+
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
5044
|
+
const data = await res.json();
|
|
5045
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
5046
|
+
return data.results;
|
|
5174
5047
|
}
|
|
5175
5048
|
|
|
5176
|
-
function _goConfigureMedia() {
|
|
5177
|
-
const dropdown = $("sib-model-dropdown");
|
|
5178
|
-
if (dropdown) dropdown.style.display = "none";
|
|
5179
|
-
_isOpen = false;
|
|
5180
|
-
_closeSubmodelPanel();
|
|
5181
|
-
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
5182
|
-
setTimeout(() => {
|
|
5183
|
-
const sec = document.getElementById("media-section");
|
|
5184
|
-
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
5185
|
-
}, 200);
|
|
5186
|
-
}
|
|
5187
|
-
|
|
5188
|
-
// Render one latency cell based on a cached result.
|
|
5189
|
-
// undefined → empty slot (never tested / in-flight starts from here)
|
|
5190
|
-
// { ok:true } → "812ms" in green/amber/red per threshold
|
|
5191
|
-
// { ok:false } → "✕" with error in tooltip
|
|
5192
|
-
// { pending:true } → "…" spinner-ish marker
|
|
5193
|
-
function _fillLatencyCell(el, entry) {
|
|
5194
|
-
el.className = "sib-model-latency";
|
|
5195
|
-
el.textContent = "";
|
|
5196
|
-
el.removeAttribute("title");
|
|
5197
|
-
if (!entry) return;
|
|
5198
|
-
if (entry.pending) {
|
|
5199
|
-
el.textContent = "…";
|
|
5200
|
-
el.classList.add("is-pending");
|
|
5201
|
-
return;
|
|
5202
|
-
}
|
|
5203
|
-
if (!entry.ok) {
|
|
5204
|
-
el.textContent = "✕";
|
|
5205
|
-
el.classList.add("is-err");
|
|
5206
|
-
el.title = entry.error || "failed";
|
|
5207
|
-
return;
|
|
5208
|
-
}
|
|
5209
|
-
const ms = entry.ttft_ms;
|
|
5210
|
-
// Same thresholds as the sib-signal status bar — keep them aligned so
|
|
5211
|
-
// "3 bars in the status bar" ≈ "green number in the picker".
|
|
5212
|
-
// We measure full non-streaming response time (not real TTFT), so ≤60s is
|
|
5213
|
-
// normal, ≤120s is slow, beyond is bad. ≤2s still gets the "feels instant"
|
|
5214
|
-
// green treatment like the 4-bar signal.
|
|
5215
|
-
let cls = "is-bad";
|
|
5216
|
-
if (ms <= 2000) cls = "is-ok";
|
|
5217
|
-
else if (ms <= 60000) cls = "is-ok";
|
|
5218
|
-
else if (ms <= 120000) cls = "is-warn";
|
|
5219
|
-
el.classList.add(cls);
|
|
5220
|
-
el.textContent = ms >= 1000 ? (ms / 1000).toFixed(1) + "s" : ms + "ms";
|
|
5221
|
-
if (typeof I18n !== "undefined") {
|
|
5222
|
-
el.title = I18n.t("sib.bench.latencyTooltip", {
|
|
5223
|
-
ttft: el.textContent,
|
|
5224
|
-
time: new Date(entry.ts).toLocaleTimeString(),
|
|
5225
|
-
});
|
|
5226
|
-
} else {
|
|
5227
|
-
el.title = `TTFT ${el.textContent} · tested ${new Date(entry.ts).toLocaleTimeString()}`;
|
|
5228
|
-
}
|
|
5229
|
-
}
|
|
5230
|
-
|
|
5231
|
-
async function _runBenchmark(sessionId, dropdown, btn, label, hint) {
|
|
5232
|
-
if (_benchInFlight) return;
|
|
5233
|
-
_benchInFlight = true;
|
|
5234
|
-
btn.disabled = true;
|
|
5235
|
-
const origLabel = label.textContent;
|
|
5236
|
-
const _t = (key, vars) => (typeof I18n !== "undefined") ? I18n.t(key, vars) : key;
|
|
5237
|
-
label.textContent = _t("sib.bench.running");
|
|
5238
|
-
hint.textContent = "";
|
|
5239
|
-
|
|
5240
|
-
// Mark every row as pending so the user sees instant feedback instead of
|
|
5241
|
-
// a silent button. _fillLatencyCell handles the visual treatment.
|
|
5242
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
5243
|
-
const id = opt.dataset.modelId;
|
|
5244
|
-
if (!id) return;
|
|
5245
|
-
_benchCache[id] = { pending: true };
|
|
5246
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[id]);
|
|
5247
|
-
});
|
|
5248
|
-
|
|
5249
|
-
const t0 = performance.now();
|
|
5250
|
-
try {
|
|
5251
|
-
const res = await fetch(`/api/sessions/${sessionId}/benchmark`, { method: "POST" });
|
|
5252
|
-
const data = await res.json();
|
|
5253
|
-
if (!res.ok || !data.ok) throw new Error(data.error || "benchmark failed");
|
|
5254
|
-
|
|
5255
|
-
const now = Date.now();
|
|
5256
|
-
(data.results || []).forEach(r => {
|
|
5257
|
-
_benchCache[r.model_id] = {
|
|
5258
|
-
ok: !!r.ok,
|
|
5259
|
-
ttft_ms: r.ttft_ms,
|
|
5260
|
-
error: r.error,
|
|
5261
|
-
ts: now,
|
|
5262
|
-
};
|
|
5263
|
-
const opt = dropdown.querySelector(`.sib-model-option[data-model-id="${CSS.escape(r.model_id)}"]`);
|
|
5264
|
-
if (opt) _fillLatencyCell(opt.querySelector(".sib-model-latency"), _benchCache[r.model_id]);
|
|
5265
|
-
});
|
|
5266
|
-
|
|
5267
|
-
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
|
5268
|
-
hint.textContent = _t("sib.bench.done", { t: elapsed });
|
|
5269
|
-
} catch (e) {
|
|
5270
|
-
console.error("Benchmark failed:", e);
|
|
5271
|
-
hint.textContent = _t("sib.bench.failed", { msg: e.message });
|
|
5272
|
-
// Clear pending markers so rows don't stay stuck on "…"
|
|
5273
|
-
dropdown.querySelectorAll(".sib-model-option").forEach(opt => {
|
|
5274
|
-
const id = opt.dataset.modelId;
|
|
5275
|
-
if (id && _benchCache[id]?.pending) {
|
|
5276
|
-
_benchCache[id] = undefined;
|
|
5277
|
-
_fillLatencyCell(opt.querySelector(".sib-model-latency"), undefined);
|
|
5278
|
-
}
|
|
5279
|
-
});
|
|
5280
|
-
} finally {
|
|
5281
|
-
_benchInFlight = false;
|
|
5282
|
-
btn.disabled = false;
|
|
5283
|
-
label.textContent = origLabel;
|
|
5284
|
-
}
|
|
5285
|
-
}
|
|
5286
|
-
|
|
5287
|
-
// Switch session model via API
|
|
5288
5049
|
// Switch the session's current card. modelId is the stable runtime id,
|
|
5289
5050
|
// modelName is for optimistic display.
|
|
5290
5051
|
async function _switchModel(sessionId, modelId, modelName) {
|
|
@@ -5293,7 +5054,7 @@ const Sessions = (() => {
|
|
|
5293
5054
|
dropdown.style.display = "none";
|
|
5294
5055
|
_isOpen = false;
|
|
5295
5056
|
}
|
|
5296
|
-
|
|
5057
|
+
ModelPicker.closeSubmodelPanel();
|
|
5297
5058
|
|
|
5298
5059
|
try {
|
|
5299
5060
|
const res = await fetch(`/api/sessions/${sessionId}/model`, {
|
|
@@ -5312,8 +5073,7 @@ const Sessions = (() => {
|
|
|
5312
5073
|
// backend emits inside this same request. Don't touch sibModel here:
|
|
5313
5074
|
// the broadcast typically arrives BEFORE this fetch resolves (WS frame
|
|
5314
5075
|
// vs HTTP response on separate TCP streams), so writing here would
|
|
5315
|
-
// overwrite the already-correct value with an incomplete one
|
|
5316
|
-
// function only knows the card name, not whether a sub-model is pinned).
|
|
5076
|
+
// overwrite the already-correct value with an incomplete one.
|
|
5317
5077
|
|
|
5318
5078
|
console.log(`Switched session ${sessionId} to model ${modelName} (${modelId})`);
|
|
5319
5079
|
} catch (e) {
|
|
@@ -5330,7 +5090,7 @@ const Sessions = (() => {
|
|
|
5330
5090
|
dropdown.style.display = "none";
|
|
5331
5091
|
_isOpen = false;
|
|
5332
5092
|
}
|
|
5333
|
-
|
|
5093
|
+
ModelPicker.closeSubmodelPanel();
|
|
5334
5094
|
|
|
5335
5095
|
try {
|
|
5336
5096
|
const res = await fetch(`/api/sessions/${sessionId}/submodel`, {
|
|
@@ -5342,145 +5102,24 @@ const Sessions = (() => {
|
|
|
5342
5102
|
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
5343
5103
|
|
|
5344
5104
|
// The status bar is updated by the session_update broadcast that the
|
|
5345
|
-
// backend emits inside this same request. Don't touch sibModel here
|
|
5346
|
-
// the broadcast typically arrives BEFORE this fetch resolves, so any
|
|
5347
|
-
// write here would race with (and often overwrite) the correct value.
|
|
5105
|
+
// backend emits inside this same request. Don't touch sibModel here.
|
|
5348
5106
|
} catch (e) {
|
|
5349
5107
|
console.error("Failed to switch sub-model:", e);
|
|
5350
5108
|
alert("Failed to switch sub-model: " + e.message);
|
|
5351
5109
|
}
|
|
5352
5110
|
}
|
|
5353
5111
|
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
function _closeSubmodelPanel() {
|
|
5357
|
-
const panel = $("sib-submodel-panel");
|
|
5358
|
-
if (panel) panel.style.display = "none";
|
|
5359
|
-
if (_activeSubmodelAnchor) {
|
|
5360
|
-
const btn = _activeSubmodelAnchor.querySelector(".sib-submodel-toggle");
|
|
5361
|
-
if (btn) btn.setAttribute("aria-expanded", "false");
|
|
5362
|
-
_activeSubmodelAnchor.classList.remove("submodel-open");
|
|
5363
|
-
_activeSubmodelAnchor = null;
|
|
5364
|
-
}
|
|
5365
|
-
}
|
|
5366
|
-
|
|
5367
|
-
function _toggleSubmodelPanel(anchorRow, btn, sessionId, subInfo) {
|
|
5368
|
-
const panel = $("sib-submodel-panel");
|
|
5112
|
+
// Navigate to media config.
|
|
5113
|
+
function _goConfigureMedia() {
|
|
5369
5114
|
const dropdown = $("sib-model-dropdown");
|
|
5370
|
-
if (
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
_closeSubmodelPanel();
|
|
5379
|
-
return;
|
|
5380
|
-
}
|
|
5381
|
-
|
|
5382
|
-
_renderSubmodelPanel(panel, sessionId, subInfo);
|
|
5383
|
-
|
|
5384
|
-
// Reset any prior position so measurements are accurate.
|
|
5385
|
-
panel.style.left = "0px";
|
|
5386
|
-
panel.style.top = "0px";
|
|
5387
|
-
panel.style.display = "block";
|
|
5388
|
-
panel.style.visibility = "hidden";
|
|
5389
|
-
|
|
5390
|
-
const dropRect = dropdown.getBoundingClientRect();
|
|
5391
|
-
const btnRect = btn.getBoundingClientRect();
|
|
5392
|
-
const panelRect = panel.getBoundingClientRect();
|
|
5393
|
-
const gap = 6;
|
|
5394
|
-
const margin = 8;
|
|
5395
|
-
const vw = window.innerWidth;
|
|
5396
|
-
const vh = window.innerHeight;
|
|
5397
|
-
|
|
5398
|
-
// Prefer right of dropdown; flip to left if we'd overflow viewport.
|
|
5399
|
-
let left = dropRect.right + gap;
|
|
5400
|
-
if (left + panelRect.width > vw - margin) {
|
|
5401
|
-
left = dropRect.left - panelRect.width - gap;
|
|
5402
|
-
}
|
|
5403
|
-
// If still off-screen on the left, clamp inside viewport.
|
|
5404
|
-
if (left < margin) left = margin;
|
|
5405
|
-
|
|
5406
|
-
// Vertically align to the chevron button, but clamp inside viewport.
|
|
5407
|
-
let top = btnRect.top - 6;
|
|
5408
|
-
if (top + panelRect.height > vh - margin) {
|
|
5409
|
-
top = vh - margin - panelRect.height;
|
|
5410
|
-
}
|
|
5411
|
-
if (top < margin) top = margin;
|
|
5412
|
-
|
|
5413
|
-
panel.style.left = `${left}px`;
|
|
5414
|
-
panel.style.top = `${top}px`;
|
|
5415
|
-
panel.style.visibility = "";
|
|
5416
|
-
|
|
5417
|
-
_activeSubmodelAnchor = anchorRow;
|
|
5418
|
-
anchorRow.classList.add("submodel-open");
|
|
5419
|
-
btn.setAttribute("aria-expanded", "true");
|
|
5420
|
-
}
|
|
5421
|
-
|
|
5422
|
-
function _renderSubmodelPanel(panel, sessionId, subInfo) {
|
|
5423
|
-
panel.innerHTML = "";
|
|
5424
|
-
|
|
5425
|
-
const header = document.createElement("div");
|
|
5426
|
-
header.className = "sib-submodel-panel-header";
|
|
5427
|
-
header.textContent = I18n.t("sib.variant.header");
|
|
5428
|
-
panel.appendChild(header);
|
|
5429
|
-
|
|
5430
|
-
const cardDefault = subInfo.cardModel;
|
|
5431
|
-
subInfo.options.forEach(name => {
|
|
5432
|
-
const row = document.createElement("div");
|
|
5433
|
-
row.className = "sib-submodel-row";
|
|
5434
|
-
row.dataset.subModel = name;
|
|
5435
|
-
|
|
5436
|
-
const isActive = subInfo.current
|
|
5437
|
-
? name === subInfo.current
|
|
5438
|
-
: name === cardDefault;
|
|
5439
|
-
if (isActive) row.classList.add("current");
|
|
5440
|
-
|
|
5441
|
-
const nameEl = document.createElement("span");
|
|
5442
|
-
nameEl.className = "sib-submodel-row-name";
|
|
5443
|
-
nameEl.textContent = name;
|
|
5444
|
-
row.appendChild(nameEl);
|
|
5445
|
-
|
|
5446
|
-
const rightBox = document.createElement("span");
|
|
5447
|
-
rightBox.style.cssText = "display:inline-flex;align-items:center;gap:0.375rem;flex-shrink:0;";
|
|
5448
|
-
|
|
5449
|
-
const priceEl = document.createElement("span");
|
|
5450
|
-
priceEl.className = "sib-model-price";
|
|
5451
|
-
rightBox.appendChild(priceEl);
|
|
5452
|
-
|
|
5453
|
-
if (name === cardDefault) {
|
|
5454
|
-
const tag = document.createElement("span");
|
|
5455
|
-
tag.className = "sib-submodel-default-tag";
|
|
5456
|
-
tag.textContent = I18n.t("sib.variant.default");
|
|
5457
|
-
rightBox.appendChild(tag);
|
|
5458
|
-
}
|
|
5459
|
-
row.appendChild(rightBox);
|
|
5460
|
-
|
|
5461
|
-
row.addEventListener("click", (ev) => {
|
|
5462
|
-
ev.stopPropagation();
|
|
5463
|
-
const passName = (name === cardDefault) ? null : name;
|
|
5464
|
-
_switchSubModel(sessionId, passName, name);
|
|
5465
|
-
});
|
|
5466
|
-
panel.appendChild(row);
|
|
5467
|
-
});
|
|
5468
|
-
|
|
5469
|
-
_fillSubmodelPrices(panel);
|
|
5470
|
-
}
|
|
5471
|
-
|
|
5472
|
-
// Ratio text fills in asynchronously once /api/model_prices responds
|
|
5473
|
-
// (cached afterwards, so re-opening the panel shows prices instantly).
|
|
5474
|
-
async function _fillSubmodelPrices(panel) {
|
|
5475
|
-
const rows = Array.from(panel.querySelectorAll(".sib-submodel-row"));
|
|
5476
|
-
await _loadPriceRatios(rows.map((r) => r.dataset.subModel));
|
|
5477
|
-
rows.forEach((row) => {
|
|
5478
|
-
const price = _priceCache.get(row.dataset.subModel);
|
|
5479
|
-
const el = row.querySelector(".sib-model-price");
|
|
5480
|
-
if (!price || !el || el.textContent) return;
|
|
5481
|
-
el.textContent = price.label;
|
|
5482
|
-
el.title = I18n.t("sib.price.tip", { in: price.in, out: price.out });
|
|
5483
|
-
});
|
|
5115
|
+
if (dropdown) dropdown.style.display = "none";
|
|
5116
|
+
_isOpen = false;
|
|
5117
|
+
ModelPicker.closeSubmodelPanel();
|
|
5118
|
+
if (typeof Router !== "undefined") Router.navigate("settings");
|
|
5119
|
+
setTimeout(() => {
|
|
5120
|
+
const sec = document.getElementById("media-section");
|
|
5121
|
+
if (sec) sec.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
5122
|
+
}, 200);
|
|
5484
5123
|
}
|
|
5485
5124
|
})();
|
|
5486
5125
|
|
|
@@ -5490,6 +5129,30 @@ const Sessions = (() => {
|
|
|
5490
5129
|
// ── Tree-based directory picker ─────────────────────────────────────────
|
|
5491
5130
|
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>';
|
|
5492
5131
|
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>';
|
|
5132
|
+
const ICON_DRIVE_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"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" y1="16" x2="6.01" y2="16"/><line x1="10" y1="16" x2="10.01" y2="16"/></svg>';
|
|
5133
|
+
const ICON_STAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2.6L14.84 8.35L21.18 9.27L16.59 13.74L17.67 20.05L12 17.07L6.33 20.05L7.41 13.74L2.82 9.27L9.16 8.35L12 2.6Z"/></svg>';
|
|
5134
|
+
const DIR_FAVORITES_STORAGE_KEY = "clacky-directory-favorites";
|
|
5135
|
+
|
|
5136
|
+
function normalizeFavoritePath(path) {
|
|
5137
|
+
return (path || "").trim().replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
function loadDirectoryFavorites() {
|
|
5141
|
+
try {
|
|
5142
|
+
const paths = JSON.parse(localStorage.getItem(DIR_FAVORITES_STORAGE_KEY) || "[]");
|
|
5143
|
+
if (!Array.isArray(paths)) return [];
|
|
5144
|
+
return [...new Set(paths.filter(path => typeof path === "string" && path.trim()).map(normalizeFavoritePath))];
|
|
5145
|
+
} catch (_e) {
|
|
5146
|
+
return [];
|
|
5147
|
+
}
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
function saveDirectoryFavorites(paths) {
|
|
5151
|
+
try {
|
|
5152
|
+
localStorage.setItem(DIR_FAVORITES_STORAGE_KEY, JSON.stringify(paths));
|
|
5153
|
+
} catch (_e) {}
|
|
5154
|
+
}
|
|
5155
|
+
|
|
5493
5156
|
function showDirectoryPicker(currentDir, sessionId) {
|
|
5494
5157
|
return new Promise((resolve) => {
|
|
5495
5158
|
const t = (key, fallback) => {
|
|
@@ -5501,12 +5164,17 @@ const Sessions = (() => {
|
|
|
5501
5164
|
// real filesystem via /api/dirs instead of the session-scoped files API.
|
|
5502
5165
|
const sessionLess = !sessionId;
|
|
5503
5166
|
|
|
5167
|
+
let currentPath = currentDir;
|
|
5504
5168
|
let selectedPath = currentDir;
|
|
5169
|
+
let currentHasChildDirs = false;
|
|
5505
5170
|
let rootDir = ""; // absolute path of the session's working directory
|
|
5506
5171
|
let homeDir = ""; // user home, used as a quick preset
|
|
5507
5172
|
let defaultDir = ""; // default workspace from agent_config (or fallback)
|
|
5508
5173
|
let showHidden = false;
|
|
5509
5174
|
let placesLoaded = false;
|
|
5175
|
+
let currentPathLoaded = false;
|
|
5176
|
+
let sidebarPlaces = [];
|
|
5177
|
+
let directoryFavorites = loadDirectoryFavorites();
|
|
5510
5178
|
|
|
5511
5179
|
// Fetch directory entries from API, returns dirs with absolute paths
|
|
5512
5180
|
async function fetchDirs(relPath, absolute = false) {
|
|
@@ -5520,10 +5188,14 @@ const Sessions = (() => {
|
|
|
5520
5188
|
rootDir = data.root || rootDir;
|
|
5521
5189
|
homeDir = data.home || homeDir;
|
|
5522
5190
|
defaultDir = data.default || defaultDir;
|
|
5523
|
-
if (!placesLoaded && data.places) {
|
|
5191
|
+
if (!placesLoaded && data.places) {
|
|
5192
|
+
placesLoaded = true;
|
|
5193
|
+
sidebarPlaces = data.places;
|
|
5194
|
+
renderSidebar();
|
|
5195
|
+
}
|
|
5524
5196
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
5525
5197
|
dirs.forEach(d => { d.absPath = d.path; d.absolute = true; });
|
|
5526
|
-
return dirs;
|
|
5198
|
+
return { dirs, resolvedPath: data.path || data.root || relPath, exact: data.exact };
|
|
5527
5199
|
}
|
|
5528
5200
|
let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
|
|
5529
5201
|
if (absolute) url += "&absolute=true";
|
|
@@ -5535,7 +5207,11 @@ const Sessions = (() => {
|
|
|
5535
5207
|
if (!absolute) rootDir = data.root || rootDir;
|
|
5536
5208
|
homeDir = data.home || homeDir;
|
|
5537
5209
|
defaultDir = data.default || defaultDir;
|
|
5538
|
-
if (!placesLoaded && data.places) {
|
|
5210
|
+
if (!placesLoaded && data.places) {
|
|
5211
|
+
placesLoaded = true;
|
|
5212
|
+
sidebarPlaces = data.places;
|
|
5213
|
+
renderSidebar();
|
|
5214
|
+
}
|
|
5539
5215
|
const dirs = (data.entries || []).filter(e => e.type === "dir");
|
|
5540
5216
|
// Convert relative paths to absolute
|
|
5541
5217
|
dirs.forEach(d => {
|
|
@@ -5543,7 +5219,8 @@ const Sessions = (() => {
|
|
|
5543
5219
|
const cleanPath = d.path.replace(/^\/+/, "");
|
|
5544
5220
|
d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
|
|
5545
5221
|
d.absolute = absolute; // Store absolute flag for child expansion
|
|
5546
|
-
});
|
|
5222
|
+
});
|
|
5223
|
+
return { dirs, resolvedPath: data.path || data.root || relPath, exact: true };
|
|
5547
5224
|
}
|
|
5548
5225
|
|
|
5549
5226
|
// Build a flat list row for a directory entry (Finder-style)
|
|
@@ -5576,7 +5253,6 @@ const Sessions = (() => {
|
|
|
5576
5253
|
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5577
5254
|
row.classList.add("selected");
|
|
5578
5255
|
selectedPath = entry.absPath;
|
|
5579
|
-
refreshBreadcrumb(entry.absPath);
|
|
5580
5256
|
}, 200);
|
|
5581
5257
|
});
|
|
5582
5258
|
|
|
@@ -5614,30 +5290,42 @@ const Sessions = (() => {
|
|
|
5614
5290
|
const toolbar = document.createElement("div");
|
|
5615
5291
|
toolbar.className = "dp-toolbar";
|
|
5616
5292
|
|
|
5617
|
-
// Back / Forward buttons
|
|
5293
|
+
// Back / Forward / Refresh buttons
|
|
5618
5294
|
const backBtn = document.createElement("button");
|
|
5619
5295
|
backBtn.className = "dp-nav-btn";
|
|
5620
5296
|
backBtn.title = t("sib.dir.back", "后退");
|
|
5621
|
-
backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
5297
|
+
backBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12H2"/><path d="m9 4-7 8 7 8"/></svg>`;
|
|
5622
5298
|
backBtn.disabled = true;
|
|
5623
5299
|
|
|
5624
5300
|
const fwdBtn = document.createElement("button");
|
|
5625
5301
|
fwdBtn.className = "dp-nav-btn";
|
|
5626
5302
|
fwdBtn.title = t("sib.dir.forward", "前进");
|
|
5627
|
-
fwdBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
5303
|
+
fwdBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h20"/><path d="m15 4 7 8-7 8"/></svg>`;
|
|
5628
5304
|
fwdBtn.disabled = true;
|
|
5629
5305
|
|
|
5306
|
+
const refreshBtn = document.createElement("button");
|
|
5307
|
+
refreshBtn.className = "dp-nav-btn";
|
|
5308
|
+
refreshBtn.title = t("sib.dir.refresh", "刷新");
|
|
5309
|
+
refreshBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path transform="translate(3.5 3.5) scale(0.0166015625)" d="M914.17946 324.34283C854.308387 324.325508 750.895846 324.317788 750.895846 324.317788 732.045471 324.317788 716.764213 339.599801 716.764213 358.451121 716.764213 377.30244 732.045471 392.584453 750.895846 392.584453L955.787864 392.584453C993.448095 392.584453 1024 362.040424 1024 324.368908L1024 119.466667C1024 100.615347 1008.718742 85.333333 989.868367 85.333333 971.017993 85.333333 955.736735 100.615347 955.736735 119.466667L955.736735 256.497996C933.314348 217.628194 905.827487 181.795372 873.995034 149.961328 778.623011 54.584531 649.577119 0 511.974435 0 229.218763 0 0 229.230209 0 512 0 794.769791 229.218763 1024 511.974435 1024 794.730125 1024 1023.948888 794.769791 1023.948888 512 1023.948888 493.148681 1008.66763 477.866667 989.817256 477.866667 970.966881 477.866667 955.685623 493.148681 955.685623 512 955.685623 757.067153 757.029358 955.733333 511.974435 955.733333 266.91953 955.733333 68.263265 757.067153 68.263265 512 68.263265 266.932847 266.91953 68.266667 511.974435 68.266667 631.286484 68.266667 743.028524 115.531923 825.725634 198.233152 862.329644 234.839003 892.298522 277.528256 914.17946 324.34283L914.17946 324.34283Z" stroke="currentColor" stroke-width="35" stroke-linejoin="round"/></svg>`;
|
|
5310
|
+
|
|
5630
5311
|
const navBtns = document.createElement("div");
|
|
5631
5312
|
navBtns.className = "dp-nav-btns";
|
|
5632
5313
|
navBtns.appendChild(backBtn);
|
|
5633
5314
|
navBtns.appendChild(fwdBtn);
|
|
5315
|
+
navBtns.appendChild(refreshBtn);
|
|
5634
5316
|
|
|
5635
5317
|
// Breadcrumb
|
|
5636
5318
|
const breadcrumb = document.createElement("div");
|
|
5637
5319
|
breadcrumb.className = "dp-breadcrumb";
|
|
5638
5320
|
|
|
5321
|
+
const favoriteBtn = document.createElement("button");
|
|
5322
|
+
favoriteBtn.className = "dp-nav-btn dp-favorite-btn";
|
|
5323
|
+
favoriteBtn.innerHTML = ICON_STAR_SVG;
|
|
5324
|
+
favoriteBtn.disabled = true;
|
|
5325
|
+
|
|
5639
5326
|
toolbar.appendChild(navBtns);
|
|
5640
5327
|
toolbar.appendChild(breadcrumb);
|
|
5328
|
+
toolbar.appendChild(favoriteBtn);
|
|
5641
5329
|
main.appendChild(toolbar);
|
|
5642
5330
|
|
|
5643
5331
|
// ── Column header ─────────────────────────────────────────────────
|
|
@@ -5702,101 +5390,320 @@ const Sessions = (() => {
|
|
|
5702
5390
|
// ── History stack for back/fwd ────────────────────────────────────
|
|
5703
5391
|
const history = [];
|
|
5704
5392
|
let historyIndex = -1;
|
|
5393
|
+
let breadcrumbMenu = null;
|
|
5394
|
+
let breadcrumbMenuAnchor = null;
|
|
5395
|
+
let breadcrumbMenuRequest = 0;
|
|
5705
5396
|
|
|
5706
5397
|
function refreshNavBtns() {
|
|
5707
5398
|
backBtn.disabled = historyIndex <= 0;
|
|
5708
5399
|
fwdBtn.disabled = historyIndex >= history.length - 1;
|
|
5709
5400
|
}
|
|
5710
5401
|
|
|
5711
|
-
// Render the sidebar quick-access favorites
|
|
5712
|
-
function renderSidebar(
|
|
5402
|
+
// Render the sidebar quick-access favorites + drives
|
|
5403
|
+
function renderSidebar() {
|
|
5713
5404
|
sidebar.innerHTML = "";
|
|
5714
|
-
|
|
5715
|
-
const
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5405
|
+
|
|
5406
|
+
const quickAccess = sidebarPlaces.filter(p => p.kind !== "drive");
|
|
5407
|
+
const drives = sidebarPlaces.filter(p => p.kind === "drive");
|
|
5408
|
+
|
|
5409
|
+
const addSection = (labelKey, labelFallback, items) => {
|
|
5410
|
+
if (!items.length) return;
|
|
5411
|
+
const label = document.createElement("div");
|
|
5412
|
+
label.className = "dp-sidebar-label";
|
|
5413
|
+
label.textContent = t(labelKey, labelFallback);
|
|
5414
|
+
sidebar.appendChild(label);
|
|
5415
|
+
items.forEach(place => sidebar.appendChild(buildSidebarItem(place)));
|
|
5416
|
+
};
|
|
5417
|
+
|
|
5418
|
+
addSection("sib.dir.favorites", "Quick access", quickAccess);
|
|
5419
|
+
if (directoryFavorites.length) {
|
|
5420
|
+
const label = document.createElement("div");
|
|
5421
|
+
label.className = "dp-sidebar-label";
|
|
5422
|
+
label.textContent = t("sib.dir.userFavorites", "My favorites");
|
|
5423
|
+
sidebar.appendChild(label);
|
|
5424
|
+
directoryFavorites.forEach(path => sidebar.appendChild(buildFavoriteSidebarItem(path)));
|
|
5425
|
+
}
|
|
5426
|
+
addSection("sib.dir.drives", "Drives", drives);
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5429
|
+
function buildSidebarItem(place) {
|
|
5430
|
+
const item = document.createElement("button");
|
|
5431
|
+
item.type = "button";
|
|
5432
|
+
item.className = "dp-sidebar-item";
|
|
5433
|
+
item.title = place.path;
|
|
5434
|
+
|
|
5435
|
+
const icon = document.createElement("span");
|
|
5436
|
+
icon.className = "dp-sidebar-icon";
|
|
5437
|
+
icon.innerHTML = place.kind === "drive" ? ICON_DRIVE_SVG : ICON_FOLDER_SVG;
|
|
5438
|
+
|
|
5439
|
+
const name = document.createElement("span");
|
|
5440
|
+
name.className = "dp-sidebar-name";
|
|
5441
|
+
|
|
5442
|
+
let label = place.label;
|
|
5443
|
+
if (!label && place.kind === "drive" && place.letter) {
|
|
5444
|
+
label = I18n.t("sib.dir.places.drive", { letter: place.letter });
|
|
5445
|
+
}
|
|
5446
|
+
if (!label) label = t("sib.dir.places." + place.id, place.path);
|
|
5447
|
+
name.textContent = label;
|
|
5448
|
+
|
|
5449
|
+
item.appendChild(icon);
|
|
5450
|
+
item.appendChild(name);
|
|
5451
|
+
item.addEventListener("click", () => navigateTo(place.path, true));
|
|
5452
|
+
return item;
|
|
5453
|
+
}
|
|
5454
|
+
|
|
5455
|
+
function buildFavoriteSidebarItem(path) {
|
|
5456
|
+
const item = document.createElement("div");
|
|
5457
|
+
item.className = "dp-sidebar-favorite";
|
|
5458
|
+
item.title = path;
|
|
5459
|
+
|
|
5460
|
+
const openButton = document.createElement("button");
|
|
5461
|
+
openButton.type = "button";
|
|
5462
|
+
openButton.className = "dp-sidebar-item dp-sidebar-favorite-open";
|
|
5463
|
+
|
|
5464
|
+
const icon = document.createElement("span");
|
|
5465
|
+
icon.className = "dp-sidebar-icon";
|
|
5466
|
+
icon.innerHTML = ICON_FOLDER_SVG;
|
|
5467
|
+
|
|
5468
|
+
const name = document.createElement("span");
|
|
5469
|
+
name.className = "dp-sidebar-name";
|
|
5470
|
+
name.textContent = path === "/" ? "/" : path.split("/").filter(Boolean).pop() || path;
|
|
5471
|
+
|
|
5472
|
+
const removeButton = document.createElement("button");
|
|
5473
|
+
removeButton.type = "button";
|
|
5474
|
+
removeButton.className = "dp-sidebar-favorite-remove";
|
|
5475
|
+
removeButton.title = t("sib.dir.removeFavorite", "Remove from favorites");
|
|
5476
|
+
removeButton.setAttribute("aria-label", removeButton.title);
|
|
5477
|
+
removeButton.innerHTML = '<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"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
|
5478
|
+
|
|
5479
|
+
openButton.appendChild(icon);
|
|
5480
|
+
openButton.appendChild(name);
|
|
5481
|
+
openButton.addEventListener("click", () => navigateTo(path, true));
|
|
5482
|
+
removeButton.addEventListener("click", (e) => {
|
|
5483
|
+
e.stopPropagation();
|
|
5484
|
+
directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
|
|
5485
|
+
saveDirectoryFavorites(directoryFavorites);
|
|
5486
|
+
renderSidebar();
|
|
5487
|
+
refreshFavoriteButton();
|
|
5738
5488
|
});
|
|
5489
|
+
|
|
5490
|
+
item.appendChild(openButton);
|
|
5491
|
+
item.appendChild(removeButton);
|
|
5492
|
+
return item;
|
|
5493
|
+
}
|
|
5494
|
+
|
|
5495
|
+
function refreshFavoriteButton() {
|
|
5496
|
+
const path = normalizeFavoritePath(currentPath);
|
|
5497
|
+
const isFavorite = directoryFavorites.includes(path);
|
|
5498
|
+
favoriteBtn.disabled = !currentPathLoaded;
|
|
5499
|
+
favoriteBtn.classList.toggle("active", isFavorite);
|
|
5500
|
+
favoriteBtn.setAttribute("aria-pressed", isFavorite ? "true" : "false");
|
|
5501
|
+
favoriteBtn.title = t(
|
|
5502
|
+
isFavorite ? "sib.dir.removeFavorite" : "sib.dir.addFavorite",
|
|
5503
|
+
isFavorite ? "Remove from favorites" : "Add to favorites"
|
|
5504
|
+
);
|
|
5505
|
+
favoriteBtn.setAttribute("aria-label", favoriteBtn.title);
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
function closeBreadcrumbMenu() {
|
|
5509
|
+
breadcrumbMenuRequest++;
|
|
5510
|
+
breadcrumbMenu?.remove();
|
|
5511
|
+
breadcrumbMenuAnchor?.classList.remove("open");
|
|
5512
|
+
breadcrumbMenu = null;
|
|
5513
|
+
breadcrumbMenuAnchor = null;
|
|
5514
|
+
}
|
|
5515
|
+
|
|
5516
|
+
function positionBreadcrumbMenu(menu, anchor) {
|
|
5517
|
+
const rect = anchor.getBoundingClientRect();
|
|
5518
|
+
const maxLeft = Math.max(8, window.innerWidth - menu.offsetWidth - 8);
|
|
5519
|
+
menu.style.left = `${Math.min(rect.left, maxLeft)}px`;
|
|
5520
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
5739
5521
|
}
|
|
5740
5522
|
|
|
5741
|
-
|
|
5523
|
+
async function toggleBreadcrumbMenu(anchor, parentPath) {
|
|
5524
|
+
if (breadcrumbMenuAnchor === anchor) {
|
|
5525
|
+
closeBreadcrumbMenu();
|
|
5526
|
+
return;
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
closeBreadcrumbMenu();
|
|
5530
|
+
const requestId = breadcrumbMenuRequest;
|
|
5531
|
+
const menu = document.createElement("div");
|
|
5532
|
+
menu.className = "dp-bc-menu";
|
|
5533
|
+
menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.loading", "Loading…")}</div>`;
|
|
5534
|
+
overlay.appendChild(menu);
|
|
5535
|
+
breadcrumbMenu = menu;
|
|
5536
|
+
breadcrumbMenuAnchor = anchor;
|
|
5537
|
+
anchor.classList.add("open");
|
|
5538
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5539
|
+
|
|
5540
|
+
try {
|
|
5541
|
+
const { dirs } = await fetchDirs(parentPath, true);
|
|
5542
|
+
if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
|
|
5543
|
+
menu.innerHTML = "";
|
|
5544
|
+
if (dirs.length === 0) {
|
|
5545
|
+
menu.innerHTML = `<div class="dp-bc-menu-state">${t("sib.dir.empty", "Empty directory")}</div>`;
|
|
5546
|
+
} else {
|
|
5547
|
+
dirs.forEach(entry => {
|
|
5548
|
+
const item = document.createElement("button");
|
|
5549
|
+
item.type = "button";
|
|
5550
|
+
item.className = "dp-bc-menu-item";
|
|
5551
|
+
item.innerHTML = `<span class="dp-bc-menu-icon">${ICON_FOLDER_SVG}</span>`;
|
|
5552
|
+
const label = document.createElement("span");
|
|
5553
|
+
label.className = "dp-bc-menu-name";
|
|
5554
|
+
label.textContent = entry.name;
|
|
5555
|
+
item.appendChild(label);
|
|
5556
|
+
item.addEventListener("click", (e) => {
|
|
5557
|
+
e.stopPropagation();
|
|
5558
|
+
closeBreadcrumbMenu();
|
|
5559
|
+
navigateTo(entry.absPath, true);
|
|
5560
|
+
});
|
|
5561
|
+
menu.appendChild(item);
|
|
5562
|
+
});
|
|
5563
|
+
}
|
|
5564
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5565
|
+
} catch (err) {
|
|
5566
|
+
if (requestId !== breadcrumbMenuRequest || breadcrumbMenu !== menu) return;
|
|
5567
|
+
console.error("dir picker breadcrumb load failed:", err);
|
|
5568
|
+
menu.innerHTML = `<div class="dp-bc-menu-state dp-bc-menu-error">${t("sib.dir.loadError", "Failed to load")}</div>`;
|
|
5569
|
+
positionBreadcrumbMenu(menu, anchor);
|
|
5570
|
+
}
|
|
5571
|
+
}
|
|
5572
|
+
|
|
5573
|
+
function appendBreadcrumbPart(label, targetPath, current = false, showArrow = true) {
|
|
5574
|
+
const seg = document.createElement("button");
|
|
5575
|
+
seg.type = "button";
|
|
5576
|
+
seg.className = "dp-bc-seg";
|
|
5577
|
+
if (current) seg.classList.add("dp-bc-current");
|
|
5578
|
+
seg.textContent = label;
|
|
5579
|
+
seg.title = targetPath;
|
|
5580
|
+
seg.addEventListener("click", (e) => {
|
|
5581
|
+
e.stopPropagation();
|
|
5582
|
+
navigateTo(targetPath, true);
|
|
5583
|
+
});
|
|
5584
|
+
|
|
5585
|
+
breadcrumb.appendChild(seg);
|
|
5586
|
+
if (showArrow) {
|
|
5587
|
+
const arrow = document.createElement("button");
|
|
5588
|
+
arrow.type = "button";
|
|
5589
|
+
arrow.className = "dp-bc-sep";
|
|
5590
|
+
arrow.title = targetPath;
|
|
5591
|
+
arrow.innerHTML = ICON_CARET_SVG;
|
|
5592
|
+
arrow.addEventListener("click", (e) => {
|
|
5593
|
+
e.stopPropagation();
|
|
5594
|
+
toggleBreadcrumbMenu(arrow, targetPath);
|
|
5595
|
+
});
|
|
5596
|
+
breadcrumb.appendChild(arrow);
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
// Build a Windows-style breadcrumb from an absolute path.
|
|
5742
5601
|
function refreshBreadcrumb(absPath) {
|
|
5602
|
+
closeBreadcrumbMenu();
|
|
5603
|
+
breadcrumb.classList.remove("editing");
|
|
5743
5604
|
breadcrumb.innerHTML = "";
|
|
5744
5605
|
if (!absPath) return;
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
parts.
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5606
|
+
|
|
5607
|
+
const normalized = absPath.replaceAll("\\", "/").replace(/\/+$/, "") || "/";
|
|
5608
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
5609
|
+
let accumulated = "";
|
|
5610
|
+
|
|
5611
|
+
if (normalized.startsWith("/")) {
|
|
5612
|
+
const isCurrent = parts.length === 0;
|
|
5613
|
+
appendBreadcrumbPart("/", "/", isCurrent, !isCurrent || currentHasChildDirs);
|
|
5614
|
+
} else if (parts.length > 0) {
|
|
5615
|
+
accumulated = parts.shift();
|
|
5616
|
+
const isCurrent = parts.length === 0;
|
|
5617
|
+
appendBreadcrumbPart(accumulated, `${accumulated}/`, isCurrent, !isCurrent || currentHasChildDirs);
|
|
5618
|
+
}
|
|
5619
|
+
|
|
5620
|
+
parts.forEach((part, index) => {
|
|
5621
|
+
accumulated = normalized.startsWith("/")
|
|
5622
|
+
? `${accumulated}/${part}`
|
|
5623
|
+
: `${accumulated}/${part}`;
|
|
5624
|
+
const targetPath = normalized.startsWith("/") ? accumulated || "/" : accumulated;
|
|
5625
|
+
const isCurrent = index === parts.length - 1;
|
|
5626
|
+
appendBreadcrumbPart(part, targetPath, isCurrent, !isCurrent || currentHasChildDirs);
|
|
5627
|
+
});
|
|
5628
|
+
|
|
5629
|
+
requestAnimationFrame(() => { breadcrumb.scrollLeft = breadcrumb.scrollWidth; });
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
function enterAddressMode(value = currentPath) {
|
|
5633
|
+
closeBreadcrumbMenu();
|
|
5634
|
+
breadcrumb.classList.add("editing");
|
|
5635
|
+
breadcrumb.innerHTML = "";
|
|
5636
|
+
|
|
5637
|
+
const input = document.createElement("input");
|
|
5638
|
+
input.type = "text";
|
|
5639
|
+
input.className = "dp-address-input";
|
|
5640
|
+
input.value = value || "";
|
|
5641
|
+
input.placeholder = t("sib.dir.inputPlaceholder", "Type or select a directory path");
|
|
5642
|
+
input.setAttribute("aria-label", input.placeholder);
|
|
5643
|
+
input.spellcheck = false;
|
|
5644
|
+
input.autocomplete = "off";
|
|
5645
|
+
input.setAttribute("autocapitalize", "off");
|
|
5646
|
+
breadcrumb.appendChild(input);
|
|
5647
|
+
|
|
5648
|
+
let submitting = false;
|
|
5649
|
+
const cancel = () => {
|
|
5650
|
+
if (!breadcrumb.contains(input)) return;
|
|
5651
|
+
refreshBreadcrumb(currentPath);
|
|
5652
|
+
if (listContainer.querySelector(".dp-error")) navigateTo(currentPath, true, false);
|
|
5653
|
+
};
|
|
5654
|
+
IME.bindEnter(input, async () => {
|
|
5655
|
+
const path = input.value.trim();
|
|
5656
|
+
if (!path || submitting) return;
|
|
5657
|
+
submitting = true;
|
|
5658
|
+
input.disabled = true;
|
|
5659
|
+
const navigated = await navigateTo(path, true, true, true);
|
|
5660
|
+
if (!navigated) {
|
|
5661
|
+
enterAddressMode(path);
|
|
5662
|
+
const retryInput = breadcrumb.querySelector(".dp-address-input");
|
|
5663
|
+
retryInput?.classList.add("invalid");
|
|
5756
5664
|
}
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
seg.textContent = part;
|
|
5764
|
-
const targetPath = parts.slice(0, i + 1).join("/") || "/";
|
|
5765
|
-
seg.addEventListener("click", () => navigateTo(targetPath, true));
|
|
5766
|
-
// Last segment = current dir, styled differently
|
|
5767
|
-
if (i === parts.length - 1) {
|
|
5768
|
-
seg.classList.add("dp-bc-current");
|
|
5769
|
-
// Add folder icon before last segment
|
|
5770
|
-
const folderIcon = document.createElement("span");
|
|
5771
|
-
folderIcon.className = "dp-bc-folder-icon";
|
|
5772
|
-
folderIcon.innerHTML = ICON_FOLDER_SVG;
|
|
5773
|
-
breadcrumb.appendChild(sep);
|
|
5774
|
-
breadcrumb.appendChild(folderIcon);
|
|
5775
|
-
} else {
|
|
5776
|
-
breadcrumb.appendChild(sep);
|
|
5665
|
+
});
|
|
5666
|
+
input.addEventListener("keydown", (e) => {
|
|
5667
|
+
e.stopPropagation();
|
|
5668
|
+
if (e.key === "Escape") {
|
|
5669
|
+
e.preventDefault();
|
|
5670
|
+
cancel();
|
|
5777
5671
|
}
|
|
5778
|
-
breadcrumb.appendChild(seg);
|
|
5779
5672
|
});
|
|
5780
|
-
|
|
5781
|
-
|
|
5673
|
+
input.addEventListener("click", (e) => e.stopPropagation());
|
|
5674
|
+
input.addEventListener("blur", () => {
|
|
5675
|
+
if (!submitting) cancel();
|
|
5676
|
+
});
|
|
5677
|
+
|
|
5678
|
+
requestAnimationFrame(() => {
|
|
5679
|
+
input.focus();
|
|
5680
|
+
input.select();
|
|
5681
|
+
});
|
|
5782
5682
|
}
|
|
5783
5683
|
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5684
|
+
breadcrumb.addEventListener("click", (e) => {
|
|
5685
|
+
if (e.target === breadcrumb) enterAddressMode();
|
|
5686
|
+
});
|
|
5788
5687
|
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5688
|
+
function comparablePath(path) {
|
|
5689
|
+
let normalized = (path || "").trim().replaceAll("\\", "/");
|
|
5690
|
+
if (normalized === "~" || normalized.startsWith("~/")) {
|
|
5691
|
+
normalized = `${homeDir}${normalized.slice(1)}`;
|
|
5692
|
+
}
|
|
5693
|
+
return normalized.replace(/\/+$/, "") || "/";
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
// Core navigation function
|
|
5697
|
+
async function navigateTo(dirPath, absolute = true, pushHistory = true, requireExact = false) {
|
|
5698
|
+
closeBreadcrumbMenu();
|
|
5699
|
+
if (pushHistory && historyIndex >= 0
|
|
5700
|
+
&& comparablePath(currentPath) === comparablePath(dirPath)) {
|
|
5701
|
+
return true;
|
|
5796
5702
|
}
|
|
5797
5703
|
|
|
5798
|
-
|
|
5799
|
-
|
|
5704
|
+
favoriteBtn.disabled = true;
|
|
5705
|
+
listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
|
|
5706
|
+
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5800
5707
|
|
|
5801
5708
|
const useAbsolute = absolute || sessionLess || dirPath.startsWith("/");
|
|
5802
5709
|
let relPath = dirPath;
|
|
@@ -5805,16 +5712,37 @@ const Sessions = (() => {
|
|
|
5805
5712
|
}
|
|
5806
5713
|
|
|
5807
5714
|
try {
|
|
5808
|
-
const dirs = await fetchDirs(relPath, useAbsolute);
|
|
5809
|
-
|
|
5715
|
+
const { dirs, resolvedPath, exact } = await fetchDirs(relPath, useAbsolute);
|
|
5716
|
+
const exactMatch = exact === undefined
|
|
5717
|
+
? comparablePath(resolvedPath) === comparablePath(dirPath)
|
|
5718
|
+
: exact;
|
|
5719
|
+
if (requireExact && !exactMatch) {
|
|
5720
|
+
throw new Error("Directory not found");
|
|
5721
|
+
}
|
|
5722
|
+
|
|
5723
|
+
let effectivePath = useAbsolute && resolvedPath ? resolvedPath : dirPath;
|
|
5724
|
+
// After first relative load, rootDir is now known.
|
|
5810
5725
|
if (rootDir && dirPath === "") {
|
|
5811
|
-
|
|
5726
|
+
effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
|
|
5812
5727
|
? currentDir
|
|
5813
5728
|
: rootDir;
|
|
5814
|
-
selectedPath = effectivePath;
|
|
5815
|
-
refreshBreadcrumb(effectivePath);
|
|
5816
|
-
history[historyIndex] = effectivePath;
|
|
5817
5729
|
}
|
|
5730
|
+
|
|
5731
|
+
const isNewHistoryEntry = historyIndex < 0
|
|
5732
|
+
|| comparablePath(history[historyIndex]) !== comparablePath(effectivePath);
|
|
5733
|
+
if (pushHistory && isNewHistoryEntry) {
|
|
5734
|
+
history.splice(historyIndex + 1);
|
|
5735
|
+
history.push(effectivePath);
|
|
5736
|
+
historyIndex = history.length - 1;
|
|
5737
|
+
}
|
|
5738
|
+
refreshNavBtns();
|
|
5739
|
+
|
|
5740
|
+
currentPath = effectivePath;
|
|
5741
|
+
selectedPath = effectivePath;
|
|
5742
|
+
currentHasChildDirs = dirs.length > 0;
|
|
5743
|
+
currentPathLoaded = true;
|
|
5744
|
+
refreshBreadcrumb(effectivePath);
|
|
5745
|
+
refreshFavoriteButton();
|
|
5818
5746
|
listContainer.innerHTML = "";
|
|
5819
5747
|
if (dirs.length === 0) {
|
|
5820
5748
|
listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
|
|
@@ -5823,37 +5751,63 @@ const Sessions = (() => {
|
|
|
5823
5751
|
dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
|
|
5824
5752
|
listContainer.appendChild(frag);
|
|
5825
5753
|
}
|
|
5754
|
+
return true;
|
|
5826
5755
|
} catch (err) {
|
|
5827
5756
|
console.error("dir picker load failed:", err);
|
|
5757
|
+
refreshFavoriteButton();
|
|
5828
5758
|
listContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
|
|
5759
|
+
return false;
|
|
5829
5760
|
}
|
|
5830
5761
|
}
|
|
5831
5762
|
|
|
5832
5763
|
// Back / Forward
|
|
5833
|
-
backBtn.addEventListener("click", () => {
|
|
5764
|
+
backBtn.addEventListener("click", async () => {
|
|
5834
5765
|
if (historyIndex <= 0) return;
|
|
5835
|
-
historyIndex
|
|
5836
|
-
|
|
5837
|
-
|
|
5766
|
+
const targetIndex = historyIndex - 1;
|
|
5767
|
+
if (await navigateTo(history[targetIndex], true, false)) {
|
|
5768
|
+
historyIndex = targetIndex;
|
|
5769
|
+
refreshNavBtns();
|
|
5770
|
+
}
|
|
5838
5771
|
});
|
|
5839
|
-
fwdBtn.addEventListener("click", () => {
|
|
5772
|
+
fwdBtn.addEventListener("click", async () => {
|
|
5840
5773
|
if (historyIndex >= history.length - 1) return;
|
|
5841
|
-
historyIndex
|
|
5842
|
-
|
|
5843
|
-
|
|
5774
|
+
const targetIndex = historyIndex + 1;
|
|
5775
|
+
if (await navigateTo(history[targetIndex], true, false)) {
|
|
5776
|
+
historyIndex = targetIndex;
|
|
5777
|
+
refreshNavBtns();
|
|
5778
|
+
}
|
|
5779
|
+
});
|
|
5780
|
+
refreshBtn.addEventListener("click", async () => {
|
|
5781
|
+
refreshBtn.disabled = true;
|
|
5782
|
+
try {
|
|
5783
|
+
await navigateTo(currentPath || currentDir, true, false);
|
|
5784
|
+
} finally {
|
|
5785
|
+
refreshBtn.disabled = false;
|
|
5786
|
+
}
|
|
5787
|
+
});
|
|
5788
|
+
favoriteBtn.addEventListener("click", () => {
|
|
5789
|
+
const path = normalizeFavoritePath(currentPath);
|
|
5790
|
+
if (directoryFavorites.includes(path)) {
|
|
5791
|
+
directoryFavorites = directoryFavorites.filter(favorite => favorite !== path);
|
|
5792
|
+
} else {
|
|
5793
|
+
directoryFavorites.push(path);
|
|
5794
|
+
}
|
|
5795
|
+
saveDirectoryFavorites(directoryFavorites);
|
|
5796
|
+
renderSidebar();
|
|
5797
|
+
refreshFavoriteButton();
|
|
5844
5798
|
});
|
|
5845
5799
|
|
|
5846
5800
|
// Hidden files toggle
|
|
5847
5801
|
hiddenCheckbox.addEventListener("change", () => {
|
|
5848
5802
|
showHidden = hiddenCheckbox.checked;
|
|
5849
|
-
navigateTo(
|
|
5803
|
+
navigateTo(currentPath || currentDir, true, false);
|
|
5850
5804
|
});
|
|
5851
5805
|
|
|
5852
5806
|
// New folder — inserts an editable placeholder row at the top of the list
|
|
5853
5807
|
newFolderBtn.addEventListener("click", () => beginCreateFolder());
|
|
5854
5808
|
|
|
5855
5809
|
function beginCreateFolder() {
|
|
5856
|
-
const parentDir = (
|
|
5810
|
+
const parentDir = (currentPath || "").trim();
|
|
5857
5811
|
if (!parentDir.startsWith("/")) {
|
|
5858
5812
|
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
|
|
5859
5813
|
return;
|
|
@@ -5910,7 +5864,8 @@ const Sessions = (() => {
|
|
|
5910
5864
|
listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
|
|
5911
5865
|
realNode._dpRow.classList.add("selected");
|
|
5912
5866
|
selectedPath = data.path;
|
|
5913
|
-
|
|
5867
|
+
currentHasChildDirs = true;
|
|
5868
|
+
refreshBreadcrumb(currentPath);
|
|
5914
5869
|
} catch (err) {
|
|
5915
5870
|
alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
|
|
5916
5871
|
placeholder.remove();
|
|
@@ -5959,26 +5914,29 @@ const Sessions = (() => {
|
|
|
5959
5914
|
const startDir = currentDir || "/";
|
|
5960
5915
|
navigateTo(startDir, true);
|
|
5961
5916
|
} else {
|
|
5962
|
-
//
|
|
5963
|
-
//
|
|
5964
|
-
navigateTo("", false).then
|
|
5965
|
-
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
if (currentDir && currentDir !== rootDir) {
|
|
5970
|
-
navigateTo(currentDir, currentDir.startsWith("/"));
|
|
5971
|
-
}
|
|
5972
|
-
}, 100);
|
|
5973
|
-
}
|
|
5917
|
+
// Load the relative root first so rootDir is known, then navigate to a
|
|
5918
|
+
// deeper current directory without racing two initial requests.
|
|
5919
|
+
navigateTo("", false).then((loaded) => {
|
|
5920
|
+
if (loaded && currentDir && currentDir !== rootDir) {
|
|
5921
|
+
navigateTo(currentDir, currentDir.startsWith("/"));
|
|
5922
|
+
}
|
|
5923
|
+
});
|
|
5974
5924
|
}
|
|
5975
5925
|
|
|
5976
5926
|
overlay.addEventListener("keydown", (e) => {
|
|
5977
|
-
if (e.key
|
|
5927
|
+
if (e.key !== "Escape") return;
|
|
5928
|
+
if (breadcrumbMenu) {
|
|
5929
|
+
e.preventDefault();
|
|
5930
|
+
closeBreadcrumbMenu();
|
|
5931
|
+
return;
|
|
5932
|
+
}
|
|
5933
|
+
cancelButton.click();
|
|
5978
5934
|
});
|
|
5979
5935
|
overlay.addEventListener("click", (e) => {
|
|
5936
|
+
if (breadcrumbMenu && !breadcrumbMenu.contains(e.target)) closeBreadcrumbMenu();
|
|
5980
5937
|
if (e.target === overlay) cancelButton.click();
|
|
5981
5938
|
});
|
|
5939
|
+
breadcrumb.addEventListener("scroll", closeBreadcrumbMenu);
|
|
5982
5940
|
});
|
|
5983
5941
|
}
|
|
5984
5942
|
|