mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: Add arbitrary directory project storage with registry system
This major update replaces the fixed `generations/` directory with support for storing projects in any directory on the filesystem. Projects are now tracked via a cross-platform registry system. ## New Features ### Project Registry (`registry.py`) - Cross-platform registry storing project name-to-path mappings - Platform-specific config locations: - Windows: %APPDATA%\autonomous-coder\projects.json - macOS: ~/Library/Application Support/autonomous-coder/projects.json - Linux: ~/.config/autonomous-coder/projects.json - POSIX path format for cross-platform compatibility - File locking for concurrent access safety (fcntl/msvcrt) - Atomic writes via temp file + rename to prevent corruption - Fixed Windows file locking issue with tempfile.mkstemp() ### Filesystem Browser API (`server/routers/filesystem.py`) - REST endpoints for browsing directories server-side - Cross-platform support with blocked system paths: - Windows: C:\Windows, Program Files, ProgramData, etc. - macOS: /System, /Library, /private, etc. - Linux: /etc, /var, /usr, /bin, etc. - Universal blocked paths: .ssh, .aws, .gnupg, .docker, etc. - Hidden file detection (Unix dot-prefix + Windows attributes) - UNC path blocking for security - Windows drive enumeration via ctypes - Directory creation with validation - Added `has_children` field to DirectoryEntry schema ### UI Folder Browser (`ui/src/components/FolderBrowser.tsx`) - React component for selecting project directories - Breadcrumb navigation with clickable segments - Windows drive selector - New folder creation inline - Fixed text visibility with explicit color values ## Updated Components ### Server Routers - `projects.py`: Uses registry instead of fixed generations/ directory - `agent.py`: Uses registry for project path lookups - `features.py`: Uses registry for database path resolution - `spec_creation.py`: Uses registry for WebSocket project resolution ### Process Manager (`server/services/process_manager.py`) - Fixed sandbox issue: subprocess now uses project_dir as cwd - This allows the Claude SDK sandbox to access external project directories ### Schemas (`server/schemas.py`) - Added `has_children` to DirectoryEntry - Added `in_progress` to ProjectStats - Added path field to ProjectSummary and ProjectDetail ### UI Components - `NewProjectModal.tsx`: Multi-step wizard with folder selection - Added clarifying text about subfolder creation - Fixed text color visibility issues ### API Client (`ui/src/lib/api.ts`) - Added filesystem API functions (listDirectory, createDirectory) - Fixed Windows path splitting for directory creation ### Documentation - Updated CLAUDE.md with registry system details - Updated command examples for absolute paths ## Security Improvements - Blocked `.` and `..` in directory names to prevent traversal - Added path blocking check in project creation - UNC path blocking throughout filesystem API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
336
ui/src/components/FolderBrowser.tsx
Normal file
336
ui/src/components/FolderBrowser.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Folder Browser Component
|
||||
*
|
||||
* Server-side filesystem browser for selecting project directories.
|
||||
* Cross-platform support for Windows, macOS, and Linux.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
FolderPlus,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import * as api from '../lib/api'
|
||||
import type { DirectoryEntry, DriveInfo } from '../lib/types'
|
||||
|
||||
interface FolderBrowserProps {
|
||||
onSelect: (path: string) => void
|
||||
onCancel: () => void
|
||||
initialPath?: string
|
||||
}
|
||||
|
||||
export function FolderBrowser({ onSelect, onCancel, initialPath }: FolderBrowserProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(initialPath)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Fetch directory listing
|
||||
const {
|
||||
data: directoryData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['filesystem', 'list', currentPath],
|
||||
queryFn: () => api.listDirectory(currentPath),
|
||||
})
|
||||
|
||||
// Update selected path when directory changes
|
||||
useEffect(() => {
|
||||
if (directoryData?.current_path) {
|
||||
setSelectedPath(directoryData.current_path)
|
||||
}
|
||||
}, [directoryData?.current_path])
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
setSelectedPath(path)
|
||||
setIsCreatingFolder(false)
|
||||
setNewFolderName('')
|
||||
setCreateError(null)
|
||||
}
|
||||
|
||||
const handleNavigateUp = () => {
|
||||
if (directoryData?.parent_path) {
|
||||
handleNavigate(directoryData.parent_path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDriveSelect = (drive: DriveInfo) => {
|
||||
handleNavigate(`${drive.letter}:/`)
|
||||
}
|
||||
|
||||
const handleEntryClick = (entry: DirectoryEntry) => {
|
||||
if (entry.is_directory) {
|
||||
handleNavigate(entry.path)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim()) {
|
||||
setCreateError('Folder name is required')
|
||||
return
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (!/^[a-zA-Z0-9_\-. ]+$/.test(newFolderName)) {
|
||||
setCreateError('Invalid folder name')
|
||||
return
|
||||
}
|
||||
|
||||
const newPath = `${directoryData?.current_path}/${newFolderName.trim()}`
|
||||
|
||||
try {
|
||||
await api.createDirectory(newPath)
|
||||
// Refresh the directory listing
|
||||
await refetch()
|
||||
// Navigate to the new folder
|
||||
handleNavigate(newPath)
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : 'Failed to create folder')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = () => {
|
||||
if (selectedPath) {
|
||||
onSelect(selectedPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse breadcrumb segments from path
|
||||
const getBreadcrumbs = (path: string): { name: string; path: string }[] => {
|
||||
if (!path) return []
|
||||
|
||||
const segments: { name: string; path: string }[] = []
|
||||
|
||||
// Handle Windows drive letters
|
||||
if (/^[A-Za-z]:/.test(path)) {
|
||||
const drive = path.slice(0, 2)
|
||||
segments.push({ name: drive, path: `${drive}/` })
|
||||
path = path.slice(3)
|
||||
} else if (path.startsWith('/')) {
|
||||
segments.push({ name: '/', path: '/' })
|
||||
path = path.slice(1)
|
||||
}
|
||||
|
||||
// Split remaining path
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
let currentPath = segments.length > 0 ? segments[0].path : ''
|
||||
|
||||
for (const part of parts) {
|
||||
currentPath = currentPath.endsWith('/') ? currentPath + part : currentPath + '/' + part
|
||||
segments.push({ name: part, path: currentPath })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
const breadcrumbs = directoryData?.current_path ? getBreadcrumbs(directoryData.current_path) : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[70vh]">
|
||||
{/* Header with breadcrumb navigation */}
|
||||
<div className="flex-shrink-0 p-4 border-b-3 border-[var(--color-neo-border)] bg-white">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Folder size={20} className="text-[var(--color-neo-progress)]" />
|
||||
<span className="font-bold text-[#1a1a1a]">Select Project Folder</span>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center gap-1 flex-wrap text-sm">
|
||||
{directoryData?.parent_path && (
|
||||
<button
|
||||
onClick={handleNavigateUp}
|
||||
className="neo-btn neo-btn-ghost p-1"
|
||||
title="Go up"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<div key={crumb.path} className="flex items-center">
|
||||
{index > 0 && <ChevronRight size={14} className="text-gray-400 mx-1" />}
|
||||
<button
|
||||
onClick={() => handleNavigate(crumb.path)}
|
||||
className={`
|
||||
px-2 py-1 rounded text-[#1a1a1a]
|
||||
hover:bg-[var(--color-neo-bg)]
|
||||
${index === breadcrumbs.length - 1 ? 'font-bold' : ''}
|
||||
`}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drive selector (Windows only) */}
|
||||
{directoryData?.drives && directoryData.drives.length > 0 && (
|
||||
<div className="flex-shrink-0 p-3 border-b-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-neo-text-secondary)]">Drives:</span>
|
||||
{directoryData.drives.map((drive) => (
|
||||
<button
|
||||
key={drive.letter}
|
||||
onClick={() => handleDriveSelect(drive)}
|
||||
className={`
|
||||
neo-btn neo-btn-ghost py-1 px-2 text-sm
|
||||
flex items-center gap-1
|
||||
${currentPath?.startsWith(drive.letter) ? 'bg-[var(--color-neo-progress)] text-white' : ''}
|
||||
`}
|
||||
>
|
||||
<HardDrive size={14} />
|
||||
{drive.letter}: {drive.label && `(${drive.label})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory listing */}
|
||||
<div className="flex-1 overflow-y-auto p-2 bg-white">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle size={32} className="mx-auto mb-2 text-[var(--color-neo-danger)]" />
|
||||
<p className="text-[var(--color-neo-danger)]">
|
||||
{error instanceof Error ? error.message : 'Failed to load directory'}
|
||||
</p>
|
||||
<button onClick={() => refetch()} className="neo-btn neo-btn-ghost mt-2">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{/* Directory entries - only show directories */}
|
||||
{directoryData?.entries
|
||||
.filter((entry) => entry.is_directory)
|
||||
.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
onDoubleClick={() => handleNavigate(entry.path)}
|
||||
className={`
|
||||
w-full text-left p-2 rounded
|
||||
flex items-center gap-2
|
||||
hover:bg-[var(--color-neo-bg)]
|
||||
border-2 border-transparent
|
||||
text-[var(--color-neo-text)]
|
||||
${selectedPath === entry.path ? 'bg-[var(--color-neo-progress)] bg-opacity-10 border-[var(--color-neo-progress)]' : ''}
|
||||
`}
|
||||
>
|
||||
{selectedPath === entry.path ? (
|
||||
<FolderOpen size={18} className="text-[var(--color-neo-progress)] flex-shrink-0" />
|
||||
) : (
|
||||
<Folder size={18} className="text-[var(--color-neo-pending)] flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1 text-[#1a1a1a]">{entry.name}</span>
|
||||
{entry.has_children && (
|
||||
<ChevronRight size={14} className="ml-auto text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{directoryData?.entries.filter((e) => e.is_directory).length === 0 && (
|
||||
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
|
||||
<Folder size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>No subfolders</p>
|
||||
<p className="text-sm">You can create a new folder or select this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New folder creation */}
|
||||
{isCreatingFolder && (
|
||||
<div className="mt-2 p-3 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderPlus size={18} className="text-[var(--color-neo-progress)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="New folder name"
|
||||
className="neo-input flex-1 py-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder()
|
||||
if (e.key === 'Escape') {
|
||||
setIsCreatingFolder(false)
|
||||
setNewFolderName('')
|
||||
setCreateError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleCreateFolder} className="neo-btn neo-btn-primary py-1 px-3">
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreatingFolder(false)
|
||||
setNewFolderName('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
className="neo-btn neo-btn-ghost py-1 px-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-sm text-[var(--color-neo-danger)] mt-1">{createError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with selected path and actions */}
|
||||
<div className="flex-shrink-0 p-4 border-t-3 border-[var(--color-neo-border)] bg-white">
|
||||
{/* Selected path display */}
|
||||
<div className="mb-3 p-2 bg-[var(--color-neo-bg)] rounded border-2 border-[var(--color-neo-border)]">
|
||||
<div className="text-xs text-[#4a4a4a] mb-1">Selected path:</div>
|
||||
<div className="font-mono text-sm truncate text-[#1a1a1a]">{selectedPath || 'No folder selected'}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setIsCreatingFolder(true)}
|
||||
className="neo-btn neo-btn-ghost"
|
||||
disabled={isCreatingFolder}
|
||||
>
|
||||
<FolderPlus size={16} />
|
||||
New Folder
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="neo-btn neo-btn-ghost">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelect}
|
||||
className="neo-btn neo-btn-primary"
|
||||
disabled={!selectedPath}
|
||||
>
|
||||
Select This Folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,20 +3,22 @@
|
||||
*
|
||||
* Multi-step modal for creating new projects:
|
||||
* 1. Enter project name
|
||||
* 2. Choose spec method (Claude or manual)
|
||||
* 3a. If Claude: Show SpecCreationChat
|
||||
* 3b. If manual: Create project and close
|
||||
* 2. Select project folder
|
||||
* 3. Choose spec method (Claude or manual)
|
||||
* 4a. If Claude: Show SpecCreationChat
|
||||
* 4b. If manual: Create project and close
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react'
|
||||
import { X, Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
|
||||
import { useCreateProject } from '../hooks/useProjects'
|
||||
import { SpecCreationChat } from './SpecCreationChat'
|
||||
import { FolderBrowser } from './FolderBrowser'
|
||||
import { startAgent } from '../lib/api'
|
||||
|
||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
||||
|
||||
type Step = 'name' | 'method' | 'chat' | 'complete'
|
||||
type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete'
|
||||
type SpecMethod = 'claude' | 'manual'
|
||||
|
||||
interface NewProjectModalProps {
|
||||
@@ -32,6 +34,7 @@ export function NewProjectModal({
|
||||
}: NewProjectModalProps) {
|
||||
const [step, setStep] = useState<Step>('name')
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectPath, setProjectPath] = useState<string | null>(null)
|
||||
const [_specMethod, setSpecMethod] = useState<SpecMethod | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
||||
@@ -59,17 +62,35 @@ export function NewProjectModal({
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setStep('folder')
|
||||
}
|
||||
|
||||
const handleFolderSelect = (path: string) => {
|
||||
// Append project name to the selected path
|
||||
const fullPath = path.endsWith('/') ? `${path}${projectName.trim()}` : `${path}/${projectName.trim()}`
|
||||
setProjectPath(fullPath)
|
||||
setStep('method')
|
||||
}
|
||||
|
||||
const handleFolderCancel = () => {
|
||||
setStep('name')
|
||||
}
|
||||
|
||||
const handleMethodSelect = async (method: SpecMethod) => {
|
||||
setSpecMethod(method)
|
||||
|
||||
if (!projectPath) {
|
||||
setError('Please select a project folder first')
|
||||
setStep('folder')
|
||||
return
|
||||
}
|
||||
|
||||
if (method === 'manual') {
|
||||
// Create project immediately with manual method
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
name: projectName.trim(),
|
||||
path: projectPath,
|
||||
specMethod: 'manual',
|
||||
})
|
||||
setStep('complete')
|
||||
@@ -85,6 +106,7 @@ export function NewProjectModal({
|
||||
try {
|
||||
await createProject.mutateAsync({
|
||||
name: projectName.trim(),
|
||||
path: projectPath,
|
||||
specMethod: 'claude',
|
||||
})
|
||||
setStep('chat')
|
||||
@@ -126,6 +148,7 @@ export function NewProjectModal({
|
||||
const handleClose = () => {
|
||||
setStep('name')
|
||||
setProjectName('')
|
||||
setProjectPath(null)
|
||||
setSpecMethod(null)
|
||||
setError(null)
|
||||
setInitializerStatus('idle')
|
||||
@@ -135,8 +158,11 @@ export function NewProjectModal({
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'method') {
|
||||
setStep('name')
|
||||
setStep('folder')
|
||||
setSpecMethod(null)
|
||||
} else if (step === 'folder') {
|
||||
setStep('name')
|
||||
setProjectPath(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +182,47 @@ export function NewProjectModal({
|
||||
)
|
||||
}
|
||||
|
||||
// Folder step uses larger modal
|
||||
if (step === 'folder') {
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={handleClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-3xl max-h-[85vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder size={24} className="text-[var(--color-neo-progress)]" />
|
||||
<div>
|
||||
<h2 className="font-display font-bold text-xl text-[#1a1a1a]">
|
||||
Select Project Location
|
||||
</h2>
|
||||
<p className="text-sm text-[#4a4a4a]">
|
||||
A folder named <span className="font-bold font-mono">{projectName}</span> will be created inside the selected directory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Folder Browser */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FolderBrowser
|
||||
onSelect={handleFolderSelect}
|
||||
onCancel={handleFolderCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={handleClose}>
|
||||
<div
|
||||
|
||||
@@ -29,8 +29,8 @@ export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, specMethod }: { name: string; specMethod?: 'claude' | 'manual' }) =>
|
||||
api.createProject(name, specMethod),
|
||||
mutationFn: ({ name, path, specMethod }: { name: string; path: string; specMethod?: 'claude' | 'manual' }) =>
|
||||
api.createProject(name, path, specMethod),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
@@ -170,3 +170,33 @@ export function useHealthCheck() {
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filesystem
|
||||
// ============================================================================
|
||||
|
||||
export function useListDirectory(path?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['filesystem', 'list', path],
|
||||
queryFn: () => api.listDirectory(path),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateDirectory() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (path: string) => api.createDirectory(path),
|
||||
onSuccess: (_, path) => {
|
||||
// Invalidate parent directory listing
|
||||
const parentPath = path.split('/').slice(0, -1).join('/') || undefined
|
||||
queryClient.invalidateQueries({ queryKey: ['filesystem', 'list', parentPath] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useValidatePath() {
|
||||
return useMutation({
|
||||
mutationFn: (path: string) => api.validatePath(path),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
AgentStatusResponse,
|
||||
AgentActionResponse,
|
||||
SetupStatus,
|
||||
DirectoryListResponse,
|
||||
PathValidationResponse,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
@@ -41,10 +43,14 @@ export async function listProjects(): Promise<ProjectSummary[]> {
|
||||
return fetchJSON('/projects')
|
||||
}
|
||||
|
||||
export async function createProject(name: string, specMethod: 'claude' | 'manual' = 'manual'): Promise<ProjectSummary> {
|
||||
export async function createProject(
|
||||
name: string,
|
||||
path: string,
|
||||
specMethod: 'claude' | 'manual' = 'manual'
|
||||
): Promise<ProjectSummary> {
|
||||
return fetchJSON('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, spec_method: specMethod }),
|
||||
body: JSON.stringify({ name, path, spec_method: specMethod }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,3 +152,59 @@ export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
return fetchJSON('/health')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filesystem API
|
||||
// ============================================================================
|
||||
|
||||
export async function listDirectory(path?: string): Promise<DirectoryListResponse> {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||
return fetchJSON(`/filesystem/list${params}`)
|
||||
}
|
||||
|
||||
export async function createDirectory(fullPath: string): Promise<{ success: boolean; path: string }> {
|
||||
// Backend expects { parent_path, name }, not { path }
|
||||
// Split the full path into parent directory and folder name
|
||||
|
||||
// Remove trailing slash if present
|
||||
const normalizedPath = fullPath.endsWith('/') ? fullPath.slice(0, -1) : fullPath
|
||||
|
||||
// Find the last path separator
|
||||
const lastSlash = normalizedPath.lastIndexOf('/')
|
||||
|
||||
let parentPath: string
|
||||
let name: string
|
||||
|
||||
// Handle Windows drive root (e.g., "C:/newfolder")
|
||||
if (lastSlash === 2 && /^[A-Za-z]:/.test(normalizedPath)) {
|
||||
// Path like "C:/newfolder" - parent is "C:/"
|
||||
parentPath = normalizedPath.substring(0, 3) // "C:/"
|
||||
name = normalizedPath.substring(3)
|
||||
} else if (lastSlash > 0) {
|
||||
parentPath = normalizedPath.substring(0, lastSlash)
|
||||
name = normalizedPath.substring(lastSlash + 1)
|
||||
} else if (lastSlash === 0) {
|
||||
// Unix root path like "/newfolder"
|
||||
parentPath = '/'
|
||||
name = normalizedPath.substring(1)
|
||||
} else {
|
||||
// No slash - invalid path
|
||||
throw new Error('Invalid path: must be an absolute path')
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Invalid path: directory name is empty')
|
||||
}
|
||||
|
||||
return fetchJSON('/filesystem/create-directory', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ parent_path: parentPath, name }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function validatePath(path: string): Promise<PathValidationResponse> {
|
||||
return fetchJSON('/filesystem/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ProjectStats {
|
||||
|
||||
export interface ProjectSummary {
|
||||
name: string
|
||||
path: string
|
||||
has_spec: boolean
|
||||
stats: ProjectStats
|
||||
}
|
||||
@@ -20,6 +21,35 @@ export interface ProjectDetail extends ProjectSummary {
|
||||
prompts_dir: string
|
||||
}
|
||||
|
||||
// Filesystem types
|
||||
export interface DriveInfo {
|
||||
letter: string
|
||||
label: string
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string
|
||||
path: string
|
||||
is_directory: boolean
|
||||
has_children: boolean
|
||||
}
|
||||
|
||||
export interface DirectoryListResponse {
|
||||
current_path: string
|
||||
parent_path: string | null
|
||||
entries: DirectoryEntry[]
|
||||
drives: DriveInfo[] | null
|
||||
}
|
||||
|
||||
export interface PathValidationResponse {
|
||||
valid: boolean
|
||||
exists: boolean
|
||||
is_directory: boolean
|
||||
can_write: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ProjectPrompts {
|
||||
app_spec: string
|
||||
initializer_prompt: string
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user