myrlin-workbook 0.9.25 → 0.9.27
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/package.json +71 -66
- package/scripts/build-lucide-bundle.mjs +244 -0
- package/scripts/build-material-bundle.mjs +108 -0
- package/src/state/store.js +2 -1
- package/src/web/file-manager.js +149 -0
- package/src/web/git-manager.js +115 -0
- package/src/web/public/app.js +1035 -22
- package/src/web/public/index.html +68 -38
- package/src/web/public/styles.css +519 -21
- package/src/web/public/terminal.js +5 -1
- package/src/web/public/vendor/codemirror.bundle.js +12 -0
- package/src/web/public/vendor/lucide.bundle.js +7 -0
- package/src/web/public/vendor/material-icons.bundle.js +8 -0
- package/src/web/server.js +185 -3
package/package.json
CHANGED
|
@@ -1,66 +1,71 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "myrlin-workbook",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
|
|
5
|
-
"main": "src/index.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"myrlin-workbook": "./src/gui.js",
|
|
8
|
-
"myrlin": "./src/gui.js",
|
|
9
|
-
"myrlin-tui": "./src/index.js",
|
|
10
|
-
"cwm": "./src/index.js"
|
|
11
|
-
},
|
|
12
|
-
"scripts": {
|
|
13
|
-
"start": "node src/index.js",
|
|
14
|
-
"demo": "node src/demo.js",
|
|
15
|
-
"gui": "node src/supervisor.js",
|
|
16
|
-
"gui:daemon": "node src/supervisor.js --daemon",
|
|
17
|
-
"gui:bare": "node src/gui.js",
|
|
18
|
-
"gui:demo": "node src/supervisor.js --demo",
|
|
19
|
-
"test": "node test/run.js",
|
|
20
|
-
"mcp:visual-qa": "node src/mcp/visual-qa.js",
|
|
21
|
-
"gui:cdp": "node src/supervisor.js --cdp",
|
|
22
|
-
"postinstall": "node scripts/postinstall.js",
|
|
23
|
-
"restart": "bash scripts/restart-gui.sh"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "myrlin-workbook",
|
|
3
|
+
"version": "0.9.27",
|
|
4
|
+
"description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"myrlin-workbook": "./src/gui.js",
|
|
8
|
+
"myrlin": "./src/gui.js",
|
|
9
|
+
"myrlin-tui": "./src/index.js",
|
|
10
|
+
"cwm": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node src/index.js",
|
|
14
|
+
"demo": "node src/demo.js",
|
|
15
|
+
"gui": "node src/supervisor.js",
|
|
16
|
+
"gui:daemon": "node src/supervisor.js --daemon",
|
|
17
|
+
"gui:bare": "node src/gui.js",
|
|
18
|
+
"gui:demo": "node src/supervisor.js --demo",
|
|
19
|
+
"test": "node test/run.js",
|
|
20
|
+
"mcp:visual-qa": "node src/mcp/visual-qa.js",
|
|
21
|
+
"gui:cdp": "node src/supervisor.js --cdp",
|
|
22
|
+
"postinstall": "node scripts/postinstall.js",
|
|
23
|
+
"restart": "bash scripts/restart-gui.sh",
|
|
24
|
+
"build:icons": "node scripts/build-lucide-bundle.mjs && node scripts/build-material-bundle.mjs"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/therealarthur/myrlin-workbook.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/therealarthur/myrlin-workbook",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"claude",
|
|
36
|
+
"workspace",
|
|
37
|
+
"manager",
|
|
38
|
+
"terminal",
|
|
39
|
+
"tui",
|
|
40
|
+
"ai",
|
|
41
|
+
"coding-assistant",
|
|
42
|
+
"session-manager",
|
|
43
|
+
"developer-tools",
|
|
44
|
+
"xterm",
|
|
45
|
+
"myrlin"
|
|
46
|
+
],
|
|
47
|
+
"author": "Arthur",
|
|
48
|
+
"license": "AGPL-3.0-only",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@lucide/lab": "^0.1.2",
|
|
51
|
+
"blessed": "^0.1.81",
|
|
52
|
+
"blessed-contrib": "^4.11.0",
|
|
53
|
+
"chalk": "^5.6.2",
|
|
54
|
+
"chrome-remote-interface": "^0.34.0",
|
|
55
|
+
"express": "^5.2.1",
|
|
56
|
+
"lucide": "^1.8.0",
|
|
57
|
+
"node-pty": "^1.1.0",
|
|
58
|
+
"qrcode": "^1.5.4",
|
|
59
|
+
"simple-git": "^3.33.0",
|
|
60
|
+
"ws": "^8.19.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@material-icons/svg": "^1.0.33",
|
|
64
|
+
"@playwright/test": "^1.58.2",
|
|
65
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
66
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
67
|
+
"@xterm/xterm": "^6.0.0",
|
|
68
|
+
"ffmpeg-static": "^5.3.0",
|
|
69
|
+
"sharp": "^0.34.5"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build script: generates public/vendor/lucide.bundle.js
|
|
4
|
+
* Imports curated icons from `lucide` and `@lucide/lab`, converts to SVG strings,
|
|
5
|
+
* and outputs a plain JS bundle that sets window.__lucideIcons.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { dirname, resolve } from 'path';
|
|
11
|
+
import vm from 'vm';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT = resolve(__dirname, '..');
|
|
15
|
+
const OUT = resolve(ROOT, 'src/web/public/vendor/lucide.bundle.js');
|
|
16
|
+
|
|
17
|
+
// ── Curated icon list ────────────────────────────────────────────────────────
|
|
18
|
+
// Format: { category: [[kebab-name, 'lucide' | 'lab'], ...], ... }
|
|
19
|
+
const ICON_CATEGORIES = {
|
|
20
|
+
'Folders & Files': [
|
|
21
|
+
['folder', 'lucide'],
|
|
22
|
+
['folder-open', 'lucide'],
|
|
23
|
+
['folder-code', 'lucide'],
|
|
24
|
+
['folder-git-2', 'lucide'],
|
|
25
|
+
['folder-sync', 'lucide'],
|
|
26
|
+
['folder-key', 'lucide'],
|
|
27
|
+
['folder-search', 'lucide'],
|
|
28
|
+
['folders', 'lucide'],
|
|
29
|
+
['file', 'lucide'],
|
|
30
|
+
['file-code', 'lucide'],
|
|
31
|
+
['file-text', 'lucide'],
|
|
32
|
+
['files', 'lucide'],
|
|
33
|
+
['notebook', 'lucide'],
|
|
34
|
+
['notebook-pen', 'lucide'],
|
|
35
|
+
],
|
|
36
|
+
'Code & Dev': [
|
|
37
|
+
['code', 'lucide'],
|
|
38
|
+
['code-xml', 'lucide'],
|
|
39
|
+
['square-terminal', 'lucide'],
|
|
40
|
+
['terminal', 'lucide'],
|
|
41
|
+
['git-branch', 'lucide'],
|
|
42
|
+
['git-merge', 'lucide'],
|
|
43
|
+
['git-commit-vertical', 'lucide'],
|
|
44
|
+
['git-fork', 'lucide'],
|
|
45
|
+
['braces', 'lucide'],
|
|
46
|
+
['brackets', 'lucide'],
|
|
47
|
+
['hash', 'lucide'],
|
|
48
|
+
['variable', 'lucide'],
|
|
49
|
+
],
|
|
50
|
+
'Infrastructure': [
|
|
51
|
+
['database', 'lucide'],
|
|
52
|
+
['database-zap', 'lucide'],
|
|
53
|
+
['server', 'lucide'],
|
|
54
|
+
['server-cog', 'lucide'],
|
|
55
|
+
['cpu', 'lucide'],
|
|
56
|
+
['hard-drive', 'lucide'],
|
|
57
|
+
['network', 'lucide'],
|
|
58
|
+
['wifi', 'lucide'],
|
|
59
|
+
['cloud', 'lucide'],
|
|
60
|
+
['cloud-upload', 'lucide'],
|
|
61
|
+
['globe', 'lucide'],
|
|
62
|
+
['layers', 'lucide'],
|
|
63
|
+
],
|
|
64
|
+
'Apps & Tools': [
|
|
65
|
+
['box', 'lucide'],
|
|
66
|
+
['package', 'lucide'],
|
|
67
|
+
['package-2', 'lucide'],
|
|
68
|
+
['workflow', 'lucide'],
|
|
69
|
+
['settings', 'lucide'],
|
|
70
|
+
['settings-2', 'lucide'],
|
|
71
|
+
['wrench', 'lucide'],
|
|
72
|
+
['hammer', 'lucide'],
|
|
73
|
+
['cog', 'lucide'],
|
|
74
|
+
['tool-case', 'lucide'],
|
|
75
|
+
['puzzle', 'lucide'],
|
|
76
|
+
['blocks', 'lucide'],
|
|
77
|
+
['plug', 'lucide'],
|
|
78
|
+
['cpu', 'lucide'],
|
|
79
|
+
],
|
|
80
|
+
'Design & Art': [
|
|
81
|
+
['palette', 'lucide'],
|
|
82
|
+
['brush', 'lucide'],
|
|
83
|
+
['paintbrush', 'lucide'],
|
|
84
|
+
['wand', 'lucide'],
|
|
85
|
+
['wand-sparkles', 'lucide'],
|
|
86
|
+
['sparkles', 'lucide'],
|
|
87
|
+
['pen-tool', 'lucide'],
|
|
88
|
+
['pencil', 'lucide'],
|
|
89
|
+
['scissors', 'lucide'],
|
|
90
|
+
['crop', 'lucide'],
|
|
91
|
+
['image', 'lucide'],
|
|
92
|
+
['camera', 'lucide'],
|
|
93
|
+
],
|
|
94
|
+
'Learning & Science': [
|
|
95
|
+
['book-open', 'lucide'],
|
|
96
|
+
['book', 'lucide'],
|
|
97
|
+
['scroll', 'lucide'],
|
|
98
|
+
['flask-conical', 'lucide'],
|
|
99
|
+
['flask-round', 'lucide'],
|
|
100
|
+
['microscope', 'lucide'],
|
|
101
|
+
['test-tube', 'lucide'],
|
|
102
|
+
['brain', 'lucide'],
|
|
103
|
+
['brain-circuit', 'lucide'],
|
|
104
|
+
['atom', 'lucide'],
|
|
105
|
+
['dna', 'lucide'],
|
|
106
|
+
['telescope', 'lucide'],
|
|
107
|
+
],
|
|
108
|
+
'People & Status': [
|
|
109
|
+
['user', 'lucide'],
|
|
110
|
+
['users', 'lucide'],
|
|
111
|
+
['user-round', 'lucide'],
|
|
112
|
+
['bot', 'lucide'],
|
|
113
|
+
['rocket', 'lucide'],
|
|
114
|
+
['star', 'lucide'],
|
|
115
|
+
['heart', 'lucide'],
|
|
116
|
+
['zap', 'lucide'],
|
|
117
|
+
['shield', 'lucide'],
|
|
118
|
+
['lock', 'lucide'],
|
|
119
|
+
['key', 'lucide'],
|
|
120
|
+
['flag', 'lucide'],
|
|
121
|
+
['trophy', 'lucide'],
|
|
122
|
+
['medal', 'lucide'],
|
|
123
|
+
['crown', 'lucide'],
|
|
124
|
+
],
|
|
125
|
+
'Home & Work': [
|
|
126
|
+
['house', 'lucide'],
|
|
127
|
+
['briefcase', 'lucide'],
|
|
128
|
+
['building', 'lucide'],
|
|
129
|
+
['building-2', 'lucide'],
|
|
130
|
+
['store', 'lucide'],
|
|
131
|
+
['landmark', 'lucide'],
|
|
132
|
+
['map-pin', 'lucide'],
|
|
133
|
+
['compass', 'lucide'],
|
|
134
|
+
['map', 'lucide'],
|
|
135
|
+
['globe-2', 'lucide'],
|
|
136
|
+
['mountain', 'lucide'],
|
|
137
|
+
],
|
|
138
|
+
'Media & Comms': [
|
|
139
|
+
['monitor', 'lucide'],
|
|
140
|
+
['laptop', 'lucide'],
|
|
141
|
+
['smartphone', 'lucide'],
|
|
142
|
+
['mail', 'lucide'],
|
|
143
|
+
['message-circle', 'lucide'],
|
|
144
|
+
['bell', 'lucide'],
|
|
145
|
+
['phone', 'lucide'],
|
|
146
|
+
['mic', 'lucide'],
|
|
147
|
+
['music', 'lucide'],
|
|
148
|
+
['headphones', 'lucide'],
|
|
149
|
+
['video', 'lucide'],
|
|
150
|
+
['radio', 'lucide'],
|
|
151
|
+
],
|
|
152
|
+
'Nature & Fun': [
|
|
153
|
+
['sun', 'lucide'],
|
|
154
|
+
['moon', 'lucide'],
|
|
155
|
+
['cloud-sun', 'lucide'],
|
|
156
|
+
['flame', 'lucide'],
|
|
157
|
+
['waves', 'lucide'],
|
|
158
|
+
['coffee', 'lucide'],
|
|
159
|
+
['leaf', 'lucide'],
|
|
160
|
+
['flower', 'lucide'],
|
|
161
|
+
['flower-2', 'lucide'],
|
|
162
|
+
['tree-pine', 'lucide'],
|
|
163
|
+
['trees', 'lucide'],
|
|
164
|
+
['snail', 'lucide'],
|
|
165
|
+
['fish', 'lucide'],
|
|
166
|
+
['rabbit', 'lucide'],
|
|
167
|
+
['turtle', 'lucide'],
|
|
168
|
+
['bird', 'lucide'],
|
|
169
|
+
['dog', 'lucide'],
|
|
170
|
+
['cat', 'lucide'],
|
|
171
|
+
],
|
|
172
|
+
'Lab Extras': [
|
|
173
|
+
['owl', 'lab'],
|
|
174
|
+
['planet', 'lab'],
|
|
175
|
+
['bee', 'lab'],
|
|
176
|
+
['venn', 'lab'],
|
|
177
|
+
['copy-code', 'lab'],
|
|
178
|
+
['grid-lines', 'lab'],
|
|
179
|
+
['farm', 'lab'],
|
|
180
|
+
['toolbox', 'lab'],
|
|
181
|
+
['toolbox-2', 'lab'],
|
|
182
|
+
['snowman', 'lab'],
|
|
183
|
+
['mountain-snow', 'lab'],
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// ── SVG serializer ────────────────────────────────────────────────────────────
|
|
188
|
+
function iconDataToSvg(iconData) {
|
|
189
|
+
const children = iconData.map(([tag, attrs]) => {
|
|
190
|
+
// Filter out non-SVG metadata attrs ('key' is a React/lucide internal)
|
|
191
|
+
const entries = Object.entries(attrs).filter(([k]) => k !== 'key');
|
|
192
|
+
const attrStr = entries.map(([k, v]) => `${k}="${v}"`).join(' ');
|
|
193
|
+
return `<${tag} ${attrStr}/>`;
|
|
194
|
+
}).join('');
|
|
195
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${children}</svg>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
199
|
+
const icons = {}; // { "folder": "<svg>...</svg>", ... }
|
|
200
|
+
const categories = {}; // { "Folders & Files": ["folder", "folder-open", ...], ... }
|
|
201
|
+
const skipped = [];
|
|
202
|
+
|
|
203
|
+
for (const [category, entries] of Object.entries(ICON_CATEGORIES)) {
|
|
204
|
+
const catIcons = [];
|
|
205
|
+
for (const [name, pkg] of entries) {
|
|
206
|
+
// De-dup (e.g. 'cpu' appears twice in the list above)
|
|
207
|
+
if (icons[name]) { catIcons.push(name); continue; }
|
|
208
|
+
|
|
209
|
+
const iconDir = pkg === 'lab'
|
|
210
|
+
? resolve(ROOT, `node_modules/@lucide/lab/dist/esm/icons/${name}.js`)
|
|
211
|
+
: resolve(ROOT, `node_modules/lucide/dist/esm/icons/${name}.js`);
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const src = readFileSync(iconDir, 'utf8');
|
|
215
|
+
// Extract: const <Name> = <array>;\nexport { <Name> as default }
|
|
216
|
+
// Use greedy match up to the last ";" before the export statement
|
|
217
|
+
const match = src.match(/const \w+ = ([\s\S]+?);\s*\nexport/);
|
|
218
|
+
if (!match) { skipped.push(`${name} (parse fail)`); continue; }
|
|
219
|
+
// Evaluate JS object literal (has unquoted keys, not valid JSON)
|
|
220
|
+
const iconData = vm.runInNewContext(`(${match[1]})`);
|
|
221
|
+
icons[name] = iconDataToSvg(iconData);
|
|
222
|
+
catIcons.push(name);
|
|
223
|
+
} catch {
|
|
224
|
+
skipped.push(`${name} (${pkg})`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (catIcons.length > 0) categories[category] = catIcons;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Output ────────────────────────────────────────────────────────────────────
|
|
231
|
+
mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
|
|
232
|
+
|
|
233
|
+
const output = `/* lucide icons bundle — generated by scripts/build-lucide-bundle.mjs */
|
|
234
|
+
/* Includes ${Object.keys(icons).length} icons from lucide + @lucide/lab */
|
|
235
|
+
(function() {
|
|
236
|
+
window.__lucideIcons = ${JSON.stringify(icons)};
|
|
237
|
+
window.__lucideIconCategories = ${JSON.stringify(categories)};
|
|
238
|
+
document.dispatchEvent(new Event('lucide-ready'));
|
|
239
|
+
})();
|
|
240
|
+
`;
|
|
241
|
+
|
|
242
|
+
writeFileSync(OUT, output);
|
|
243
|
+
console.log(`✓ Built ${Object.keys(icons).length} icons → src/web/public/vendor/lucide.bundle.js`);
|
|
244
|
+
if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build script: generates public/vendor/material-icons.bundle.js
|
|
4
|
+
* Includes ALL ~2191 icons from @material-icons/svg (baseline/filled variant),
|
|
5
|
+
* organized by their official categories from data.json.
|
|
6
|
+
*
|
|
7
|
+
* Icon names are stored with a "mi/" prefix when used in ws.icon so they
|
|
8
|
+
* can coexist with Lucide icon names without collision.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { dirname, resolve } from 'path';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = resolve(__dirname, '..');
|
|
17
|
+
const ICONS_DIR = resolve(ROOT, 'node_modules/@material-icons/svg/svg');
|
|
18
|
+
const DATA_JSON = resolve(ROOT, 'node_modules/@material-icons/svg/data.json');
|
|
19
|
+
const OUT = resolve(ROOT, 'src/web/public/vendor/material-icons.bundle.js');
|
|
20
|
+
|
|
21
|
+
// Official category short-name → display name
|
|
22
|
+
const CATEGORY_LABELS = {
|
|
23
|
+
action: 'Action',
|
|
24
|
+
alert: 'Alert',
|
|
25
|
+
av: 'Audio & Video',
|
|
26
|
+
communication:'Communication',
|
|
27
|
+
content: 'Content',
|
|
28
|
+
device: 'Device',
|
|
29
|
+
editor: 'Editor',
|
|
30
|
+
file: 'File',
|
|
31
|
+
hardware: 'Hardware',
|
|
32
|
+
home: 'Home',
|
|
33
|
+
image: 'Image',
|
|
34
|
+
maps: 'Maps',
|
|
35
|
+
math: 'Math',
|
|
36
|
+
navigation: 'Navigation',
|
|
37
|
+
notification: 'Notification',
|
|
38
|
+
places: 'Places',
|
|
39
|
+
search: 'Search',
|
|
40
|
+
shopping: 'Shopping',
|
|
41
|
+
social: 'Social',
|
|
42
|
+
toggle: 'Toggle',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── SVG processor ─────────────────────────────────────────────────────────────
|
|
46
|
+
function processSvg(raw) {
|
|
47
|
+
return raw
|
|
48
|
+
.replace(/width="24"/, 'width="14"')
|
|
49
|
+
.replace(/height="24"/, 'height="14"')
|
|
50
|
+
// Make color inherit from CSS (without this, icons always render black)
|
|
51
|
+
.replace('<svg ', '<svg fill="currentColor" ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Load icon metadata ────────────────────────────────────────────────────────
|
|
55
|
+
const data = JSON.parse(readFileSync(DATA_JSON, 'utf8'));
|
|
56
|
+
const iconMeta = data.icons; // [{ name, categories: [string], tags: [string] }]
|
|
57
|
+
|
|
58
|
+
// Build category → [name] map using official categories
|
|
59
|
+
const categoryMap = {}; // { 'Audio & Video': ['10k', ...], ... }
|
|
60
|
+
for (const icon of iconMeta) {
|
|
61
|
+
for (const cat of (icon.categories || [])) {
|
|
62
|
+
const label = CATEGORY_LABELS[cat] || cat;
|
|
63
|
+
if (!categoryMap[label]) categoryMap[label] = [];
|
|
64
|
+
categoryMap[label].push(icon.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Process all icons ─────────────────────────────────────────────────────────
|
|
69
|
+
const icons = {}; // { name: '<svg>...' }
|
|
70
|
+
const categories = {}; // { 'Audio & Video': ['10k', ...] }
|
|
71
|
+
const skipped = [];
|
|
72
|
+
|
|
73
|
+
for (const icon of iconMeta) {
|
|
74
|
+
const svgPath = resolve(ICONS_DIR, icon.name, 'baseline.svg');
|
|
75
|
+
if (!existsSync(svgPath)) {
|
|
76
|
+
skipped.push(icon.name);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const raw = readFileSync(svgPath, 'utf8').trim();
|
|
81
|
+
icons[icon.name] = processSvg(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
skipped.push(icon.name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Build final categories (only include icons that were successfully loaded)
|
|
88
|
+
for (const [label, names] of Object.entries(categoryMap)) {
|
|
89
|
+
const valid = names.filter(n => icons[n]);
|
|
90
|
+
if (valid.length > 0) categories[label] = valid;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Output ────────────────────────────────────────────────────────────────────
|
|
94
|
+
mkdirSync(resolve(ROOT, 'src/web/public/vendor'), { recursive: true });
|
|
95
|
+
|
|
96
|
+
const output = `/* Material Icons bundle — generated by scripts/build-material-bundle.mjs */
|
|
97
|
+
/* Includes ${Object.keys(icons).length} icons from @material-icons/svg (baseline variant) */
|
|
98
|
+
/* Icon names are stored as "mi/<name>" in workspace records to avoid Lucide collisions */
|
|
99
|
+
(function() {
|
|
100
|
+
window.__materialIcons = ${JSON.stringify(icons)};
|
|
101
|
+
window.__materialIconCategories = ${JSON.stringify(categories)};
|
|
102
|
+
document.dispatchEvent(new Event('material-icons-ready'));
|
|
103
|
+
})();
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
writeFileSync(OUT, output);
|
|
107
|
+
console.log(`✓ Built ${Object.keys(icons).length} Material icons across ${Object.keys(categories).length} categories → src/web/public/vendor/material-icons.bundle.js`);
|
|
108
|
+
if (skipped.length) console.log(` Skipped (not found): ${skipped.join(', ')}`);
|
package/src/state/store.js
CHANGED
|
@@ -452,7 +452,7 @@ class Store extends EventEmitter {
|
|
|
452
452
|
|
|
453
453
|
// ─── Workspace CRUD ──────────────────────────────────────
|
|
454
454
|
|
|
455
|
-
createWorkspace({ name, description = '', color = 'cyan' }) {
|
|
455
|
+
createWorkspace({ name, description = '', color = 'cyan', icon = null }) {
|
|
456
456
|
const id = crypto.randomUUID();
|
|
457
457
|
const now = new Date().toISOString();
|
|
458
458
|
const workspace = {
|
|
@@ -460,6 +460,7 @@ class Store extends EventEmitter {
|
|
|
460
460
|
name,
|
|
461
461
|
description,
|
|
462
462
|
color,
|
|
463
|
+
icon,
|
|
463
464
|
sessions: [],
|
|
464
465
|
createdAt: now,
|
|
465
466
|
lastActive: now,
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Map of file extensions to CodeMirror language names.
|
|
8
|
+
*/
|
|
9
|
+
const EXT_TO_LANG = {
|
|
10
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
|
11
|
+
ts: 'typescript', tsx: 'typescript',
|
|
12
|
+
jsx: 'javascript',
|
|
13
|
+
json: 'json',
|
|
14
|
+
html: 'html', htm: 'html',
|
|
15
|
+
css: 'css', scss: 'css', less: 'css',
|
|
16
|
+
md: 'markdown', markdown: 'markdown',
|
|
17
|
+
py: 'python',
|
|
18
|
+
sh: 'shell', bash: 'shell',
|
|
19
|
+
yaml: 'yaml', yml: 'yaml',
|
|
20
|
+
toml: 'toml',
|
|
21
|
+
rs: 'rust',
|
|
22
|
+
go: 'go',
|
|
23
|
+
java: 'java',
|
|
24
|
+
rb: 'ruby',
|
|
25
|
+
php: 'php',
|
|
26
|
+
sql: 'sql',
|
|
27
|
+
xml: 'xml',
|
|
28
|
+
txt: 'text',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Directory names to skip when building the file tree.
|
|
33
|
+
* Prevents traversal into version control internals and build artifacts.
|
|
34
|
+
*/
|
|
35
|
+
const SKIP_DIRS = new Set([
|
|
36
|
+
'.git', 'node_modules', '.DS_Store', '__pycache__',
|
|
37
|
+
'.next', 'dist', 'build', '.cache',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate that the resolved target path stays within root.
|
|
42
|
+
* Throws if path traversal is detected.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} root - Absolute workspace root path
|
|
45
|
+
* @param {string} relPath - Relative path from client
|
|
46
|
+
* @returns {string} Resolved absolute path
|
|
47
|
+
*/
|
|
48
|
+
function validatePath(root, relPath) {
|
|
49
|
+
if (!root || typeof root !== 'string') throw new Error('root is required');
|
|
50
|
+
const resolved = path.resolve(root, relPath || '');
|
|
51
|
+
if (!resolved.startsWith(path.resolve(root) + path.sep) && resolved !== path.resolve(root)) {
|
|
52
|
+
throw new Error('Path traversal detected');
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* GET /api/files/tree
|
|
59
|
+
* Returns directory entries (dirs and files) for a given subpath within the
|
|
60
|
+
* workspace root. Skips .git, node_modules, and other build artifacts.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} workingDir - Workspace root directory
|
|
63
|
+
* @param {string} [subpath=''] - Relative subpath to list
|
|
64
|
+
* @returns {Promise<{path: string, entries: Array}>}
|
|
65
|
+
*/
|
|
66
|
+
async function getTree(workingDir, subpath = '') {
|
|
67
|
+
const root = path.resolve(workingDir);
|
|
68
|
+
const target = validatePath(root, subpath);
|
|
69
|
+
|
|
70
|
+
const entries = await fs.promises.readdir(target, { withFileTypes: true });
|
|
71
|
+
const result = [];
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
75
|
+
// Skip hidden files/dirs (dot-files) at root level
|
|
76
|
+
if (subpath === '' && entry.name.startsWith('.')) continue;
|
|
77
|
+
const relPath = subpath ? path.join(subpath, entry.name) : entry.name;
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
result.push({ name: entry.name, path: relPath, type: 'dir' });
|
|
80
|
+
} else if (entry.isFile()) {
|
|
81
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
82
|
+
result.push({ name: entry.name, path: relPath, type: 'file', ext });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Sort: directories first, then files, each group sorted alphabetically
|
|
87
|
+
result.sort((a, b) => {
|
|
88
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
89
|
+
return a.name.localeCompare(b.name);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return { path: subpath || '/', entries: result };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* GET /api/files/content
|
|
97
|
+
* Returns file content as UTF-8 text with a language hint for CodeMirror.
|
|
98
|
+
* Rejects files larger than 5MB.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} workingDir - Workspace root directory
|
|
101
|
+
* @param {string} file - Relative path to the file
|
|
102
|
+
* @returns {Promise<{content: string, language: string, size: number, path: string}>}
|
|
103
|
+
*/
|
|
104
|
+
async function getContent(workingDir, file) {
|
|
105
|
+
if (!file) throw new Error('file is required');
|
|
106
|
+
const root = path.resolve(workingDir);
|
|
107
|
+
const target = validatePath(root, file);
|
|
108
|
+
|
|
109
|
+
const stat = await fs.promises.stat(target);
|
|
110
|
+
if (stat.size > 5 * 1024 * 1024) throw new Error('File too large (> 5MB)');
|
|
111
|
+
|
|
112
|
+
const content = await fs.promises.readFile(target, 'utf8');
|
|
113
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
114
|
+
return {
|
|
115
|
+
content,
|
|
116
|
+
language: EXT_TO_LANG[ext] || 'text',
|
|
117
|
+
size: stat.size,
|
|
118
|
+
path: file,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* POST /api/files/save
|
|
124
|
+
* Atomically writes file content by writing to a temp file then renaming.
|
|
125
|
+
* Creates parent directories if they don't exist.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} workingDir - Workspace root directory
|
|
128
|
+
* @param {string} file - Relative path to the file
|
|
129
|
+
* @param {string} content - UTF-8 text content to write
|
|
130
|
+
* @returns {Promise<{ok: boolean}>}
|
|
131
|
+
*/
|
|
132
|
+
async function saveContent(workingDir, file, content) {
|
|
133
|
+
if (!file) throw new Error('file is required');
|
|
134
|
+
if (typeof content !== 'string') throw new Error('content must be a string');
|
|
135
|
+
const root = path.resolve(workingDir);
|
|
136
|
+
const target = validatePath(root, file);
|
|
137
|
+
|
|
138
|
+
// Ensure parent directory exists
|
|
139
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
140
|
+
|
|
141
|
+
// Atomic write: write to temp file then rename into place
|
|
142
|
+
const tmp = target + '.tmp.' + Date.now();
|
|
143
|
+
await fs.promises.writeFile(tmp, content, 'utf8');
|
|
144
|
+
await fs.promises.rename(tmp, target);
|
|
145
|
+
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { getTree, getContent, saveContent };
|