diff --git a/package-lock.json b/package-lock.json index 769aeb5..1b089d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-ui", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-ui", - "version": "1.7.0", + "version": "1.8.0", "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/server/index.js b/server/index.js index f074c57..d69f825 100755 --- a/server/index.js +++ b/server/index.js @@ -301,6 +301,66 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => { } }); +// Browse filesystem endpoint for project suggestions - uses existing getFileTree +app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { + try { + const { path: dirPath } = req.query; + + // Default to home directory if no path provided + const homeDir = os.homedir(); + let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir; + + // Resolve and normalize the path + targetPath = path.resolve(targetPath); + + // Security check - ensure path is accessible + try { + await fs.promises.access(targetPath); + const stats = await fs.promises.stat(targetPath); + + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Path is not a directory' }); + } + } catch (err) { + return res.status(404).json({ error: 'Directory not accessible' }); + } + + // Use existing getFileTree function with shallow depth (only direct children) + const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false + + // Filter only directories and format for suggestions + const directories = fileTree + .filter(item => item.type === 'directory') + .map(item => ({ + path: item.path, + name: item.name, + type: 'directory' + })) + .slice(0, 20); // Limit results + + // Add common directories if browsing home directory + const suggestions = []; + if (targetPath === homeDir) { + const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; + const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); + const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); + + suggestions.push(...existingCommon, ...otherDirs); + } else { + suggestions.push(...directories); + } + + res.json({ + path: targetPath, + suggestions: suggestions + }); + + } catch (error) { + console.error('Error browsing filesystem:', error); + res.status(500).json({ error: 'Failed to browse filesystem' }); + } +}); + // Read file content endpoint app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { try { diff --git a/server/projects.js b/server/projects.js index 51178c9..99e659c 100755 --- a/server/projects.js +++ b/server/projects.js @@ -532,7 +532,7 @@ async function getSessions(projectName, limit = 5, offset = 0) { return { sessions: [], hasMore: false, total: 0 }; } - // For performance, get file stats to sort by modification time + // Sort files by modification time (newest first) const filesWithStats = await Promise.all( jsonlFiles.map(async (file) => { const filePath = path.join(projectDir, file); @@ -540,40 +540,84 @@ async function getSessions(projectName, limit = 5, offset = 0) { return { file, mtime: stats.mtime }; }) ); - - // Sort files by modification time (newest first) for better performance filesWithStats.sort((a, b) => b.mtime - a.mtime); const allSessions = new Map(); - let processedCount = 0; + const allEntries = []; + const uuidToSessionMap = new Map(); - // Process files in order of modification time + // Collect all sessions and entries from all files for (const { file } of filesWithStats) { const jsonlFile = path.join(projectDir, file); - const sessions = await parseJsonlSessions(jsonlFile); + const result = await parseJsonlSessions(jsonlFile); - // Merge sessions, avoiding duplicates by session ID - sessions.forEach(session => { + result.sessions.forEach(session => { if (!allSessions.has(session.id)) { allSessions.set(session.id, session); } }); - processedCount++; + allEntries.push(...result.entries); - // Early exit optimization: if we have enough sessions and processed recent files - if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) { + // Early exit optimization for large projects + if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) { break; } } - // Convert to array and sort by last activity - const sortedSessions = Array.from(allSessions.values()).sort((a, b) => - new Date(b.lastActivity) - new Date(a.lastActivity) - ); + // Build UUID-to-session mapping for timeline detection + allEntries.forEach(entry => { + if (entry.uuid && entry.sessionId) { + uuidToSessionMap.set(entry.uuid, entry.sessionId); + } + }); - const total = sortedSessions.length; - const paginatedSessions = sortedSessions.slice(offset, offset + limit); + // Detect session continuations using leafUuid + const sessionContinuations = new Map(); + let pendingContinuationInfo = null; + + allEntries.forEach(entry => { + // Summary entries without sessionId indicate a session continuation + if (entry.type === 'summary' && !entry.sessionId && (entry.leafUuid || entry.leafUUID)) { + pendingContinuationInfo = { + leafUuid: entry.leafUuid || entry.leafUUID, + summary: entry.summary || 'Continued Session' + }; + return; + } + + if (entry.sessionId) { + const session = allSessions.get(entry.sessionId); + + // Apply pending continuation info + if (session && pendingContinuationInfo) { + const previousSession = uuidToSessionMap.get(pendingContinuationInfo.leafUuid); + if (previousSession) { + session.summary = pendingContinuationInfo.summary; + sessionContinuations.set(entry.sessionId, previousSession); + } + pendingContinuationInfo = null; + } + + // Handle summary entries with sessionId that have leafUuid + if (entry.type === 'summary' && (entry.leafUuid || entry.leafUUID)) { + const leafUuid = entry.leafUuid || entry.leafUUID; + const previousSession = uuidToSessionMap.get(leafUuid); + if (previousSession && session) { + sessionContinuations.set(entry.sessionId, previousSession); + } + } + } + }); + + // Filter out continued sessions - only show the latest in each timeline + const continuedSessions = new Set(sessionContinuations.values()); + const visibleSessions = Array.from(allSessions.values()) + .filter(session => !continuedSessions.has(session.id)) + .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + + const total = visibleSessions.length; + const paginatedSessions = visibleSessions.slice(offset, offset + limit); const hasMore = offset + limit < total; return { @@ -591,6 +635,7 @@ async function getSessions(projectName, limit = 5, offset = 0) { async function parseJsonlSessions(filePath) { const sessions = new Map(); + const entries = []; try { const fileStream = fsSync.createReadStream(filePath); @@ -599,14 +644,11 @@ async function parseJsonlSessions(filePath) { crlfDelay: Infinity }); - // console.log(`[JSONL Parser] Reading file: ${filePath}`); - let lineCount = 0; - for await (const line of rl) { if (line.trim()) { - lineCount++; try { const entry = JSON.parse(line); + entries.push(entry); if (entry.sessionId) { if (!sessions.has(entry.sessionId)) { @@ -621,43 +663,37 @@ async function parseJsonlSessions(filePath) { const session = sessions.get(entry.sessionId); - // Update summary if this is a summary entry + // Update summary from summary entries or first user message if (entry.type === 'summary' && entry.summary) { session.summary = entry.summary; } else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') { - // Use first user message as summary if no summary entry exists const content = entry.message.content; - if (typeof content === 'string' && content.length > 0) { - // Skip command messages that start with - if (!content.startsWith('')) { - session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content; - } + if (typeof content === 'string' && content.length > 0 && !content.startsWith('')) { + session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content; } } - // Count messages instead of storing them all - session.messageCount = (session.messageCount || 0) + 1; + session.messageCount++; - // Update last activity if (entry.timestamp) { session.lastActivity = new Date(entry.timestamp); } } } catch (parseError) { - console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message); + // Skip malformed lines silently } } } - // console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`); + return { + sessions: Array.from(sessions.values()), + entries: entries + }; + } catch (error) { console.error('Error reading JSONL file:', error); + return { sessions: [], entries: [] }; } - - // Convert Map to Array and sort by last activity - return Array.from(sessions.values()).sort((a, b) => - new Date(b.lastActivity) - new Date(a.lastActivity) - ); } // Get messages for a specific session with pagination support diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 4b79adf..4a118ba 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; @@ -74,6 +74,10 @@ function Sidebar({ const [editingSessionName, setEditingSessionName] = useState(''); const [generatingSummary, setGeneratingSummary] = useState({}); const [searchFilter, setSearchFilter] = useState(''); + const [showPathDropdown, setShowPathDropdown] = useState(false); + const [pathList, setPathList] = useState([]); + const [filteredPaths, setFilteredPaths] = useState([]); + const [selectedPathIndex, setSelectedPathIndex] = useState(-1); // TaskMaster context const { setCurrentProject, mcpServerStatus } = useTaskMaster(); @@ -178,6 +182,124 @@ function Sidebar({ }; }, []); + // Load available paths for suggestions + useEffect(() => { + const loadPaths = async () => { + try { + // Get recent paths from localStorage + const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); + + // Load common/home directory paths + const response = await api.browseFilesystem(); + const data = await response.json(); + + if (data.suggestions) { + const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path })); + const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths]; + setPathList(allPaths); + } else { + setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); + } + } catch (error) { + console.error('Error loading paths:', error); + const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); + setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); + } + }; + + loadPaths(); + }, []); + + // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing) + useEffect(() => { + const inputValue = newProjectPath.trim(); + + if (inputValue.length === 0) { + setShowPathDropdown(false); + return; + } + + // Show dropdown when user starts typing + setShowPathDropdown(true); + + const updateSuggestions = async () => { + // First show filtered existing suggestions from pathList + const staticFiltered = pathList.filter(pathItem => + pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) || + pathItem.path.toLowerCase().includes(inputValue.toLowerCase()) + ); + + // Check if input looks like a directory path for dynamic browsing + const isDirPath = inputValue.includes('/') && inputValue.length > 1; + + if (isDirPath) { + try { + let dirToSearch; + + // Determine which directory to search + if (inputValue.endsWith('/')) { + // User typed "/home/simos/" - search inside /home/simos + dirToSearch = inputValue.slice(0, -1); + } else { + // User typed "/home/simos/con" - search inside /home/simos for items starting with "con" + const lastSlashIndex = inputValue.lastIndexOf('/'); + dirToSearch = inputValue.substring(0, lastSlashIndex); + } + + // Only search if we have a valid directory path (not root only) + if (dirToSearch && dirToSearch !== '') { + const response = await api.browseFilesystem(dirToSearch); + const data = await response.json(); + + if (data.suggestions) { + // Filter directories that match the current input + const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1); + const dynamicPaths = data.suggestions + .filter(suggestion => { + const dirName = suggestion.name; + return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true; + }) + .map(s => ({ name: s.name, path: s.path })) + .slice(0, 8); + + // Combine static and dynamic suggestions, prioritize dynamic + const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8); + setFilteredPaths(combined); + setSelectedPathIndex(-1); + return; + } + } + } catch (error) { + console.debug('Dynamic browsing failed:', error.message); + } + } + + // Fallback to just static filtered suggestions + setFilteredPaths(staticFiltered.slice(0, 8)); + setSelectedPathIndex(-1); + }; + + updateSuggestions(); + }, [newProjectPath, pathList]); + + // Select path from dropdown (ChatInterface pattern) + const selectPath = (pathItem) => { + setNewProjectPath(pathItem.path); + setShowPathDropdown(false); + setSelectedPathIndex(-1); + }; + + // Save path to recent paths + const saveToRecentPaths = (path) => { + try { + const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); + const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10); + localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths)); + } catch (error) { + console.error('Error saving recent paths:', error); + } + }; + const toggleProject = (projectName) => { const newExpanded = new Set(expandedProjects); if (newExpanded.has(projectName)) { @@ -349,8 +471,13 @@ function Sidebar({ if (response.ok) { const result = await response.json(); + + // Save the path to recent paths before clearing + saveToRecentPaths(newProjectPath.trim()); + setShowNewProject(false); setNewProjectPath(''); + setShowSuggestions(false); // Refresh projects to show the new one if (window.refreshProjects) { @@ -373,6 +500,7 @@ function Sidebar({ const cancelNewProject = () => { setShowNewProject(false); setNewProjectPath(''); + setShowSuggestions(false); }; const loadMoreSessions = async (project) => { @@ -534,17 +662,96 @@ function Sidebar({ Create New Project - setNewProjectPath(e.target.value)} - placeholder="/path/to/project or relative/path" - className="text-sm focus:ring-2 focus:ring-primary/20" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') createNewProject(); - if (e.key === 'Escape') cancelNewProject(); - }} - /> +
+ setNewProjectPath(e.target.value)} + placeholder="/path/to/project or relative/path" + className="text-sm focus:ring-2 focus:ring-primary/20" + autoFocus + onKeyDown={(e) => { + // Handle path dropdown navigation (ChatInterface pattern) + if (showPathDropdown && filteredPaths.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedPathIndex(prev => + prev < filteredPaths.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedPathIndex(prev => + prev > 0 ? prev - 1 : filteredPaths.length - 1 + ); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedPathIndex >= 0) { + selectPath(filteredPaths[selectedPathIndex]); + } else if (filteredPaths.length > 0) { + selectPath(filteredPaths[0]); + } else { + createNewProject(); + } + return; + } else if (e.key === 'Escape') { + e.preventDefault(); + setShowPathDropdown(false); + return; + } else if (e.key === 'Tab') { + e.preventDefault(); + if (selectedPathIndex >= 0) { + selectPath(filteredPaths[selectedPathIndex]); + } else if (filteredPaths.length > 0) { + selectPath(filteredPaths[0]); + } + return; + } + } + + // Regular input handling + if (e.key === 'Enter') { + createNewProject(); + } + if (e.key === 'Escape') { + cancelNewProject(); + } + }} + /> + + {/* Path dropdown (ChatInterface pattern) */} + {showPathDropdown && filteredPaths.length > 0 && ( +
+ {filteredPaths.map((pathItem, index) => ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + selectPath(pathItem); + }} + > +
+ +
+
{pathItem.name}
+
+ {pathItem.path} +
+
+
+
+ ))} +
+ )} +