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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +20 -1
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/providers.rb +7 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +124 -9
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +16 -2
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +221 -2
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +28 -4
- data/lib/clacky/web/index.html +9 -1
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +50 -0
- data/lib/clacky/web/workspace.js +9 -1
- data/scripts/build/lib/network.sh +3 -3
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +3 -3
- data/scripts/install_browser.sh +3 -3
- data/scripts/install_full.sh +3 -3
- data/scripts/install_rails_deps.sh +3 -3
- data/scripts/install_system_deps.sh +3 -3
- metadata +6 -2
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -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
|
|
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).
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -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";
|
data/lib/clacky/web/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (~
|
|
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
|
|
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 : ~
|
|
213
|
-
exit
|
|
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
|
|
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
|
|
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
|
|
292
|
+
exit 2
|
|
293
293
|
fi
|
|
294
294
|
else
|
|
295
295
|
USE_CN_MIRRORS=false
|
data/scripts/install_browser.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
288
|
+
exit 2
|
|
289
289
|
fi
|
|
290
290
|
else
|
|
291
291
|
USE_CN_MIRRORS=false
|