diff --git a/server/server.go b/server/server.go index 6a7bfad1639279a08808166b188115ddfaa0dc9d..ce79d9b233cb7a5814e7915646c3a9d955e3df27 100644 --- a/server/server.go +++ b/server/server.go @@ -256,6 +256,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.Handle("/api/conversation-by-slug/", gzipHandler(http.HandlerFunc(s.handleConversationBySlug))) mux.Handle("/api/validate-cwd", http.HandlerFunc(s.handleValidateCwd)) // Small response mux.Handle("/api/list-directory", gzipHandler(http.HandlerFunc(s.handleListDirectory))) + mux.Handle("/api/create-directory", http.HandlerFunc(s.handleCreateDirectory)) mux.Handle("/api/git/diffs", gzipHandler(http.HandlerFunc(s.handleGitDiffs))) mux.Handle("/api/git/diffs/", gzipHandler(http.HandlerFunc(s.handleGitDiffFiles))) mux.Handle("/api/git/file-diff/", gzipHandler(http.HandlerFunc(s.handleGitFileDiff))) @@ -448,6 +449,75 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// handleCreateDirectory creates a new directory +func (s *Server) handleCreateDirectory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid request body", + }) + return + } + + if req.Path == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "path is required", + }) + return + } + + // Clean the path + path := filepath.Clean(req.Path) + + // Check if path already exists + if _, err := os.Stat(path); err == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "path already exists", + }) + return + } + + // Verify parent directory exists + parentDir := filepath.Dir(path) + if _, err := os.Stat(parentDir); os.IsNotExist(err) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "parent directory does not exist", + }) + return + } + + // Create the directory (only the final directory, not parents) + if err := os.Mkdir(path, 0o755); err != nil { + w.Header().Set("Content-Type", "application/json") + if os.IsPermission(err) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "permission denied", + }) + } else { + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": err.Error(), + }) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "path": path, + }) +} + // getOrCreateConversationManager gets an existing conversation manager or creates a new one. func (s *Server) getOrCreateConversationManager(ctx context.Context, conversationID string) (*ConversationManager, error) { manager, err, _ := s.conversationGroup.Do(conversationID, func() (*ConversationManager, error) { diff --git a/ui/src/components/DirectoryPickerModal.tsx b/ui/src/components/DirectoryPickerModal.tsx index 966c762e66e05fb9e9868d44bcd76fea2ea0e897..eea5e7420dbc04858a5c2202d0858af3967882f3 100644 --- a/ui/src/components/DirectoryPickerModal.tsx +++ b/ui/src/components/DirectoryPickerModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useId } from "react"; import { api } from "../services/api"; interface DirectoryEntry { @@ -33,6 +33,14 @@ function DirectoryPickerModal({ const [error, setError] = useState(null); const inputRef = useRef(null); + // State for create directory mode + const [isCreating, setIsCreating] = useState(false); + const [newDirName, setNewDirName] = useState(""); + const [createError, setCreateError] = useState(null); + const [createLoading, setCreateLoading] = useState(false); + const newDirInputRef = useRef(null); + const createInputId = useId(); + // Cache for directory listings const cacheRef = useRef>(new Map()); @@ -190,6 +198,75 @@ function DirectoryPickerModal({ onClose(); }; + // Focus the new directory input when entering create mode + useEffect(() => { + if (isCreating && newDirInputRef.current) { + newDirInputRef.current.focus(); + } + }, [isCreating]); + + const handleStartCreate = () => { + setIsCreating(true); + setNewDirName(""); + setCreateError(null); + }; + + const handleCancelCreate = () => { + setIsCreating(false); + setNewDirName(""); + setCreateError(null); + }; + + const handleCreateDirectory = async () => { + if (!newDirName.trim()) { + setCreateError("Directory name is required"); + return; + } + + // Validate directory name (no path separators or special chars) + if (newDirName.includes("/") || newDirName.includes("\\")) { + setCreateError("Directory name cannot contain slashes"); + return; + } + + const basePath = displayDir?.path || "/"; + const newPath = basePath === "/" ? `/${newDirName}` : `${basePath}/${newDirName}`; + + setCreateLoading(true); + setCreateError(null); + + try { + const result = await api.createDirectory(newPath); + if (result.error) { + setCreateError(result.error); + return; + } + + // Clear the cache for the current directory so it reloads with the new dir + cacheRef.current.delete(basePath); + + // Exit create mode and navigate to the new directory + setIsCreating(false); + setNewDirName(""); + setInputPath(newPath + "/"); + } catch (err) { + setCreateError(err instanceof Error ? err.message : "Failed to create directory"); + } finally { + setCreateLoading(false); + } + }; + + const handleCreateKeyDown = (e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + handleCreateDirectory(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelCreate(); + } + }; + const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); @@ -313,8 +390,78 @@ function DirectoryPickerModal({ ))} + {/* Create new directory inline form */} + {isCreating && ( +
+ + + + + setNewDirName(e.target.value)} + onKeyDown={handleCreateKeyDown} + placeholder="New folder name" + className="directory-picker-create-input" + disabled={createLoading} + /> + + +
+ )} + + {/* Create error message */} + {createError &&
{createError}
} + {/* Empty state */} - {filteredEntries.length === 0 && !showParent && ( + {filteredEntries.length === 0 && !showParent && !isCreating && (
{filterPrefix ? "No matching directories" : "No subdirectories"}
@@ -325,6 +472,30 @@ function DirectoryPickerModal({ {/* Footer */}
+ {/* New Folder button */} + {!isCreating && !loading && !error && ( + + )} +
diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index 8f3c6519d7e7b94a9731f18631a4a6787ba5a971..2e63d0e6dc1699cadf969298ac9bde71f1024ed5 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -118,6 +118,18 @@ class ApiService { return response.json(); } + async createDirectory(path: string): Promise<{ path?: string; error?: string }> { + const response = await fetch(`${this.baseUrl}/create-directory`, { + method: "POST", + headers: this.postHeaders, + body: JSON.stringify({ path }), + }); + if (!response.ok) { + throw new Error(`Failed to create directory: ${response.statusText}`); + } + return response.json(); + } + async getArchivedConversations(): Promise { const response = await fetch(`${this.baseUrl}/conversations/archived`); if (!response.ok) { diff --git a/ui/src/styles.css b/ui/src/styles.css index 08f90164eac06b3bab79d7a52cdd67df3860dd49..5f416a2edb36ad35973450d48d885958ed00be4d 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2834,12 +2834,112 @@ svg { .directory-picker-footer { display: flex; - justify-content: flex-end; + align-items: center; gap: 0.5rem; padding: 1rem; border-top: 1px solid var(--border); } +.directory-picker-footer-spacer { + flex: 1; +} + +.directory-picker-new-btn { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.directory-picker-new-icon { + width: 1rem; + height: 1rem; +} + +/* Create directory form inside the list */ +.directory-picker-create-form { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.directory-picker-create-input { + flex: 1; + padding: 0.375rem 0.5rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--bg-base); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.875rem; +} + +.directory-picker-create-input:focus { + outline: none; + border-color: var(--primary); +} + +.directory-picker-create-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + border-radius: 0.25rem; + background: var(--primary); + color: white; + cursor: pointer; + transition: background-color 0.15s; +} + +.directory-picker-create-btn:hover:not(:disabled) { + background: var(--primary-hover); +} + +.directory-picker-create-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.directory-picker-create-btn svg { + width: 1rem; + height: 1rem; +} + +.directory-picker-cancel-btn { + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.directory-picker-cancel-btn:hover:not(:disabled) { + background: var(--bg-tertiary); +} + +.directory-picker-create-error { + padding: 0.375rem 0.75rem; + background: var(--error-bg); + color: var(--error-text); + font-size: 0.75rem; + border-bottom: 1px solid var(--error-border); +} + +/* Screen reader only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Context Usage Bar */ /* Status bar for active conversation */ .status-bar-active {