diff --git a/.changeset/fn-7469-select-created-project-directory.md b/.changeset/fn-7469-select-created-project-directory.md new file mode 100644 index 0000000000..2572f60643 --- /dev/null +++ b/.changeset/fn-7469-select-created-project-directory.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Select newly created folders automatically during project setup. +category: fix +dev: Adds DirectoryPicker opt-in selection for project-registration surfaces while preserving default picker behavior. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f02bce9fca..2695e37fe6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -793,6 +793,8 @@ First-run setup and embedded project setup both expose a **Repository setup** se - **Initialize New Repository** registers the selected local folder and relies on the server-side project registration path to run `git init` if the folder is not already a git repository. - **Clone Git Repository** requires a non-blank remote URL and an absolute destination path. Fusion runs `git clone` with argument-vector execution, requires the destination to be absent or empty, cleans up a newly-created failed destination best-effort, then registers the cloned folder. +When creating a folder from the project directory picker, Fusion selects the newly created folder immediately so registration targets that folder instead of its parent. + Advanced setup remains limited to runtime node and isolation-mode choices, so repository mode selection is not hidden behind the advanced panel. ## Agents View diff --git a/docs/getting-started.md b/docs/getting-started.md index 64757167e4..8febe68f34 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -98,6 +98,7 @@ On first launch, Fusion opens an onboarding wizard with guided setup steps: - **Use Existing Directory** registers a folder that is already a git repository or a workspace root with detected sub-repositories. - **Initialize New Repository** registers an existing local folder and lets the server run `git init` during registration when the folder is not already a git repository. - **Clone Git Repository** runs `git clone` from a remote URL into an empty or absent destination directory, then registers the cloned folder. Fusion rejects blank clone URLs and populated destinations. + - Creating a folder from the project directory picker automatically selects that new folder for registration. 4. **First Task** — create your first task or import one from GitHub. The wizard is dismissible and non-blocking. You can skip it and continue using Fusion, then reopen it later from **Settings → Authentication**. diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index 1f0d4d6c20..893664f50d 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -15,6 +15,8 @@ export interface DirectoryPickerProps { nodeId?: string; /** Node ID of the local node (used to determine when to route through proxy). */ localNodeId?: string; + /** Select the newly created directory path returned by the API after folder creation. */ + selectCreatedDirectory?: boolean; } interface BrowserState { @@ -29,7 +31,7 @@ interface BrowserState { createFolderError: string | null; } -export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) { +export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId, selectCreatedDirectory = false }: DirectoryPickerProps) { const { t } = useTranslation("app"); const [browser, setBrowser] = useState({ isOpen: false, @@ -138,9 +140,17 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null })); try { - await createDirectory(folderPath); + const result = await createDirectory(folderPath); setNewFolderName(""); setBrowser((prev) => ({ ...prev, createFolderOpen: false })); + /* + FNXC:DirectoryPicker 2026-07-03-00:00: + Project setup must select the directory returned by createDirectory immediately after folder creation so first-time users do not accidentally register the parent directory. + Keep this opt-in because DirectoryPicker is shared by non-project surfaces such as plugin installation. + */ + if (selectCreatedDirectory) { + onChange(result.path); + } // Refresh entries to show the new folder await fetchEntries(browser.currentPath, browser.showHidden); } catch (err) { @@ -150,7 +160,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, createFolderError: err instanceof Error ? err.message : "Failed to create folder", })); } - }, [newFolderName, browser.currentPath, browser.showHidden, fetchEntries]); + }, [newFolderName, browser.currentPath, browser.showHidden, fetchEntries, onChange, selectCreatedDirectory]); const handleCreateFolderKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter") { diff --git a/packages/dashboard/app/components/SetupProjectForm.tsx b/packages/dashboard/app/components/SetupProjectForm.tsx index 372a3e76e8..5d469fb952 100644 --- a/packages/dashboard/app/components/SetupProjectForm.tsx +++ b/packages/dashboard/app/components/SetupProjectForm.tsx @@ -148,6 +148,7 @@ export function SetupProjectForm({ onChange={handlePathChange} nodeId={nodeId || undefined} localNodeId={localNodeId} + selectCreatedDirectory placeholder={t("setup.pathPlaceholder", "/path/to/your/project")} /> {pathError && touched.path && ( diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index 1625dc3c23..06ecdc290d 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -150,7 +150,7 @@ export function SetupWizardModal({ const handlePathChange = useCallback((path: string) => { setState((prev) => { - const updates: Partial = { manualPath: path, detectedRepos: [], workspaceMode: false }; + const updates: Partial = { manualPath: path, detectedRepos: [], workspaceMode: false, error: null }; // Auto-suggest name when path changes and name is empty or was previously auto-suggested if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) { updates.manualName = suggestProjectName(path); @@ -496,6 +496,7 @@ export function SetupWizardModal({ onChange={handlePathChange} nodeId={state.manualNodeId || undefined} localNodeId={localNodeId} + selectCreatedDirectory placeholder={isCloneMode ? t("setup.clonePathPlaceholder", "/path/for/new-clone") : isInitMode ? t("setup.initPathPlaceholder", "/path/to/new-project") : t("setup.projectPathPlaceholder", "/path/to/your/project")} />

diff --git a/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx b/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx index 259fda7684..fec6bce643 100644 --- a/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx +++ b/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx @@ -289,7 +289,8 @@ describe("DirectoryPicker", () => { ); }); - it("allows creating a new folder", async () => { + it("allows creating a new folder without changing the selected path by default", async () => { + const onChange = vi.fn(); mockBrowseDirectory.mockResolvedValue({ currentPath: "/home/user", parentPath: "/home", @@ -300,7 +301,7 @@ describe("DirectoryPicker", () => { mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/my-new-folder" }); - render(); + render(); fireEvent.click(screen.getByText("Browse")); @@ -308,27 +309,98 @@ describe("DirectoryPicker", () => { expect(screen.getByText("projects")).toBeDefined(); }); - // Click "New folder" button fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); - - // Type folder name - const input = screen.getByPlaceholderText("Folder name"); - fireEvent.change(input, { target: { value: "my-new-folder" } }); - - // Click Create + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "my-new-folder" } }); fireEvent.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/my-new-folder"); }); - // Should refresh the directory listing + await waitFor(() => { + expect(mockBrowseDirectory).toHaveBeenLastCalledWith("/home/user", false, undefined, undefined); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("selects the API-returned normalized directory path when enabled", async () => { + const onChange = vi.fn(); + mockBrowseDirectory.mockResolvedValue({ + currentPath: "/home/user", + parentPath: "/home", + entries: [], + }); + mockCreateDirectory.mockResolvedValue({ success: true, path: "/normalized/home/user/Fresh Project" }); + + render(); + + fireEvent.click(screen.getByText("Browse")); + + await waitFor(() => { + expect(screen.getByText("No subdirectories")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "fresh-project" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith("/normalized/home/user/Fresh Project"); + }); + expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/fresh-project"); await waitFor(() => { expect(mockBrowseDirectory).toHaveBeenLastCalledWith("/home/user", false, undefined, undefined); }); }); - it("shows error when creating folder fails", async () => { + it("keeps create disabled until a folder name and current path exist", async () => { + let resolveBrowse: (value: any) => void; + mockBrowseDirectory.mockImplementationOnce(() => new Promise((resolve) => { resolveBrowse = resolve; })); + + render(); + + fireEvent.click(screen.getByText("Browse")); + fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); + + const createButton = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; + expect(createButton.disabled).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "fresh-project" } }); + expect(createButton.disabled).toBe(true); + expect(mockCreateDirectory).not.toHaveBeenCalled(); + + resolveBrowse!({ currentPath: "/home/user", parentPath: "/home", entries: [] }); + await waitFor(() => { + expect(screen.getByText("No subdirectories")).toBeDefined(); + }); + }); + + it("rejects invalid folder names before calling createDirectory", async () => { + const onChange = vi.fn(); + mockBrowseDirectory.mockResolvedValue({ + currentPath: "/home/user", + parentPath: "/home", + entries: [], + }); + + render(); + + fireEvent.click(screen.getByText("Browse")); + await waitFor(() => { + expect(screen.getByText("No subdirectories")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "../fresh" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + expect(mockCreateDirectory).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText("Folder name cannot contain path separators or '..'")).toBeDefined(); + }); + + it("shows error when creating folder fails and leaves the selected path untouched", async () => { + const onChange = vi.fn(); mockBrowseDirectory.mockResolvedValue({ currentPath: "/home/user", parentPath: "/home", @@ -337,7 +409,7 @@ describe("DirectoryPicker", () => { mockCreateDirectory.mockRejectedValue(new Error("Permission denied")); - render(); + render(); fireEvent.click(screen.getByText("Browse")); @@ -345,18 +417,13 @@ describe("DirectoryPicker", () => { expect(screen.getByText("No subdirectories")).toBeDefined(); }); - // Click "New folder" button fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); - - // Type folder name - const input = screen.getByPlaceholderText("Folder name"); - fireEvent.change(input, { target: { value: "test-folder" } }); - - // Click Create + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "test-folder" } }); fireEvent.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { expect(screen.getByText("Permission denied")).toBeDefined(); }); + expect(onChange).not.toHaveBeenCalled(); }); }); diff --git a/packages/dashboard/app/components/__tests__/SetupProjectForm.test.tsx b/packages/dashboard/app/components/__tests__/SetupProjectForm.test.tsx index 2c2d640464..2b2c4c14de 100644 --- a/packages/dashboard/app/components/__tests__/SetupProjectForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/SetupProjectForm.test.tsx @@ -53,12 +53,14 @@ vi.mock("../DirectoryPicker", () => ({ nodeId, localNodeId, placeholder, + selectCreatedDirectory, }: { value: string; onChange: (path: string) => void; nodeId?: string; localNodeId?: string; placeholder?: string; + selectCreatedDirectory?: boolean; }) => (

({ {localNodeId !== undefined && ( {localNodeId} )} + {selectCreatedDirectory && ( + enabled + )}
), })); @@ -118,6 +123,11 @@ describe("SetupProjectForm", () => { expect(screen.getByText("Directory Path")).toBeDefined(); }); + it("opts into selecting directories created during project registration", () => { + render(); + expect(screen.getByTestId("select-created-directory").textContent).toBe("enabled"); + }); + it("has disabled submit button initially", () => { render(); const submitBtn = screen.getByText("Create Project").closest("button") as HTMLButtonElement; diff --git a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx b/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx index b1c3e20a8c..d2a863a4c6 100644 --- a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx @@ -52,6 +52,7 @@ vi.mock("../../api", () => ({ parentPath: "/home", entries: [], }), + createDirectory: vi.fn().mockResolvedValue({ success: true, path: "/home/user/fresh-project" }), })); vi.mock("../ExperimentalAgentOnboardingModal", () => ({ @@ -84,12 +85,14 @@ vi.mock("../ExperimentalAgentOnboardingModal", () => ({ ), })); -import { createAgent, registerProject, detectWorkspace } from "../../api"; +import { createAgent, registerProject, detectWorkspace, browseDirectory, createDirectory } from "../../api"; import { useNodes } from "../../hooks/useNodes"; const mockRegisterProject = vi.mocked(registerProject); const mockCreateAgent = vi.mocked(createAgent); const mockDetectWorkspace = vi.mocked(detectWorkspace); +const mockBrowseDirectory = vi.mocked(browseDirectory); +const mockCreateDirectory = vi.mocked(createDirectory); const mockUseNodes = vi.mocked(useNodes); function buildMockProject(overrides = {}) { @@ -135,6 +138,12 @@ describe("SetupWizardModal", () => { mockRegisterProject.mockReset(); mockCreateAgent.mockReset(); mockDetectWorkspace.mockResolvedValue({ repos: [], isWorkspace: false }); + mockBrowseDirectory.mockResolvedValue({ + currentPath: "/home/user", + parentPath: "/home", + entries: [], + }); + mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/fresh-project" }); }); it("starts on the project form without an auth token step", () => { @@ -190,6 +199,38 @@ describe("SetupWizardModal", () => { expect(nameInput.value).toBe("my-awesome-project"); }); + it("selects a newly created project directory and derives the project name", async () => { + mockBrowseDirectory.mockResolvedValue({ + currentPath: "/home/user", + parentPath: "/home", + entries: [], + }); + mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/fresh-project" }); + + render( + + ); + + fireEvent.click(screen.getByText("Browse")); + + await waitFor(() => { + expect(screen.getByText("No subdirectories")).toBeDefined(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); + fireEvent.change(screen.getByPlaceholderText("Folder name"), { target: { value: "fresh-project" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => { + expect((screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement).value).toBe("/home/user/fresh-project"); + }); + expect((screen.getByPlaceholderText("my-project") as HTMLInputElement).value).toBe("fresh-project"); + expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/fresh-project"); + }); + it("register button is disabled when required fields are empty", () => { render(