@@ -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) {
@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
+ // State for create directory mode
+ const [isCreating, setIsCreating] = useState(false);
+ const [newDirName, setNewDirName] = useState("");
+ const [createError, setCreateError] = useState<string | null>(null);
+ const [createLoading, setCreateLoading] = useState(false);
+ const newDirInputRef = useRef<HTMLInputElement>(null);
+ const createInputId = useId();
+
// Cache for directory listings
const cacheRef = useRef<Map<string, CachedDirectory>>(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<HTMLInputElement>) => {
+ 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({
</button>
))}
+ {/* Create new directory inline form */}
+ {isCreating && (
+ <div className="directory-picker-create-form">
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ className="directory-picker-icon"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
+ />
+ </svg>
+ <label htmlFor={createInputId} className="sr-only">
+ New folder name
+ </label>
+ <input
+ id={createInputId}
+ ref={newDirInputRef}
+ type="text"
+ value={newDirName}
+ onChange={(e) => setNewDirName(e.target.value)}
+ onKeyDown={handleCreateKeyDown}
+ placeholder="New folder name"
+ className="directory-picker-create-input"
+ disabled={createLoading}
+ />
+ <button
+ className="directory-picker-create-btn"
+ onClick={handleCreateDirectory}
+ disabled={createLoading || !newDirName.trim()}
+ title="Create"
+ >
+ {createLoading ? (
+ <div className="spinner spinner-small"></div>
+ ) : (
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 13l4 4L19 7"
+ />
+ </svg>
+ )}
+ </button>
+ <button
+ className="directory-picker-create-btn directory-picker-cancel-btn"
+ onClick={handleCancelCreate}
+ disabled={createLoading}
+ title="Cancel"
+ >
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+ )}
+
+ {/* Create error message */}
+ {createError && <div className="directory-picker-create-error">{createError}</div>}
+
{/* Empty state */}
- {filteredEntries.length === 0 && !showParent && (
+ {filteredEntries.length === 0 && !showParent && !isCreating && (
<div className="directory-picker-empty">
{filterPrefix ? "No matching directories" : "No subdirectories"}
</div>
@@ -325,6 +472,30 @@ function DirectoryPickerModal({
{/* Footer */}
<div className="directory-picker-footer">
+ {/* New Folder button */}
+ {!isCreating && !loading && !error && (
+ <button
+ className="btn directory-picker-new-btn"
+ onClick={handleStartCreate}
+ title="Create new folder"
+ >
+ <svg
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ className="directory-picker-new-icon"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
+ />
+ </svg>
+ New Folder
+ </button>
+ )}
+ <div className="directory-picker-footer-spacer"></div>
<button className="btn" onClick={onClose}>
Cancel
</button>
@@ -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<Conversation[]> {
const response = await fetch(`${this.baseUrl}/conversations/archived`);
if (!response.ok) {
@@ -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 {