refactor(web): slim AddProjectModal to a shell

This commit is contained in:
Priyanshu Choudhary 2026-05-22 16:21:17 +05:30
parent ee806b88f8
commit 46d60ffd4f
1 changed files with 121 additions and 284 deletions

View File

@ -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<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false);
const [inlineError, setInlineError] = useState<string | null>(null);
const [networkError, setNetworkError] = useState<string | null>(null);
const [collision, setCollision] = useState<CollisionState | null>(null);
const [browsePath, setBrowsePath] = useState("~");
const [selectedBrowsePath, setSelectedBrowsePath] = useState("~");
const [browseHistory, setBrowseHistory] = useState<string[]>(["~"]);
const [browseHistoryIndex, setBrowseHistoryIndex] = useState(0);
const [browseEntries, setBrowseEntries] = useState<BrowseEntry[]>([]);
const [currentDirectory, setCurrentDirectory] = useState<BrowseCurrentDirectory | null>(null);
const [browseRoots, setBrowseRoots] = useState<BrowseRoot[]>([]);
const [locationInput, setLocationInput] = useState("~");
const [browseLoading, setBrowseLoading] = useState(false);
const [browseError, setBrowseError] = useState<string | null>(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 ? (
<div className="add-project-modal__notice add-project-modal__notice--warning">
<p className="add-project-modal__notice-title">{collision.error}</p>
<p className="add-project-modal__notice-copy">Existing project: <code>{collision.existingProjectId}</code></p>
<p className="add-project-modal__notice-copy">Suggested project ID: <code>{collision.suggestedProjectId}</code></p>
<p className="add-project-modal__notice-copy">
Existing project: <code>{collision.existingProjectId}</code>
</p>
<p className="add-project-modal__notice-copy">
Suggested project ID: <code>{collision.suggestedProjectId}</code>
</p>
<div className="add-project-modal__notice-actions">
<button type="button" onClick={() => { onClose(); router.push(`/projects/${encodeURIComponent(collision.existingProjectId)}`); }} className="add-project-modal__ghostbtn">Open existing</button>
<button type="button" onClick={() => void submit(true)} className="add-project-modal__ghostbtn">Use suggested ID</button>
<button
type="button"
onClick={() => {
onClose();
router.push(`/projects/${encodeURIComponent(collision.existingProjectId)}`);
}}
className="add-project-modal__ghostbtn"
>
Open existing
</button>
<button type="button" onClick={() => void submit(true)} className="add-project-modal__ghostbtn">
Use suggested ID
</button>
<span className="add-project-modal__notice-hint">Edit the Project ID field or accept the suggested suffix.</span>
</div>
</div>
) : inlineError ? (
<div role="alert" className="add-project-modal__notice add-project-modal__notice--error">{inlineError}</div>
<div role="alert" className="add-project-modal__notice add-project-modal__notice--error">
{inlineError}
</div>
) : selectedIsKnownNonRepo ? (
<div role="alert" className="add-project-modal__notice add-project-modal__notice--error">
Selected folder is not a git repository.
@ -313,95 +203,31 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) {
return (
<div className="add-project-modal-backdrop">
<div ref={modalRef} role="dialog" aria-modal="true" aria-label="Add project" className="add-project-modal" tabIndex={-1}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label="Add project"
className="add-project-modal"
tabIndex={-1}
>
<div className="add-project-modal__titlebar">
<h2 className="add-project-modal__windowtitle">add project</h2>
<button type="button" aria-label="Close" onClick={onClose} className="add-project-modal__iconbtn">×</button>
</div>
<div className="add-project-modal__toolbar">
<div className="add-project-modal__toolbarcluster">
<button type="button" onClick={() => navigateHistory(browseHistoryIndex - 1)} disabled={!canGoBack} className="add-project-modal__toolbtn" aria-label="Go back"><ChevronLeftIcon /></button>
<button type="button" onClick={() => navigateHistory(browseHistoryIndex + 1)} disabled={!canGoForward} className="add-project-modal__toolbtn" aria-label="Go forward"><ChevronRightIcon /></button>
<button type="button" onClick={() => parentPath && void browse(parentPath)} disabled={!parentPath} className="add-project-modal__toolbtn" aria-label="Go up"><ArrowUpIcon /></button>
<button type="button" onClick={() => void browse(browsePath, { mode: "replace", selectedPath: selectedBrowsePath })} className="add-project-modal__toolbtn" aria-label="Refresh"><RefreshIcon /></button>
{browseRoots.length > 0 ? (
<select
aria-label="Drive"
value={selectedRootPath}
onChange={(event) => {
const nextPath = event.target.value;
if (nextPath) void browse(nextPath, { selectedPath: nextPath });
}}
className="add-project-modal__drive-select"
>
<option value="">Drive</option>
{browseRoots.map((root) => (
<option key={root.path} value={root.path}>{root.label}</option>
))}
</select>
) : null}
</div>
<input
aria-label="Folder path"
value={locationInput}
onChange={(event) => 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"
/>
<button type="button" aria-label="Close" onClick={onClose} className="add-project-modal__iconbtn">
×
</button>
</div>
<div className="add-project-modal__content">
<div className="add-project-browser">
<div className="add-project-browser__current">
<div className="add-project-browser__current-label">Current folder</div>
<div className="add-project-browser__current-path">{browsePath}</div>
</div>
{browseError ? (
<div className="add-project-browser__state add-project-browser__state--error">
<p className="add-project-browser__state-title">Directory browser unavailable</p>
<p className="add-project-browser__state-copy">{browseError}</p>
</div>
) : browseLoading ? (
<div className="add-project-browser__state">
<p className="add-project-browser__state-title">Loading folders</p>
<p className="add-project-browser__state-copy">Fetching directories for this location.</p>
</div>
) : directoryEntries.length === 0 ? (
<div className="add-project-browser__state">
<p className="add-project-browser__state-title">No visible folders here</p>
<p className="add-project-browser__state-copy">Try navigating up or picking a different location.</p>
</div>
) : (
<div className="add-project-browser__rows">
{parentPath ? (
<button type="button" onClick={() => void browse(parentPath)} className="add-project-browser__row add-project-browser__row--parent">
..
</button>
) : null}
{directoryEntries.map((entry) => {
const nextPath = joinBrowsePath(browsePath, entry.name);
return (
<button key={nextPath} type="button" onClick={() => setSelectedBrowsePath(nextPath)} onDoubleClick={() => void browse(nextPath)} className={`add-project-browser__row${selectedBrowsePath === nextPath ? " is-selected" : ""}`}>
{entry.name}
</button>
);
})}
</div>
)}
</div>
</div>
<DirectoryBrowser browser={browser} />
<div className="add-project-modal__pathbar add-project-modal__pathbar--selection">
<span className="add-project-modal__selection-label">Selected</span>
<span className="add-project-modal__selection-path">{selectedBrowsePath || "No directory selected"}</span>
</div>
<div className="add-project-modal__pathbar add-project-modal__pathbar--selection">
<label className="add-project-modal__selection-label" htmlFor="project-id-input">Project ID</label>
<label className="add-project-modal__selection-label" htmlFor="project-id-input">
Project ID
</label>
<input
id="project-id-input"
value={projectIdInput}
@ -410,7 +236,9 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) {
/>
</div>
<div className="add-project-modal__pathbar add-project-modal__pathbar--selection">
<label className="add-project-modal__selection-label" htmlFor="project-name-input">Project name</label>
<label className="add-project-modal__selection-label" htmlFor="project-name-input">
Project name
</label>
<input
id="project-name-input"
value={projectNameInput}
@ -423,8 +251,17 @@ export function AddProjectModal({ open, onClose }: AddProjectModalProps) {
<div className="add-project-modal__footer">
<div className="add-project-modal__foldercount">{directoryEntries.length} folders</div>
<div className="add-project-modal__actions">
<button type="button" onClick={onClose} className="add-project-modal__ghostbtn">Cancel</button>
<button type="button" onClick={() => void submit()} disabled={!canSubmit || submitting} className="add-project-modal__primarybtn">{submitting ? "Adding…" : "Add project"}</button>
<button type="button" onClick={onClose} className="add-project-modal__ghostbtn">
Cancel
</button>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit || submitting}
className="add-project-modal__primarybtn"
>
{submitting ? "Adding…" : "Add project"}
</button>
</div>
</div>
</div>