Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Theme } from "@radix-ui/themes";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";

const selectDirectoryQuery = vi.fn();
const addFolder = vi.fn().mockResolvedValue(undefined);

vi.mock("@renderer/trpc", () => ({
trpcClient: {
os: { selectDirectory: { query: () => selectDirectoryQuery() } },
},
}));

vi.mock("@features/folders/hooks/useFolders", () => ({
useFolders: () => ({
getRecentFolders: () => [],
getFolderDisplayName: () => null,
addFolder,
updateLastAccessed: vi.fn(),
getFolderByPath: vi.fn(),
}),
}));

vi.mock("@utils/logger", () => ({
logger: { scope: () => ({ error: vi.fn() }) },
}));

import { FolderPicker } from "./FolderPicker";

/** A promise we resolve by hand, to hold the picker open mid-flight. */
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}

function renderPicker() {
const onChange = vi.fn();
render(
<Theme>
<FolderPicker variant="field" value="" onChange={onChange} />
</Theme>,
);
return { onChange, trigger: screen.getByRole("button") };
}

describe("FolderPicker", () => {
afterEach(() => vi.clearAllMocks());

it("shows feedback synchronously while the dialog is open, then commits the path", async () => {
// The synchronous "Opening..." state both reassures the user and gives
// PostHog a DOM mutation, so the open native dialog stops being logged as a
// dead click.
const user = userEvent.setup();
const pending = deferred<string | null>();
selectDirectoryQuery.mockReturnValue(pending.promise);
const { onChange, trigger } = renderPicker();

await user.click(trigger);

expect(trigger).toHaveTextContent("Opening...");
expect(trigger).toBeDisabled();

pending.resolve("/Users/me/code/posthog");

await waitFor(() =>
expect(onChange).toHaveBeenCalledWith("/Users/me/code/posthog"),
);
expect(addFolder).toHaveBeenCalledTimes(1);
expect(trigger).not.toBeDisabled();
});

it("ignores re-clicks while a dialog is already open", async () => {
const user = userEvent.setup();
const pending = deferred<string | null>();
selectDirectoryQuery.mockReturnValue(pending.promise);
const { trigger } = renderPicker();

await user.click(trigger);
await user.click(trigger);

expect(selectDirectoryQuery).toHaveBeenCalledTimes(1);

pending.resolve(null);
await waitFor(() => expect(trigger).not.toBeDisabled());
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useFolders } from "@features/folders/hooks/useFolders";
import {
CaretDown,
CircleNotch,
Folder as FolderIcon,
FolderOpen,
GitBranch,
Expand All @@ -18,7 +19,7 @@ import { Flex, Text } from "@radix-ui/themes";
import { FIELD_TRIGGER_CLASS } from "@renderer/styles/fieldTrigger";
import { trpcClient } from "@renderer/trpc";
import { logger } from "@utils/logger";
import type { RefObject } from "react";
import { type RefObject, useState } from "react";

const log = logger.scope("folder-picker");

Expand Down Expand Up @@ -49,20 +50,26 @@ export function FolderPicker({
const displayValue = getFolderDisplayName(value);
const isField = variant === "field";

const [isOpening, setIsOpening] = useState(false);

const handleSelect = (path: string) => {
Comment thread
charlesvien marked this conversation as resolved.
onChange(path);
const folder = getFolderByPath(path);
if (folder) updateLastAccessed(folder.id);
};

const handleOpenFilePicker = async () => {
if (isOpening) return;
setIsOpening(true);
try {
const selectedPath = await trpcClient.os.selectDirectory.query();
if (!selectedPath) return;
await addFolder(selectedPath);
onChange(selectedPath);
} catch (error) {
log.error("Failed to open folder picker", { error });
} finally {
setIsOpening(false);
}
};

Expand All @@ -74,20 +81,31 @@ export function FolderPicker({
className="min-w-0 max-w-full truncate text-left font-medium text-(--gray-12)"
title={displayValue || undefined}
>
{displayValue || placeholder}
{isOpening ? "Opening..." : displayValue || placeholder}
</Text>
</Flex>
<CaretDown size={14} className="shrink-0 text-(--gray-9)" />
{isOpening ? (
<CircleNotch
size={14}
className="shrink-0 animate-spin text-(--gray-9)"
/>
) : (
<CaretDown size={14} className="shrink-0 text-(--gray-9)" />
)}
</>
);

const compactContent = (
<>
<FolderIcon size={14} weight="regular" className="shrink-0" />
<span className="max-w-[120px] truncate">
{displayValue || placeholder}
{isOpening ? "Opening..." : displayValue || placeholder}
</span>
<CaretDown size={10} weight="bold" className="text-muted-foreground" />
{isOpening ? (
<CircleNotch size={10} className="animate-spin text-muted-foreground" />
) : (
<CaretDown size={10} weight="bold" className="text-muted-foreground" />
)}
</>
);

Expand All @@ -97,6 +115,8 @@ export function FolderPicker({
type="button"
onClick={handleOpenFilePicker}
className={FIELD_TRIGGER_CLASS}
disabled={isOpening}
aria-busy={isOpening}
>
{fieldContent}
</button>
Expand All @@ -106,6 +126,8 @@ export function FolderPicker({
size="sm"
aria-label="Folder"
onClick={handleOpenFilePicker}
disabled={isOpening}
aria-busy={isOpening}
>
{compactContent}
</Button>
Expand Down
Loading