@cluesmith/codev 1.4.1 → 1.4.3
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/dist/agent-farm/servers/dashboard-server.js +487 -9
- package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +141 -40
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
- package/dist/agent-farm/utils/port-registry.js +19 -5
- package/dist/agent-farm/utils/port-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +10 -0
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts +1 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +56 -8
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -0
- package/dist/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/resources/commands/consult.md +50 -0
- package/skeleton/templates/projectlist-archive.md +21 -0
- package/skeleton/templates/projectlist.md +17 -0
- package/templates/dashboard/css/activity.css +151 -0
- package/templates/dashboard/css/dialogs.css +149 -0
- package/templates/dashboard/css/files.css +530 -0
- package/templates/dashboard/css/layout.css +124 -0
- package/templates/dashboard/css/projects.css +501 -0
- package/templates/dashboard/css/statusbar.css +23 -0
- package/templates/dashboard/css/tabs.css +314 -0
- package/templates/dashboard/css/utilities.css +50 -0
- package/templates/dashboard/css/variables.css +45 -0
- package/templates/dashboard/index.html +158 -0
- package/templates/dashboard/js/activity.js +238 -0
- package/templates/dashboard/js/dialogs.js +328 -0
- package/templates/dashboard/js/files.js +436 -0
- package/templates/dashboard/js/main.js +487 -0
- package/templates/dashboard/js/projects.js +544 -0
- package/templates/dashboard/js/state.js +91 -0
- package/templates/dashboard/js/tabs.js +500 -0
- package/templates/dashboard/js/utils.js +57 -0
- package/templates/tower.html +172 -4
- package/dist/commands/eject.d.ts +0 -18
- package/dist/commands/eject.d.ts.map +0 -1
- package/dist/commands/eject.js +0 -149
- package/dist/commands/eject.js.map +0 -1
- package/templates/dashboard-split.html +0 -3721
- package/templates/dashboard.html +0 -149
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// Tab Management - Rendering, Selection, Overflow
|
|
2
|
+
|
|
3
|
+
// Build tabs from initial state
|
|
4
|
+
function buildTabsFromState() {
|
|
5
|
+
const previousTabIds = new Set(tabs.map(t => t.id));
|
|
6
|
+
// Preserve client-side-only tabs (like activity)
|
|
7
|
+
const clientSideTabs = tabs.filter(t => t.type === 'activity');
|
|
8
|
+
tabs = [];
|
|
9
|
+
|
|
10
|
+
// Dashboard tab is ALWAYS first and uncloseable (Spec 0045, 0057)
|
|
11
|
+
tabs.push({
|
|
12
|
+
id: 'dashboard',
|
|
13
|
+
type: 'dashboard',
|
|
14
|
+
name: 'Dashboard',
|
|
15
|
+
closeable: false
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Add file tabs from annotations
|
|
19
|
+
for (const annotation of state.annotations || []) {
|
|
20
|
+
tabs.push({
|
|
21
|
+
id: `file-${annotation.id}`,
|
|
22
|
+
type: 'file',
|
|
23
|
+
name: getFileName(annotation.file),
|
|
24
|
+
path: annotation.file,
|
|
25
|
+
port: annotation.port,
|
|
26
|
+
annotationId: annotation.id
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Add builder tabs
|
|
31
|
+
for (const builder of state.builders || []) {
|
|
32
|
+
tabs.push({
|
|
33
|
+
id: `builder-${builder.id}`,
|
|
34
|
+
type: 'builder',
|
|
35
|
+
name: builder.name || `Builder ${builder.id}`,
|
|
36
|
+
projectId: builder.id,
|
|
37
|
+
port: builder.port,
|
|
38
|
+
status: builder.status
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add shell tabs
|
|
43
|
+
for (const util of state.utils || []) {
|
|
44
|
+
tabs.push({
|
|
45
|
+
id: `shell-${util.id}`,
|
|
46
|
+
type: 'shell',
|
|
47
|
+
name: util.name,
|
|
48
|
+
port: util.port,
|
|
49
|
+
utilId: util.id
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Re-add preserved client-side tabs
|
|
54
|
+
for (const tab of clientSideTabs) {
|
|
55
|
+
tabs.push(tab);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Detect new tabs and auto-switch to them (skip projects tab)
|
|
59
|
+
for (const tab of tabs) {
|
|
60
|
+
if (tab.id !== 'dashboard' && tab.id !== 'files' && !knownTabIds.has(tab.id) && previousTabIds.size > 0) {
|
|
61
|
+
// This is a new tab - switch to it
|
|
62
|
+
activeTabId = tab.id;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Update known tab IDs
|
|
68
|
+
knownTabIds = new Set(tabs.map(t => t.id));
|
|
69
|
+
|
|
70
|
+
// Set active tab to Dashboard on first load if none selected
|
|
71
|
+
if (!activeTabId) {
|
|
72
|
+
activeTabId = 'dashboard';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Render architect pane
|
|
77
|
+
function renderArchitect() {
|
|
78
|
+
const content = document.getElementById('architect-content');
|
|
79
|
+
const statusDot = document.getElementById('architect-status');
|
|
80
|
+
|
|
81
|
+
if (state.architect && state.architect.port) {
|
|
82
|
+
statusDot.classList.remove('inactive');
|
|
83
|
+
// Only update iframe if port changed (avoid flashing on poll)
|
|
84
|
+
if (currentArchitectPort !== state.architect.port) {
|
|
85
|
+
currentArchitectPort = state.architect.port;
|
|
86
|
+
content.innerHTML = `<iframe src="http://localhost:${state.architect.port}" title="Architect Terminal" allow="clipboard-read; clipboard-write"></iframe>`;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
if (currentArchitectPort !== null) {
|
|
90
|
+
currentArchitectPort = null;
|
|
91
|
+
content.innerHTML = `
|
|
92
|
+
<div class="architect-placeholder">
|
|
93
|
+
<p>Architect not running</p>
|
|
94
|
+
<p>Run <code>agent-farm start</code> to begin</p>
|
|
95
|
+
</div>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
statusDot.classList.add('inactive');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Render tabs
|
|
103
|
+
function renderTabs() {
|
|
104
|
+
const container = document.getElementById('tabs-container');
|
|
105
|
+
|
|
106
|
+
if (tabs.length === 0) {
|
|
107
|
+
container.innerHTML = '';
|
|
108
|
+
checkTabOverflow(); // Update overflow state when tabs cleared
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
container.innerHTML = tabs.map(tab => {
|
|
113
|
+
const isActive = tab.id === activeTabId;
|
|
114
|
+
const icon = getTabIcon(tab.type);
|
|
115
|
+
const statusDot = tab.type === 'builder' ? getStatusDot(tab.status) : '';
|
|
116
|
+
const tooltip = getTabTooltip(tab);
|
|
117
|
+
const isUncloseable = tab.closeable === false;
|
|
118
|
+
|
|
119
|
+
return `
|
|
120
|
+
<div class="tab ${isActive ? 'active' : ''} ${isUncloseable ? 'tab-uncloseable' : ''}"
|
|
121
|
+
onclick="selectTab('${tab.id}')"
|
|
122
|
+
oncontextmenu="showContextMenu(event, '${tab.id}')"
|
|
123
|
+
data-tab-id="${tab.id}"
|
|
124
|
+
title="${tooltip}">
|
|
125
|
+
<span class="icon">${icon}</span>
|
|
126
|
+
<span class="name">${tab.name}</span>
|
|
127
|
+
${statusDot}
|
|
128
|
+
${!isUncloseable ? `<span class="close"
|
|
129
|
+
onclick="event.stopPropagation(); closeTab('${tab.id}', event)"
|
|
130
|
+
role="button"
|
|
131
|
+
tabindex="0"
|
|
132
|
+
aria-label="Close ${tab.name}"
|
|
133
|
+
onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();closeTab('${tab.id}',event)}">×</span>` : ''}
|
|
134
|
+
</div>
|
|
135
|
+
`;
|
|
136
|
+
}).join('');
|
|
137
|
+
|
|
138
|
+
// Check overflow after tabs are rendered
|
|
139
|
+
checkTabOverflow();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Get tab icon
|
|
143
|
+
function getTabIcon(type) {
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'dashboard': return '🏠';
|
|
146
|
+
case 'files': return '📁';
|
|
147
|
+
case 'file': return '📄';
|
|
148
|
+
case 'builder': return '🔨';
|
|
149
|
+
case 'shell': return '>_';
|
|
150
|
+
default: return '?';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Status configuration - hoisted for performance (per Codex review)
|
|
155
|
+
const STATUS_CONFIG = {
|
|
156
|
+
'spawning': { color: 'var(--status-active)', label: 'Spawning', shape: 'circle', animation: 'pulse' },
|
|
157
|
+
'implementing': { color: 'var(--status-active)', label: 'Implementing', shape: 'circle', animation: 'pulse' },
|
|
158
|
+
'blocked': { color: 'var(--status-error)', label: 'Blocked', shape: 'diamond', animation: 'blink-fast' },
|
|
159
|
+
'pr-ready': { color: 'var(--status-waiting)', label: 'PR Ready', shape: 'ring', animation: 'blink-slow' },
|
|
160
|
+
'complete': { color: 'var(--status-complete)', label: 'Complete', shape: 'circle', animation: null }
|
|
161
|
+
};
|
|
162
|
+
const DEFAULT_STATUS_CONFIG = { color: 'var(--text-muted)', label: 'Unknown', shape: 'circle', animation: null };
|
|
163
|
+
|
|
164
|
+
// Get status dot HTML with accessibility support
|
|
165
|
+
function getStatusDot(status) {
|
|
166
|
+
const config = STATUS_CONFIG[status] || { ...DEFAULT_STATUS_CONFIG, label: status || 'Unknown' };
|
|
167
|
+
const classes = ['status-dot'];
|
|
168
|
+
if (config.shape === 'diamond') classes.push('status-dot--diamond');
|
|
169
|
+
if (config.shape === 'ring') classes.push('status-dot--ring');
|
|
170
|
+
if (config.animation === 'pulse') classes.push('status-dot--pulse');
|
|
171
|
+
if (config.animation === 'blink-slow') classes.push('status-dot--blink-slow');
|
|
172
|
+
if (config.animation === 'blink-fast') classes.push('status-dot--blink-fast');
|
|
173
|
+
return `<span class="${classes.join(' ')}" style="background: ${config.color}" title="${config.label}" role="img" aria-label="${config.label}"></span>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Generate tooltip text for tab hover
|
|
177
|
+
function getTabTooltip(tab) {
|
|
178
|
+
const lines = [tab.name];
|
|
179
|
+
|
|
180
|
+
if (tab.type === 'builder') {
|
|
181
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
182
|
+
lines.push(`Status: ${tab.status || 'unknown'}`);
|
|
183
|
+
const projectId = tab.id.replace('builder-', '');
|
|
184
|
+
lines.push(`Worktree: .builders/${projectId}`);
|
|
185
|
+
} else if (tab.type === 'file') {
|
|
186
|
+
lines.push(`Path: ${tab.path}`);
|
|
187
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
188
|
+
} else if (tab.type === 'shell') {
|
|
189
|
+
if (tab.port) lines.push(`Port: ${tab.port}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return escapeHtml(lines.join('\n'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Render tab content
|
|
196
|
+
function renderTabContent() {
|
|
197
|
+
const content = document.getElementById('tab-content');
|
|
198
|
+
|
|
199
|
+
if (!activeTabId || tabs.length === 0) {
|
|
200
|
+
if (currentTabPort !== null || currentTabType !== null) {
|
|
201
|
+
currentTabPort = null;
|
|
202
|
+
currentTabType = null;
|
|
203
|
+
content.innerHTML = `
|
|
204
|
+
<div class="empty-state">
|
|
205
|
+
<p>No tabs open</p>
|
|
206
|
+
<p class="hint">Click the + buttons above or ask the architect to open files/builders</p>
|
|
207
|
+
</div>
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const tab = tabs.find(t => t.id === activeTabId);
|
|
214
|
+
if (!tab) {
|
|
215
|
+
if (currentTabPort !== null || currentTabType !== null) {
|
|
216
|
+
currentTabPort = null;
|
|
217
|
+
currentTabType = null;
|
|
218
|
+
content.innerHTML = '<div class="empty-state"><p>Tab not found</p></div>';
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Handle dashboard tab specially (no iframe, inline content)
|
|
224
|
+
if (tab.type === 'dashboard') {
|
|
225
|
+
if (currentTabType !== 'dashboard') {
|
|
226
|
+
currentTabType = 'dashboard';
|
|
227
|
+
currentTabPort = null;
|
|
228
|
+
renderDashboardTab();
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Handle activity tab specially (no iframe, inline content)
|
|
234
|
+
if (tab.type === 'activity') {
|
|
235
|
+
if (currentTabType !== 'activity') {
|
|
236
|
+
currentTabType = 'activity';
|
|
237
|
+
currentTabPort = null;
|
|
238
|
+
renderActivityTab();
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// For other tabs, only update iframe if port changed (avoid flashing on poll)
|
|
244
|
+
if (currentTabPort !== tab.port || currentTabType !== tab.type) {
|
|
245
|
+
currentTabPort = tab.port;
|
|
246
|
+
currentTabType = tab.type;
|
|
247
|
+
content.innerHTML = `<iframe src="http://localhost:${tab.port}" title="${tab.name}" allow="clipboard-read; clipboard-write"></iframe>`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Force refresh the iframe for a file tab (reloads content from server)
|
|
252
|
+
function refreshFileTab(tabId) {
|
|
253
|
+
const tab = tabs.find(t => t.id === tabId);
|
|
254
|
+
if (!tab || tab.type !== 'file' || !tab.port) return;
|
|
255
|
+
|
|
256
|
+
if (activeTabId === tabId) {
|
|
257
|
+
const content = document.getElementById('tab-content');
|
|
258
|
+
const iframe = content.querySelector('iframe');
|
|
259
|
+
if (iframe) {
|
|
260
|
+
iframe.src = `http://localhost:${tab.port}?t=${Date.now()}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Update status bar
|
|
266
|
+
function updateStatusBar() {
|
|
267
|
+
const archStatus = document.getElementById('status-architect');
|
|
268
|
+
if (state.architect) {
|
|
269
|
+
archStatus.innerHTML = `
|
|
270
|
+
<span class="dot" style="background: var(--status-active)"></span>
|
|
271
|
+
<span>Architect: running</span>
|
|
272
|
+
`;
|
|
273
|
+
} else {
|
|
274
|
+
archStatus.innerHTML = `
|
|
275
|
+
<span class="dot" style="background: var(--text-muted)"></span>
|
|
276
|
+
<span>Architect: stopped</span>
|
|
277
|
+
`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const builderCount = (state.builders || []).length;
|
|
281
|
+
const shellCount = (state.utils || []).length;
|
|
282
|
+
const fileCount = (state.annotations || []).length;
|
|
283
|
+
|
|
284
|
+
document.getElementById('status-builders').innerHTML = `<span>${builderCount} builder${builderCount !== 1 ? 's' : ''}</span>`;
|
|
285
|
+
document.getElementById('status-shells').innerHTML = `<span>${shellCount} shell${shellCount !== 1 ? 's' : ''}</span>`;
|
|
286
|
+
document.getElementById('status-files').innerHTML = `<span>${fileCount} file${fileCount !== 1 ? 's' : ''}</span>`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Select tab
|
|
290
|
+
function selectTab(tabId) {
|
|
291
|
+
activeTabId = tabId;
|
|
292
|
+
renderTabs();
|
|
293
|
+
renderTabContent();
|
|
294
|
+
scrollActiveTabIntoView();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Scroll the active tab into view
|
|
298
|
+
function scrollActiveTabIntoView() {
|
|
299
|
+
const container = document.getElementById('tabs-container');
|
|
300
|
+
const activeTab = container.querySelector('.tab.active');
|
|
301
|
+
if (activeTab) {
|
|
302
|
+
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Set up overflow detection for the tab bar
|
|
307
|
+
function setupOverflowDetection() {
|
|
308
|
+
const container = document.getElementById('tabs-container');
|
|
309
|
+
|
|
310
|
+
checkTabOverflow();
|
|
311
|
+
|
|
312
|
+
let resizeTimeout;
|
|
313
|
+
window.addEventListener('resize', () => {
|
|
314
|
+
clearTimeout(resizeTimeout);
|
|
315
|
+
resizeTimeout = setTimeout(checkTabOverflow, 100);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (container) {
|
|
319
|
+
let scrollTimeout;
|
|
320
|
+
container.addEventListener('scroll', () => {
|
|
321
|
+
clearTimeout(scrollTimeout);
|
|
322
|
+
scrollTimeout = setTimeout(checkTabOverflow, 50);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
327
|
+
if (container) {
|
|
328
|
+
const observer = new ResizeObserver(() => {
|
|
329
|
+
checkTabOverflow();
|
|
330
|
+
});
|
|
331
|
+
observer.observe(container);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check if tabs are overflowing and update the overflow button
|
|
337
|
+
function checkTabOverflow() {
|
|
338
|
+
const container = document.getElementById('tabs-container');
|
|
339
|
+
const overflowBtn = document.getElementById('overflow-btn');
|
|
340
|
+
const overflowCount = document.getElementById('overflow-count');
|
|
341
|
+
|
|
342
|
+
if (!container || !overflowBtn) return;
|
|
343
|
+
|
|
344
|
+
const isOverflowing = container.scrollWidth > container.clientWidth;
|
|
345
|
+
overflowBtn.style.display = isOverflowing ? 'flex' : 'none';
|
|
346
|
+
|
|
347
|
+
if (isOverflowing) {
|
|
348
|
+
const tabElements = container.querySelectorAll('.tab');
|
|
349
|
+
const containerRect = container.getBoundingClientRect();
|
|
350
|
+
let hiddenCount = 0;
|
|
351
|
+
|
|
352
|
+
tabElements.forEach(tab => {
|
|
353
|
+
const rect = tab.getBoundingClientRect();
|
|
354
|
+
if (rect.right > containerRect.right + 1) {
|
|
355
|
+
hiddenCount++;
|
|
356
|
+
} else if (rect.left < containerRect.left - 1) {
|
|
357
|
+
hiddenCount++;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
overflowCount.textContent = `+${hiddenCount}`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Toggle the overflow menu
|
|
366
|
+
function toggleOverflowMenu() {
|
|
367
|
+
const menu = document.getElementById('overflow-menu');
|
|
368
|
+
const isHidden = menu.classList.contains('hidden');
|
|
369
|
+
|
|
370
|
+
if (isHidden) {
|
|
371
|
+
showOverflowMenu();
|
|
372
|
+
} else {
|
|
373
|
+
hideOverflowMenu();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Show the overflow menu
|
|
378
|
+
function showOverflowMenu() {
|
|
379
|
+
const menu = document.getElementById('overflow-menu');
|
|
380
|
+
const btn = document.getElementById('overflow-btn');
|
|
381
|
+
|
|
382
|
+
menu.innerHTML = tabs.map((tab, index) => {
|
|
383
|
+
const icon = getTabIcon(tab.type);
|
|
384
|
+
const isActive = tab.id === activeTabId;
|
|
385
|
+
return `
|
|
386
|
+
<div class="overflow-menu-item ${isActive ? 'active' : ''}"
|
|
387
|
+
role="menuitem"
|
|
388
|
+
tabindex="${index === 0 ? 0 : -1}"
|
|
389
|
+
data-tab-id="${tab.id}"
|
|
390
|
+
onclick="selectTabFromMenu('${tab.id}')"
|
|
391
|
+
onkeydown="handleOverflowMenuKeydown(event, '${tab.id}')">
|
|
392
|
+
<span class="icon">${icon}</span>
|
|
393
|
+
<span class="name">${tab.name}</span>
|
|
394
|
+
<span class="open-external"
|
|
395
|
+
onclick="event.stopPropagation(); openInNewTabFromMenu('${tab.id}')"
|
|
396
|
+
onkeydown="if(event.key==='Enter'||event.key===' '){event.stopPropagation();openInNewTabFromMenu('${tab.id}')}"
|
|
397
|
+
title="Open in new tab"
|
|
398
|
+
role="button"
|
|
399
|
+
tabindex="0"
|
|
400
|
+
aria-label="Open ${tab.name} in new tab">↗</span>
|
|
401
|
+
</div>
|
|
402
|
+
`;
|
|
403
|
+
}).join('');
|
|
404
|
+
|
|
405
|
+
menu.classList.remove('hidden');
|
|
406
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
407
|
+
|
|
408
|
+
const firstItem = menu.querySelector('.overflow-menu-item');
|
|
409
|
+
if (firstItem) firstItem.focus();
|
|
410
|
+
|
|
411
|
+
setTimeout(() => {
|
|
412
|
+
document.addEventListener('click', handleOverflowClickOutside);
|
|
413
|
+
}, 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Hide the overflow menu
|
|
417
|
+
function hideOverflowMenu() {
|
|
418
|
+
const menu = document.getElementById('overflow-menu');
|
|
419
|
+
const btn = document.getElementById('overflow-btn');
|
|
420
|
+
menu.classList.add('hidden');
|
|
421
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
422
|
+
document.removeEventListener('click', handleOverflowClickOutside);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Handle click outside overflow menu
|
|
426
|
+
function handleOverflowClickOutside(event) {
|
|
427
|
+
const menu = document.getElementById('overflow-menu');
|
|
428
|
+
const btn = document.getElementById('overflow-btn');
|
|
429
|
+
if (!menu.contains(event.target) && !btn.contains(event.target)) {
|
|
430
|
+
hideOverflowMenu();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Select tab from overflow menu
|
|
435
|
+
function selectTabFromMenu(tabId) {
|
|
436
|
+
hideOverflowMenu();
|
|
437
|
+
selectTab(tabId);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Open tab in new window from overflow menu
|
|
441
|
+
function openInNewTabFromMenu(tabId) {
|
|
442
|
+
hideOverflowMenu();
|
|
443
|
+
openInNewTab(tabId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Handle keyboard navigation in overflow menu
|
|
447
|
+
function handleOverflowMenuKeydown(event, tabId) {
|
|
448
|
+
const menu = document.getElementById('overflow-menu');
|
|
449
|
+
const items = Array.from(menu.querySelectorAll('.overflow-menu-item'));
|
|
450
|
+
const currentIndex = items.findIndex(item => item === document.activeElement);
|
|
451
|
+
|
|
452
|
+
switch (event.key) {
|
|
453
|
+
case 'ArrowDown':
|
|
454
|
+
event.preventDefault();
|
|
455
|
+
const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
456
|
+
items[nextIndex].focus();
|
|
457
|
+
break;
|
|
458
|
+
case 'ArrowUp':
|
|
459
|
+
event.preventDefault();
|
|
460
|
+
const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
461
|
+
items[prevIndex].focus();
|
|
462
|
+
break;
|
|
463
|
+
case 'Enter':
|
|
464
|
+
case ' ':
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
selectTabFromMenu(tabId);
|
|
467
|
+
break;
|
|
468
|
+
case 'Escape':
|
|
469
|
+
event.preventDefault();
|
|
470
|
+
hideOverflowMenu();
|
|
471
|
+
document.getElementById('overflow-btn').focus();
|
|
472
|
+
break;
|
|
473
|
+
case 'Tab':
|
|
474
|
+
hideOverflowMenu();
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Open tab content in a new browser tab
|
|
480
|
+
function openInNewTab(tabId) {
|
|
481
|
+
const tab = tabs.find(t => t.id === tabId);
|
|
482
|
+
if (!tab) return;
|
|
483
|
+
|
|
484
|
+
let url;
|
|
485
|
+
if (tab.type === 'file') {
|
|
486
|
+
if (!tab.port) {
|
|
487
|
+
showToast('Tab not ready', 'error');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
url = `http://localhost:${tab.port}`;
|
|
491
|
+
} else {
|
|
492
|
+
if (!tab.port) {
|
|
493
|
+
showToast('Tab not ready', 'error');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
url = `http://localhost:${tab.port}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
500
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Dashboard Utility Functions
|
|
2
|
+
|
|
3
|
+
// Escape HTML special characters to prevent XSS
|
|
4
|
+
function escapeHtml(text) {
|
|
5
|
+
return String(text)
|
|
6
|
+
.replace(/&/g, '&')
|
|
7
|
+
.replace(/</g, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// XSS-safe HTML escaping (used by projects module)
|
|
14
|
+
function escapeProjectHtml(text) {
|
|
15
|
+
if (!text) return '';
|
|
16
|
+
const div = document.createElement('div');
|
|
17
|
+
div.textContent = String(text);
|
|
18
|
+
return div.innerHTML;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Escape a string for use inside a JavaScript string literal in onclick handlers
|
|
22
|
+
function escapeJsString(str) {
|
|
23
|
+
return str
|
|
24
|
+
.replace(/\\/g, '\\\\')
|
|
25
|
+
.replace(/'/g, "\\'")
|
|
26
|
+
.replace(/"/g, '\\"')
|
|
27
|
+
.replace(/\n/g, '\\n')
|
|
28
|
+
.replace(/\r/g, '\\r');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Get filename from path
|
|
32
|
+
function getFileName(path) {
|
|
33
|
+
const parts = path.split('/');
|
|
34
|
+
return parts[parts.length - 1];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Simple DJB2 hash for change detection
|
|
38
|
+
function hashString(str) {
|
|
39
|
+
let hash = 5381;
|
|
40
|
+
for (let i = 0; i < str.length; i++) {
|
|
41
|
+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
42
|
+
}
|
|
43
|
+
return hash >>> 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Toast notifications
|
|
47
|
+
function showToast(message, type = 'info') {
|
|
48
|
+
const container = document.getElementById('toast-container');
|
|
49
|
+
const toast = document.createElement('div');
|
|
50
|
+
toast.className = `toast ${type}`;
|
|
51
|
+
toast.textContent = message;
|
|
52
|
+
container.appendChild(toast);
|
|
53
|
+
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
toast.remove();
|
|
56
|
+
}, 3000);
|
|
57
|
+
}
|