openclacky 1.5.2 → 1.5.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +13 -0
  3. data/CHANGELOG.md +50 -0
  4. data/Dockerfile +21 -1
  5. data/README.md +26 -7
  6. data/README_CN.md +26 -7
  7. data/README_JA.md +26 -7
  8. data/lib/clacky/agent/message_compressor.rb +4 -29
  9. data/lib/clacky/agent/message_compressor_helper.rb +13 -15
  10. data/lib/clacky/agent/session_serializer.rb +6 -0
  11. data/lib/clacky/agent.rb +13 -1
  12. data/lib/clacky/agent_config.rb +42 -11
  13. data/lib/clacky/brand_config.rb +32 -12
  14. data/lib/clacky/client.rb +7 -0
  15. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
  16. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
  17. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
  18. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
  19. data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
  20. data/lib/clacky/extension/api_extension.rb +3 -2
  21. data/lib/clacky/message_format/anthropic.rb +27 -1
  22. data/lib/clacky/message_format/open_ai.rb +92 -4
  23. data/lib/clacky/providers.rb +67 -14
  24. data/lib/clacky/server/http_server.rb +361 -36
  25. data/lib/clacky/server/project_manager.rb +150 -0
  26. data/lib/clacky/server/server_master.rb +7 -0
  27. data/lib/clacky/server/session_registry.rb +14 -1
  28. data/lib/clacky/session_manager.rb +5 -4
  29. data/lib/clacky/tools/file_reader.rb +2 -1
  30. data/lib/clacky/utils/model_pricing.rb +77 -18
  31. data/lib/clacky/utils/parser_manager.rb +115 -7
  32. data/lib/clacky/version.rb +1 -1
  33. data/lib/clacky/web/app.css +1053 -143
  34. data/lib/clacky/web/app.js +19 -5
  35. data/lib/clacky/web/components/code-editor.js +50 -12
  36. data/lib/clacky/web/components/notify.js +139 -22
  37. data/lib/clacky/web/core/aside.js +21 -0
  38. data/lib/clacky/web/features/billing/view.js +2 -1
  39. data/lib/clacky/web/features/brand/store.js +3 -1
  40. data/lib/clacky/web/features/brand/view.js +29 -5
  41. data/lib/clacky/web/features/extensions/store.js +15 -6
  42. data/lib/clacky/web/features/extensions/view.js +8 -4
  43. data/lib/clacky/web/features/new-session/view.js +2 -2
  44. data/lib/clacky/web/features/trash/store.js +23 -9
  45. data/lib/clacky/web/features/trash/view.js +51 -0
  46. data/lib/clacky/web/features/workspace/store.js +9 -0
  47. data/lib/clacky/web/features/workspace/view.js +8 -1
  48. data/lib/clacky/web/i18n.js +99 -4
  49. data/lib/clacky/web/index.html +70 -17
  50. data/lib/clacky/web/projects.js +1143 -0
  51. data/lib/clacky/web/sessions.js +318 -537
  52. data/lib/clacky/web/settings.js +146 -7
  53. data/lib/clacky/web/ws-dispatcher.js +59 -1
  54. data/scripts/build/src/install_system_deps.sh.cc +11 -3
  55. data/scripts/install_system_deps.sh +11 -3
  56. metadata +5 -2
  57. data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
@@ -59,6 +59,10 @@ const Sessions = (() => {
59
59
  // Flushed in _fetchHistory after the fragment is appended to the DOM.
60
60
  let _pendingStdoutLines = null; // string[] | null
61
61
 
62
+ // ── Chat section collapse ──────────────────────────────────────────────────
63
+ const _CHAT_COLLAPSE_KEY = "clacky_chat_section_collapsed";
64
+ let _chatCollapsed = localStorage.getItem(_CHAT_COLLAPSE_KEY) === "1";
65
+
62
66
  // ── Markdown renderer ──────────────────────────────────────────────────
63
67
  //
64
68
  // Renders assistant message text as Markdown HTML using the marked library.
@@ -433,6 +437,25 @@ const Sessions = (() => {
433
437
  }
434
438
  }
435
439
 
440
+ function _initSectionCollapse() {
441
+ const chatHeader = document.getElementById("chat-section-header");
442
+ const sessionList = document.getElementById("session-list");
443
+ if (!chatHeader || !sessionList) return;
444
+
445
+ if (_chatCollapsed) {
446
+ sessionList.classList.add("chat-section-collapsed");
447
+ chatHeader.classList.add("chat-header-collapsed");
448
+ }
449
+
450
+ chatHeader.addEventListener("click", e => {
451
+ if (e.target.closest(".sidebar-divider-actions")) return;
452
+ _chatCollapsed = !_chatCollapsed;
453
+ localStorage.setItem(_CHAT_COLLAPSE_KEY, _chatCollapsed ? "1" : "0");
454
+ sessionList.classList.toggle("chat-section-collapsed", _chatCollapsed);
455
+ chatHeader.classList.toggle("chat-header-collapsed", _chatCollapsed);
456
+ });
457
+ }
458
+
436
459
  function _initEmptyHint() {
437
460
  const messages = RenderTarget.outer();
438
461
  if (!messages) return;
@@ -551,7 +574,7 @@ const Sessions = (() => {
551
574
  const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB — hard reject before compression
552
575
  const MAX_IMAGE_BYTES_SEND = 512 * 1024; // 512 KB — target after compression
553
576
  const MAX_IMAGE_LONG_EDGE = 1920; // px — scale down if larger
554
- const MAX_FILE_BYTES = 32 * 1024 * 1024; // 32 MB
577
+ const MAX_FILE_BYTES = 300 * 1024 * 1024; // 300 MB
555
578
  const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
556
579
 
557
580
  function _isAcceptedImage(file) {
@@ -662,7 +685,7 @@ const Sessions = (() => {
662
685
 
663
686
  function _addGenericFile(file) {
664
687
  if (file.size > MAX_FILE_BYTES) {
665
- alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "32 MB" }));
688
+ alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "300 MB" }));
666
689
  return;
667
690
  }
668
691
  // Upload file to server via HTTP — only the path is returned, no base64 in memory
@@ -2314,6 +2337,15 @@ const Sessions = (() => {
2314
2337
  find: id => _sessions.find(s => s.id === id)
2315
2338
  || _extraSessions.find(s => s.id === id),
2316
2339
 
2340
+ /** Return the next available "Session N" name based on loaded sessions. */
2341
+ nextDefaultName() {
2342
+ const maxN = _sessions.reduce((max, s) => {
2343
+ const m = s.name && s.name.match(/^Session (\d+)$/);
2344
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
2345
+ }, 0);
2346
+ return "Session " + (maxN + 1);
2347
+ },
2348
+
2317
2349
  // Async variant of `find`: when not found in memory, falls back to
2318
2350
  // GET /api/sessions/:id which returns the on-disk session merged with
2319
2351
  // any live in-memory state (see SessionRegistry#snapshot in
@@ -2355,6 +2387,9 @@ const Sessions = (() => {
2355
2387
  init() {
2356
2388
  _initNewMessageBanner();
2357
2389
  _initEmptyHint();
2390
+
2391
+ _initSectionCollapse();
2392
+
2358
2393
  _initNewSessionControls();
2359
2394
  _initComposer();
2360
2395
  _initSearch();
@@ -2438,11 +2473,7 @@ const Sessions = (() => {
2438
2473
  // Returns the created session.
2439
2474
  async startWith(command, { name, source = "setup", display = null } = {}) {
2440
2475
  if (!name) {
2441
- const maxN = _sessions.reduce((max, s) => {
2442
- const m = s.name && s.name.match(/^Session (\d+)$/);
2443
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
2444
- }, 0);
2445
- name = "Session " + (maxN + 1);
2476
+ name = Sessions.nextDefaultName();
2446
2477
  }
2447
2478
 
2448
2479
  const res = await fetch("/api/sessions", {
@@ -2494,6 +2525,28 @@ const Sessions = (() => {
2494
2525
  _drafts.delete(id);
2495
2526
  },
2496
2527
 
2528
+ /** Clear project_id from all sessions that belong to a deleted project. */
2529
+ clearProjectId(projectId) {
2530
+ _sessions.forEach(s => {
2531
+ if (s.project_id === projectId) s.project_id = null;
2532
+ });
2533
+ },
2534
+
2535
+ /** Remove all sessions belonging to a deleted project from local memory. */
2536
+ removeByProjectId(projectId) {
2537
+ for (let i = _sessions.length - 1; i >= 0; i--) {
2538
+ if (_sessions[i].project_id === projectId) _sessions.splice(i, 1);
2539
+ }
2540
+ },
2541
+
2542
+ /**
2543
+ * Public wrapper so Projects.renderSection() can reuse the same item
2544
+ * renderer without duplicating session-item DOM/event logic.
2545
+ */
2546
+ renderSessionItem(container, s) {
2547
+ _renderSessionItem(container, s);
2548
+ },
2549
+
2497
2550
  /** Load the next page of older sessions (unified time cursor). */
2498
2551
  async loadMore() {
2499
2552
  if (_loadingMore || !_hasMore) return;
@@ -2517,7 +2570,7 @@ const Sessions = (() => {
2517
2570
  return (!min || t < min) ? t : min;
2518
2571
  }, null);
2519
2572
 
2520
- const params = new URLSearchParams({ limit: "20", exclude_type: "cron" });
2573
+ const params = new URLSearchParams({ limit: "15", exclude_type: "cron" });
2521
2574
  if (oldest) params.set("before", oldest);
2522
2575
  if (_filter.q) params.set("q", _filter.q);
2523
2576
  if (_filter.date) params.set("date", _filter.date);
@@ -2560,7 +2613,7 @@ const Sessions = (() => {
2560
2613
  Sessions.renderList();
2561
2614
 
2562
2615
  try {
2563
- const params = new URLSearchParams({ limit: "20", type: "cron" });
2616
+ const params = new URLSearchParams({ limit: "15", type: "cron" });
2564
2617
  if (_cronBefore) params.set("before", _cronBefore);
2565
2618
 
2566
2619
  const res = await fetch(`/api/sessions?${params}`);
@@ -2798,6 +2851,7 @@ const Sessions = (() => {
2798
2851
  Sessions.remove(id);
2799
2852
  if (id === Sessions.activeId) Router.navigate("welcome");
2800
2853
  Sessions.renderList();
2854
+ Projects.renderSection();
2801
2855
  } else {
2802
2856
  const data = await res.json().catch(() => ({}));
2803
2857
  console.error("Failed to delete session:", data.error || res.status);
@@ -2806,6 +2860,7 @@ const Sessions = (() => {
2806
2860
  Sessions.remove(id);
2807
2861
  if (id === Sessions.activeId) Router.navigate("welcome");
2808
2862
  Sessions.renderList();
2863
+ Projects.renderSection();
2809
2864
  }
2810
2865
  }
2811
2866
  // Server also broadcasts session_deleted WS event; Sessions.remove() is idempotent
@@ -2906,7 +2961,10 @@ const Sessions = (() => {
2906
2961
 
2907
2962
  // ── Sidebar list always shows the full session set, sorted ───────
2908
2963
  // Search/filter no longer touches this list — it lives in the overlay.
2909
- const visible = [..._sessions].sort(byPinnedAndTime);
2964
+ // Sessions assigned to a project are shown in the project section below.
2965
+ const visible = [..._sessions]
2966
+ .filter(s => !s.project_id)
2967
+ .sort(byPinnedAndTime);
2910
2968
 
2911
2969
  // ── Split cron vs non-cron for folding ───────────────────────────
2912
2970
  const isCronView = _cronView;
@@ -2954,8 +3012,6 @@ const Sessions = (() => {
2954
3012
  // Normal list view, no cron sessions
2955
3013
  visible.forEach(s => _renderSessionItem(list, s));
2956
3014
  }
2957
-
2958
- // Empty state fallback
2959
3015
  if (list.children.length === 0) {
2960
3016
  list.innerHTML = `<div class="session-empty">${I18n.t("sessions.empty")}</div>`;
2961
3017
  }
@@ -3009,6 +3065,7 @@ const Sessions = (() => {
3009
3065
  if (res.ok) {
3010
3066
  Sessions.patch(sessionId, { name: newName });
3011
3067
  Sessions.renderList();
3068
+ Projects.renderSection();
3012
3069
  } else {
3013
3070
  console.error("Rename failed:", await res.text());
3014
3071
  }
@@ -3076,7 +3133,13 @@ const Sessions = (() => {
3076
3133
  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>`;
3077
3134
  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>`;
3078
3135
 
3136
+ // Pin option is only shown for sessions that are NOT in a project
3079
3137
  const pinLabel = session.pinned ? I18n.t("sessions.actions.unpin") : I18n.t("sessions.actions.pin");
3138
+ const pinItemHtml = `
3139
+ <div class="session-actions-menu-item" data-action="pin">
3140
+ <span class="session-actions-menu-icon">${iconPin}</span>
3141
+ <span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
3142
+ </div>`;
3080
3143
 
3081
3144
  const menu = document.createElement("div");
3082
3145
  menu.className = "session-actions-menu";
@@ -3085,10 +3148,7 @@ const Sessions = (() => {
3085
3148
  <span class="session-actions-menu-icon">${iconFork}</span>
3086
3149
  <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.fork"))}</span>
3087
3150
  </div>
3088
- <div class="session-actions-menu-item" data-action="pin">
3089
- <span class="session-actions-menu-icon">${iconPin}</span>
3090
- <span class="session-actions-menu-label">${escapeHtml(pinLabel)}</span>
3091
- </div>
3151
+ ${pinItemHtml}
3092
3152
  <div class="session-actions-menu-item" data-action="rename">
3093
3153
  <span class="session-actions-menu-icon">${iconRename}</span>
3094
3154
  <span class="session-actions-menu-label">${escapeHtml(I18n.t("sessions.actions.rename"))}</span>
@@ -3167,6 +3227,7 @@ const Sessions = (() => {
3167
3227
  // Update local state
3168
3228
  session.pinned = newPinnedState;
3169
3229
  Sessions.renderList();
3230
+ Projects.renderSection();
3170
3231
  } else {
3171
3232
  console.error("Toggle pin failed:", await res.text());
3172
3233
  }
@@ -3190,6 +3251,7 @@ const Sessions = (() => {
3190
3251
  if (res.ok) {
3191
3252
  Sessions.remove(sessionId);
3192
3253
  Sessions.renderList();
3254
+ Projects.renderSection();
3193
3255
  // If deleted session was active, switch to welcome
3194
3256
  if (sessionId === _activeId) {
3195
3257
  Router.navigate("welcome");
@@ -4704,18 +4766,13 @@ const Sessions = (() => {
4704
4766
  }); return dirs;
4705
4767
  }
4706
4768
 
4707
- // Build a tree node for a directory entry
4708
- function buildDirNode(entry, depth) {
4769
+ // Build a flat list row for a directory entry (Finder-style)
4770
+ function buildDirNode(entry, _depth) {
4709
4771
  const node = document.createElement("div");
4710
4772
  node.className = "dp-node";
4711
- node.dataset.depth = depth; // Store depth for child expansion
4712
4773
 
4713
4774
  const row = document.createElement("div");
4714
4775
  row.className = "dp-row";
4715
- row.style.paddingLeft = `${depth * 16 + 8}px`;
4716
- const caret = document.createElement("span");
4717
- caret.className = "dp-caret";
4718
- caret.innerHTML = ICON_CARET_SVG;
4719
4776
 
4720
4777
  const icon = document.createElement("span");
4721
4778
  icon.className = "dp-icon";
@@ -4725,192 +4782,266 @@ const Sessions = (() => {
4725
4782
  name.className = "dp-name";
4726
4783
  name.textContent = entry.name;
4727
4784
 
4728
- row.appendChild(caret);
4729
4785
  row.appendChild(icon);
4730
4786
  row.appendChild(name);
4731
4787
  node.appendChild(row);
4732
4788
 
4733
- const children = document.createElement("div");
4734
- children.className = "dp-children";
4735
- children.style.display = "none";
4736
- node.appendChild(children);
4737
-
4738
- // Single-click: select directory (show path) + expand/collapse
4789
+ // Single-click: select this directory
4739
4790
  let clickTimer = null;
4740
4791
  row.addEventListener("click", (e) => {
4741
4792
  e.stopPropagation();
4742
4793
  if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
4743
4794
  clickTimer = setTimeout(() => {
4744
4795
  clickTimer = null;
4745
- // Select this directory
4746
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4796
+ listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4747
4797
  row.classList.add("selected");
4748
4798
  selectedPath = entry.absPath;
4749
- pathInput.value = entry.absPath;
4750
- // Also expand/collapse
4751
- toggleExpand(entry, caret, children);
4752
- }, 250);
4799
+ refreshBreadcrumb(entry.absPath);
4800
+ }, 200);
4753
4801
  });
4754
4802
 
4755
4803
  // Double-click: enter directory (navigate into it)
4756
4804
  row.addEventListener("dblclick", (e) => {
4757
4805
  e.stopPropagation();
4758
4806
  if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
4759
- // Enter this directory - reload tree with this as root
4760
- loadTreeForPath(entry.absPath, entry.absolute);
4807
+ navigateTo(entry.absPath, true);
4761
4808
  });
4762
4809
 
4763
- // Make the row addressable for inline-edit invoked elsewhere
4764
- // (e.g. from the "+ New folder" button placing its placeholder).
4765
4810
  node._dpEntry = entry;
4766
4811
  node._dpName = name;
4767
4812
  node._dpRow = row;
4768
4813
  return node;
4769
4814
  }
4770
4815
 
4771
- async function toggleExpand(entry, caret, children) {
4772
- const isOpen = caret.classList.contains("open");
4773
- if (isOpen) {
4774
- caret.classList.remove("open");
4775
- children.style.display = "none";
4776
- return;
4816
+ // Create modal overlay
4817
+ const overlay = document.createElement("div");
4818
+ overlay.className = "modal-overlay";
4819
+
4820
+ // Create modal content — Finder-style layout
4821
+ const modal = document.createElement("div");
4822
+ modal.className = "modal-content dp-modal";
4823
+
4824
+ // ── Toolbar ──────────────────────────────────────────────────────
4825
+ const toolbar = document.createElement("div");
4826
+ toolbar.className = "dp-toolbar";
4827
+
4828
+ // Back / Forward buttons
4829
+ const backBtn = document.createElement("button");
4830
+ backBtn.className = "dp-nav-btn";
4831
+ backBtn.title = t("sib.dir.back", "后退");
4832
+ backBtn.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.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`;
4833
+ backBtn.disabled = true;
4834
+
4835
+ const fwdBtn = document.createElement("button");
4836
+ fwdBtn.className = "dp-nav-btn";
4837
+ fwdBtn.title = t("sib.dir.forward", "前进");
4838
+ fwdBtn.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.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
4839
+ fwdBtn.disabled = true;
4840
+
4841
+ const navBtns = document.createElement("div");
4842
+ navBtns.className = "dp-nav-btns";
4843
+ navBtns.appendChild(backBtn);
4844
+ navBtns.appendChild(fwdBtn);
4845
+
4846
+ // Breadcrumb
4847
+ const breadcrumb = document.createElement("div");
4848
+ breadcrumb.className = "dp-breadcrumb";
4849
+
4850
+ toolbar.appendChild(navBtns);
4851
+ toolbar.appendChild(breadcrumb);
4852
+ modal.appendChild(toolbar);
4853
+
4854
+ // ── Column header ─────────────────────────────────────────────────
4855
+ const colHeader = document.createElement("div");
4856
+ colHeader.className = "dp-col-header";
4857
+ const colName = document.createElement("span");
4858
+ colName.className = "dp-col-name";
4859
+ colName.textContent = t("sib.dir.colName", "名称");
4860
+ // Hidden files toggle (right side of header)
4861
+ const hiddenToggle = document.createElement("label");
4862
+ hiddenToggle.className = "dp-hidden-toggle";
4863
+ const hiddenCheckbox = document.createElement("input");
4864
+ hiddenCheckbox.type = "checkbox";
4865
+ hiddenCheckbox.checked = false;
4866
+ const hiddenLabelText = document.createElement("span");
4867
+ hiddenLabelText.textContent = t("sib.dir.showHidden", "显示隐藏文件");
4868
+ hiddenToggle.appendChild(hiddenCheckbox);
4869
+ hiddenToggle.appendChild(hiddenLabelText);
4870
+ colHeader.appendChild(colName);
4871
+ colHeader.appendChild(hiddenToggle);
4872
+ modal.appendChild(colHeader);
4873
+
4874
+ // ── List container ────────────────────────────────────────────────
4875
+ const listContainer = document.createElement("div");
4876
+ listContainer.className = "dp-list";
4877
+ listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4878
+ modal.appendChild(listContainer);
4879
+
4880
+ // ── Footer ────────────────────────────────────────────────────────
4881
+ const footer = document.createElement("div");
4882
+ footer.className = "dp-footer";
4883
+
4884
+ const newFolderBtn = document.createElement("button");
4885
+ newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
4886
+ 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>`;
4887
+
4888
+ const footerActions = document.createElement("div");
4889
+ footerActions.className = "dp-footer-actions";
4890
+
4891
+ const cancelButton = document.createElement("button");
4892
+ cancelButton.className = "btn btn-secondary";
4893
+ cancelButton.textContent = t("sib.dir.cancel", "取消");
4894
+ cancelButton.onclick = () => { overlay.remove(); resolve(null); };
4895
+
4896
+ const confirmButton = document.createElement("button");
4897
+ confirmButton.className = "btn btn-primary";
4898
+ confirmButton.textContent = t("sib.dir.confirm", "确认");
4899
+ confirmButton.onclick = () => { overlay.remove(); resolve(selectedPath || null); };
4900
+
4901
+ footerActions.appendChild(cancelButton);
4902
+ footerActions.appendChild(confirmButton);
4903
+ footer.appendChild(newFolderBtn);
4904
+ footer.appendChild(footerActions);
4905
+ modal.appendChild(footer);
4906
+
4907
+ overlay.appendChild(modal);
4908
+ document.body.appendChild(overlay);
4909
+
4910
+ // ── History stack for back/fwd ────────────────────────────────────
4911
+ const history = [];
4912
+ let historyIndex = -1;
4913
+
4914
+ function refreshNavBtns() {
4915
+ backBtn.disabled = historyIndex <= 0;
4916
+ fwdBtn.disabled = historyIndex >= history.length - 1;
4917
+ }
4918
+
4919
+ // Build breadcrumb from an absolute path
4920
+ function refreshBreadcrumb(absPath) {
4921
+ breadcrumb.innerHTML = "";
4922
+ if (!absPath) return;
4923
+ const parts = absPath.replace(/\/+$/, "").split("/");
4924
+ // parts[0] is "" for absolute paths starting with /
4925
+ parts.forEach((part, i) => {
4926
+ if (i === 0 && part === "") {
4927
+ // Root "/"
4928
+ const seg = document.createElement("span");
4929
+ seg.className = "dp-bc-seg";
4930
+ seg.textContent = "/";
4931
+ seg.addEventListener("click", () => navigateTo("/", true));
4932
+ breadcrumb.appendChild(seg);
4933
+ return;
4934
+ }
4935
+ if (!part) return;
4936
+ const sep = document.createElement("span");
4937
+ sep.className = "dp-bc-sep";
4938
+ sep.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`;
4939
+ const seg = document.createElement("span");
4940
+ seg.className = "dp-bc-seg";
4941
+ seg.textContent = part;
4942
+ const targetPath = parts.slice(0, i + 1).join("/") || "/";
4943
+ seg.addEventListener("click", () => navigateTo(targetPath, true));
4944
+ // Last segment = current dir, styled differently
4945
+ if (i === parts.length - 1) {
4946
+ seg.classList.add("dp-bc-current");
4947
+ // Add folder icon before last segment
4948
+ const folderIcon = document.createElement("span");
4949
+ folderIcon.className = "dp-bc-folder-icon";
4950
+ folderIcon.innerHTML = ICON_FOLDER_SVG;
4951
+ breadcrumb.appendChild(sep);
4952
+ breadcrumb.appendChild(folderIcon);
4953
+ } else {
4954
+ breadcrumb.appendChild(sep);
4955
+ }
4956
+ breadcrumb.appendChild(seg);
4957
+ });
4958
+ // Always scroll to show the rightmost (current) segment
4959
+ requestAnimationFrame(() => { breadcrumb.scrollLeft = breadcrumb.scrollWidth; });
4960
+ }
4961
+
4962
+ // Core navigation function
4963
+ async function navigateTo(dirPath, absolute = true, pushHistory = true) {
4964
+ listContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4965
+ listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4966
+
4967
+ // Push to history
4968
+ if (pushHistory) {
4969
+ // Trim forward history
4970
+ history.splice(historyIndex + 1);
4971
+ history.push(dirPath);
4972
+ historyIndex = history.length - 1;
4973
+ refreshNavBtns();
4974
+ }
4975
+
4976
+ selectedPath = dirPath;
4977
+ refreshBreadcrumb(dirPath);
4978
+
4979
+ const useAbsolute = absolute || sessionLess || dirPath.startsWith("/");
4980
+ let relPath = dirPath;
4981
+ if (!sessionLess && !useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
4982
+ relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
4777
4983
  }
4778
- caret.classList.add("open");
4779
- children.style.display = "flex";
4780
- if (children.dataset.loaded === "1") return;
4781
- children.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4984
+
4782
4985
  try {
4783
- const dirs = await fetchDirs(entry.path, entry.absolute);
4784
- children.innerHTML = "";
4986
+ const dirs = await fetchDirs(relPath, useAbsolute);
4987
+ // After first relative load, rootDir is now known — refresh breadcrumb + selectedPath
4988
+ if (rootDir && dirPath === "") {
4989
+ const effectivePath = currentDir && (currentDir === rootDir || currentDir.startsWith(rootDir + "/"))
4990
+ ? currentDir
4991
+ : rootDir;
4992
+ selectedPath = effectivePath;
4993
+ refreshBreadcrumb(effectivePath);
4994
+ history[historyIndex] = effectivePath;
4995
+ }
4996
+ listContainer.innerHTML = "";
4785
4997
  if (dirs.length === 0) {
4786
- children.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
4998
+ listContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
4787
4999
  } else {
4788
- const parentNode = children.parentElement;
4789
- const parentDepth = parseInt(parentNode?.dataset?.depth) || 0;
4790
- const childDepth = parentDepth + 1;
4791
5000
  const frag = document.createDocumentFragment();
4792
- dirs.forEach(d => frag.appendChild(buildDirNode(d, childDepth)));
4793
- children.appendChild(frag);
4794
- } children.dataset.loaded = "1";
5001
+ dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
5002
+ listContainer.appendChild(frag);
5003
+ }
4795
5004
  } catch (err) {
4796
5005
  console.error("dir picker load failed:", err);
4797
- children.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
5006
+ listContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
4798
5007
  }
4799
5008
  }
4800
5009
 
4801
- // Create modal overlay
4802
- const overlay = document.createElement("div");
4803
- overlay.className = "modal-overlay";
5010
+ // Back / Forward
5011
+ backBtn.addEventListener("click", () => {
5012
+ if (historyIndex <= 0) return;
5013
+ historyIndex--;
5014
+ refreshNavBtns();
5015
+ navigateTo(history[historyIndex], true, false);
5016
+ });
5017
+ fwdBtn.addEventListener("click", () => {
5018
+ if (historyIndex >= history.length - 1) return;
5019
+ historyIndex++;
5020
+ refreshNavBtns();
5021
+ navigateTo(history[historyIndex], true, false);
5022
+ });
4804
5023
 
4805
- // Create modal content
4806
- const modal = document.createElement("div");
4807
- modal.className = "modal-content";
4808
- modal.style.maxWidth = "520px";
4809
- modal.style.maxHeight = "80vh";
4810
- modal.style.display = "flex";
4811
- modal.style.flexDirection = "column";
4812
-
4813
- // Title
4814
- const title = document.createElement("div");
4815
- title.className = "modal-title";
4816
- title.textContent = sessionLess
4817
- ? t("sessions.modal.dirpicker.title", "选择工作目录")
4818
- : t("sib.dir.changePrompt", "切换工作目录");
4819
- modal.appendChild(title);
4820
-
4821
- // Modal body
4822
- const body = document.createElement("div");
4823
- body.className = "modal-body";
4824
- body.style.flex = "1";
4825
- body.style.overflow = "hidden";
4826
- body.style.display = "flex";
4827
- body.style.flexDirection = "column";
4828
- body.style.gap = "8px";
4829
-
4830
- // Quick presets (will be populated with absolute paths after first API call)
4831
- const presets = document.createElement("div");
4832
- presets.className = "dp-presets";
4833
- body.appendChild(presets);
4834
-
4835
- // "Up one level" button — navigates to the parent of the current path.
4836
- const upBtn = document.createElement("button");
4837
- upBtn.className = "btn btn-secondary btn-sm dp-up-btn";
4838
- upBtn.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"><polyline points="18 15 12 9 6 15"/></svg><span>${t("sib.dir.up", "上一级")}</span>`;
4839
- function parentOf(p) {
4840
- const trimmed = (p || "").replace(/\/+$/, "");
4841
- if (!trimmed || trimmed === "/" ) return null;
4842
- const idx = trimmed.lastIndexOf("/");
4843
- if (idx < 0) return null;
4844
- return idx === 0 ? "/" : trimmed.substring(0, idx);
4845
- }
4846
- function refreshUpBtn() {
4847
- const parent = parentOf(pathInput.value);
4848
- upBtn.disabled = !parent;
4849
- }
4850
- upBtn.addEventListener("click", () => {
4851
- const parent = parentOf(pathInput.value);
4852
- if (!parent) return;
4853
- selectedPath = parent;
4854
- pathInput.value = parent;
4855
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4856
- loadTreeForPath(parent, true);
5024
+ // Hidden files toggle
5025
+ hiddenCheckbox.addEventListener("change", () => {
5026
+ showHidden = hiddenCheckbox.checked;
5027
+ navigateTo(selectedPath || currentDir, true);
4857
5028
  });
4858
5029
 
4859
- // ── "+ New folder" ────────────────────────────────────────────────
4860
- // Drops an editable placeholder row at the top of the current view
4861
- // and POSTs to the backend on commit. Confirm = Enter / blur, cancel
4862
- // = Escape. The placeholder is purely visual; nothing is created on
4863
- // disk until the user commits a non-empty name.
4864
- const newFolderBtn = document.createElement("button");
4865
- newFolderBtn.className = "btn btn-secondary btn-sm dp-newfolder-btn";
4866
- 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>`;
5030
+ // New folder — inserts an editable placeholder row at the top of the list
4867
5031
  newFolderBtn.addEventListener("click", () => beginCreateFolder());
4868
5032
 
4869
5033
  function beginCreateFolder() {
4870
- const parentDir = (pathInput.value || "").trim();
5034
+ const parentDir = (selectedPath || "").trim();
4871
5035
  if (!parentDir.startsWith("/")) {
4872
5036
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", "invalid parent path"));
4873
5037
  return;
4874
5038
  }
4875
5039
 
4876
- // Locate the parent node in the current tree, if it's rendered.
4877
- // The path input may point at any expanded descendant of the
4878
- // tree root, so we can't assume parentDir == tree root.
4879
- // Walk every rendered .dp-node and match by entry.absPath.
4880
- let parentNode = null;
4881
- treeContainer.querySelectorAll(".dp-node").forEach(n => {
4882
- if (parentNode) return;
4883
- if (n._dpEntry && n._dpEntry.absPath === parentDir) parentNode = n;
4884
- });
4885
-
4886
- // Pick where to insert + what depth the placeholder lives at.
4887
- // - Tree root (parentDir is the root) → depth 0, host = treeContainer
4888
- // - Rendered subtree → depth = parent.depth + 1,
4889
- // host = parent's .dp-children
4890
- let host, depth, parentRow, parentCaret, parentChildren;
4891
- if (parentNode) {
4892
- parentRow = parentNode._dpRow;
4893
- parentCaret = parentRow.querySelector(".dp-caret");
4894
- parentChildren = parentNode.querySelector(":scope > .dp-children");
4895
- depth = (parseInt(parentNode.dataset.depth, 10) || 0) + 1;
4896
- host = parentChildren;
4897
- } else {
4898
- depth = 0;
4899
- host = treeContainer;
4900
- }
4901
-
4902
- // Build a placeholder node and seed it with an inline editor.
4903
5040
  const placeholder = document.createElement("div");
4904
5041
  placeholder.className = "dp-node dp-node-pending";
4905
- placeholder.dataset.depth = depth;
4906
5042
 
4907
5043
  const row = document.createElement("div");
4908
5044
  row.className = "dp-row dp-row-editing";
4909
- row.style.paddingLeft = `${depth * 16 + 8}px`;
4910
-
4911
- const caret = document.createElement("span");
4912
- caret.className = "dp-caret";
4913
- caret.innerHTML = ICON_CARET_SVG;
4914
5045
 
4915
5046
  const icon = document.createElement("span");
4916
5047
  icon.className = "dp-icon";
@@ -4919,29 +5050,14 @@ const Sessions = (() => {
4919
5050
  const nameSpan = document.createElement("span");
4920
5051
  nameSpan.className = "dp-name";
4921
5052
 
4922
- row.appendChild(caret);
4923
5053
  row.appendChild(icon);
4924
5054
  row.appendChild(nameSpan);
4925
5055
  placeholder.appendChild(row);
4926
5056
 
4927
- // If we're inserting under a parent node, make sure the parent is
4928
- // visually expanded so the user sees the placeholder. Mark the
4929
- // children container as loaded so we don't trigger a refetch on
4930
- // first toggleExpand (we'll keep our placeholder + inserted node).
4931
- if (parentNode) {
4932
- parentCaret.classList.add("open");
4933
- parentChildren.style.display = "flex";
4934
- // Drop "loading" / "empty" / "error" stub if present.
4935
- const stub = parentChildren.querySelector(".dp-empty, .dp-loading, .dp-error");
4936
- if (stub) stub.remove();
4937
- parentChildren.dataset.loaded = "1";
4938
- parentChildren.insertBefore(placeholder, parentChildren.firstChild);
4939
- } else {
4940
- // Root level: drop the empty/loading/error stub, then prepend.
4941
- const emptyEl = treeContainer.querySelector(":scope > .dp-empty, :scope > .dp-loading, :scope > .dp-error");
4942
- if (emptyEl) emptyEl.remove();
4943
- treeContainer.insertBefore(placeholder, treeContainer.firstChild);
4944
- }
5057
+ // Drop stubs and prepend
5058
+ const emptyEl = listContainer.querySelector(".dp-empty, .dp-loading, .dp-error");
5059
+ if (emptyEl) emptyEl.remove();
5060
+ listContainer.insertBefore(placeholder, listContainer.firstChild);
4945
5061
 
4946
5062
  startInlineEdit(nameSpan, t("sib.dir.newFolderDefault", "New Folder"), {
4947
5063
  onCommit: async (newName) => {
@@ -4960,8 +5076,6 @@ const Sessions = (() => {
4960
5076
  placeholder.remove();
4961
5077
  return;
4962
5078
  }
4963
- // Replace placeholder with a real, fully-wired node so it can
4964
- // be expanded / right-clicked exactly like any sibling.
4965
5079
  const realEntry = {
4966
5080
  name: data.name,
4967
5081
  path: data.path,
@@ -4969,14 +5083,12 @@ const Sessions = (() => {
4969
5083
  absolute: true,
4970
5084
  type: "dir"
4971
5085
  };
4972
- const realNode = buildDirNode(realEntry, depth);
5086
+ const realNode = buildDirNode(realEntry, 0);
4973
5087
  placeholder.replaceWith(realNode);
4974
- // Auto-select the new folder so the path input updates.
4975
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5088
+ listContainer.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4976
5089
  realNode._dpRow.classList.add("selected");
4977
5090
  selectedPath = data.path;
4978
- pathInput.value = data.path;
4979
- refreshUpBtn();
5091
+ refreshBreadcrumb(data.path);
4980
5092
  } catch (err) {
4981
5093
  alert(t("sib.dir.mkdirError", "Failed to create folder: {{msg}}").replace("{{msg}}", err.message || String(err)));
4982
5094
  placeholder.remove();
@@ -4986,10 +5098,7 @@ const Sessions = (() => {
4986
5098
  });
4987
5099
  }
4988
5100
 
4989
- // ── Inline rename editor (shared by mkdir + rename) ───────────────
4990
- // Replaces a name span with an <input> seeded by `initialValue`,
4991
- // commits on Enter/blur, cancels on Escape. The caller wires the
4992
- // backend round-trip via onCommit (it receives the new name string).
5101
+ // ── Inline rename editor (shared by mkdir) ────────────────────────
4993
5102
  function startInlineEdit(nameSpan, initialValue, { onCommit, onCancel }) {
4994
5103
  const input = document.createElement("input");
4995
5104
  input.type = "text";
@@ -4998,39 +5107,21 @@ const Sessions = (() => {
4998
5107
  input.spellcheck = false;
4999
5108
  input.autocomplete = "off";
5000
5109
  input.setAttribute("autocapitalize", "off");
5001
-
5002
- // Stop click-through so editing doesn't trigger row selection /
5003
- // expand / collapse.
5004
5110
  ["click", "dblclick", "mousedown"].forEach(ev =>
5005
5111
  input.addEventListener(ev, (e) => e.stopPropagation())
5006
5112
  );
5007
-
5008
- // Replace span with input. Track via parent so we can swap back.
5009
5113
  const parent = nameSpan.parentNode;
5010
5114
  parent.replaceChild(input, nameSpan);
5011
- // Defer focus until the next tick so the caller's surrounding DOM
5012
- // mutations (e.g. inserting a placeholder row) finish first.
5013
- setTimeout(() => {
5014
- input.focus();
5015
- input.select();
5016
- }, 0);
5017
-
5115
+ setTimeout(() => { input.focus(); input.select(); }, 0);
5018
5116
  let finished = false;
5019
5117
  const finish = (commit) => {
5020
5118
  if (finished) return;
5021
5119
  finished = true;
5022
5120
  const value = input.value;
5023
5121
  if (input.parentNode === parent) parent.replaceChild(nameSpan, input);
5024
- if (commit) {
5025
- try { onCommit && onCommit(value); } catch (e) { console.error(e); }
5026
- } else {
5027
- try { onCancel && onCancel(); } catch (e) { console.error(e); }
5028
- }
5122
+ if (commit) { try { onCommit && onCommit(value); } catch (e) { console.error(e); } }
5123
+ else { try { onCancel && onCancel(); } catch (e) { console.error(e); } }
5029
5124
  };
5030
-
5031
- // Enter commits; Escape cancels. Use IME.bindEnter so that the
5032
- // Enter that confirms a Chinese / Japanese / Korean IME candidate
5033
- // does NOT commit the rename. Mirrors Sessions._startRename.
5034
5125
  IME.bindEnter(input, () => finish(true));
5035
5126
  input.addEventListener("keydown", (e) => {
5036
5127
  if (e.key === "Escape") { e.preventDefault(); finish(false); }
@@ -5038,341 +5129,31 @@ const Sessions = (() => {
5038
5129
  input.addEventListener("blur", () => finish(true));
5039
5130
  }
5040
5131
 
5041
- function setupPresets() {
5042
- presets.innerHTML = "";
5043
- presets.appendChild(upBtn);
5044
- const presetDirs = sessionLess
5045
- ? [
5046
- { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5047
- { value: homeDir, text: t("sib.dir.home", "主目录"), absolute: true },
5048
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5049
- ]
5050
- : [
5051
- { value: defaultDir, text: t("sib.dir.default", "默认工作目录"), absolute: true },
5052
- { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
5053
- ];
5054
- presetDirs.forEach(p => {
5055
- // Skip preset whose absolute path isn't known yet (defensive: the
5056
- // backend always returns it, but a malformed deploy shouldn't render
5057
- // a button that navigates to "").
5058
- if (!p.value) return;
5059
- const btn = document.createElement("button");
5060
- btn.className = "btn btn-secondary btn-sm";
5061
- btn.textContent = p.text;
5062
- btn.addEventListener("click", () => {
5063
- selectedPath = p.value;
5064
- pathInput.value = p.value;
5065
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5066
- loadTreeForPath(p.value, p.absolute);
5067
- });
5068
- presets.appendChild(btn);
5069
- });
5070
- // "+ New folder" trailing action on the same toolbar.
5071
- presets.appendChild(newFolderBtn);
5072
- }
5073
-
5074
- // Path input
5075
- const pathContainer = document.createElement("div");
5076
- pathContainer.className = "dp-path-container";
5077
- const pathInput = document.createElement("input");
5078
- pathInput.type = "text";
5079
- pathInput.className = "dir-picker-input";
5080
- pathInput.spellcheck = false;
5081
- pathInput.autocomplete = "off";
5082
- pathInput.setAttribute("autocapitalize", "off");
5083
- pathInput.value = currentDir;
5084
- pathInput.placeholder = t("sib.dir.inputPlaceholder", "输入或选择目录路径");
5085
- pathContainer.appendChild(pathInput);
5086
-
5087
- // Autocomplete dropdown
5088
- const autocomplete = document.createElement("div");
5089
- autocomplete.className = "dp-autocomplete";
5090
- autocomplete.style.display = "none";
5091
- pathContainer.appendChild(autocomplete);
5092
-
5093
- body.appendChild(pathContainer);
5094
-
5095
- // Show hidden files toggle
5096
- const hiddenToggle = document.createElement("label");
5097
- hiddenToggle.className = "dp-hidden-toggle";
5098
- const hiddenCheckbox = document.createElement("input");
5099
- hiddenCheckbox.type = "checkbox";
5100
- hiddenCheckbox.checked = false;
5101
- const hiddenLabelText = document.createElement("span");
5102
- hiddenLabelText.textContent = t("sib.dir.showHidden", "显示隐藏文件");
5103
- hiddenToggle.appendChild(hiddenCheckbox);
5104
- hiddenToggle.appendChild(hiddenLabelText);
5105
- body.appendChild(hiddenToggle);
5106
-
5107
- // Tree container
5108
- const treeContainer = document.createElement("div");
5109
- treeContainer.className = "dp-tree";
5110
- treeContainer.style.flex = "1";
5111
- treeContainer.style.overflow = "auto";
5112
- treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5113
- body.appendChild(treeContainer);
5114
-
5115
- modal.appendChild(body);
5116
-
5117
- // Buttons
5118
- const buttonContainer = document.createElement("div");
5119
- buttonContainer.className = "modal-buttons";
5120
-
5121
- const cancelButton = document.createElement("button");
5122
- cancelButton.className = "btn btn-secondary";
5123
- cancelButton.textContent = t("sib.dir.cancel", "取消");
5124
- cancelButton.onclick = () => {
5125
- overlay.remove();
5126
- resolve(null);
5127
- };
5128
-
5129
- const confirmButton = document.createElement("button");
5130
- confirmButton.className = "btn btn-primary";
5131
- confirmButton.textContent = t("sib.dir.confirm", "确认");
5132
- confirmButton.onclick = () => {
5133
- const dir = pathInput.value.trim();
5134
- overlay.remove();
5135
- resolve(dir || null);
5136
- };
5137
-
5138
- buttonContainer.appendChild(cancelButton);
5139
- buttonContainer.appendChild(confirmButton);
5140
- modal.appendChild(buttonContainer);
5141
-
5142
- overlay.appendChild(modal);
5143
- document.body.appendChild(overlay);
5144
-
5145
- // Sync pathInput changes to selectedPath
5146
- pathInput.addEventListener("input", () => {
5147
- selectedPath = pathInput.value;
5148
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5149
- refreshUpBtn();
5150
- });
5151
-
5152
- // Load tree for a given path
5153
- async function loadTreeForPath(dirPath, absolute = false) {
5154
- treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
5155
- if (dirPath) { pathInput.value = dirPath; selectedPath = dirPath; }
5156
- // Session-less mode browses absolute paths directly via /api/dirs;
5157
- // skip the working-directory-relative path math.
5158
- const useAbsolute = sessionLess || absolute || (dirPath.startsWith("/") && (!rootDir || !(dirPath === rootDir || dirPath.startsWith(rootDir + "/"))));
5159
- // Convert absolute path to relative path for API
5160
- let relPath = dirPath;
5161
- if (!sessionLess && !useAbsolute && rootDir && (dirPath === rootDir || dirPath.startsWith(rootDir + "/"))) {
5162
- relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
5163
- }
5164
- try {
5165
- const dirs = await fetchDirs(relPath, useAbsolute);
5166
- // Update presets and pathInput with absolute paths after first API call
5167
- if (rootDir && presets.children.length === 0) {
5168
- setupPresets();
5169
- // Update pathInput to show absolute path
5170
- if (rootDir !== currentDir && !(currentDir === rootDir || currentDir.startsWith(rootDir + "/"))) {
5171
- pathInput.value = rootDir;
5172
- selectedPath = rootDir;
5173
- }
5174
- }
5175
- treeContainer.innerHTML = "";
5176
- if (dirs.length === 0) {
5177
- treeContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
5178
- } else {
5179
- const frag = document.createDocumentFragment();
5180
- dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
5181
- treeContainer.appendChild(frag);
5182
- }
5183
- refreshUpBtn();
5184
- } catch (err) {
5185
- console.error("dir picker load failed:", err);
5186
- treeContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
5187
- }
5188
- }
5189
- // Session-less mode: start browsing from the requested default (e.g.
5190
- // ~/clacky_workspace). The backend walks up to the nearest existing
5191
- // ancestor if it doesn't exist yet, while pathInput keeps the original.
5192
- if (sessionLess && currentDir) {
5193
- const wanted = currentDir;
5194
- loadTreeForPath(currentDir, true).then(() => {
5195
- pathInput.value = wanted;
5196
- selectedPath = wanted;
5197
- refreshUpBtn();
5198
- });
5132
+ // ── Initial load ───────────────────────────────────────────────────
5133
+ // session-less: browse absolute paths from currentDir (or "/" fallback)
5134
+ // sessionful: load relative root first so rootDir is populated, then
5135
+ // navigate to currentDir if it's provided.
5136
+ if (sessionLess) {
5137
+ const startDir = currentDir || "/";
5138
+ navigateTo(startDir, true);
5199
5139
  } else {
5200
- loadTreeForPath("");
5201
- }
5202
-
5203
- hiddenCheckbox.addEventListener("change", () => {
5204
- showHidden = hiddenCheckbox.checked;
5205
- const cur = pathInput.value.trim();
5206
- const absolute = sessionLess || cur.startsWith("/");
5207
- loadTreeForPath(cur, absolute);
5208
- });
5209
-
5210
- // ── Autocomplete logic ──────────────────────────────────────────────
5211
- let autocompleteTimer = null;
5212
- let activeIndex = -1;
5213
-
5214
- function hideAutocomplete() {
5215
- autocomplete.style.display = "none";
5216
- autocomplete.innerHTML = "";
5217
- activeIndex = -1;
5218
- }
5219
-
5220
- function showAutocomplete(items) {
5221
- autocomplete.innerHTML = "";
5222
- if (!items.length) { hideAutocomplete(); return; }
5223
-
5224
- items.forEach((item, i) => {
5225
- const row = document.createElement("div");
5226
- row.className = "dp-ac-item";
5227
- if (i === activeIndex) row.classList.add("active");
5228
-
5229
- const icon = document.createElement("span");
5230
- icon.className = "dp-ac-icon";
5231
- icon.innerHTML = ICON_FOLDER_SVG;
5232
-
5233
- const name = document.createElement("span");
5234
- name.className = "dp-ac-name";
5235
- name.textContent = item.name;
5236
-
5237
- row.appendChild(icon);
5238
- row.appendChild(name);
5239
-
5240
- row.addEventListener("mousedown", (e) => {
5241
- e.preventDefault(); // prevent blur
5242
- // Construct full path: keep parent path from input, append selected item name
5243
- const inputVal = pathInput.value;
5244
- const lastSlash = inputVal.lastIndexOf("/");
5245
- const fullPath = lastSlash >= 0
5246
- ? inputVal.substring(0, lastSlash + 1) + item.name
5247
- : item.name;
5248
- pathInput.value = fullPath;
5249
- selectedPath = fullPath;
5250
- hideAutocomplete();
5251
- loadTreeForPath(fullPath);
5252
- });
5253
- row.addEventListener("mouseenter", () => {
5254
- activeIndex = i;
5255
- autocomplete.querySelectorAll(".dp-ac-item").forEach((el, j) => {
5256
- el.classList.toggle("active", j === i);
5257
- });
5258
- });
5259
-
5260
- autocomplete.appendChild(row);
5261
- });
5262
- autocomplete.style.display = "";
5263
- }
5264
-
5265
- async function fetchSuggestions(inputVal) {
5266
- if (!inputVal) { hideAutocomplete(); return; }
5267
-
5268
- // Determine parent dir and prefix
5269
- const lastSlash = inputVal.lastIndexOf("/");
5270
- let parentPath, prefix;
5271
- if (lastSlash > 0) {
5272
- parentPath = inputVal.substring(0, lastSlash);
5273
- prefix = inputVal.substring(lastSlash + 1).toLowerCase();
5274
- } else if (lastSlash === 0) {
5275
- parentPath = "/";
5276
- prefix = inputVal.substring(1).toLowerCase();
5277
- } else {
5278
- parentPath = "";
5279
- prefix = inputVal.toLowerCase();
5280
- }
5281
-
5282
- // Determine if we need absolute mode (path outside working directory)
5283
- const isAbsolute = parentPath.startsWith("/") && (!rootDir || !(parentPath === rootDir || parentPath.startsWith(rootDir + "/")));
5284
- let relPath = parentPath;
5285
- if (!isAbsolute && rootDir && (parentPath === rootDir || parentPath.startsWith(rootDir + "/"))) {
5286
- relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
5287
- }
5288
-
5289
- try {
5290
- const dirs = await fetchDirs(relPath, isAbsolute);
5291
- const filtered = prefix
5292
- ? dirs.filter(d => d.name.toLowerCase().startsWith(prefix))
5293
- : dirs;
5294
- showAutocomplete(filtered.slice(0, 15)); // limit to 15 items
5295
- } catch (_) {
5296
- hideAutocomplete();
5297
- }
5298
- }
5299
-
5300
- pathInput.addEventListener("input", () => {
5301
- selectedPath = pathInput.value;
5302
- modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
5303
- clearTimeout(autocompleteTimer);
5304
- autocompleteTimer = setTimeout(() => fetchSuggestions(pathInput.value.trim()), 200);
5305
- });
5306
-
5307
- pathInput.addEventListener("blur", () => {
5308
- // Delay to allow mousedown on suggestion
5309
- setTimeout(hideAutocomplete, 150);
5310
- });
5311
-
5312
- pathInput.addEventListener("focus", () => {
5313
- if (pathInput.value.trim()) fetchSuggestions(pathInput.value.trim());
5314
- });
5315
-
5316
- // Keyboard navigation in autocomplete
5317
- const pathIme = IME.track(pathInput);
5318
- pathInput.addEventListener("keydown", (e) => {
5319
- const items = autocomplete.querySelectorAll(".dp-ac-item");
5320
- if (!items.length || autocomplete.style.display === "none") {
5321
- if (e.key === "Enter") {
5322
- if (pathIme.isComposing(e)) return;
5323
- e.preventDefault();
5324
- // Navigate to typed path without closing modal
5325
- const dir = pathInput.value.trim();
5326
- if (dir) {
5327
- selectedPath = dir;
5328
- hideAutocomplete();
5329
- loadTreeForPath(dir);
5330
- }
5331
- }
5332
- return;
5333
- }
5334
-
5335
- if (e.key === "ArrowDown") {
5336
- e.preventDefault();
5337
- activeIndex = Math.min(activeIndex + 1, items.length - 1);
5338
- items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
5339
- items[activeIndex]?.scrollIntoView({ block: "nearest" });
5340
- } else if (e.key === "ArrowUp") {
5341
- e.preventDefault();
5342
- activeIndex = Math.max(activeIndex - 1, 0);
5343
- items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
5344
- items[activeIndex]?.scrollIntoView({ block: "nearest" });
5345
- } else if (e.key === "Enter") {
5346
- if (pathIme.isComposing(e)) return;
5347
- e.preventDefault();
5348
- if (activeIndex >= 0 && items[activeIndex]) {
5349
- // Select the highlighted suggestion
5350
- const evt = new MouseEvent("mousedown", { bubbles: true });
5351
- items[activeIndex].dispatchEvent(evt);
5352
- } else {
5353
- // Navigate to typed path without closing modal
5354
- const dir = pathInput.value.trim();
5355
- if (dir) {
5356
- selectedPath = dir;
5357
- hideAutocomplete();
5358
- loadTreeForPath(dir);
5140
+ // First load relative root to get rootDir from API, then optionally
5141
+ // re-navigate to the actual currentDir.
5142
+ navigateTo("", false).then ? navigateTo("", false) : navigateTo("", false);
5143
+ if (currentDir) {
5144
+ // After rootDir is known, breadcrumb + selectedPath already set by navigateTo.
5145
+ // If currentDir differs from rootDir, do a second navigate.
5146
+ setTimeout(() => {
5147
+ if (currentDir && currentDir !== rootDir) {
5148
+ navigateTo(currentDir, currentDir.startsWith("/"));
5359
5149
  }
5360
- }
5361
- } else if (e.key === "Escape") {
5362
- hideAutocomplete();
5150
+ }, 100);
5363
5151
  }
5364
- });
5152
+ }
5365
5153
 
5366
5154
  overlay.addEventListener("keydown", (e) => {
5367
- if (e.key === "Escape") {
5368
- if (autocomplete.style.display !== "none") {
5369
- hideAutocomplete();
5370
- } else {
5371
- cancelButton.click();
5372
- }
5373
- }
5155
+ if (e.key === "Escape") cancelButton.click();
5374
5156
  });
5375
-
5376
5157
  overlay.addEventListener("click", (e) => {
5377
5158
  if (e.target === overlay) cancelButton.click();
5378
5159
  });