fix(web): keep repository actions available (#3593)
This commit is contained in:
@@ -26,8 +26,8 @@ describe("BranchRefreshButton", () => {
|
||||
|
||||
const button = screen.getByTestId("repo-refresh-button");
|
||||
expect(button.getAttribute("aria-label")).toBe("Refresh repositories");
|
||||
expect(button.className).toContain("h-12");
|
||||
expect(button.className).toContain("w-12");
|
||||
expect(button.className).toContain("size-7");
|
||||
expect(button.className).toContain("max-md:size-11");
|
||||
});
|
||||
|
||||
it("refreshes on click", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
@@ -47,7 +48,7 @@ export function BranchRefreshButton({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
disabled={refreshing}
|
||||
className={`inline-flex ${touchTarget ? "h-12 w-12" : "h-6 w-6"} items-center justify-center rounded-md hover:bg-muted/40 ${
|
||||
className={`inline-flex ${touchTarget ? `${controlSizingClassName("icon")} max-md:min-h-12 max-md:min-w-12 [@media(pointer:coarse)]:min-h-12 [@media(pointer:coarse)]:min-w-12` : "h-6 w-6"} items-center justify-center rounded-md hover:bg-muted/40 ${
|
||||
hasError
|
||||
? "text-amber-500 hover:text-amber-600"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
|
||||
@@ -184,6 +184,59 @@ describe("CreateLocalRepositorySurface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateLocalRepositorySurface async creation", () => {
|
||||
it("delivers a delayed response to the current row handler", async () => {
|
||||
let complete!: (repository: Repository) => void;
|
||||
mocks.initialize.mockReturnValue(
|
||||
new Promise<Repository>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const previousHandler = vi.fn();
|
||||
const currentHandler = vi.fn();
|
||||
const props = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
workspaceId: "ws-1",
|
||||
executorSelection: directLocalSelection,
|
||||
onCreated: previousHandler,
|
||||
};
|
||||
const { rerender } = render(<CreateLocalRepositorySurface {...props} />);
|
||||
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
|
||||
target: { value: REPOSITORY_NAME },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
await waitFor(() => expect(mocks.initialize).toHaveBeenCalledOnce());
|
||||
rerender(<CreateLocalRepositorySurface {...props} onCreated={currentHandler} />);
|
||||
complete(createdRepository);
|
||||
await waitFor(() => expect(previousHandler).toHaveBeenCalledWith(createdRepository));
|
||||
expect(currentHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates for a multi-row task without a direct-local profile", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
const props = renderSurface({ context: "task-create-multi", executorSelection: null });
|
||||
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
|
||||
target: { value: REPOSITORY_NAME },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository));
|
||||
});
|
||||
|
||||
it("keeps the surface open when the completion is no longer current", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
const onOpenChange = vi.fn();
|
||||
const onCreated = vi.fn(() => false);
|
||||
renderSurface({ onOpenChange, onCreated });
|
||||
fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), {
|
||||
target: { value: REPOSITORY_NAME },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME }));
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith(createdRepository));
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateLocalRepositorySurface submission", () => {
|
||||
it("does not submit the parent task form", async () => {
|
||||
mocks.initialize.mockResolvedValue(createdRepository);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconFolder,
|
||||
@@ -60,8 +60,8 @@ export type CreateLocalRepositorySurfaceProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string | null;
|
||||
executorSelection: DirectLocalExecutorSelection | null;
|
||||
context?: "task-create" | "workspace";
|
||||
onCreated: (repository: Repository) => void;
|
||||
context?: "task-create" | "task-create-multi" | "workspace";
|
||||
onCreated: (repository: Repository) => boolean | void;
|
||||
};
|
||||
|
||||
// `context` and `requiresSwitch` stay logic; only the notices they select are
|
||||
@@ -71,7 +71,7 @@ function executorNotice(
|
||||
selection: DirectLocalExecutorSelection | null,
|
||||
context: NonNullable<CreateLocalRepositorySurfaceProps["context"]>,
|
||||
): string {
|
||||
if (context === "workspace") {
|
||||
if (!requiresDirectLocalExecutor(context)) {
|
||||
return t("common:createsAnEmptyGitRepository");
|
||||
}
|
||||
if (!selection) {
|
||||
@@ -178,7 +178,7 @@ function RepositoryFormDetails({
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
executorSelection || context === "workspace"
|
||||
executorSelection || !requiresDirectLocalExecutor(context)
|
||||
? "flex items-start gap-2 text-xs text-muted-foreground"
|
||||
: "flex items-start gap-2 text-xs text-destructive"
|
||||
}
|
||||
@@ -221,6 +221,13 @@ function CreateRepositoryFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function requiresDirectLocalExecutor(context: CreateLocalRepositorySurfaceProps["context"]) {
|
||||
return context === "task-create";
|
||||
}
|
||||
|
||||
// Keep async submission ownership beside the form so stale completions cannot
|
||||
// mutate a newer request's state.
|
||||
// eslint-disable-next-line max-lines-per-function -- form coordinates submission and its full layout
|
||||
function CreateRepositoryForm({
|
||||
open,
|
||||
workspaceId,
|
||||
@@ -235,6 +242,7 @@ function CreateRepositoryForm({
|
||||
const [editingParentPath, setEditingParentPath] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const submissionIdRef = useRef(0);
|
||||
const { listing, loading, error: listingError, load } = useDirectoryListing(open, "");
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -249,13 +257,15 @@ function CreateRepositoryForm({
|
||||
const nameError = validateLocalRepositoryName(name);
|
||||
const targetPath =
|
||||
parentPath && !nameError ? buildLocalRepositoryTargetPath(parentPath, name) : "";
|
||||
const executorReady = Boolean(executorSelection || context === "workspace");
|
||||
const executorReady = Boolean(executorSelection || !requiresDirectLocalExecutor(context));
|
||||
const canSubmit = !!workspaceId && executorReady && !!parentPath && !nameError && !submitting;
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!canSubmit || !workspaceId) return;
|
||||
const submissionId = submissionIdRef.current + 1;
|
||||
submissionIdRef.current = submissionId;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
@@ -263,13 +273,14 @@ function CreateRepositoryForm({
|
||||
name: name.trim(),
|
||||
parentPath,
|
||||
});
|
||||
onCreated(repository);
|
||||
const shouldDismiss = onCreated(repository);
|
||||
if (shouldDismiss === false) return;
|
||||
setName("");
|
||||
onDismiss();
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : t("common:failedToCreateRepository"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
if (submissionIdRef.current === submissionId) setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -62,6 +62,103 @@ function repository(id: string): Repository {
|
||||
}
|
||||
|
||||
describe("local repository creation selection", () => {
|
||||
it("accepts creation through the dialog handler when only a Worktree profile exists", () => {
|
||||
const fs = {
|
||||
repositories: [
|
||||
{ key: "first", repositoryId: "existing", branch: "main" },
|
||||
{ key: "second", branch: "" },
|
||||
],
|
||||
executorProfileId: WORKTREE_PROFILE_ID,
|
||||
updateRepository: vi.fn(),
|
||||
setExecutorId: vi.fn(),
|
||||
setExecutorProfileId: vi.fn(),
|
||||
} as unknown as Parameters<typeof useDialogHandlers>[0];
|
||||
const upsertWorkspaceRepository = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useDialogHandlers(fs, [], {
|
||||
workspaceId: "ws-1",
|
||||
upsertWorkspaceRepository,
|
||||
executors: [
|
||||
executor("worktree", "worktree", [{ id: WORKTREE_PROFILE_ID, name: "Worktree" }]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const created = repository("new");
|
||||
result.current.handleLocalRepositoryCreated("second", created);
|
||||
expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
|
||||
expect(fs.updateRepository).toHaveBeenCalledWith(
|
||||
"second",
|
||||
expect.objectContaining({ repositoryId: "new", branch: "main" }),
|
||||
);
|
||||
expect(fs.setExecutorId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{
|
||||
executorId: "local",
|
||||
executorProfileId: LOCAL_PROFILE_ID,
|
||||
executorProfileName: "Local",
|
||||
requiresSwitch: true,
|
||||
},
|
||||
])(
|
||||
"preserves executor and sibling rows when creating into a multi-row task (%j)",
|
||||
(executorSelection) => {
|
||||
const rows = [
|
||||
{ key: "first", repositoryId: "existing", branch: "develop" },
|
||||
{ key: "second", branch: "", branchPolicyId: "old-policy" },
|
||||
];
|
||||
const fs = {
|
||||
repositories: rows,
|
||||
updateRepository: (key: string, patch: object) =>
|
||||
Object.assign(rows.find((r) => r.key === key)!, patch),
|
||||
setExecutorId: vi.fn(),
|
||||
setExecutorProfileId: vi.fn(),
|
||||
};
|
||||
applyCreatedLocalRepository({
|
||||
fs,
|
||||
rowKey: "second",
|
||||
repository: repository("new"),
|
||||
workspaceId: "ws-1",
|
||||
upsertWorkspaceRepository: vi.fn(),
|
||||
executorSelection,
|
||||
});
|
||||
expect(rows[0]).toEqual({ key: "first", repositoryId: "existing", branch: "develop" });
|
||||
expect(rows[1]).toMatchObject({
|
||||
repositoryId: "new",
|
||||
branch: "main",
|
||||
branchPolicyId: undefined,
|
||||
});
|
||||
expect(fs.setExecutorId).not.toHaveBeenCalled();
|
||||
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("retains the created repository without changing another row after its target is removed", () => {
|
||||
const fs = {
|
||||
repositories: [{ key: "remaining", branch: "develop" }],
|
||||
updateRepository: vi.fn(),
|
||||
setExecutorId: vi.fn(),
|
||||
setExecutorProfileId: vi.fn(),
|
||||
};
|
||||
const upsertWorkspaceRepository = vi.fn();
|
||||
const created = repository("new");
|
||||
applyCreatedLocalRepository({
|
||||
fs,
|
||||
rowKey: "removed",
|
||||
repository: created,
|
||||
workspaceId: "ws-1",
|
||||
upsertWorkspaceRepository,
|
||||
executorSelection: null,
|
||||
});
|
||||
expect(upsertWorkspaceRepository).toHaveBeenCalledWith("ws-1", created);
|
||||
expect(fs.updateRepository).not.toHaveBeenCalled();
|
||||
expect(fs.setExecutorId).not.toHaveBeenCalled();
|
||||
expect(fs.setExecutorProfileId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("local repository executor selection", () => {
|
||||
it("keeps the selected profile when it already runs directly on the local host", () => {
|
||||
const selection = findDirectLocalExecutorProfile(
|
||||
[
|
||||
@@ -112,7 +209,12 @@ describe("local repository creation selection", () => {
|
||||
const created = repository("repo-new");
|
||||
|
||||
applyCreatedLocalRepository({
|
||||
fs: { updateRepository, setExecutorId, setExecutorProfileId },
|
||||
fs: {
|
||||
repositories: [{ key: "row-2", branch: "" }],
|
||||
updateRepository,
|
||||
setExecutorId,
|
||||
setExecutorProfileId,
|
||||
},
|
||||
rowKey: "row-2",
|
||||
repository: created,
|
||||
workspaceId: "ws-1",
|
||||
@@ -129,6 +231,7 @@ describe("local repository creation selection", () => {
|
||||
repositoryId: "repo-new",
|
||||
localPath: undefined,
|
||||
branch: "main",
|
||||
branchPolicyId: undefined,
|
||||
});
|
||||
expect(setExecutorId).toHaveBeenCalledWith("local");
|
||||
expect(setExecutorProfileId).toHaveBeenCalledWith(LOCAL_PROFILE_ID);
|
||||
|
||||
@@ -63,7 +63,7 @@ export function findDirectLocalExecutorProfile(
|
||||
|
||||
type CreatedLocalRepositoryForm = Pick<
|
||||
DialogFormState,
|
||||
"updateRepository" | "setExecutorId" | "setExecutorProfileId"
|
||||
"repositories" | "updateRepository" | "setExecutorId" | "setExecutorProfileId"
|
||||
>;
|
||||
|
||||
export function applyCreatedLocalRepository({
|
||||
@@ -79,20 +79,25 @@ export function applyCreatedLocalRepository({
|
||||
repository: Repository;
|
||||
workspaceId: string;
|
||||
upsertWorkspaceRepository: (workspaceId: string, repository: Repository) => void;
|
||||
executorSelection: DirectLocalExecutorSelection;
|
||||
executorSelection: DirectLocalExecutorSelection | null;
|
||||
}) {
|
||||
upsertWorkspaceRepository(workspaceId, repository);
|
||||
if (!fs.repositories.some((row) => row.key === rowKey)) return;
|
||||
const selection = fs.repositories.length === 1 ? executorSelection : null;
|
||||
fs.updateRepository(rowKey, {
|
||||
repositoryId: repository.id,
|
||||
localPath: undefined,
|
||||
branch: "main",
|
||||
branchPolicyId: undefined,
|
||||
});
|
||||
fs.setExecutorId(executorSelection.executorId);
|
||||
fs.setExecutorProfileId(executorSelection.executorProfileId);
|
||||
upsertWorkspaceRepository(workspaceId, repository);
|
||||
if (selection) {
|
||||
fs.setExecutorId(selection.executorId);
|
||||
fs.setExecutorProfileId(selection.executorProfileId);
|
||||
}
|
||||
syncTaskCreateLastUsed({
|
||||
repository_id: repository.id,
|
||||
branch: "main",
|
||||
executor_profile_id: executorSelection.executorProfileId,
|
||||
...(selection ? { executor_profile_id: selection.executorProfileId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -446,7 +451,7 @@ export function useDialogHandlers(
|
||||
);
|
||||
const handleLocalRepositoryCreated = useCallback(
|
||||
(rowKey: string, repository: Repository) => {
|
||||
if (!context?.workspaceId || !directLocalExecutorSelection) return;
|
||||
if (!context?.workspaceId) return;
|
||||
applyCreatedLocalRepository({
|
||||
fs,
|
||||
rowKey,
|
||||
@@ -455,7 +460,9 @@ export function useDialogHandlers(
|
||||
upsertWorkspaceRepository: context.upsertWorkspaceRepository,
|
||||
executorSelection: directLocalExecutorSelection,
|
||||
});
|
||||
clearFreshBranch(fs);
|
||||
if (fs.repositories.length === 1 && fs.repositories.some((row) => row.key === rowKey)) {
|
||||
clearFreshBranch(fs);
|
||||
}
|
||||
},
|
||||
[context, directLocalExecutorSelection, fs],
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CommandList,
|
||||
} from "@kandev/ui/command";
|
||||
import { BranchRefreshButton } from "@/components/branch-refresh-button";
|
||||
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import { usePillTooltipSuppression } from "@/hooks/use-pill-tooltip-suppression";
|
||||
import { useTooltipMountGate } from "@/hooks/use-tooltip-mount-gate";
|
||||
@@ -280,7 +281,7 @@ function PillPopoverContent({
|
||||
action.onSelect();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer"
|
||||
className={`${controlSizingClassName("icon")} max-md:min-h-12 max-md:min-w-12 [@media(pointer:coarse)]:min-h-12 [@media(pointer:coarse)]:min-w-12 inline-flex shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground cursor-pointer`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- repository creation regressions share this focused row fixture. */
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Branch, Repository, RepositoryBranchPolicy } from "@/lib/types/http";
|
||||
@@ -13,6 +14,12 @@ const mockBranches = vi.hoisted(
|
||||
}),
|
||||
);
|
||||
const mockPolicies = vi.hoisted((): { value: RepositoryBranchPolicy[] } => ({ value: [] }));
|
||||
type CreationSurfaceProps = {
|
||||
open: boolean;
|
||||
onCreated: (repository: Repository) => boolean | void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
const creationSurface = vi.hoisted(() => ({ props: null as CreationSurfaceProps | null }));
|
||||
|
||||
vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({
|
||||
useBranches: (source: unknown) => {
|
||||
@@ -49,10 +56,18 @@ vi.mock("@/components/repository-discovery-controls", () => ({
|
||||
RepositoryDiscoveryControls: () => <div data-testid="repository-discovery-controls" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/create-local-repository-surface", () => ({
|
||||
CreateLocalRepositorySurface: (props: CreationSurfaceProps) => {
|
||||
creationSurface.props = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { RepoChipsRow } from "./task-create-dialog-repo-chips";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
creationSurface.props = null;
|
||||
mockBranches.value = { branches: [], isLoading: false, isLoaded: false };
|
||||
mockPolicies.value = [];
|
||||
});
|
||||
@@ -193,6 +208,35 @@ describe("RepoChipsRow", () => {
|
||||
expect(screen.getAllByTestId("repo-chip")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("binds delayed creation to the originating row without closing a newer surface", () => {
|
||||
const onCreated = vi.fn();
|
||||
renderInProvider(
|
||||
<RepoChipsRow
|
||||
fs={makeFs({ repositories: [row({ key: "r0" }), row({ key: "r1" })] })}
|
||||
repositories={[makeRepo(REPO_FRONT_ID, "frontend")]}
|
||||
isTaskStarted={false}
|
||||
workspaceId="ws-1"
|
||||
onRowRepositoryChange={NOOP}
|
||||
onRowBranchChange={NOOP}
|
||||
localRepositoryCreation={{ executorSelection: null, onCreated }}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[0]);
|
||||
fireEvent.click(screen.getByTestId("create-local-repository-button"));
|
||||
const firstCompletion = creationSurface.props!.onCreated;
|
||||
creationSurface.props!.onOpenChange(false);
|
||||
|
||||
fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]);
|
||||
fireEvent.click(screen.getByTestId("create-local-repository-button"));
|
||||
expect(creationSurface.props!.open).toBe(true);
|
||||
|
||||
const result = firstCompletion(makeRepo("repo-new", "new"));
|
||||
expect(result).toBe(false);
|
||||
expect(onCreated).toHaveBeenCalledWith("r0", expect.objectContaining({ id: "repo-new" }));
|
||||
expect(creationSurface.props!.open).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the remote chips row in Remote mode (workspace chips suppressed)", () => {
|
||||
renderInProvider(
|
||||
<RepoChipsRow
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { IconGitFork } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -87,24 +87,6 @@ type RepoChipsRowProps = {
|
||||
};
|
||||
};
|
||||
|
||||
function useRepositoryCreationOpenChange(
|
||||
chipRowRef: { current: HTMLDivElement | null },
|
||||
creatingForRowKey: string | null,
|
||||
setCreatingForRowKey: (value: string | null) => void,
|
||||
) {
|
||||
return (open: boolean) => {
|
||||
if (open || creatingForRowKey === null) return;
|
||||
const rowKey = creatingForRowKey;
|
||||
setCreatingForRowKey(null);
|
||||
requestAnimationFrame(() => {
|
||||
const row = Array.from(
|
||||
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
|
||||
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
|
||||
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function applyRowBranchChange(
|
||||
fs: DialogFormState,
|
||||
isLocalExecutor: boolean | undefined,
|
||||
@@ -125,6 +107,59 @@ function updateSavedBaseBranch(fs: DialogFormState, key: string, value: string)
|
||||
fs.updateRepository(key, { baseBranch: value || undefined });
|
||||
}
|
||||
|
||||
type CreatingRepositoryTarget = { rowKey: string; requestId: number };
|
||||
|
||||
function useCreatingRepositoryTarget() {
|
||||
const [target, setTarget] = useState<CreatingRepositoryTarget | null>(null);
|
||||
const targetRef = useRef<CreatingRepositoryTarget | null>(null);
|
||||
const nextRequestId = useRef(0);
|
||||
const openForRow = useCallback((rowKey: string) => {
|
||||
const nextTarget = { rowKey, requestId: ++nextRequestId.current };
|
||||
targetRef.current = nextTarget;
|
||||
setTarget(nextTarget);
|
||||
}, []);
|
||||
const clear = useCallback(() => {
|
||||
const rowKey = targetRef.current?.rowKey ?? null;
|
||||
targetRef.current = null;
|
||||
setTarget(null);
|
||||
return rowKey;
|
||||
}, []);
|
||||
return { target, targetRef, openForRow, clear };
|
||||
}
|
||||
|
||||
function LocalRepositoryCreationSurface({
|
||||
creation,
|
||||
target,
|
||||
targetRef,
|
||||
workspaceId,
|
||||
multiRow,
|
||||
onOpenChange,
|
||||
}: {
|
||||
creation: RepoChipsRowProps["localRepositoryCreation"];
|
||||
target: CreatingRepositoryTarget | null;
|
||||
targetRef: { current: CreatingRepositoryTarget | null };
|
||||
workspaceId: string | null;
|
||||
multiRow: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
if (!creation) return null;
|
||||
return (
|
||||
<CreateLocalRepositorySurface
|
||||
open={target !== null}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
executorSelection={creation.executorSelection}
|
||||
context={multiRow ? "task-create-multi" : "task-create"}
|
||||
onCreated={(repository) => {
|
||||
if (!target) return false;
|
||||
const isCurrentRequest = targetRef.current?.requestId === target.requestId;
|
||||
creation.onCreated(target.rowKey, repository);
|
||||
return isCurrentRequest;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepoChipsRow({
|
||||
fs,
|
||||
repositories,
|
||||
@@ -149,12 +184,18 @@ export function RepoChipsRow({
|
||||
repositorySets,
|
||||
}: RepoChipsRowProps) {
|
||||
const chipRowRef = useRef<HTMLDivElement>(null);
|
||||
const [creatingForRowKey, setCreatingForRowKey] = useState<string | null>(null);
|
||||
const handleCreationOpenChange = useRepositoryCreationOpenChange(
|
||||
chipRowRef,
|
||||
creatingForRowKey,
|
||||
setCreatingForRowKey,
|
||||
);
|
||||
const { target, targetRef, openForRow, clear } = useCreatingRepositoryTarget();
|
||||
const handleCreationOpenChange = (open: boolean) => {
|
||||
if (open || target === null) return;
|
||||
const rowKey = clear();
|
||||
if (rowKey === null) return;
|
||||
requestAnimationFrame(() => {
|
||||
const row = Array.from(
|
||||
chipRowRef.current?.querySelectorAll<HTMLElement>("[data-repo-row-key]") ?? [],
|
||||
).find((candidate) => candidate.dataset.repoRowKey === rowKey);
|
||||
row?.querySelector<HTMLElement>("[data-testid='repo-chip-trigger']")?.focus();
|
||||
});
|
||||
};
|
||||
// Local executor branch behavior:
|
||||
// - chip is clickable (user can switch to any existing branch on disk)
|
||||
// - row.branch seeds from the workspace's current branch (currentLocalBranch)
|
||||
@@ -207,7 +248,7 @@ export function RepoChipsRow({
|
||||
onWorkspacePathChange={onWorkspacePathChange}
|
||||
lastUsedBranch={lastUsedBranch}
|
||||
userSettingsLoaded={userSettingsLoaded}
|
||||
onCreateRepository={localRepositoryCreation ? setCreatingForRowKey : undefined}
|
||||
onCreateRepository={localRepositoryCreation ? openForRow : undefined}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
/>
|
||||
@@ -226,17 +267,14 @@ export function RepoChipsRow({
|
||||
onToggleRemote={onToggleRemote}
|
||||
onToggleNoRepository={onToggleNoRepository}
|
||||
/>
|
||||
{localRepositoryCreation ? (
|
||||
<CreateLocalRepositorySurface
|
||||
open={creatingForRowKey !== null}
|
||||
onOpenChange={handleCreationOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
executorSelection={localRepositoryCreation.executorSelection}
|
||||
onCreated={(repository) => {
|
||||
if (creatingForRowKey) localRepositoryCreation.onCreated(creatingForRowKey, repository);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<LocalRepositoryCreationSurface
|
||||
creation={localRepositoryCreation}
|
||||
target={target}
|
||||
targetRef={targetRef}
|
||||
workspaceId={workspaceId}
|
||||
multiRow={fs.repositories.length > 1}
|
||||
onOpenChange={handleCreationOpenChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("WorkspaceRepoChips duplicate policy", () => {
|
||||
renderChips({ allowDuplicateRepositories: false });
|
||||
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]);
|
||||
|
||||
expect(screen.queryByText("Create new repository")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Create new repository" })).toBeNull();
|
||||
});
|
||||
|
||||
it("routes repository creation to the only row", () => {
|
||||
@@ -130,11 +130,30 @@ describe("WorkspaceRepoChips duplicate policy", () => {
|
||||
expect(onRefreshRepositories).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not expose repository creation for multi-repository tasks", () => {
|
||||
renderChips({ onCreateRepository: vi.fn() });
|
||||
it("offers creation and refresh from a second repository row", () => {
|
||||
const onCreateRepository = vi.fn();
|
||||
const onRefreshRepositories = vi.fn();
|
||||
renderChips({ onCreateRepository, onRefreshRepositories });
|
||||
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]);
|
||||
fireEvent.click(screen.getByTestId("repo-refresh-button"));
|
||||
expect(onRefreshRepositories).toHaveBeenCalledOnce();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search repositories..."), {
|
||||
target: { value: "no-matching-repository" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create new repository" }));
|
||||
expect(onCreateRepository).toHaveBeenCalledWith("r1");
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Create new repository")).toBeNull();
|
||||
it.each([0, 1])("keeps both actions in empty lists on row %i while refreshing", (rowIndex) => {
|
||||
renderChips({
|
||||
repositories: [],
|
||||
onCreateRepository: vi.fn(),
|
||||
onRefreshRepositories: vi.fn(),
|
||||
repositoriesRefreshing: true,
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[rowIndex]);
|
||||
expect(screen.getByTestId("repo-refresh-button").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Create new repository" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -141,10 +141,8 @@ export function WorkspaceRepoChips({
|
||||
onPolicySelected={onPolicySelected}
|
||||
showBranchPolicies={showBranchPolicies}
|
||||
showDiscoveryControls={showDiscoveryControls}
|
||||
onCreateRepository={
|
||||
rows.length === 1 && onCreateRepository ? () => onCreateRepository(row.key) : undefined
|
||||
}
|
||||
onRefreshRepositories={rows.length === 1 ? onRefreshRepositories : undefined}
|
||||
onCreateRepository={onCreateRepository ? () => onCreateRepository(row.key) : undefined}
|
||||
onRefreshRepositories={onRefreshRepositories}
|
||||
repositoriesRefreshing={repositoriesRefreshing}
|
||||
onRemove={() => onRemove(row.key)}
|
||||
/>
|
||||
|
||||
@@ -176,6 +176,6 @@ describe("WatcherRepositoryFields", () => {
|
||||
const optionClassName = screen.getByRole("option", { name: /^main local/ }).className;
|
||||
expect(optionClassName).toContain("max-md:min-h-12");
|
||||
expect(optionClassName).toContain("[@media(pointer:coarse)]:min-h-12");
|
||||
expect(screen.getByTestId("branch-refresh-button").className).toContain("h-12 w-12");
|
||||
expect(screen.getByTestId("branch-refresh-button").className).toContain("size-11");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { exerciseMultiRowCreation } from "./local-repository-multi-row-helpers";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
@@ -133,6 +134,19 @@ function taskIdFromUrl(page: Page): string {
|
||||
}
|
||||
|
||||
test.describe("Create task with a new local repository", () => {
|
||||
test("refreshes and creates from a second repository row", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
await openCreateTask(testPage);
|
||||
await exerciseMultiRowCreation(testPage, apiClient, {
|
||||
workspaceId: seedData.workspaceId,
|
||||
parentPath: backend.tmpDir,
|
||||
mobile: false,
|
||||
});
|
||||
});
|
||||
test("initializes, registers, selects, and starts from a real main repository", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
|
||||
export async function exerciseMultiRowCreation(
|
||||
page: Page,
|
||||
apiClient: ApiClient,
|
||||
options: { workspaceId: string; parentPath: string; mobile: boolean },
|
||||
) {
|
||||
const activate = (locator: Locator) => (options.mobile ? locator.tap() : locator.click());
|
||||
const name = options.mobile ? "mobile-second-repository" : "desktop-second-repository";
|
||||
await page.getByTestId("task-title-input").fill(name);
|
||||
await page.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
const executor = page.getByTestId("executor-profile-selector");
|
||||
await activate(executor);
|
||||
await activate(page.getByRole("option", { name: /Worktree/i }));
|
||||
const originalExecutor = await executor.textContent();
|
||||
const firstRow = page.getByTestId("repo-chip").nth(0);
|
||||
await expect(firstRow).toHaveAttribute("data-repository-id", /.+/);
|
||||
await expect(firstRow.getByTestId("branch-chip-trigger")).toBeEnabled();
|
||||
await expect(firstRow.getByTestId("branch-chip-trigger")).not.toHaveText("branch");
|
||||
const firstId = await firstRow.getAttribute("data-repository-id");
|
||||
expect(firstId).toBeTruthy();
|
||||
const firstBranch = await firstRow.getByTestId("branch-chip-trigger").textContent();
|
||||
await activate(page.getByTestId("add-repository"));
|
||||
const secondRow = page.getByTestId("repo-chip").nth(1);
|
||||
await activate(secondRow.getByTestId("repo-chip-trigger"));
|
||||
const refresh = page.getByTestId("repo-refresh-button");
|
||||
const create = page.getByTestId("create-local-repository-button");
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(create).toBeVisible();
|
||||
|
||||
const repositoriesPath = `/api/v1/workspaces/${options.workspaceId}/repositories`;
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshBarrier = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
await page.route(`**${repositoriesPath}`, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
await refreshBarrier;
|
||||
await route.fulfill({
|
||||
response,
|
||||
json: {
|
||||
...body,
|
||||
repositories: [
|
||||
...body.repositories,
|
||||
{ ...body.repositories[0], id: "refresh-only-option", name: "Refreshed option" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
const refreshed = waitForHttp(page, "GET", new RegExp(`${repositoriesPath}$`));
|
||||
try {
|
||||
await activate(refresh);
|
||||
await expect(refresh).toBeDisabled();
|
||||
await expect(create).toBeVisible();
|
||||
releaseRefresh();
|
||||
await refreshed;
|
||||
await expect(page.getByRole("option", { name: /Refreshed option/ })).toBeVisible();
|
||||
} finally {
|
||||
releaseRefresh();
|
||||
await refreshed;
|
||||
await page.unroute(`**${repositoriesPath}`);
|
||||
}
|
||||
await expect(firstRow).toHaveAttribute("data-repository-id", firstId!);
|
||||
await page.getByPlaceholder("Search repositories...").fill("no-matching-option");
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(create).toBeVisible();
|
||||
for (const control of [refresh, create]) {
|
||||
const box = await control.boundingBox();
|
||||
if (options.mobile) {
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44);
|
||||
} else {
|
||||
expect(box!.height).toBeCloseTo(28, 0);
|
||||
expect(box!.width).toBeCloseTo(28, 0);
|
||||
}
|
||||
}
|
||||
await activate(create);
|
||||
const surface = page.getByTestId(
|
||||
options.mobile ? "create-local-repository-drawer" : "create-local-repository-dialog",
|
||||
);
|
||||
await expect(surface).toBeVisible();
|
||||
await surface.getByRole("textbox", { name: "Repository name" }).fill(name);
|
||||
await surface.getByRole("textbox", { name: "Parent directory" }).fill(options.parentPath);
|
||||
const initialized = waitForHttp(page, "POST", /\/repositories\/initialize-local$/);
|
||||
await activate(surface.getByRole("button", { name: "Create repository" }));
|
||||
await initialized;
|
||||
await expect(surface).not.toBeVisible();
|
||||
await expect(secondRow.getByTestId("repo-chip-trigger")).toContainText(name);
|
||||
await expect(secondRow.getByTestId("branch-chip-trigger")).toContainText("main");
|
||||
await expect(firstRow).toHaveAttribute("data-repository-id", firstId!);
|
||||
await expect(firstRow.getByTestId("branch-chip-trigger")).toHaveText(firstBranch!);
|
||||
await expect(executor).toHaveText(originalExecutor!);
|
||||
const secondId = await secondRow.getAttribute("data-repository-id");
|
||||
await activate(secondRow.getByTestId("repo-chip-trigger"));
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(create).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(false);
|
||||
const taskCreated = page.waitForResponse(
|
||||
(r) => r.url().endsWith("/api/v1/tasks") && r.request().method() === "POST",
|
||||
);
|
||||
await activate(page.getByTestId("submit-start-agent"));
|
||||
const response = await taskCreated;
|
||||
expect(response.ok()).toBe(true);
|
||||
const task = await response.json();
|
||||
const taskRepositories = (await apiClient.getTask(task.id)).repositories;
|
||||
expect(taskRepositories).toHaveLength(2);
|
||||
expect(taskRepositories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ repository_id: firstId }),
|
||||
expect.objectContaining({ repository_id: secondId, base_branch: "main" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(() => apiClient.getTaskEnvironment(task.id))
|
||||
.toMatchObject({ executor_type: "worktree" });
|
||||
const repositories = await apiClient.rawRequest("GET", repositoriesPath);
|
||||
expect((await repositories.json()).repositories).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: secondId, local_path: path.join(options.parentPath, name) }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
import { exerciseMultiRowCreation } from "./local-repository-multi-row-helpers";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
@@ -178,6 +179,19 @@ function expectMainRepository(repositoryPath: string): void {
|
||||
}
|
||||
|
||||
test.describe("Create task with a new local repository on mobile", () => {
|
||||
test("refreshes and creates from a second repository row", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
await openCreateTask(testPage);
|
||||
await exerciseMultiRowCreation(testPage, apiClient, {
|
||||
workspaceId: seedData.workspaceId,
|
||||
parentPath: backend.tmpDir,
|
||||
mobile: true,
|
||||
});
|
||||
});
|
||||
test("creates and selects the repository in a contained, scrollable drawer", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -100,6 +100,24 @@ describe("useRepositories", () => {
|
||||
expect(mockSetRepositories).toHaveBeenCalledWith("ws-1", [{ id: "r1", name: "Repo One" }]);
|
||||
});
|
||||
|
||||
it("keeps a cached workspace loading until its refresh completes", async () => {
|
||||
setup(true);
|
||||
let complete!: (response: { repositories: Repos }) => void;
|
||||
mockListRepositories.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, rerender } = renderHook(() => useRepositories("ws-1"));
|
||||
const pending = result.current.refresh();
|
||||
mockState.repositories.loadingByWorkspaceId["ws-1"] = true;
|
||||
rerender();
|
||||
expect(mockSetRepositoriesLoading).not.toHaveBeenCalledWith("ws-1", false);
|
||||
complete({ repositories: [{ id: "r2", name: "Refreshed" }] });
|
||||
await pending;
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
|
||||
});
|
||||
|
||||
it("keeps cached repositories when a manual refresh fails", async () => {
|
||||
setup(/* loaded */ true);
|
||||
mockListRepositories.mockRejectedValue(new Error("Network unavailable"));
|
||||
@@ -112,3 +130,48 @@ describe("useRepositories", () => {
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRepositories request ownership", () => {
|
||||
beforeEach(() => {
|
||||
mockListRepositories.mockResolvedValue({ repositories: [{ id: "r1", name: "Repo One" }] });
|
||||
});
|
||||
|
||||
it("clears loading when a force refresh is cancelled", async () => {
|
||||
setup(/* loaded */ true);
|
||||
let complete!: (response: { repositories: Repos }) => void;
|
||||
mockListRepositories.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useRepositories("ws-1", true, true));
|
||||
await waitFor(() => expect(mockSetRepositoriesLoading).toHaveBeenCalledWith("ws-1", true));
|
||||
unmount();
|
||||
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false);
|
||||
complete({ repositories: [{ id: "stale", name: "Stale" }] });
|
||||
await Promise.resolve();
|
||||
expect(mockSetRepositories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps loading while another workspace request is active", async () => {
|
||||
setup(/* loaded */ true);
|
||||
let completeFirst!: (response: { repositories: Repos }) => void;
|
||||
let completeSecond!: (response: { repositories: Repos }) => void;
|
||||
mockListRepositories
|
||||
.mockReturnValueOnce(new Promise((resolve) => (completeFirst = resolve)))
|
||||
.mockReturnValueOnce(new Promise((resolve) => (completeSecond = resolve)));
|
||||
|
||||
const first = renderHook(() => useRepositories("ws-1", true, true));
|
||||
const second = renderHook(() => useRepositories("ws-1", true, true));
|
||||
await waitFor(() => expect(mockListRepositories).toHaveBeenCalledTimes(2));
|
||||
first.unmount();
|
||||
expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", true);
|
||||
|
||||
completeSecond({ repositories: [{ id: "fresh", name: "Fresh" }] });
|
||||
await waitFor(() => expect(mockSetRepositoriesLoading).toHaveBeenLastCalledWith("ws-1", false));
|
||||
completeFirst({ repositories: [{ id: "stale", name: "Stale" }] });
|
||||
second.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,28 @@ import { listRepositories } from "@/lib/api";
|
||||
|
||||
const EMPTY_REPOSITORIES: Repository[] = [];
|
||||
const REPOSITORY_LIST_RETRY_DELAYS_MS = [100, 250, 500, 1_000] as const;
|
||||
const activeRepositoryRequests = new Map<string, number>();
|
||||
|
||||
function beginRepositoryRequest(
|
||||
workspaceId: string,
|
||||
setRepositoriesLoading: (workspaceId: string, loading: boolean) => void,
|
||||
): () => void {
|
||||
activeRepositoryRequests.set(workspaceId, (activeRepositoryRequests.get(workspaceId) ?? 0) + 1);
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const remaining = (activeRepositoryRequests.get(workspaceId) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
activeRepositoryRequests.set(workspaceId, remaining);
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
} else {
|
||||
activeRepositoryRequests.delete(workspaceId);
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function listRepositoriesUntilSettled(workspaceId: string) {
|
||||
for (const retryDelayMs of REPOSITORY_LIST_RETRY_DELAYS_MS) {
|
||||
@@ -47,31 +69,24 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled || !workspaceId) return;
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
|
||||
try {
|
||||
const response = await listRepositoriesUntilSettled(workspaceId);
|
||||
setRepositories(workspaceId, response.repositories);
|
||||
} catch {
|
||||
// Keep the existing cached repositories when a manual refresh fails.
|
||||
} finally {
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
releaseRequest();
|
||||
}
|
||||
}, [enabled, setRepositories, setRepositoriesLoading, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspaceId) return;
|
||||
if (isLoaded && isLoading) {
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
}
|
||||
}, [enabled, isLoaded, isLoading, setRepositoriesLoading, workspaceId]);
|
||||
|
||||
// Force-refresh: pull a fresh list once per workspace, bypassing the
|
||||
// isLoaded cache. forcedRef is set only on success so a failed fetch retries.
|
||||
useEffect(() => {
|
||||
if (!enabled || !workspaceId || !forceRefresh) return;
|
||||
if (forcedRef.current === workspaceId) return;
|
||||
let cancelled = false;
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
|
||||
listRepositoriesUntilSettled(workspaceId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
@@ -81,12 +96,10 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
.catch(() => {
|
||||
// Leave forcedRef unset so the next mount retries; keep cached repos.
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
});
|
||||
.finally(releaseRequest);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
releaseRequest();
|
||||
};
|
||||
}, [enabled, forceRefresh, workspaceId, setRepositories, setRepositoriesLoading]);
|
||||
|
||||
@@ -94,7 +107,7 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
if (!enabled || !workspaceId || forceRefresh) return;
|
||||
if (isLoaded) return;
|
||||
let cancelled = false;
|
||||
setRepositoriesLoading(workspaceId, true);
|
||||
const releaseRequest = beginRepositoryRequest(workspaceId, setRepositoriesLoading);
|
||||
listRepositoriesUntilSettled(workspaceId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
@@ -104,12 +117,10 @@ export function useRepositories(workspaceId: string | null, enabled = true, forc
|
||||
// Keep the cache unloaded after a failed request. The next dialog
|
||||
// mount can retry instead of treating an empty fallback as real.
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setRepositoriesLoading(workspaceId, false);
|
||||
});
|
||||
.finally(releaseRequest);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
releaseRequest();
|
||||
};
|
||||
}, [enabled, forceRefresh, isLoaded, setRepositories, setRepositoriesLoading, workspaceId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user