diff --git a/packages/web/src/components/AddProjectModal.tsx b/packages/web/src/components/AddProjectModal.tsx index 749500238..bfd76520d 100644 --- a/packages/web/src/components/AddProjectModal.tsx +++ b/packages/web/src/components/AddProjectModal.tsx @@ -1,36 +1,15 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { - ArrowUpIcon, - ChevronLeftIcon, - ChevronRightIcon, deriveProjectIdFromPath, deriveProjectNameFromPath, - getParentBrowsePath, joinBrowsePath, - RefreshIcon, saveRecentPath, } from "@/components/AddProjectModal.parts"; - -interface BrowseEntry { - name: string; - isDirectory: boolean; - isGitRepo: boolean; - hasLocalConfig: boolean; - modifiedAt?: number; -} - -interface BrowseCurrentDirectory { - isGitRepo: boolean; - hasLocalConfig: boolean; -} - -interface BrowseRoot { - label: string; - path: string; -} +import { DirectoryBrowser } from "@/components/DirectoryBrowser"; +import { useDirectoryBrowser } from "@/hooks/useDirectoryBrowser"; interface CollisionState { error: string; @@ -46,120 +25,41 @@ interface AddProjectModalProps { export function AddProjectModal({ open, onClose }: AddProjectModalProps) { const router = useRouter(); + const browser = useDirectoryBrowser(); const modalRef = useRef(null); const [submitting, setSubmitting] = useState(false); const [inlineError, setInlineError] = useState(null); const [networkError, setNetworkError] = useState(null); const [collision, setCollision] = useState(null); - const [browsePath, setBrowsePath] = useState("~"); - const [selectedBrowsePath, setSelectedBrowsePath] = useState("~"); - const [browseHistory, setBrowseHistory] = useState(["~"]); - const [browseHistoryIndex, setBrowseHistoryIndex] = useState(0); - const [browseEntries, setBrowseEntries] = useState([]); - const [currentDirectory, setCurrentDirectory] = useState(null); - const [browseRoots, setBrowseRoots] = useState([]); - const [locationInput, setLocationInput] = useState("~"); - const [browseLoading, setBrowseLoading] = useState(false); - const [browseError, setBrowseError] = useState(null); const [projectIdInput, setProjectIdInput] = useState(""); const [projectNameInput, setProjectNameInput] = useState(""); - const browse = async ( - path: string, - options?: { mode?: "push" | "replace"; selectedPath?: string; historyIndex?: number }, - ) => { - setLocationInput(path); - setBrowseLoading(true); - setBrowseError(null); - try { - const response = await fetch(`/api/filesystem/browse?path=${encodeURIComponent(path)}`).catch( - () => null, - ); - if (!response) { - setBrowseEntries([]); - setCurrentDirectory(null); - setBrowseRoots([]); - setSelectedBrowsePath(options?.selectedPath ?? path); - setBrowseError("Failed to browse directories."); - return; - } - - if (!response.ok) { - const body = (await response.json().catch(() => null)) as - | { error?: string; entries?: BrowseEntry[] } - | null; - setBrowseEntries([]); - setCurrentDirectory(null); - setBrowseRoots([]); - setSelectedBrowsePath(options?.selectedPath ?? path); - setBrowseError(body?.error ?? "Failed to browse directories."); - return; - } - - const body = (await response.json().catch(() => null)) as - | { error?: string; entries?: BrowseEntry[]; current?: BrowseCurrentDirectory; roots?: BrowseRoot[] } - | null; - const mode = options?.mode ?? "push"; - const targetHistoryIndex = options?.historyIndex ?? browseHistoryIndex; - setBrowsePath(path); - setLocationInput(path); - setSelectedBrowsePath(options?.selectedPath ?? path); - setBrowseEntries(body?.entries ?? []); - setCurrentDirectory(body?.current ?? null); - setBrowseRoots(body?.roots ?? []); - if (mode === "push") { - setBrowseHistory((current) => { - const next = current.slice(0, targetHistoryIndex + 1); - if (next[next.length - 1] !== path) next.push(path); - setBrowseHistoryIndex(next.length - 1); - return next; - }); - } else { - setBrowseHistory((current) => { - const next = [...current]; - next[targetHistoryIndex] = path; - return next; - }); - } - } catch { - setCurrentDirectory(null); - setBrowseRoots([]); - setBrowseError("Failed to browse directories."); - } finally { - setBrowseLoading(false); - } - }; + const { + reset, + selectedBrowsePath, + directoryEntries, + currentDirectory, + error: browseError, + browsePath, + } = browser; useEffect(() => { if (!open) return; - const initialPath = "~"; + setSubmitting(false); setInlineError(null); setNetworkError(null); setCollision(null); - setBrowseError(null); - setBrowseHistory([initialPath]); - setBrowseHistoryIndex(0); - setBrowsePath(initialPath); - setLocationInput(initialPath); - setSelectedBrowsePath(initialPath); - setCurrentDirectory(null); - setBrowseRoots([]); setProjectIdInput(""); setProjectNameInput(""); modalRef.current?.focus(); - void browse(initialPath, { mode: "replace", selectedPath: initialPath }); - }, [open]); + reset(); + }, [open, reset]); - const directoryEntries = useMemo(() => browseEntries.filter((entry) => entry.isDirectory), [browseEntries]); const selectedEntry = useMemo( () => directoryEntries.find((entry) => joinBrowsePath(browsePath, entry.name) === selectedBrowsePath) ?? null, [browsePath, directoryEntries, selectedBrowsePath], ); const selectedCurrentDirectory = selectedBrowsePath === browsePath ? currentDirectory : null; - const parentPath = getParentBrowsePath(browsePath); - const selectedRootPath = browseRoots.find((root) => browsePath.startsWith(root.path))?.path ?? ""; - const canGoBack = browseHistoryIndex > 0; - const canGoForward = browseHistoryIndex < browseHistory.length - 1; const projectIdValue = projectIdInput.trim() || (selectedBrowsePath.trim() && selectedBrowsePath !== "~" ? deriveProjectIdFromPath(selectedBrowsePath) : ""); @@ -173,9 +73,6 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { Boolean(selectedEntry?.isGitRepo || selectedCurrentDirectory?.isGitRepo) && projectIdValue.length > 0 && projectNameValue.length > 0; - const selectedIndex = directoryEntries.findIndex( - (entry) => joinBrowsePath(browsePath, entry.name) === selectedBrowsePath, - ); useEffect(() => { if (!selectedBrowsePath || selectedBrowsePath === "~") { @@ -188,6 +85,60 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { setProjectNameInput(deriveProjectNameFromPath(selectedBrowsePath)); }, [selectedBrowsePath]); + const submit = useCallback( + async (useDefaultProjectId = false) => { + setInlineError(null); + setNetworkError(null); + setCollision(null); + setSubmitting(true); + const resolvedPath = selectedBrowsePath.trim(); + const projectId = projectIdValue; + const name = projectNameValue; + try { + const response = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, name, path: resolvedPath, useDefaultProjectId }), + }); + const body = (await response.json().catch(() => null)) as + | { + error?: string; + projectId?: string; + existingProjectId?: string; + suggestedProjectId?: string; + suggestion?: "choose-project-id"; + } + | null; + if (response.status === 409 && body?.existingProjectId && body?.suggestedProjectId && body?.suggestion) { + setCollision({ + error: body.error ?? "A project with that ID already exists.", + existingProjectId: body.existingProjectId, + suggestedProjectId: body.suggestedProjectId, + suggestion: body.suggestion, + }); + setProjectIdInput(body.suggestedProjectId); + return; + } + if (!response.ok) { + const message = body?.error ?? "Failed to add project."; + if (response.status < 500) setInlineError(message); + else setNetworkError(message); + return; + } + saveRecentPath(resolvedPath); + const nextProjectId = body?.projectId ?? projectId.trim(); + onClose(); + router.push(`/projects/${encodeURIComponent(nextProjectId)}`); + router.refresh(); + } catch { + setNetworkError("Network error while adding project."); + } finally { + setSubmitting(false); + } + }, + [onClose, projectIdValue, projectNameValue, router, selectedBrowsePath], + ); + useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { @@ -200,92 +151,14 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && canSubmit) { event.preventDefault(); void submit(); - return; - } - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - if (directoryEntries.length === 0) return; - event.preventDefault(); - const offset = event.key === "ArrowDown" ? 1 : -1; - const nextIndex = selectedIndex === -1 ? (offset > 0 ? 0 : directoryEntries.length - 1) : Math.min(Math.max(selectedIndex + offset, 0), directoryEntries.length - 1); - const nextEntry = directoryEntries[nextIndex]; - if (nextEntry) setSelectedBrowsePath(joinBrowsePath(browsePath, nextEntry.name)); - return; - } - if (event.key === "Enter") { - if (selectedIndex >= 0) { - event.preventDefault(); - void browse(selectedBrowsePath); - return; - } - if (canSubmit) { - event.preventDefault(); - void submit(); - } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [browsePath, canSubmit, directoryEntries, onClose, open, selectedBrowsePath, selectedIndex]); - - const submit = async (useDefaultProjectId = false) => { - setInlineError(null); - setNetworkError(null); - setCollision(null); - setSubmitting(true); - const resolvedPath = selectedBrowsePath.trim(); - const projectId = projectIdValue; - const name = projectNameValue; - try { - const response = await fetch("/api/projects", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId, name, path: resolvedPath, useDefaultProjectId }), - }); - const body = (await response.json().catch(() => null)) as - | { - error?: string; - projectId?: string; - existingProjectId?: string; - suggestedProjectId?: string; - suggestion?: "choose-project-id"; - } - | null; - if (response.status === 409 && body?.existingProjectId && body?.suggestedProjectId && body?.suggestion) { - setCollision({ - error: body.error ?? "A project with that ID already exists.", - existingProjectId: body.existingProjectId, - suggestedProjectId: body.suggestedProjectId, - suggestion: body.suggestion, - }); - setProjectIdInput(body.suggestedProjectId); - return; - } - if (!response.ok) { - const message = body?.error ?? "Failed to add project."; - if (response.status < 500) setInlineError(message); - else setNetworkError(message); - return; - } - saveRecentPath(resolvedPath); - const nextProjectId = body?.projectId ?? projectId.trim(); - onClose(); - router.push(`/projects/${encodeURIComponent(nextProjectId)}`); - router.refresh(); - } catch { - setNetworkError("Network error while adding project."); - } finally { - setSubmitting(false); - } - }; + }, [canSubmit, onClose, open, submit]); if (!open) return null; - const navigateHistory = (nextIndex: number) => { - if (nextIndex < 0 || nextIndex >= browseHistory.length) return; - setBrowseHistoryIndex(nextIndex); - void browse(browseHistory[nextIndex] ?? "~", { mode: "replace", historyIndex: nextIndex }); - }; - const selectedIsKnownNonRepo = Boolean(selectedEntry && !selectedEntry.isGitRepo) || Boolean(selectedCurrentDirectory && selectedBrowsePath !== "~" && !selectedCurrentDirectory.isGitRepo); @@ -293,16 +166,33 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { const selectedNotice = collision ? (

{collision.error}

-

Existing project: {collision.existingProjectId}

-

Suggested project ID: {collision.suggestedProjectId}

+

+ Existing project: {collision.existingProjectId} +

+

+ Suggested project ID: {collision.suggestedProjectId} +

- - + + Edit the Project ID field or accept the suggested suffix.
) : inlineError ? ( -
{inlineError}
+
+ {inlineError} +
) : selectedIsKnownNonRepo ? (
Selected folder is not a git repository. @@ -313,95 +203,31 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) { return (
-
+

add project

- -
-
-
- - - - - {browseRoots.length > 0 ? ( - - ) : null} -
- setLocationInput(event.target.value)} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - const nextPath = locationInput.trim() || "~"; - void browse(nextPath, { selectedPath: nextPath }); - }} - className="add-project-modal__location" - /> +
-
-
-
-
Current folder
-
{browsePath}
-
- {browseError ? ( -
-

Directory browser unavailable

-

{browseError}

-
- ) : browseLoading ? ( -
-

Loading folders

-

Fetching directories for this location.

-
- ) : directoryEntries.length === 0 ? ( -
-

No visible folders here

-

Try navigating up or picking a different location.

-
- ) : ( -
- {parentPath ? ( - - ) : null} - {directoryEntries.map((entry) => { - const nextPath = joinBrowsePath(browsePath, entry.name); - return ( - - ); - })} -
- )} -
-
+
Selected {selectedBrowsePath || "No directory selected"}
- +
- +
{directoryEntries.length} folders
- - + +