From 387afe4287bd47ef0baf56d0002ae28e407a1e48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 16:26:58 +0000 Subject: [PATCH] Optimize publish repository dialog state Co-authored-by: Julius Marminge --- .../components/GitActionsControl.browser.tsx | 177 ++++++++++++++++-- apps/web/src/components/GitActionsControl.tsx | 47 ++--- 2 files changed, 176 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 0a79f2f2cf3..ae54f2231e5 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -1,7 +1,8 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; const SHARED_THREAD_ID = ThreadId.make("thread-shared"); @@ -26,6 +27,8 @@ const { activeRunStackedActionDeferredRef, activeDraftThreadRef, hasServerThreadRef, + sourceControlDiscoveryRef, + vcsStatusRef, invalidateSourceControlStateSpy, refreshVcsStatusSpy, runStackedActionSpy, @@ -39,6 +42,26 @@ const { activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, activeDraftThreadRef: { current: null as unknown }, hasServerThreadRef: { current: true }, + sourceControlDiscoveryRef: { current: null as unknown }, + vcsStatusRef: { + current: { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/toast-scope", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }, + }, invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), @@ -64,6 +87,10 @@ vi.mock("~/editorPreferences", () => ({ openInPreferredEditor: vi.fn(), })); +vi.mock("~/lib/sourceControlDiscoveryState", () => ({ + useSourceControlDiscovery: vi.fn(() => sourceControlDiscoveryRef.current), +})); + vi.mock("~/lib/sourceControlActions", () => ({ invalidateSourceControlState: invalidateSourceControlStateSpy, useGitStackedAction: vi.fn(() => ({ @@ -97,23 +124,7 @@ vi.mock("~/lib/vcsStatusState", () => ({ refreshVcsStatus: refreshVcsStatusSpy, resetVcsStatusStateForTests: () => undefined, useVcsStatus: vi.fn(() => ({ - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, + data: vcsStatusRef.current, error: null, isPending: false, })), @@ -245,6 +256,77 @@ function findButtonByText(text: string): HTMLButtonElement | null { ) ?? null) as HTMLButtonElement | null; } +function findRadioByText(text: string): HTMLElement | null { + return (Array.from(document.querySelectorAll('[role="radio"]')).find((radio) => + radio.textContent?.includes(text), + ) ?? null) as HTMLElement | null; +} + +function setInputValue(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function createDefaultVcsStatus() { + return { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: BRANCH_NAME, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }; +} + +function createDefaultSourceControlDiscovery() { + return { + data: { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + status: "available", + version: Option.some("2.85.0"), + installHint: "Install GitHub CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("octo"), + host: Option.some("github.com"), + detail: Option.none(), + }, + }, + { + kind: "gitlab", + label: "GitLab", + status: "available", + version: Option.some("19.0.0"), + installHint: "Install GitLab CLI.", + detail: Option.none(), + auth: { + status: "authenticated", + account: Option.some("tanuki"), + host: Option.some("gitlab.com"), + detail: Option.none(), + }, + }, + ], + }, + error: null, + isPending: false, + }; +} + function Harness() { const [activeThreadRef, setActiveThreadRef] = useState( scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), @@ -264,12 +346,19 @@ function Harness() { } describe("GitActionsControl thread-scoped progress toast", () => { + beforeEach(() => { + vcsStatusRef.current = createDefaultVcsStatus(); + sourceControlDiscoveryRef.current = createDefaultSourceControlDiscovery(); + }); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); activeRunStackedActionDeferredRef.current = createDeferredPromise(); activeDraftThreadRef.current = null; hasServerThreadRef.current = true; + vcsStatusRef.current = createDefaultVcsStatus(); + sourceControlDiscoveryRef.current = createDefaultSourceControlDiscovery(); document.body.innerHTML = ""; }); @@ -335,6 +424,58 @@ describe("GitActionsControl thread-scoped progress toast", () => { } }); + it("derives publish provider and repository defaults while preserving user edits", async () => { + vcsStatusRef.current = { + ...createDefaultVcsStatus(), + hasPrimaryRemote: false, + hasUpstream: false, + aheadCount: 0, + }; + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { container: host }, + ); + + try { + findButtonByText("Publish repository")?.click(); + await Promise.resolve(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + "Publish repository", + ); + + findButtonByText("Next")?.click(); + await Promise.resolve(); + const repositoryInput = document.querySelector("#publish-repository-path"); + expect(repositoryInput?.value).toBe("octo/"); + + if (!repositoryInput) { + throw new Error("Repository input was not rendered."); + } + setInputValue(repositoryInput, "octo/demo"); + expect(repositoryInput.value).toBe("octo/demo"); + + findButtonByText("Back")?.click(); + await Promise.resolve(); + findRadioByText("GitLab")?.click(); + await Promise.resolve(); + findButtonByText("Next")?.click(); + await Promise.resolve(); + + expect(document.querySelector("#publish-repository-path")?.value).toBe( + "tanuki/", + ); + } finally { + await screen.unmount(); + host.remove(); + } + }); + it("debounces focus-driven git status refreshes", async () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index ef28523fe5a..f3e43e3e55b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -348,8 +348,9 @@ interface PublishRepositoryDialogProps { function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const navigate = useNavigate(); const sourceControlDiscovery = useSourceControlDiscovery(); - const [publishProvider, setPublishProvider] = useState("github"); - const [publishRepository, setPublishRepository] = useState(""); + const [requestedPublishProvider, setRequestedPublishProvider] = + useState("github"); + const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); const [publishVisibility, setPublishVisibility] = useState("private"); const [publishRemoteName, setPublishRemoteName] = useState("origin"); @@ -360,7 +361,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const [publishResult, setPublishResult] = useState( null, ); - const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const sourceControlScope = useMemo( () => ({ environmentId: props.environmentId, @@ -395,6 +395,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ]), ) as Record; }, [sourceControlDiscovery.data]); + const firstReadyPublishProvider = PUBLISH_PROVIDER_OPTIONS.find( + (option) => publishProviderReadiness[option.value].ready, + ); + const publishProvider = + publishProviderReadiness[requestedPublishProvider].ready || !firstReadyPublishProvider + ? requestedPublishProvider + : firstReadyPublishProvider.value; const hasReadyPublishProvider = useMemo( () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness[option.value].ready), [publishProviderReadiness], @@ -415,6 +422,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const publishRepositoryPrefill = publishAccountByProvider[publishProvider] ? `${publishAccountByProvider[publishProvider]}/` : ""; + const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); const publishHost = currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; @@ -426,13 +434,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { null, ] as const; - useEffect(() => { - if (!props.open || hasUserEditedPublishRepository) { - return; - } - setPublishRepository(publishRepositoryPrefill); - }, [hasUserEditedPublishRepository, props.open, publishRepositoryPrefill]); - const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; if (publishRepositoryAction.isPending) return false; @@ -443,21 +444,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return owner.length > 0 && name.length > 0; }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); - useEffect(() => { - if (!props.open) { - return; - } - if (publishProviderReadiness[publishProvider].ready) { - return; - } - const firstReadyProvider = PUBLISH_PROVIDER_OPTIONS.find( - (option) => publishProviderReadiness[option.value].ready, - ); - if (firstReadyProvider) { - setPublishProvider(firstReadyProvider.value); - } - }, [props.open, publishProvider, publishProviderReadiness]); - const submitPublishRepository = useCallback(() => { if (!canSubmitPublishRepository) { return; @@ -499,8 +485,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const resetState = useCallback(() => { setPublishRemoteName("origin"); - setPublishRepository(""); - setHasUserEditedPublishRepository(false); + setPublishRepositoryOverride(null); setPublishWizardStep(0); setPublishAdvancedOpen(false); setPublishError(null); @@ -593,7 +578,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishProvider(value as PublishProviderKind)} + onValueChange={(value) => { + setRequestedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > @@ -679,8 +667,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { name="publish-repository-path" value={publishRepository} onChange={(event) => { - setPublishRepository(event.target.value); - setHasUserEditedPublishRepository(true); + setPublishRepositoryOverride(event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter") {