Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,33 @@ describe("BranchListBox", () => {
expect(props.onSelect).not.toHaveBeenCalled();
});

it("highlights the local row when the worktree base is the origin ref", () => {
const props = baseProps();
const branch = {
name: "main",
current: true,
commit: "abc123",
isRemote: false,
};

render(
<BranchListBox
{...props}
hasLocal
hasRemote={false}
worktreeMode
value="origin/main"
baseBranch="origin/main"
items={[
{ type: "header", id: "header-local", name: msg`Local` },
{ type: "branch", id: branch.name, branch },
]}
/>,
);

expect(screen.getByRole("option", { name: "main" })).toHaveAttribute("aria-selected", "true");
});

it("marks worktree branches with a fork icon and a thread-count badge", () => {
const props = baseProps();
const branch = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { PixelLoader } from "../../PixelLoader";
import { useResponsiveMenu } from "../../ResponsiveMenuSurface";
import type { BranchListItem } from "./useBranchList";
import { localBranchNameFromRef } from "./worktreeBaseRef";

const STATUS_DOT_CLASS: Record<StatusTone, string> = {
inactive: "bg-muted/40",
Expand Down Expand Up @@ -101,17 +102,19 @@ export function BranchListBox(props: {
);
}

const selectedKey = isWorktree || worktreeMode ? (baseBranch ?? value) : value;
const selectedRef = isWorktree || worktreeMode ? (baseBranch ?? value) : value;
const listedBranches = items.flatMap((item) => (item.type === "branch" ? [item.branch] : []));
const selectedKey = items.some((item) => item.type === "branch" && item.id === selectedRef)
? selectedRef
: localBranchNameFromRef(selectedRef, listedBranches);

return (
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight, padding: 8 }}>
<ListBox
aria-label={t`Branches`}
className={`poracode-menu max-h-60 overflow-y-auto ${mobile ? "" : VIRTUALIZED_COMPACT_DROPDOWN_ITEM_CLASS}`}
items={items}
selectedKeys={
isWorktree || worktreeMode ? new Set([baseBranch ?? value]) : new Set([value])
}
selectedKeys={new Set([selectedKey])}
selectionMode="single"
disallowEmptySelection
onSelectionChange={(keys) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,27 @@ export function useBranchList(params: { projectId: string; search: string }) {
}
}

const remoteQualifiedNames = new Set(
allBranches
.filter((branch) => branch.isRemote && branch.remote)
.map((branch) => `${branch.remote}/${branch.name}`),
);
const normalizedSearch = search.trim().toLowerCase();
const allLocal: GitBranchInfo[] = [];
const allRemote: GitBranchInfo[] = [];
for (const branch of deduped) {
if (normalizedSearch && !branch.name.toLowerCase().includes(normalizedSearch)) {
continue;
if (normalizedSearch) {
const qualified =
branch.isRemote && branch.remote ? `${branch.remote}/${branch.name}` : undefined;
const originAlias =
!branch.isRemote && remoteQualifiedNames.has(`origin/${branch.name}`)
? `origin/${branch.name}`
: undefined;
const haystack = [branch.name, qualified, originAlias]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!haystack.includes(normalizedSearch)) continue;
}
if (branch.isRemote) {
allRemote.push(branch);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment node

import { describe, expect, it } from "vitest";
import type { GitBranchInfo } from "@/shared/contracts";
import {
isCurrentCheckoutRef,
localBranchNameFromRef,
resolveWorktreeOriginRef,
} from "./worktreeBaseRef";

function local(name: string): GitBranchInfo {
return { name, current: name === "master", commit: "abc", isRemote: false };
}

function remote(name: string, remoteName = "origin"): GitBranchInfo {
return { name, current: false, commit: "def", isRemote: true, remote: remoteName };
}

describe("localBranchNameFromRef", () => {
it("strips a known origin prefix even without a branch list", () => {
expect(localBranchNameFromRef("origin/master")).toBe("master");
expect(localBranchNameFromRef("origin/feature/x")).toBe("feature/x");
});

it("uses the remote field when the listed remote is not origin", () => {
expect(localBranchNameFromRef("upstream/release", [remote("release", "upstream")])).toBe(
"release",
);
});

it("leaves a local name unchanged", () => {
expect(localBranchNameFromRef("master", [local("master")])).toBe("master");
});
});

describe("resolveWorktreeOriginRef", () => {
it("keeps an already-qualified remote ref", () => {
expect(resolveWorktreeOriginRef("origin/master", [local("master"), remote("master")])).toBe(
"origin/master",
);
});

it("maps a local name to its tracking ref", () => {
expect(
resolveWorktreeOriginRef("master", [local("master"), remote("master")], "origin/master"),
).toBe("origin/master");
});

it("maps a local name to origin even without status.tracking when the remote exists", () => {
expect(resolveWorktreeOriginRef("feature/x", [local("feature/x"), remote("feature/x")])).toBe(
"origin/feature/x",
);
});

it("uses tracking when the branch list has not loaded yet", () => {
expect(resolveWorktreeOriginRef("master", [], "origin/master")).toBe("origin/master");
});

it("falls back to the local name when no remote counterpart exists", () => {
expect(resolveWorktreeOriginRef("wip", [local("wip")])).toBe("wip");
});

it("prefers a non-origin remote when that is the only match", () => {
expect(
resolveWorktreeOriginRef("release", [local("release"), remote("release", "upstream")]),
).toBe("upstream/release");
});
});

describe("isCurrentCheckoutRef", () => {
it("matches the local checkout or its tracking ref", () => {
expect(isCurrentCheckoutRef("master", "master", "origin/master")).toBe(true);
expect(isCurrentCheckoutRef("origin/master", "master", "origin/master")).toBe(true);
expect(isCurrentCheckoutRef("develop", "master", "origin/master")).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { GitBranchInfo } from "@/shared/contracts";

export function qualifiedRemoteName(branch: GitBranchInfo): string | undefined {
if (!branch.isRemote || !branch.remote) return undefined;
return `${branch.remote}/${branch.name}`;
}

/** Map `origin/main` (or another remote-qualified ref) back to the local short name. */
export function localBranchNameFromRef(
ref: string,
branches: readonly GitBranchInfo[] = [],
): string {
const remoteMatch = branches.find((branch) => qualifiedRemoteName(branch) === ref);
if (remoteMatch) return remoteMatch.name;
if (ref.startsWith("origin/")) return ref.slice("origin/".length);
return ref;
}

/**
* Worktree (no changes) should fork from the origin-tracking ref when one
* exists — same as T3's start-from-origin default. Selecting "main" in the
* picker must stay on `origin/main`, not flip to the local checkout.
*/
export function resolveWorktreeOriginRef(
branchName: string,
branches: readonly GitBranchInfo[] = [],
tracking?: string | null,
): string {
if (branches.some((branch) => qualifiedRemoteName(branch) === branchName)) {
return branchName;
}

const localName = localBranchNameFromRef(branchName, branches);

if (tracking) {
const trackingLocal = localBranchNameFromRef(tracking, branches);
if (trackingLocal === localName) return tracking;
}

const origin = branches.find(
(branch) =>
branch.isRemote && (branch.remote ?? "origin") === "origin" && branch.name === localName,
);
const originName = origin ? qualifiedRemoteName(origin) : undefined;
if (originName) return originName;

const anyRemote = branches.find(
(branch) => branch.isRemote && branch.name === localName && branch.remote,
);
return (anyRemote ? qualifiedRemoteName(anyRemote) : undefined) ?? localName;
}

export function isCurrentCheckoutRef(
ref: string,
currentBranch: string | undefined,
tracking?: string | null,
): boolean {
if (!currentBranch) return false;
return ref === currentBranch || (tracking != null && ref === tracking);
}
66 changes: 56 additions & 10 deletions src/renderer/components/thread/ThreadDraftComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
import type {
AgentHookPluginStatus,
AgentStatus,
GitBranchInfo,
Project,
PromptSegment,
ThreadConfig,
Expand Down Expand Up @@ -92,6 +93,13 @@ import {
import { useKeybindingStore } from "@/renderer/commands/keybindingStore";
import { handleComposerControlShortcut } from "./threadComposerShortcuts";
import { WorktreeModeSelect, type WorktreeMode } from "./WorktreeModeSelect";
import {
isCurrentCheckoutRef,
localBranchNameFromRef,
resolveWorktreeOriginRef,
} from "@/renderer/components/common/BranchSelector/parts/worktreeBaseRef";

const EMPTY_BRANCHES: GitBranchInfo[] = [];

// Optional fields admit explicit `undefined` so wire shapes with
// `prop?: T | undefined` (e.g. the zod-parsed quick-composer submission)
Expand Down Expand Up @@ -463,14 +471,15 @@ export function ThreadDraftComposerArea(props: {
// changes" affordance only appears when the new worktree forks from the
// current (dirty) checkout — the only case where transferring is meaningful.
const projectStatus = useGitStore((s) => s.statuses[props.project.id]);
const projectBranches = useGitStore(
(s) => s.branches[props.project.id]?.branches ?? EMPTY_BRANCHES,
);
const hasUncommittedChanges =
!!projectStatus && projectStatus.staged.length + projectStatus.unstaged.length > 0;
const trackingWorktreeBase =
projectStatus &&
props.gitBranch &&
projectStatus.branch === props.gitBranch &&
projectStatus.behind > 0 &&
projectStatus.ahead === 0 &&
projectStatus.tracking
? projectStatus.tracking
: undefined;
Expand All @@ -497,6 +506,10 @@ export function ThreadDraftComposerArea(props: {
? "new-with-changes"
: "new";

function resolveOriginBase(branchName: string): string {
return resolveWorktreeOriginRef(branchName, projectBranches, projectStatus?.tracking);
}

function selectNewWorktree(overrides?: Partial<BranchSelection>) {
const base = overrides?.baseBranch ?? worktreeBase ?? props.gitBranch ?? "";
setBranchSelection({ branch: base, baseBranch: base, isWorktree: true, ...overrides });
Expand All @@ -512,13 +525,44 @@ export function ThreadDraftComposerArea(props: {
// Keep an existing worktree selection (e.g. a worktreePath from "New thread
// in worktree") intact rather than rebuilding it into a brand-new branch.
if (branchSelection?.worktreePath) return;
const baseBranch = mode === "new-with-changes" ? props.gitBranch : defaultWorktreeBase;
// Worktree + changes must fork from the local checkout so uncommitted
// files can be copied. Plain worktree uses the origin ref (T3-style).
const localBase = props.gitBranch;
const originBase = defaultWorktreeBase ? resolveOriginBase(defaultWorktreeBase) : localBase;
const baseBranch = mode === "new-with-changes" ? localBase : originBase;
selectNewWorktree({
...(baseBranch ? { baseBranch } : {}),
transferUncommitted: mode === "new-with-changes",
});
}

function handleBranchSelect(selection: BranchSelection) {
if (selection.worktreePath || !selection.isWorktree) {
setBranchSelection(selection);
return;
}
const selected = selection.baseBranch ?? selection.branch;
const keepChanges =
(shouldTransferUncommitted || selection.transferUncommitted === true) &&
isCurrentCheckoutRef(selected, props.gitBranch, projectStatus?.tracking);
if (keepChanges) {
const localName = localBranchNameFromRef(selected, projectBranches);
setBranchSelection({
...selection,
branch: localName,
baseBranch: localName,
transferUncommitted: true,
});
return;
}
const originBase = resolveOriginBase(selected);
setBranchSelection({
...selection,
branch: originBase,
baseBranch: originBase,
});
}

const computerUseScope =
disabledBuiltInMcpServers[COMPUTER_USE_MCP_ID] === true
? "none"
Expand Down Expand Up @@ -1150,12 +1194,11 @@ export function ThreadDraftComposerArea(props: {
onToggle: (next: boolean) => {
setExperimentMode(next);
if (next) {
setExperimentBaseBranch(
const rawBase =
branchSelection?.baseBranch ??
branchSelection?.branch ??
defaultWorktreeBase ??
null,
);
branchSelection?.branch ??
defaultWorktreeBase;
setExperimentBaseBranch(rawBase ? resolveOriginBase(rawBase) : null);
} else {
setExperimentCandidates([]);
setExperimentBaseBranch(null);
Expand Down Expand Up @@ -1205,8 +1248,11 @@ export function ThreadDraftComposerArea(props: {
{...(!experimentMode ? { onWorktreeModeChange: props.onWorktreeModeChange } : {})}
onSelect={
experimentMode
? (selection) => setExperimentBaseBranch(selection.baseBranch ?? selection.branch)
: setBranchSelection
? (selection) =>
setExperimentBaseBranch(
resolveOriginBase(selection.baseBranch ?? selection.branch),
)
: handleBranchSelect
}
onSwitchBranch={props.onSwitchBranch}
hideWorktreeToggle
Expand Down
Loading