@mcpware/claude-code-organizer 0.1.4 → 0.2.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/README.md +31 -9
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/mover.mjs +61 -1
- package/src/scanner.mjs +2 -2
- package/src/server.mjs +91 -4
- package/src/ui/app.js +565 -27
- package/src/ui/index.html +40 -7
- package/src/ui/style.css +35 -4
package/README.md
CHANGED
|
@@ -2,9 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
**Organize all your Claude Code memories, skills, MCP servers, and hooks — view by scope hierarchy, move between scopes via drag-and-drop.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<video src="docs/demo.webm" autoplay loop muted playsinline width="100%"></video>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## The Problem
|
|
8
|
+
|
|
9
|
+
Ever asked Claude Code to "remember this", only to find it saved the memory to the wrong scope?
|
|
10
|
+
|
|
11
|
+
You're inside a project folder, you tell Claude to remember a preference — and it saves it to that project's scope. Switch to another project, and Claude has no idea. That memory is trapped.
|
|
12
|
+
|
|
13
|
+
The reverse happens too — skills or memories sitting in global scope that really only apply to one repo, leaking into everything else.
|
|
14
|
+
|
|
15
|
+
Want to fix it? You'd have to manually dig through `~/.claude/` and its encoded-path folders (`-home-user-projects-my-app/`), find the right file, and move it yourself.
|
|
16
|
+
|
|
17
|
+
**Claude Code Organizer fixes this.**
|
|
18
|
+
|
|
19
|
+
### Example: Project → Global
|
|
20
|
+
|
|
21
|
+
You told Claude to remember "I prefer TypeScript + ESM" while inside a project, but that preference applies everywhere. Open the dashboard, drag that memory from Project scope to Global scope. Done.
|
|
22
|
+
|
|
23
|
+
### Example: Global → Project
|
|
24
|
+
|
|
25
|
+
You have a deploy skill sitting in global, but it only makes sense for one repo. Drag it into that Project scope — other projects won't see it anymore.
|
|
8
26
|
|
|
9
27
|
---
|
|
10
28
|
|
|
@@ -30,6 +48,10 @@ npm install -g @mcpware/claude-code-organizer
|
|
|
30
48
|
claude-code-organizer
|
|
31
49
|
```
|
|
32
50
|
|
|
51
|
+
Or paste this into Claude Code:
|
|
52
|
+
|
|
53
|
+
> Run `npx @mcpware/claude-code-organizer` — it's a dashboard for managing Claude Code settings. Tell me the URL when it's ready.
|
|
54
|
+
|
|
33
55
|
Opens a dashboard at `http://localhost:3847`. Works with your real `~/.claude/` directory.
|
|
34
56
|
|
|
35
57
|
## What It Manages
|
|
@@ -47,12 +69,12 @@ Opens a dashboard at `http://localhost:3847`. Works with your real `~/.claude/`
|
|
|
47
69
|
## Scope Hierarchy
|
|
48
70
|
|
|
49
71
|
```
|
|
50
|
-
Global
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Documents (project)
|
|
72
|
+
Global <- applies everywhere
|
|
73
|
+
Company (workspace) <- applies to all sub-projects
|
|
74
|
+
CompanyRepo1 <- project-specific
|
|
75
|
+
CompanyRepo2 <- project-specific
|
|
76
|
+
SideProjects (project) <- independent project
|
|
77
|
+
Documents (project) <- independent project
|
|
56
78
|
```
|
|
57
79
|
|
|
58
80
|
Child scopes inherit parent scope's memories, skills, and MCP servers.
|
|
@@ -120,6 +142,6 @@ MIT
|
|
|
120
142
|
|
|
121
143
|
## Author
|
|
122
144
|
|
|
123
|
-
[
|
|
145
|
+
[ithiria894](https://github.com/ithiria894) — Building tools for the Claude Code ecosystem.
|
|
124
146
|
|
|
125
147
|
See also: [@mcpware/instagram-mcp](https://github.com/mcpware/instagram-mcp)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcpware/claude-code-organizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Organize all your Claude Code memories, skills, MCP servers, and hooks — view by scope hierarchy, move between scopes via drag-and-drop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/mcpware/claude-code-organizer",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@mcpware/claude-code-organizer",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|
package/src/mover.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Pure data module. No HTTP, no UI.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { rename, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { rename, mkdir, readFile, writeFile, rm, unlink } from "node:fs/promises";
|
|
14
14
|
import { join, dirname, basename } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
@@ -193,6 +193,66 @@ export async function moveItem(item, toScopeId, scopes) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// ── Delete functions ─────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async function deleteMemory(item) {
|
|
199
|
+
await unlink(item.path);
|
|
200
|
+
return { ok: true, deleted: item.path, message: `Deleted memory "${item.name}"` };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function deleteSkill(item) {
|
|
204
|
+
await rm(item.path, { recursive: true, force: true });
|
|
205
|
+
return { ok: true, deleted: item.path, message: `Deleted skill "${item.name}"` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function deleteMcp(item) {
|
|
209
|
+
const mcpJson = item.path;
|
|
210
|
+
let content;
|
|
211
|
+
try {
|
|
212
|
+
content = JSON.parse(await readFile(mcpJson, "utf-8"));
|
|
213
|
+
} catch {
|
|
214
|
+
return { ok: false, error: `Cannot read .mcp.json: ${mcpJson}` };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!content.mcpServers?.[item.name]) {
|
|
218
|
+
return { ok: false, error: `Server "${item.name}" not found in ${mcpJson}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
delete content.mcpServers[item.name];
|
|
222
|
+
await writeFile(mcpJson, JSON.stringify(content, null, 2) + "\n");
|
|
223
|
+
|
|
224
|
+
return { ok: true, deleted: mcpJson, message: `Deleted MCP server "${item.name}"` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Delete an item permanently.
|
|
229
|
+
*/
|
|
230
|
+
export async function deleteItem(item, scopes) {
|
|
231
|
+
if (item.locked) {
|
|
232
|
+
return { ok: false, error: `${item.name} is locked and cannot be deleted` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const deletableCategories = ["memory", "skill", "mcp"];
|
|
236
|
+
if (!deletableCategories.includes(item.category)) {
|
|
237
|
+
return { ok: false, error: `${item.category} items cannot be deleted` };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
switch (item.category) {
|
|
242
|
+
case "memory":
|
|
243
|
+
return await deleteMemory(item);
|
|
244
|
+
case "skill":
|
|
245
|
+
return await deleteSkill(item);
|
|
246
|
+
case "mcp":
|
|
247
|
+
return await deleteMcp(item);
|
|
248
|
+
default:
|
|
249
|
+
return { ok: false, error: `Unknown category: ${item.category}` };
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return { ok: false, error: `Delete failed: ${err.message}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
196
256
|
/**
|
|
197
257
|
* Get valid destination scopes for an item.
|
|
198
258
|
* Returns only scopes where this category of item can live.
|
package/src/scanner.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function parseFrontmatter(content) {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolve an encoded project dir name back to a real filesystem path.
|
|
52
|
-
* E.g. "-home-
|
|
52
|
+
* E.g. "-home-user-mycompany-repo1" → "/home/user/mycompany/repo1"
|
|
53
53
|
*
|
|
54
54
|
* Strategy: starting from root, greedily match the longest existing directory
|
|
55
55
|
* at each level by consuming segments from the encoded name.
|
|
@@ -120,7 +120,7 @@ async function discoverScopes() {
|
|
|
120
120
|
|
|
121
121
|
// Decode encoded path: try to find the real directory on disk.
|
|
122
122
|
// The encoding replaces / with - and prepends -.
|
|
123
|
-
// E.g. -home-
|
|
123
|
+
// E.g. -home-user-mycompany-repo1 → /home/user/mycompany/repo1
|
|
124
124
|
// Since directory names can contain dashes, we resolve by checking which real path exists.
|
|
125
125
|
const realPath = await resolveEncodedProjectPath(d.name);
|
|
126
126
|
if (!realPath) continue;
|
package/src/server.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { createServer } from "node:http";
|
|
|
8
8
|
import { readFile } from "node:fs/promises";
|
|
9
9
|
import { join, extname } from "node:path";
|
|
10
10
|
import { scan } from "./scanner.mjs";
|
|
11
|
-
import { moveItem, getValidDestinations } from "./mover.mjs";
|
|
11
|
+
import { moveItem, deleteItem, getValidDestinations } from "./mover.mjs";
|
|
12
12
|
|
|
13
13
|
const UI_DIR = join(import.meta.dirname, "ui");
|
|
14
14
|
|
|
@@ -84,6 +84,22 @@ async function handleRequest(req, res) {
|
|
|
84
84
|
return json(res, result, result.ok ? 200 : 400);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// POST /api/delete — delete an item
|
|
88
|
+
if (path === "/api/delete" && req.method === "POST") {
|
|
89
|
+
const { itemPath } = await readBody(req);
|
|
90
|
+
|
|
91
|
+
if (!cachedData) await freshScan();
|
|
92
|
+
|
|
93
|
+
const item = cachedData.items.find(i => i.path === itemPath && !i.locked);
|
|
94
|
+
if (!item) return json(res, { ok: false, error: "Item not found or locked" }, 400);
|
|
95
|
+
|
|
96
|
+
const result = await deleteItem(item, cachedData.scopes);
|
|
97
|
+
|
|
98
|
+
if (result.ok) await freshScan();
|
|
99
|
+
|
|
100
|
+
return json(res, result, result.ok ? 200 : 400);
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
// GET /api/destinations?path=...&category=... — valid move destinations
|
|
88
104
|
if (path === "/api/destinations" && req.method === "GET") {
|
|
89
105
|
if (!cachedData) await freshScan();
|
|
@@ -95,6 +111,55 @@ async function handleRequest(req, res) {
|
|
|
95
111
|
return json(res, { ok: true, destinations, currentScopeId: item.scopeId });
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
// POST /api/restore — restore a deleted file (for undo)
|
|
115
|
+
if (path === "/api/restore" && req.method === "POST") {
|
|
116
|
+
const { filePath, content, isDir } = await readBody(req);
|
|
117
|
+
if (!filePath || !filePath.startsWith("/")) {
|
|
118
|
+
return json(res, { ok: false, error: "Invalid path" }, 400);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const { mkdir, writeFile: wf } = await import("node:fs/promises");
|
|
122
|
+
const { dirname } = await import("node:path");
|
|
123
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
124
|
+
if (isDir) {
|
|
125
|
+
// For skills: restore SKILL.md inside the directory
|
|
126
|
+
await mkdir(filePath, { recursive: true });
|
|
127
|
+
const skillPath = join(filePath, "SKILL.md");
|
|
128
|
+
await wf(skillPath, content, "utf-8");
|
|
129
|
+
} else {
|
|
130
|
+
await wf(filePath, content, "utf-8");
|
|
131
|
+
}
|
|
132
|
+
await freshScan();
|
|
133
|
+
return json(res, { ok: true, message: "Restored successfully" });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return json(res, { ok: false, error: `Restore failed: ${err.message}` }, 400);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// POST /api/restore-mcp — restore a deleted MCP server entry
|
|
140
|
+
if (path === "/api/restore-mcp" && req.method === "POST") {
|
|
141
|
+
const { name, config, mcpJsonPath } = await readBody(req);
|
|
142
|
+
if (!name || !config || !mcpJsonPath) {
|
|
143
|
+
return json(res, { ok: false, error: "Missing name, config, or mcpJsonPath" }, 400);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
let content = { mcpServers: {} };
|
|
147
|
+
try {
|
|
148
|
+
content = JSON.parse(await readFile(mcpJsonPath, "utf-8"));
|
|
149
|
+
if (!content.mcpServers) content.mcpServers = {};
|
|
150
|
+
} catch { /* file doesn't exist, start fresh */ }
|
|
151
|
+
content.mcpServers[name] = config;
|
|
152
|
+
const { writeFile: wf, mkdir: mk } = await import("node:fs/promises");
|
|
153
|
+
const { dirname } = await import("node:path");
|
|
154
|
+
await mk(dirname(mcpJsonPath), { recursive: true });
|
|
155
|
+
await wf(mcpJsonPath, JSON.stringify(content, null, 2) + "\n");
|
|
156
|
+
await freshScan();
|
|
157
|
+
return json(res, { ok: true, message: `Restored MCP server "${name}"` });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return json(res, { ok: false, error: `Restore failed: ${err.message}` }, 400);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
98
163
|
// GET /api/file-content?path=... — read file content for detail panel
|
|
99
164
|
if (path === "/api/file-content" && req.method === "GET") {
|
|
100
165
|
const filePath = url.searchParams.get("path");
|
|
@@ -121,6 +186,12 @@ async function handleRequest(req, res) {
|
|
|
121
186
|
return serveFile(res, join(UI_DIR, "app.js"));
|
|
122
187
|
}
|
|
123
188
|
|
|
189
|
+
// Suppress favicon 404
|
|
190
|
+
if (path === "/favicon.ico") {
|
|
191
|
+
res.writeHead(204);
|
|
192
|
+
return res.end();
|
|
193
|
+
}
|
|
194
|
+
|
|
124
195
|
// ── 404 ──
|
|
125
196
|
res.writeHead(404);
|
|
126
197
|
res.end("Not found");
|
|
@@ -128,7 +199,7 @@ async function handleRequest(req, res) {
|
|
|
128
199
|
|
|
129
200
|
// ── Start server ─────────────────────────────────────────────────────
|
|
130
201
|
|
|
131
|
-
export function startServer(port = 3847) {
|
|
202
|
+
export function startServer(port = 3847, maxRetries = 10) {
|
|
132
203
|
const server = createServer(async (req, res) => {
|
|
133
204
|
try {
|
|
134
205
|
await handleRequest(req, res);
|
|
@@ -139,9 +210,25 @@ export function startServer(port = 3847) {
|
|
|
139
210
|
}
|
|
140
211
|
});
|
|
141
212
|
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
let attempt = 0;
|
|
214
|
+
function tryListen(p) {
|
|
215
|
+
server.listen(p, () => {
|
|
216
|
+
console.log(`Claude Code Organizer running at http://localhost:${p}`);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
server.on("error", (err) => {
|
|
221
|
+
if (err.code === "EADDRINUSE" && attempt < maxRetries) {
|
|
222
|
+
attempt++;
|
|
223
|
+
const nextPort = port + attempt;
|
|
224
|
+
console.log(`Port ${port + attempt - 1} in use, trying ${nextPort}...`);
|
|
225
|
+
tryListen(nextPort);
|
|
226
|
+
} else {
|
|
227
|
+
console.error(`Failed to start server: ${err.message}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
144
230
|
});
|
|
145
231
|
|
|
232
|
+
tryListen(port);
|
|
146
233
|
return server;
|
|
147
234
|
}
|
package/src/ui/app.js
CHANGED
|
@@ -14,6 +14,10 @@ let data = null; // { scopes, items, counts }
|
|
|
14
14
|
let activeFilters = new Set(); // empty = show all, or set of "memory", "skill", "mcp", etc.
|
|
15
15
|
let selectedItem = null; // currently selected item object
|
|
16
16
|
let pendingDrag = null; // { item, fromScopeId, toScopeId, revertFn }
|
|
17
|
+
let pendingDelete = null; // item to delete
|
|
18
|
+
let draggingItem = null; // item currently being dragged
|
|
19
|
+
let expandState = { scopes: new Set(), cats: new Set() }; // track expanded sections
|
|
20
|
+
let bulkSelected = new Set(); // paths of selected items for bulk ops
|
|
17
21
|
|
|
18
22
|
// ── Category config ──────────────────────────────────────────────────
|
|
19
23
|
|
|
@@ -44,6 +48,9 @@ async function init() {
|
|
|
44
48
|
setupSearch();
|
|
45
49
|
setupDetailPanel();
|
|
46
50
|
setupModals();
|
|
51
|
+
setupBulkBar();
|
|
52
|
+
setupScopeDropZones();
|
|
53
|
+
setupExpandToggle();
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
async function fetchJson(url) {
|
|
@@ -155,6 +162,17 @@ function renderScope(scope, depth) {
|
|
|
155
162
|
(categories[item.category] ??= []).push(item);
|
|
156
163
|
}
|
|
157
164
|
|
|
165
|
+
// Sort memory items: feedback last, then alphabetical within each subType
|
|
166
|
+
if (categories.memory) {
|
|
167
|
+
const subTypeOrder = { project: 0, reference: 1, user: 2, feedback: 3 };
|
|
168
|
+
categories.memory.sort((a, b) => {
|
|
169
|
+
const oa = subTypeOrder[a.subType] ?? 2;
|
|
170
|
+
const ob = subTypeOrder[b.subType] ?? 2;
|
|
171
|
+
if (oa !== ob) return oa - ob;
|
|
172
|
+
return a.name.localeCompare(b.name);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
// Count sub-projects
|
|
159
177
|
const subInfo = childScopes.length > 0 ? `${childScopes.length} sub-projects` : "";
|
|
160
178
|
|
|
@@ -205,15 +223,20 @@ function renderItem(item) {
|
|
|
205
223
|
const icon = ITEM_ICONS[item.category] || "📄";
|
|
206
224
|
const locked = item.locked ? " locked" : "";
|
|
207
225
|
const badgeClass = `b-${item.subType || item.category}`;
|
|
226
|
+
const checked = bulkSelected.has(item.path) ? " checked" : "";
|
|
227
|
+
|
|
228
|
+
const checkbox = item.locked ? "" : `<input type="checkbox" class="row-chk" data-path="${esc(item.path)}"${checked}>`;
|
|
208
229
|
|
|
209
230
|
const actions = item.locked ? "" : `
|
|
210
231
|
<span class="row-acts">
|
|
211
232
|
<button class="rbtn" data-action="move">Move</button>
|
|
212
233
|
<button class="rbtn" data-action="open">Open</button>
|
|
234
|
+
<button class="rbtn rbtn-danger" data-action="delete">Delete</button>
|
|
213
235
|
</span>`;
|
|
214
236
|
|
|
215
237
|
return `
|
|
216
238
|
<div class="item-row${locked}" data-path="${esc(item.path)}" data-category="${item.category}">
|
|
239
|
+
${checkbox}
|
|
217
240
|
<span class="row-ico">${icon}</span>
|
|
218
241
|
<span class="row-name">${esc(item.name)}</span>
|
|
219
242
|
<span class="row-badge ${badgeClass}">${esc(item.subType || item.category)}</span>
|
|
@@ -241,10 +264,58 @@ function esc(s) {
|
|
|
241
264
|
|
|
242
265
|
// ── SortableJS init ──────────────────────────────────────────────────
|
|
243
266
|
|
|
267
|
+
function saveExpandState() {
|
|
268
|
+
expandState.scopes.clear();
|
|
269
|
+
expandState.cats.clear();
|
|
270
|
+
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
271
|
+
const body = hdr.nextElementSibling;
|
|
272
|
+
if (body && !body.classList.contains("c")) {
|
|
273
|
+
expandState.scopes.add(hdr.dataset.scopeId);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
277
|
+
const body = hdr.nextElementSibling;
|
|
278
|
+
const scopeId = hdr.closest(".scope-block")?.querySelector(".scope-hdr")?.dataset.scopeId || "";
|
|
279
|
+
const catKey = `${scopeId}::${hdr.dataset.cat}`;
|
|
280
|
+
if (body && !body.classList.contains("c")) {
|
|
281
|
+
expandState.cats.add(catKey);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function restoreExpandState() {
|
|
287
|
+
// Scopes default open — collapse those NOT in saved state (only if we have saved state)
|
|
288
|
+
const hasSavedState = expandState.scopes.size > 0 || expandState.cats.size > 0;
|
|
289
|
+
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
290
|
+
const body = hdr.nextElementSibling;
|
|
291
|
+
const tog = hdr.querySelector(".scope-tog");
|
|
292
|
+
if (hasSavedState && !expandState.scopes.has(hdr.dataset.scopeId)) {
|
|
293
|
+
body?.classList.add("c");
|
|
294
|
+
tog?.classList.add("c");
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Categories default collapsed — expand those in saved state
|
|
299
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
300
|
+
const body = hdr.nextElementSibling;
|
|
301
|
+
const tog = hdr.querySelector(".cat-tog");
|
|
302
|
+
const scopeId = hdr.closest(".scope-block")?.querySelector(".scope-hdr")?.dataset.scopeId || "";
|
|
303
|
+
const catKey = `${scopeId}::${hdr.dataset.cat}`;
|
|
304
|
+
|
|
305
|
+
if (expandState.cats.has(catKey)) {
|
|
306
|
+
body?.classList.remove("c");
|
|
307
|
+
tog?.classList.remove("c");
|
|
308
|
+
} else {
|
|
309
|
+
body?.classList.add("c");
|
|
310
|
+
tog?.classList.add("c");
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
244
315
|
function initSortable() {
|
|
245
316
|
document.querySelectorAll(".sortable-zone").forEach(el => {
|
|
246
317
|
const group = el.dataset.group;
|
|
247
|
-
if (!group || group === "none") return;
|
|
318
|
+
if (!group || group === "none") return;
|
|
248
319
|
|
|
249
320
|
Sortable.create(el, {
|
|
250
321
|
group,
|
|
@@ -257,7 +328,15 @@ function initSortable() {
|
|
|
257
328
|
scrollSensitivity: 100,
|
|
258
329
|
scrollSpeed: 15,
|
|
259
330
|
bubbleScroll: true,
|
|
331
|
+
onStart(evt) {
|
|
332
|
+
const itemPath = evt.item.dataset.path;
|
|
333
|
+
draggingItem = data.items.find(i => i.path === itemPath);
|
|
334
|
+
},
|
|
260
335
|
onEnd(evt) {
|
|
336
|
+
draggingItem = null;
|
|
337
|
+
// Remove all drop-target highlights
|
|
338
|
+
document.querySelectorAll(".scope-block.drop-target").forEach(b => b.classList.remove("drop-target"));
|
|
339
|
+
|
|
261
340
|
if (evt.from === evt.to) return;
|
|
262
341
|
|
|
263
342
|
const itemEl = evt.item;
|
|
@@ -270,7 +349,6 @@ function initSortable() {
|
|
|
270
349
|
const fromScope = data.scopes.find(s => s.id === fromScopeId);
|
|
271
350
|
const toScope = data.scopes.find(s => s.id === toScopeId);
|
|
272
351
|
|
|
273
|
-
// Revert function
|
|
274
352
|
const oldParent = evt.from;
|
|
275
353
|
const oldIndex = evt.oldIndex;
|
|
276
354
|
const revertFn = () => {
|
|
@@ -278,14 +356,13 @@ function initSortable() {
|
|
|
278
356
|
else oldParent.insertBefore(itemEl, oldParent.children[oldIndex]);
|
|
279
357
|
};
|
|
280
358
|
|
|
281
|
-
// Show confirm with full details
|
|
282
359
|
pendingDrag = { item, fromScopeId, toScopeId, revertFn };
|
|
283
360
|
showDragConfirm(item, fromScope, toScope);
|
|
284
361
|
}
|
|
285
362
|
});
|
|
286
363
|
});
|
|
287
364
|
|
|
288
|
-
// Scope header toggle — default OPEN
|
|
365
|
+
// Scope header toggle — default OPEN
|
|
289
366
|
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
290
367
|
hdr.addEventListener("click", () => {
|
|
291
368
|
const body = hdr.nextElementSibling;
|
|
@@ -295,14 +372,13 @@ function initSortable() {
|
|
|
295
372
|
});
|
|
296
373
|
});
|
|
297
374
|
|
|
298
|
-
// Category toggle —
|
|
299
|
-
|
|
300
|
-
const body = hdr.nextElementSibling;
|
|
301
|
-
const tog = hdr.querySelector(".cat-tog");
|
|
302
|
-
body.classList.add("c");
|
|
303
|
-
tog.classList.add("c");
|
|
375
|
+
// Category toggle — restore state or default collapsed
|
|
376
|
+
restoreExpandState();
|
|
304
377
|
|
|
378
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
305
379
|
hdr.addEventListener("click", () => {
|
|
380
|
+
const body = hdr.nextElementSibling;
|
|
381
|
+
const tog = hdr.querySelector(".cat-tog");
|
|
306
382
|
body.classList.toggle("c");
|
|
307
383
|
tog.classList.toggle("c");
|
|
308
384
|
});
|
|
@@ -311,7 +387,7 @@ function initSortable() {
|
|
|
311
387
|
// Item click → detail panel
|
|
312
388
|
document.querySelectorAll(".item-row").forEach(row => {
|
|
313
389
|
row.addEventListener("click", (e) => {
|
|
314
|
-
if (e.target.closest(".rbtn")) return;
|
|
390
|
+
if (e.target.closest(".rbtn")) return;
|
|
315
391
|
const path = row.dataset.path;
|
|
316
392
|
const item = data.items.find(i => i.path === path);
|
|
317
393
|
if (item) showDetail(item, row);
|
|
@@ -332,6 +408,229 @@ function initSortable() {
|
|
|
332
408
|
openMoveModal(item);
|
|
333
409
|
} else if (btn.dataset.action === "open") {
|
|
334
410
|
window.open(`vscode://file${item.path}`, "_blank");
|
|
411
|
+
} else if (btn.dataset.action === "delete") {
|
|
412
|
+
openDeleteModal(item);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Checkbox handlers for bulk select
|
|
418
|
+
document.querySelectorAll(".row-chk").forEach(chk => {
|
|
419
|
+
chk.addEventListener("change", (e) => {
|
|
420
|
+
e.stopPropagation();
|
|
421
|
+
const path = chk.dataset.path;
|
|
422
|
+
if (chk.checked) bulkSelected.add(path);
|
|
423
|
+
else bulkSelected.delete(path);
|
|
424
|
+
updateBulkBar();
|
|
425
|
+
});
|
|
426
|
+
chk.addEventListener("click", (e) => e.stopPropagation());
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Bulk operations ─────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function updateBulkBar() {
|
|
433
|
+
const bar = document.getElementById("bulkBar");
|
|
434
|
+
if (bulkSelected.size === 0) {
|
|
435
|
+
bar.classList.add("hidden");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
bar.classList.remove("hidden");
|
|
439
|
+
document.getElementById("bulkCount").textContent = `${bulkSelected.size} selected`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function setupBulkBar() {
|
|
443
|
+
document.getElementById("bulkClear").addEventListener("click", () => {
|
|
444
|
+
bulkSelected.clear();
|
|
445
|
+
document.querySelectorAll(".row-chk").forEach(c => c.checked = false);
|
|
446
|
+
updateBulkBar();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
document.getElementById("bulkDelete").addEventListener("click", async () => {
|
|
450
|
+
if (bulkSelected.size === 0) return;
|
|
451
|
+
const count = bulkSelected.size;
|
|
452
|
+
const paths = [...bulkSelected];
|
|
453
|
+
|
|
454
|
+
// Confirm
|
|
455
|
+
if (!confirm(`Delete ${count} item(s)? This cannot be undone.`)) return;
|
|
456
|
+
|
|
457
|
+
let ok = 0, fail = 0;
|
|
458
|
+
for (const p of paths) {
|
|
459
|
+
const result = await doDelete(p, true); // true = skip refresh
|
|
460
|
+
if (result.ok) ok++;
|
|
461
|
+
else fail++;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
bulkSelected.clear();
|
|
465
|
+
await refreshUI();
|
|
466
|
+
toast(`Deleted ${ok} item(s)${fail ? `, ${fail} failed` : ""}`);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
document.getElementById("bulkMove").addEventListener("click", async () => {
|
|
470
|
+
if (bulkSelected.size === 0) return;
|
|
471
|
+
const paths = [...bulkSelected];
|
|
472
|
+
|
|
473
|
+
// All selected items must be same category for move
|
|
474
|
+
const items = paths.map(p => data.items.find(i => i.path === p)).filter(Boolean);
|
|
475
|
+
const categories = new Set(items.map(i => i.category));
|
|
476
|
+
if (categories.size > 1) {
|
|
477
|
+
return toast("Cannot bulk-move items of different types", true);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Use first item to get destinations, then move all
|
|
481
|
+
openBulkMoveModal(items);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function openBulkMoveModal(items) {
|
|
486
|
+
const first = items[0];
|
|
487
|
+
const res = await fetchJson(`/api/destinations?path=${encodeURIComponent(first.path)}`);
|
|
488
|
+
if (!res.ok) return toast(res.error, true);
|
|
489
|
+
|
|
490
|
+
const listEl = document.getElementById("moveDestList");
|
|
491
|
+
const allScopeMap = {};
|
|
492
|
+
for (const s of data.scopes) allScopeMap[s.id] = s;
|
|
493
|
+
for (const s of res.destinations) allScopeMap[s.id] = s;
|
|
494
|
+
|
|
495
|
+
function getDepth(scope) {
|
|
496
|
+
let depth = 0, cur = scope;
|
|
497
|
+
while (cur.parentId) { depth++; cur = allScopeMap[cur.parentId] || { parentId: null }; }
|
|
498
|
+
return depth;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const ordered = [];
|
|
502
|
+
function addWithChildren(parentId) {
|
|
503
|
+
for (const s of res.destinations) {
|
|
504
|
+
if ((s.parentId || null) === parentId) { ordered.push(s); addWithChildren(s.id); }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
addWithChildren(null);
|
|
508
|
+
|
|
509
|
+
listEl.innerHTML = ordered.map(scope => {
|
|
510
|
+
const depth = getDepth(scope);
|
|
511
|
+
const indentPx = depth > 0 ? ` style="padding-left:${depth * 28}px"` : "";
|
|
512
|
+
const icon = scope.id === "global" ? "🌐" : (SCOPE_ICONS[scope.type] || "📂");
|
|
513
|
+
return `<div class="dest" data-scope-id="${esc(scope.id)}"${indentPx}>
|
|
514
|
+
<span class="di">${icon}</span>
|
|
515
|
+
<span class="dn">${esc(scope.name)}</span>
|
|
516
|
+
<span class="dp">${esc(scope.tag)}</span>
|
|
517
|
+
</div>`;
|
|
518
|
+
}).join("");
|
|
519
|
+
|
|
520
|
+
let selectedDest = null;
|
|
521
|
+
listEl.querySelectorAll(".dest").forEach(d => {
|
|
522
|
+
d.addEventListener("click", () => {
|
|
523
|
+
listEl.querySelectorAll(".dest").forEach(x => x.classList.remove("sel"));
|
|
524
|
+
d.classList.add("sel");
|
|
525
|
+
selectedDest = d.dataset.scopeId;
|
|
526
|
+
document.getElementById("moveConfirm").disabled = false;
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
document.getElementById("moveConfirm").disabled = true;
|
|
531
|
+
document.getElementById("moveConfirm").onclick = async () => {
|
|
532
|
+
if (!selectedDest) return;
|
|
533
|
+
closeMoveModal();
|
|
534
|
+
let ok = 0, fail = 0;
|
|
535
|
+
for (const item of items) {
|
|
536
|
+
const result = await doMove(item.path, selectedDest, true); // true = skip refresh
|
|
537
|
+
if (result.ok) ok++;
|
|
538
|
+
else fail++;
|
|
539
|
+
}
|
|
540
|
+
bulkSelected.clear();
|
|
541
|
+
await refreshUI();
|
|
542
|
+
toast(`Moved ${ok} item(s)${fail ? `, ${fail} failed` : ""}`);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
document.getElementById("moveModal").classList.remove("hidden");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Scope card drop zones (document-level, bypass SortableJS) ────────
|
|
549
|
+
// SortableJS intercepts per-element dragover events on sortable containers.
|
|
550
|
+
// Using document-level listeners in capture phase ensures highlighting works.
|
|
551
|
+
|
|
552
|
+
function setupScopeDropZones() {
|
|
553
|
+
document.addEventListener("dragover", (e) => {
|
|
554
|
+
if (!draggingItem) return;
|
|
555
|
+
|
|
556
|
+
// Find the innermost scope-block under cursor
|
|
557
|
+
const scopeBlock = e.target.closest(".scope-block");
|
|
558
|
+
|
|
559
|
+
// Clear all highlights
|
|
560
|
+
document.querySelectorAll(".scope-block.drop-target").forEach(b => b.classList.remove("drop-target"));
|
|
561
|
+
|
|
562
|
+
if (scopeBlock) {
|
|
563
|
+
const hdr = scopeBlock.querySelector(":scope > .scope-hdr");
|
|
564
|
+
const scopeId = hdr?.dataset.scopeId;
|
|
565
|
+
if (scopeId && scopeId !== draggingItem.scopeId) {
|
|
566
|
+
e.preventDefault();
|
|
567
|
+
scopeBlock.classList.add("drop-target");
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}, true); // capture phase
|
|
571
|
+
|
|
572
|
+
document.addEventListener("drop", (e) => {
|
|
573
|
+
if (!draggingItem) return;
|
|
574
|
+
|
|
575
|
+
const scopeBlock = e.target.closest(".scope-block");
|
|
576
|
+
document.querySelectorAll(".scope-block.drop-target").forEach(b => b.classList.remove("drop-target"));
|
|
577
|
+
|
|
578
|
+
if (!scopeBlock) return;
|
|
579
|
+
const hdr = scopeBlock.querySelector(":scope > .scope-hdr");
|
|
580
|
+
const scopeId = hdr?.dataset.scopeId;
|
|
581
|
+
if (!scopeId || scopeId === draggingItem.scopeId) return;
|
|
582
|
+
|
|
583
|
+
// If drop landed inside a sortable zone, SortableJS onEnd handles it
|
|
584
|
+
if (e.target.closest(".sortable-zone")) return;
|
|
585
|
+
|
|
586
|
+
e.preventDefault();
|
|
587
|
+
e.stopPropagation();
|
|
588
|
+
|
|
589
|
+
const item = draggingItem;
|
|
590
|
+
const fromScope = data.scopes.find(s => s.id === item.scopeId);
|
|
591
|
+
const toScope = data.scopes.find(s => s.id === scopeId);
|
|
592
|
+
|
|
593
|
+
pendingDrag = { item, fromScopeId: item.scopeId, toScopeId: scopeId, revertFn: () => {} };
|
|
594
|
+
showDragConfirm(item, fromScope, toScope);
|
|
595
|
+
draggingItem = null;
|
|
596
|
+
}, true); // capture phase
|
|
597
|
+
|
|
598
|
+
document.addEventListener("dragend", () => {
|
|
599
|
+
draggingItem = null;
|
|
600
|
+
document.querySelectorAll(".scope-block.drop-target").forEach(b => b.classList.remove("drop-target"));
|
|
601
|
+
}, true); // capture phase
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Expand/Collapse toggle ───────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
let allExpanded = false;
|
|
607
|
+
|
|
608
|
+
function setupExpandToggle() {
|
|
609
|
+
const btn = document.getElementById("expandToggle");
|
|
610
|
+
btn.addEventListener("click", () => {
|
|
611
|
+
allExpanded = !allExpanded;
|
|
612
|
+
btn.innerHTML = allExpanded
|
|
613
|
+
? '<span class="toggle-icon expanded"></span> Collapse all'
|
|
614
|
+
: '<span class="toggle-icon"></span> Expand all';
|
|
615
|
+
|
|
616
|
+
// Scopes always stay open — only categories toggle
|
|
617
|
+
// Expand all = categories open; Collapse all = categories closed (default)
|
|
618
|
+
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
619
|
+
const body = hdr.nextElementSibling;
|
|
620
|
+
const tog = hdr.querySelector(".scope-tog");
|
|
621
|
+
body?.classList.remove("c");
|
|
622
|
+
tog?.classList.remove("c");
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
626
|
+
const body = hdr.nextElementSibling;
|
|
627
|
+
const tog = hdr.querySelector(".cat-tog");
|
|
628
|
+
if (allExpanded) {
|
|
629
|
+
body?.classList.remove("c");
|
|
630
|
+
tog?.classList.remove("c");
|
|
631
|
+
} else {
|
|
632
|
+
body?.classList.add("c");
|
|
633
|
+
tog?.classList.add("c");
|
|
335
634
|
}
|
|
336
635
|
});
|
|
337
636
|
});
|
|
@@ -342,10 +641,63 @@ function initSortable() {
|
|
|
342
641
|
function setupSearch() {
|
|
343
642
|
document.getElementById("searchInput").addEventListener("input", function () {
|
|
344
643
|
const q = this.value.toLowerCase();
|
|
644
|
+
const btn = document.getElementById("expandToggle");
|
|
645
|
+
|
|
646
|
+
// Auto-expand when searching, collapse back when cleared
|
|
647
|
+
if (q && !allExpanded) {
|
|
648
|
+
allExpanded = true;
|
|
649
|
+
btn.innerHTML = '<span class="toggle-icon expanded"></span> Collapse all';
|
|
650
|
+
document.querySelectorAll(".scope-hdr").forEach(hdr => {
|
|
651
|
+
hdr.nextElementSibling?.classList.remove("c");
|
|
652
|
+
hdr.querySelector(".scope-tog")?.classList.remove("c");
|
|
653
|
+
});
|
|
654
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
655
|
+
hdr.nextElementSibling?.classList.remove("c");
|
|
656
|
+
hdr.querySelector(".cat-tog")?.classList.remove("c");
|
|
657
|
+
});
|
|
658
|
+
} else if (!q && allExpanded) {
|
|
659
|
+
allExpanded = false;
|
|
660
|
+
btn.innerHTML = '<span class="toggle-icon"></span> Expand all';
|
|
661
|
+
document.querySelectorAll(".cat-hdr").forEach(hdr => {
|
|
662
|
+
hdr.nextElementSibling?.classList.add("c");
|
|
663
|
+
hdr.querySelector(".cat-tog")?.classList.add("c");
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 1. Show/hide individual item rows
|
|
345
668
|
document.querySelectorAll(".item-row").forEach(row => {
|
|
346
669
|
const text = row.textContent.toLowerCase();
|
|
347
670
|
row.style.display = (!q || text.includes(q)) ? "" : "none";
|
|
348
671
|
});
|
|
672
|
+
|
|
673
|
+
// 2. Hide category sections where all items are hidden
|
|
674
|
+
document.querySelectorAll(".cat-hdr").forEach(catHdr => {
|
|
675
|
+
const catBody = catHdr.nextElementSibling;
|
|
676
|
+
if (!catBody) return;
|
|
677
|
+
const rows = catBody.querySelectorAll(".item-row");
|
|
678
|
+
const anyVisible = rows.length === 0 || [...rows].some(r => r.style.display !== "none");
|
|
679
|
+
catHdr.style.display = anyVisible ? "" : "none";
|
|
680
|
+
catBody.style.display = anyVisible ? "" : "none";
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// 3. Hide scope blocks bottom-up (deepest first so parents see child visibility)
|
|
684
|
+
const allBlocks = [...document.querySelectorAll(".scope-block")];
|
|
685
|
+
allBlocks.reverse().forEach(block => {
|
|
686
|
+
const hdr = block.querySelector(":scope > .scope-hdr");
|
|
687
|
+
const body = block.querySelector(":scope > .scope-body");
|
|
688
|
+
if (!hdr || !body) return;
|
|
689
|
+
|
|
690
|
+
// Check if any direct category content is visible
|
|
691
|
+
const catHdrs = body.querySelectorAll(":scope > .cat-hdr");
|
|
692
|
+
const anyCatVisible = [...catHdrs].some(ch => ch.style.display !== "none");
|
|
693
|
+
|
|
694
|
+
// Check if any child scope-block is visible
|
|
695
|
+
const childScopes = body.querySelectorAll(":scope > .child-scopes > .scope-block");
|
|
696
|
+
const anyChildVisible = [...childScopes].some(cb => cb.style.display !== "none");
|
|
697
|
+
|
|
698
|
+
const visible = !q || anyCatVisible || anyChildVisible;
|
|
699
|
+
block.style.display = visible ? "" : "none";
|
|
700
|
+
});
|
|
349
701
|
});
|
|
350
702
|
}
|
|
351
703
|
|
|
@@ -359,6 +711,9 @@ function setupDetailPanel() {
|
|
|
359
711
|
document.getElementById("detailMove").addEventListener("click", () => {
|
|
360
712
|
if (selectedItem && !selectedItem.locked) openMoveModal(selectedItem);
|
|
361
713
|
});
|
|
714
|
+
document.getElementById("detailDelete").addEventListener("click", () => {
|
|
715
|
+
if (selectedItem && !selectedItem.locked) openDeleteModal(selectedItem);
|
|
716
|
+
});
|
|
362
717
|
}
|
|
363
718
|
|
|
364
719
|
function showDetail(item, rowEl) {
|
|
@@ -375,12 +730,56 @@ function showDetail(item, rowEl) {
|
|
|
375
730
|
document.getElementById("detailDate").textContent = item.mtime || "—";
|
|
376
731
|
document.getElementById("detailPath").textContent = item.path;
|
|
377
732
|
|
|
378
|
-
// Show/hide move
|
|
733
|
+
// Show/hide move and delete buttons
|
|
379
734
|
document.getElementById("detailMove").style.display = item.locked ? "none" : "";
|
|
735
|
+
document.getElementById("detailDelete").style.display = item.locked ? "none" : "";
|
|
380
736
|
|
|
381
737
|
// Highlight row
|
|
382
738
|
document.querySelectorAll(".item-row.selected").forEach(r => r.classList.remove("selected"));
|
|
383
739
|
if (rowEl) rowEl.classList.add("selected");
|
|
740
|
+
|
|
741
|
+
// Load preview
|
|
742
|
+
loadPreview(item);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function loadPreview(item) {
|
|
746
|
+
const el = document.getElementById("previewContent");
|
|
747
|
+
el.textContent = "Loading...";
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
// MCP: show config directly from item data
|
|
751
|
+
if (item.category === "mcp") {
|
|
752
|
+
el.textContent = JSON.stringify(item.mcpConfig || {}, null, 2);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Hook: show command/prompt inline
|
|
757
|
+
if (item.category === "hook") {
|
|
758
|
+
el.textContent = item.description || "(no content)";
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Plugin: directory, no single file to preview
|
|
763
|
+
if (item.category === "plugin") {
|
|
764
|
+
el.textContent = `Plugin directory: ${item.path}`;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Skill: read SKILL.md inside the directory
|
|
769
|
+
let filePath = item.path;
|
|
770
|
+
if (item.category === "skill") {
|
|
771
|
+
filePath = item.path + "/SKILL.md";
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const res = await fetchJson(`/api/file-content?path=${encodeURIComponent(filePath)}`);
|
|
775
|
+
if (res.ok) {
|
|
776
|
+
el.textContent = res.content;
|
|
777
|
+
} else {
|
|
778
|
+
el.textContent = res.error || "Cannot load preview";
|
|
779
|
+
}
|
|
780
|
+
} catch {
|
|
781
|
+
el.textContent = "Failed to load preview";
|
|
782
|
+
}
|
|
384
783
|
}
|
|
385
784
|
|
|
386
785
|
function closeDetail() {
|
|
@@ -427,6 +826,26 @@ function showDragConfirm(item, fromScope, toScope) {
|
|
|
427
826
|
|
|
428
827
|
// ── Modals ───────────────────────────────────────────────────────────
|
|
429
828
|
|
|
829
|
+
function openDeleteModal(item) {
|
|
830
|
+
pendingDelete = item;
|
|
831
|
+
const catConfig = CATEGORIES[item.category] || { icon: "📄", label: item.category };
|
|
832
|
+
const scope = data.scopes.find(s => s.id === item.scopeId);
|
|
833
|
+
|
|
834
|
+
document.getElementById("deletePreview").innerHTML = `
|
|
835
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
|
836
|
+
<span style="font-size:1.1rem;">${catConfig.icon}</span>
|
|
837
|
+
<div>
|
|
838
|
+
<div style="font-weight:700;color:var(--text-primary);font-size:0.88rem;">${esc(item.name)}</div>
|
|
839
|
+
<div style="font-size:0.65rem;color:var(--text-muted);margin-top:2px;">${esc(scope?.name || item.scopeId)} · ${esc(item.category)}</div>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
<div style="font-size:0.68rem;color:#dc2626;margin-top:8px;padding-top:8px;border-top:1px solid var(--border-light);">
|
|
843
|
+
${item.category === "skill" ? "This will delete the entire skill folder and all its files." : "This will permanently delete the file."}
|
|
844
|
+
</div>`;
|
|
845
|
+
|
|
846
|
+
document.getElementById("deleteModal").classList.remove("hidden");
|
|
847
|
+
}
|
|
848
|
+
|
|
430
849
|
function setupModals() {
|
|
431
850
|
// Drag confirm
|
|
432
851
|
document.getElementById("dcCancel").addEventListener("click", () => {
|
|
@@ -455,6 +874,25 @@ function setupModals() {
|
|
|
455
874
|
pendingDrag = null;
|
|
456
875
|
}
|
|
457
876
|
});
|
|
877
|
+
|
|
878
|
+
// Delete modal
|
|
879
|
+
document.getElementById("deleteCancel").addEventListener("click", () => {
|
|
880
|
+
document.getElementById("deleteModal").classList.add("hidden");
|
|
881
|
+
pendingDelete = null;
|
|
882
|
+
});
|
|
883
|
+
document.getElementById("deleteConfirm").addEventListener("click", async () => {
|
|
884
|
+
document.getElementById("deleteModal").classList.add("hidden");
|
|
885
|
+
if (pendingDelete) {
|
|
886
|
+
await doDelete(pendingDelete.path);
|
|
887
|
+
pendingDelete = null;
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
document.getElementById("deleteModal").addEventListener("click", (e) => {
|
|
891
|
+
if (e.target === document.getElementById("deleteModal")) {
|
|
892
|
+
document.getElementById("deleteModal").classList.add("hidden");
|
|
893
|
+
pendingDelete = null;
|
|
894
|
+
}
|
|
895
|
+
});
|
|
458
896
|
}
|
|
459
897
|
|
|
460
898
|
async function openMoveModal(item) {
|
|
@@ -545,7 +983,19 @@ function closeMoveModal() {
|
|
|
545
983
|
|
|
546
984
|
// ── API calls ────────────────────────────────────────────────────────
|
|
547
985
|
|
|
548
|
-
async function
|
|
986
|
+
async function refreshUI() {
|
|
987
|
+
saveExpandState();
|
|
988
|
+
data = await fetchJson("/api/scan");
|
|
989
|
+
renderPills();
|
|
990
|
+
renderTree();
|
|
991
|
+
closeDetail();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function doMove(itemPath, toScopeId, skipRefresh = false) {
|
|
995
|
+
// Find item before move for undo info
|
|
996
|
+
const item = data.items.find(i => i.path === itemPath);
|
|
997
|
+
const fromScopeId = item?.scopeId;
|
|
998
|
+
|
|
549
999
|
const response = await fetch("/api/move", {
|
|
550
1000
|
method: "POST",
|
|
551
1001
|
headers: { "Content-Type": "application/json" },
|
|
@@ -553,27 +1003,115 @@ async function doMove(itemPath, toScopeId) {
|
|
|
553
1003
|
});
|
|
554
1004
|
const result = await response.json();
|
|
555
1005
|
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
1006
|
+
if (!skipRefresh) {
|
|
1007
|
+
if (result.ok) {
|
|
1008
|
+
const undoFn = async () => {
|
|
1009
|
+
// Move back: result.to is the new path, fromScopeId is where it came from
|
|
1010
|
+
const undoResult = await fetch("/api/move", {
|
|
1011
|
+
method: "POST",
|
|
1012
|
+
headers: { "Content-Type": "application/json" },
|
|
1013
|
+
body: JSON.stringify({ itemPath: result.to, toScopeId: fromScopeId }),
|
|
1014
|
+
}).then(r => r.json());
|
|
1015
|
+
if (undoResult.ok) { toast("Move undone"); await refreshUI(); }
|
|
1016
|
+
else toast(undoResult.error, true);
|
|
1017
|
+
};
|
|
1018
|
+
toast(result.message, false, undoFn);
|
|
1019
|
+
await refreshUI();
|
|
1020
|
+
} else {
|
|
1021
|
+
toast(result.error, true);
|
|
1022
|
+
}
|
|
565
1023
|
}
|
|
566
1024
|
|
|
567
1025
|
return result;
|
|
568
1026
|
}
|
|
569
1027
|
|
|
570
|
-
|
|
1028
|
+
async function doDelete(itemPath, skipRefresh = false) {
|
|
1029
|
+
// Backup content before delete for undo
|
|
1030
|
+
const item = data.items.find(i => i.path === itemPath);
|
|
1031
|
+
let backupContent = null;
|
|
1032
|
+
let mcpBackup = null;
|
|
1033
|
+
if (item) {
|
|
1034
|
+
try {
|
|
1035
|
+
if (item.category === "mcp") {
|
|
1036
|
+
// MCP: backup the server config from item data
|
|
1037
|
+
mcpBackup = { name: item.name, config: item.mcpConfig, mcpJsonPath: item.path };
|
|
1038
|
+
} else {
|
|
1039
|
+
let readPath = item.path;
|
|
1040
|
+
if (item.category === "skill") readPath = item.path + "/SKILL.md";
|
|
1041
|
+
const backup = await fetchJson(`/api/file-content?path=${encodeURIComponent(readPath)}`);
|
|
1042
|
+
if (backup.ok) backupContent = backup.content;
|
|
1043
|
+
}
|
|
1044
|
+
} catch { /* best effort */ }
|
|
1045
|
+
}
|
|
571
1046
|
|
|
572
|
-
|
|
1047
|
+
const response = await fetch("/api/delete", {
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
headers: { "Content-Type": "application/json" },
|
|
1050
|
+
body: JSON.stringify({ itemPath }),
|
|
1051
|
+
});
|
|
1052
|
+
const result = await response.json();
|
|
1053
|
+
|
|
1054
|
+
if (!skipRefresh) {
|
|
1055
|
+
if (result.ok) {
|
|
1056
|
+
let undoFn = null;
|
|
1057
|
+
if (mcpBackup) {
|
|
1058
|
+
// MCP undo: re-add the server entry to .mcp.json
|
|
1059
|
+
undoFn = async () => {
|
|
1060
|
+
const restoreResult = await fetch("/api/restore-mcp", {
|
|
1061
|
+
method: "POST",
|
|
1062
|
+
headers: { "Content-Type": "application/json" },
|
|
1063
|
+
body: JSON.stringify(mcpBackup),
|
|
1064
|
+
}).then(r => r.json());
|
|
1065
|
+
if (restoreResult.ok) { toast("Delete undone"); await refreshUI(); }
|
|
1066
|
+
else toast(restoreResult.error, true);
|
|
1067
|
+
};
|
|
1068
|
+
} else if (backupContent) {
|
|
1069
|
+
undoFn = async () => {
|
|
1070
|
+
const restoreResult = await fetch("/api/restore", {
|
|
1071
|
+
method: "POST",
|
|
1072
|
+
headers: { "Content-Type": "application/json" },
|
|
1073
|
+
body: JSON.stringify({
|
|
1074
|
+
filePath: item.path,
|
|
1075
|
+
content: backupContent,
|
|
1076
|
+
isDir: item.category === "skill",
|
|
1077
|
+
}),
|
|
1078
|
+
}).then(r => r.json());
|
|
1079
|
+
if (restoreResult.ok) { toast("Delete undone"); await refreshUI(); }
|
|
1080
|
+
else toast(restoreResult.error, true);
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
toast(result.message, false, undoFn);
|
|
1084
|
+
await refreshUI();
|
|
1085
|
+
} else {
|
|
1086
|
+
toast(result.error, true);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return result;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── Toast with optional Undo ─────────────────────────────────────────
|
|
1094
|
+
|
|
1095
|
+
let toastTimer = null;
|
|
1096
|
+
|
|
1097
|
+
function toast(msg, isError = false, undoFn = null) {
|
|
573
1098
|
const el = document.getElementById("toast");
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1099
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
1100
|
+
|
|
1101
|
+
if (undoFn) {
|
|
1102
|
+
document.getElementById("toastMsg").innerHTML =
|
|
1103
|
+
`${esc(msg)} <button class="toast-undo" id="toastUndo">Undo</button>`;
|
|
1104
|
+
el.className = "toast";
|
|
1105
|
+
document.getElementById("toastUndo").onclick = async () => {
|
|
1106
|
+
el.classList.add("hidden");
|
|
1107
|
+
await undoFn();
|
|
1108
|
+
};
|
|
1109
|
+
toastTimer = setTimeout(() => el.classList.add("hidden"), 8000); // longer for undo
|
|
1110
|
+
} else {
|
|
1111
|
+
document.getElementById("toastMsg").textContent = msg;
|
|
1112
|
+
el.className = isError ? "toast error" : "toast";
|
|
1113
|
+
toastTimer = setTimeout(() => el.classList.add("hidden"), 4000);
|
|
1114
|
+
}
|
|
577
1115
|
}
|
|
578
1116
|
|
|
579
1117
|
// ── Start ────────────────────────────────────────────────────────────
|
package/src/ui/index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
-
<title>Claude
|
|
6
|
+
<title>Claude Code Organizer</title>
|
|
7
7
|
<link rel="stylesheet" href="/style.css">
|
|
8
8
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
9
9
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
<div class="tree-area">
|
|
14
14
|
<!-- Header -->
|
|
15
15
|
<div class="page-hdr">
|
|
16
|
-
<div class="logo"
|
|
17
|
-
<h1>Claude
|
|
16
|
+
<div class="logo"><svg width="20" height="20" viewBox="0 0 248 248" fill="none"><path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#fff"/></svg></div>
|
|
17
|
+
<h1>Claude Code Organizer</h1>
|
|
18
18
|
<span class="spacer"></span>
|
|
19
19
|
<div class="pills" id="pills">
|
|
20
20
|
<!-- Rendered by app.js -->
|
|
21
21
|
</div>
|
|
22
|
-
<
|
|
22
|
+
<div class="hdr-actions">
|
|
23
|
+
<input class="search" placeholder="Search name, type, description..." id="searchInput">
|
|
24
|
+
<button class="expand-toggle" id="expandToggle" title="Expand / Collapse all"><span class="toggle-icon"></span> Expand all</button>
|
|
25
|
+
</div>
|
|
23
26
|
</div>
|
|
24
27
|
|
|
25
28
|
<!-- Tree content — rendered by app.js -->
|
|
@@ -37,17 +40,24 @@
|
|
|
37
40
|
<h2 id="detailTitle">—</h2>
|
|
38
41
|
<button class="detail-x" id="detailClose">×</button>
|
|
39
42
|
</div>
|
|
40
|
-
<div class="detail-
|
|
43
|
+
<div class="detail-meta">
|
|
41
44
|
<div class="df"><div class="dl">Type</div><div class="dv" id="detailType">—</div></div>
|
|
42
45
|
<div class="df"><div class="dl">Scope</div><div class="dv" id="detailScope">—</div></div>
|
|
43
46
|
<div class="df"><div class="dl">Description</div><div class="dv" id="detailDesc">—</div></div>
|
|
44
|
-
<div class="df"
|
|
45
|
-
|
|
47
|
+
<div class="df-row">
|
|
48
|
+
<div class="df"><div class="dl">Size</div><div class="dv" id="detailSize">—</div></div>
|
|
49
|
+
<div class="df"><div class="dl">Modified</div><div class="dv" id="detailDate">—</div></div>
|
|
50
|
+
</div>
|
|
46
51
|
<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
52
|
</div>
|
|
53
|
+
<div class="detail-preview">
|
|
54
|
+
<div class="preview-hdr"><span class="preview-label">PREVIEW</span></div>
|
|
55
|
+
<pre class="preview-content" id="previewContent">Select an item to preview</pre>
|
|
56
|
+
</div>
|
|
48
57
|
<div class="detail-ft">
|
|
49
58
|
<button class="btn btn-p" id="detailOpen">Open in Editor</button>
|
|
50
59
|
<button class="btn btn-s" id="detailMove">Move to...</button>
|
|
60
|
+
<button class="btn btn-danger" id="detailDelete">Delete</button>
|
|
51
61
|
</div>
|
|
52
62
|
</div>
|
|
53
63
|
</div>
|
|
@@ -80,6 +90,29 @@
|
|
|
80
90
|
</div>
|
|
81
91
|
</div>
|
|
82
92
|
|
|
93
|
+
<!-- Delete confirm modal -->
|
|
94
|
+
<div class="modal-bg hidden" id="deleteModal">
|
|
95
|
+
<div class="modal">
|
|
96
|
+
<h3>Delete Item</h3>
|
|
97
|
+
<div class="modal-sub">This will permanently delete the file from disk</div>
|
|
98
|
+
<div class="move-preview" id="deletePreview">
|
|
99
|
+
<!-- Rendered by app.js -->
|
|
100
|
+
</div>
|
|
101
|
+
<div class="modal-btns">
|
|
102
|
+
<button class="btn btn-s" id="deleteCancel">Cancel</button>
|
|
103
|
+
<button class="btn btn-danger" id="deleteConfirm">Delete</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<!-- Bulk action bar -->
|
|
109
|
+
<div class="bulk-bar hidden" id="bulkBar">
|
|
110
|
+
<span class="bulk-count" id="bulkCount">0 selected</span>
|
|
111
|
+
<button class="btn btn-p btn-sm" id="bulkMove">Move all</button>
|
|
112
|
+
<button class="btn btn-danger btn-sm" id="bulkDelete">Delete all</button>
|
|
113
|
+
<button class="btn btn-s btn-sm" id="bulkClear">Clear</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
83
116
|
<!-- Toast -->
|
|
84
117
|
<div class="toast hidden" id="toast"><span id="toastMsg">Done</span></div>
|
|
85
118
|
|
package/src/ui/style.css
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* ══════════════════════════════════════════════════════════
|
|
2
|
-
Claude
|
|
2
|
+
Claude Code Organizer — Styles
|
|
3
3
|
Edit this file freely. No logic here.
|
|
4
4
|
══════════════════════════════════════════════════════════ */
|
|
5
5
|
|
|
@@ -52,7 +52,12 @@ body {
|
|
|
52
52
|
.logo { width:32px; height:32px; background:var(--accent); border-radius:8px; display:flex; align-items:center; justify-content:center; color:#fff; font-size:16px; font-weight:700; flex-shrink:0; }
|
|
53
53
|
.page-hdr h1 { font-size:1.25rem; color:var(--text-primary); font-weight:700; letter-spacing:-0.4px; }
|
|
54
54
|
.spacer { flex:1; }
|
|
55
|
-
.
|
|
55
|
+
.hdr-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; }
|
|
56
|
+
.expand-toggle { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:7px 12px; color:var(--text-secondary); font-size:0.72rem; cursor:pointer; font-family:inherit; font-weight:500; transition:all 0.15s; white-space:nowrap; display:inline-flex; align-items:center; gap:4px; }
|
|
57
|
+
.expand-toggle:hover { background:var(--hover); border-color:var(--scope-border); }
|
|
58
|
+
.toggle-icon { display:inline-block; width:0; height:0; border-top:4px solid transparent; border-bottom:4px solid transparent; border-left:6px solid var(--text-muted); transition:transform 0.15s; }
|
|
59
|
+
.toggle-icon.expanded { transform:rotate(90deg); }
|
|
60
|
+
.search { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:7px 14px; color:var(--text-primary); font-size:0.78rem; width:260px; font-family:inherit; outline:none; transition:all 0.15s; }
|
|
56
61
|
.search:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-light); }
|
|
57
62
|
.search::placeholder { color:var(--text-faint); }
|
|
58
63
|
.pills { display:flex; gap:4px; flex-wrap:wrap; }
|
|
@@ -89,6 +94,14 @@ body {
|
|
|
89
94
|
.scope-body { padding:0 0 6px; }
|
|
90
95
|
.scope-body.c { display:none; }
|
|
91
96
|
|
|
97
|
+
/* Drop zone highlighting — entire scope card lights up */
|
|
98
|
+
.scope-block.drop-target > .scope-hdr {
|
|
99
|
+
background:var(--accent-light);
|
|
100
|
+
border-color:var(--accent);
|
|
101
|
+
box-shadow:0 0 0 3px rgba(91,95,199,0.15);
|
|
102
|
+
transition:all 0.12s;
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
.inherit {
|
|
93
106
|
font-size:0.68rem; color:var(--accent-text); padding:6px 14px 10px 40px;
|
|
94
107
|
font-weight:500; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
|
@@ -153,6 +166,7 @@ body {
|
|
|
153
166
|
.item-row:hover .row-acts { opacity:1; }
|
|
154
167
|
.rbtn { background:var(--surface); border:1px solid var(--border); border-radius:5px; color:var(--text-secondary); padding:3px 8px; cursor:pointer; font-size:0.6rem; font-family:inherit; transition:all 0.12s; }
|
|
155
168
|
.rbtn:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
169
|
+
.rbtn-danger:hover { background:#dc2626; color:#fff; border-color:#dc2626; }
|
|
156
170
|
|
|
157
171
|
/* ── Detail panel ── */
|
|
158
172
|
.detail { width:340px; border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; background:var(--surface); }
|
|
@@ -161,10 +175,16 @@ body {
|
|
|
161
175
|
.detail-hdr h2 { font-size:0.88rem; color:var(--text-primary); flex:1; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
162
176
|
.detail-x { background:none; border:none; color:var(--text-faint); cursor:pointer; font-size:1.1rem; transition:color 0.1s; }
|
|
163
177
|
.detail-x:hover { color:var(--text-primary); }
|
|
164
|
-
.detail-
|
|
165
|
-
.df { margin-bottom:
|
|
178
|
+
.detail-meta { padding:16px 20px 8px; border-bottom:1px solid var(--border-light); flex-shrink:0; }
|
|
179
|
+
.df { margin-bottom:12px; }
|
|
180
|
+
.df-row { display:flex; gap:20px; }
|
|
181
|
+
.df-row .df { flex:1; }
|
|
166
182
|
.dl { font-size:0.6rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.8px; margin-bottom:4px; font-weight:600; }
|
|
167
183
|
.dv { font-size:0.78rem; color:var(--text-secondary); line-height:1.4; }
|
|
184
|
+
.detail-preview { flex:1; display:flex; flex-direction:column; overflow:hidden; min-height:0; }
|
|
185
|
+
.preview-hdr { padding:12px 20px 6px; flex-shrink:0; }
|
|
186
|
+
.preview-label { font-size:0.6rem; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.8px; font-weight:600; }
|
|
187
|
+
.preview-content { flex:1; overflow-y:auto; padding:0 20px 16px; font-size:0.68rem; line-height:1.55; color:var(--text-secondary); white-space:pre-wrap; word-break:break-word; font-family:'SF Mono','Cascadia Code','Fira Code','Courier New',monospace; margin:0; background:transparent; }
|
|
168
188
|
.detail-ft { padding:16px 20px; border-top:1px solid var(--border-light); display:flex; gap:8px; }
|
|
169
189
|
.btn { padding:7px 16px; border-radius:8px; font-size:0.73rem; cursor:pointer; font-family:inherit; border:1px solid var(--border); font-weight:500; transition:all 0.15s; }
|
|
170
190
|
.btn-p { background:var(--accent); color:#fff; border-color:var(--accent); }
|
|
@@ -172,6 +192,8 @@ body {
|
|
|
172
192
|
.btn-p:disabled { opacity:0.4; cursor:not-allowed; }
|
|
173
193
|
.btn-s { background:var(--surface); color:var(--text-secondary); }
|
|
174
194
|
.btn-s:hover { background:var(--hover); }
|
|
195
|
+
.btn-danger { background:#dc2626; color:#fff; border-color:#dc2626; }
|
|
196
|
+
.btn-danger:hover { background:#b91c1c; }
|
|
175
197
|
|
|
176
198
|
/* ── Modals ── */
|
|
177
199
|
.modal-bg { position:fixed; inset:0; background:rgba(30,32,40,0.25); display:flex; align-items:center; justify-content:center; z-index:100; backdrop-filter:blur(3px); }
|
|
@@ -192,9 +214,18 @@ body {
|
|
|
192
214
|
.dest .dp { color:var(--text-muted); font-size:0.6rem; margin-left:auto; }
|
|
193
215
|
.dest-indent { margin-left:24px; }
|
|
194
216
|
|
|
217
|
+
/* ── Checkbox & Bulk bar ── */
|
|
218
|
+
.row-chk { width:14px; height:14px; cursor:pointer; accent-color:var(--accent); flex-shrink:0; margin:0; }
|
|
219
|
+
.bulk-bar { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:10px 20px; display:flex; align-items:center; gap:12px; z-index:200; box-shadow:0 4px 20px rgba(0,0,0,0.12); }
|
|
220
|
+
.bulk-bar.hidden { display:none; }
|
|
221
|
+
.bulk-count { font-size:0.75rem; font-weight:600; color:var(--text-primary); }
|
|
222
|
+
.btn-sm { padding:5px 12px; font-size:0.68rem; }
|
|
223
|
+
|
|
195
224
|
.toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:var(--text-primary); color:#fff; padding:12px 28px; border-radius:12px; font-size:0.78rem; z-index:200; box-shadow:0 4px 20px rgba(0,0,0,0.12); font-weight:500; transition:opacity 0.3s; }
|
|
196
225
|
.toast.hidden { display:none; }
|
|
197
226
|
.toast.error { background:#dc2626; }
|
|
227
|
+
.toast-undo { background:none; border:1px solid rgba(255,255,255,0.4); color:#fff; padding:3px 10px; border-radius:6px; cursor:pointer; font-size:0.7rem; font-family:inherit; font-weight:600; margin-left:10px; transition:all 0.12s; }
|
|
228
|
+
.toast-undo:hover { background:rgba(255,255,255,0.2); border-color:#fff; }
|
|
198
229
|
|
|
199
230
|
/* ── Scrollbar ── */
|
|
200
231
|
::-webkit-scrollbar { width:6px; }
|