@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +8 -0
- package/README.md +55 -0
- package/dist/bin/orangetree.js +89 -0
- package/dist/public/api.js +33 -0
- package/dist/public/app.js +650 -0
- package/dist/public/canvas.js +362 -0
- package/dist/public/chat.js +890 -0
- package/dist/public/connection.js +102 -0
- package/dist/public/dirpicker.js +93 -0
- package/dist/public/explorer.js +299 -0
- package/dist/public/guidelines.js +35 -0
- package/dist/public/i18n.js +959 -0
- package/dist/public/index.html +75 -0
- package/dist/public/insight.js +40 -0
- package/dist/public/logo.svg +5 -0
- package/dist/public/md.js +103 -0
- package/dist/public/pairing.js +114 -0
- package/dist/public/projectSettings.js +150 -0
- package/dist/public/slash.js +95 -0
- package/dist/public/style.css +649 -0
- package/dist/public/ui.js +372 -0
- package/dist/public/viewer.html +156 -0
- package/dist/server.js +18573 -0
- package/package.json +45 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { api, toast } from "./api.js";
|
|
2
|
+
import { t } from "./i18n.js";
|
|
3
|
+
import { pickFolder } from "./dirpicker.js";
|
|
4
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
5
|
+
async function openConnectionSettings() {
|
|
6
|
+
let cfg;
|
|
7
|
+
try {
|
|
8
|
+
cfg = await api("GET", "/setup");
|
|
9
|
+
} catch {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let workRoot = cfg.workRoot;
|
|
13
|
+
const o = document.createElement("div");
|
|
14
|
+
o.className = "ui-overlay";
|
|
15
|
+
const box = document.createElement("div");
|
|
16
|
+
box.className = "ui-modal";
|
|
17
|
+
box.style.width = "min(540px, 94vw)";
|
|
18
|
+
o.append(box);
|
|
19
|
+
document.body.append(o);
|
|
20
|
+
const close = () => {
|
|
21
|
+
o.remove();
|
|
22
|
+
document.removeEventListener("keydown", onKey);
|
|
23
|
+
};
|
|
24
|
+
const onKey = (e) => {
|
|
25
|
+
if (e.key === "Escape") close();
|
|
26
|
+
};
|
|
27
|
+
document.addEventListener("keydown", onKey);
|
|
28
|
+
o.onclick = (e) => {
|
|
29
|
+
if (e.target === o) close();
|
|
30
|
+
};
|
|
31
|
+
const workRootText = () => workRoot ?? t("setup.workRoot.default");
|
|
32
|
+
box.innerHTML = `<div class="ui-head"><b>${esc(t("setup.title"))}</b><button class="ui-x" type="button" aria-label="${esc(t("common.close"))}">\u2715</button></div>
|
|
33
|
+
<div class="ui-body">
|
|
34
|
+
<div class="ui-field">
|
|
35
|
+
<label class="ui-lbl">${esc(t("setup.workRoot"))}</label>
|
|
36
|
+
<div class="cs-row"><span class="cs-path">${esc(workRootText())}</span><button type="button" class="cs-pick">${esc(t("setup.workRoot.change"))}</button></div>
|
|
37
|
+
<div class="ui-hint">${esc(t("setup.workRoot.hint"))}</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="ui-field">
|
|
40
|
+
<label class="ui-check"><input type="checkbox" id="cs-remote"${cfg.remoteEnabled ? " checked" : ""}> ${esc(t("setup.remoteEnabled"))}</label>
|
|
41
|
+
<div class="ui-hint">${esc(t("setup.remoteEnabled.hint"))}</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="ui-field">
|
|
44
|
+
<label class="ui-check"><input type="checkbox" id="cs-openbrowser"${cfg.openBrowser ? " checked" : ""}> ${esc(t("setup.openBrowser"))}</label>
|
|
45
|
+
</div>
|
|
46
|
+
<details class="cs-adv">
|
|
47
|
+
<summary>${esc(t("setup.advanced"))}</summary>
|
|
48
|
+
<div class="ui-field">
|
|
49
|
+
<label class="ui-lbl" for="cs-cloud">${esc(t("setup.cloudApiBase"))}</label>
|
|
50
|
+
<input type="text" id="cs-cloud" value="${esc(cfg.cloudApiBase ?? "")}" placeholder="https://api.orangetree.dev">
|
|
51
|
+
</div>
|
|
52
|
+
<div class="ui-field">
|
|
53
|
+
<label class="ui-lbl" for="cs-port">${esc(t("setup.port"))}</label>
|
|
54
|
+
<input type="number" id="cs-port" value="${esc(cfg.port ?? "")}" placeholder="4100" min="1" max="65535">
|
|
55
|
+
</div>
|
|
56
|
+
<div class="ui-field">
|
|
57
|
+
<label class="ui-lbl" for="cs-auth">${esc(t("setup.remoteAuth"))}</label>
|
|
58
|
+
<select id="cs-auth"><option value="cloud"${cfg.remoteAuth === "cloud" ? " selected" : ""}>cloud (magic-link)</option><option value="token"${cfg.remoteAuth === "token" ? " selected" : ""}>token</option></select>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="ui-field">
|
|
61
|
+
<label class="ui-lbl" for="cs-bind">${esc(t("setup.bindHost"))}</label>
|
|
62
|
+
<input type="text" id="cs-bind" value="${esc(cfg.bindHost ?? "")}" placeholder="127.0.0.1">
|
|
63
|
+
</div>
|
|
64
|
+
<div class="ui-hint">${esc(t("setup.restartHint"))}</div>
|
|
65
|
+
</details>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="ui-foot"><button type="button" class="ui-cancel">${esc(t("common.cancel"))}</button>
|
|
68
|
+
<button type="button" class="ui-ok">${esc(t("common.save"))}</button></div>`;
|
|
69
|
+
const pathEl = box.querySelector(".cs-path");
|
|
70
|
+
box.querySelector(".cs-pick").onclick = async () => {
|
|
71
|
+
const picked = await pickFolder(api);
|
|
72
|
+
if (picked) {
|
|
73
|
+
workRoot = picked;
|
|
74
|
+
pathEl.textContent = workRootText();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const save = async () => {
|
|
78
|
+
const portStr = box.querySelector("#cs-port").value.trim();
|
|
79
|
+
const patch = {
|
|
80
|
+
workRoot,
|
|
81
|
+
remoteEnabled: box.querySelector("#cs-remote").checked,
|
|
82
|
+
openBrowser: box.querySelector("#cs-openbrowser").checked,
|
|
83
|
+
cloudApiBase: box.querySelector("#cs-cloud").value.trim() || null,
|
|
84
|
+
port: portStr ? Number(portStr) : null,
|
|
85
|
+
remoteAuth: box.querySelector("#cs-auth").value,
|
|
86
|
+
bindHost: box.querySelector("#cs-bind").value.trim() || null
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
await api("PATCH", "/setup", patch);
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
toast(t("setup.saved"), "success");
|
|
94
|
+
close();
|
|
95
|
+
};
|
|
96
|
+
box.querySelector(".ui-x").onclick = close;
|
|
97
|
+
box.querySelector(".ui-cancel").onclick = close;
|
|
98
|
+
box.querySelector(".ui-ok").onclick = () => void save();
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
openConnectionSettings
|
|
102
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { esc } from "./md.js";
|
|
2
|
+
import { t, applyDataI18n } from "./i18n.js";
|
|
3
|
+
function pickFolder(api) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const overlay = document.createElement("div");
|
|
6
|
+
overlay.className = "dp-overlay";
|
|
7
|
+
overlay.innerHTML = `
|
|
8
|
+
<div class="dp-box">
|
|
9
|
+
<div class="dp-head"><span data-i18n="dp.title">\uC791\uC5C5 \uD3F4\uB354 \uC120\uD0DD</span><button type="button" class="dp-x" data-i18n-title="common.cancel" title="\uCDE8\uC18C">\u2715</button></div>
|
|
10
|
+
<div class="dp-path"></div>
|
|
11
|
+
<div class="dp-drives"></div>
|
|
12
|
+
<div class="dp-list"></div>
|
|
13
|
+
<div class="dp-foot">
|
|
14
|
+
<button type="button" class="dp-cancel" data-i18n="common.cancel">\uCDE8\uC18C</button>
|
|
15
|
+
<button type="button" class="dp-ok" data-i18n="dp.choose" disabled>\u{1F4C1} \uC774 \uD3F4\uB354 \uC120\uD0DD</button>
|
|
16
|
+
</div>
|
|
17
|
+
</div>`;
|
|
18
|
+
document.body.append(overlay);
|
|
19
|
+
applyDataI18n(overlay);
|
|
20
|
+
const $ = (s) => overlay.querySelector(s);
|
|
21
|
+
let current = null;
|
|
22
|
+
const close = (val) => {
|
|
23
|
+
overlay.remove();
|
|
24
|
+
document.removeEventListener("keydown", onKey);
|
|
25
|
+
resolve(val);
|
|
26
|
+
};
|
|
27
|
+
const onKey = (e) => {
|
|
28
|
+
if (e.key === "Escape") close(null);
|
|
29
|
+
};
|
|
30
|
+
document.addEventListener("keydown", onKey);
|
|
31
|
+
overlay.onclick = (e) => {
|
|
32
|
+
if (e.target === overlay) close(null);
|
|
33
|
+
};
|
|
34
|
+
$(".dp-x").onclick = () => close(null);
|
|
35
|
+
$(".dp-cancel").onclick = () => close(null);
|
|
36
|
+
$(".dp-ok").onclick = () => {
|
|
37
|
+
if (current) close(current);
|
|
38
|
+
};
|
|
39
|
+
const load = async (path) => {
|
|
40
|
+
let data;
|
|
41
|
+
try {
|
|
42
|
+
data = await api("GET", `/dirs${path ? `?path=${encodeURIComponent(path)}` : ""}`);
|
|
43
|
+
} catch {
|
|
44
|
+
if (current === null) {
|
|
45
|
+
const err = document.createElement("div");
|
|
46
|
+
err.className = "dp-empty";
|
|
47
|
+
err.textContent = t("dp.loadFail");
|
|
48
|
+
$(".dp-list").replaceChildren(err);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
current = data.path;
|
|
53
|
+
$(".dp-path").textContent = data.path;
|
|
54
|
+
$(".dp-ok").disabled = false;
|
|
55
|
+
$(".dp-drives").replaceChildren(
|
|
56
|
+
...(data.drives ?? []).map((d) => {
|
|
57
|
+
const b = document.createElement("button");
|
|
58
|
+
b.type = "button";
|
|
59
|
+
b.className = "dp-drive";
|
|
60
|
+
b.textContent = d;
|
|
61
|
+
b.onclick = () => void load(d);
|
|
62
|
+
return b;
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
const rows = [];
|
|
66
|
+
if (data.parent) {
|
|
67
|
+
const up = document.createElement("div");
|
|
68
|
+
up.className = "dp-item dp-up";
|
|
69
|
+
up.innerHTML = `\u2B06 <span>${esc(t("dp.parentFolder"))}</span>`;
|
|
70
|
+
up.onclick = () => void load(data.parent);
|
|
71
|
+
rows.push(up);
|
|
72
|
+
}
|
|
73
|
+
for (const d of data.dirs) {
|
|
74
|
+
const row = document.createElement("div");
|
|
75
|
+
row.className = "dp-item";
|
|
76
|
+
row.innerHTML = `\u{1F4C1} <span>${esc(d.name)}</span>`;
|
|
77
|
+
row.onclick = () => void load(d.path);
|
|
78
|
+
rows.push(row);
|
|
79
|
+
}
|
|
80
|
+
if (!data.dirs.length) {
|
|
81
|
+
const empty = document.createElement("div");
|
|
82
|
+
empty.className = "dp-empty";
|
|
83
|
+
empty.textContent = data.parent ? t("dp.emptySelectable") : t("dp.empty");
|
|
84
|
+
rows.push(empty);
|
|
85
|
+
}
|
|
86
|
+
$(".dp-list").replaceChildren(...rows);
|
|
87
|
+
};
|
|
88
|
+
void load(null);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
pickFolder
|
|
93
|
+
};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { esc } from "./md.js";
|
|
2
|
+
import { confirmModal } from "./ui.js";
|
|
3
|
+
import { t } from "./i18n.js";
|
|
4
|
+
import { isDoc } from "./insight.js";
|
|
5
|
+
const VIEWABLE = /\.(md|txt|json|mjs|js|ts|jsx|tsx|css|html|py|sh|sql|ya?ml|png|jpe?g|gif|webp|svg|ico|bmp|mp4|webm|mov|ogg|mp3|wav|m4a)$/i;
|
|
6
|
+
class Explorer {
|
|
7
|
+
api;
|
|
8
|
+
onOpen;
|
|
9
|
+
onLink;
|
|
10
|
+
hasSelection;
|
|
11
|
+
onSwitchWs;
|
|
12
|
+
onRemoveWs;
|
|
13
|
+
nodes;
|
|
14
|
+
onGoto;
|
|
15
|
+
query = "";
|
|
16
|
+
// live search filter (SCR-INSIGHT-006); empty = normal tree view
|
|
17
|
+
openState;
|
|
18
|
+
collapsed;
|
|
19
|
+
lastFolders = [];
|
|
20
|
+
locks = {};
|
|
21
|
+
// "folderId:rel" -> holder (turn-lifetime soft lock)
|
|
22
|
+
$tree;
|
|
23
|
+
$search;
|
|
24
|
+
$wsSelect;
|
|
25
|
+
$wsList;
|
|
26
|
+
constructor(pane, { api, onOpen, onLink, hasSelection, onSwitchWs, onAddWs, onRemoveWs, onSettings, nodes, onGoto }) {
|
|
27
|
+
this.api = api;
|
|
28
|
+
this.onOpen = onOpen;
|
|
29
|
+
this.onLink = onLink;
|
|
30
|
+
this.hasSelection = hasSelection;
|
|
31
|
+
this.onSwitchWs = onSwitchWs;
|
|
32
|
+
this.onRemoveWs = onRemoveWs;
|
|
33
|
+
this.nodes = nodes;
|
|
34
|
+
this.onGoto = onGoto;
|
|
35
|
+
pane.innerHTML = `
|
|
36
|
+
<div class="ws-bar">
|
|
37
|
+
<select id="ws-select" data-i18n-title="exp.project.title"></select>
|
|
38
|
+
<button id="ws-settings" type="button" data-i18n-title="exp.project.settings">\u2699</button>
|
|
39
|
+
<button id="ws-add" type="button" data-i18n-title="exp.project.add">\u2795</button>
|
|
40
|
+
</div>
|
|
41
|
+
<div id="ws-list"></div>
|
|
42
|
+
<div class="exp-search-bar">
|
|
43
|
+
<input id="exp-search" type="search" autocomplete="off" data-i18n-ph="search.placeholder" placeholder="\uAC80\uC0C9 (\uD30C\uC77C\xB7\uB178\uB4DC)">
|
|
44
|
+
</div>
|
|
45
|
+
<div class="pane-title"><span data-i18n="exp.title">\uD0D0\uC0C9\uAE30</span> <span id="exp-root"></span></div>
|
|
46
|
+
<div id="exp-tree"><div class="pane-empty" data-i18n="exp.loading">\uB85C\uB4DC \uC911\u2026</div></div>
|
|
47
|
+
<div class="exp-footer">
|
|
48
|
+
<span id="app-ver" class="app-ver"></span>
|
|
49
|
+
<span class="copyright">\xA9 2026 <a href="https://www.orangeworks.kr" target="_blank" rel="noopener noreferrer">ORANGEWORKS</a></span>
|
|
50
|
+
</div>`;
|
|
51
|
+
this.openState = this.loadJson("otree-exp-open");
|
|
52
|
+
this.collapsed = this.loadJson("otree-exp-collapsed");
|
|
53
|
+
this.$tree = pane.querySelector("#exp-tree");
|
|
54
|
+
this.$search = pane.querySelector("#exp-search");
|
|
55
|
+
this.$wsSelect = pane.querySelector("#ws-select");
|
|
56
|
+
this.$wsList = pane.querySelector("#ws-list");
|
|
57
|
+
this.$wsSelect.onchange = () => this.onSwitchWs(this.$wsSelect.value);
|
|
58
|
+
pane.querySelector("#ws-add").onclick = () => onAddWs();
|
|
59
|
+
pane.querySelector("#ws-settings").onclick = () => onSettings();
|
|
60
|
+
this.$search.oninput = () => {
|
|
61
|
+
this.query = this.$search.value;
|
|
62
|
+
this.paint();
|
|
63
|
+
};
|
|
64
|
+
void this.refresh();
|
|
65
|
+
void this.loadVersion();
|
|
66
|
+
}
|
|
67
|
+
async loadVersion() {
|
|
68
|
+
try {
|
|
69
|
+
const { version } = await this.api("GET", "/version");
|
|
70
|
+
const el = document.querySelector("#app-ver");
|
|
71
|
+
if (el && version) el.textContent = `v${version}`;
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Project list/selection refresh (called when app receives the tree).
|
|
76
|
+
setProjects(workspaces, currentId) {
|
|
77
|
+
const none = document.createElement("option");
|
|
78
|
+
none.value = "none";
|
|
79
|
+
none.textContent = t("exp.project.none");
|
|
80
|
+
none.selected = !currentId;
|
|
81
|
+
this.$wsSelect.replaceChildren(none, ...workspaces.map((w) => {
|
|
82
|
+
const opt = document.createElement("option");
|
|
83
|
+
opt.value = w.id;
|
|
84
|
+
opt.textContent = w.name;
|
|
85
|
+
opt.title = (w.folders ?? []).map((f) => f.path).join("\n");
|
|
86
|
+
opt.selected = w.id === currentId;
|
|
87
|
+
return opt;
|
|
88
|
+
}));
|
|
89
|
+
if (!workspaces.length) {
|
|
90
|
+
const empty = document.createElement("div");
|
|
91
|
+
empty.className = "ws-empty";
|
|
92
|
+
empty.textContent = t("exp.project.empty");
|
|
93
|
+
this.$wsList.replaceChildren(empty);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.$wsList.replaceChildren(...workspaces.map((w) => {
|
|
97
|
+
const row = document.createElement("div");
|
|
98
|
+
row.className = "ws-item" + (w.id === currentId ? " active" : "");
|
|
99
|
+
row.innerHTML = `<span class="ws-name">\u{1F4C1} ${esc(w.name)}</span><button class="ws-rm" type="button" title="${t("exp.project.remove")}">\u{1F5D1}</button>`;
|
|
100
|
+
row.title = (w.folders ?? []).map((f) => f.path).join("\n");
|
|
101
|
+
row.querySelector(".ws-name").onclick = () => this.onSwitchWs(w.id);
|
|
102
|
+
row.querySelector(".ws-rm").onclick = (e) => {
|
|
103
|
+
e.stopPropagation();
|
|
104
|
+
this.onRemoveWs(w.id);
|
|
105
|
+
};
|
|
106
|
+
return row;
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
loadJson(key) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(localStorage.getItem(key) ?? "{}");
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
saveJson(key, value) {
|
|
117
|
+
try {
|
|
118
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Active soft file locks (DES-COLLAB-015), keyed "folderId:rel" — re-render to paint/clear 🔒.
|
|
123
|
+
setLocks(locks) {
|
|
124
|
+
this.locks = locks ?? {};
|
|
125
|
+
if (this.lastFolders.length) this.paint();
|
|
126
|
+
}
|
|
127
|
+
async refresh() {
|
|
128
|
+
try {
|
|
129
|
+
const { project, folders } = await this.api("GET", "/files");
|
|
130
|
+
this.lastFolders = folders;
|
|
131
|
+
const root = document.querySelector("#exp-root");
|
|
132
|
+
if (root) root.textContent = project;
|
|
133
|
+
this.paint();
|
|
134
|
+
} catch {
|
|
135
|
+
this.$tree.innerHTML = `<div class="pane-empty">${t("exp.loadFail")}</div>`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Tree view (no query) or flat search results (query present) — SCR-INSIGHT-006.
|
|
139
|
+
paint() {
|
|
140
|
+
if (this.query.trim()) this.renderSearch();
|
|
141
|
+
else this.renderRoots();
|
|
142
|
+
}
|
|
143
|
+
renderRoots() {
|
|
144
|
+
const folders = this.lastFolders;
|
|
145
|
+
this.$tree.replaceChildren(...folders.map((f) => this.renderRoot(f)));
|
|
146
|
+
if (!folders.length) this.$tree.innerHTML = `<div class="pane-empty">${t("exp.noFolder")}</div>`;
|
|
147
|
+
}
|
|
148
|
+
// Flatten every folder's tree to (folderId, path, name) file entries — for search matching.
|
|
149
|
+
collectFiles(items, folderId, acc) {
|
|
150
|
+
for (const item of items) {
|
|
151
|
+
if (item.type === "dir") this.collectFiles(item.children ?? [], folderId, acc);
|
|
152
|
+
else acc.push({ folderId, path: item.path, name: item.name });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Client-side metadata filter (DES-INSIGHT-006): files (path/name) + nodes (title/decision/branchSummary).
|
|
156
|
+
// No full-text body search (v2 follow-up). Each result navigates to the viewer or the node.
|
|
157
|
+
renderSearch() {
|
|
158
|
+
const q = this.query.trim().toLowerCase();
|
|
159
|
+
const CAP = 50;
|
|
160
|
+
const files = [];
|
|
161
|
+
for (const f of this.lastFolders) this.collectFiles(f.tree, f.id, files);
|
|
162
|
+
const fileMatches = files.filter((x) => x.name.toLowerCase().includes(q) || x.path.toLowerCase().includes(q));
|
|
163
|
+
const fileHits = fileMatches.slice(0, CAP);
|
|
164
|
+
const nodeMatches = this.nodes().filter((n) => {
|
|
165
|
+
const hay = [n.title, n.decision?.summary, n.branchSummary].filter(Boolean).join(" ").toLowerCase();
|
|
166
|
+
return hay.includes(q);
|
|
167
|
+
});
|
|
168
|
+
const nodeHits = nodeMatches.slice(0, CAP);
|
|
169
|
+
if (!fileHits.length && !nodeHits.length) {
|
|
170
|
+
this.$tree.innerHTML = `<div class="pane-empty">${t("search.none")}</div>`;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const count = (shown, total) => total > shown ? `${shown}/${total}` : `${shown}`;
|
|
174
|
+
const frag = document.createDocumentFragment();
|
|
175
|
+
if (fileHits.length) {
|
|
176
|
+
const h = document.createElement("div");
|
|
177
|
+
h.className = "srch-group";
|
|
178
|
+
h.textContent = `${t("search.files")} (${count(fileHits.length, fileMatches.length)})`;
|
|
179
|
+
frag.append(h);
|
|
180
|
+
for (const x of fileHits) {
|
|
181
|
+
const key = `${x.folderId}:${x.path}`;
|
|
182
|
+
const row = document.createElement("div");
|
|
183
|
+
row.className = "srch-hit";
|
|
184
|
+
row.title = x.path;
|
|
185
|
+
row.innerHTML = `<span class="srch-ic">${isDoc(x.name) ? "\u{1F4C4}" : "\xB7"}</span><span class="srch-name">${esc(x.name)}</span><span class="srch-sub">${esc(x.path)}</span>`;
|
|
186
|
+
if (VIEWABLE.test(x.name)) row.onclick = () => this.onOpen(key);
|
|
187
|
+
else row.classList.add("disabled");
|
|
188
|
+
frag.append(row);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (nodeHits.length) {
|
|
192
|
+
const h = document.createElement("div");
|
|
193
|
+
h.className = "srch-group";
|
|
194
|
+
h.textContent = `${t("search.nodes")} (${count(nodeHits.length, nodeMatches.length)})`;
|
|
195
|
+
frag.append(h);
|
|
196
|
+
for (const n of nodeHits) {
|
|
197
|
+
const sub = n.decision?.summary || n.branchSummary || "";
|
|
198
|
+
const row = document.createElement("div");
|
|
199
|
+
row.className = "srch-hit";
|
|
200
|
+
row.title = `${n.id} \xB7 ${n.title}`;
|
|
201
|
+
row.innerHTML = `<span class="srch-ic">\u25C6</span><span class="srch-name">${esc(n.title)}</span><span class="srch-sub">${esc(sub)}</span>`;
|
|
202
|
+
row.onclick = () => this.onGoto(n.id);
|
|
203
|
+
frag.append(row);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.$tree.replaceChildren(frag);
|
|
207
|
+
}
|
|
208
|
+
renderRoot(f) {
|
|
209
|
+
const wrap = document.createElement("div");
|
|
210
|
+
wrap.className = "exp-root-group";
|
|
211
|
+
const collapsed = !!this.collapsed[f.id];
|
|
212
|
+
const head = document.createElement("div");
|
|
213
|
+
head.className = "exp-folder-head" + (collapsed ? " collapsed" : "");
|
|
214
|
+
head.innerHTML = `<span class="erh-tw">${collapsed ? "\u25B6" : "\u25BC"}</span><span class="erh-name">\u{1F4C2} ${esc(f.name)}</span><span class="fmode ${f.mode}">${t(f.mode === "ro" ? "exp.mode.ro" : "exp.mode.rw")}</span>${f.role === "primary" ? `<span class="fprimary">${t("exp.primary")}</span>` : ""}<span class="erh-spacer"></span><button class="erh-btn erh-expand" type="button" title="${t("exp.expandAll")}">\u2295</button><button class="erh-btn erh-collapse" type="button" title="${t("exp.collapseAll")}">\u2296</button>`;
|
|
215
|
+
const body = document.createElement("div");
|
|
216
|
+
body.className = "exp-root-body";
|
|
217
|
+
body.hidden = collapsed;
|
|
218
|
+
body.append(...this.renderList(f.tree, f.id));
|
|
219
|
+
const name = head.querySelector(".erh-name");
|
|
220
|
+
const tw = head.querySelector(".erh-tw");
|
|
221
|
+
name.onclick = () => this.toggleRoot(f.id);
|
|
222
|
+
tw.onclick = () => this.toggleRoot(f.id);
|
|
223
|
+
head.querySelector(".erh-expand").onclick = (ev) => {
|
|
224
|
+
ev.stopPropagation();
|
|
225
|
+
if (this.collapsed[f.id]) this.toggleRoot(f.id);
|
|
226
|
+
else body.querySelectorAll("details").forEach((d) => {
|
|
227
|
+
d.open = true;
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
head.querySelector(".erh-collapse").onclick = (ev) => {
|
|
231
|
+
ev.stopPropagation();
|
|
232
|
+
body.querySelectorAll("details").forEach((d) => {
|
|
233
|
+
d.open = false;
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
wrap.append(head, body);
|
|
237
|
+
return wrap;
|
|
238
|
+
}
|
|
239
|
+
toggleRoot(id) {
|
|
240
|
+
this.collapsed[id] = !this.collapsed[id];
|
|
241
|
+
this.saveJson("otree-exp-collapsed", this.collapsed);
|
|
242
|
+
this.renderRoots();
|
|
243
|
+
}
|
|
244
|
+
renderList(items, folderId) {
|
|
245
|
+
return items.map((item) => {
|
|
246
|
+
if (item.type === "dir") {
|
|
247
|
+
const key2 = `${folderId}:${item.path}`;
|
|
248
|
+
const details = document.createElement("details");
|
|
249
|
+
details.open = key2 in this.openState ? this.openState[key2] : ["docs"].includes(item.name);
|
|
250
|
+
const summary = document.createElement("summary");
|
|
251
|
+
const setIcon = () => {
|
|
252
|
+
summary.textContent = `${details.open ? "\u{1F4C2}" : "\u{1F4C1}"} ${item.name}`;
|
|
253
|
+
};
|
|
254
|
+
details.addEventListener("toggle", () => {
|
|
255
|
+
this.openState[key2] = details.open;
|
|
256
|
+
this.saveJson("otree-exp-open", this.openState);
|
|
257
|
+
setIcon();
|
|
258
|
+
});
|
|
259
|
+
setIcon();
|
|
260
|
+
summary.title = item.name;
|
|
261
|
+
details.append(summary, ...this.renderList(item.children ?? [], folderId));
|
|
262
|
+
return details;
|
|
263
|
+
}
|
|
264
|
+
const row = document.createElement("div");
|
|
265
|
+
row.className = "exp-file";
|
|
266
|
+
const isMd = /\.md$/i.test(item.name);
|
|
267
|
+
const key = `${folderId}:${item.path}`;
|
|
268
|
+
row.title = item.name;
|
|
269
|
+
const lock = this.locks[key];
|
|
270
|
+
row.innerHTML = `<span class="fname">${isMd ? "\u{1F4C4}" : "\xB7"} ${esc(item.name)}${lock ? ` <span class="flock" title="${esc(t("exp.lock.title", { id: lock.nodeId }))}">\u{1F512}${esc(lock.nodeId)}</span>` : ""}</span><button class="flink" type="button" title="${esc(t("exp.link.title"))}">\u{1F4CE}</button>`;
|
|
271
|
+
const fname = row.querySelector(".fname");
|
|
272
|
+
fname.onclick = () => {
|
|
273
|
+
if (VIEWABLE.test(item.name)) this.onOpen(key);
|
|
274
|
+
};
|
|
275
|
+
const flock = row.querySelector(".flock");
|
|
276
|
+
if (flock) flock.onclick = async (e) => {
|
|
277
|
+
e.stopPropagation();
|
|
278
|
+
if (await confirmModal({ title: t("exp.lock.release.title"), message: t("exp.lock.release.msg", { path: item.path, id: this.locks[key]?.nodeId ?? "" }), okLabel: t("exp.lock.release.ok") })) {
|
|
279
|
+
await this.api("DELETE", `/locks?path=${encodeURIComponent(key)}`);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const linkBtn = row.querySelector(".flink");
|
|
283
|
+
linkBtn.onclick = (e) => {
|
|
284
|
+
e.stopPropagation();
|
|
285
|
+
this.onLink(key);
|
|
286
|
+
};
|
|
287
|
+
row.onmouseenter = () => {
|
|
288
|
+
linkBtn.style.visibility = this.hasSelection() ? "visible" : "hidden";
|
|
289
|
+
};
|
|
290
|
+
row.onmouseleave = () => {
|
|
291
|
+
linkBtn.style.visibility = "hidden";
|
|
292
|
+
};
|
|
293
|
+
return row;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
export {
|
|
298
|
+
Explorer
|
|
299
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render } from "./md.js";
|
|
2
|
+
import { t } from "./i18n.js";
|
|
3
|
+
async function showGuidelines(api) {
|
|
4
|
+
let text = "";
|
|
5
|
+
try {
|
|
6
|
+
text = (await api("GET", "/guidelines")).text || "";
|
|
7
|
+
} catch {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const ov = document.createElement("div");
|
|
11
|
+
ov.className = "guide-modal";
|
|
12
|
+
ov.innerHTML = `
|
|
13
|
+
<div class="pj-box guide-box">
|
|
14
|
+
<div class="pj-head"><b>${t("guide.title")}</b><button class="guide-close" type="button" aria-label="${t("common.close")}">\u2715</button></div>
|
|
15
|
+
<div class="guide-body"></div>
|
|
16
|
+
<div class="guide-note">${t("guide.note")}</div>
|
|
17
|
+
</div>`;
|
|
18
|
+
ov.querySelector(".guide-body").innerHTML = render(text);
|
|
19
|
+
const close = () => {
|
|
20
|
+
ov.remove();
|
|
21
|
+
document.removeEventListener("keydown", onKey);
|
|
22
|
+
};
|
|
23
|
+
const onKey = (e) => {
|
|
24
|
+
if (e.key === "Escape") close();
|
|
25
|
+
};
|
|
26
|
+
document.addEventListener("keydown", onKey);
|
|
27
|
+
ov.onclick = (e) => {
|
|
28
|
+
if (e.target === ov) close();
|
|
29
|
+
};
|
|
30
|
+
ov.querySelector(".guide-close").onclick = close;
|
|
31
|
+
document.body.append(ov);
|
|
32
|
+
}
|
|
33
|
+
export {
|
|
34
|
+
showGuidelines
|
|
35
|
+
};
|