Skip to content
Draft
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
177 changes: 159 additions & 18 deletions apps/web/src/components/GitActionsControl.browser.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -26,6 +27,8 @@ const {
activeRunStackedActionDeferredRef,
activeDraftThreadRef,
hasServerThreadRef,
sourceControlDiscoveryRef,
vcsStatusRef,
invalidateSourceControlStateSpy,
refreshVcsStatusSpy,
runStackedActionSpy,
Expand All @@ -39,6 +42,26 @@ const {
activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
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),
Expand All @@ -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(() => ({
Expand Down Expand Up @@ -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,
})),
Expand Down Expand Up @@ -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),
Expand All @@ -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<never>();
activeDraftThreadRef.current = null;
hasServerThreadRef.current = true;
vcsStatusRef.current = createDefaultVcsStatus();
sourceControlDiscoveryRef.current = createDefaultSourceControlDiscovery();
document.body.innerHTML = "";
});

Expand Down Expand Up @@ -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(
<GitActionsControl
gitCwd={GIT_CWD}
activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
/>,
{ 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<HTMLInputElement>("#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<HTMLInputElement>("#publish-repository-path")?.value).toBe(
"tanuki/",
);
} finally {
await screen.unmount();
host.remove();
}
});

it("debounces focus-driven git status refreshes", async () => {
vi.useFakeTimers();

Expand Down
47 changes: 17 additions & 30 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,9 @@ interface PublishRepositoryDialogProps {
function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
const navigate = useNavigate();
const sourceControlDiscovery = useSourceControlDiscovery();
const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");
const [publishRepository, setPublishRepository] = useState("");
const [requestedPublishProvider, setRequestedPublishProvider] =
useState<PublishProviderKind>("github");
const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);
const [publishVisibility, setPublishVisibility] =
useState<SourceControlRepositoryVisibility>("private");
const [publishRemoteName, setPublishRemoteName] = useState("origin");
Expand All @@ -360,7 +361,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>(
null,
);
const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);
const sourceControlScope = useMemo(
() => ({
environmentId: props.environmentId,
Expand Down Expand Up @@ -395,6 +395,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
]),
) as Record<PublishProviderKind, { readonly ready: boolean; readonly hint: string | null }>;
}, [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],
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -499,8 +485,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {

const resetState = useCallback(() => {
setPublishRemoteName("origin");
setPublishRepository("");
setHasUserEditedPublishRepository(false);
setPublishRepositoryOverride(null);
setPublishWizardStep(0);
setPublishAdvancedOpen(false);
setPublishError(null);
Expand Down Expand Up @@ -593,7 +578,10 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
</span>
<RadioGroup
value={publishProvider}
onValueChange={(value) => 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"
>
Expand Down Expand Up @@ -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") {
Expand Down
Loading