FN-7469: select newly created project directories

Project setup now targets folders immediately after they are created from the directory picker.

- Add an opt-in DirectoryPicker mode that selects the API-returned created folder path.
- Enable the mode for setup wizard and project setup forms while preserving other picker behavior.
- Clear wizard path errors on path changes and document the setup behavior.
- Cover created-folder selection and default-picker preservation with dashboard tests.

Files changed:
 .../fn-7469-select-created-project-directory.md    |   7 ++
 docs/dashboard-guide.md                            |   2 +
 docs/getting-started.md                            |   1 +
 .../dashboard/app/components/DirectoryPicker.tsx   |  16 +++-
 .../dashboard/app/components/SetupProjectForm.tsx  |   1 +
 .../dashboard/app/components/SetupWizardModal.tsx  |   3 +-
 .../components/__tests__/DirectoryPicker.test.tsx  | 101 +++++++++++++++++----
 .../components/__tests__/SetupProjectForm.test.tsx |  10 ++
 .../components/__tests__/SetupWizardModal.test.tsx |  43 ++++++++-
 9 files changed, 162 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7469

Fusion-Task-Lineage: 1883262f-3fdf-4583-9bb0-e9435039e17d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 15:45:40 -07:00
parent d16c8b429b
commit b800f7d0df
9 changed files with 164 additions and 24 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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**.

View File

@@ -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<BrowserState>({
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<HTMLInputElement>) => {
if (e.key === "Enter") {

View File

@@ -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 && (

View File

@@ -150,7 +150,7 @@ export function SetupWizardModal({
const handlePathChange = useCallback((path: string) => {
setState((prev) => {
const updates: Partial<WizardState> = { manualPath: path, detectedRepos: [], workspaceMode: false };
const updates: Partial<WizardState> = { 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")}
/>
<p className="form-hint">

View File

@@ -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(<DirectoryPicker value="" onChange={vi.fn()} />);
render(<DirectoryPicker value="" onChange={onChange} />);
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(<DirectoryPicker value="" onChange={onChange} selectCreatedDirectory />);
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(<DirectoryPicker value="" onChange={vi.fn()} />);
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(<DirectoryPicker value="" onChange={onChange} selectCreatedDirectory />);
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(<DirectoryPicker value="" onChange={vi.fn()} />);
render(<DirectoryPicker value="" onChange={onChange} selectCreatedDirectory />);
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();
});
});

View File

@@ -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;
}) => (
<div data-testid="directory-picker">
<input
@@ -74,6 +76,9 @@ vi.mock("../DirectoryPicker", () => ({
{localNodeId !== undefined && (
<span data-testid="local-node-id">{localNodeId}</span>
)}
{selectCreatedDirectory && (
<span data-testid="select-created-directory">enabled</span>
)}
</div>
),
}));
@@ -118,6 +123,11 @@ describe("SetupProjectForm", () => {
expect(screen.getByText("Directory Path")).toBeDefined();
});
it("opts into selecting directories created during project registration", () => {
render(<SetupProjectForm onSubmit={vi.fn()} />);
expect(screen.getByTestId("select-created-directory").textContent).toBe("enabled");
});
it("has disabled submit button initially", () => {
render(<SetupProjectForm onSubmit={vi.fn()} />);
const submitBtn = screen.getByText("Create Project").closest("button") as HTMLButtonElement;

View File

@@ -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(
<SetupWizardModal
onProjectRegistered={vi.fn()}
onClose={vi.fn()}
/>
);
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(
<SetupWizardModal