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:
7
.changeset/fn-7469-select-created-project-directory.md
Normal file
7
.changeset/fn-7469-select-created-project-directory.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user