openclacky 1.5.2 → 1.5.4
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/.dockerignore +13 -0
- data/CHANGELOG.md +50 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +13 -15
- data/lib/clacky/agent/session_serializer.rb +6 -0
- data/lib/clacky/agent.rb +13 -1
- data/lib/clacky/agent_config.rb +42 -11
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/extension/api_extension.rb +3 -2
- data/lib/clacky/message_format/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +92 -4
- data/lib/clacky/providers.rb +67 -14
- data/lib/clacky/server/http_server.rb +361 -36
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/session_manager.rb +5 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/utils/model_pricing.rb +77 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1053 -143
- data/lib/clacky/web/app.js +19 -5
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/billing/view.js +2 -1
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/new-session/view.js +2 -2
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +99 -4
- data/lib/clacky/web/index.html +70 -17
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +146 -7
- data/lib/clacky/web/ws-dispatcher.js +59 -1
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
// ── Projects — project state, CRUD, sidebar rendering ─────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Responsibilities:
|
|
4
|
+
// - Maintain the canonical projects list (received via session_list WS event)
|
|
5
|
+
// - CRUD: create / update / delete projects via REST API
|
|
6
|
+
// - Move sessions in / out of projects via PATCH /api/sessions/:id/project
|
|
7
|
+
// - Render the #project-section sidebar area (two modes: grouped / flat)
|
|
8
|
+
//
|
|
9
|
+
// Depends on: Sessions (sessions.js), global $ / escapeHtml helpers
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const Projects = (() => {
|
|
13
|
+
let _projects = []; // [{ id, name, description, color, created_at, updated_at }]
|
|
14
|
+
|
|
15
|
+
// "grouped" (default) — each project is a collapsible row
|
|
16
|
+
// "flat" — all project-sessions listed in one flat list with project tag
|
|
17
|
+
let _viewMode = "grouped";
|
|
18
|
+
|
|
19
|
+
// "recent" (default) — sort by updated_at desc
|
|
20
|
+
// "priority" — oldest-created project first
|
|
21
|
+
const _LS_SORT_KEY = "clacky_projects_sort_mode";
|
|
22
|
+
const _LS_COLLAPSED_KEY = "clacky_projects_collapsed";
|
|
23
|
+
const _LS_SECTION_COLLAPSE_KEY = "clacky_projects_section_collapsed";
|
|
24
|
+
let _sortMode = localStorage.getItem(_LS_SORT_KEY) || "recent";
|
|
25
|
+
let _sectionCollapsed = localStorage.getItem(_LS_SECTION_COLLAPSE_KEY) === "1";
|
|
26
|
+
|
|
27
|
+
// Track which project groups are collapsed: Set of project IDs (persisted to localStorage)
|
|
28
|
+
const _collapsed = new Set(
|
|
29
|
+
JSON.parse(localStorage.getItem(_LS_COLLAPSED_KEY) || "[]")
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
function _saveCollapsed() {
|
|
33
|
+
localStorage.setItem(_LS_COLLAPSED_KEY, JSON.stringify([..._collapsed]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Return a sorted copy of _projects according to _sortMode. */
|
|
37
|
+
function _sortProjects(projects) {
|
|
38
|
+
return [...projects].sort((a, b) => {
|
|
39
|
+
if (_sortMode === "priority") {
|
|
40
|
+
// Oldest project first (by created_at)
|
|
41
|
+
return (a.created_at || "").localeCompare(b.created_at || "");
|
|
42
|
+
}
|
|
43
|
+
// Default "recent": most recently updated first
|
|
44
|
+
const ta = a.updated_at || a.created_at || "";
|
|
45
|
+
const tb = b.updated_at || b.created_at || "";
|
|
46
|
+
return tb.localeCompare(ta);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Shared icon helper ────────────────────────────────────────────────────
|
|
51
|
+
// Reads SVG path from the picker popup DOM and forces a consistent size.
|
|
52
|
+
// Falls back to a hard-coded folder icon when the popup isn't in the DOM yet.
|
|
53
|
+
const _FOLDER_PATH = `<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"/>`;
|
|
54
|
+
const _FOLDER_OPEN_PATH = `<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"/>`;
|
|
55
|
+
function _getIconSvg(iconName, size = 14) {
|
|
56
|
+
// folder-open is not in the picker; use the built-in path directly
|
|
57
|
+
if (iconName === "folder-open") {
|
|
58
|
+
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${_FOLDER_OPEN_PATH}</svg>`;
|
|
59
|
+
}
|
|
60
|
+
const popup = document.getElementById("project-picker-icons");
|
|
61
|
+
let inner = null;
|
|
62
|
+
if (popup) {
|
|
63
|
+
const btn = popup.querySelector(`[data-icon="${iconName}"]`);
|
|
64
|
+
if (btn) {
|
|
65
|
+
// Clone the SVG and override size attrs
|
|
66
|
+
const svg = btn.querySelector("svg");
|
|
67
|
+
if (svg) inner = svg.innerHTML;
|
|
68
|
+
}
|
|
69
|
+
if (!inner) {
|
|
70
|
+
const first = popup.querySelector("[data-icon] svg");
|
|
71
|
+
if (first) inner = first.innerHTML;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!inner) inner = _FOLDER_PATH;
|
|
75
|
+
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── State accessors ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function all() { return _projects; }
|
|
81
|
+
|
|
82
|
+
function find(id) { return id != null ? (_projects.find(p => p.id === String(id)) || null) : null; }
|
|
83
|
+
|
|
84
|
+
/** Called by ws-dispatcher when session_list arrives — replaces full list. */
|
|
85
|
+
function setAll(list) {
|
|
86
|
+
_projects = Array.isArray(list) ? [...list] : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── REST API ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** POST /api/projects — create a project. Returns { project } or throws. */
|
|
92
|
+
async function create({ name, description = null, color = null, icon = null, working_dir = null } = {}) {
|
|
93
|
+
const res = await fetch("/api/projects", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "Content-Type": "application/json" },
|
|
96
|
+
body: JSON.stringify({ name, description, color, icon, working_dir }),
|
|
97
|
+
});
|
|
98
|
+
const data = await res.json();
|
|
99
|
+
if (!res.ok) throw new Error(data.error || "Failed to create project");
|
|
100
|
+
|
|
101
|
+
const project = data.project;
|
|
102
|
+
_projects.push(project);
|
|
103
|
+
return project;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** PATCH /api/projects/:id — update a project's name / description / color. */
|
|
107
|
+
async function update(id, fields = {}) {
|
|
108
|
+
const res = await fetch(`/api/projects/${id}`, {
|
|
109
|
+
method: "PATCH",
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
body: JSON.stringify(fields),
|
|
112
|
+
});
|
|
113
|
+
const data = await res.json();
|
|
114
|
+
if (!res.ok) throw new Error(data.error || "Failed to update project");
|
|
115
|
+
|
|
116
|
+
const updated = data.project;
|
|
117
|
+
const idx = _projects.findIndex(p => p.id === id);
|
|
118
|
+
if (idx !== -1) _projects[idx] = updated;
|
|
119
|
+
return updated;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* DELETE /api/projects/:id — delete a project and all its sessions.
|
|
124
|
+
* The server soft-deletes the sessions; we remove them from local state
|
|
125
|
+
* and trigger a re-render.
|
|
126
|
+
*/
|
|
127
|
+
async function remove(id) {
|
|
128
|
+
const res = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
|
129
|
+
const data = await res.json();
|
|
130
|
+
if (!res.ok) throw new Error(data.error || "Failed to delete project");
|
|
131
|
+
|
|
132
|
+
_projects = _projects.filter(p => p.id !== id);
|
|
133
|
+
_collapsed.delete(id);
|
|
134
|
+
_saveCollapsed();
|
|
135
|
+
// Remove all sessions belonging to this project from local memory,
|
|
136
|
+
// then re-render both sections in one pass.
|
|
137
|
+
Sessions.removeByProjectId(id);
|
|
138
|
+
Sessions.renderList();
|
|
139
|
+
renderSection();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* PATCH /api/sessions/:sessionId/project — assign or remove a session's project.
|
|
144
|
+
* Pass projectId=null to remove from project.
|
|
145
|
+
*/
|
|
146
|
+
async function moveSession(sessionId, projectId) {
|
|
147
|
+
const res = await fetch(`/api/sessions/${sessionId}/project`, {
|
|
148
|
+
method: "PATCH",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ project_id: projectId }),
|
|
151
|
+
});
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
if (!res.ok) throw new Error(data.error || "Failed to move session");
|
|
154
|
+
|
|
155
|
+
Sessions.patch(sessionId, { project_id: projectId });
|
|
156
|
+
Sessions.renderList();
|
|
157
|
+
renderSection();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Render the #project-list container.
|
|
162
|
+
* Reads _viewMode to decide between "grouped" and "flat" layout.
|
|
163
|
+
* Called by ws-dispatcher after every list/update/delete event.
|
|
164
|
+
*/
|
|
165
|
+
function renderSection() {
|
|
166
|
+
const list = document.getElementById("project-list");
|
|
167
|
+
if (!list) return;
|
|
168
|
+
list.innerHTML = "";
|
|
169
|
+
|
|
170
|
+
if (_projects.length === 0) {
|
|
171
|
+
// Show an empty hint so the section doesn't look broken
|
|
172
|
+
const hint = document.createElement("div");
|
|
173
|
+
hint.className = "project-empty-hint";
|
|
174
|
+
hint.textContent = I18n.t("projects.emptyHint", "No projects yet");
|
|
175
|
+
list.appendChild(hint);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (_viewMode === "flat") {
|
|
180
|
+
_renderFlat(list);
|
|
181
|
+
} else {
|
|
182
|
+
_renderGrouped(list);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Flat mode: all project-sessions in one list, each tagged with project name. */
|
|
187
|
+
function _renderFlat(container) {
|
|
188
|
+
const sortedProjects = _sortProjects(_projects);
|
|
189
|
+
const allSessions = Sessions.all;
|
|
190
|
+
const projectSessions = allSessions
|
|
191
|
+
.filter(s => s.project_id)
|
|
192
|
+
.sort((a, b) => {
|
|
193
|
+
// Primary: group by project, ordered by sortedProjects
|
|
194
|
+
if (a.project_id !== b.project_id) {
|
|
195
|
+
const ai = sortedProjects.findIndex(p => p.id === a.project_id);
|
|
196
|
+
const bi = sortedProjects.findIndex(p => p.id === b.project_id);
|
|
197
|
+
return ai - bi;
|
|
198
|
+
}
|
|
199
|
+
// Secondary: within project, newest session first
|
|
200
|
+
const ta = a.updated_at || a.created_at || "";
|
|
201
|
+
const tb = b.updated_at || b.created_at || "";
|
|
202
|
+
return tb.localeCompare(ta);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Render with project tag only on first session of each project group
|
|
206
|
+
let lastProjectId = null;
|
|
207
|
+
projectSessions.forEach(s => {
|
|
208
|
+
// Show project tag header when project changes
|
|
209
|
+
if (s.project_id !== lastProjectId) {
|
|
210
|
+
lastProjectId = s.project_id;
|
|
211
|
+
const project = find(s.project_id);
|
|
212
|
+
if (project) {
|
|
213
|
+
const tag = document.createElement("div");
|
|
214
|
+
tag.className = "project-session-tag";
|
|
215
|
+
tag.textContent = project.name;
|
|
216
|
+
tag.style.setProperty("--project-color", project.color || "var(--color-accent-primary)");
|
|
217
|
+
container.appendChild(tag);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
Sessions.renderSessionItem(container, s);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Grouped mode: each project is a collapsible row with indented sessions. */
|
|
226
|
+
function _renderGrouped(container) {
|
|
227
|
+
const sortedProjects = _sortProjects(_projects);
|
|
228
|
+
|
|
229
|
+
sortedProjects.forEach(project => {
|
|
230
|
+
const sessions = Sessions.all
|
|
231
|
+
.filter(s => s.project_id === project.id)
|
|
232
|
+
.sort((a, b) => {
|
|
233
|
+
// Sessions within a project: always newest first
|
|
234
|
+
const ta = a.updated_at || a.created_at || "";
|
|
235
|
+
const tb = b.updated_at || b.created_at || "";
|
|
236
|
+
return tb.localeCompare(ta);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const isCollapsed = _collapsed.has(project.id);
|
|
240
|
+
|
|
241
|
+
// ── Project header row ──────────────────────────────────────────
|
|
242
|
+
const header = document.createElement("div");
|
|
243
|
+
header.className = "project-group-header" + (isCollapsed ? " collapsed" : "");
|
|
244
|
+
header.dataset.projectId = project.id;
|
|
245
|
+
|
|
246
|
+
// Icon or color dot — use shared _getIconSvg helper (size=14)
|
|
247
|
+
// When icon is "folder", switch to folder-open when the group is expanded
|
|
248
|
+
const color = project.color || null;
|
|
249
|
+
const iconName = project.icon || "folder";
|
|
250
|
+
const displayIcon = (iconName === "folder" && !isCollapsed) ? "folder-open" : iconName;
|
|
251
|
+
const iconSvg = _getIconSvg(displayIcon);
|
|
252
|
+
|
|
253
|
+
const dot = document.createElement("span");
|
|
254
|
+
dot.className = "project-icon-badge";
|
|
255
|
+
if (color) dot.style.color = color;
|
|
256
|
+
dot.innerHTML = iconSvg;
|
|
257
|
+
header.appendChild(dot);
|
|
258
|
+
|
|
259
|
+
// Project name
|
|
260
|
+
const nameEl = document.createElement("span");
|
|
261
|
+
nameEl.className = "project-name";
|
|
262
|
+
nameEl.textContent = project.name;
|
|
263
|
+
header.appendChild(nameEl);
|
|
264
|
+
|
|
265
|
+
// New session button (✎)
|
|
266
|
+
const newSessionBtn = document.createElement("button");
|
|
267
|
+
newSessionBtn.className = "project-actions-btn project-new-session-btn";
|
|
268
|
+
newSessionBtn.title = "New session";
|
|
269
|
+
newSessionBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
270
|
+
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>
|
|
271
|
+
<path d="M12 8v8"/>
|
|
272
|
+
<path d="M8 12h8"/>
|
|
273
|
+
</svg>`;
|
|
274
|
+
newSessionBtn.addEventListener("click", async e => {
|
|
275
|
+
e.stopPropagation();
|
|
276
|
+
try {
|
|
277
|
+
const name = Sessions.nextDefaultName();
|
|
278
|
+
const res = await fetch("/api/sessions", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "Content-Type": "application/json" },
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
name,
|
|
283
|
+
agent_profile: "general",
|
|
284
|
+
source: "manual",
|
|
285
|
+
project_id: project.id,
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
if (res.ok && data.session) {
|
|
290
|
+
Sessions.select(data.session.id);
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error("Failed to create session:", err);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
// Actions button (⋯)
|
|
297
|
+
const actionsBtn = document.createElement("button");
|
|
298
|
+
actionsBtn.className = "project-actions-btn";
|
|
299
|
+
actionsBtn.title = "Project options";
|
|
300
|
+
actionsBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
301
|
+
<circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/>
|
|
302
|
+
</svg>`;
|
|
303
|
+
actionsBtn.addEventListener("click", e => {
|
|
304
|
+
e.stopPropagation();
|
|
305
|
+
_clearTooltip();
|
|
306
|
+
_showProjectMenu(project.id, actionsBtn);
|
|
307
|
+
});
|
|
308
|
+
header.appendChild(actionsBtn);
|
|
309
|
+
header.appendChild(newSessionBtn);
|
|
310
|
+
|
|
311
|
+
// Tooltip on hover — show after short delay; hide via shared _hideTimer
|
|
312
|
+
header.addEventListener("mouseenter", () => {
|
|
313
|
+
_cancelHide();
|
|
314
|
+
clearTimeout(_tooltipTimer);
|
|
315
|
+
_tooltipTimer = setTimeout(() => {
|
|
316
|
+
_showProjectTooltip(project, sessions, header);
|
|
317
|
+
}, 300);
|
|
318
|
+
});
|
|
319
|
+
header.addEventListener("mouseleave", () => {
|
|
320
|
+
clearTimeout(_tooltipTimer);
|
|
321
|
+
_scheduleHide();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Toggle collapse on header click — in-place DOM toggle, no full re-render
|
|
325
|
+
header.addEventListener("click", () => {
|
|
326
|
+
_clearTooltip();
|
|
327
|
+
const nowCollapsed = _collapsed.has(project.id);
|
|
328
|
+
if (nowCollapsed) {
|
|
329
|
+
_collapsed.delete(project.id);
|
|
330
|
+
} else {
|
|
331
|
+
_collapsed.add(project.id);
|
|
332
|
+
}
|
|
333
|
+
const willCollapse = !nowCollapsed;
|
|
334
|
+
_saveCollapsed();
|
|
335
|
+
|
|
336
|
+
// Toggle header class
|
|
337
|
+
header.classList.toggle("collapsed", willCollapse);
|
|
338
|
+
|
|
339
|
+
// Toggle folder icon in-place
|
|
340
|
+
const iconName = project.icon || "folder";
|
|
341
|
+
if (iconName === "folder") {
|
|
342
|
+
const dot = header.querySelector(".project-icon-badge");
|
|
343
|
+
if (dot) dot.innerHTML = _getIconSvg(willCollapse ? "folder" : "folder-open");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Toggle session group visibility
|
|
347
|
+
const sessionGroup = header.nextElementSibling;
|
|
348
|
+
if (sessionGroup && sessionGroup.classList.contains("project-session-group")) {
|
|
349
|
+
sessionGroup.style.display = willCollapse ? "none" : "";
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
container.appendChild(header);
|
|
354
|
+
|
|
355
|
+
// ── Session items — always in DOM, hidden when collapsed ────────
|
|
356
|
+
const sessionGroup = document.createElement("div");
|
|
357
|
+
sessionGroup.className = "project-session-group";
|
|
358
|
+
if (isCollapsed) sessionGroup.style.display = "none";
|
|
359
|
+
|
|
360
|
+
if (sessions.length === 0) {
|
|
361
|
+
const empty = document.createElement("div");
|
|
362
|
+
empty.className = "project-sessions-empty";
|
|
363
|
+
empty.textContent = I18n.t("projects.noSessions", "No sessions");
|
|
364
|
+
sessionGroup.appendChild(empty);
|
|
365
|
+
} else {
|
|
366
|
+
sessions.forEach(s => Sessions.renderSessionItem(sessionGroup, s));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
container.appendChild(sessionGroup);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Project tooltip ────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
let _tooltipEl = null;
|
|
376
|
+
let _tooltipTimer = null; // show delay
|
|
377
|
+
let _hideTimer = null; // hide delay — shared by header + tooltip
|
|
378
|
+
|
|
379
|
+
/** Start hide countdown. Cancelled if mouse re-enters header or tooltip. */
|
|
380
|
+
function _scheduleHide() {
|
|
381
|
+
clearTimeout(_hideTimer);
|
|
382
|
+
_hideTimer = setTimeout(_clearTooltip, 150);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Cancel pending hide (mouse is back on header or tooltip). */
|
|
386
|
+
function _cancelHide() {
|
|
387
|
+
clearTimeout(_hideTimer);
|
|
388
|
+
_hideTimer = null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Create and position the hover tooltip for a project. */
|
|
392
|
+
function _showProjectTooltip(project, sessions, anchorEl) {
|
|
393
|
+
_clearTooltip();
|
|
394
|
+
|
|
395
|
+
const tip = document.createElement("div");
|
|
396
|
+
tip.className = "project-tooltip";
|
|
397
|
+
|
|
398
|
+
// ── Header: icon + name + session count ──────────────────────────
|
|
399
|
+
const header = document.createElement("div");
|
|
400
|
+
header.className = "project-tooltip-header";
|
|
401
|
+
|
|
402
|
+
// Project icon — use shared helper (12px, matches text size)
|
|
403
|
+
const iconWrap = document.createElement("span");
|
|
404
|
+
iconWrap.className = "project-tooltip-icon";
|
|
405
|
+
iconWrap.innerHTML = _getIconSvg(project.icon || "folder", 12);
|
|
406
|
+
if (project.color) iconWrap.style.color = project.color;
|
|
407
|
+
header.appendChild(iconWrap);
|
|
408
|
+
|
|
409
|
+
const nameEl = document.createElement("span");
|
|
410
|
+
nameEl.className = "project-tooltip-name";
|
|
411
|
+
nameEl.textContent = project.name;
|
|
412
|
+
header.appendChild(nameEl);
|
|
413
|
+
|
|
414
|
+
tip.appendChild(header);
|
|
415
|
+
|
|
416
|
+
// ── Info rows ─────────────────────────────────────────────────────
|
|
417
|
+
const rows = document.createElement("div");
|
|
418
|
+
rows.className = "project-tooltip-rows";
|
|
419
|
+
|
|
420
|
+
// Helper: build one row with icon (12px) + text
|
|
421
|
+
const addRow = (iconSvgHtml, text, titleText) => {
|
|
422
|
+
const row = document.createElement("div");
|
|
423
|
+
row.className = "project-tooltip-row";
|
|
424
|
+
|
|
425
|
+
const iconSpan = document.createElement("span");
|
|
426
|
+
iconSpan.className = "project-tooltip-row-icon";
|
|
427
|
+
iconSpan.innerHTML = iconSvgHtml;
|
|
428
|
+
row.appendChild(iconSpan);
|
|
429
|
+
|
|
430
|
+
const textSpan = document.createElement("span");
|
|
431
|
+
textSpan.className = "project-tooltip-row-text";
|
|
432
|
+
textSpan.textContent = text;
|
|
433
|
+
if (titleText) textSpan.title = titleText;
|
|
434
|
+
row.appendChild(textSpan);
|
|
435
|
+
|
|
436
|
+
rows.appendChild(row);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// message-circle icon — simple chat bubble, no "+" (used for session count display)
|
|
440
|
+
const chatIcon = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/></svg>`;
|
|
441
|
+
|
|
442
|
+
// Sessions row
|
|
443
|
+
const sessionText = sessions.length === 0
|
|
444
|
+
? I18n.t("projects.noSessions", "No sessions")
|
|
445
|
+
: I18n.t("projects.tooltip.sessions", "{n} sessions").replace("{n}", sessions.length);
|
|
446
|
+
addRow(chatIcon, sessionText);
|
|
447
|
+
|
|
448
|
+
// Working dir row (only if set) — reuse picker folder icon at 12px
|
|
449
|
+
if (project.working_dir) {
|
|
450
|
+
const divider = document.createElement("div");
|
|
451
|
+
divider.className = "project-tooltip-divider";
|
|
452
|
+
rows.appendChild(divider);
|
|
453
|
+
|
|
454
|
+
const dirRow = document.createElement("div");
|
|
455
|
+
dirRow.className = "project-tooltip-row project-tooltip-row--clickable";
|
|
456
|
+
dirRow.title = project.working_dir;
|
|
457
|
+
|
|
458
|
+
const iconSpan = document.createElement("span");
|
|
459
|
+
iconSpan.className = "project-tooltip-row-icon";
|
|
460
|
+
iconSpan.innerHTML = _getIconSvg("folder", 12);
|
|
461
|
+
dirRow.appendChild(iconSpan);
|
|
462
|
+
|
|
463
|
+
const textSpan = document.createElement("span");
|
|
464
|
+
textSpan.className = "project-tooltip-row-text";
|
|
465
|
+
textSpan.textContent = project.working_dir;
|
|
466
|
+
dirRow.appendChild(textSpan);
|
|
467
|
+
|
|
468
|
+
// Click: reveal in Finder/Explorer
|
|
469
|
+
dirRow.addEventListener("click", () => {
|
|
470
|
+
fetch("/api/file-action", {
|
|
471
|
+
method: "POST",
|
|
472
|
+
headers: { "Content-Type": "application/json" },
|
|
473
|
+
body: JSON.stringify({ path: project.working_dir, action: "reveal" }),
|
|
474
|
+
}).catch(err => console.error("[Projects] reveal error:", err));
|
|
475
|
+
_clearTooltip();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
rows.appendChild(dirRow);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
tip.appendChild(rows);
|
|
482
|
+
|
|
483
|
+
// ── Positioning: right side of the sidebar ───────────────────────
|
|
484
|
+
document.body.appendChild(tip);
|
|
485
|
+
_tooltipEl = tip;
|
|
486
|
+
|
|
487
|
+
// Hide when mouse leaves tooltip — use shared timer so gap crossing works
|
|
488
|
+
tip.addEventListener("mouseenter", () => _cancelHide());
|
|
489
|
+
tip.addEventListener("mouseleave", () => _scheduleHide());
|
|
490
|
+
|
|
491
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
492
|
+
const tipRect = tip.getBoundingClientRect();
|
|
493
|
+
const margin = 8; // gap between sidebar and tooltip card
|
|
494
|
+
|
|
495
|
+
// Position tooltip 8px right of anchor; ::before pseudo fills the physical gap
|
|
496
|
+
let left = rect.right + margin;
|
|
497
|
+
if (left + tipRect.width > window.innerWidth - margin) {
|
|
498
|
+
left = rect.left - tipRect.width - margin;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Vertically align top with anchor row, but keep inside viewport
|
|
502
|
+
let top = rect.top;
|
|
503
|
+
if (top + tipRect.height > window.innerHeight - margin) {
|
|
504
|
+
top = window.innerHeight - tipRect.height - margin;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
tip.style.left = `${Math.max(margin, left)}px`;
|
|
508
|
+
tip.style.top = `${Math.max(margin, top)}px`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function _clearTooltip() {
|
|
512
|
+
clearTimeout(_tooltipTimer);
|
|
513
|
+
_tooltipTimer = null;
|
|
514
|
+
clearTimeout(_hideTimer);
|
|
515
|
+
_hideTimer = null;
|
|
516
|
+
if (_tooltipEl) {
|
|
517
|
+
_tooltipEl.remove();
|
|
518
|
+
_tooltipEl = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Show a small context menu for project actions (rename / delete). */
|
|
523
|
+
function _showProjectMenu(projectId, anchor) {
|
|
524
|
+
// Remove any existing project menu
|
|
525
|
+
document.querySelectorAll(".project-actions-menu").forEach(m => m.remove());
|
|
526
|
+
|
|
527
|
+
const menu = document.createElement("div");
|
|
528
|
+
menu.className = "project-actions-menu";
|
|
529
|
+
|
|
530
|
+
const addItem = (labelKey, defaultLabel, danger, onClick) => {
|
|
531
|
+
const item = document.createElement("button");
|
|
532
|
+
item.className = "project-actions-menu-item" + (danger ? " danger" : "");
|
|
533
|
+
item.textContent = I18n.t(labelKey, defaultLabel);
|
|
534
|
+
item.addEventListener("click", e => {
|
|
535
|
+
e.stopPropagation();
|
|
536
|
+
menu.remove();
|
|
537
|
+
onClick();
|
|
538
|
+
});
|
|
539
|
+
menu.appendChild(item);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
addItem("projects.menu.rename", "Rename", false, () => promptRename(projectId));
|
|
543
|
+
addItem("projects.menu.delete", "Delete", true, () => promptDelete(projectId));
|
|
544
|
+
|
|
545
|
+
// Position near anchor
|
|
546
|
+
const rect = anchor.getBoundingClientRect();
|
|
547
|
+
menu.style.position = "fixed";
|
|
548
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
549
|
+
menu.style.left = `${rect.left}px`;
|
|
550
|
+
document.body.appendChild(menu);
|
|
551
|
+
|
|
552
|
+
// Close on click outside
|
|
553
|
+
const dismiss = e => {
|
|
554
|
+
if (!menu.contains(e.target)) {
|
|
555
|
+
menu.remove();
|
|
556
|
+
document.removeEventListener("mousedown", dismiss, true);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
document.addEventListener("mousedown", dismiss, true);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Show the organize menu (toggle view mode + sort mode). */
|
|
563
|
+
function _showOrganizeMenu(anchor) {
|
|
564
|
+
document.querySelectorAll(".project-organize-menu").forEach(m => m.remove());
|
|
565
|
+
|
|
566
|
+
const menu = document.createElement("div");
|
|
567
|
+
menu.className = "project-organize-menu";
|
|
568
|
+
|
|
569
|
+
// ── View mode section ──────────────────────────────────────────────
|
|
570
|
+
const viewSection = document.createElement("div");
|
|
571
|
+
viewSection.className = "project-organize-section";
|
|
572
|
+
|
|
573
|
+
const addViewItem = (labelKey, defaultLabel, mode) => {
|
|
574
|
+
const item = document.createElement("button");
|
|
575
|
+
item.className = "project-organize-item" + (_viewMode === mode ? " active" : "");
|
|
576
|
+
item.textContent = I18n.t(labelKey, defaultLabel);
|
|
577
|
+
item.addEventListener("click", e => {
|
|
578
|
+
e.stopPropagation();
|
|
579
|
+
menu.remove();
|
|
580
|
+
_viewMode = mode;
|
|
581
|
+
renderSection();
|
|
582
|
+
});
|
|
583
|
+
viewSection.appendChild(item);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
addViewItem("projects.organize.grouped", "By Project", "grouped");
|
|
587
|
+
addViewItem("projects.organize.flat", "In a flat list", "flat");
|
|
588
|
+
menu.appendChild(viewSection);
|
|
589
|
+
|
|
590
|
+
// ── Divider ────────────────────────────────────────────────────────
|
|
591
|
+
const divider = document.createElement("div");
|
|
592
|
+
divider.className = "project-organize-divider";
|
|
593
|
+
menu.appendChild(divider);
|
|
594
|
+
|
|
595
|
+
// ── Sort mode section ──────────────────────────────────────────────
|
|
596
|
+
const sortLabel = document.createElement("div");
|
|
597
|
+
sortLabel.className = "project-organize-section-label";
|
|
598
|
+
sortLabel.textContent = I18n.t("projects.organize.sortLabel", "Sort by");
|
|
599
|
+
menu.appendChild(sortLabel);
|
|
600
|
+
|
|
601
|
+
const sortSection = document.createElement("div");
|
|
602
|
+
sortSection.className = "project-organize-section";
|
|
603
|
+
|
|
604
|
+
const addSortItem = (labelKey, defaultLabel, mode) => {
|
|
605
|
+
const item = document.createElement("button");
|
|
606
|
+
item.className = "project-organize-item" + (_sortMode === mode ? " active" : "");
|
|
607
|
+
item.textContent = I18n.t(labelKey, defaultLabel);
|
|
608
|
+
item.addEventListener("click", e => {
|
|
609
|
+
e.stopPropagation();
|
|
610
|
+
menu.remove();
|
|
611
|
+
_sortMode = mode;
|
|
612
|
+
localStorage.setItem(_LS_SORT_KEY, mode);
|
|
613
|
+
renderSection();
|
|
614
|
+
});
|
|
615
|
+
sortSection.appendChild(item);
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
addSortItem("projects.organize.sortPriority", "Priority", "priority");
|
|
619
|
+
addSortItem("projects.organize.sortRecent", "Last updated", "recent");
|
|
620
|
+
menu.appendChild(sortSection);
|
|
621
|
+
|
|
622
|
+
const rect = anchor.getBoundingClientRect();
|
|
623
|
+
menu.style.position = "fixed";
|
|
624
|
+
menu.style.top = `${rect.bottom + 4}px`;
|
|
625
|
+
menu.style.right = `${window.innerWidth - rect.right}px`;
|
|
626
|
+
document.body.appendChild(menu);
|
|
627
|
+
|
|
628
|
+
const dismiss = e => {
|
|
629
|
+
if (!menu.contains(e.target)) {
|
|
630
|
+
menu.remove();
|
|
631
|
+
document.removeEventListener("mousedown", dismiss, true);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
document.addEventListener("mousedown", dismiss, true);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ── Create project modal ───────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
// ── Create project modal ───────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Lazily inject the picker popup + create-project modal DOM into <body>.
|
|
643
|
+
* Called once before the modal is shown; subsequent calls are no-ops.
|
|
644
|
+
*/
|
|
645
|
+
function _ensureModalDOM() {
|
|
646
|
+
if (document.getElementById("project-picker-popup")) return;
|
|
647
|
+
const tpl = document.createElement("div");
|
|
648
|
+
tpl.innerHTML = `
|
|
649
|
+
<div id="project-picker-popup" class="project-picker-popup" style="display:none">
|
|
650
|
+
<!-- Color row -->
|
|
651
|
+
<div class="project-picker-colors" id="project-picker-colors">
|
|
652
|
+
<button class="project-picker-color-option project-picker-color-option--selected project-picker-color-none" data-color="" title="Default"></button>
|
|
653
|
+
<button class="project-picker-color-option" data-color="#ef4444" style="background:#ef4444" title="Red"></button>
|
|
654
|
+
<button class="project-picker-color-option" data-color="#f97316" style="background:#f97316" title="Orange"></button>
|
|
655
|
+
<button class="project-picker-color-option" data-color="#eab308" style="background:#eab308" title="Yellow"></button>
|
|
656
|
+
<button class="project-picker-color-option" data-color="#22c55e" style="background:#22c55e" title="Green"></button>
|
|
657
|
+
<button class="project-picker-color-option" data-color="#3b82f6" style="background:#3b82f6" title="Blue"></button>
|
|
658
|
+
<button class="project-picker-color-option" data-color="#8b5cf6" style="background:#8b5cf6" title="Purple"></button>
|
|
659
|
+
<button class="project-picker-color-option" data-color="#ec4899" style="background:#ec4899" title="Pink"></button>
|
|
660
|
+
</div>
|
|
661
|
+
<!-- Divider -->
|
|
662
|
+
<div class="project-picker-divider"></div>
|
|
663
|
+
<!-- Icon grid -->
|
|
664
|
+
<div class="project-picker-icons" id="project-picker-icons">
|
|
665
|
+
<button class="project-picker-icon-option project-picker-icon-option--selected" data-icon="folder" title="Folder">
|
|
666
|
+
<svg viewBox="0 0 24 24" width="16" height="16" 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>
|
|
667
|
+
</button>
|
|
668
|
+
<button class="project-picker-icon-option" data-icon="code" title="Code">
|
|
669
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
670
|
+
</button>
|
|
671
|
+
<button class="project-picker-icon-option" data-icon="book" title="Book">
|
|
672
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
|
673
|
+
</button>
|
|
674
|
+
<button class="project-picker-icon-option" data-icon="globe" title="Globe">
|
|
675
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
|
676
|
+
</button>
|
|
677
|
+
<button class="project-picker-icon-option" data-icon="zap" title="Zap">
|
|
678
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
|
679
|
+
</button>
|
|
680
|
+
<button class="project-picker-icon-option" data-icon="star" title="Star">
|
|
681
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
|
682
|
+
</button>
|
|
683
|
+
<button class="project-picker-icon-option" data-icon="heart" title="Heart">
|
|
684
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
|
685
|
+
</button>
|
|
686
|
+
<button class="project-picker-icon-option" data-icon="tool" title="Tool">
|
|
687
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
|
688
|
+
</button>
|
|
689
|
+
<button class="project-picker-icon-option" data-icon="database" title="Database">
|
|
690
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
|
691
|
+
</button>
|
|
692
|
+
<button class="project-picker-icon-option" data-icon="image" title="Image">
|
|
693
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="16" height="16" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
|
694
|
+
</button>
|
|
695
|
+
<button class="project-picker-icon-option" data-icon="music" title="Music">
|
|
696
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
697
|
+
</button>
|
|
698
|
+
<button class="project-picker-icon-option" data-icon="coffee" title="Coffee">
|
|
699
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
|
700
|
+
</button>
|
|
701
|
+
<button class="project-picker-icon-option" data-icon="briefcase" title="Briefcase">
|
|
702
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
|
|
703
|
+
</button>
|
|
704
|
+
<button class="project-picker-icon-option" data-icon="flask" title="Flask">
|
|
705
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6"/><path d="M10 3v6l-4 8a1 1 0 0 0 .9 1.5h10.2a1 1 0 0 0 .9-1.5L14 9V3"/></svg>
|
|
706
|
+
</button>
|
|
707
|
+
<button class="project-picker-icon-option" data-icon="chart" title="Chart">
|
|
708
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
|
709
|
+
</button>
|
|
710
|
+
<button class="project-picker-icon-option" data-icon="rocket" title="Rocket">
|
|
711
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>
|
|
712
|
+
</button>
|
|
713
|
+
<button class="project-picker-icon-option" data-icon="dollar" title="Dollar">
|
|
714
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
715
|
+
</button>
|
|
716
|
+
<button class="project-picker-icon-option" data-icon="cpu" title="CPU">
|
|
717
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
|
|
718
|
+
</button>
|
|
719
|
+
<button class="project-picker-icon-option" data-icon="paint" title="Paint">
|
|
720
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="12.5" r=".5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>
|
|
721
|
+
</button>
|
|
722
|
+
<button class="project-picker-icon-option" data-icon="leaf" title="Leaf">
|
|
723
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
|
|
724
|
+
</button>
|
|
725
|
+
<button class="project-picker-icon-option" data-icon="trophy" title="Trophy">
|
|
726
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 21 8 14 4 9 4 4 20 4 20 9 16 14 16 21"/><line x1="8" y1="21" x2="16" y2="21"/><path d="M4 9H2a1 1 0 0 0-1 1 6 6 0 0 0 6 6"/><path d="M20 9h2a1 1 0 0 1 1 1 6 6 0 0 1-6 6"/></svg>
|
|
727
|
+
</button>
|
|
728
|
+
<button class="project-picker-icon-option" data-icon="smile" title="Smile">
|
|
729
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
|
730
|
+
</button>
|
|
731
|
+
<button class="project-picker-icon-option" data-icon="map" title="Map">
|
|
732
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg>
|
|
733
|
+
</button>
|
|
734
|
+
<button class="project-picker-icon-option" data-icon="flower" title="Flower">
|
|
735
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7.5a4.5 4.5 0 1 1 4.5 4.5M12 7.5A4.5 4.5 0 1 0 7.5 12M12 7.5V9m-4.5 3a4.5 4.5 0 1 0 4.5 4.5M7.5 12H9m7.5 0a4.5 4.5 0 1 1-4.5 4.5m4.5-4.5H15m-3 4.5V15"/><circle cx="12" cy="12" r="3"/></svg>
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
<!-- Done button -->
|
|
739
|
+
<div class="project-picker-footer">
|
|
740
|
+
<button id="picker-popup-done" class="project-picker-done-btn" data-i18n="projects.picker.done">Done</button>
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
<div id="create-project-modal-overlay" class="modal-overlay" style="display:none">
|
|
744
|
+
<div class="modal-box sm">
|
|
745
|
+
<div class="modal-header">
|
|
746
|
+
<h3 class="modal-title" data-i18n="projects.create.title">New Project</h3>
|
|
747
|
+
</div>
|
|
748
|
+
<div class="modal-body">
|
|
749
|
+
<div class="create-project-input-row">
|
|
750
|
+
<button id="create-project-icon-trigger" class="project-icon-trigger" type="button" title="Choose icon & color">
|
|
751
|
+
<svg id="create-project-trigger-svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
|
752
|
+
</button>
|
|
753
|
+
<input type="text" id="create-project-modal-input" class="modal-input create-project-name-input" autocomplete="off" spellcheck="false" placeholder="" data-i18n-placeholder="projects.create.placeholder">
|
|
754
|
+
</div>
|
|
755
|
+
<div class="create-project-folder-section">
|
|
756
|
+
<div class="create-project-folder-label" data-i18n="projects.create.sourceFolders">Source folders</div>
|
|
757
|
+
<div id="create-project-folder-area" class="create-project-folder-area">
|
|
758
|
+
<div id="create-project-folder-empty" class="create-project-folder-empty">
|
|
759
|
+
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
760
|
+
<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"/>
|
|
761
|
+
<line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/>
|
|
762
|
+
</svg>
|
|
763
|
+
<span data-i18n="projects.create.folderHint">Add a working folder</span>
|
|
764
|
+
</div>
|
|
765
|
+
<div id="create-project-folder-card" class="create-project-folder-card" style="display:none">
|
|
766
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
767
|
+
<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"/>
|
|
768
|
+
</svg>
|
|
769
|
+
<span id="create-project-folder-name" class="create-project-folder-name"></span>
|
|
770
|
+
<button id="create-project-folder-remove" class="create-project-folder-remove" type="button" title="Remove">
|
|
771
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
772
|
+
</button>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
</div>
|
|
776
|
+
</div>
|
|
777
|
+
<div class="modal-footer">
|
|
778
|
+
<button id="create-project-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
|
|
779
|
+
<button id="create-project-modal-save" class="btn-primary" data-i18n="projects.create.submit">Create Project</button>
|
|
780
|
+
</div>
|
|
781
|
+
</div>
|
|
782
|
+
</div>`;
|
|
783
|
+
// Append all top-level nodes to body
|
|
784
|
+
while (tpl.firstChild) document.body.appendChild(tpl.firstChild);
|
|
785
|
+
// Apply i18n to newly injected nodes
|
|
786
|
+
if (typeof I18n !== "undefined") I18n.applyAll();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Show the create-project modal and resolve with { name, color, icon, working_dir }
|
|
791
|
+
* or null if cancelled. Owned here because all DOM nodes belong to project UI.
|
|
792
|
+
*/
|
|
793
|
+
function _showCreateProjectModal() {
|
|
794
|
+
_ensureModalDOM();
|
|
795
|
+
return new Promise(resolve => {
|
|
796
|
+
const $ = id => document.getElementById(id);
|
|
797
|
+
const overlay = $("create-project-modal-overlay");
|
|
798
|
+
const input = $("create-project-modal-input");
|
|
799
|
+
const trigger = $("create-project-icon-trigger");
|
|
800
|
+
const popup = $("project-picker-popup");
|
|
801
|
+
const popupColors = $("project-picker-colors");
|
|
802
|
+
const popupIcons = $("project-picker-icons");
|
|
803
|
+
const doneBtn = $("picker-popup-done");
|
|
804
|
+
const folderArea = $("create-project-folder-area");
|
|
805
|
+
const folderEmpty = $("create-project-folder-empty");
|
|
806
|
+
const folderCard = $("create-project-folder-card");
|
|
807
|
+
const folderName = $("create-project-folder-name");
|
|
808
|
+
const folderRemoveBtn = $("create-project-folder-remove");
|
|
809
|
+
|
|
810
|
+
// State
|
|
811
|
+
let selectedColor = ""; // "" = default (accent)
|
|
812
|
+
let selectedIcon = "folder";
|
|
813
|
+
let selectedDir = null; // absolute path chosen by directory picker
|
|
814
|
+
let popupOpen = false;
|
|
815
|
+
let pickerBusy = false; // prevent double-open of directory picker
|
|
816
|
+
|
|
817
|
+
// Icon SVGs lookup (same set as popup, keyed by data-icon)
|
|
818
|
+
const _getIconSvg = (icon) => {
|
|
819
|
+
const btn = popupIcons.querySelector(`[data-icon="${icon}"]`);
|
|
820
|
+
return btn ? btn.innerHTML : popupIcons.querySelector("[data-icon]").innerHTML;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// Update trigger button appearance
|
|
824
|
+
const _updateTrigger = () => {
|
|
825
|
+
if (selectedColor) {
|
|
826
|
+
trigger.style.background = selectedColor;
|
|
827
|
+
trigger.style.color = "#fff";
|
|
828
|
+
trigger.style.borderColor = "transparent";
|
|
829
|
+
} else {
|
|
830
|
+
trigger.style.background = "";
|
|
831
|
+
trigger.style.color = "";
|
|
832
|
+
trigger.style.borderColor = "";
|
|
833
|
+
}
|
|
834
|
+
trigger.innerHTML = _getIconSvg(selectedIcon);
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// Helper: extract the last path segment (folder name) from an absolute path
|
|
838
|
+
const _dirBasename = (p) => p ? p.replace(/\/+$/, "").split("/").pop() || p : "";
|
|
839
|
+
|
|
840
|
+
// Show / hide folder card vs empty state; also auto-fill project name from dir
|
|
841
|
+
const _updateFolderArea = () => {
|
|
842
|
+
if (selectedDir) {
|
|
843
|
+
folderEmpty.style.display = "none";
|
|
844
|
+
folderCard.style.display = "flex";
|
|
845
|
+
folderName.textContent = selectedDir;
|
|
846
|
+
folderName.title = selectedDir;
|
|
847
|
+
folderArea.style.cursor = "default";
|
|
848
|
+
// Auto-fill name if user hasn't typed anything yet
|
|
849
|
+
if (!input.value.trim()) {
|
|
850
|
+
input.value = _dirBasename(selectedDir);
|
|
851
|
+
input.classList.remove("input-error");
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
folderEmpty.style.display = "";
|
|
855
|
+
folderCard.style.display = "none";
|
|
856
|
+
folderArea.style.cursor = "pointer";
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// Reset all state
|
|
861
|
+
input.value = "";
|
|
862
|
+
input.classList.remove("input-error");
|
|
863
|
+
selectedColor = "";
|
|
864
|
+
selectedIcon = "folder";
|
|
865
|
+
selectedDir = null;
|
|
866
|
+
_updateTrigger();
|
|
867
|
+
_updateFolderArea();
|
|
868
|
+
|
|
869
|
+
// Reset popup selections
|
|
870
|
+
popupColors.querySelectorAll(".project-picker-color-option").forEach(b => {
|
|
871
|
+
b.classList.toggle("project-picker-color-option--selected", b.dataset.color === "");
|
|
872
|
+
});
|
|
873
|
+
popupIcons.querySelectorAll(".project-picker-icon-option").forEach(b => {
|
|
874
|
+
b.classList.toggle("project-picker-icon-option--selected", b.dataset.icon === "folder");
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Show/hide icon/color popup
|
|
878
|
+
const _openPopup = () => {
|
|
879
|
+
popupOpen = true;
|
|
880
|
+
const rect = trigger.getBoundingClientRect();
|
|
881
|
+
popup.style.display = "block";
|
|
882
|
+
requestAnimationFrame(() => {
|
|
883
|
+
const pw = popup.offsetWidth;
|
|
884
|
+
const ph = popup.offsetHeight;
|
|
885
|
+
let left = rect.left;
|
|
886
|
+
let top = rect.bottom + 6;
|
|
887
|
+
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
|
|
888
|
+
if (top + ph > window.innerHeight - 8) top = rect.top - ph - 6;
|
|
889
|
+
popup.style.left = `${left}px`;
|
|
890
|
+
popup.style.top = `${top}px`;
|
|
891
|
+
});
|
|
892
|
+
};
|
|
893
|
+
const _closePopup = () => {
|
|
894
|
+
popupOpen = false;
|
|
895
|
+
popup.style.display = "none";
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Move popup / overlay to top of body to avoid clipping
|
|
899
|
+
if (popup.parentNode !== document.body) document.body.appendChild(popup);
|
|
900
|
+
if (overlay.parentNode !== document.body || overlay.nextSibling) document.body.appendChild(overlay);
|
|
901
|
+
|
|
902
|
+
overlay.style.display = "flex";
|
|
903
|
+
setTimeout(() => { input.focus(); }, 50);
|
|
904
|
+
|
|
905
|
+
// ── Event handlers ──────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
const onTriggerClick = (e) => {
|
|
908
|
+
e.stopPropagation();
|
|
909
|
+
if (popupOpen) _closePopup(); else _openPopup();
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
const onPopupColorClick = (e) => {
|
|
913
|
+
const btn = e.target.closest(".project-picker-color-option");
|
|
914
|
+
if (!btn) return;
|
|
915
|
+
selectedColor = btn.dataset.color;
|
|
916
|
+
popupColors.querySelectorAll(".project-picker-color-option").forEach(b =>
|
|
917
|
+
b.classList.toggle("project-picker-color-option--selected", b === btn)
|
|
918
|
+
);
|
|
919
|
+
_updateTrigger();
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
const onPopupIconClick = (e) => {
|
|
923
|
+
const btn = e.target.closest(".project-picker-icon-option");
|
|
924
|
+
if (!btn) return;
|
|
925
|
+
selectedIcon = btn.dataset.icon;
|
|
926
|
+
popupIcons.querySelectorAll(".project-picker-icon-option").forEach(b =>
|
|
927
|
+
b.classList.toggle("project-picker-icon-option--selected", b === btn)
|
|
928
|
+
);
|
|
929
|
+
_updateTrigger();
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const onDoneClick = (e) => {
|
|
933
|
+
e.stopPropagation();
|
|
934
|
+
_closePopup();
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const onDocClick = (e) => {
|
|
938
|
+
if (popupOpen && !popup.contains(e.target) && e.target !== trigger) {
|
|
939
|
+
_closePopup();
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Folder area click: open directory picker (session-less mode)
|
|
944
|
+
const onFolderAreaClick = async (e) => {
|
|
945
|
+
if (e.target.closest(".create-project-folder-remove")) return;
|
|
946
|
+
if (selectedDir) return;
|
|
947
|
+
if (pickerBusy) return;
|
|
948
|
+
pickerBusy = true;
|
|
949
|
+
try {
|
|
950
|
+
let startDir = null;
|
|
951
|
+
try {
|
|
952
|
+
const r = await fetch("/api/dirs");
|
|
953
|
+
if (r.ok) { const d = await r.json(); startDir = d.default || d.root || null; }
|
|
954
|
+
} catch (_) { /* ignore, picker will fall back to home */ }
|
|
955
|
+
const chosen = await window.openDirectoryPicker(startDir, null);
|
|
956
|
+
if (chosen) {
|
|
957
|
+
selectedDir = chosen;
|
|
958
|
+
_updateFolderArea();
|
|
959
|
+
}
|
|
960
|
+
} finally {
|
|
961
|
+
pickerBusy = false;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
// Remove button clears the selected folder
|
|
966
|
+
const onFolderRemove = (e) => {
|
|
967
|
+
e.stopPropagation();
|
|
968
|
+
const prevDir = selectedDir;
|
|
969
|
+
selectedDir = null;
|
|
970
|
+
if (input.value === _dirBasename(prevDir)) input.value = "";
|
|
971
|
+
_updateFolderArea();
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
trigger.addEventListener("click", onTriggerClick);
|
|
975
|
+
popupColors.addEventListener("click", onPopupColorClick);
|
|
976
|
+
popupIcons.addEventListener("click", onPopupIconClick);
|
|
977
|
+
doneBtn.addEventListener("click", onDoneClick);
|
|
978
|
+
document.addEventListener("click", onDocClick);
|
|
979
|
+
folderArea.addEventListener("click", onFolderAreaClick);
|
|
980
|
+
folderRemoveBtn.addEventListener("click", onFolderRemove);
|
|
981
|
+
|
|
982
|
+
// ── Cleanup & resolve ───────────────────────────────────────────
|
|
983
|
+
const cleanup = (result) => {
|
|
984
|
+
_closePopup();
|
|
985
|
+
overlay.style.display = "none";
|
|
986
|
+
$("create-project-modal-save").onclick = null;
|
|
987
|
+
$("create-project-modal-cancel").onclick = null;
|
|
988
|
+
overlay.onclick = null;
|
|
989
|
+
input.onkeydown = null;
|
|
990
|
+
input.oninput = null;
|
|
991
|
+
trigger.removeEventListener("click", onTriggerClick);
|
|
992
|
+
popupColors.removeEventListener("click", onPopupColorClick);
|
|
993
|
+
popupIcons.removeEventListener("click", onPopupIconClick);
|
|
994
|
+
doneBtn.removeEventListener("click", onDoneClick);
|
|
995
|
+
document.removeEventListener("click", onDocClick);
|
|
996
|
+
folderArea.removeEventListener("click", onFolderAreaClick);
|
|
997
|
+
folderRemoveBtn.removeEventListener("click", onFolderRemove);
|
|
998
|
+
unbindEnter();
|
|
999
|
+
resolve(result);
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const saveHandler = () => {
|
|
1003
|
+
const rawName = input.value.trim();
|
|
1004
|
+
const name = rawName || _dirBasename(selectedDir) || I18n.t("projects.defaultName", "New Project");
|
|
1005
|
+
const color = selectedColor || null;
|
|
1006
|
+
const icon = selectedIcon || null;
|
|
1007
|
+
const working_dir = selectedDir || null;
|
|
1008
|
+
cleanup({ name, color, icon, working_dir });
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
input.oninput = () => input.classList.remove("input-error");
|
|
1012
|
+
$("create-project-modal-save").onclick = saveHandler;
|
|
1013
|
+
$("create-project-modal-cancel").onclick = () => cleanup(null);
|
|
1014
|
+
|
|
1015
|
+
const unbindEnter = IME.bindEnter(input, saveHandler);
|
|
1016
|
+
input.onkeydown = (e) => { if (e.key === "Escape") cleanup(null); };
|
|
1017
|
+
|
|
1018
|
+
overlay.onclick = (e) => {
|
|
1019
|
+
if (e.target.id === "create-project-modal-overlay") cleanup(null);
|
|
1020
|
+
};
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ── UI helpers ─────────────────────────────────────────────────────────────
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Prompt for a project name and create it.
|
|
1028
|
+
* Returns the created project, or null if the user cancels.
|
|
1029
|
+
*/
|
|
1030
|
+
async function promptCreate() {
|
|
1031
|
+
const result = await _showCreateProjectModal();
|
|
1032
|
+
if (!result || !result.name || !result.name.trim()) return null;
|
|
1033
|
+
|
|
1034
|
+
try {
|
|
1035
|
+
const project = await create({
|
|
1036
|
+
name: result.name.trim(),
|
|
1037
|
+
color: result.color || null,
|
|
1038
|
+
icon: result.icon || null,
|
|
1039
|
+
working_dir: result.working_dir || null,
|
|
1040
|
+
});
|
|
1041
|
+
renderSection();
|
|
1042
|
+
return project;
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
console.error("[Projects] create error:", err);
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** Prompt for a new name and update an existing project. */
|
|
1050
|
+
async function promptRename(id) {
|
|
1051
|
+
const project = find(id);
|
|
1052
|
+
if (!project) return;
|
|
1053
|
+
|
|
1054
|
+
const name = await Modal.rename(project.name, "projects.modal.name");
|
|
1055
|
+
if (!name || name === project.name) return;
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
await update(id, { name: name.trim() });
|
|
1059
|
+
renderSection();
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
console.error("[Projects] rename error:", err);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Confirm and delete a project. */
|
|
1066
|
+
async function promptDelete(id) {
|
|
1067
|
+
const project = find(id);
|
|
1068
|
+
if (!project) return;
|
|
1069
|
+
|
|
1070
|
+
const confirmed = await Modal.confirm(
|
|
1071
|
+
I18n.t("projects.delete.confirm", { name: project.name })
|
|
1072
|
+
);
|
|
1073
|
+
if (!confirmed) return;
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
await remove(id);
|
|
1077
|
+
// remove() already calls Sessions.renderList() + renderSection()
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.error("[Projects] delete error:", err);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── Init ───────────────────────────────────────────────────────────────────
|
|
1084
|
+
|
|
1085
|
+
function init() {
|
|
1086
|
+
const btnNew = document.getElementById("btn-new-project");
|
|
1087
|
+
const btnOrganize = document.getElementById("btn-projects-organize");
|
|
1088
|
+
const sectionHeader = document.getElementById("projects-section-header");
|
|
1089
|
+
const projectList = document.getElementById("project-list");
|
|
1090
|
+
|
|
1091
|
+
// Section-level collapse (click on title / chevron area, not on action buttons)
|
|
1092
|
+
if (sectionHeader && projectList) {
|
|
1093
|
+
// Apply persisted state on init
|
|
1094
|
+
if (_sectionCollapsed) {
|
|
1095
|
+
projectList.classList.add("project-list-collapsed");
|
|
1096
|
+
sectionHeader.classList.add("projects-section-collapsed");
|
|
1097
|
+
}
|
|
1098
|
+
sectionHeader.addEventListener("click", e => {
|
|
1099
|
+
// Don't collapse when clicking the action buttons
|
|
1100
|
+
if (e.target.closest("#btn-new-project") || e.target.closest("#btn-projects-organize")) return;
|
|
1101
|
+
_sectionCollapsed = !_sectionCollapsed;
|
|
1102
|
+
localStorage.setItem(_LS_SECTION_COLLAPSE_KEY, _sectionCollapsed ? "1" : "0");
|
|
1103
|
+
projectList.classList.toggle("project-list-collapsed", _sectionCollapsed);
|
|
1104
|
+
sectionHeader.classList.toggle("projects-section-collapsed", _sectionCollapsed);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (btnNew) {
|
|
1109
|
+
btnNew.addEventListener("click", () => promptCreate());
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (btnOrganize) {
|
|
1113
|
+
btnOrganize.addEventListener("click", e => {
|
|
1114
|
+
e.stopPropagation();
|
|
1115
|
+
_showOrganizeMenu(btnOrganize);
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
all,
|
|
1124
|
+
find,
|
|
1125
|
+
setAll,
|
|
1126
|
+
create,
|
|
1127
|
+
update,
|
|
1128
|
+
remove,
|
|
1129
|
+
moveSession,
|
|
1130
|
+
promptCreate,
|
|
1131
|
+
promptRename,
|
|
1132
|
+
promptDelete,
|
|
1133
|
+
renderSection,
|
|
1134
|
+
init,
|
|
1135
|
+
};
|
|
1136
|
+
})();
|
|
1137
|
+
|
|
1138
|
+
// Auto-init when DOM is ready
|
|
1139
|
+
if (document.readyState === "loading") {
|
|
1140
|
+
document.addEventListener("DOMContentLoaded", () => Projects.init());
|
|
1141
|
+
} else {
|
|
1142
|
+
Projects.init();
|
|
1143
|
+
}
|