diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx new file mode 100644 index 000000000..caf6d5582 --- /dev/null +++ b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { Host } from "@bb/domain"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProjectPathDialog } from "./ProjectPathDialog"; + +vi.mock("@/components/dialogs/RemotePathBrowser", () => ({ + RemotePathBrowser: ({ + hostId, + onDirectoryChange, + }: { + hostId: string; + onDirectoryChange: (directory: string) => void; + }) => ( + + ), +})); + +function host(overrides: Partial & Pick): Host { + return { + type: "persistent", + status: "connected", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +const atum = host({ id: "host_atum", name: "atum" }); +const kunst = host({ id: "host_kunst", name: "Kunst" }); +const offline = host({ + id: "host_offline", + name: "Offline Mac", + status: "disconnected", +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ProjectPathDialog machine selection", () => { + it("creates a project from a folder on the selected connected machine", () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect( + (screen.getByRole("radio", { name: "atum" }) as HTMLInputElement).checked, + ).toBe(true); + expect( + screen + .getByRole("radio", { name: "Offline Mac" }) + .hasAttribute("disabled"), + ).toBe(true); + + fireEvent.click(screen.getByRole("radio", { name: "Kunst" })); + fireEvent.click( + screen.getByRole("button", { name: "Choose folder on host_kunst" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + + expect(onSubmit).toHaveBeenCalledWith( + { kind: "create" }, + "/Users/amadad/projects/reachy_mini", + "host_kunst", + ); + }); + + it("preserves the direct single-machine folder flow", () => { + const onSubmit = vi.fn(); + render( + , + ); + + expect(screen.queryByRole("radiogroup", { name: "Machine" })).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Choose folder on host_atum" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + + expect(onSubmit).toHaveBeenCalledWith( + { kind: "create" }, + "/home/deploy/repos/givecare", + "host_atum", + ); + }); +}); diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index ad90dad7a..68b8a1620 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -3,6 +3,7 @@ import { deriveProjectNameFromPath, getProjectPathValidationMessage, normalizeProjectPathInput, + type Host, } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; @@ -15,7 +16,9 @@ import { DialogTitle, } from "@bb/shared-ui/dialog"; import { Input } from "@bb/shared-ui/input"; +import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; export type ProjectPathDialogTarget = @@ -37,6 +40,7 @@ export type ProjectPathDialogTarget = export type ProjectPathDialogSubmitHandler = ( target: ProjectPathDialogTarget, path: string, + hostId: string | null, ) => Promise | void; interface ProjectPathDialogProps { @@ -45,6 +49,7 @@ interface ProjectPathDialogProps { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts?: readonly Host[]; onOpenChange: (open: boolean) => void; onSubmit: ProjectPathDialogSubmitHandler; } @@ -55,6 +60,7 @@ export function ProjectPathDialog({ platform, hostId, hostName, + hosts, onOpenChange, onSubmit, }: ProjectPathDialogProps) { @@ -69,6 +75,7 @@ export function ProjectPathDialog({ platform={platform} hostId={hostId} hostName={hostName} + hosts={hosts} onSubmit={onSubmit} /> ) : null} @@ -83,6 +90,7 @@ export interface ProjectPathDialogContentProps { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts?: readonly Host[]; onSubmit: ProjectPathDialogSubmitHandler; } @@ -139,10 +147,31 @@ export function ProjectPathDialogContent({ platform, hostId, hostName, + hosts, onSubmit, }: ProjectPathDialogContentProps) { const inputId = useId(); const isPointerCoarse = usePointerCoarse(); + const machineOptions = target.kind === "create" ? hosts : undefined; + const firstConnectedHostId = machineOptions?.find( + (host) => host.status === "connected", + )?.id; + const initialHostId = + hostId !== null && + (machineOptions === undefined || + machineOptions.some( + (host) => host.id === hostId && host.status === "connected", + )) + ? hostId + : (firstConnectedHostId ?? hostId); + const [selectedHostId, setSelectedHostId] = useState(initialHostId); + const selectedHost = machineOptions?.find( + (host) => host.id === selectedHostId, + ); + const selectedHostName = selectedHost?.name ?? hostName; + const selectedHostConnected = + selectedHost === undefined || selectedHost.status === "connected"; + const showMachinePicker = (machineOptions?.length ?? 0) > 1; // No-host fallback only: the browser owns the path when a host is present. const [manualPath, setManualPath] = useState( target.kind === "update" ? target.currentPath : "", @@ -153,13 +182,13 @@ export function ProjectPathDialogContent({ const [validationMessage, setValidationMessage] = useState( null, ); - const copy = getPlatformCopy(platform, hostName); + const copy = getPlatformCopy(platform, selectedHostName); const placeholder = target.kind === "update" ? target.currentPath || copy.placeholder : copy.placeholder; - const selectedPath = hostId + const selectedPath = selectedHostId ? browserDirectory : normalizeProjectPathInput(manualPath) || null; const derivedProjectName = selectedPath @@ -195,7 +224,7 @@ export function ProjectPathDialogContent({ return; } - void onSubmit(target, normalizedPath); + void onSubmit(target, normalizedPath, selectedHostId); }; return ( @@ -203,20 +232,67 @@ export function ProjectPathDialogContent({ {getDialogTitle(target.kind)} - {hostId + {selectedHostId ? `Browse to the project folder${ - hostName ? ` on ${hostName}` : "" + selectedHostName ? ` on ${selectedHostName}` : "" }, or edit the path directly.` : copy.description}
- {hostId ? ( + {showMachinePicker ? ( +
+ {machineOptions?.map((host) => { + const connected = host.status === "connected"; + const selected = host.id === selectedHostId; + return ( + + ); + })} +
+ ) : null} + {selectedHostId ? ( ) : ( ) : null} - diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index 4db1b34a6..9e4d7df5a 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -1111,6 +1111,7 @@ export function AppLayout({ children }: AppLayoutProps) { platform={quickCreateProject.platform} hostId={quickCreateProject.hostId} hostName={quickCreateProject.hostName} + hosts={quickCreateProject.hosts} onOpenChange={quickCreateProject.projectPathDialog.onOpenChange} onSubmit={quickCreateProject.submitProjectPath} /> diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index 0f1d88f49..d934da095 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -77,9 +77,13 @@ export function useLocalPathPicker({ const closeDialog = projectPathDialog.onClose; const submitPath = useCallback( - (path: string, target: ProjectPathDialogTarget) => { - if (isPending || !hostId) return; - submit({ path, hostId, target, closeDialog }); + ( + path: string, + target: ProjectPathDialogTarget, + selectedHostId: string | null = hostId, + ) => { + if (isPending || !selectedHostId) return; + submit({ path, hostId: selectedHostId, target, closeDialog }); }, [closeDialog, hostId, isPending, submit], ); @@ -118,8 +122,8 @@ export function useLocalPathPicker({ ); const submitProjectPath = useCallback( - (target, path) => { - submitPath(path, target); + (target, path, selectedHostId) => { + submitPath(path, target, selectedHostId); }, [submitPath], ); diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx new file mode 100644 index 000000000..c1bce2d5c --- /dev/null +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { Host } from "@bb/domain"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useQuickCreateProject } from "./useQuickCreateProject"; + +const mocks = vi.hoisted(() => ({ + hosts: [] as Host[], + mutate: vi.fn(), + navigate: vi.fn(), + onClose: vi.fn(), + onOpen: vi.fn(), + onOpenChange: vi.fn(), + openPicker: vi.fn(), + setRootComposeProjectId: vi.fn(), +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ pathname: "/" }), + useNavigate: () => mocks.navigate, +})); + +vi.mock("@/hooks/mutations/project-mutations", () => ({ + useCreateProject: () => ({ isPending: false, mutate: mocks.mutate }), +})); + +vi.mock("@/hooks/queries/host-queries", () => ({ + useHosts: () => ({ data: mocks.hosts }), +})); + +vi.mock("@/hooks/useLocalPathPicker", () => ({ + useLocalPathPicker: () => ({ + isAvailable: true, + hostId: "host_atum", + hostName: "atum", + openPicker: mocks.openPicker, + platform: "linux", + projectPathDialog: { + isOpen: false, + onClose: mocks.onClose, + onOpen: mocks.onOpen, + onOpenChange: mocks.onOpenChange, + target: null, + }, + submitProjectPath: vi.fn(), + }), +})); + +vi.mock("@/lib/root-compose-selection", () => ({ + useSetRootComposeProjectId: () => mocks.setRootComposeProjectId, +})); + +function host(id: string, name: string): Host { + return { + id, + name, + type: "persistent", + status: "connected", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 0, + updatedAt: 0, + }; +} + +beforeEach(() => { + mocks.hosts = [host("host_atum", "atum")]; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useQuickCreateProject", () => { + it("opens the dialog instead of the native primary-host picker when several machines exist", () => { + mocks.hosts = [host("host_atum", "atum"), host("host_thoth", "Thoth")]; + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.onOpen).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.openPicker).not.toHaveBeenCalled(); + expect(result.current.hosts.map((item) => item.id)).toEqual([ + "host_atum", + "host_thoth", + ]); + }); + + it("keeps the current picker behavior with one machine", () => { + const { result } = renderHook(() => useQuickCreateProject()); + + act(() => result.current.openCreateDialog()); + + expect(mocks.openPicker).toHaveBeenCalledWith({ kind: "create" }); + expect(mocks.onOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx index ed3955bcc..6a189bfbc 100644 --- a/apps/app/src/hooks/useQuickCreateProject.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.tsx @@ -6,9 +6,10 @@ import { type ReactNode, } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { deriveProjectNameFromPath } from "@bb/domain"; +import { deriveProjectNameFromPath, type Host } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { useCreateProject } from "@/hooks/mutations/project-mutations"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useLocalPathPicker, type LocalPathSubmitParams, @@ -36,15 +37,18 @@ export interface QuickCreateProjectController { platform: HostPlatform | null; hostId: string | null; hostName: string | null; + hosts: readonly Host[]; projectPathDialog: QuickCreateProjectDialogState; submitProjectPath: ProjectPathDialogSubmitHandler; } const quickCreateProjectContext = createContext(null); +const EMPTY_HOSTS: readonly Host[] = []; export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); + const hosts = useHosts().data ?? EMPTY_HOSTS; const navigate = useNavigate(); const location = useLocation(); const setRootComposeProjectId = useSetRootComposeProjectId(); @@ -81,8 +85,12 @@ export function useQuickCreateProject(): QuickCreateProjectController { }); const openCreateDialog = useCallback(() => { + if (hosts.length > 1) { + controller.projectPathDialog.onOpen({ kind: "create" }); + return; + } controller.openPicker({ kind: "create" }); - }, [controller]); + }, [controller, hosts.length]); return useMemo( () => ({ @@ -92,10 +100,11 @@ export function useQuickCreateProject(): QuickCreateProjectController { platform: controller.platform, hostId: controller.hostId, hostName: controller.hostName, + hosts, projectPathDialog: controller.projectPathDialog, submitProjectPath: controller.submitProjectPath, }), - [controller, isPending, openCreateDialog], + [controller, hosts, isPending, openCreateDialog], ); } diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index 8fafbabf4..eb0f79e8d 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -1,3 +1,5 @@ + + # Using bb on multiple devices There are two separate ways to use more than one device with bb: @@ -85,7 +87,9 @@ service. After it connects: -1. Add that machine's project path or clone source in project settings. +1. To create a project from that machine, choose New project, select the + machine, and browse to its folder. To map an existing project there instead, + add its path or clone source in project settings. 2. Select the machine when creating a thread, or use `bb thread spawn --machine ...`. 3. Inspect enrolled machines with `bb machine list`.