From 0349f106140793c6cdee97631484ca7827176139 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 3 Jun 2026 07:50:28 -0700 Subject: [PATCH] feat(folder-picker): show loading state while opening folder picker Generated-By: PostHog Code Task-Id: e6034585-4d15-44f5-acdb-20fc8c001a07 --- .../components/FolderPicker.test.tsx | 90 +++++++++++++++++++ .../folder-picker/components/FolderPicker.tsx | 32 +++++-- 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 apps/code/src/renderer/features/folder-picker/components/FolderPicker.test.tsx diff --git a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.test.tsx b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.test.tsx new file mode 100644 index 0000000000..6f57063d3b --- /dev/null +++ b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.test.tsx @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function renderPicker() { + const onChange = vi.fn(); + render( + + + , + ); + 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(); + 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(); + 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()); + }); +}); diff --git a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx index 095d7c32f7..dd6cdf94b5 100644 --- a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx @@ -1,6 +1,7 @@ import { useFolders } from "@features/folders/hooks/useFolders"; import { CaretDown, + CircleNotch, Folder as FolderIcon, FolderOpen, GitBranch, @@ -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"); @@ -49,6 +50,8 @@ export function FolderPicker({ const displayValue = getFolderDisplayName(value); const isField = variant === "field"; + const [isOpening, setIsOpening] = useState(false); + const handleSelect = (path: string) => { onChange(path); const folder = getFolderByPath(path); @@ -56,6 +59,8 @@ export function FolderPicker({ }; const handleOpenFilePicker = async () => { + if (isOpening) return; + setIsOpening(true); try { const selectedPath = await trpcClient.os.selectDirectory.query(); if (!selectedPath) return; @@ -63,6 +68,8 @@ export function FolderPicker({ onChange(selectedPath); } catch (error) { log.error("Failed to open folder picker", { error }); + } finally { + setIsOpening(false); } }; @@ -74,10 +81,17 @@ 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} - + {isOpening ? ( + + ) : ( + + )} ); @@ -85,9 +99,13 @@ export function FolderPicker({ <> - {displayValue || placeholder} + {isOpening ? "Opening..." : displayValue || placeholder} - + {isOpening ? ( + + ) : ( + + )} ); @@ -97,6 +115,8 @@ export function FolderPicker({ type="button" onClick={handleOpenFilePicker} className={FIELD_TRIGGER_CLASS} + disabled={isOpening} + aria-busy={isOpening} > {fieldContent} @@ -106,6 +126,8 @@ export function FolderPicker({ size="sm" aria-label="Folder" onClick={handleOpenFilePicker} + disabled={isOpening} + aria-busy={isOpening} > {compactContent}