mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -328,6 +328,12 @@ interface PairingDaemonClient {
|
|||||||
|
|
||||||
async function createFakeEditorBin(): Promise<string> {
|
async function createFakeEditorBin(): Promise<string> {
|
||||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||||
|
let realGhPath = "";
|
||||||
|
try {
|
||||||
|
realGhPath = execSync("which gh").toString().trim();
|
||||||
|
} catch {
|
||||||
|
// The local PR fixture below remains usable without a system gh binary.
|
||||||
|
}
|
||||||
|
|
||||||
const fakeEditorSource = `#!/usr/bin/env node
|
const fakeEditorSource = `#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -349,6 +355,70 @@ if (recordPath) {
|
|||||||
await chmod(editorPath, 0o755);
|
await chmod(editorPath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fakeGhPath = path.join(binDir, "gh");
|
||||||
|
const fakeGhSource = `#!/usr/bin/env node
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||||
|
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"]
|
||||||
|
}).stdout?.trim();
|
||||||
|
|
||||||
|
if (origin === fixtureRemote) {
|
||||||
|
const command = args.slice(0, 2).join(" ");
|
||||||
|
if (command === "auth status") process.exit(0);
|
||||||
|
if (command === "repo view") {
|
||||||
|
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "issue list") {
|
||||||
|
process.stdout.write("[]");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "pr list" || command === "pr view") {
|
||||||
|
const pr = {
|
||||||
|
number: 1,
|
||||||
|
title: "Use pasted PR as start ref",
|
||||||
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||||
|
state: "OPEN",
|
||||||
|
body: null,
|
||||||
|
labels: [],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "pr-branch-1",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z"
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
data: { repository: { pullRequest: {
|
||||||
|
number: 1,
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "pr-branch-1",
|
||||||
|
isCrossRepository: false,
|
||||||
|
headRepositoryOwner: { login: "paseo-e2e" },
|
||||||
|
headRepository: {
|
||||||
|
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||||
|
url: fixtureRemote
|
||||||
|
}
|
||||||
|
} } }
|
||||||
|
}));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||||
|
if (!realGhPath) process.exit(127);
|
||||||
|
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
`;
|
||||||
|
await writeFile(fakeGhPath, fakeGhSource);
|
||||||
|
await chmod(fakeGhPath, 0o755);
|
||||||
|
|
||||||
return binDir;
|
return binDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export function hasGithubAuth(): boolean {
|
export function hasGithubAuth(): boolean {
|
||||||
@@ -59,6 +59,12 @@ export interface GhDefaultBranchClone {
|
|||||||
cleanup(): Promise<void>;
|
cleanup(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalGhPrFixture {
|
||||||
|
pr: GhPrFixture;
|
||||||
|
mainCheckout: GhDefaultBranchClone;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||||
return execFileSync("gh", args, {
|
return execFileSync("gh", args, {
|
||||||
cwd: opts?.cwd,
|
cwd: opts?.cwd,
|
||||||
@@ -281,3 +287,57 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||||
|
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||||
|
const basePath = path.join(fixtureRoot, "base");
|
||||||
|
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||||
|
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||||
|
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||||
|
await mkdir(basePath);
|
||||||
|
|
||||||
|
git(["init", "-b", "main"], basePath);
|
||||||
|
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||||
|
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||||
|
git(["config", "commit.gpgsign", "false"], basePath);
|
||||||
|
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||||
|
git(["add", "README.md"], basePath);
|
||||||
|
git(["commit", "-m", "Initial commit"], basePath);
|
||||||
|
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||||
|
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||||
|
git(["add", "pr-1.txt"], basePath);
|
||||||
|
git(["commit", "-m", "Add PR 1"], basePath);
|
||||||
|
git(["checkout", "main"], basePath);
|
||||||
|
|
||||||
|
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||||
|
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||||
|
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||||
|
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||||
|
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pr: {
|
||||||
|
number: 1,
|
||||||
|
title: "Use pasted PR as start ref",
|
||||||
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||||
|
branch: "pr-branch-1",
|
||||||
|
localPath: basePath,
|
||||||
|
},
|
||||||
|
mainCheckout: {
|
||||||
|
path: checkoutPath,
|
||||||
|
cleanup: async () => {},
|
||||||
|
},
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(fixtureRoot, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
import { connectDaemonClient } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
@@ -290,6 +290,13 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
|||||||
await branchRow.click();
|
await branchRow.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||||
|
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||||
|
await searchInput.fill(name);
|
||||||
|
await selectBranchInPicker(page, name);
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -351,6 +358,18 @@ export async function expectComposerGithubAttachmentPill(
|
|||||||
await expect(pills.first()).toContainText(input.title);
|
await expect(pills.first()).toContainText(input.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pasteGithubPrUrl(
|
||||||
|
page: Page,
|
||||||
|
context: BrowserContext,
|
||||||
|
url: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
|
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||||
|
await composer.focus();
|
||||||
|
await page.keyboard.press("Control+V");
|
||||||
|
}
|
||||||
|
|
||||||
export async function assertNewWorkspaceSidebarAndHeader(
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import {
|
|||||||
expectPickerOpen,
|
expectPickerOpen,
|
||||||
expectPickerSelected,
|
expectPickerSelected,
|
||||||
expectStartingRefPickerTriggerPr,
|
expectStartingRefPickerTriggerPr,
|
||||||
|
fillNewWorkspaceDraft,
|
||||||
openGlobalNewWorkspaceComposer,
|
openGlobalNewWorkspaceComposer,
|
||||||
openBranchPicker,
|
openBranchPicker,
|
||||||
openNewWorkspaceComposer,
|
openNewWorkspaceComposer,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
openStartingRefPicker,
|
openStartingRefPicker,
|
||||||
|
pasteGithubPrUrl,
|
||||||
|
searchAndSelectBranchInPicker,
|
||||||
selectBranchInPicker,
|
selectBranchInPicker,
|
||||||
selectGitHubPrInPicker,
|
selectGitHubPrInPicker,
|
||||||
selectPickerOptionByKeyboard,
|
selectPickerOptionByKeyboard,
|
||||||
@@ -30,9 +33,11 @@ import {
|
|||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
|
createLocalGithubPrFixture,
|
||||||
cloneGithubRepoDefaultBranchOnly,
|
cloneGithubRepoDefaultBranchOnly,
|
||||||
createTempGithubRepo,
|
createTempGithubRepo,
|
||||||
hasGithubAuth,
|
hasGithubAuth,
|
||||||
|
type LocalGhPrFixture,
|
||||||
} from "./helpers/github-fixtures";
|
} from "./helpers/github-fixtures";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
@@ -191,6 +196,7 @@ test.describe("New workspace flow", () => {
|
|||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
const createdWorktreeDirectories = new Set<string>();
|
const createdWorktreeDirectories = new Set<string>();
|
||||||
|
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||||
|
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
@@ -212,6 +218,13 @@ test.describe("New workspace flow", () => {
|
|||||||
await client?.close().catch(() => undefined);
|
await client?.close().catch(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
for (const fixture of localGithubFixtures) {
|
||||||
|
await fixture.cleanup();
|
||||||
|
}
|
||||||
|
localGithubFixtures.clear();
|
||||||
|
});
|
||||||
|
|
||||||
test("adds a project from the selected empty host", async ({ page }) => {
|
test("adds a project from the selected empty host", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||||
const primaryServerId = getServerId();
|
const primaryServerId = getServerId();
|
||||||
@@ -807,6 +820,100 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const fixture = await createLocalGithubPrFixture();
|
||||||
|
localGithubFixtures.add(fixture);
|
||||||
|
const { pr, mainCheckout } = fixture;
|
||||||
|
|
||||||
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await selectBranchInPicker(page, "main");
|
||||||
|
|
||||||
|
await pasteGithubPrUrl(page, context, pr.url);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||||
|
await expectComposerGithubAttachmentPill(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
});
|
||||||
|
await expectStartingRefPickerTriggerPr(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
headRef: pr.branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitNewWorkspaceWithoutPrompt(page);
|
||||||
|
|
||||||
|
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
client,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
|
|
||||||
|
const branchInfo = await readWorktreeBranchInfo({
|
||||||
|
worktreePath: worktree.workspaceDirectory,
|
||||||
|
});
|
||||||
|
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||||
|
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const fixture = await createLocalGithubPrFixture();
|
||||||
|
localGithubFixtures.add(fixture);
|
||||||
|
const { pr, mainCheckout } = fixture;
|
||||||
|
|
||||||
|
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await selectWorkspaceIsolation(page, "worktree");
|
||||||
|
await pasteGithubPrUrl(page, context, pr.url);
|
||||||
|
await expectStartingRefPickerTriggerPr(page, {
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
headRef: pr.branch,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await searchAndSelectBranchInPicker(page, "main");
|
||||||
|
await expectPickerSelected(page, "main");
|
||||||
|
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||||
|
await expectPickerSelected(page, "main");
|
||||||
|
await submitNewWorkspaceWithoutPrompt(page);
|
||||||
|
|
||||||
|
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId: getServerId(),
|
||||||
|
client,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
|
|
||||||
|
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ interface SearchCall {
|
|||||||
|
|
||||||
interface HarnessInput {
|
interface HarnessInput {
|
||||||
initialAttachments?: UserComposerAttachment[];
|
initialAttachments?: UserComposerAttachment[];
|
||||||
|
initialCwd?: string;
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
|
onPullRequestDetected?: () => void;
|
||||||
remote?: string | null;
|
remote?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,14 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -87,6 +97,8 @@ function createWrapper() {
|
|||||||
|
|
||||||
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||||
const [text, setText] = useState(input.initialText ?? "");
|
const [text, setText] = useState(input.initialText ?? "");
|
||||||
|
const [searchClient, setSearchClient] = useState(client);
|
||||||
|
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
|
||||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||||
input.initialAttachments ?? [],
|
input.initialAttachments ?? [],
|
||||||
);
|
);
|
||||||
@@ -94,18 +106,22 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
|||||||
text,
|
text,
|
||||||
remoteUrl: input.remote ?? remoteUrl,
|
remoteUrl: input.remote ?? remoteUrl,
|
||||||
attachments,
|
attachments,
|
||||||
client,
|
client: searchClient,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
cwd,
|
cwd: workingDirectory,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
|
onPullRequestDetected: input.onPullRequestDetected,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
setText,
|
setText,
|
||||||
|
setSearchClient,
|
||||||
|
setWorkingDirectory,
|
||||||
attachments,
|
attachments,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
|
isResolving: autoAttach.isResolving,
|
||||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -121,14 +137,20 @@ describe("useComposerGithubAutoAttach", () => {
|
|||||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = createSearchClient([pr101]);
|
const client = createSearchClient([pr101]);
|
||||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
const onPullRequestDetected = vi.fn();
|
||||||
|
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||||
});
|
});
|
||||||
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
|
||||||
await flushDebounce();
|
await flushDebounce();
|
||||||
|
|
||||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -208,4 +230,115 @@ describe("useComposerGithubAutoAttach", () => {
|
|||||||
]);
|
]);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stays resolving while overlapping lookups share a ref", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const firstLookup = deferred<ForgeSearchPayload>();
|
||||||
|
const secondLookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText(
|
||||||
|
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Still https://github.com/acme/paseo/pull/202");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
firstLookup.resolve(githubPayload([], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
secondLookup.resolve(githubPayload([], "search-202"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops resolving when an in-flight PR URL is removed", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isResolving).toBe(false);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a lookup that finishes after the target changes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const client: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setWorkingDirectory("/other-repo");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
await act(async () => {
|
||||||
|
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.attachments).toEqual([]);
|
||||||
|
expect(client.searchForge).toHaveBeenCalledTimes(1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a lookup after the transport client is replaced for the same target", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const lookup = deferred<ForgeSearchPayload>();
|
||||||
|
const firstClient: ForgeSearchClient = {
|
||||||
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
|
};
|
||||||
|
const replacementClient = createSearchClient([pr101]);
|
||||||
|
const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
act(() => {
|
||||||
|
result.current.setSearchClient(replacementClient);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
|
expect(replacementClient.calls).toEqual([]);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
@@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
supportsForgeSearch?: boolean;
|
supportsForgeSearch?: boolean;
|
||||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||||
|
onPullRequestDetected?: () => void;
|
||||||
|
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposerGithubAutoAttachResult {
|
interface ComposerGithubAutoAttachResult {
|
||||||
|
isResolving: boolean;
|
||||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach(
|
|||||||
const latestRef = useRef(params);
|
const latestRef = useRef(params);
|
||||||
const removedRefKeysRef = useRef(new Set<string>());
|
const removedRefKeysRef = useRef(new Set<string>());
|
||||||
const pendingRefKeysRef = useRef(new Set<string>());
|
const pendingRefKeysRef = useRef(new Set<string>());
|
||||||
|
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||||
|
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
||||||
|
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
latestRef.current = params;
|
latestRef.current = params;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
suppressRefsCarriedAcrossTargets({
|
||||||
|
params: latestRef.current,
|
||||||
|
previousTargetRef,
|
||||||
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
|
});
|
||||||
|
notifyNewPullRequestRefs({
|
||||||
|
params: latestRef.current,
|
||||||
|
presentPullRequestKeysRef,
|
||||||
|
});
|
||||||
const refs = refsReadyForLookup({
|
const refs = refsReadyForLookup({
|
||||||
params: latestRef.current,
|
params: latestRef.current,
|
||||||
removedRefKeys: removedRefKeysRef.current,
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
@@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refKeys = refs.map(githubRefKey);
|
||||||
|
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
||||||
|
let resolvingReleased = false;
|
||||||
|
const releaseResolving = () => {
|
||||||
|
if (resolvingReleased) return;
|
||||||
|
resolvingReleased = true;
|
||||||
|
clearResolvingKeys(setResolvingRefCounts, refKeys);
|
||||||
|
};
|
||||||
|
|
||||||
const timerId = setTimeout(() => {
|
const timerId = setTimeout(() => {
|
||||||
void attachRefs({
|
void attachRefs({
|
||||||
refs,
|
refs,
|
||||||
@@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach(
|
|||||||
latestRef,
|
latestRef,
|
||||||
removedRefKeys: removedRefKeysRef.current,
|
removedRefKeys: removedRefKeysRef.current,
|
||||||
pendingRefKeys: pendingRefKeysRef.current,
|
pendingRefKeys: pendingRefKeysRef.current,
|
||||||
});
|
}).finally(releaseResolving);
|
||||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timerId);
|
clearTimeout(timerId);
|
||||||
|
releaseResolving();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
params.text,
|
params.text,
|
||||||
@@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach(
|
|||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
isResolving: resolvingRefCounts.size > 0,
|
||||||
markGithubAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
}),
|
}),
|
||||||
[markGithubAttachmentRemoved],
|
[markGithubAttachmentRemoved, resolvingRefCounts.size],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function suppressRefsCarriedAcrossTargets({
|
||||||
|
params,
|
||||||
|
previousTargetRef,
|
||||||
|
removedRefKeys,
|
||||||
|
}: {
|
||||||
|
params: ComposerGithubAutoAttachInput;
|
||||||
|
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||||
|
removedRefKeys: Set<string>;
|
||||||
|
}): void {
|
||||||
|
const previous = previousTargetRef.current;
|
||||||
|
const targetChanged =
|
||||||
|
previous.cwd.trim().length > 0 &&
|
||||||
|
params.cwd.trim().length > 0 &&
|
||||||
|
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
|
||||||
|
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||||
|
if (!targetChanged) return;
|
||||||
|
|
||||||
|
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||||
|
removedRefKeys.add(githubRefKey(ref));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyNewPullRequestRefs({
|
||||||
|
params,
|
||||||
|
presentPullRequestKeysRef,
|
||||||
|
}: {
|
||||||
|
params: ComposerGithubAutoAttachInput;
|
||||||
|
presentPullRequestKeysRef: RefObject<Set<string>>;
|
||||||
|
}): void {
|
||||||
|
const currentKeys = new Set(
|
||||||
|
extractGithubRefs(params.text, params.remoteUrl)
|
||||||
|
.filter((ref) => ref.kind === "pull")
|
||||||
|
.map(githubRefKey),
|
||||||
|
);
|
||||||
|
for (const key of currentKeys) {
|
||||||
|
if (!presentPullRequestKeysRef.current.has(key)) {
|
||||||
|
params.onPullRequestDetected?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presentPullRequestKeysRef.current = currentKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeys(
|
||||||
|
current: ReadonlyMap<string, number>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): ReadonlyMap<string, number> {
|
||||||
|
const nextCounts = new Map(current);
|
||||||
|
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
|
||||||
|
return nextCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeys(
|
||||||
|
current: ReadonlyMap<string, number>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): ReadonlyMap<string, number> {
|
||||||
|
const next = new Map(current);
|
||||||
|
for (const key of keys) {
|
||||||
|
const count = next.get(key) ?? 0;
|
||||||
|
if (count <= 1) next.delete(key);
|
||||||
|
else next.set(key, count - 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResolvingKeys(
|
||||||
|
setResolvingRefCounts: Dispatch<SetStateAction<ReadonlyMap<string, number>>>,
|
||||||
|
keys: readonly string[],
|
||||||
|
): void {
|
||||||
|
setResolvingRefCounts((current) => removeKeys(current, keys));
|
||||||
|
}
|
||||||
|
|
||||||
async function attachRefs({
|
async function attachRefs({
|
||||||
refs,
|
refs,
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -142,16 +242,28 @@ async function attachRef({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
const current = latestRef.current;
|
||||||
|
if (
|
||||||
|
!item ||
|
||||||
|
removedRefKeys.has(key) ||
|
||||||
|
!isSameLookupTarget(snapshot, current) ||
|
||||||
|
!isRefStillPresent(ref, current)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
latestRef.current.setAttachments((current) => {
|
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
return;
|
||||||
return current;
|
}
|
||||||
|
current.setAttachments((attachments) => {
|
||||||
|
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
|
||||||
|
return attachments;
|
||||||
}
|
}
|
||||||
return toggleGithubAttachment(current, item);
|
return toggleGithubAttachment(attachments, item);
|
||||||
});
|
});
|
||||||
|
if (item.kind === "change_request") {
|
||||||
|
current.onPullRequestAdded?.(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refsReadyForLookup({
|
function refsReadyForLookup({
|
||||||
@@ -212,6 +324,17 @@ function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameLookupTarget(
|
||||||
|
initial: ComposerGithubAutoAttachInput,
|
||||||
|
current: ComposerGithubAutoAttachInput,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
initial.serverId === current.serverId &&
|
||||||
|
initial.cwd === current.cwd &&
|
||||||
|
initial.remoteUrl === current.remoteUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,6 +768,8 @@ interface ComposerProps {
|
|||||||
submitIcon?: "arrow" | "return";
|
submitIcon?: "arrow" | "return";
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
|
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||||
|
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
@@ -777,6 +779,8 @@ interface ComposerProps {
|
|||||||
attachmentScopeKeys?: readonly string[];
|
attachmentScopeKeys?: readonly string[];
|
||||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
|
onGithubPrDetected?: () => void;
|
||||||
|
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
@@ -981,6 +985,7 @@ export function Composer({
|
|||||||
submitButtonTestID,
|
submitButtonTestID,
|
||||||
submitIcon = "arrow",
|
submitIcon = "arrow",
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
|
waitForGithubAutoAttachOnSubmit = false,
|
||||||
submitBehavior = "clear",
|
submitBehavior = "clear",
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -989,6 +994,8 @@ export function Composer({
|
|||||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||||
onOpenWorkspaceAttachment,
|
onOpenWorkspaceAttachment,
|
||||||
onChangeAttachments,
|
onChangeAttachments,
|
||||||
|
onGithubPrDetected,
|
||||||
|
onGithubPrAutoAttach,
|
||||||
cwd,
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -1070,6 +1077,8 @@ export function Composer({
|
|||||||
cwd,
|
cwd,
|
||||||
supportsForgeSearch,
|
supportsForgeSearch,
|
||||||
setAttachments: setSelectedAttachments,
|
setAttachments: setSelectedAttachments,
|
||||||
|
onPullRequestDetected: onGithubPrDetected,
|
||||||
|
onPullRequestAdded: onGithubPrAutoAttach,
|
||||||
});
|
});
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
@@ -1949,7 +1958,11 @@ export function Composer({
|
|||||||
|
|
||||||
const messageInputContainerRef = useRef<View>(null);
|
const messageInputContainerRef = useRef<View>(null);
|
||||||
|
|
||||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
const isSubmitBusy =
|
||||||
|
isProcessing ||
|
||||||
|
isSubmitLoading ||
|
||||||
|
isUploadingFile ||
|
||||||
|
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||||
|
|
||||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||||
|
|||||||
@@ -985,9 +985,6 @@ export const ar: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "بدء المرجع",
|
startingRef: "بدء المرجع",
|
||||||
chooseStart: "اختر من أين تبدأ",
|
chooseStart: "اختر من أين تبدأ",
|
||||||
checkoutHint: "تحقق من {{noun}} {{numberPrefix}}{{number}}؟",
|
|
||||||
checkoutPr: "تحقق من {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "تجاهل تلميح الخروج {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
intoBase: "إلى {{baseRef}}",
|
intoBase: "إلى {{baseRef}}",
|
||||||
searching: "جارٍ البحث...",
|
searching: "جارٍ البحث...",
|
||||||
noMatchingRefs: "لا توجد مراجع مطابقة.",
|
noMatchingRefs: "لا توجد مراجع مطابقة.",
|
||||||
|
|||||||
@@ -995,9 +995,6 @@ export const en = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "Starting ref",
|
startingRef: "Starting ref",
|
||||||
chooseStart: "Choose where to start from",
|
chooseStart: "Choose where to start from",
|
||||||
checkoutHint: "Check out {{noun}} {{numberPrefix}}{{number}}?",
|
|
||||||
checkoutPr: "Check out {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "Dismiss {{noun}} {{numberPrefix}}{{number}} checkout hint",
|
|
||||||
intoBase: "into {{baseRef}}",
|
intoBase: "into {{baseRef}}",
|
||||||
searching: "Searching...",
|
searching: "Searching...",
|
||||||
noMatchingRefs: "No matching refs.",
|
noMatchingRefs: "No matching refs.",
|
||||||
|
|||||||
@@ -1016,9 +1016,6 @@ export const es: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "Árbitro inicial",
|
startingRef: "Árbitro inicial",
|
||||||
chooseStart: "Elige por dónde empezar",
|
chooseStart: "Elige por dónde empezar",
|
||||||
checkoutHint: "¿Mira {{noun}} {{numberPrefix}}{{number}}?",
|
|
||||||
checkoutPr: "Echa un vistazo a {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "Descartar la sugerencia de pago de {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
intoBase: "en {{baseRef}}",
|
intoBase: "en {{baseRef}}",
|
||||||
searching: "Búsqueda...",
|
searching: "Búsqueda...",
|
||||||
noMatchingRefs: "No hay árbitros coincidentes.",
|
noMatchingRefs: "No hay árbitros coincidentes.",
|
||||||
|
|||||||
@@ -1015,9 +1015,6 @@ export const fr: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "Réf de départ",
|
startingRef: "Réf de départ",
|
||||||
chooseStart: "Choisissez par où commencer",
|
chooseStart: "Choisissez par où commencer",
|
||||||
checkoutHint: "Découvrez {{noun}} {{numberPrefix}}{{number}} ?",
|
|
||||||
checkoutPr: "Découvrez {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "Ignorer l'indice de paiement {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
intoBase: "dans {{baseRef}}",
|
intoBase: "dans {{baseRef}}",
|
||||||
searching: "Recherche...",
|
searching: "Recherche...",
|
||||||
noMatchingRefs: "Aucune référence correspondante.",
|
noMatchingRefs: "Aucune référence correspondante.",
|
||||||
|
|||||||
@@ -996,9 +996,6 @@ export const ja: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "開始Ref",
|
startingRef: "開始Ref",
|
||||||
chooseStart: "開始点を選択",
|
chooseStart: "開始点を選択",
|
||||||
checkoutHint: "{{noun}} {{numberPrefix}}{{number}}をチェックアウトしますか?",
|
|
||||||
checkoutPr: "{{noun}} {{numberPrefix}}{{number}}をチェックアウト",
|
|
||||||
dismissCheckoutHint: "{{noun}} {{numberPrefix}}{{number}}のチェックアウトヒントを閉じる",
|
|
||||||
intoBase: "{{baseRef}}に",
|
intoBase: "{{baseRef}}に",
|
||||||
searching: "検索中...",
|
searching: "検索中...",
|
||||||
noMatchingRefs: "一致するRefがありません。",
|
noMatchingRefs: "一致するRefがありません。",
|
||||||
|
|||||||
@@ -1007,9 +1007,6 @@ export const ptBR: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "Ref inicial",
|
startingRef: "Ref inicial",
|
||||||
chooseStart: "Escolha de onde começar",
|
chooseStart: "Escolha de onde começar",
|
||||||
checkoutHint: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}?",
|
|
||||||
checkoutPr: "Fazer checkout da {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "Dispensar dica de checkout da {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
intoBase: "em {{baseRef}}",
|
intoBase: "em {{baseRef}}",
|
||||||
searching: "Buscando...",
|
searching: "Buscando...",
|
||||||
noMatchingRefs: "Nenhuma ref correspondente.",
|
noMatchingRefs: "Nenhuma ref correspondente.",
|
||||||
|
|||||||
@@ -1007,10 +1007,6 @@ export const ru: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "Начальная ссылка",
|
startingRef: "Начальная ссылка",
|
||||||
chooseStart: "Выберите, с чего начать",
|
chooseStart: "Выберите, с чего начать",
|
||||||
checkoutHint: "Проверьте {{noun}} {{numberPrefix}}{{number}}?",
|
|
||||||
checkoutPr: "Проверьте {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint:
|
|
||||||
"Отклонить подсказку по оформлению заказа {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
intoBase: "в {{baseRef}}",
|
intoBase: "в {{baseRef}}",
|
||||||
searching: "Идет поиск...",
|
searching: "Идет поиск...",
|
||||||
noMatchingRefs: "Нет подходящих ссылок.",
|
noMatchingRefs: "Нет подходящих ссылок.",
|
||||||
|
|||||||
@@ -974,9 +974,6 @@ export const zhCN: TranslationResources = {
|
|||||||
refPicker: {
|
refPicker: {
|
||||||
startingRef: "起始 ref",
|
startingRef: "起始 ref",
|
||||||
chooseStart: "选择起始位置",
|
chooseStart: "选择起始位置",
|
||||||
checkoutHint: "Checkout {{noun}} {{numberPrefix}}{{number}}?",
|
|
||||||
checkoutPr: "Checkout {{noun}} {{numberPrefix}}{{number}}",
|
|
||||||
dismissCheckoutHint: "忽略 {{noun}} {{numberPrefix}}{{number}} checkout 提示",
|
|
||||||
intoBase: "进入 {{baseRef}}",
|
intoBase: "进入 {{baseRef}}",
|
||||||
searching: "正在搜索...",
|
searching: "正在搜索...",
|
||||||
noMatchingRefs: "没有匹配的 refs。",
|
noMatchingRefs: "没有匹配的 refs。",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import {
|
import {
|
||||||
clearPickerPrAttachmentForTargetChange,
|
clearPickerPrAttachmentForTargetChange,
|
||||||
findCheckoutHintPrAttachment,
|
initialPickerSelectionState,
|
||||||
|
reducePickerSelection,
|
||||||
syncPickerPrAttachment,
|
syncPickerPrAttachment,
|
||||||
} from "./new-workspace-picker-state";
|
} from "./new-workspace-picker-state";
|
||||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
@@ -28,21 +29,28 @@ function prAttachment(
|
|||||||
return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
|
return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueAttachment(number: number): UserComposerAttachment {
|
function forgePrAttachment(
|
||||||
|
item: ForgeSearchItem,
|
||||||
|
): Extract<UserComposerAttachment, { kind: "forge_change_request" }> {
|
||||||
|
return { kind: "forge_change_request", item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIssueItem(number: number): ForgeSearchItem {
|
||||||
return {
|
return {
|
||||||
kind: "github_issue",
|
kind: "issue",
|
||||||
item: {
|
number,
|
||||||
kind: "issue",
|
title: `Issue ${number}`,
|
||||||
number,
|
url: `https://example.com/issues/${number}`,
|
||||||
title: `Issue ${number}`,
|
state: "open",
|
||||||
url: `https://example.com/issues/${number}`,
|
body: null,
|
||||||
state: "open",
|
labels: [],
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function issueAttachment(number: number): UserComposerAttachment {
|
||||||
|
return { kind: "github_issue", item: makeIssueItem(number) };
|
||||||
|
}
|
||||||
|
|
||||||
describe("syncPickerPrAttachment", () => {
|
describe("syncPickerPrAttachment", () => {
|
||||||
it("selects a PR when no previous picker PR is set", () => {
|
it("selects a PR when no previous picker PR is set", () => {
|
||||||
const pr = makePrItem(202, "Refactor picker");
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
@@ -91,6 +99,15 @@ describe("syncPickerPrAttachment", () => {
|
|||||||
expect(result).toEqual([prAttachment(pr)]);
|
expect(result).toEqual([prAttachment(pr)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not duplicate a generalized PR attachment", () => {
|
||||||
|
const pr = makePrItem(202, "Refactor picker");
|
||||||
|
const result = syncPickerPrAttachment({
|
||||||
|
attachments: [forgePrAttachment(pr)],
|
||||||
|
item: { kind: "github-pr", item: pr },
|
||||||
|
});
|
||||||
|
expect(result).toEqual([forgePrAttachment(pr)]);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears a persisted picker selection without removing user-added attachments", () => {
|
it("clears a persisted picker selection without removing user-added attachments", () => {
|
||||||
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
||||||
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
|
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
|
||||||
@@ -119,67 +136,82 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
|
|||||||
).toBe(attachments);
|
).toBe(attachments);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears only the picker-owned PR when the target changes", () => {
|
it("clears all PR attachments when the target changes", () => {
|
||||||
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
|
||||||
const manualPr = prAttachment(makePrItem(303, "Manual PR"));
|
const manualPr = prAttachment(makePrItem(303, "Manual PR"));
|
||||||
|
const forgePr = forgePrAttachment(makePrItem(404, "Forge PR"));
|
||||||
|
const issue = issueAttachment(44);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
clearPickerPrAttachmentForTargetChange({
|
clearPickerPrAttachmentForTargetChange({
|
||||||
attachments: [pickerPr, manualPr],
|
attachments: [issue, pickerPr, manualPr, forgePr],
|
||||||
currentTargetId: "server-a",
|
currentTargetId: "server-a",
|
||||||
nextTargetId: "server-b",
|
nextTargetId: "server-b",
|
||||||
}),
|
}),
|
||||||
).toEqual([manualPr]);
|
).toEqual([issue]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findCheckoutHintPrAttachment", () => {
|
describe("reducePickerSelection", () => {
|
||||||
it("returns the first attached PR that is not selected or dismissed", () => {
|
it("selects a PR that was newly detected and added", () => {
|
||||||
const first = prAttachment(makePrItem(101, "A"));
|
const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||||
const second = prAttachment(makePrItem(202, "B"));
|
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||||
|
|
||||||
expect(
|
expect(reducePickerSelection(detected, { type: "pr-added", item })).toEqual({
|
||||||
findCheckoutHintPrAttachment({
|
selectedItem: item,
|
||||||
attachments: [issueAttachment(44), first, second],
|
allowAutoPrSelection: false,
|
||||||
selectedItem: null,
|
});
|
||||||
dismissedPrNumbers: new Set(),
|
|
||||||
}),
|
|
||||||
).toBe(first);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips the selected PR and offers the next attached PR", () => {
|
it("keeps the first PR selected when one edit adds multiple PRs", () => {
|
||||||
const selected = prAttachment(makePrItem(101, "A"));
|
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||||
const next = prAttachment(makePrItem(202, "B"));
|
const first = reducePickerSelection(detected, {
|
||||||
|
type: "pr-added",
|
||||||
|
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
findCheckoutHintPrAttachment({
|
reducePickerSelection(first, {
|
||||||
attachments: [selected, next],
|
type: "pr-added",
|
||||||
selectedItem: { kind: "github-pr", item: selected.item },
|
item: { kind: "github-pr", item: makePrItem(202, "B") },
|
||||||
dismissedPrNumbers: new Set(),
|
|
||||||
}),
|
}),
|
||||||
).toBe(next);
|
).toEqual(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips dismissed PRs and ignores issues", () => {
|
it("keeps a branch selected after a pending PR is added", () => {
|
||||||
const dismissed = prAttachment(makePrItem(101, "A"));
|
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||||
const next = prAttachment(makePrItem(202, "B"));
|
const branchSelected = reducePickerSelection(detected, {
|
||||||
|
type: "picker-selected",
|
||||||
|
item: { kind: "branch", name: "main" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
findCheckoutHintPrAttachment({
|
reducePickerSelection(branchSelected, {
|
||||||
attachments: [issueAttachment(44), dismissed, next],
|
type: "pr-added",
|
||||||
selectedItem: null,
|
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||||
dismissedPrNumbers: new Set([101]),
|
|
||||||
}),
|
}),
|
||||||
).toBe(next);
|
).toEqual(branchSelected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when only issues qualify", () => {
|
it("does not derive checkout selection from an existing attachment", () => {
|
||||||
expect(
|
expect(
|
||||||
findCheckoutHintPrAttachment({
|
reducePickerSelection(initialPickerSelectionState, {
|
||||||
attachments: [issueAttachment(44)],
|
type: "pr-added",
|
||||||
selectedItem: null,
|
item: { kind: "github-pr", item: makePrItem(101, "A") },
|
||||||
dismissedPrNumbers: new Set(),
|
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toEqual(initialPickerSelectionState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a newly detected PR replace an earlier explicit branch", () => {
|
||||||
|
const branchSelected = reducePickerSelection(initialPickerSelectionState, {
|
||||||
|
type: "picker-selected",
|
||||||
|
item: { kind: "branch", name: "main" },
|
||||||
|
});
|
||||||
|
const detected = reducePickerSelection(branchSelected, { type: "pr-detected" });
|
||||||
|
const pr = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||||
|
|
||||||
|
expect(reducePickerSelection(detected, { type: "pr-added", item: pr }).selectedItem).toEqual(
|
||||||
|
pr,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ import {
|
|||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import type { PickerItem } from "./new-workspace-picker-item";
|
import type { PickerItem } from "./new-workspace-picker-item";
|
||||||
|
|
||||||
|
export interface PickerSelectionState {
|
||||||
|
selectedItem: PickerItem | null;
|
||||||
|
allowAutoPrSelection: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PickerSelectionEvent =
|
||||||
|
| { type: "pr-detected" }
|
||||||
|
| { type: "pr-added"; item: Extract<PickerItem, { kind: "github-pr" }> }
|
||||||
|
| { type: "picker-selected"; item: PickerItem }
|
||||||
|
| { type: "target-changed" };
|
||||||
|
|
||||||
|
export const initialPickerSelectionState: PickerSelectionState = {
|
||||||
|
selectedItem: null,
|
||||||
|
allowAutoPrSelection: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function reducePickerSelection(
|
||||||
|
state: PickerSelectionState,
|
||||||
|
event: PickerSelectionEvent,
|
||||||
|
): PickerSelectionState {
|
||||||
|
switch (event.type) {
|
||||||
|
case "pr-detected":
|
||||||
|
return { ...state, allowAutoPrSelection: true };
|
||||||
|
case "pr-added":
|
||||||
|
return state.allowAutoPrSelection
|
||||||
|
? { selectedItem: event.item, allowAutoPrSelection: false }
|
||||||
|
: state;
|
||||||
|
case "picker-selected":
|
||||||
|
return { selectedItem: event.item, allowAutoPrSelection: false };
|
||||||
|
case "target-changed":
|
||||||
|
return initialPickerSelectionState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrAttachment(
|
||||||
|
attachment: UserComposerAttachment,
|
||||||
|
): attachment is Extract<UserComposerAttachment, { kind: "forge_change_request" | "github_pr" }> {
|
||||||
|
return attachment.kind === "forge_change_request" || attachment.kind === "github_pr";
|
||||||
|
}
|
||||||
|
|
||||||
function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract<
|
function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract<
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
{ kind: "github_pr" }
|
{ kind: "github_pr" }
|
||||||
@@ -28,8 +68,7 @@ export function syncPickerPrAttachment(input: {
|
|||||||
if (input.item?.kind === "github-pr") {
|
if (input.item?.kind === "github-pr") {
|
||||||
const selectedPr = input.item.item;
|
const selectedPr = input.item.item;
|
||||||
const hasExistingPrAttachment = nextAttachments.some(
|
const hasExistingPrAttachment = nextAttachments.some(
|
||||||
(attachment) =>
|
(attachment) => isPrAttachment(attachment) && attachment.item.number === selectedPr.number,
|
||||||
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
|
|
||||||
);
|
);
|
||||||
if (!hasExistingPrAttachment) {
|
if (!hasExistingPrAttachment) {
|
||||||
return [
|
return [
|
||||||
@@ -54,24 +93,5 @@ export function clearPickerPrAttachmentForTargetChange(input: {
|
|||||||
if (input.currentTargetId === input.nextTargetId) {
|
if (input.currentTargetId === input.nextTargetId) {
|
||||||
return input.attachments;
|
return input.attachments;
|
||||||
}
|
}
|
||||||
return syncPickerPrAttachment({ attachments: input.attachments, item: null });
|
return input.attachments.filter((attachment) => !isPrAttachment(attachment));
|
||||||
}
|
|
||||||
|
|
||||||
export function findCheckoutHintPrAttachment(input: {
|
|
||||||
attachments: ReadonlyArray<UserComposerAttachment>;
|
|
||||||
selectedItem: PickerItem | null;
|
|
||||||
dismissedPrNumbers: ReadonlySet<number>;
|
|
||||||
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | null {
|
|
||||||
const selectedPrNumber =
|
|
||||||
input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null;
|
|
||||||
|
|
||||||
for (const attachment of input.attachments) {
|
|
||||||
if (attachment.kind !== "github_pr") continue;
|
|
||||||
const prNumber = attachment.item.number;
|
|
||||||
if (prNumber === selectedPrNumber) continue;
|
|
||||||
if (input.dismissedPrNumbers.has(prNumber)) continue;
|
|
||||||
return attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||||
import type { ReactElement, RefObject } from "react";
|
import type { ReactElement, RefObject } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
@@ -9,15 +9,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
GitBranch,
|
|
||||||
GitPullRequest,
|
|
||||||
X,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
@@ -80,7 +72,7 @@ import {
|
|||||||
} from "@/projects/host-projects";
|
} from "@/projects/host-projects";
|
||||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
import type { MessagePayload } from "@/composer/types";
|
import type { MessagePayload } from "@/composer/types";
|
||||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
@@ -99,7 +91,8 @@ import {
|
|||||||
} from "./new-workspace-picker-item";
|
} from "./new-workspace-picker-item";
|
||||||
import {
|
import {
|
||||||
clearPickerPrAttachmentForTargetChange,
|
clearPickerPrAttachmentForTargetChange,
|
||||||
findCheckoutHintPrAttachment,
|
initialPickerSelectionState,
|
||||||
|
reducePickerSelection,
|
||||||
syncPickerPrAttachment,
|
syncPickerPrAttachment,
|
||||||
} from "./new-workspace-picker-state";
|
} from "./new-workspace-picker-state";
|
||||||
import {
|
import {
|
||||||
@@ -186,10 +179,6 @@ interface PickerOptionData {
|
|||||||
itemById: Map<string, PickerItem>;
|
itemById: Map<string, PickerItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerSelection {
|
|
||||||
item: PickerItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BRANCH_OPTION_PREFIX = "branch:";
|
const BRANCH_OPTION_PREFIX = "branch:";
|
||||||
const PR_OPTION_PREFIX = "github-pr:";
|
const PR_OPTION_PREFIX = "github-pr:";
|
||||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||||
@@ -335,50 +324,6 @@ function ProjectPickerTrigger({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckoutHintBadge({
|
|
||||||
label,
|
|
||||||
acceptLabel,
|
|
||||||
dismissLabel,
|
|
||||||
onAccept,
|
|
||||||
onDismiss,
|
|
||||||
iconColor,
|
|
||||||
iconSize,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
acceptLabel: string;
|
|
||||||
dismissLabel: string;
|
|
||||||
onAccept: () => void;
|
|
||||||
onDismiss: () => void;
|
|
||||||
iconColor: string;
|
|
||||||
iconSize: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View style={styles.checkoutHintBadge}>
|
|
||||||
<Text style={styles.badgeText} numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
testID="new-workspace-checkout-hint-accept"
|
|
||||||
onPress={onAccept}
|
|
||||||
style={styles.checkoutHintAction}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={acceptLabel}
|
|
||||||
>
|
|
||||||
<Check size={iconSize} color={iconColor} />
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
testID="new-workspace-checkout-hint-dismiss"
|
|
||||||
onPress={onDismiss}
|
|
||||||
style={styles.checkoutHintAction}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={dismissLabel}
|
|
||||||
>
|
|
||||||
<X size={iconSize} color={iconColor} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PickerOptionItem({
|
function PickerOptionItem({
|
||||||
testID,
|
testID,
|
||||||
label,
|
label,
|
||||||
@@ -646,14 +591,6 @@ function formatPrLabel(item: Pick<ForgeSearchItem, "forge" | "number" | "title">
|
|||||||
return `${presentation.numberPrefix}${item.number} ${item.title}`;
|
return `${presentation.numberPrefix}${item.number} ${item.title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCheckoutHintPresentation(item: ForgeSearchItem) {
|
|
||||||
const presentation = getForgePresentation(item.forge ?? "github");
|
|
||||||
return {
|
|
||||||
noun: presentation.changeRequestAbbrev,
|
|
||||||
numberPrefix: presentation.numberPrefix,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickerItemLabel(item: PickerItem): string {
|
function pickerItemLabel(item: PickerItem): string {
|
||||||
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
return item.kind === "branch" ? item.name : formatPrLabel(item.item);
|
||||||
}
|
}
|
||||||
@@ -803,11 +740,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
|||||||
return [styles.content, styles.contentCentered];
|
return [styles.content, styles.contentCentered];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
|
|
||||||
if (!selection) return null;
|
|
||||||
return selection.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBranchDetails(
|
function normalizeBranchDetails(
|
||||||
data:
|
data:
|
||||||
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
|
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
|
||||||
@@ -1060,47 +992,6 @@ function buildComposerConfig(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
|
|
||||||
const numbers = new Set<number>();
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
if (attachment.kind === "github_pr") {
|
|
||||||
numbers.add(attachment.item.number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return numbers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneDismissedCheckoutHintPrNumbers(
|
|
||||||
dismissed: ReadonlySet<number>,
|
|
||||||
attached: ReadonlySet<number>,
|
|
||||||
): ReadonlySet<number> {
|
|
||||||
let changed = false;
|
|
||||||
const next = new Set<number>();
|
|
||||||
for (const prNumber of dismissed) {
|
|
||||||
if (attached.has(prNumber)) {
|
|
||||||
next.add(prNumber);
|
|
||||||
} else {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : dismissed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
|
|
||||||
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDismissedPrNumbers((current) =>
|
|
||||||
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
|
|
||||||
);
|
|
||||||
}, [attachedPrNumbers]);
|
|
||||||
|
|
||||||
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePendingWorkspaceDraftSetup(
|
function usePendingWorkspaceDraftSetup(
|
||||||
draftId: string | undefined,
|
draftId: string | undefined,
|
||||||
): PendingWorkspaceDraftSetup | null {
|
): PendingWorkspaceDraftSetup | null {
|
||||||
@@ -1647,7 +1538,6 @@ export function NewWorkspaceScreen({
|
|||||||
typeof normalizeWorkspaceDescriptor
|
typeof normalizeWorkspaceDescriptor
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
|
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
|
||||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||||
const openAddProjectPicker = useOpenAddProject();
|
const openAddProjectPicker = useOpenAddProject();
|
||||||
@@ -1718,10 +1608,22 @@ export function NewWorkspaceScreen({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const composerState = chatDraft.composerState;
|
const composerState = chatDraft.composerState;
|
||||||
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
|
const [pickerSelection, dispatchPickerSelection] = useReducer(
|
||||||
useCheckoutHintDismissals(chatDraft.attachments);
|
reducePickerSelection,
|
||||||
|
initialPickerSelectionState,
|
||||||
|
);
|
||||||
|
const selectedItem = pickerSelection.selectedItem;
|
||||||
|
|
||||||
const selectedItem = getSelectedPickerItem(manualPickerSelection);
|
const handleGithubPrDetected = useCallback(() => {
|
||||||
|
dispatchPickerSelection({ type: "pr-detected" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGithubPrAutoAttach = useCallback((item: ForgeSearchItem) => {
|
||||||
|
dispatchPickerSelection({
|
||||||
|
type: "pr-added",
|
||||||
|
item: { kind: "github-pr", item },
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const withConnectedClient = useCallback(() => {
|
const withConnectedClient = useCallback(() => {
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
@@ -1822,7 +1724,7 @@ export function NewWorkspaceScreen({
|
|||||||
item,
|
item,
|
||||||
});
|
});
|
||||||
|
|
||||||
setManualPickerSelection({ item });
|
dispatchPickerSelection({ type: "picker-selected", item });
|
||||||
chatDraft.setAttachments(nextAttachments);
|
chatDraft.setAttachments(nextAttachments);
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
},
|
},
|
||||||
@@ -1838,7 +1740,7 @@ export function NewWorkspaceScreen({
|
|||||||
[itemById, selectPickerItem],
|
[itemById, selectPickerItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearManualPickerSelectionForTargetChange = useCallback(
|
const clearPickerSelectionForTargetChange = useCallback(
|
||||||
(currentTargetId: string, nextTargetId: string) => {
|
(currentTargetId: string, nextTargetId: string) => {
|
||||||
const nextAttachments = clearPickerPrAttachmentForTargetChange({
|
const nextAttachments = clearPickerPrAttachmentForTargetChange({
|
||||||
attachments: chatDraft.attachments,
|
attachments: chatDraft.attachments,
|
||||||
@@ -1847,7 +1749,7 @@ export function NewWorkspaceScreen({
|
|||||||
});
|
});
|
||||||
if (nextAttachments === chatDraft.attachments) return;
|
if (nextAttachments === chatDraft.attachments) return;
|
||||||
chatDraft.setAttachments(nextAttachments);
|
chatDraft.setAttachments(nextAttachments);
|
||||||
setManualPickerSelection(null);
|
dispatchPickerSelection({ type: "target-changed" });
|
||||||
},
|
},
|
||||||
[chatDraft],
|
[chatDraft],
|
||||||
);
|
);
|
||||||
@@ -1859,17 +1761,17 @@ export function NewWorkspaceScreen({
|
|||||||
// canCreateWorktree or non-git projects become unselectable.
|
// canCreateWorktree or non-git projects become unselectable.
|
||||||
selectProjectOption(id);
|
selectProjectOption(id);
|
||||||
setProjectPickerOpen(false);
|
setProjectPickerOpen(false);
|
||||||
clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id);
|
clearPickerSelectionForTargetChange(selectedProjectOptionId, id);
|
||||||
},
|
},
|
||||||
[clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
|
[clearPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectWorkspaceHost = useCallback(
|
const handleSelectWorkspaceHost = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
handleSelectHost(id);
|
handleSelectHost(id);
|
||||||
clearManualPickerSelectionForTargetChange(selectedServerId, id);
|
clearPickerSelectionForTargetChange(selectedServerId, id);
|
||||||
},
|
},
|
||||||
[clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
|
[clearPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddProject = useCallback(() => {
|
const handleAddProject = useCallback(() => {
|
||||||
@@ -1877,32 +1779,6 @@ export function NewWorkspaceScreen({
|
|||||||
openAddProjectPicker(selectedServerId);
|
openAddProjectPicker(selectedServerId);
|
||||||
}, [openAddProjectPicker, selectedServerId]);
|
}, [openAddProjectPicker, selectedServerId]);
|
||||||
|
|
||||||
const checkoutHintPrAttachment = useMemo(
|
|
||||||
() =>
|
|
||||||
findCheckoutHintPrAttachment({
|
|
||||||
attachments: chatDraft.attachments,
|
|
||||||
selectedItem,
|
|
||||||
dismissedPrNumbers: dismissedCheckoutHintPrNumbers,
|
|
||||||
}),
|
|
||||||
[chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptCheckoutHint = useCallback(() => {
|
|
||||||
if (!checkoutHintPrAttachment) return;
|
|
||||||
selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item });
|
|
||||||
}, [checkoutHintPrAttachment, selectPickerItem]);
|
|
||||||
|
|
||||||
const dismissCheckoutHint = useCallback(() => {
|
|
||||||
if (!checkoutHintPrAttachment) return;
|
|
||||||
const prNumber = checkoutHintPrAttachment.item.number;
|
|
||||||
setDismissedCheckoutHintPrNumbers((current) => {
|
|
||||||
if (current.has(prNumber)) return current;
|
|
||||||
const next = new Set(current);
|
|
||||||
next.add(prNumber);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]);
|
|
||||||
|
|
||||||
const openPicker = useCallback(() => {
|
const openPicker = useCallback(() => {
|
||||||
setPickerOpen(true);
|
setPickerOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2234,42 +2110,11 @@ export function NewWorkspaceScreen({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const composerFooter = useMemo(
|
const composerFooter = useMemo(
|
||||||
() => (
|
() =>
|
||||||
<>
|
agentControlsWithDisabled ? (
|
||||||
{agentControlsWithDisabled ? (
|
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
||||||
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
|
) : null,
|
||||||
) : null}
|
[agentControlsWithDisabled],
|
||||||
{checkoutHintPrAttachment ? (
|
|
||||||
<CheckoutHintBadge
|
|
||||||
label={t("newWorkspace.refPicker.checkoutHint", {
|
|
||||||
number: checkoutHintPrAttachment.item.number,
|
|
||||||
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
|
|
||||||
})}
|
|
||||||
acceptLabel={t("newWorkspace.refPicker.checkoutPr", {
|
|
||||||
number: checkoutHintPrAttachment.item.number,
|
|
||||||
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
|
|
||||||
})}
|
|
||||||
dismissLabel={t("newWorkspace.refPicker.dismissCheckoutHint", {
|
|
||||||
number: checkoutHintPrAttachment.item.number,
|
|
||||||
...getCheckoutHintPresentation(checkoutHintPrAttachment.item),
|
|
||||||
})}
|
|
||||||
onAccept={acceptCheckoutHint}
|
|
||||||
onDismiss={dismissCheckoutHint}
|
|
||||||
iconColor={theme.colors.foregroundMuted}
|
|
||||||
iconSize={theme.iconSize.sm}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
[
|
|
||||||
acceptCheckoutHint,
|
|
||||||
agentControlsWithDisabled,
|
|
||||||
checkoutHintPrAttachment,
|
|
||||||
dismissCheckoutHint,
|
|
||||||
t,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
theme.iconSize.sm,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||||
|
|
||||||
@@ -2294,6 +2139,7 @@ export function NewWorkspaceScreen({
|
|||||||
submitButtonTestID="workspace-create-submit"
|
submitButtonTestID="workspace-create-submit"
|
||||||
submitIcon="return"
|
submitIcon="return"
|
||||||
isSubmitLoading={isPending}
|
isSubmitLoading={isPending}
|
||||||
|
waitForGithubAutoAttachOnSubmit
|
||||||
submitBehavior="preserve-and-lock"
|
submitBehavior="preserve-and-lock"
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={chatDraft.text}
|
value={chatDraft.text}
|
||||||
@@ -2301,6 +2147,8 @@ export function NewWorkspaceScreen({
|
|||||||
attachments={chatDraft.attachments}
|
attachments={chatDraft.attachments}
|
||||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||||
onChangeAttachments={chatDraft.setAttachments}
|
onChangeAttachments={chatDraft.setAttachments}
|
||||||
|
onGithubPrDetected={handleGithubPrDetected}
|
||||||
|
onGithubPrAutoAttach={handleGithubPrAutoAttach}
|
||||||
cwd={selectedSourceDirectory ?? ""}
|
cwd={selectedSourceDirectory ?? ""}
|
||||||
clearDraft={handleClearDraft}
|
clearDraft={handleClearDraft}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -2387,23 +2235,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
checkoutHintBadge: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
height: BADGE_HEIGHT,
|
|
||||||
maxWidth: 240,
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
},
|
|
||||||
checkoutHintAction: {
|
|
||||||
width: theme.iconSize.md,
|
|
||||||
height: theme.iconSize.md,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
},
|
|
||||||
badgeHovered: {
|
badgeHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -621,6 +621,52 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(result.worktree.branchName).toBe("pr-123");
|
expect(result.worktree.branchName).toBe("pr-123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tracks a same-repo PR branch from a single-branch clone", async () => {
|
||||||
|
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
execFileSync("git", ["config", "--unset-all", "remote.origin.fetch"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["config", "--add", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main"],
|
||||||
|
{ cwd: repoDir, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/daemon-shutdown-diagnostics"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
const github = {
|
||||||
|
...createGitHubServiceStub(),
|
||||||
|
getPullRequestCheckoutTarget: async () => ({
|
||||||
|
number: 1790,
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "daemon-shutdown-diagnostics",
|
||||||
|
headOwnerLogin: "getpaseo",
|
||||||
|
headRepositorySshUrl: remoteDir,
|
||||||
|
headRepositoryUrl: remoteDir,
|
||||||
|
isCrossRepository: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createCoreWorktree(
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
action: "checkout",
|
||||||
|
githubPrNumber: 1790,
|
||||||
|
paseoHome,
|
||||||
|
runSetup: false,
|
||||||
|
},
|
||||||
|
createCoreDeps({ github }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.worktree.branchName).toBe("daemon-shutdown-diagnostics");
|
||||||
|
expect(getBranchUpstream(result.worktree.worktreePath)).toBe(
|
||||||
|
"origin/daemon-shutdown-diagnostics",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("checks out a GitLab MR source branch through the resolved forge service", async () => {
|
test("checks out a GitLab MR source branch through the resolved forge service", async () => {
|
||||||
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch();
|
const { tempDir, repoDir, paseoHome } = createGitRepoWithOriginFeatureBranch();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
|
|||||||
@@ -1511,7 +1511,33 @@ async function tryFetchWorktreeTrackingRemote(options: {
|
|||||||
acceptExitCodes: [0, 1, 128],
|
acceptExitCodes: [0, 1, 128],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return result.exitCode === 0 ? { name: options.remoteName, headRef: options.headRef } : undefined;
|
if (result.exitCode !== 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
await ensureRemoteFetchesBranch(options);
|
||||||
|
return { name: options.remoteName, headRef: options.headRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRemoteFetchesBranch(options: {
|
||||||
|
cwd: string;
|
||||||
|
remoteName: string;
|
||||||
|
headRef: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const configKey = `remote.${options.remoteName}.fetch`;
|
||||||
|
const exactRefspec = `refs/heads/${options.headRef}:refs/remotes/${options.remoteName}/${options.headRef}`;
|
||||||
|
const wildcardRefspec = `refs/heads/*:refs/remotes/${options.remoteName}/*`;
|
||||||
|
const { stdout } = await runGitCommand(["config", "--get-all", configKey], {
|
||||||
|
cwd: options.cwd,
|
||||||
|
acceptExitCodes: [0, 1],
|
||||||
|
});
|
||||||
|
const alreadyTracked = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((refspec) => refspec.trim().replace(/^\+/, ""))
|
||||||
|
.some((refspec) => refspec === exactRefspec || refspec === wildcardRefspec);
|
||||||
|
if (alreadyTracked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runGitCommand(["config", "--add", configKey, `+${exactRefspec}`], { cwd: options.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWorktreeRemotePushUrl(
|
async function getWorktreeRemotePushUrl(
|
||||||
|
|||||||
Reference in New Issue
Block a user