Start new workspaces from pasted pull requests (#2290)
* fix(workspaces): start pasted pull requests from their branch Recognized pull request links now select their branch directly instead of waiting for a separate confirmation. Ensure single-branch clones also record the fetched branch so the new worktree can track it. * fix(workspaces): keep pasted PR checkout target-safe * fix(workspaces): make pasted PR resolution deterministic * fix(workspaces): preserve explicit checkout selection A recognized pull request selects itself only when its attachment is added. Once the user chooses a branch, later composer edits must not derive checkout state from existing attachments. * fix(composer): ignore stale pull request lookups A lookup may finish after the workspace target changes. Only apply its attachment and selection event when the host, working directory, client, and remote still match the target that started it. * fix(workspaces): make checkout choices authoritative Arm automatic pull request selection only when a new PR link is detected. An explicit picker choice cancels that pending selection, existing draft attachments never infer checkout state, and target changes suppress carried-over lookups. * fix(workspaces): stabilize pull request auto-selection Keep the first PR selected when one edit contains several links, and accept a completed lookup when only the transport client changed for the same host and checkout. * fix(composer): release removed pull request lookups Release each auto-attach invocation when its composer state is abandoned, while continuing to discard any stale response.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
@@ -26,9 +27,12 @@ interface ComposerGithubAutoAttachInput {
|
||||
cwd: string;
|
||||
supportsForgeSearch?: boolean;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
onPullRequestDetected?: () => void;
|
||||
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||
}
|
||||
|
||||
interface ComposerGithubAutoAttachResult {
|
||||
isResolving: boolean;
|
||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -39,10 +43,24 @@ export function useComposerGithubAutoAttach(
|
||||
const latestRef = useRef(params);
|
||||
const removedRefKeysRef = 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;
|
||||
|
||||
useEffect(() => {
|
||||
suppressRefsCarriedAcrossTargets({
|
||||
params: latestRef.current,
|
||||
previousTargetRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
});
|
||||
notifyNewPullRequestRefs({
|
||||
params: latestRef.current,
|
||||
presentPullRequestKeysRef,
|
||||
});
|
||||
const refs = refsReadyForLookup({
|
||||
params: latestRef.current,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
@@ -52,6 +70,15 @@ export function useComposerGithubAutoAttach(
|
||||
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(() => {
|
||||
void attachRefs({
|
||||
refs,
|
||||
@@ -59,11 +86,12 @@ export function useComposerGithubAutoAttach(
|
||||
latestRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
});
|
||||
}).finally(releaseResolving);
|
||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
releaseResolving();
|
||||
};
|
||||
}, [
|
||||
params.text,
|
||||
@@ -86,12 +114,84 @@ export function useComposerGithubAutoAttach(
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isResolving: resolvingRefCounts.size > 0,
|
||||
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({
|
||||
refs,
|
||||
queryClient,
|
||||
@@ -142,16 +242,28 @@ async function attachRef({
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
latestRef.current.setAttachments((current) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
||||
return current;
|
||||
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||
return;
|
||||
}
|
||||
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({
|
||||
@@ -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 {
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user