import { useState, useCallback } from "react"; import { basename } from "path"; /** * Add-directory step machine — pure React hook, no store coupling. * * Encapsulates the three-step flow shared by the runtime AddDirectoryDialog * and the onboarding DirectoriesStep: * * browse → maxDepth → label → done * * Validation lives here in one place: maxDepth must parse to an integer in * [0, 10]; label defaults to basename(selectedPath) when blank. * * The hook is rendering-agnostic — callers render the right widget per step * and wire keystroke handlers to advance / back / setters. The hook does not * call useInput. * * See .scratch/architecture-deepening-2/issues/05-use-add-directory-flow-tracer.md. */ export type AddDirectoryStep = "browse" | "maxDepth" | "label"; export interface UseAddDirectoryFlowOptions { initialPath?: string; /** Optional seed for the maxDepth input. Defaults to "2". */ initialMaxDepth?: string; /** Optional seed for the label input. Defaults to "". */ initialLabel?: string; onComplete: (path: string, maxDepth: number, label: string) => void; onCancel: () => void; } export interface AddDirectoryFlowResult { step: AddDirectoryStep; selectedPath: string | null; maxDepthInput: string; labelInput: string; error: string | null; setSelectedPath: (path: string) => void; setMaxDepthInput: (value: string) => void; setLabelInput: (value: string) => void; /** Move forward through the flow, validating the current step. */ advance: () => void; /** Move back one step. From `browse`, calls `onCancel`. */ back: () => void; } const DEFAULT_MAX_DEPTH_INPUT = "2"; function validateMaxDepth(input: string): { depth: number; error: string | null } { const depth = parseInt(input, 10); if (isNaN(depth) || depth < 0 || depth > 10) { return { depth: NaN, error: "Max depth must be between 0 and 10" }; } return { depth, error: null }; } export function useAddDirectoryFlow(options: UseAddDirectoryFlowOptions): AddDirectoryFlowResult { const { initialPath, initialMaxDepth, initialLabel, onComplete, onCancel } = options; const [step, setStep] = useState(initialPath ? "maxDepth" : "browse"); const [selectedPath, setSelectedPathState] = useState(initialPath ?? null); const [maxDepthInput, setMaxDepthInput] = useState(initialMaxDepth ?? DEFAULT_MAX_DEPTH_INPUT); const [labelInput, setLabelInput] = useState(initialLabel ?? ""); const [error, setError] = useState(null); const setSelectedPath = useCallback((path: string) => { setSelectedPathState(path); setMaxDepthInput(DEFAULT_MAX_DEPTH_INPUT); setLabelInput(""); setError(null); setStep("maxDepth"); }, []); const advance = useCallback(() => { if (step === "browse") { // Browse → maxDepth requires a selected path. Caller normally invokes // setSelectedPath from the browser widget instead of advance(); this // branch is here for completeness. if (!selectedPath) return; setStep("maxDepth"); setError(null); return; } if (step === "maxDepth") { const { error: depthError } = validateMaxDepth(maxDepthInput); if (depthError) { setError(depthError); return; } setError(null); setStep("label"); return; } if (step === "label") { if (!selectedPath) return; const { depth, error: depthError } = validateMaxDepth(maxDepthInput); // maxDepth should have been validated already; if somehow not, fall // back to the default rather than blocking confirmation. const safeDepth = depthError ? 2 : depth; const label = labelInput.trim() || basename(selectedPath); onComplete(selectedPath, safeDepth, label); } }, [step, selectedPath, maxDepthInput, labelInput, onComplete]); const back = useCallback(() => { if (step === "browse") { onCancel(); return; } if (step === "maxDepth") { setStep("browse"); setSelectedPathState(null); setError(null); return; } if (step === "label") { setStep("maxDepth"); setError(null); } }, [step, onCancel]); return { step, selectedPath, maxDepthInput, labelInput, error, setSelectedPath, setMaxDepthInput, setLabelInput, advance, back, }; }