@mcpware/claude-code-organizer 0.1.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/.claude-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/PLAN.md +243 -0
- package/README.md +123 -0
- package/SHIP.md +96 -0
- package/bin/cli.mjs +23 -0
- package/mockup.html +419 -0
- package/package.json +39 -0
- package/src/mover.mjs +218 -0
- package/src/scanner.mjs +513 -0
- package/src/server.mjs +147 -0
- package/src/ui/app.js +580 -0
- package/src/ui/index.html +88 -0
- package/src/ui/style.css +203 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.js — Frontend logic for Claude Inventory Manager.
|
|
3
|
+
*
|
|
4
|
+
* Fetches data from /api/scan, renders the scope tree,
|
|
5
|
+
* handles drag-and-drop (SortableJS), search, filter, detail panel.
|
|
6
|
+
*
|
|
7
|
+
* All DOM rendering is here. Change index.html for structure,
|
|
8
|
+
* style.css for appearance, this file for behavior.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
let data = null; // { scopes, items, counts }
|
|
14
|
+
let activeFilters = new Set(); // empty = show all, or set of "memory", "skill", "mcp", etc.
|
|
15
|
+
let selectedItem = null; // currently selected item object
|
|
16
|
+
let pendingDrag = null; // { item, fromScopeId, toScopeId, revertFn }
|
|
17
|
+
|
|
18
|
+
// ── Category config ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const CATEGORIES = {
|
|
21
|
+
config: { icon: "⚙️", label: "CONFIG", group: null },
|
|
22
|
+
memory: { icon: "🧠", label: "MEMORIES", group: "memory" },
|
|
23
|
+
skill: { icon: "⚡", label: "SKILLS", group: "skill" },
|
|
24
|
+
mcp: { icon: "🔌", label: "MCP SERVERS", group: "mcp" },
|
|
25
|
+
hook: { icon: "🪝", label: "HOOKS", group: null },
|
|
26
|
+
plugin: { icon: "🧩", label: "PLUGINS", group: null },
|
|
27
|
+
plan: { icon: "📐", label: "PLANS", group: null },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ITEM_ICONS = {
|
|
31
|
+
memory: "🧠", skill: "⚡", mcp: "🔌", config: "⚙️",
|
|
32
|
+
hook: "🪝", plugin: "🧩", plan: "📐",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const SCOPE_ICONS = { global: "🌐", workspace: "📂", project: "📂" };
|
|
36
|
+
|
|
37
|
+
// ── Init ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
async function init() {
|
|
40
|
+
data = await fetchJson("/api/scan");
|
|
41
|
+
document.getElementById("loading").style.display = "none";
|
|
42
|
+
renderPills();
|
|
43
|
+
renderTree();
|
|
44
|
+
setupSearch();
|
|
45
|
+
setupDetailPanel();
|
|
46
|
+
setupModals();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fetchJson(url) {
|
|
50
|
+
const res = await fetch(url);
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Pills (filter tabs with counts) ──────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function renderPills() {
|
|
57
|
+
const el = document.getElementById("pills");
|
|
58
|
+
const pills = [
|
|
59
|
+
{ key: "all", label: "All", count: data.counts.total },
|
|
60
|
+
{ key: "memory", label: "🧠 Memory", count: data.counts.memory || 0 },
|
|
61
|
+
{ key: "skill", label: "⚡ Skills", count: data.counts.skill || 0 },
|
|
62
|
+
{ key: "mcp", label: "🔌 MCP", count: data.counts.mcp || 0 },
|
|
63
|
+
{ key: "config", label: "⚙️ Config", count: data.counts.config || 0 },
|
|
64
|
+
{ key: "hook", label: "🪝 Hooks", count: data.counts.hook || 0 },
|
|
65
|
+
{ key: "plugin", label: "🧩 Plugins", count: data.counts.plugin || 0 },
|
|
66
|
+
{ key: "plan", label: "📐 Plans", count: data.counts.plan || 0 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// "All" is active when no filters selected
|
|
70
|
+
const allActive = activeFilters.size === 0;
|
|
71
|
+
|
|
72
|
+
el.innerHTML = pills.map(p => {
|
|
73
|
+
const isActive = p.key === "all" ? allActive : activeFilters.has(p.key);
|
|
74
|
+
return `<span class="pill${isActive ? ' active' : ''}" data-filter="${p.key}">${p.label} <b>${p.count}</b></span>`;
|
|
75
|
+
}).join("");
|
|
76
|
+
|
|
77
|
+
el.querySelectorAll(".pill").forEach(pill => {
|
|
78
|
+
pill.addEventListener("click", () => {
|
|
79
|
+
const key = pill.dataset.filter;
|
|
80
|
+
if (key === "all") {
|
|
81
|
+
// Clear all filters → show everything
|
|
82
|
+
activeFilters.clear();
|
|
83
|
+
} else {
|
|
84
|
+
// Toggle this filter
|
|
85
|
+
if (activeFilters.has(key)) {
|
|
86
|
+
activeFilters.delete(key);
|
|
87
|
+
} else {
|
|
88
|
+
activeFilters.add(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Re-render pill states
|
|
92
|
+
const allNow = activeFilters.size === 0;
|
|
93
|
+
el.querySelectorAll(".pill").forEach(p => {
|
|
94
|
+
const k = p.dataset.filter;
|
|
95
|
+
p.classList.toggle("active", k === "all" ? allNow : activeFilters.has(k));
|
|
96
|
+
});
|
|
97
|
+
applyFilter();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyFilter() {
|
|
103
|
+
const hasFilter = activeFilters.size > 0;
|
|
104
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
105
|
+
const cat = hdr.dataset.cat;
|
|
106
|
+
const show = !hasFilter || activeFilters.has(cat);
|
|
107
|
+
hdr.style.display = show ? "" : "none";
|
|
108
|
+
const body = hdr.nextElementSibling;
|
|
109
|
+
if (body) body.style.display = show ? "" : "none";
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Tree rendering ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
function renderTree() {
|
|
116
|
+
const treeEl = document.getElementById("tree");
|
|
117
|
+
const rootScopes = data.scopes.filter(s => s.parentId === null);
|
|
118
|
+
|
|
119
|
+
let html = "";
|
|
120
|
+
|
|
121
|
+
// Render from root — renderScope recursively handles children
|
|
122
|
+
for (const scope of rootScopes) {
|
|
123
|
+
html += renderScope(scope, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
treeEl.innerHTML = html;
|
|
127
|
+
initSortable();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderScope(scope, depth) {
|
|
131
|
+
const items = data.items.filter(i => i.scopeId === scope.id);
|
|
132
|
+
const childScopes = data.scopes.filter(s => s.parentId === scope.id);
|
|
133
|
+
const totalCount = items.length + childScopes.reduce((sum, cs) =>
|
|
134
|
+
sum + data.items.filter(i => i.scopeId === cs.id).length, 0
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const icon = SCOPE_ICONS[scope.type] || "📂";
|
|
138
|
+
const tagClass = `tag-${scope.type}`;
|
|
139
|
+
|
|
140
|
+
// Build inheritance pills
|
|
141
|
+
let inheritHtml = "";
|
|
142
|
+
if (scope.parentId) {
|
|
143
|
+
const chain = getScopeChain(scope);
|
|
144
|
+
if (chain.length > 0) {
|
|
145
|
+
const pills = chain.map(s =>
|
|
146
|
+
`<span class="inherit-pill">${SCOPE_ICONS[s.type] || "📂"} ${esc(s.name)}</span>`
|
|
147
|
+
).join(" ");
|
|
148
|
+
inheritHtml = `<div class="inherit"><span class="inherit-arrow">↳</span> Inherits ${pills}</div>`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Group items by category
|
|
153
|
+
const categories = {};
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
(categories[item.category] ??= []).push(item);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Count sub-projects
|
|
159
|
+
const subInfo = childScopes.length > 0 ? `${childScopes.length} sub-projects` : "";
|
|
160
|
+
|
|
161
|
+
let html = `
|
|
162
|
+
<div class="scope-block">
|
|
163
|
+
<div class="scope-hdr" data-scope-id="${esc(scope.id)}">
|
|
164
|
+
<span class="scope-tog">▼</span>
|
|
165
|
+
<span class="scope-ico">${icon}</span>
|
|
166
|
+
<span class="scope-nm">${esc(scope.name)}</span>
|
|
167
|
+
<span class="scope-tag ${tagClass}">${esc(scope.tag)}</span>
|
|
168
|
+
<span class="scope-info">${esc(subInfo)}</span>
|
|
169
|
+
<span class="scope-cnt">${totalCount}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="scope-body">
|
|
172
|
+
${inheritHtml}`;
|
|
173
|
+
|
|
174
|
+
// Render each category
|
|
175
|
+
for (const [cat, catItems] of Object.entries(categories)) {
|
|
176
|
+
const catConfig = CATEGORIES[cat] || { icon: "📄", label: cat.toUpperCase(), group: null };
|
|
177
|
+
html += `
|
|
178
|
+
<div class="cat-hdr" data-cat="${esc(cat)}">
|
|
179
|
+
<span class="cat-tog">▼</span>
|
|
180
|
+
<span class="cat-ico">${catConfig.icon}</span>
|
|
181
|
+
<span class="cat-nm">${catConfig.label}</span>
|
|
182
|
+
<span class="cat-cnt">${catItems.length}</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="cat-body" data-cat="${esc(cat)}">
|
|
185
|
+
<div class="sortable-zone" data-scope="${esc(scope.id)}" data-group="${catConfig.group || 'none'}">
|
|
186
|
+
${catItems.map(item => renderItem(item)).join("")}
|
|
187
|
+
</div>
|
|
188
|
+
</div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Render child scopes
|
|
192
|
+
if (childScopes.length > 0) {
|
|
193
|
+
html += `<div class="child-scopes">`;
|
|
194
|
+
for (const child of childScopes) {
|
|
195
|
+
html += renderScope(child, depth + 1);
|
|
196
|
+
}
|
|
197
|
+
html += `</div>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
html += `</div></div>`;
|
|
201
|
+
return html;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderItem(item) {
|
|
205
|
+
const icon = ITEM_ICONS[item.category] || "📄";
|
|
206
|
+
const locked = item.locked ? " locked" : "";
|
|
207
|
+
const badgeClass = `b-${item.subType || item.category}`;
|
|
208
|
+
|
|
209
|
+
const actions = item.locked ? "" : `
|
|
210
|
+
<span class="row-acts">
|
|
211
|
+
<button class="rbtn" data-action="move">Move</button>
|
|
212
|
+
<button class="rbtn" data-action="open">Open</button>
|
|
213
|
+
</span>`;
|
|
214
|
+
|
|
215
|
+
return `
|
|
216
|
+
<div class="item-row${locked}" data-path="${esc(item.path)}" data-category="${item.category}">
|
|
217
|
+
<span class="row-ico">${icon}</span>
|
|
218
|
+
<span class="row-name">${esc(item.name)}</span>
|
|
219
|
+
<span class="row-badge ${badgeClass}">${esc(item.subType || item.category)}</span>
|
|
220
|
+
<span class="row-desc">${esc(item.description)}</span>
|
|
221
|
+
<span class="row-meta">${esc(item.size)}${item.fileCount ? ` · ${item.fileCount} files` : ""}</span>
|
|
222
|
+
${actions}
|
|
223
|
+
</div>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function getScopeChain(scope) {
|
|
227
|
+
const chain = [];
|
|
228
|
+
let current = scope;
|
|
229
|
+
while (current.parentId) {
|
|
230
|
+
const parent = data.scopes.find(s => s.id === current.parentId);
|
|
231
|
+
if (!parent) break;
|
|
232
|
+
chain.unshift(parent);
|
|
233
|
+
current = parent;
|
|
234
|
+
}
|
|
235
|
+
return chain;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function esc(s) {
|
|
239
|
+
return String(s || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── SortableJS init ──────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function initSortable() {
|
|
245
|
+
document.querySelectorAll(".sortable-zone").forEach(el => {
|
|
246
|
+
const group = el.dataset.group;
|
|
247
|
+
if (!group || group === "none") return; // non-movable categories
|
|
248
|
+
|
|
249
|
+
Sortable.create(el, {
|
|
250
|
+
group,
|
|
251
|
+
animation: 150,
|
|
252
|
+
ghostClass: "sortable-ghost",
|
|
253
|
+
chosenClass: "sortable-chosen",
|
|
254
|
+
draggable: ".item-row:not(.locked)",
|
|
255
|
+
fallbackOnBody: true,
|
|
256
|
+
scroll: document.querySelector(".tree-area"),
|
|
257
|
+
scrollSensitivity: 100,
|
|
258
|
+
scrollSpeed: 15,
|
|
259
|
+
bubbleScroll: true,
|
|
260
|
+
onEnd(evt) {
|
|
261
|
+
if (evt.from === evt.to) return;
|
|
262
|
+
|
|
263
|
+
const itemEl = evt.item;
|
|
264
|
+
const itemPath = itemEl.dataset.path;
|
|
265
|
+
const item = data.items.find(i => i.path === itemPath);
|
|
266
|
+
if (!item) return;
|
|
267
|
+
|
|
268
|
+
const fromScopeId = evt.from.dataset.scope;
|
|
269
|
+
const toScopeId = evt.to.dataset.scope;
|
|
270
|
+
const fromScope = data.scopes.find(s => s.id === fromScopeId);
|
|
271
|
+
const toScope = data.scopes.find(s => s.id === toScopeId);
|
|
272
|
+
|
|
273
|
+
// Revert function
|
|
274
|
+
const oldParent = evt.from;
|
|
275
|
+
const oldIndex = evt.oldIndex;
|
|
276
|
+
const revertFn = () => {
|
|
277
|
+
if (oldIndex >= oldParent.children.length) oldParent.appendChild(itemEl);
|
|
278
|
+
else oldParent.insertBefore(itemEl, oldParent.children[oldIndex]);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Show confirm with full details
|
|
282
|
+
pendingDrag = { item, fromScopeId, toScopeId, revertFn };
|
|
283
|
+
showDragConfirm(item, fromScope, toScope);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Scope header toggle — default OPEN (show structure)
|
|
289
|
+
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
290
|
+
hdr.addEventListener("click", () => {
|
|
291
|
+
const body = hdr.nextElementSibling;
|
|
292
|
+
const tog = hdr.querySelector(".scope-tog");
|
|
293
|
+
body.classList.toggle("c");
|
|
294
|
+
tog.classList.toggle("c");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Category toggle — default COLLAPSED (hide items)
|
|
299
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
300
|
+
const body = hdr.nextElementSibling;
|
|
301
|
+
const tog = hdr.querySelector(".cat-tog");
|
|
302
|
+
body.classList.add("c");
|
|
303
|
+
tog.classList.add("c");
|
|
304
|
+
|
|
305
|
+
hdr.addEventListener("click", () => {
|
|
306
|
+
body.classList.toggle("c");
|
|
307
|
+
tog.classList.toggle("c");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Item click → detail panel
|
|
312
|
+
document.querySelectorAll(".item-row").forEach(row => {
|
|
313
|
+
row.addEventListener("click", (e) => {
|
|
314
|
+
if (e.target.closest(".rbtn")) return; // don't trigger on button click
|
|
315
|
+
const path = row.dataset.path;
|
|
316
|
+
const item = data.items.find(i => i.path === path);
|
|
317
|
+
if (item) showDetail(item, row);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Item action buttons
|
|
322
|
+
document.querySelectorAll(".rbtn").forEach(btn => {
|
|
323
|
+
btn.addEventListener("click", (e) => {
|
|
324
|
+
e.stopPropagation();
|
|
325
|
+
const row = btn.closest(".item-row");
|
|
326
|
+
const path = row.dataset.path;
|
|
327
|
+
const item = data.items.find(i => i.path === path);
|
|
328
|
+
if (!item) return;
|
|
329
|
+
|
|
330
|
+
if (btn.dataset.action === "move") {
|
|
331
|
+
selectedItem = item;
|
|
332
|
+
openMoveModal(item);
|
|
333
|
+
} else if (btn.dataset.action === "open") {
|
|
334
|
+
window.open(`vscode://file${item.path}`, "_blank");
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Search ───────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
function setupSearch() {
|
|
343
|
+
document.getElementById("searchInput").addEventListener("input", function () {
|
|
344
|
+
const q = this.value.toLowerCase();
|
|
345
|
+
document.querySelectorAll(".item-row").forEach(row => {
|
|
346
|
+
const text = row.textContent.toLowerCase();
|
|
347
|
+
row.style.display = (!q || text.includes(q)) ? "" : "none";
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Detail panel ─────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
function setupDetailPanel() {
|
|
355
|
+
document.getElementById("detailClose").addEventListener("click", closeDetail);
|
|
356
|
+
document.getElementById("detailOpen").addEventListener("click", () => {
|
|
357
|
+
if (selectedItem) window.open(`vscode://file${selectedItem.path}`, "_blank");
|
|
358
|
+
});
|
|
359
|
+
document.getElementById("detailMove").addEventListener("click", () => {
|
|
360
|
+
if (selectedItem && !selectedItem.locked) openMoveModal(selectedItem);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function showDetail(item, rowEl) {
|
|
365
|
+
selectedItem = item;
|
|
366
|
+
const panel = document.getElementById("detailPanel");
|
|
367
|
+
panel.classList.remove("hidden");
|
|
368
|
+
|
|
369
|
+
document.getElementById("detailTitle").textContent = item.name;
|
|
370
|
+
document.getElementById("detailType").innerHTML = `<span class="row-badge b-${item.subType || item.category}">${item.subType || item.category}</span>`;
|
|
371
|
+
const scope = data.scopes.find(s => s.id === item.scopeId);
|
|
372
|
+
document.getElementById("detailScope").textContent = scope?.name || item.scopeId;
|
|
373
|
+
document.getElementById("detailDesc").textContent = item.description || "—";
|
|
374
|
+
document.getElementById("detailSize").textContent = item.size || "—";
|
|
375
|
+
document.getElementById("detailDate").textContent = item.mtime || "—";
|
|
376
|
+
document.getElementById("detailPath").textContent = item.path;
|
|
377
|
+
|
|
378
|
+
// Show/hide move button
|
|
379
|
+
document.getElementById("detailMove").style.display = item.locked ? "none" : "";
|
|
380
|
+
|
|
381
|
+
// Highlight row
|
|
382
|
+
document.querySelectorAll(".item-row.selected").forEach(r => r.classList.remove("selected"));
|
|
383
|
+
if (rowEl) rowEl.classList.add("selected");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function closeDetail() {
|
|
387
|
+
document.getElementById("detailPanel").classList.add("hidden");
|
|
388
|
+
document.querySelectorAll(".item-row.selected").forEach(r => r.classList.remove("selected"));
|
|
389
|
+
selectedItem = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Drag confirm rendering ───────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function showDragConfirm(item, fromScope, toScope) {
|
|
395
|
+
const catConfig = CATEGORIES[item.category] || { icon: "📄", label: item.category };
|
|
396
|
+
const badgeClass = `b-${item.subType || item.category}`;
|
|
397
|
+
const fromIcon = SCOPE_ICONS[fromScope?.type] || "📂";
|
|
398
|
+
const toIcon = SCOPE_ICONS[toScope?.type] || "📂";
|
|
399
|
+
|
|
400
|
+
document.getElementById("dcPreview").innerHTML = `
|
|
401
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
|
402
|
+
<span style="font-size:1.1rem;">${catConfig.icon}</span>
|
|
403
|
+
<div>
|
|
404
|
+
<div style="font-weight:700;color:var(--text-primary);font-size:0.88rem;">${esc(item.name)}</div>
|
|
405
|
+
<div style="display:flex;gap:6px;align-items:center;margin-top:3px;">
|
|
406
|
+
<span class="row-badge ${badgeClass}">${esc(item.subType || item.category)}</span>
|
|
407
|
+
<span style="font-size:0.7rem;color:var(--text-muted);">${esc(item.category)}</span>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div style="display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--border-light);">
|
|
412
|
+
<div style="flex:1;text-align:center;">
|
|
413
|
+
<div style="font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">From</div>
|
|
414
|
+
<div style="font-size:0.82rem;font-weight:600;color:#dc2626;">${fromIcon} ${esc(fromScope?.name || "?")}</div>
|
|
415
|
+
<div style="font-size:0.6rem;color:var(--text-faint);">${esc(fromScope?.tag || "")}</div>
|
|
416
|
+
</div>
|
|
417
|
+
<div style="font-size:1.2rem;color:var(--text-faint);">→</div>
|
|
418
|
+
<div style="flex:1;text-align:center;">
|
|
419
|
+
<div style="font-size:0.6rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">To</div>
|
|
420
|
+
<div style="font-size:0.82rem;font-weight:600;color:#16a34a;">${toIcon} ${esc(toScope?.name || "?")}</div>
|
|
421
|
+
<div style="font-size:0.6rem;color:var(--text-faint);">${esc(toScope?.tag || "")}</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
`;
|
|
425
|
+
document.getElementById("dragConfirmModal").classList.remove("hidden");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Modals ───────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
function setupModals() {
|
|
431
|
+
// Drag confirm
|
|
432
|
+
document.getElementById("dcCancel").addEventListener("click", () => {
|
|
433
|
+
document.getElementById("dragConfirmModal").classList.add("hidden");
|
|
434
|
+
if (pendingDrag?.revertFn) pendingDrag.revertFn();
|
|
435
|
+
pendingDrag = null;
|
|
436
|
+
});
|
|
437
|
+
document.getElementById("dcConfirm").addEventListener("click", async () => {
|
|
438
|
+
document.getElementById("dragConfirmModal").classList.add("hidden");
|
|
439
|
+
if (pendingDrag) {
|
|
440
|
+
const result = await doMove(pendingDrag.item.path, pendingDrag.toScopeId);
|
|
441
|
+
if (!result.ok && pendingDrag.revertFn) pendingDrag.revertFn();
|
|
442
|
+
pendingDrag = null;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Move modal
|
|
447
|
+
document.getElementById("moveCancel").addEventListener("click", closeMoveModal);
|
|
448
|
+
document.getElementById("moveModal").addEventListener("click", (e) => {
|
|
449
|
+
if (e.target === document.getElementById("moveModal")) closeMoveModal();
|
|
450
|
+
});
|
|
451
|
+
document.getElementById("dragConfirmModal").addEventListener("click", (e) => {
|
|
452
|
+
if (e.target === document.getElementById("dragConfirmModal")) {
|
|
453
|
+
document.getElementById("dragConfirmModal").classList.add("hidden");
|
|
454
|
+
if (pendingDrag?.revertFn) pendingDrag.revertFn();
|
|
455
|
+
pendingDrag = null;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function openMoveModal(item) {
|
|
461
|
+
const res = await fetchJson(`/api/destinations?path=${encodeURIComponent(item.path)}`);
|
|
462
|
+
if (!res.ok) return toast(res.error, true);
|
|
463
|
+
|
|
464
|
+
const listEl = document.getElementById("moveDestList");
|
|
465
|
+
// Build full scope lookup for indent
|
|
466
|
+
const allScopeMap = {};
|
|
467
|
+
for (const s of data.scopes) allScopeMap[s.id] = s;
|
|
468
|
+
for (const s of res.destinations) allScopeMap[s.id] = s;
|
|
469
|
+
|
|
470
|
+
function getDepth(scope) {
|
|
471
|
+
let depth = 0;
|
|
472
|
+
let cur = scope;
|
|
473
|
+
while (cur.parentId) {
|
|
474
|
+
depth++;
|
|
475
|
+
cur = allScopeMap[cur.parentId] || { parentId: null };
|
|
476
|
+
}
|
|
477
|
+
return depth;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Add current scope (grayed out) + all destinations
|
|
481
|
+
const currentScope = data.scopes.find(s => s.id === res.currentScopeId);
|
|
482
|
+
const allEntries = [];
|
|
483
|
+
|
|
484
|
+
// Insert current scope at the right position based on depth
|
|
485
|
+
const allScopes = currentScope
|
|
486
|
+
? [...res.destinations, { ...currentScope, isCurrent: true }]
|
|
487
|
+
: res.destinations;
|
|
488
|
+
|
|
489
|
+
// Sort by depth then name to maintain tree order
|
|
490
|
+
allScopes.sort((a, b) => {
|
|
491
|
+
const da = getDepth(a), db = getDepth(b);
|
|
492
|
+
if (da !== db) return da - db;
|
|
493
|
+
return a.name.localeCompare(b.name);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Reorder to put children right after their parent
|
|
497
|
+
const ordered = [];
|
|
498
|
+
function addWithChildren(parentId) {
|
|
499
|
+
for (const s of allScopes) {
|
|
500
|
+
if ((s.parentId || null) === parentId) {
|
|
501
|
+
ordered.push(s);
|
|
502
|
+
addWithChildren(s.id);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
addWithChildren(null);
|
|
507
|
+
|
|
508
|
+
listEl.innerHTML = ordered.map(scope => {
|
|
509
|
+
const depth = getDepth(scope);
|
|
510
|
+
const indentPx = depth > 0 ? ` style="padding-left:${depth * 28}px"` : "";
|
|
511
|
+
const icon = scope.id === "global" ? "🌐" : (SCOPE_ICONS[scope.type] || "📂");
|
|
512
|
+
const curClass = scope.isCurrent ? " cur" : "";
|
|
513
|
+
const curLabel = scope.isCurrent ? ' <span style="font-size:0.6rem;color:var(--text-faint);margin-left:4px;">(current)</span>' : "";
|
|
514
|
+
return `<div class="dest${curClass}" data-scope-id="${esc(scope.id)}"${indentPx}>
|
|
515
|
+
<span class="di">${icon}</span>
|
|
516
|
+
<span class="dn">${esc(scope.name)}${curLabel}</span>
|
|
517
|
+
<span class="dp">${esc(scope.tag)}</span>
|
|
518
|
+
</div>`;
|
|
519
|
+
}).join("");
|
|
520
|
+
|
|
521
|
+
// Click handlers
|
|
522
|
+
let selectedDest = null;
|
|
523
|
+
listEl.querySelectorAll(".dest").forEach(d => {
|
|
524
|
+
d.addEventListener("click", () => {
|
|
525
|
+
listEl.querySelectorAll(".dest").forEach(x => x.classList.remove("sel"));
|
|
526
|
+
d.classList.add("sel");
|
|
527
|
+
selectedDest = d.dataset.scopeId;
|
|
528
|
+
document.getElementById("moveConfirm").disabled = false;
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
document.getElementById("moveConfirm").disabled = true;
|
|
533
|
+
document.getElementById("moveConfirm").onclick = async () => {
|
|
534
|
+
if (!selectedDest) return;
|
|
535
|
+
closeMoveModal();
|
|
536
|
+
await doMove(item.path, selectedDest);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
document.getElementById("moveModal").classList.remove("hidden");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function closeMoveModal() {
|
|
543
|
+
document.getElementById("moveModal").classList.add("hidden");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── API calls ────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
async function doMove(itemPath, toScopeId) {
|
|
549
|
+
const response = await fetch("/api/move", {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({ itemPath, toScopeId }),
|
|
553
|
+
});
|
|
554
|
+
const result = await response.json();
|
|
555
|
+
|
|
556
|
+
if (result.ok) {
|
|
557
|
+
toast(result.message);
|
|
558
|
+
// Refresh everything
|
|
559
|
+
data = await fetchJson("/api/scan");
|
|
560
|
+
renderPills();
|
|
561
|
+
renderTree();
|
|
562
|
+
closeDetail();
|
|
563
|
+
} else {
|
|
564
|
+
toast(result.error, true);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Toast ────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
function toast(msg, isError = false) {
|
|
573
|
+
const el = document.getElementById("toast");
|
|
574
|
+
document.getElementById("toastMsg").textContent = msg;
|
|
575
|
+
el.className = isError ? "toast error" : "toast";
|
|
576
|
+
setTimeout(() => el.classList.add("hidden"), 4000);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Start ────────────────────────────────────────────────────────────
|
|
580
|
+
init();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Claude Inventory</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div class="app">
|
|
13
|
+
<div class="tree-area">
|
|
14
|
+
<!-- Header -->
|
|
15
|
+
<div class="page-hdr">
|
|
16
|
+
<div class="logo">C</div>
|
|
17
|
+
<h1>Claude Inventory</h1>
|
|
18
|
+
<span class="spacer"></span>
|
|
19
|
+
<div class="pills" id="pills">
|
|
20
|
+
<!-- Rendered by app.js -->
|
|
21
|
+
</div>
|
|
22
|
+
<input class="search" placeholder="Search..." id="searchInput">
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Tree content — rendered by app.js -->
|
|
26
|
+
<div id="tree"></div>
|
|
27
|
+
|
|
28
|
+
<!-- Loading state -->
|
|
29
|
+
<div id="loading" style="text-align:center;padding:60px;color:#9598a8;">
|
|
30
|
+
Scanning ~/.claude/ ...
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- Detail panel -->
|
|
35
|
+
<div class="detail hidden" id="detailPanel">
|
|
36
|
+
<div class="detail-hdr">
|
|
37
|
+
<h2 id="detailTitle">—</h2>
|
|
38
|
+
<button class="detail-x" id="detailClose">×</button>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="detail-bd">
|
|
41
|
+
<div class="df"><div class="dl">Type</div><div class="dv" id="detailType">—</div></div>
|
|
42
|
+
<div class="df"><div class="dl">Scope</div><div class="dv" id="detailScope">—</div></div>
|
|
43
|
+
<div class="df"><div class="dl">Description</div><div class="dv" id="detailDesc">—</div></div>
|
|
44
|
+
<div class="df"><div class="dl">Size</div><div class="dv" id="detailSize">—</div></div>
|
|
45
|
+
<div class="df"><div class="dl">Modified</div><div class="dv" id="detailDate">—</div></div>
|
|
46
|
+
<div class="df"><div class="dl">Path</div><div class="dv" id="detailPath" style="font-size:0.65rem;word-break:break-all;">—</div></div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="detail-ft">
|
|
49
|
+
<button class="btn btn-p" id="detailOpen">Open in Editor</button>
|
|
50
|
+
<button class="btn btn-s" id="detailMove">Move to...</button>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Drag confirm modal -->
|
|
56
|
+
<div class="modal-bg hidden" id="dragConfirmModal">
|
|
57
|
+
<div class="modal">
|
|
58
|
+
<h3>Confirm Move</h3>
|
|
59
|
+
<div class="modal-sub">This will move the file in your Claude config</div>
|
|
60
|
+
<div class="move-preview" id="dcPreview">
|
|
61
|
+
<!-- Rendered by app.js showDragConfirm() -->
|
|
62
|
+
</div>
|
|
63
|
+
<div class="modal-btns">
|
|
64
|
+
<button class="btn btn-s" id="dcCancel">Cancel</button>
|
|
65
|
+
<button class="btn btn-p" id="dcConfirm">Yes, move it</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- Move-to picker modal -->
|
|
71
|
+
<div class="modal-bg hidden" id="moveModal">
|
|
72
|
+
<div class="modal">
|
|
73
|
+
<h3>Move to...</h3>
|
|
74
|
+
<div class="modal-sub">Select destination scope</div>
|
|
75
|
+
<div class="modal-body" id="moveDestList"></div>
|
|
76
|
+
<div class="modal-btns">
|
|
77
|
+
<button class="btn btn-s" id="moveCancel">Cancel</button>
|
|
78
|
+
<button class="btn btn-p" id="moveConfirm" disabled>Move here</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Toast -->
|
|
84
|
+
<div class="toast hidden" id="toast"><span id="toastMsg">Done</span></div>
|
|
85
|
+
|
|
86
|
+
<script src="/app.js"></script>
|
|
87
|
+
</body>
|
|
88
|
+
</html>
|