openclacky 1.5.21 โ 1.5.22
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 +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
|
@@ -52,7 +52,7 @@ const NewSessionView = (() => {
|
|
|
52
52
|
return a.description || "";
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
const LS_SEEN_AGENTS_KEY = "
|
|
55
|
+
const LS_SEEN_AGENTS_KEY = "clacky-new-session-seen-agents";
|
|
56
56
|
|
|
57
57
|
function _seenAgents() {
|
|
58
58
|
try {
|
|
@@ -356,20 +356,6 @@ const NewSessionView = (() => {
|
|
|
356
356
|
const MAX_FILE_BYTES = 300 * 1024 * 1024;
|
|
357
357
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
358
358
|
|
|
359
|
-
function _docTypeIcon(mimeType, filename) {
|
|
360
|
-
const lower = (filename || "").toLowerCase();
|
|
361
|
-
if (mimeType === "application/pdf" || lower.endsWith(".pdf")) return "๐";
|
|
362
|
-
if (mimeType === "application/zip" || lower.endsWith(".zip")) return "๐๏ธ";
|
|
363
|
-
if (lower.endsWith(".tar") || lower.endsWith(".gz") || lower.endsWith(".tgz") ||
|
|
364
|
-
lower.endsWith(".tar.gz") || lower.endsWith(".rar") || lower.endsWith(".7z")) return "๐๏ธ";
|
|
365
|
-
if ((mimeType && mimeType.includes("wordprocessingml")) || lower.endsWith(".doc") || lower.endsWith(".docx")) return "๐";
|
|
366
|
-
if ((mimeType && mimeType.includes("spreadsheetml")) || lower.endsWith(".xls") || lower.endsWith(".xlsx")) return "๐";
|
|
367
|
-
if ((mimeType && mimeType.includes("presentationml")) || lower.endsWith(".ppt") || lower.endsWith(".pptx")) return "๐";
|
|
368
|
-
if (mimeType === "text/csv" || lower.endsWith(".csv")) return "๐";
|
|
369
|
-
if (lower.endsWith(".md") || lower.endsWith(".markdown")) return "๐";
|
|
370
|
-
return "๐";
|
|
371
|
-
}
|
|
372
|
-
|
|
373
359
|
function _compressImage(file) {
|
|
374
360
|
return new Promise((resolve, reject) => {
|
|
375
361
|
const reader = new FileReader();
|
|
@@ -477,7 +463,7 @@ const NewSessionView = (() => {
|
|
|
477
463
|
const removeBtn = document.createElement("button");
|
|
478
464
|
removeBtn.className = "img-preview-remove";
|
|
479
465
|
removeBtn.textContent = "โ";
|
|
480
|
-
removeBtn.title = "
|
|
466
|
+
removeBtn.title = I18n.t("tooltip.remove");
|
|
481
467
|
removeBtn.addEventListener("click", () => { _pendingImages.splice(idx, 1); _renderAttachmentPreviews(); });
|
|
482
468
|
item.appendChild(thumbnail);
|
|
483
469
|
item.appendChild(removeBtn);
|
|
@@ -490,7 +476,7 @@ const NewSessionView = (() => {
|
|
|
490
476
|
item.title = f.name;
|
|
491
477
|
const icon = document.createElement("div");
|
|
492
478
|
icon.className = "pdf-preview-icon";
|
|
493
|
-
icon.
|
|
479
|
+
icon.innerHTML = Clacky.WorkspaceView.fileIcon(f.name);
|
|
494
480
|
const info = document.createElement("div");
|
|
495
481
|
info.className = "pdf-preview-info";
|
|
496
482
|
const name = document.createElement("div");
|
|
@@ -504,7 +490,7 @@ const NewSessionView = (() => {
|
|
|
504
490
|
const removeBtn = document.createElement("button");
|
|
505
491
|
removeBtn.className = "pdf-preview-remove";
|
|
506
492
|
removeBtn.textContent = "โ";
|
|
507
|
-
removeBtn.title = "
|
|
493
|
+
removeBtn.title = I18n.t("tooltip.remove");
|
|
508
494
|
removeBtn.addEventListener("click", () => { _pendingFiles.splice(idx, 1); _renderAttachmentPreviews(); });
|
|
509
495
|
item.appendChild(icon);
|
|
510
496
|
item.appendChild(info);
|
|
@@ -541,11 +527,15 @@ const NewSessionView = (() => {
|
|
|
541
527
|
}
|
|
542
528
|
if (_pendingFiles.length > 0) {
|
|
543
529
|
const badges = _pendingFiles.map((f) => {
|
|
544
|
-
const icon =
|
|
530
|
+
const icon = Clacky.WorkspaceView.fileIcon(f.name);
|
|
545
531
|
const ext = (f.name.split(".").pop() || "file").toUpperCase();
|
|
546
|
-
|
|
532
|
+
const tag = f.path ? "button" : "span";
|
|
533
|
+
const attrs = f.path
|
|
534
|
+
? ` type="button" class="msg-pdf-badge msg-file-open" data-file-path="${encodeURIComponent(f.path)}"`
|
|
535
|
+
: ` class="msg-pdf-badge"`;
|
|
536
|
+
return `<${tag}${attrs}><span class="msg-pdf-badge-icon">${icon}</span>` +
|
|
547
537
|
`<span class="msg-pdf-badge-info"><span class="msg-pdf-badge-name">${escapeHtml(f.name)}</span>` +
|
|
548
|
-
`<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span></span
|
|
538
|
+
`<span class="msg-pdf-badge-type">${escapeHtml(ext)}</span></span></${tag}>`;
|
|
549
539
|
}).join(" ");
|
|
550
540
|
html = badges + (html ? "<br>" + html : "");
|
|
551
541
|
}
|
|
@@ -604,6 +594,11 @@ const NewSessionView = (() => {
|
|
|
604
594
|
if (popRect.bottom > window.innerHeight - 8) {
|
|
605
595
|
pop.style.top = `${rect.top - popRect.height - 6}px`;
|
|
606
596
|
}
|
|
597
|
+
// Keep the popover inside the viewport when the anchor sits near the right
|
|
598
|
+
// edge (narrow windows), instead of letting it run off-screen.
|
|
599
|
+
if (popRect.right > window.innerWidth - 8) {
|
|
600
|
+
pop.style.left = `${Math.max(8, window.innerWidth - popRect.width - 8)}px`;
|
|
601
|
+
}
|
|
607
602
|
|
|
608
603
|
const dismiss = (e) => {
|
|
609
604
|
if (!pop.contains(e.target) && e.target !== anchor) {
|
|
@@ -799,36 +794,482 @@ const NewSessionView = (() => {
|
|
|
799
794
|
_updateSendButton();
|
|
800
795
|
}
|
|
801
796
|
|
|
797
|
+
// โโ Branch chip popover โ search, switch, create โโ
|
|
798
|
+
// Branches live in the project's work tree, not in the session, so switching
|
|
799
|
+
// here really runs `git checkout` on the shared directory. Local branches
|
|
800
|
+
// switch directly; remote ones become a tracking branch of the same name.
|
|
801
|
+
function _openBranchPopover(anchor) {
|
|
802
|
+
const zh = _isZh();
|
|
803
|
+
const projectId = NewSessionStore.state.advanced.projectId;
|
|
804
|
+
if (!projectId) return;
|
|
805
|
+
|
|
806
|
+
let busy = false;
|
|
807
|
+
|
|
808
|
+
_showCtxPopover(anchor, (pop) => {
|
|
809
|
+
pop.classList.add("ns-branch-pop");
|
|
810
|
+
|
|
811
|
+
const header = document.createElement("div");
|
|
812
|
+
header.className = "ns-branch-search";
|
|
813
|
+
header.innerHTML =
|
|
814
|
+
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
815
|
+
'stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
|
|
816
|
+
const input = document.createElement("input");
|
|
817
|
+
input.type = "text";
|
|
818
|
+
input.className = "ns-branch-search-input";
|
|
819
|
+
input.placeholder = zh ? "ๆ็ดขๅๆฏ" : "Search branches";
|
|
820
|
+
input.autocomplete = "off";
|
|
821
|
+
input.spellcheck = false;
|
|
822
|
+
header.appendChild(input);
|
|
823
|
+
pop.appendChild(header);
|
|
824
|
+
|
|
825
|
+
const list = document.createElement("div");
|
|
826
|
+
list.className = "ns-branch-list";
|
|
827
|
+
pop.appendChild(list);
|
|
828
|
+
|
|
829
|
+
const foot = document.createElement("div");
|
|
830
|
+
foot.className = "ns-branch-foot";
|
|
831
|
+
pop.appendChild(foot);
|
|
832
|
+
|
|
833
|
+
const _showError = (msg) => {
|
|
834
|
+
Clacky.Modal.toast(msg, "error");
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const _checkout = async (branch, opts = {}) => {
|
|
838
|
+
if (busy) return;
|
|
839
|
+
busy = true;
|
|
840
|
+
list.classList.add("is-busy");
|
|
841
|
+
try {
|
|
842
|
+
const data = await Projects.checkoutBranch(projectId, {
|
|
843
|
+
branch,
|
|
844
|
+
dir: NewSessionStore.state.advanced.workingDir || "",
|
|
845
|
+
...opts,
|
|
846
|
+
});
|
|
847
|
+
NewSessionStore.applyGitState(data);
|
|
848
|
+
_dismissCtxPopover();
|
|
849
|
+
} catch (e) {
|
|
850
|
+
busy = false;
|
|
851
|
+
list.classList.remove("is-busy");
|
|
852
|
+
_showError(_branchErrorText(e.message, zh, branch));
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const _branchItem = (b, label, onClick) => {
|
|
857
|
+
const item = document.createElement("button");
|
|
858
|
+
item.type = "button";
|
|
859
|
+
item.className = "ns-ctx-popover-item ns-branch-item" + (b.current ? " active" : "");
|
|
860
|
+
const icon =
|
|
861
|
+
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
862
|
+
'stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/>' +
|
|
863
|
+
'<circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 3-2.5 4.5-6 5"/></svg>';
|
|
864
|
+
const check = b.current
|
|
865
|
+
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" ' +
|
|
866
|
+
'stroke-linecap="round" stroke-linejoin="round"><path d="m20 6-11 11-5-5"/></svg>'
|
|
867
|
+
: "";
|
|
868
|
+
item.innerHTML =
|
|
869
|
+
`<span class="ns-branch-item-icon">${icon}</span>` +
|
|
870
|
+
`<span class="ns-branch-item-label">${_escHtml(label)}</span>` +
|
|
871
|
+
`<span class="ns-branch-item-check">${check}</span>`;
|
|
872
|
+
item.title = label;
|
|
873
|
+
item.addEventListener("click", onClick);
|
|
874
|
+
return item;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
const _localName = (ref) => ref.replace(/^[^/]+\//, "");
|
|
878
|
+
|
|
879
|
+
const renderList = () => {
|
|
880
|
+
const q = input.value.trim().toLowerCase();
|
|
881
|
+
const branches = NewSessionStore.state.git.branches || [];
|
|
882
|
+
const local = branches.filter(b => !b.remote && b.name.toLowerCase().includes(q));
|
|
883
|
+
const remote = branches.filter(b => b.remote && b.name.toLowerCase().includes(q));
|
|
884
|
+
const currentRef = NewSessionStore.state.git.branch;
|
|
885
|
+
const localNames = new Set(branches.filter(b => !b.remote).map(b => b.name));
|
|
886
|
+
|
|
887
|
+
list.innerHTML = "";
|
|
888
|
+
list.scrollTop = 0;
|
|
889
|
+
|
|
890
|
+
if (!local.length && !remote.length) {
|
|
891
|
+
const empty = document.createElement("div");
|
|
892
|
+
empty.className = "ns-ctx-popover-empty";
|
|
893
|
+
empty.textContent = q
|
|
894
|
+
? (zh ? "ๆฒกๆๅน้
็ๅๆฏ" : "No matching branches")
|
|
895
|
+
: (zh ? "ๆๆ ๅๆฏ" : "No branches");
|
|
896
|
+
list.appendChild(empty);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const addGroup = (title, items, toItem) => {
|
|
901
|
+
if (!items.length) return;
|
|
902
|
+
const label = document.createElement("div");
|
|
903
|
+
label.className = "ns-branch-group";
|
|
904
|
+
label.textContent = title;
|
|
905
|
+
list.appendChild(label);
|
|
906
|
+
items.forEach(b => list.appendChild(toItem(b)));
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
addGroup(zh ? "ๅๆฏ" : "Branches", local, (b) => {
|
|
910
|
+
const isCurrent = b.name === currentRef || b.current;
|
|
911
|
+
return _branchItem({ current: isCurrent }, b.name, () => {
|
|
912
|
+
if (isCurrent) { _dismissCtxPopover(); return; }
|
|
913
|
+
_checkout(b.name);
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
addGroup(zh ? "่ฟ็จ" : "Remote", remote, (b) => {
|
|
918
|
+
// A remote ref is not a thing HEAD can point at, so picking one
|
|
919
|
+
// materialises a local branch of the same name (or reuses it).
|
|
920
|
+
const short = _localName(b.name);
|
|
921
|
+
const existsLocally = localNames.has(short);
|
|
922
|
+
const item = _branchItem({ current: false }, b.name, () => {
|
|
923
|
+
if (existsLocally) _checkout(short);
|
|
924
|
+
else _checkout(short, { create: true, from: b.name });
|
|
925
|
+
});
|
|
926
|
+
item.classList.add("is-remote");
|
|
927
|
+
return item;
|
|
928
|
+
});
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// Naming a new branch needs room for the prefix rule and validation
|
|
932
|
+
// messages, so it opens in a modal instead of an inline field here.
|
|
933
|
+
const createBtn = document.createElement("button");
|
|
934
|
+
createBtn.type = "button";
|
|
935
|
+
createBtn.className = "ns-ctx-popover-item ns-branch-create";
|
|
936
|
+
createBtn.innerHTML =
|
|
937
|
+
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
938
|
+
'stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>' +
|
|
939
|
+
`<span>${zh ? "ๅๅปบๅนถๆฃๅบๅๆฏโฆ" : "Create and check outโฆ"}</span>`;
|
|
940
|
+
createBtn.addEventListener("click", () => {
|
|
941
|
+
_dismissCtxPopover();
|
|
942
|
+
_openCreateBranchModal();
|
|
943
|
+
});
|
|
944
|
+
foot.appendChild(createBtn);
|
|
945
|
+
|
|
946
|
+
input.addEventListener("input", renderList);
|
|
947
|
+
input.addEventListener("keydown", (e) => {
|
|
948
|
+
if (e.key === "Escape") { e.preventDefault(); _dismissCtxPopover(); }
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
renderList();
|
|
952
|
+
setTimeout(() => input.focus(), 30);
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// โโ Create-branch modal โโ
|
|
957
|
+
// Mirrors `git check-ref-format --branch` locally so most mistakes are caught
|
|
958
|
+
// before the round trip; git's own message is shown for anything missed.
|
|
959
|
+
function _branchNameError(name, zh) {
|
|
960
|
+
if (!name) return zh ? "่ฏท่พๅ
ฅๅๆฏๅ็งฐใ" : "Please enter a branch name.";
|
|
961
|
+
if (name.endsWith("/")) return zh ? 'ๅๆฏๅไธ่ฝไปฅ"/"็ปๅฐพใ' : 'Branch name cannot end with "/".';
|
|
962
|
+
if (name.startsWith("/")) return zh ? 'ๅๆฏๅไธ่ฝไปฅ"/"ๅผๅคดใ' : 'Branch name cannot start with "/".';
|
|
963
|
+
if (name.startsWith("-")) return zh ? 'ๅๆฏๅไธ่ฝไปฅ"-"ๅผๅคดใ' : 'Branch name cannot start with "-".';
|
|
964
|
+
if (name.indexOf("//") !== -1) return zh ? 'ๅๆฏๅไธ่ฝๅ
ๅซ"//"ใ' : 'Branch name cannot contain "//".';
|
|
965
|
+
if (name.indexOf("..") !== -1) return zh ? 'ๅๆฏๅไธ่ฝๅ
ๅซ".."ใ' : 'Branch name cannot contain "..".';
|
|
966
|
+
if (name.endsWith(".")) return zh ? 'ๅๆฏๅไธ่ฝไปฅ"."็ปๅฐพใ' : 'Branch name cannot end with "."';
|
|
967
|
+
if (name.endsWith(".lock")) return zh ? 'ๅๆฏๅไธ่ฝไปฅ".lock"็ปๅฐพใ' : 'Branch name cannot end with ".lock".';
|
|
968
|
+
if (/[\s~^:?*\[\\\x00-\x1f\x7f]/.test(name)) {
|
|
969
|
+
return zh
|
|
970
|
+
? "ๅๆฏๅไธ่ฝๅ
ๅซ็ฉบๆ ผๆ ~ ^ : ? * [ \\ ็ญๅญ็ฌฆใ"
|
|
971
|
+
: "Branch name cannot contain spaces or characters like ~ ^ : ? * [ \\.";
|
|
972
|
+
}
|
|
973
|
+
return "";
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// git reports checkout failures on stderr in English and with a file list that
|
|
977
|
+
// would swamp the popover, so the common cases are rewritten for the active
|
|
978
|
+
// language; anything unrecognised is passed through untouched.
|
|
979
|
+
function _branchErrorText(raw, zh, branch, verb = "ๅๆขๅๆฏ") {
|
|
980
|
+
const msg = String(raw || "").trim();
|
|
981
|
+
if (!zh) return msg || "Failed";
|
|
982
|
+
if (!msg) return `${verb}ๅคฑ่ดฅใ`;
|
|
983
|
+
|
|
984
|
+
if (/would be overwritten by checkout|would be overwritten by merge/i.test(msg)) {
|
|
985
|
+
const files = msg
|
|
986
|
+
.slice(msg.indexOf(":") + 1)
|
|
987
|
+
.split("\n")
|
|
988
|
+
.map(line => line.trim())
|
|
989
|
+
.filter(line => line && !/^(Please|error:|hint:|Aborting)/i.test(line));
|
|
990
|
+
if (!files.length) return "ๆฌๅฐๆๆชๆไบค็ไฟฎๆน๏ผๅๆขๅๆฏไผ่ขซ่ฆ็ใ่ฏทๅ
ๆไบคๆๆๅญๅๅ่ฏใ";
|
|
991
|
+
const shown = files.slice(0, 5);
|
|
992
|
+
const more = files.length > shown.length ? `\nโฆ๏ผๅ
ฑ ${files.length} ไธชๆไปถ๏ผ` : "";
|
|
993
|
+
return `ๆฌๅฐๆๆชๆไบค็ไฟฎๆน๏ผๅๆขๅๆฏไผ่ขซ่ฆ็๏ผ\n${shown.join("\n")}${more}\n่ฏทๅ
ๆไบคๆๆๅญๅๅ่ฏใ`;
|
|
994
|
+
}
|
|
995
|
+
if (/untracked working tree files would be overwritten/i.test(msg)) {
|
|
996
|
+
return "ๆฌๅฐๆๆช่ท่ธช็ๆไปถไผ่ขซ่ฆ็๏ผ่ฏทๅ
็งป่ตฐๆๆไบคๅๅ่ฏใ";
|
|
997
|
+
}
|
|
998
|
+
if (/a branch named '.+' already exists/i.test(msg)) {
|
|
999
|
+
const found = msg.match(/a branch named '(.+)' already exists/i);
|
|
1000
|
+
return `ๅๆฏ ${found ? found[1] : branch || ""} ๅทฒๅญๅจใ`;
|
|
1001
|
+
}
|
|
1002
|
+
if (/not a valid branch name/i.test(msg)) return "ๅๆฏๅไธๅๆณใ";
|
|
1003
|
+
if (/couldn't find remote ref/i.test(msg)) return "่ฟ็จๅๆฏไธๅญๅจใ";
|
|
1004
|
+
if (/did not match any file\(s\) known to git|pathspec .+ did not match/i.test(msg)) {
|
|
1005
|
+
return "ๆพไธๅฐ่ฏฅๅๆฏใ";
|
|
1006
|
+
}
|
|
1007
|
+
if (/not a git repository/i.test(msg)) return "่ฏฅ็ฎๅฝไธๆฏ Git ไปๅบใ";
|
|
1008
|
+
if (/you need to resolve your current index first/i.test(msg)) {
|
|
1009
|
+
return "ๅฝๅๆๆช่งฃๅณ็ๅๅนถๅฒ็ช๏ผ่ฏทๅ
ๅค็ๅๅๅๆขๅๆฏใ";
|
|
1010
|
+
}
|
|
1011
|
+
return `${verb}ๅคฑ่ดฅ๏ผ${msg}`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function _openCreateBranchModal() {
|
|
1015
|
+
const zh = _isZh();
|
|
1016
|
+
const projectId = NewSessionStore.state.advanced.projectId;
|
|
1017
|
+
if (!projectId) return;
|
|
1018
|
+
|
|
1019
|
+
let prefix = "";
|
|
1020
|
+
let busy = false;
|
|
1021
|
+
|
|
1022
|
+
const overlay = document.createElement("div");
|
|
1023
|
+
overlay.className = "modal-overlay";
|
|
1024
|
+
overlay.innerHTML =
|
|
1025
|
+
'<div class="modal-box sm ns-branch-modal" role="dialog" aria-modal="true">' +
|
|
1026
|
+
'<div class="modal-header">' +
|
|
1027
|
+
`<h3 class="modal-title">${zh ? "ๅๅปบๅนถๆฃๅบๅๆฏ" : "Create and check out branch"}</h3>` +
|
|
1028
|
+
'<button type="button" class="modal-close-btn ns-branch-modal-close" aria-label="Close"></button>' +
|
|
1029
|
+
"</div>" +
|
|
1030
|
+
'<div class="modal-body">' +
|
|
1031
|
+
'<div class="modal-field">' +
|
|
1032
|
+
'<div class="ns-branch-field-head">' +
|
|
1033
|
+
`<span class="modal-label">${zh ? "ๅๆฏๅ็งฐ" : "Branch name"}</span>` +
|
|
1034
|
+
`<button type="button" class="ns-branch-prefix-btn">${zh ? "่ฎพ็ฝฎๅ็ผ" : "Set prefix"}</button>` +
|
|
1035
|
+
"</div>" +
|
|
1036
|
+
'<div class="ns-branch-name-row">' +
|
|
1037
|
+
'<span class="ns-branch-prefix-chip" hidden></span>' +
|
|
1038
|
+
'<input type="text" class="ns-branch-name-input" autocomplete="off" spellcheck="false"' +
|
|
1039
|
+
` aria-label="${zh ? "ๅๆฏๅ็งฐ" : "Branch name"}"` +
|
|
1040
|
+
` placeholder="${zh ? "ไพๅฆ feature/login" : "e.g. feature/login"}">` +
|
|
1041
|
+
"</div>" +
|
|
1042
|
+
'<div class="ns-branch-modal-error" hidden></div>' +
|
|
1043
|
+
"</div>" +
|
|
1044
|
+
"</div>" +
|
|
1045
|
+
'<div class="modal-footer">' +
|
|
1046
|
+
`<button type="button" class="btn-secondary ns-branch-modal-cancel">${zh ? "ๅ
ณ้ญ" : "Close"}</button>` +
|
|
1047
|
+
`<button type="button" class="btn-primary ns-branch-modal-submit">${zh ? "ๅๅปบๅนถๆฃๅบ" : "Create and check out"}</button>` +
|
|
1048
|
+
"</div>" +
|
|
1049
|
+
"</div>";
|
|
1050
|
+
document.body.appendChild(overlay);
|
|
1051
|
+
|
|
1052
|
+
const input = overlay.querySelector(".ns-branch-name-input");
|
|
1053
|
+
const prefixChip = overlay.querySelector(".ns-branch-prefix-chip");
|
|
1054
|
+
const prefixBtn = overlay.querySelector(".ns-branch-prefix-btn");
|
|
1055
|
+
const nameRow = overlay.querySelector(".ns-branch-name-row");
|
|
1056
|
+
const errorEl = overlay.querySelector(".ns-branch-modal-error");
|
|
1057
|
+
const submitBtn = overlay.querySelector(".ns-branch-modal-submit");
|
|
1058
|
+
const cancelBtn = overlay.querySelector(".ns-branch-modal-cancel");
|
|
1059
|
+
const closeBtn = overlay.querySelector(".ns-branch-modal-close");
|
|
1060
|
+
|
|
1061
|
+
const fullName = () => (prefix + input.value.trim()).trim();
|
|
1062
|
+
|
|
1063
|
+
const setError = (msg) => {
|
|
1064
|
+
errorEl.textContent = msg;
|
|
1065
|
+
errorEl.hidden = !msg;
|
|
1066
|
+
nameRow.classList.toggle("is-invalid", !!msg);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
const sync = () => {
|
|
1070
|
+
const name = input.value.trim();
|
|
1071
|
+
prefixChip.hidden = !prefix;
|
|
1072
|
+
prefixChip.textContent = prefix;
|
|
1073
|
+
prefixBtn.textContent = prefix
|
|
1074
|
+
? (zh ? "ๅๆถๅ็ผ" : "Clear prefix")
|
|
1075
|
+
: (zh ? "่ฎพ็ฝฎๅ็ผ" : "Set prefix");
|
|
1076
|
+
prefixBtn.disabled = busy || (!prefix && !name);
|
|
1077
|
+
submitBtn.disabled = busy || !name;
|
|
1078
|
+
setError(name ? _branchNameError(fullName(), zh) : "");
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
const close = () => {
|
|
1082
|
+
unbindEnter();
|
|
1083
|
+
document.removeEventListener("keydown", onKeydown);
|
|
1084
|
+
overlay.remove();
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
const onKeydown = (e) => {
|
|
1088
|
+
if (e.key === "Escape") { e.preventDefault(); close(); }
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
const submit = async () => {
|
|
1092
|
+
if (busy) return;
|
|
1093
|
+
const branch = fullName();
|
|
1094
|
+
const err = _branchNameError(branch, zh);
|
|
1095
|
+
if (err) { setError(err); input.focus(); return; }
|
|
1096
|
+
|
|
1097
|
+
busy = true;
|
|
1098
|
+
const label = submitBtn.textContent;
|
|
1099
|
+
submitBtn.textContent = zh ? "ๅๅปบไธญโฆ" : "Creatingโฆ";
|
|
1100
|
+
sync();
|
|
1101
|
+
try {
|
|
1102
|
+
const data = await Projects.checkoutBranch(projectId, {
|
|
1103
|
+
branch,
|
|
1104
|
+
create: true,
|
|
1105
|
+
dir: NewSessionStore.state.advanced.workingDir || "",
|
|
1106
|
+
});
|
|
1107
|
+
NewSessionStore.applyGitState(data);
|
|
1108
|
+
close();
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
busy = false;
|
|
1111
|
+
submitBtn.textContent = label;
|
|
1112
|
+
sync();
|
|
1113
|
+
setError(_branchErrorText(e.message, zh, branch, "ๅๅปบๅๆฏ"));
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
prefixBtn.addEventListener("click", () => {
|
|
1118
|
+
const name = input.value.trim();
|
|
1119
|
+
if (prefix) {
|
|
1120
|
+
input.value = prefix + name;
|
|
1121
|
+
prefix = "";
|
|
1122
|
+
} else {
|
|
1123
|
+
if (!name) return;
|
|
1124
|
+
const cut = name.lastIndexOf("/");
|
|
1125
|
+
if (cut > 0) {
|
|
1126
|
+
prefix = name.slice(0, cut + 1);
|
|
1127
|
+
input.value = name.slice(cut + 1);
|
|
1128
|
+
} else {
|
|
1129
|
+
prefix = name + "/";
|
|
1130
|
+
input.value = "";
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
input.focus();
|
|
1134
|
+
sync();
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
input.addEventListener("input", sync);
|
|
1138
|
+
overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) close(); });
|
|
1139
|
+
submitBtn.addEventListener("click", submit);
|
|
1140
|
+
cancelBtn.addEventListener("click", close);
|
|
1141
|
+
closeBtn.addEventListener("click", close);
|
|
1142
|
+
document.addEventListener("keydown", onKeydown);
|
|
1143
|
+
const unbindEnter = IME.bindEnter(input, submit);
|
|
1144
|
+
|
|
1145
|
+
sync();
|
|
1146
|
+
setTimeout(() => input.focus(), 50);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
802
1149
|
// โโ Render context bar โโ
|
|
1150
|
+
// Chips are created once and updated in place. Rebuilding the row on every
|
|
1151
|
+
// store event made it flash during a project switch: the branch chip dropped
|
|
1152
|
+
// out until git info arrived, which shoved the model and init chips sideways,
|
|
1153
|
+
// and the row was repainted two or three times per switch.
|
|
1154
|
+
const CTX_CHIP_ORDER = ["name", "project", "dir", "branch", "model", "init"];
|
|
1155
|
+
|
|
1156
|
+
const _ctxSvg = (body) =>
|
|
1157
|
+
`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
|
1158
|
+
|
|
1159
|
+
const CTX_ICONS = {
|
|
1160
|
+
name: _ctxSvg(`<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>`),
|
|
1161
|
+
project: _ctxSvg(`<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"/>`),
|
|
1162
|
+
dir: _ctxSvg(`<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/>`),
|
|
1163
|
+
branch: _ctxSvg(`<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 3-2.5 4.5-6 5"/>`),
|
|
1164
|
+
model: _ctxSvg(`<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>`),
|
|
1165
|
+
init: _ctxSvg(`<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>`),
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
let _ctxChips = null;
|
|
1169
|
+
|
|
1170
|
+
function _setChipLabel(chip, label) {
|
|
1171
|
+
const text = chip.querySelector(".ns-ctx-chip-label");
|
|
1172
|
+
if (text && text.textContent !== label) text.textContent = label;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function _setChipIcon(chip, icon, cacheKey) {
|
|
1176
|
+
if (!icon) return;
|
|
1177
|
+
if (cacheKey && chip.dataset.iconKey === cacheKey) return;
|
|
1178
|
+
const slot = chip.querySelector(".ns-ctx-chip-icon");
|
|
1179
|
+
if (slot) {
|
|
1180
|
+
if (icon instanceof Node) {
|
|
1181
|
+
slot.innerHTML = "";
|
|
1182
|
+
slot.appendChild(icon);
|
|
1183
|
+
slot.classList.add("ns-ctx-chip-icon--badge");
|
|
1184
|
+
} else if (slot.innerHTML !== icon) {
|
|
1185
|
+
slot.innerHTML = icon;
|
|
1186
|
+
slot.classList.remove("ns-ctx-chip-icon--badge");
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (cacheKey) chip.dataset.iconKey = cacheKey;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function _ensureCtxChips(bar) {
|
|
1193
|
+
if (_ctxChips && _ctxChips.name.parentElement === bar) return _ctxChips;
|
|
1194
|
+
bar.innerHTML = "";
|
|
1195
|
+
_ctxChips = {};
|
|
1196
|
+
CTX_CHIP_ORDER.forEach((role) => {
|
|
1197
|
+
const onClick = {
|
|
1198
|
+
name: _openNamePopover,
|
|
1199
|
+
project: _openProjectPopover,
|
|
1200
|
+
dir: _openDirPopover,
|
|
1201
|
+
branch: _openBranchPopover,
|
|
1202
|
+
model: _openModelPopover,
|
|
1203
|
+
init: _toggleInitProject,
|
|
1204
|
+
}[role];
|
|
1205
|
+
const chip = _makeChip(CTX_ICONS[role], "", onClick);
|
|
1206
|
+
chip.dataset.ctxRole = role;
|
|
1207
|
+
_ctxChips[role] = chip;
|
|
1208
|
+
bar.appendChild(chip);
|
|
1209
|
+
});
|
|
1210
|
+
_ctxChips.branch.id = "ns-chip-branch";
|
|
1211
|
+
return _ctxChips;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
803
1214
|
function _renderContextBar() {
|
|
804
1215
|
const bar = $("ns-context-bar");
|
|
805
1216
|
if (!bar) return;
|
|
806
|
-
|
|
1217
|
+
const chips = _ensureCtxChips(bar);
|
|
807
1218
|
|
|
808
1219
|
const zh = _isZh();
|
|
809
1220
|
const agent = NewSessionStore.currentAgent();
|
|
810
1221
|
const adv = NewSessionStore.state.advanced;
|
|
811
1222
|
|
|
1223
|
+
// Session name chip โ leads the bar: naming the session is the first thing
|
|
1224
|
+
// people do here, and every other chip is a setting they rarely touch.
|
|
1225
|
+
const nameVal = adv.name || "";
|
|
1226
|
+
_setChipLabel(chips.name, nameVal || (zh ? "ไผ่ฏๅ็งฐ" : "Name"));
|
|
1227
|
+
chips.name.classList.toggle("ns-ctx-chip--muted", !nameVal);
|
|
1228
|
+
|
|
812
1229
|
// Project chip
|
|
813
1230
|
const projectId = adv.projectId;
|
|
814
1231
|
const project = projectId && typeof Projects !== "undefined" && Projects.find
|
|
815
1232
|
? Projects.find(projectId)
|
|
816
1233
|
: null;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1234
|
+
_setChipLabel(chips.project, project ? project.name : (zh ? "้ๆฉ้กน็ฎ" : "Project"));
|
|
1235
|
+
chips.project.classList.toggle("ns-ctx-chip--muted", !projectId);
|
|
1236
|
+
|
|
1237
|
+
// Working dir chip โ a terminal, not a folder: the folder glyph is already
|
|
1238
|
+
// the project chip's, and two folders in a row read as the same thing.
|
|
1239
|
+
const dirVal = adv.workingDir || "";
|
|
1240
|
+
const dirShort = dirVal
|
|
1241
|
+
? dirVal.replace(/^.*[\/]([^\/]+[\/][^\/]*)$/, "$1").replace(/^.*[\/]/, "").slice(-32)
|
|
1242
|
+
: "";
|
|
1243
|
+
_setChipLabel(chips.dir, dirShort || (zh ? "ๅทฅไฝ็ฎๅฝ" : "Directory"));
|
|
1244
|
+
chips.dir.classList.toggle("ns-ctx-chip--muted", !dirVal);
|
|
1245
|
+
|
|
1246
|
+
// Branch chip โ coding agent on a git-backed project. The work tree's
|
|
1247
|
+
// branch is shared state, and the session's working dir can point at a
|
|
1248
|
+
// different repo than the project's, so the key is project + dir.
|
|
1249
|
+
const showBranch = !!(agent && agent.id === "coding" && projectId);
|
|
1250
|
+
const git = NewSessionStore.state.git || {};
|
|
1251
|
+
const gitKey = showBranch ? NewSessionStore.gitKey(projectId, dirVal) : "";
|
|
1252
|
+
const gitKnown = showBranch && git.loaded && git.key === gitKey;
|
|
1253
|
+
|
|
1254
|
+
if (showBranch && !gitKnown && !git.loading) {
|
|
1255
|
+
NewSessionStore.loadGitInfo(projectId, { dir: dirVal });
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Only a confirmed non-repo hides the chip; while loading it stays in place
|
|
1259
|
+
// so the chips after it don't shift.
|
|
1260
|
+
chips.branch.hidden = !showBranch || (gitKnown && !git.repo);
|
|
1261
|
+
if (showBranch) {
|
|
1262
|
+
// Keep the previous branch name while a switch is in flight โ swapping in
|
|
1263
|
+
// a placeholder and then the real name reads as two separate flickers.
|
|
1264
|
+
const branchLabel = gitKnown
|
|
1265
|
+
? (git.branch || (zh ? "ๅๆฏ" : "Branch"))
|
|
1266
|
+
: (chips.branch.querySelector(".ns-ctx-chip-label").textContent || (zh ? "ๅๆฏ" : "Branch"));
|
|
1267
|
+
_setChipLabel(chips.branch, branchLabel);
|
|
1268
|
+
chips.branch.classList.toggle("ns-ctx-chip--muted", !gitKnown || !git.branch);
|
|
1269
|
+
chips.branch.title = gitKnown
|
|
1270
|
+
? (zh ? "ๅๆขๆๅๅปบๅๆฏ" : "Switch or create a branch")
|
|
1271
|
+
: (zh ? "ๆญฃๅจ่ฏปๅๅๆฏโฆ" : "Loading branchโฆ");
|
|
1272
|
+
}
|
|
832
1273
|
|
|
833
1274
|
// Model chip โ show selected model name from store (strip prefix/suffix for brevity)
|
|
834
1275
|
let modelLabel = zh ? "ๆจกๅ" : "Model";
|
|
@@ -842,29 +1283,19 @@ const NewSessionView = (() => {
|
|
|
842
1283
|
modelLabel = modelRaw.length > 28 ? modelRaw.slice(0, 26) + "โฆ" : modelRaw;
|
|
843
1284
|
}
|
|
844
1285
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const dirVal = adv.workingDir || "";
|
|
852
|
-
const dirShort = dirVal
|
|
853
|
-
? dirVal.replace(/^.*[\/]([^\/]+[\/][^\/]*)$/, "$1").replace(/^.*[\/]/, "").slice(-32)
|
|
854
|
-
: (zh ? "ๅทฅไฝ็ฎๅฝ" : "Directory");
|
|
855
|
-
const dirIcon = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>`;
|
|
856
|
-
const dirChip = _makeChip(dirIcon, dirShort || (zh ? "ๅทฅไฝ็ฎๅฝ" : "Directory"), _openDirPopover);
|
|
857
|
-
if (!dirVal) dirChip.classList.add("ns-ctx-chip--muted");
|
|
858
|
-
bar.appendChild(dirChip);
|
|
1286
|
+
_setChipLabel(chips.model, modelLabel);
|
|
1287
|
+
_setChipIcon(
|
|
1288
|
+
chips.model,
|
|
1289
|
+
modelRaw && typeof ModelPicker !== "undefined" ? ModelPicker.vendorBadge(modelRaw) : CTX_ICONS.model,
|
|
1290
|
+
modelRaw || "default"
|
|
1291
|
+
);
|
|
859
1292
|
|
|
860
1293
|
// Init project chip โ only for coding agent
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
if (active) initChip.classList.add("ns-ctx-chip--active");
|
|
867
|
-
bar.appendChild(initChip);
|
|
1294
|
+
const showInit = !!(agent && agent.id === "coding");
|
|
1295
|
+
chips.init.hidden = !showInit;
|
|
1296
|
+
if (showInit) {
|
|
1297
|
+
_setChipLabel(chips.init, zh ? "ๅๅงๅ้กน็ฎ" : "Init project");
|
|
1298
|
+
chips.init.classList.toggle("ns-ctx-chip--active", adv.initProject === true);
|
|
868
1299
|
}
|
|
869
1300
|
}
|
|
870
1301
|
|
|
@@ -943,6 +1374,7 @@ const NewSessionView = (() => {
|
|
|
943
1374
|
_renderContextBar();
|
|
944
1375
|
});
|
|
945
1376
|
NewSessionStore.on("newSession:creating", _updateSendButton);
|
|
1377
|
+
NewSessionStore.on("newSession:git-changed", _renderContextBar);
|
|
946
1378
|
|
|
947
1379
|
const input = $("new-session-input");
|
|
948
1380
|
if (input) {
|
|
@@ -1031,6 +1463,12 @@ const NewSessionView = (() => {
|
|
|
1031
1463
|
// projectId from localStorage, which decides whether the working dir is
|
|
1032
1464
|
// prefilled from the project or from the global default.
|
|
1033
1465
|
await _prefillDefaultDir();
|
|
1466
|
+
// Another session may have switched the work tree's branch while this page
|
|
1467
|
+
// was hidden, so the cached value is refreshed on every show.
|
|
1468
|
+
await NewSessionStore.loadGitInfo(NewSessionStore.state.advanced.projectId, {
|
|
1469
|
+
dir: NewSessionStore.state.advanced.workingDir || "",
|
|
1470
|
+
force: true,
|
|
1471
|
+
});
|
|
1034
1472
|
_renderContextBar();
|
|
1035
1473
|
_updateSendButton();
|
|
1036
1474
|
const input = $("new-session-input");
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
24
24
|
|
|
25
25
|
const SkillsStore = (() => {
|
|
26
|
-
const SKILL_AC_SHOW_SYSTEM_KEY = "skill-ac-show-system";
|
|
26
|
+
const SKILL_AC_SHOW_SYSTEM_KEY = "clacky-skill-ac-show-system";
|
|
27
27
|
|
|
28
28
|
// โโ State (single source of truth) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
29
29
|
let _skills = []; // [{ name, description, source, enabled }]
|