@orangeworks/orangetree 0.4.0

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.
@@ -0,0 +1,75 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Orange Tree</title>
7
+ <script>
8
+ /* Set theme + UI locale before render (no flash). theme 'system' = no attribute -> OS decides. */
9
+ (function () { try {
10
+ var th = localStorage.getItem('otree-theme'); if (th === 'dark' || th === 'light') document.documentElement.setAttribute('data-theme', th);
11
+ var lo = localStorage.getItem('otree-locale'); if (lo === 'ko' || lo === 'en' || lo === 'ja') document.documentElement.lang = lo;
12
+ } catch (e) {} })();
13
+ </script>
14
+ <link rel="icon" type="image/svg+xml" href="/logo.svg">
15
+ <link rel="stylesheet" href="/style.css">
16
+ </head>
17
+ <body>
18
+ <header id="toolbar">
19
+ <h1><img src="/logo.svg" alt="" width="26" height="26" style="vertical-align:-6px;margin-right:4px"> Orange Tree</h1>
20
+ <select id="sel-lang" data-i18n-title="toolbar.lang.title">
21
+ <option value="" data-i18n="toolbar.lang.auto">🌐 μžλ™</option>
22
+ <option value="ko">πŸ‡°πŸ‡· ν•œκ΅­μ–΄</option>
23
+ <option value="en">πŸ‡ΊπŸ‡Έ English</option>
24
+ <option value="ja">πŸ‡―πŸ‡΅ ζ—₯本θͺž</option>
25
+ </select>
26
+ <select id="sel-style" data-i18n-title="toolbar.style.title">
27
+ <option value="" data-i18n="toolbar.style.auto">πŸ’¬ 말투 μžλ™</option>
28
+ <option value="formal" data-i18n="toolbar.style.formal">🎩 정쀑체</option>
29
+ <option value="casual" data-i18n="toolbar.style.casual">πŸ™‚ 반말체</option>
30
+ <option value="concise" data-i18n="toolbar.style.concise">⚑ κ°„κ²°</option>
31
+ </select>
32
+ <select id="sel-theme" data-i18n-title="toolbar.theme.title">
33
+ <option value="system" data-i18n="toolbar.theme.system">πŸ–₯ ν…Œλ§ˆ μžλ™</option>
34
+ <option value="light" data-i18n="toolbar.theme.light">β˜€ 밝게</option>
35
+ <option value="dark" data-i18n="toolbar.theme.dark">πŸŒ™ μ–΄λ‘‘κ²Œ</option>
36
+ </select>
37
+ <button id="btn-root" data-i18n="toolbar.addRoot" data-i18n-title="toolbar.addRoot.title">+ 루트</button>
38
+ <select id="sel-status" disabled data-i18n-title="status.title">
39
+ <option value="" data-i18n="status.placeholder">μƒνƒœβ€¦</option>
40
+ <option value="in_progress" data-i18n="status.in_progress">πŸƒ 진행쀑 (잎)</option>
41
+ <option value="blocked" data-i18n="status.blocked">🌰 차단됨 (씨앗)</option>
42
+ <option value="waiting_handoff" data-i18n="status.waiting_handoff">🌷 ν•Έλ“œμ˜€ν”„ (κ½ƒλ΄‰μ˜€λ¦¬)</option>
43
+ <option value="review" data-i18n="status.review">🌸 리뷰 (꽃)</option>
44
+ <option value="done" data-i18n="status.done">🍊 μ™„λ£Œ (μ—΄λ§€)</option>
45
+ </select>
46
+ <button id="btn-del" disabled data-i18n-title="toolbar.del.title">πŸ—‘</button>
47
+ <button id="btn-archive" data-i18n="toolbar.archive" data-i18n-title="toolbar.archive.title">πŸ—„ 보관함</button>
48
+ <span id="sel-info"></span>
49
+ <button id="sel-doc" hidden data-i18n-title="toolbar.openDoc.title">πŸ“„</button>
50
+ <span id="usage" class="usage" hidden></span>
51
+ <button id="btn-settings" hidden data-i18n-title="toolbar.settings.title">βš™</button>
52
+ <button id="btn-pair" hidden data-i18n="toolbar.pair" data-i18n-title="toolbar.pair.title">πŸ”— μ—°κ²°</button>
53
+ <button id="btn-report" hidden data-i18n="toolbar.report" data-i18n-title="toolbar.report.title">🐞 μ‹ κ³ </button>
54
+ <button id="btn-quit" data-i18n="toolbar.quit" data-i18n-title="toolbar.quit.title">⏻ μ’…λ£Œ</button>
55
+ <span id="conn" class="conn">●</span>
56
+ </header>
57
+ <main>
58
+ <aside id="explorer" class="pane"></aside>
59
+ <div id="exp-resizer" data-i18n-title="resize.exp"></div>
60
+ <section id="canvas-wrap">
61
+ <div id="root-tabs" hidden></div>
62
+ <svg id="canvas" xmlns="http://www.w3.org/2000/svg"></svg>
63
+ <div id="zoom-ctrl">
64
+ <button id="zoom-out" data-i18n-title="zoom.out">βˆ’</button>
65
+ <button id="zoom-in" data-i18n-title="zoom.in">οΌ‹</button>
66
+ <button id="zoom-fit" data-i18n-title="zoom.fit">⊑</button>
67
+ <button id="zoom-arrange" data-i18n-title="zoom.arrange">πŸͺ„</button>
68
+ </div>
69
+ </section>
70
+ <div id="chat-resizer" data-i18n-title="resize.chat"></div>
71
+ <aside id="chat" class="pane"></aside>
72
+ </main>
73
+ <script type="module" src="/app.js"></script>
74
+ </body>
75
+ </html>
@@ -0,0 +1,40 @@
1
+ const isDoc = (f) => /\.md$/i.test(f) || /(^|[\\/])docs[\\/]/.test(f);
2
+ const baseName = (p) => {
3
+ const noPrefix = p.replace(/^f-\d+:/, "");
4
+ const parts = noPrefix.split(/[\\/]/);
5
+ return parts[parts.length - 1] || noPrefix;
6
+ };
7
+ const canon = (p) => p.replace(/^f-\d+:/, "");
8
+ const viewerKey = (node, rel) => /^f-\d+:/.test(rel) ? rel : node.cwdFolderId ? `${node.cwdFolderId}:${rel}` : rel;
9
+ function nodeOwnDocs(node) {
10
+ const out = [];
11
+ const seen = /* @__PURE__ */ new Set();
12
+ const push = (p) => {
13
+ if (!p) return;
14
+ const key = canon(p);
15
+ if (seen.has(key)) return;
16
+ seen.add(key);
17
+ out.push(p);
18
+ };
19
+ push(node.docPath);
20
+ for (const f of node.touchedFiles ?? []) if (isDoc(f)) push(viewerKey(node, f));
21
+ return out;
22
+ }
23
+ function relatedDocs(node, all) {
24
+ const out = nodeOwnDocs(node);
25
+ const seen = new Set(out.map(canon));
26
+ for (const c of all) {
27
+ if (c.parentId !== node.id || c.status !== "done" || !c.docPath) continue;
28
+ const key = canon(c.docPath);
29
+ if (seen.has(key)) continue;
30
+ seen.add(key);
31
+ out.push(c.docPath);
32
+ }
33
+ return out;
34
+ }
35
+ export {
36
+ baseName,
37
+ isDoc,
38
+ nodeOwnDocs,
39
+ relatedDocs
40
+ };
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
2
+ <circle cx="24" cy="26" r="13" fill="none" stroke="#E0612A" stroke-width="6.5"/>
3
+ <path d="M 21 7 Q 8 -1 6 13 Q 19 13 21 7 Z" fill="#2F6B3D"/>
4
+ <path d="M 24 5 Q 14 -6 5 4 Q 14 16 24 5 Z" fill="#4C9A5E"/>
5
+ </svg>
@@ -0,0 +1,103 @@
1
+ const ESC_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
2
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ESC_MAP[c] ?? c);
3
+ function inline(s) {
4
+ return s.replace(/`([^`\n]+)`/g, "<code>$1</code>").replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>").replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, url) => {
5
+ const href = /^[a-z][a-z0-9+.-]*:/i.test(url) && !/^(https?|mailto):/i.test(url) ? "#" : url;
6
+ return `<a href="${href}" target="_blank" rel="noopener">${label}</a>`;
7
+ });
8
+ }
9
+ const isTableSep = (l) => /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|\s*:?-{1,}:?\s*\|?\s*$/.test(l) && l.includes("|");
10
+ const splitRow = (l) => l.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim());
11
+ const isHr = (l) => /^\s*([-*_])(\s*\1){2,}\s*$/.test(l);
12
+ const isList = (l) => /^\s*([-*+]|\d+\.)\s+/.test(l);
13
+ const isOrdered = (l) => /^\s*\d+\.\s+/.test(l);
14
+ const isHeading = (l) => /^#{1,6}\s+/.test(l);
15
+ const isQuote = (l) => /^&gt;\s?/.test(l);
16
+ const startsTable = (lines, i) => /\|/.test(lines[i]) && i + 1 < lines.length && isTableSep(lines[i + 1]);
17
+ function opensBlock(lines, i) {
18
+ const l = lines[i];
19
+ return /^```/.test(l) || isHeading(l) || isQuote(l) || isList(l) || isHr(l) || startsTable(lines, i);
20
+ }
21
+ function render(src) {
22
+ if (src == null) return "";
23
+ const lines = esc(String(src)).replace(/\r\n?/g, "\n").split("\n");
24
+ const out = [];
25
+ let i = 0;
26
+ while (i < lines.length) {
27
+ const line = lines[i];
28
+ if (/^```/.test(line)) {
29
+ const body = [];
30
+ i++;
31
+ while (i < lines.length && !/^```/.test(lines[i])) {
32
+ body.push(lines[i]);
33
+ i++;
34
+ }
35
+ i++;
36
+ out.push(`<pre>${body.join("\n")}</pre>`);
37
+ continue;
38
+ }
39
+ if (startsTable(lines, i)) {
40
+ const header = splitRow(line);
41
+ i += 2;
42
+ const rows = [];
43
+ while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
44
+ rows.push(splitRow(lines[i]));
45
+ i++;
46
+ }
47
+ const th = header.map((c) => `<th>${inline(c)}</th>`).join("");
48
+ const trs = rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("");
49
+ out.push(`<table><thead><tr>${th}</tr></thead><tbody>${trs}</tbody></table>`);
50
+ continue;
51
+ }
52
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
53
+ if (h) {
54
+ const n = h[1].length;
55
+ out.push(`<h${n}>${inline(h[2])}</h${n}>`);
56
+ i++;
57
+ continue;
58
+ }
59
+ if (isQuote(line)) {
60
+ const body = [];
61
+ while (i < lines.length && isQuote(lines[i])) {
62
+ body.push(lines[i].replace(/^&gt;\s?/, ""));
63
+ i++;
64
+ }
65
+ out.push(`<blockquote>${inline(body.join("<br>"))}</blockquote>`);
66
+ continue;
67
+ }
68
+ if (isList(line)) {
69
+ const ordered = isOrdered(line);
70
+ const lis = [];
71
+ while (i < lines.length && isList(lines[i]) && isOrdered(lines[i]) === ordered) {
72
+ lis.push(`<li>${inline(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, ""))}</li>`);
73
+ i++;
74
+ }
75
+ out.push(ordered ? `<ol>${lis.join("")}</ol>` : `<ul>${lis.join("")}</ul>`);
76
+ continue;
77
+ }
78
+ if (isHr(line)) {
79
+ out.push("<hr>");
80
+ i++;
81
+ continue;
82
+ }
83
+ if (line.trim() === "") {
84
+ i++;
85
+ continue;
86
+ }
87
+ const para = [];
88
+ while (i < lines.length && lines[i].trim() !== "" && !opensBlock(lines, i)) {
89
+ para.push(lines[i]);
90
+ i++;
91
+ }
92
+ out.push(`<p>${inline(para.join("<br>"))}</p>`);
93
+ }
94
+ return out.join("");
95
+ }
96
+ const md = render;
97
+ const mdDoc = render;
98
+ export {
99
+ esc,
100
+ md,
101
+ mdDoc,
102
+ render
103
+ };
@@ -0,0 +1,114 @@
1
+ import { api } from "./api.js";
2
+ import { t } from "./i18n.js";
3
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
4
+ const formatCode = (code) => code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
5
+ function failureMessage(code) {
6
+ if (code === "start_fail") return t("pair.start.fail");
7
+ const known = /* @__PURE__ */ new Set([
8
+ "denied",
9
+ "expired",
10
+ "no_subscription",
11
+ "limit_reached",
12
+ "session_expired",
13
+ "cloud_error",
14
+ "network",
15
+ "provision_failed"
16
+ ]);
17
+ return known.has(code ?? "") ? t(`pair.failed.${code}`) : t("pair.failed.cloud_error");
18
+ }
19
+ function openPairingPanel() {
20
+ const o = document.createElement("div");
21
+ o.className = "ui-overlay";
22
+ const box = document.createElement("div");
23
+ box.className = "ui-modal";
24
+ box.style.width = "min(460px, 94vw)";
25
+ o.append(box);
26
+ document.body.append(o);
27
+ let timer = null;
28
+ let closed = false;
29
+ let phase = "idle";
30
+ const stopPoll = () => {
31
+ if (timer !== null) {
32
+ clearTimeout(timer);
33
+ timer = null;
34
+ }
35
+ };
36
+ const close = () => {
37
+ if (closed) return;
38
+ closed = true;
39
+ stopPoll();
40
+ document.removeEventListener("keydown", onKey);
41
+ if (phase === "awaiting-approval" || phase === "provisioning") {
42
+ void api("POST", "/pairing/cancel").catch(() => {
43
+ });
44
+ }
45
+ o.remove();
46
+ };
47
+ const onKey = (e) => {
48
+ if (e.key === "Escape") close();
49
+ };
50
+ document.addEventListener("keydown", onKey);
51
+ o.onclick = (e) => {
52
+ if (e.target === o) close();
53
+ };
54
+ const schedule = (s) => {
55
+ if (s.phase === "awaiting-approval" || s.phase === "provisioning") {
56
+ timer = window.setTimeout(() => void poll(), 2e3);
57
+ }
58
+ };
59
+ const render = (s) => {
60
+ phase = s.phase;
61
+ let bodyHtml = "";
62
+ if (s.phase === "awaiting-approval") {
63
+ const secs = s.expiresAt ? Math.max(0, Math.round((s.expiresAt - Date.now()) / 1e3)) : 0;
64
+ bodyHtml = `<p class="ui-msg">${esc(t("pair.intro"))}</p>
65
+ <div class="pair-code">${esc(formatCode(s.userCode ?? ""))}</div>
66
+ ${s.verificationUrl ? `<a class="ui-ok pair-approve" href="${esc(s.verificationUrl)}" target="_blank" rel="noopener">${esc(t("pair.approve"))}</a>` : ""}
67
+ <div class="ui-hint">${esc(t("pair.approve.hint"))}</div>
68
+ ${s.verificationUrl ? `<div class="ui-hint pair-url">${esc(s.verificationUrl)}</div>` : ""}
69
+ <div class="ui-hint">${esc(t("pair.waiting"))}${secs ? ` \xB7 ${esc(t("pair.expiresIn", { s: secs }))}` : ""}</div>`;
70
+ } else if (s.phase === "provisioning") {
71
+ bodyHtml = `<p class="ui-msg">${esc(t("pair.provisioning"))} \u2026</p>`;
72
+ } else if (s.phase === "paired") {
73
+ bodyHtml = `<p class="ui-msg"><b>${esc(t("pair.paired.title"))}</b></p>
74
+ ${s.accessUrl ? `<div class="ui-hint">${esc(t("pair.paired.url"))}</div><div class="pair-url"><a href="${esc(s.accessUrl)}" target="_blank" rel="noopener">${esc(s.accessUrl)}</a></div>` : ""}
75
+ ${s.needsRestart ? `<div class="ui-hint">${esc(t("pair.paired.restart"))}</div>` : ""}`;
76
+ } else {
77
+ bodyHtml = `<p class="ui-msg">${esc(failureMessage(s.error))}</p>`;
78
+ }
79
+ const footHtml = s.phase === "paired" ? `<button type="button" class="ui-ok pair-done">${esc(t("common.close"))}</button>` : s.phase === "failed" ? `<button type="button" class="ui-cancel pair-x">${esc(t("common.close"))}</button><button type="button" class="ui-ok pair-retry">${esc(t("pair.retry"))}</button>` : `<button type="button" class="ui-cancel pair-x">${esc(t("common.cancel"))}</button>`;
80
+ box.innerHTML = `<div class="ui-head"><b>${esc(t("pair.title"))}</b><button class="ui-x" type="button" aria-label="${esc(t("common.close"))}">\u2715</button></div>
81
+ <div class="ui-body">${bodyHtml}</div>
82
+ <div class="ui-foot">${footHtml}</div>`;
83
+ box.querySelector(".ui-x").onclick = close;
84
+ box.querySelector(".pair-x")?.addEventListener("click", close);
85
+ box.querySelector(".pair-done")?.addEventListener("click", close);
86
+ box.querySelector(".pair-retry")?.addEventListener("click", () => void begin());
87
+ };
88
+ const poll = async () => {
89
+ timer = null;
90
+ try {
91
+ const s = await api("GET", "/pairing/status");
92
+ if (closed) return;
93
+ render(s);
94
+ schedule(s);
95
+ } catch {
96
+ if (!closed) timer = window.setTimeout(() => void poll(), 3e3);
97
+ }
98
+ };
99
+ const begin = async () => {
100
+ stopPoll();
101
+ try {
102
+ const s = await api("POST", "/pairing/start", {});
103
+ if (closed) return;
104
+ render(s);
105
+ schedule(s);
106
+ } catch {
107
+ if (!closed) render({ phase: "failed", error: "start_fail" });
108
+ }
109
+ };
110
+ void begin();
111
+ }
112
+ export {
113
+ openPairingPanel
114
+ };
@@ -0,0 +1,150 @@
1
+ import { esc } from "./md.js";
2
+ import { t } from "./i18n.js";
3
+ import { formModal, confirmModal } from "./ui.js";
4
+ import { pickFolder } from "./dirpicker.js";
5
+ async function openProjectSettings(deps, state = {}) {
6
+ const { api } = deps;
7
+ let pj = null;
8
+ try {
9
+ const { projects, currentId } = await api("GET", "/projects");
10
+ const target = state.projectId ?? currentId;
11
+ pj = projects.find((p) => p.id === target) ?? null;
12
+ } catch {
13
+ return;
14
+ }
15
+ if (!pj) return;
16
+ render(pj, deps, state.draft);
17
+ }
18
+ function render(pj, deps, draft) {
19
+ const { api } = deps;
20
+ document.querySelector("#pj-modal")?.remove();
21
+ const wrap = document.createElement("div");
22
+ wrap.id = "pj-modal";
23
+ const foldersHtml = pj.folders.map((f) => `
24
+ <div class="pj-folder" data-id="${esc(f.id)}">
25
+ <span class="pj-fname" title="${esc(f.path)}">\u{1F4C2} ${esc(f.name)}${f.role === "primary" ? ` <em>(${esc(t("pj.folder.primary"))})</em>` : ""}</span>
26
+ ${f.role === "primary" ? "" : `
27
+ <button class="pj-fmode" type="button">${f.mode === "ro" ? t("pj.folder.ro") : t("pj.folder.rw")}</button>
28
+ <button class="pj-frm" type="button" title="${esc(t("pj.folder.remove"))}">\u{1F5D1}</button>`}
29
+ </div>`).join("");
30
+ const ctxHtml = (pj.contextFiles ?? []).map((c, i) => `
31
+ <div class="pj-ctx" data-i="${i}">
32
+ <span title="${esc(c.path)}">\u{1F4CE} ${esc(c.path)}${c.note ? ` \u2014 ${esc(c.note)}` : ""}</span>
33
+ <button class="pj-crm" type="button">\u{1F5D1}</button>
34
+ </div>`).join("");
35
+ const docNodes = deps.nodes().filter((n) => n.projectId === pj.id && n.docPath).sort((a, b) => String(a.docPath).localeCompare(String(b.docPath)));
36
+ const docIdxHtml = docNodes.map((n) => `
37
+ <div class="pj-doc" data-doc="${esc(String(n.docPath))}">
38
+ <span class="pj-doc-path" title="${esc(String(n.docPath))}">\u{1F4C4} ${esc(String(n.docPath))}</span>
39
+ <span class="pj-doc-node">${esc(n.id)} \xB7 ${esc(n.title)}</span>
40
+ </div>`).join("");
41
+ wrap.innerHTML = `
42
+ <div class="pj-box">
43
+ <div class="pj-head"><b>\u2699 ${esc(pj.name)}</b><button id="pj-close" type="button" aria-label="${esc(t("common.close"))}">\u2715</button></div>
44
+ <label>${esc(t("pj.inst.label"))} <span class="lbl-sub">${esc(t("pj.inst.sub"))}</span> <button id="pj-guide" type="button" title="${esc(t("pj.guide.title"))}">\u{1F4C4} ${esc(t("pj.guide.btn"))}</button></label>
45
+ <textarea id="pj-inst" rows="5" placeholder="${esc(t("pj.inst.ph"))}">${esc(draft ?? pj.instructions ?? "")}</textarea>
46
+ <label>${esc(t("pj.folders.label"))} <button id="pj-fadd" type="button">${esc(t("pj.folder.addBtn"))}</button></label>
47
+ <div>${foldersHtml || `<div class="pane-empty">${esc(t("pj.folders.empty"))}</div>`}</div>
48
+ <label>${esc(t("pj.ctx.label"))} <button id="pj-cadd" type="button">${esc(t("pj.ctx.addBtn"))}</button></label>
49
+ <div>${ctxHtml || `<div class="pane-empty">${esc(t("pj.ctx.empty"))}</div>`}</div>
50
+ <label>${esc(t("pj.docidx.label"))}</label>
51
+ <div class="pj-docidx">${docIdxHtml || `<div class="pane-empty">${esc(t("pj.docidx.empty"))}</div>`}</div>
52
+ <div class="pj-actions"><button id="pj-save" type="button">${esc(t("common.save"))}</button></div>
53
+ </div>`;
54
+ document.body.append(wrap);
55
+ const instEl = wrap.querySelector("#pj-inst");
56
+ const close = () => {
57
+ wrap.remove();
58
+ document.removeEventListener("keydown", onKey);
59
+ };
60
+ const onKey = (e) => {
61
+ if (e.key === "Escape") close();
62
+ };
63
+ document.addEventListener("keydown", onKey);
64
+ const reopen = () => {
65
+ close();
66
+ void openProjectSettings(deps, { projectId: pj.id, draft: instEl.value });
67
+ };
68
+ wrap.onclick = (e) => {
69
+ if (e.target === wrap) close();
70
+ };
71
+ wrap.querySelector("#pj-close").onclick = close;
72
+ instEl.focus();
73
+ wrap.querySelector("#pj-guide").onclick = () => deps.onShowGuidelines();
74
+ wrap.querySelector("#pj-save").onclick = async () => {
75
+ await api("PATCH", `/projects/${pj.id}`, { instructions: wrap.querySelector("#pj-inst").value });
76
+ close();
77
+ };
78
+ wrap.querySelector("#pj-fadd").onclick = async () => {
79
+ const path = await pickFolder(api);
80
+ if (!path) return;
81
+ const v = await formModal({
82
+ title: t("pj.folder.addBtn"),
83
+ submitLabel: t("common.add"),
84
+ fields: [
85
+ {
86
+ name: "mode",
87
+ label: t("pj.folder.access.label"),
88
+ type: "radio",
89
+ value: "rw",
90
+ hint: path,
91
+ options: [
92
+ { value: "rw", label: t("pj.folder.rwOpt"), hint: t("pj.folder.rwOpt.hint") },
93
+ { value: "ro", label: t("pj.folder.roOpt"), hint: t("pj.folder.roOpt.hint") }
94
+ ]
95
+ }
96
+ ]
97
+ });
98
+ if (v === null) return;
99
+ await api("POST", `/projects/${pj.id}/folders`, { path, mode: String(v.mode ?? "rw") });
100
+ reopen();
101
+ };
102
+ wrap.querySelector("#pj-cadd").onclick = async () => {
103
+ const v = await formModal({
104
+ title: t("pj.ctx.addBtn"),
105
+ submitLabel: t("common.add"),
106
+ fields: [
107
+ { name: "path", label: t("pj.ctx.path.label"), type: "text", placeholder: t("pj.ctx.path.ph"), hint: t("pj.ctx.path.hint") },
108
+ { name: "note", label: t("pj.ctx.note.label"), type: "text", placeholder: t("pj.ctx.note.ph") }
109
+ ]
110
+ });
111
+ const path = String(v?.path ?? "").trim();
112
+ if (!path) return;
113
+ await api("PATCH", `/projects/${pj.id}`, { contextFiles: [...pj.contextFiles ?? [], { path, note: String(v?.note ?? "").trim() }] });
114
+ reopen();
115
+ };
116
+ for (const row of wrap.querySelectorAll(".pj-folder")) {
117
+ const fid = row.dataset.id;
118
+ row.querySelector(".pj-fmode")?.addEventListener("click", async () => {
119
+ const f = pj.folders.find((x) => x.id === fid);
120
+ if (!f) return;
121
+ await api("PATCH", `/projects/${pj.id}/folders/${fid}`, { mode: f.mode === "ro" ? "rw" : "ro" });
122
+ reopen();
123
+ });
124
+ row.querySelector(".pj-frm")?.addEventListener("click", async () => {
125
+ if (!await confirmModal({ title: t("pj.folder.remove"), message: t("pj.folder.remove.msg"), okLabel: t("common.remove"), danger: true })) return;
126
+ await api("DELETE", `/projects/${pj.id}/folders/${fid}`);
127
+ reopen();
128
+ });
129
+ }
130
+ for (const row of wrap.querySelectorAll(".pj-ctx")) {
131
+ row.querySelector(".pj-crm").onclick = async () => {
132
+ const i = Number(row.dataset.i);
133
+ const next = (pj.contextFiles ?? []).filter((_, j) => j !== i);
134
+ await api("PATCH", `/projects/${pj.id}`, { contextFiles: next });
135
+ reopen();
136
+ };
137
+ }
138
+ for (const row of wrap.querySelectorAll(".pj-doc")) {
139
+ row.onclick = () => {
140
+ const p = row.dataset.doc;
141
+ if (p) {
142
+ deps.onOpenDoc(p);
143
+ close();
144
+ }
145
+ };
146
+ }
147
+ }
148
+ export {
149
+ openProjectSettings
150
+ };
@@ -0,0 +1,95 @@
1
+ import { esc } from "./md.js";
2
+ class SlashMenu {
3
+ input;
4
+ getCommands;
5
+ el;
6
+ items = [];
7
+ index = 0;
8
+ constructor(input, { getCommands }) {
9
+ this.input = input;
10
+ this.getCommands = getCommands;
11
+ this.el = document.createElement("div");
12
+ this.el.className = "slash-menu";
13
+ this.el.hidden = true;
14
+ (input.closest("form") ?? input.parentElement ?? document.body).append(this.el);
15
+ this.el.addEventListener("mousedown", (e) => e.preventDefault());
16
+ }
17
+ get isOpen() {
18
+ return !this.el.hidden;
19
+ }
20
+ // Call on every input event β€” decides show / filter / close.
21
+ update() {
22
+ const m = this.input.value.match(/^\/([\w-]*)$/);
23
+ if (!m) return this.close();
24
+ const q = m[1].toLowerCase();
25
+ const all = this.getCommands() ?? [];
26
+ const matches = (c) => c.name.toLowerCase().startsWith(q) || (c.aliases ?? []).some((a) => a.toLowerCase().startsWith(q));
27
+ const items = q ? all.filter(matches).sort((a, b) => Number(b.name.toLowerCase().startsWith(q)) - Number(a.name.toLowerCase().startsWith(q))) : all;
28
+ if (!items.length) return this.close();
29
+ this.items = items.slice(0, 50);
30
+ this.index = 0;
31
+ this.render();
32
+ this.el.hidden = false;
33
+ }
34
+ // Consume keys only while open; chat calls this first in the textarea keydown so nav beats send-on-Enter.
35
+ handleKeydown(e) {
36
+ if (!this.isOpen) return false;
37
+ if (e.key === "ArrowDown") {
38
+ e.preventDefault();
39
+ this.move(1);
40
+ return true;
41
+ }
42
+ if (e.key === "ArrowUp") {
43
+ e.preventDefault();
44
+ this.move(-1);
45
+ return true;
46
+ }
47
+ if (e.key === "Enter" || e.key === "Tab") {
48
+ e.preventDefault();
49
+ this.pick(this.index);
50
+ return true;
51
+ }
52
+ if (e.key === "Escape") {
53
+ e.preventDefault();
54
+ this.close();
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ close() {
60
+ this.el.hidden = true;
61
+ this.items = [];
62
+ }
63
+ move(delta) {
64
+ if (!this.items.length) return;
65
+ this.index = (this.index + delta + this.items.length) % this.items.length;
66
+ this.render();
67
+ this.el.querySelector(".slash-row.sel")?.scrollIntoView({ block: "nearest" });
68
+ }
69
+ pick(i) {
70
+ const cmd = this.items[i];
71
+ if (!cmd) return this.close();
72
+ this.input.value = `/${cmd.name} `;
73
+ this.input.dispatchEvent(new Event("input", { bubbles: true }));
74
+ this.input.focus();
75
+ this.close();
76
+ }
77
+ render() {
78
+ this.el.replaceChildren(
79
+ ...this.items.map((c, i) => {
80
+ const row = document.createElement("div");
81
+ row.className = "slash-row" + (i === this.index ? " sel" : "");
82
+ row.innerHTML = `<span class="slash-name">/${esc(c.name)}</span>` + (c.argumentHint ? `<span class="slash-arg">${esc(c.argumentHint)}</span>` : "") + (c.description ? `<span class="slash-desc">${esc(c.description)}</span>` : "");
83
+ row.addEventListener("mouseenter", () => {
84
+ this.index = i;
85
+ this.render();
86
+ });
87
+ row.addEventListener("click", () => this.pick(i));
88
+ return row;
89
+ })
90
+ );
91
+ }
92
+ }
93
+ export {
94
+ SlashMenu
95
+ };