Add CodeNomad-inspired two-level tab system (Phase 2)
Phase 2 of enhancement plan: - Created project-manager.js for project-level organization - Created session-tabs.js for session-level organization - Created project-tabs.css with responsive design - Added tab structure to index.html - Cache bust: v1769083200000 Features: - Project tabs organize sessions by working directory - Session tabs show all sessions within active project - Context menu for rename/duplicate/delete/close - Visual indicators for active/running sessions - Responsive design for mobile Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
458
public/claude-ide/project-manager.js
Normal file
458
public/claude-ide/project-manager.js
Normal file
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Project Manager - Organizes sessions by project/folder
|
||||
* Inspired by CodeNomad's two-level tab system
|
||||
* https://github.com/NeuralNomadsAI/CodeNomad
|
||||
*
|
||||
* Provides:
|
||||
* - Project-level organization (top tabs)
|
||||
* - Session-level organization (second tabs)
|
||||
* - Easy switching between projects and sessions
|
||||
* - Project creation and management
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ============================================================
|
||||
// Project Manager Class
|
||||
// ============================================================
|
||||
|
||||
class ProjectManager {
|
||||
constructor() {
|
||||
this.projects = new Map(); // Map<projectId, Project>
|
||||
this.activeProjectId = null;
|
||||
this.activeSessionId = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the project manager
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
console.log('[ProjectManager] Initializing...');
|
||||
await this.loadProjects();
|
||||
this.renderProjectTabs();
|
||||
this.initialized = true;
|
||||
|
||||
// Auto-select first project if available
|
||||
if (this.projects.size > 0 && !this.activeProjectId) {
|
||||
const firstProject = this.projects.values().next().value;
|
||||
this.switchProject(firstProject.id);
|
||||
}
|
||||
|
||||
console.log('[ProjectManager] Initialized with', this.projects.size, 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all sessions and organize them by project
|
||||
*/
|
||||
async loadProjects() {
|
||||
try {
|
||||
const res = await fetch('/claude/api/claude/sessions');
|
||||
if (!res.ok) throw new Error('Failed to fetch sessions');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Combine active and historical sessions
|
||||
const allSessions = [
|
||||
...(data.active || []).map(s => ({...s, status: 'active'})),
|
||||
...(data.historical || []).map(s => ({...s, status: 'historical'}))
|
||||
];
|
||||
|
||||
// Group by working directory
|
||||
const grouped = new Map();
|
||||
|
||||
allSessions.forEach(session => {
|
||||
const dir = session.workingDir || 'default';
|
||||
const projectKey = dir.replace(/\//g, '-').replace(/^-/, '') || 'default';
|
||||
|
||||
if (!grouped.has(projectKey)) {
|
||||
const projectName = dir.split('/').pop() || 'Default';
|
||||
const project = {
|
||||
id: `project-${projectKey}`,
|
||||
name: this.deduplicateProjectName(projectName, grouped),
|
||||
workingDir: dir,
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
createdAt: this.getOldestSessionTime(allSessions.filter(s => s.workingDir === dir))
|
||||
};
|
||||
grouped.set(projectKey, project);
|
||||
}
|
||||
|
||||
grouped.get(projectKey).sessions.push(session);
|
||||
});
|
||||
|
||||
// Sort sessions by last activity within each project
|
||||
grouped.forEach(project => {
|
||||
project.sessions.sort((a, b) => {
|
||||
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at || 0);
|
||||
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Set active session to most recent
|
||||
if (project.sessions.length > 0) {
|
||||
project.activeSessionId = project.sessions[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
this.projects = grouped;
|
||||
console.log('[ProjectManager] Loaded', this.projects.size, 'projects');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ProjectManager] Error loading projects:', error);
|
||||
// Create default project on error
|
||||
this.projects.set('default', {
|
||||
id: 'project-default',
|
||||
name: 'Default',
|
||||
workingDir: '',
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate project names by adding counter
|
||||
*/
|
||||
deduplicateProjectName(name, existingProjects) {
|
||||
const names = Array.from(existingProjects.values()).map(p => p.name);
|
||||
let finalName = name;
|
||||
let counter = 2;
|
||||
|
||||
while (names.includes(finalName)) {
|
||||
finalName = `${name} (${counter})`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return finalName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oldest session time for a project
|
||||
*/
|
||||
getOldestSessionTime(sessions) {
|
||||
if (sessions.length === 0) return Date.now();
|
||||
|
||||
return sessions.reduce((oldest, session) => {
|
||||
const time = new Date(session.createdAt || session.created_at || 0).getTime();
|
||||
return time < oldest ? time : oldest;
|
||||
}, Infinity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render project tabs
|
||||
*/
|
||||
renderProjectTabs() {
|
||||
const container = document.getElementById('project-tabs');
|
||||
if (!container) {
|
||||
console.warn('[ProjectManager] Project tabs container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectsArray = Array.from(this.projects.values());
|
||||
|
||||
if (projectsArray.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="project-tabs-empty">
|
||||
<span>No projects yet</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="project-tabs">
|
||||
${projectsArray.map(project => this.renderProjectTab(project)).join('')}
|
||||
<button class="project-tab project-tab-new" onclick="window.projectManager.createNewProject()">
|
||||
<span class="tab-icon">+</span>
|
||||
<span class="tab-label">New Project</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single project tab
|
||||
*/
|
||||
renderProjectTab(project) {
|
||||
const isActive = project.id === this.activeProjectId;
|
||||
const sessionCount = project.sessions.length;
|
||||
|
||||
return `
|
||||
<button class="project-tab ${isActive ? 'active' : ''}"
|
||||
data-project-id="${project.id}"
|
||||
onclick="window.projectManager.switchProject('${project.id}')"
|
||||
title="${escapeHtml(project.workingDir || 'Default project')}">
|
||||
<span class="tab-icon">📁</span>
|
||||
<span class="tab-label">${escapeHtml(project.name)}</span>
|
||||
${sessionCount > 0 ? `<span class="tab-count">${sessionCount}</span>` : ''}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different project
|
||||
*/
|
||||
async switchProject(projectId) {
|
||||
const project = this.projects.get(projectId.replace('project-', ''));
|
||||
if (!project) {
|
||||
console.warn('[ProjectManager] Project not found:', projectId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ProjectManager] Switching to project:', project.name);
|
||||
this.activeProjectId = project.id;
|
||||
|
||||
// Re-render project tabs to update active state
|
||||
this.renderProjectTabs();
|
||||
|
||||
// Update session tabs for this project
|
||||
if (window.sessionTabs) {
|
||||
window.sessionTabs.setSessions(project.sessions);
|
||||
window.sessionTabs.setActiveSession(project.activeSessionId);
|
||||
window.sessionTabs.render();
|
||||
}
|
||||
|
||||
// Attach to active session if exists
|
||||
if (project.activeSessionId && typeof attachToSession === 'function') {
|
||||
await attachToSession(project.activeSessionId);
|
||||
} else {
|
||||
// Show empty state
|
||||
this.showEmptyProjectState(project);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show empty project state
|
||||
*/
|
||||
showEmptyProjectState(project) {
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
if (!messagesContainer) return;
|
||||
|
||||
messagesContainer.innerHTML = `
|
||||
<div class="empty-project-state">
|
||||
<div class="empty-icon">📁</div>
|
||||
<h3>${escapeHtml(project.name)}</h3>
|
||||
<p>No sessions yet in this project</p>
|
||||
<button class="btn-primary" onclick="window.projectManager.createNewSessionInProject('${project.id}')">
|
||||
Create Session
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new project (select folder)
|
||||
*/
|
||||
async createNewProject() {
|
||||
// Trigger folder picker if available
|
||||
if (window.folderPicker && typeof window.folderPicker.pick === 'function') {
|
||||
try {
|
||||
const folder = await window.folderPicker.pick();
|
||||
if (folder) {
|
||||
await this.createSessionInFolder(folder);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProjectManager] Error picking folder:', error);
|
||||
this.showError('Failed to select folder');
|
||||
}
|
||||
} else {
|
||||
// Fallback: prompt for folder or create default session
|
||||
await this.createNewSessionInProject('default');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session in a specific folder
|
||||
*/
|
||||
async createSessionInFolder(workingDir) {
|
||||
try {
|
||||
if (typeof showLoadingOverlay === 'function') {
|
||||
showLoadingOverlay('Creating session...');
|
||||
}
|
||||
|
||||
const res = await fetch('/claude/api/claude/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir,
|
||||
metadata: {
|
||||
type: 'chat',
|
||||
source: 'web-ide'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to create session');
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// Reload projects and switch to new session
|
||||
await this.loadProjects();
|
||||
await this.initialize();
|
||||
|
||||
// Find the new session and switch to it
|
||||
for (const project of this.projects.values()) {
|
||||
const session = project.sessions.find(s => s.id === data.session.id);
|
||||
if (session) {
|
||||
this.switchProject(project.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof hideLoadingOverlay === 'function') {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ProjectManager] Error creating session:', error);
|
||||
if (typeof hideLoadingOverlay === 'function') {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
this.showError('Failed to create session');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session in the current project
|
||||
*/
|
||||
async createNewSessionInProject(projectId) {
|
||||
const project = this.projects.get(projectId.replace('project-', ''));
|
||||
if (!project) return;
|
||||
|
||||
await this.createSessionInFolder(project.workingDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project for a session ID
|
||||
*/
|
||||
getProjectForSession(sessionId) {
|
||||
for (const project of this.projects.values()) {
|
||||
if (project.sessions.find(s => s.id === sessionId)) {
|
||||
return project;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a session to its project
|
||||
*/
|
||||
addSessionToProject(session) {
|
||||
const dir = session.workingDir || 'default';
|
||||
const projectKey = dir.replace(/\//g, '-').replace(/^-/, '') || 'default';
|
||||
|
||||
if (!this.projects.has(projectKey)) {
|
||||
const projectName = dir.split('/').pop() || 'Default';
|
||||
this.projects.set(projectKey, {
|
||||
id: `project-${projectKey}`,
|
||||
name: this.deduplicateProjectName(projectName, this.projects),
|
||||
workingDir: dir,
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
const project = this.projects.get(projectKey);
|
||||
project.sessions.unshift(session); // Add to beginning
|
||||
project.activeSessionId = session.id;
|
||||
|
||||
// Re-render if this is the active project
|
||||
if (this.activeProjectId === project.id) {
|
||||
this.renderProjectTabs();
|
||||
if (window.sessionTabs) {
|
||||
window.sessionTabs.setSessions(project.sessions);
|
||||
window.sessionTabs.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session in its project
|
||||
*/
|
||||
updateSessionInProject(session) {
|
||||
const project = this.getProjectForSession(session.id);
|
||||
if (project) {
|
||||
const index = project.sessions.findIndex(s => s.id === session.id);
|
||||
if (index !== -1) {
|
||||
project.sessions[index] = session;
|
||||
// Move to top
|
||||
project.sessions.splice(index, 1);
|
||||
project.sessions.unshift(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove session from project
|
||||
*/
|
||||
removeSessionFromProject(sessionId) {
|
||||
const project = this.getProjectForSession(sessionId);
|
||||
if (project) {
|
||||
project.sessions = project.sessions.filter(s => s.id !== sessionId);
|
||||
if (project.activeSessionId === sessionId) {
|
||||
project.activeSessionId = project.sessions.length > 0 ? project.sessions[0].id : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message) {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, 'error');
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all projects
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadProjects();
|
||||
this.renderProjectTabs();
|
||||
|
||||
if (this.activeProjectId) {
|
||||
const project = this.projects.get(this.activeProjectId.replace('project-', ''));
|
||||
if (project && window.sessionTabs) {
|
||||
window.sessionTabs.setSessions(project.sessions);
|
||||
window.sessionTabs.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize Globally
|
||||
// ============================================================
|
||||
|
||||
window.projectManager = new ProjectManager();
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => window.projectManager.initialize());
|
||||
} else {
|
||||
window.projectManager.initialize();
|
||||
}
|
||||
|
||||
console.log('[ProjectManager] Module loaded');
|
||||
Reference in New Issue
Block a user