Files
SuperCharged-Claude-Code-Up…/public/claude-ide/project-manager.js
uroma b82837aa5f Fix tab persistence in Claude IDE - persist closed tabs to localStorage
Implement localStorage persistence for closed session and project tabs.
When users close tabs, they now remain closed after page refresh.

Changes:
- session-tabs.js: Add closedSessions tracking with localStorage
- project-manager.js: Add closedProjects tracking with localStorage
- Filter out closed tabs on load
- Persist state whenever tabs are closed

Fixes issue where closed tabs would reappear on page refresh.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:40:31 +00:00

583 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
this.closedProjects = new Set(); // Track closed project IDs
this.STORAGE_KEY = 'claude_ide_closed_projects';
}
/**
* Initialize the project manager
*/
async initialize() {
if (this.initialized) return;
console.log('[ProjectManager] Initializing...');
this.loadClosedProjects();
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 closed projects from localStorage
*/
loadClosedProjects() {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
const closedIds = JSON.parse(stored);
this.closedProjects = new Set(closedIds);
console.log('[ProjectManager] Loaded', this.closedProjects.size, 'closed projects from storage');
}
} catch (error) {
console.error('[ProjectManager] Error loading closed projects:', error);
this.closedProjects = new Set();
}
}
/**
* Save closed projects to localStorage
*/
saveClosedProjects() {
try {
const closedIds = Array.from(this.closedProjects);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(closedIds));
console.log('[ProjectManager] Saved', closedIds.length, 'closed projects to storage');
} catch (error) {
console.error('[ProjectManager] Error saving closed projects:', error);
}
}
/**
* 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;
}
});
// Filter out closed projects
const filtered = new Map();
grouped.forEach((project, key) => {
if (!this.closedProjects.has(project.id)) {
filtered.set(key, project);
}
});
this.projects = filtered;
console.log('[ProjectManager] Loaded', this.projects.size, 'projects (filtered out', grouped.size - this.projects.size, 'closed)');
} 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>` : ''}
<span class="tab-close" onclick="event.stopPropagation(); window.projectManager.closeProject('${project.id}')">×</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);
}
}
/**
* Close a project (with confirmation)
*/
async closeProject(projectId) {
const projectKey = projectId.replace('project-', '');
const project = this.projects.get(projectKey);
if (!project) return;
// Check if project has sessions
if (project.sessions.length > 0) {
const sessionList = project.sessions.map(s => this.getSessionName(s)).join(', ');
if (!confirm(`Close project "${project.name}"?\n\nThis project contains ${project.sessions.length} session(s):\n${sessionList}\n\nThe sessions will remain accessible but the project tab will be removed.`)) {
return;
}
} else {
if (!confirm(`Close project "${project.name}"?`)) {
return;
}
}
console.log('[ProjectManager] Closing project:', projectId);
// Add to closed projects set and persist
this.closedProjects.add(projectId);
this.saveClosedProjects();
// Remove project from map
this.projects.delete(projectKey);
// If we closed the active project, switch to another
if (this.activeProjectId === projectId) {
const remainingProjects = Array.from(this.projects.values());
if (remainingProjects.length > 0) {
// Switch to the next available project
await this.switchProject(remainingProjects[0].id);
} else {
// No projects left
this.activeProjectId = null;
if (window.sessionTabs) {
window.sessionTabs.setSessions([]);
window.sessionTabs.render();
}
// Show empty state
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
messagesContainer.innerHTML = `
<div class="empty-project-state">
<div class="empty-icon">📁</div>
<h3>No Projects</h3>
<p>Create a new project to get started</p>
<button class="btn-primary" onclick="window.projectManager.createNewProject()">
Create Project
</button>
</div>
`;
}
}
}
this.renderProjectTabs();
}
/**
* Get display name for a session
*/
getSessionName(session) {
// Try to get name from metadata
if (session.metadata) {
if (session.metadata.project) return session.metadata.project;
if (session.metadata.title) return session.metadata.title;
if (session.metadata.name) return session.metadata.name;
}
// Use working directory
if (session.workingDir) {
return session.workingDir.split('/').pop();
}
// Fallback to session ID
return session.id.substring(0, 8);
}
/**
* 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');