mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:59:46 +00:00
Feat: Path suggestion when creating a project
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-ui",
|
"name": "claude-code-ui",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-code-ui",
|
"name": "claude-code-ui",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
|||||||
@@ -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
|
// Read file content endpoint
|
||||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -72,6 +72,10 @@ function Sidebar({
|
|||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||||
const [searchFilter, setSearchFilter] = 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
|
// TaskMaster context
|
||||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||||
@@ -176,6 +180,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 toggleProject = (projectName) => {
|
||||||
const newExpanded = new Set(expandedProjects);
|
const newExpanded = new Set(expandedProjects);
|
||||||
if (newExpanded.has(projectName)) {
|
if (newExpanded.has(projectName)) {
|
||||||
@@ -347,8 +469,13 @@ function Sidebar({
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Save the path to recent paths before clearing
|
||||||
|
saveToRecentPaths(newProjectPath.trim());
|
||||||
|
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
|
setShowSuggestions(false);
|
||||||
|
|
||||||
// Refresh projects to show the new one
|
// Refresh projects to show the new one
|
||||||
if (window.refreshProjects) {
|
if (window.refreshProjects) {
|
||||||
@@ -371,6 +498,7 @@ function Sidebar({
|
|||||||
const cancelNewProject = () => {
|
const cancelNewProject = () => {
|
||||||
setShowNewProject(false);
|
setShowNewProject(false);
|
||||||
setNewProjectPath('');
|
setNewProjectPath('');
|
||||||
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreSessions = async (project) => {
|
const loadMoreSessions = async (project) => {
|
||||||
@@ -526,17 +654,96 @@ function Sidebar({
|
|||||||
<FolderPlus className="w-4 h-4" />
|
<FolderPlus className="w-4 h-4" />
|
||||||
Create New Project
|
Create New Project
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<div className="relative">
|
||||||
value={newProjectPath}
|
<Input
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
value={newProjectPath}
|
||||||
placeholder="/path/to/project or relative/path"
|
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
placeholder="/path/to/project or relative/path"
|
||||||
autoFocus
|
className="text-sm focus:ring-2 focus:ring-primary/20"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === 'Enter') createNewProject();
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') cancelNewProject();
|
// 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 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||||
|
{filteredPaths.map((pathItem, index) => (
|
||||||
|
<div
|
||||||
|
key={pathItem.path}
|
||||||
|
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
|
||||||
|
index === selectedPathIndex
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPath(pathItem);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{pathItem.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -580,17 +787,92 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input
|
<div className="relative">
|
||||||
value={newProjectPath}
|
<Input
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
value={newProjectPath}
|
||||||
placeholder="/path/to/project or relative/path"
|
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
placeholder="/path/to/project or relative/path"
|
||||||
autoFocus
|
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === 'Enter') createNewProject();
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') cancelNewProject();
|
// Handle path dropdown navigation (same as desktop)
|
||||||
}}
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular input handling
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
createNewProject();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelNewProject();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: '16px', // Prevents zoom on iOS
|
||||||
|
WebkitAppearance: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mobile Path dropdown */}
|
||||||
|
{showPathDropdown && filteredPaths.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
|
||||||
|
{filteredPaths.map((pathItem, index) => (
|
||||||
|
<div
|
||||||
|
key={pathItem.path}
|
||||||
|
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
|
||||||
|
index === selectedPathIndex
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPath(pathItem);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">
|
||||||
|
{pathItem.path}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Browse filesystem for project suggestions
|
||||||
|
browseFilesystem: (dirPath = null) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (dirPath) params.append('path', dirPath);
|
||||||
|
|
||||||
|
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||||
|
},
|
||||||
|
|
||||||
// Generic GET method for any endpoint
|
// Generic GET method for any endpoint
|
||||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user