Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/app/src/components/dialogs/ProjectPathDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => (
<button
type="button"
onClick={() =>
onDirectoryChange(
hostId === "host_kunst"
? "/Users/amadad/projects/reachy_mini"
: "/home/deploy/repos/givecare",
)
}
>
Choose folder on {hostId}
</button>
),
}));

function host(overrides: Partial<Host> & Pick<Host, "id" | "name">): 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(
<ProjectPathDialog
target={{ kind: "create" }}
platform="linux"
hostId={atum.id}
hostName={atum.name}
hosts={[atum, kunst, offline]}
onOpenChange={vi.fn()}
onSubmit={onSubmit}
/>,
);

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(
<ProjectPathDialog
target={{ kind: "create" }}
platform="linux"
hostId={atum.id}
hostName={atum.name}
hosts={[atum]}
onOpenChange={vi.fn()}
onSubmit={onSubmit}
/>,
);

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",
);
});
});
97 changes: 88 additions & 9 deletions apps/app/src/components/dialogs/ProjectPathDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 =
Expand All @@ -37,6 +40,7 @@ export type ProjectPathDialogTarget =
export type ProjectPathDialogSubmitHandler = (
target: ProjectPathDialogTarget,
path: string,
hostId: string | null,
) => Promise<void> | void;

interface ProjectPathDialogProps {
Expand All @@ -45,6 +49,7 @@ interface ProjectPathDialogProps {
platform: HostPlatform | null;
hostId: string | null;
hostName: string | null;
hosts?: readonly Host[];
onOpenChange: (open: boolean) => void;
onSubmit: ProjectPathDialogSubmitHandler;
}
Expand All @@ -55,6 +60,7 @@ export function ProjectPathDialog({
platform,
hostId,
hostName,
hosts,
onOpenChange,
onSubmit,
}: ProjectPathDialogProps) {
Expand All @@ -69,6 +75,7 @@ export function ProjectPathDialog({
platform={platform}
hostId={hostId}
hostName={hostName}
hosts={hosts}
onSubmit={onSubmit}
/>
) : null}
Expand All @@ -83,6 +90,7 @@ export interface ProjectPathDialogContentProps {
platform: HostPlatform | null;
hostId: string | null;
hostName: string | null;
hosts?: readonly Host[];
onSubmit: ProjectPathDialogSubmitHandler;
}

Expand Down Expand Up @@ -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 : "",
Expand All @@ -153,13 +182,13 @@ export function ProjectPathDialogContent({
const [validationMessage, setValidationMessage] = useState<string | null>(
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
Expand Down Expand Up @@ -195,28 +224,75 @@ export function ProjectPathDialogContent({
return;
}

void onSubmit(target, normalizedPath);
void onSubmit(target, normalizedPath, selectedHostId);
};

return (
<>
<DialogHeader>
<DialogTitle>{getDialogTitle(target.kind)}</DialogTitle>
<DialogDescription>
{hostId
{selectedHostId
? `Browse to the project folder${
hostName ? ` on ${hostName}` : ""
selectedHostName ? ` on ${selectedHostName}` : ""
}, or edit the path directly.`
: copy.description}
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{hostId ? (
{showMachinePicker ? (
<div
role="radiogroup"
aria-label="Machine"
className="grid max-h-36 gap-1 overflow-y-auto rounded-md border p-1"
>
{machineOptions?.map((host) => {
const connected = host.status === "connected";
const selected = host.id === selectedHostId;
return (
<label
key={host.id}
className={cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none ring-ring focus-within:ring-2",
selected
? "bg-state-active text-foreground"
: "text-muted-foreground hover:bg-state-hover hover:text-foreground",
pending || !connected
? "cursor-not-allowed opacity-50"
: "cursor-pointer",
)}
>
<input
type="radio"
name={`project-machine-${inputId}`}
aria-label={host.name}
checked={selected}
disabled={pending || !connected}
className="sr-only"
onChange={() => {
setSelectedHostId(host.id);
setBrowserDirectory(null);
}}
/>
<MachineStatusDot connected={connected} />
<span className="min-w-0 flex-1 truncate">{host.name}</span>
{!connected ? (
<span className="text-xs text-subtle-foreground">
Offline
</span>
) : null}
</label>
);
})}
</div>
) : null}
{selectedHostId ? (
<RemotePathBrowser
hostId={hostId}
key={selectedHostId}
hostId={selectedHostId}
initialPath={target.kind === "update" ? target.currentPath : null}
onDirectoryChange={setBrowserDirectory}
disabled={pending}
disabled={pending || !selectedHostConnected}
/>
) : (
<Input
Expand Down Expand Up @@ -248,7 +324,10 @@ export function ProjectPathDialogContent({
</div>
) : null}
<DialogFooter>
<Button type="submit" disabled={pending || !selectedPath}>
<Button
type="submit"
disabled={pending || !selectedPath || !selectedHostConnected}
>
{getDialogSubmitLabel(target.kind)}
</Button>
</DialogFooter>
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
14 changes: 9 additions & 5 deletions apps/app/src/hooks/useLocalPathPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down Expand Up @@ -118,8 +122,8 @@ export function useLocalPathPicker({
);

const submitProjectPath = useCallback<ProjectPathDialogSubmitHandler>(
(target, path) => {
submitPath(path, target);
(target, path, selectedHostId) => {
submitPath(path, target, selectedHostId);
},
[submitPath],
);
Expand Down
Loading