openclacky 1.2.9 → 1.2.10

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.
@@ -3804,6 +3804,448 @@ const Sessions = (() => {
3804
3804
 
3805
3805
  // ── Session Info Bar Working Directory Switcher ───────────────────────────
3806
3806
  (function() {
3807
+ // Directory picker with predefined list
3808
+ // ── Tree-based directory picker ─────────────────────────────────────────
3809
+ 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>';
3810
+ 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>';
3811
+ function showDirectoryPicker(currentDir, sessionId) {
3812
+ return new Promise((resolve) => {
3813
+ const t = (key, fallback) => {
3814
+ const s = I18n.t(key);
3815
+ return (s && s !== key) ? s : fallback;
3816
+ };
3817
+
3818
+ let selectedPath = currentDir;
3819
+ let rootDir = ""; // absolute path of the session's working directory
3820
+
3821
+ // Fetch directory entries from API, returns dirs with absolute paths
3822
+ async function fetchDirs(relPath, absolute = false) {
3823
+ let url = `/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(relPath || "")}`;
3824
+ if (absolute) url += "&absolute=true";
3825
+ const resp = await fetch(url);
3826
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
3827
+ const data = await resp.json();
3828
+ // Only update rootDir in relative mode; absolute mode would overwrite it with "/"
3829
+ if (!absolute) rootDir = data.root || rootDir;
3830
+ const dirs = (data.entries || []).filter(e => e.type === "dir");
3831
+ // Convert relative paths to absolute
3832
+ dirs.forEach(d => {
3833
+ // Strip leading slashes from path to avoid double slashes
3834
+ const cleanPath = d.path.replace(/^\/+/, "");
3835
+ d.absPath = absolute ? ("/" + cleanPath) : (rootDir.replace(/\/+$/, "") + "/" + cleanPath);
3836
+ d.absolute = absolute; // Store absolute flag for child expansion
3837
+ }); return dirs;
3838
+ }
3839
+
3840
+ // Build a tree node for a directory entry
3841
+ function buildDirNode(entry, depth) {
3842
+ const node = document.createElement("div");
3843
+ node.className = "dp-node";
3844
+ node.dataset.depth = depth; // Store depth for child expansion
3845
+
3846
+ const row = document.createElement("div");
3847
+ row.className = "dp-row";
3848
+ row.style.paddingLeft = `${depth * 16 + 8}px`;
3849
+ const caret = document.createElement("span");
3850
+ caret.className = "dp-caret";
3851
+ caret.innerHTML = ICON_CARET_SVG;
3852
+
3853
+ const icon = document.createElement("span");
3854
+ icon.className = "dp-icon";
3855
+ icon.innerHTML = ICON_FOLDER_SVG;
3856
+
3857
+ const name = document.createElement("span");
3858
+ name.className = "dp-name";
3859
+ name.textContent = entry.name;
3860
+
3861
+ row.appendChild(caret);
3862
+ row.appendChild(icon);
3863
+ row.appendChild(name);
3864
+ node.appendChild(row);
3865
+
3866
+ const children = document.createElement("div");
3867
+ children.className = "dp-children";
3868
+ children.style.display = "none";
3869
+ node.appendChild(children);
3870
+
3871
+ // Single-click: select directory (show path) + expand/collapse
3872
+ let clickTimer = null;
3873
+ row.addEventListener("click", (e) => {
3874
+ e.stopPropagation();
3875
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
3876
+ clickTimer = setTimeout(() => {
3877
+ clickTimer = null;
3878
+ // Select this directory
3879
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
3880
+ row.classList.add("selected");
3881
+ selectedPath = entry.absPath;
3882
+ pathInput.value = entry.absPath;
3883
+ // Also expand/collapse
3884
+ toggleExpand(entry, caret, children);
3885
+ }, 250);
3886
+ });
3887
+
3888
+ // Double-click: enter directory (navigate into it)
3889
+ row.addEventListener("dblclick", (e) => {
3890
+ e.stopPropagation();
3891
+ if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
3892
+ // Enter this directory - reload tree with this as root
3893
+ loadTreeForPath(entry.absPath, entry.absolute);
3894
+ });
3895
+ return node;
3896
+ }
3897
+
3898
+ async function toggleExpand(entry, caret, children) {
3899
+ const isOpen = caret.classList.contains("open");
3900
+ if (isOpen) {
3901
+ caret.classList.remove("open");
3902
+ children.style.display = "none";
3903
+ return;
3904
+ }
3905
+ caret.classList.add("open");
3906
+ children.style.display = "flex";
3907
+ if (children.dataset.loaded === "1") return;
3908
+ children.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
3909
+ try {
3910
+ const dirs = await fetchDirs(entry.path, entry.absolute);
3911
+ children.innerHTML = "";
3912
+ if (dirs.length === 0) {
3913
+ children.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
3914
+ } else {
3915
+ const parentNode = children.parentElement;
3916
+ const parentDepth = parseInt(parentNode?.dataset?.depth) || 0;
3917
+ const childDepth = parentDepth + 1;
3918
+ const frag = document.createDocumentFragment();
3919
+ dirs.forEach(d => frag.appendChild(buildDirNode(d, childDepth)));
3920
+ children.appendChild(frag);
3921
+ } children.dataset.loaded = "1";
3922
+ } catch (err) {
3923
+ console.error("dir picker load failed:", err);
3924
+ children.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
3925
+ }
3926
+ }
3927
+
3928
+ // Create modal overlay
3929
+ const overlay = document.createElement("div");
3930
+ overlay.className = "modal-overlay";
3931
+
3932
+ // Create modal content
3933
+ const modal = document.createElement("div");
3934
+ modal.className = "modal-content";
3935
+ modal.style.maxWidth = "520px";
3936
+ modal.style.maxHeight = "80vh";
3937
+ modal.style.display = "flex";
3938
+ modal.style.flexDirection = "column";
3939
+
3940
+ // Title
3941
+ const title = document.createElement("div");
3942
+ title.className = "modal-title";
3943
+ title.textContent = t("sib.dir.changePrompt", "切换工作目录");
3944
+ modal.appendChild(title);
3945
+
3946
+ // Modal body
3947
+ const body = document.createElement("div");
3948
+ body.className = "modal-body";
3949
+ body.style.flex = "1";
3950
+ body.style.overflow = "hidden";
3951
+ body.style.display = "flex";
3952
+ body.style.flexDirection = "column";
3953
+ body.style.gap = "8px";
3954
+
3955
+ // Quick presets (will be populated with absolute paths after first API call)
3956
+ const presets = document.createElement("div");
3957
+ presets.className = "dp-presets";
3958
+ body.appendChild(presets);
3959
+
3960
+ function setupPresets() {
3961
+ presets.innerHTML = "";
3962
+ const presetDirs = [
3963
+ { value: rootDir, text: t("sib.dir.current", "当前工作目录"), absolute: false },
3964
+ { value: "/", text: t("sib.dir.root", "根目录"), absolute: true }
3965
+ ]; presetDirs.forEach(p => {
3966
+ const btn = document.createElement("button");
3967
+ btn.className = "btn btn-secondary btn-sm";
3968
+ btn.textContent = p.text;
3969
+ btn.addEventListener("click", () => {
3970
+ selectedPath = p.value;
3971
+ pathInput.value = p.value;
3972
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
3973
+ loadTreeForPath(p.value, p.absolute);
3974
+ });
3975
+ presets.appendChild(btn);
3976
+ });
3977
+ }
3978
+
3979
+ // Path input
3980
+ const pathContainer = document.createElement("div");
3981
+ pathContainer.className = "dp-path-container";
3982
+ const pathInput = document.createElement("input");
3983
+ pathInput.type = "text";
3984
+ pathInput.className = "dir-picker-input";
3985
+ pathInput.value = currentDir;
3986
+ pathInput.placeholder = t("sib.dir.inputPlaceholder", "输入或选择目录路径");
3987
+ pathContainer.appendChild(pathInput);
3988
+
3989
+ // Autocomplete dropdown
3990
+ const autocomplete = document.createElement("div");
3991
+ autocomplete.className = "dp-autocomplete";
3992
+ autocomplete.style.display = "none";
3993
+ pathContainer.appendChild(autocomplete);
3994
+
3995
+ body.appendChild(pathContainer);
3996
+ // Tree container
3997
+ const treeContainer = document.createElement("div");
3998
+ treeContainer.className = "dp-tree";
3999
+ treeContainer.style.flex = "1";
4000
+ treeContainer.style.overflow = "auto";
4001
+ treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4002
+ body.appendChild(treeContainer);
4003
+
4004
+ modal.appendChild(body);
4005
+
4006
+ // Buttons
4007
+ const buttonContainer = document.createElement("div");
4008
+ buttonContainer.className = "modal-buttons";
4009
+
4010
+ const cancelButton = document.createElement("button");
4011
+ cancelButton.className = "btn btn-secondary";
4012
+ cancelButton.textContent = t("sib.dir.cancel", "取消");
4013
+ cancelButton.onclick = () => {
4014
+ overlay.remove();
4015
+ resolve(null);
4016
+ };
4017
+
4018
+ const confirmButton = document.createElement("button");
4019
+ confirmButton.className = "btn btn-primary";
4020
+ confirmButton.textContent = t("sib.dir.confirm", "确认");
4021
+ confirmButton.onclick = () => {
4022
+ const dir = pathInput.value.trim();
4023
+ overlay.remove();
4024
+ resolve(dir || null);
4025
+ };
4026
+
4027
+ buttonContainer.appendChild(cancelButton);
4028
+ buttonContainer.appendChild(confirmButton);
4029
+ modal.appendChild(buttonContainer);
4030
+
4031
+ overlay.appendChild(modal);
4032
+ document.body.appendChild(overlay);
4033
+
4034
+ // Sync pathInput changes to selectedPath
4035
+ pathInput.addEventListener("input", () => {
4036
+ selectedPath = pathInput.value;
4037
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4038
+ });
4039
+
4040
+ // Load tree for a given path
4041
+ async function loadTreeForPath(dirPath, absolute = false) {
4042
+ treeContainer.innerHTML = `<div class="dp-loading">${t("sib.dir.loading", "加载中...")}</div>`;
4043
+ // Auto-detect absolute mode: path is absolute and outside working directory
4044
+ const useAbsolute = absolute || (dirPath.startsWith("/") && (!rootDir || !dirPath.startsWith(rootDir)));
4045
+ // Convert absolute path to relative path for API
4046
+ let relPath = dirPath;
4047
+ if (!useAbsolute && rootDir && dirPath.startsWith(rootDir)) {
4048
+ relPath = dirPath.substring(rootDir.length).replace(/^\/+/, "");
4049
+ }
4050
+ try {
4051
+ const dirs = await fetchDirs(relPath, useAbsolute);
4052
+ // Update presets and pathInput with absolute paths after first API call
4053
+ if (rootDir && presets.children.length === 0) {
4054
+ setupPresets();
4055
+ // Update pathInput to show absolute path
4056
+ if (rootDir !== currentDir && !currentDir.startsWith(rootDir)) {
4057
+ pathInput.value = rootDir;
4058
+ selectedPath = rootDir;
4059
+ }
4060
+ }
4061
+ treeContainer.innerHTML = "";
4062
+ if (dirs.length === 0) {
4063
+ treeContainer.innerHTML = `<div class="dp-empty">${t("sib.dir.empty", "空目录")}</div>`;
4064
+ } else {
4065
+ const frag = document.createDocumentFragment();
4066
+ dirs.forEach(d => frag.appendChild(buildDirNode(d, 0)));
4067
+ treeContainer.appendChild(frag);
4068
+ }
4069
+ } catch (err) {
4070
+ console.error("dir picker load failed:", err);
4071
+ treeContainer.innerHTML = `<div class="dp-error">${t("sib.dir.loadError", "加载失败")}</div>`;
4072
+ }
4073
+ }
4074
+ loadTreeForPath("");
4075
+
4076
+ // ── Autocomplete logic ──────────────────────────────────────────────
4077
+ let autocompleteTimer = null;
4078
+ let activeIndex = -1;
4079
+
4080
+ function hideAutocomplete() {
4081
+ autocomplete.style.display = "none";
4082
+ autocomplete.innerHTML = "";
4083
+ activeIndex = -1;
4084
+ }
4085
+
4086
+ function showAutocomplete(items) {
4087
+ autocomplete.innerHTML = "";
4088
+ if (!items.length) { hideAutocomplete(); return; }
4089
+
4090
+ items.forEach((item, i) => {
4091
+ const row = document.createElement("div");
4092
+ row.className = "dp-ac-item";
4093
+ if (i === activeIndex) row.classList.add("active");
4094
+
4095
+ const icon = document.createElement("span");
4096
+ icon.className = "dp-ac-icon";
4097
+ icon.innerHTML = ICON_FOLDER_SVG;
4098
+
4099
+ const name = document.createElement("span");
4100
+ name.className = "dp-ac-name";
4101
+ name.textContent = item.name;
4102
+
4103
+ row.appendChild(icon);
4104
+ row.appendChild(name);
4105
+
4106
+ row.addEventListener("mousedown", (e) => {
4107
+ e.preventDefault(); // prevent blur
4108
+ // Construct full path: keep parent path from input, append selected item name
4109
+ const inputVal = pathInput.value;
4110
+ const lastSlash = inputVal.lastIndexOf("/");
4111
+ const fullPath = lastSlash >= 0
4112
+ ? inputVal.substring(0, lastSlash + 1) + item.name
4113
+ : item.name;
4114
+ pathInput.value = fullPath;
4115
+ selectedPath = fullPath;
4116
+ hideAutocomplete();
4117
+ loadTreeForPath(fullPath);
4118
+ });
4119
+ row.addEventListener("mouseenter", () => {
4120
+ activeIndex = i;
4121
+ autocomplete.querySelectorAll(".dp-ac-item").forEach((el, j) => {
4122
+ el.classList.toggle("active", j === i);
4123
+ });
4124
+ });
4125
+
4126
+ autocomplete.appendChild(row);
4127
+ });
4128
+ autocomplete.style.display = "";
4129
+ }
4130
+
4131
+ async function fetchSuggestions(inputVal) {
4132
+ if (!inputVal) { hideAutocomplete(); return; }
4133
+
4134
+ // Determine parent dir and prefix
4135
+ const lastSlash = inputVal.lastIndexOf("/");
4136
+ let parentPath, prefix;
4137
+ if (lastSlash > 0) {
4138
+ parentPath = inputVal.substring(0, lastSlash);
4139
+ prefix = inputVal.substring(lastSlash + 1).toLowerCase();
4140
+ } else if (lastSlash === 0) {
4141
+ parentPath = "/";
4142
+ prefix = inputVal.substring(1).toLowerCase();
4143
+ } else {
4144
+ parentPath = "";
4145
+ prefix = inputVal.toLowerCase();
4146
+ }
4147
+
4148
+ // Determine if we need absolute mode (path outside working directory)
4149
+ const isAbsolute = parentPath.startsWith("/") && (!rootDir || !parentPath.startsWith(rootDir));
4150
+ let relPath = parentPath;
4151
+ if (!isAbsolute && rootDir && parentPath.startsWith(rootDir)) {
4152
+ relPath = parentPath.substring(rootDir.length).replace(/^\/+/, "");
4153
+ }
4154
+
4155
+ try {
4156
+ const dirs = await fetchDirs(relPath, isAbsolute);
4157
+ const filtered = prefix
4158
+ ? dirs.filter(d => d.name.toLowerCase().startsWith(prefix))
4159
+ : dirs;
4160
+ showAutocomplete(filtered.slice(0, 15)); // limit to 15 items
4161
+ } catch (_) {
4162
+ hideAutocomplete();
4163
+ }
4164
+ }
4165
+
4166
+ pathInput.addEventListener("input", () => {
4167
+ selectedPath = pathInput.value;
4168
+ modal.querySelectorAll(".dp-row.selected").forEach(el => el.classList.remove("selected"));
4169
+ clearTimeout(autocompleteTimer);
4170
+ autocompleteTimer = setTimeout(() => fetchSuggestions(pathInput.value.trim()), 200);
4171
+ });
4172
+
4173
+ pathInput.addEventListener("blur", () => {
4174
+ // Delay to allow mousedown on suggestion
4175
+ setTimeout(hideAutocomplete, 150);
4176
+ });
4177
+
4178
+ pathInput.addEventListener("focus", () => {
4179
+ if (pathInput.value.trim()) fetchSuggestions(pathInput.value.trim());
4180
+ });
4181
+
4182
+ // Keyboard navigation in autocomplete
4183
+ pathInput.addEventListener("keydown", (e) => {
4184
+ const items = autocomplete.querySelectorAll(".dp-ac-item");
4185
+ if (!items.length || autocomplete.style.display === "none") {
4186
+ if (e.key === "Enter") {
4187
+ e.preventDefault();
4188
+ // Navigate to typed path without closing modal
4189
+ const dir = pathInput.value.trim();
4190
+ if (dir) {
4191
+ selectedPath = dir;
4192
+ hideAutocomplete();
4193
+ loadTreeForPath(dir);
4194
+ }
4195
+ }
4196
+ return;
4197
+ }
4198
+
4199
+ if (e.key === "ArrowDown") {
4200
+ e.preventDefault();
4201
+ activeIndex = Math.min(activeIndex + 1, items.length - 1);
4202
+ items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
4203
+ items[activeIndex]?.scrollIntoView({ block: "nearest" });
4204
+ } else if (e.key === "ArrowUp") {
4205
+ e.preventDefault();
4206
+ activeIndex = Math.max(activeIndex - 1, 0);
4207
+ items.forEach((el, i) => el.classList.toggle("active", i === activeIndex));
4208
+ items[activeIndex]?.scrollIntoView({ block: "nearest" });
4209
+ } else if (e.key === "Enter") {
4210
+ e.preventDefault();
4211
+ if (activeIndex >= 0 && items[activeIndex]) {
4212
+ // Select the highlighted suggestion
4213
+ const evt = new MouseEvent("mousedown", { bubbles: true });
4214
+ items[activeIndex].dispatchEvent(evt);
4215
+ } else {
4216
+ // Navigate to typed path without closing modal
4217
+ const dir = pathInput.value.trim();
4218
+ if (dir) {
4219
+ selectedPath = dir;
4220
+ hideAutocomplete();
4221
+ loadTreeForPath(dir);
4222
+ }
4223
+ }
4224
+ } else if (e.key === "Escape") {
4225
+ hideAutocomplete();
4226
+ }
4227
+ });
4228
+
4229
+ overlay.addEventListener("keydown", (e) => {
4230
+ if (e.key === "Escape") {
4231
+ if (autocomplete.style.display !== "none") {
4232
+ hideAutocomplete();
4233
+ } else {
4234
+ cancelButton.click();
4235
+ }
4236
+ }
4237
+ });
4238
+
4239
+ overlay.addEventListener("click", (e) => {
4240
+ if (e.target === overlay) cancelButton.click();
4241
+ });
4242
+
4243
+ // Focus path input
4244
+ pathInput.focus();
4245
+ pathInput.select();
4246
+ });
4247
+ }
4248
+
3807
4249
  // Handle click on working directory
3808
4250
  document.addEventListener("click", async (e) => {
3809
4251
  const dirEl = e.target.closest("#sib-dir");
@@ -3812,12 +4254,11 @@ const Sessions = (() => {
3812
4254
  const sessionId = dirEl.dataset.sessionId;
3813
4255
  const currentDir = dirEl.dataset.workingDir || dirEl.textContent;
3814
4256
 
3815
- const newDir = await Modal.prompt(I18n.t("sib.dir.changePrompt"), currentDir);
4257
+ const newDir = await showDirectoryPicker(currentDir, sessionId);
3816
4258
  if (newDir && newDir !== currentDir) {
3817
4259
  _changeWorkingDirectory(sessionId, newDir);
3818
4260
  }
3819
4261
  }
3820
-
3821
4262
  // Handle click on session ID — toggles a small actions dropdown with
3822
4263
  // items like "Download session files (for debugging)". Designed to be
3823
4264
  // extensible (more session-level actions can be added here later).
@@ -1914,6 +1914,7 @@ const Settings = (() => {
1914
1914
  // Initialize exchange rate input
1915
1915
  const exchangeRateInput = document.getElementById("settings-exchange-rate");
1916
1916
  const exchangeRateSection = document.getElementById("exchange-rate-section");
1917
+ const updateRateBtn = document.getElementById("btn-update-exchange-rate");
1917
1918
  if (exchangeRateInput && exchangeRateSection) {
1918
1919
  // Set initial value
1919
1920
  exchangeRateInput.value = _getExchangeRate();
@@ -1928,9 +1929,58 @@ const Settings = (() => {
1928
1929
  exchangeRateInput.value = _getExchangeRate();
1929
1930
  }
1930
1931
  });
1932
+
1933
+ if (updateRateBtn && !updateRateBtn.dataset.bound) {
1934
+ updateRateBtn.dataset.bound = "1";
1935
+ updateRateBtn.addEventListener("click", () => _updateLatestExchangeRate());
1936
+ }
1931
1937
  }
1932
1938
  }
1933
1939
 
1940
+ async function _updateLatestExchangeRate() {
1941
+ const input = document.getElementById("settings-exchange-rate");
1942
+ const btn = document.getElementById("btn-update-exchange-rate");
1943
+ if (!input || !btn) return;
1944
+
1945
+ const label = btn.querySelector("span");
1946
+ const originalText = label ? label.textContent : btn.textContent;
1947
+ btn.disabled = true;
1948
+ if (label) label.textContent = I18n.t("settings.currency.updating");
1949
+ else btn.textContent = I18n.t("settings.currency.updating");
1950
+ _setExchangeRateStatus("", "");
1951
+
1952
+ try {
1953
+ const res = await fetch("/api/exchange-rate?from=USD&to=CNY");
1954
+ const data = await res.json().catch(() => ({}));
1955
+ if (!res.ok) throw new Error(I18n.t("settings.currency.updateFailed"));
1956
+
1957
+ const rate = parseFloat(data.rate);
1958
+ if (isNaN(rate) || rate <= 0) throw new Error(I18n.t("settings.currency.updateFailed"));
1959
+
1960
+ input.value = rate.toString();
1961
+ _setExchangeRate(rate);
1962
+ _setExchangeRateStatus(
1963
+ I18n.t("settings.currency.updated", { source: data.source || "", date: data.date || "" }),
1964
+ "success"
1965
+ );
1966
+ } catch (e) {
1967
+ _setExchangeRateStatus(e.message || I18n.t("settings.currency.updateFailed"), "error");
1968
+ } finally {
1969
+ btn.disabled = false;
1970
+ if (label) label.textContent = originalText || I18n.t("settings.currency.updateLatest");
1971
+ else btn.textContent = originalText || I18n.t("settings.currency.updateLatest");
1972
+ }
1973
+ }
1974
+
1975
+ function _setExchangeRateStatus(message, type) {
1976
+ const status = document.getElementById("settings-exchange-rate-status");
1977
+ if (!status) return;
1978
+
1979
+ status.textContent = message || "";
1980
+ status.classList.toggle("success", type === "success");
1981
+ status.classList.toggle("error", type === "error");
1982
+ }
1983
+
1934
1984
  // ── Font Size ──────────────────────────────────────────────────────────
1935
1985
  const FONT_STORAGE_KEY = "clacky-font-size";
1936
1986
  const FONT_DEFAULT = "medium";
@@ -188,14 +188,22 @@ const Workspace = (() => {
188
188
  },
189
189
 
190
190
  // Called from Sessions.updateInfoBar whenever the active session changes.
191
+ // On a real session switch (from one session to another) we always collapse
192
+ // the panel: the file list is only ever loaded when the user explicitly
193
+ // expands it (which triggers a single refresh via setOpen), so the list is
194
+ // never shown stale across sessions. The first attach (no previous session)
195
+ // is not a switch and keeps the restored open state.
191
196
  onSession(session) {
192
197
  const newId = session ? session.id : null;
193
198
  const newDir = session ? session.working_dir : null;
199
+ const hadSession = _sessionId != null;
194
200
  const changed = newId !== _sessionId || newDir !== _workingDir;
195
201
  _sessionId = newId;
196
202
  _workingDir = newDir;
203
+ if (changed && hadSession && _open) setOpen(false);
197
204
  applyOpenState();
198
- if (changed && _open && _sessionId) loadRoot();
205
+ // First attach with the panel restored open: load once.
206
+ if (!hadSession && _open && _sessionId) loadRoot();
199
207
  }
200
208
  };
201
209
  })();
@@ -104,7 +104,7 @@ _resolve_cdn_base_url() {
104
104
  _print_probe_result "CN CDN fallback" "$result"
105
105
  if _is_slow_or_unreachable "$result"; then
106
106
  print_error "CN CDN and fallback both unreachable — cannot install."
107
- exit 1
107
+ exit 2
108
108
  fi
109
109
  CN_CDN_BASE_URL="$CN_CDN_FALLBACK_URL"
110
110
  }
@@ -152,7 +152,7 @@ detect_network_region() {
152
152
  print_success "Region: china"
153
153
  else
154
154
  print_error "Region: unknown (all unreachable) — cannot install."
155
- exit 1
155
+ exit 2
156
156
  fi
157
157
  echo ""
158
158
 
@@ -177,7 +177,7 @@ detect_network_region() {
177
177
  print_info "CN mirrors applied"
178
178
  else
179
179
  print_error "CN mirrors unreachable — cannot install."
180
- exit 1
180
+ exit 2
181
181
  fi
182
182
  else
183
183
  USE_CN_MIRRORS=false
data/scripts/install.ps1 CHANGED
@@ -203,14 +203,14 @@ function Get-UbuntuRootfs {
203
203
  $tarPath = "$safeTemp\ubuntu-wsl-$cpuArch.tar.gz"
204
204
  $installDir = $UBUNTU_WSL_DIR
205
205
 
206
- # Disk space check (~2 GB needed: 350 MB download + ~1.5 GB imported)
206
+ # Disk space check (~4 GB needed: 350 MB download + ~1.5 GB imported + buffer)
207
207
  $drive = Split-Path -Qualifier $installDir
208
208
  $freeBytes = (Get-PSDrive ($drive.TrimEnd(':'))).Free
209
- if ($freeBytes -lt 2GB) {
209
+ if ($freeBytes -lt 4GB) {
210
210
  Write-Fail "Not enough disk space on $drive."
211
211
  Write-Fail " Available : $([math]::Round($freeBytes / 1GB, 1)) GB"
212
- Write-Fail " Required : ~2 GB"
213
- exit 1
212
+ Write-Fail " Required : ~4 GB"
213
+ exit 3
214
214
  }
215
215
 
216
216
  # Check if a valid cached tarball exists (skip download if checksum passes)
@@ -272,6 +272,17 @@ function Install-UbuntuRootfs {
272
272
  Write-Success "Ubuntu (WSL$WslVersion) imported successfully."
273
273
  }
274
274
 
275
+ function Test-WslNetwork {
276
+ Write-Info "Checking WSL network connectivity..."
277
+ wsl.exe -d Ubuntu -u root -- bash -c "curl -fsSL --max-time 3 --retry 1 $INSTALL_SCRIPT_URL -o /dev/null 2>/dev/null" | Out-Null
278
+ if ($LASTEXITCODE -ne 0) {
279
+ Write-Fail "WSL cannot reach $CLACKY_CDN_PRIMARY_HOST (curl exit $LASTEXITCODE)."
280
+ Write-Fail "Please fix the network inside WSL and re-run this installer."
281
+ exit 2
282
+ }
283
+ Write-Success "WSL network OK."
284
+ }
285
+
275
286
  # Install OpenClacky inside the Ubuntu WSL distro.
276
287
  function Run-InstallInWsl {
277
288
  Write-Step "Installing $DisplayName inside WSL..."
@@ -288,6 +299,7 @@ function Run-InstallInWsl {
288
299
  Write-Info "Local mode: using $wslPath"
289
300
  wsl.exe -d Ubuntu -u root -- bash $wslPath --brand-name=$BrandName --command=$CommandName
290
301
  } else {
302
+ Test-WslNetwork
291
303
  wsl.exe -d Ubuntu -u root -- bash -c "cd ~ && curl -fsSL $INSTALL_SCRIPT_URL | bash -s -- --brand-name=$BrandName --command=$CommandName"
292
304
  }
293
305
 
data/scripts/install.sh CHANGED
@@ -216,7 +216,7 @@ _resolve_cdn_base_url() {
216
216
  _print_probe_result "CN CDN fallback" "$result"
217
217
  if _is_slow_or_unreachable "$result"; then
218
218
  print_error "CN CDN and fallback both unreachable — cannot install."
219
- exit 1
219
+ exit 2
220
220
  fi
221
221
  CN_CDN_BASE_URL="$CN_CDN_FALLBACK_URL"
222
222
  }
@@ -264,7 +264,7 @@ detect_network_region() {
264
264
  print_success "Region: china"
265
265
  else
266
266
  print_error "Region: unknown (all unreachable) — cannot install."
267
- exit 1
267
+ exit 2
268
268
  fi
269
269
  echo ""
270
270
 
@@ -289,7 +289,7 @@ detect_network_region() {
289
289
  print_info "CN mirrors applied"
290
290
  else
291
291
  print_error "CN mirrors unreachable — cannot install."
292
- exit 1
292
+ exit 2
293
293
  fi
294
294
  else
295
295
  USE_CN_MIRRORS=false
@@ -212,7 +212,7 @@ _resolve_cdn_base_url() {
212
212
  _print_probe_result "CN CDN fallback" "$result"
213
213
  if _is_slow_or_unreachable "$result"; then
214
214
  print_error "CN CDN and fallback both unreachable — cannot install."
215
- exit 1
215
+ exit 2
216
216
  fi
217
217
  CN_CDN_BASE_URL="$CN_CDN_FALLBACK_URL"
218
218
  }
@@ -260,7 +260,7 @@ detect_network_region() {
260
260
  print_success "Region: china"
261
261
  else
262
262
  print_error "Region: unknown (all unreachable) — cannot install."
263
- exit 1
263
+ exit 2
264
264
  fi
265
265
  echo ""
266
266
 
@@ -285,7 +285,7 @@ detect_network_region() {
285
285
  print_info "CN mirrors applied"
286
286
  else
287
287
  print_error "CN mirrors unreachable — cannot install."
288
- exit 1
288
+ exit 2
289
289
  fi
290
290
  else
291
291
  USE_CN_MIRRORS=false