mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site/src/pages/AgentsPage): consolidate git panel tab strip into a view switcher dropdown (#27012)
## What Replaces the git panel's tab strip (`[PR #4847] [Working coder] [Working other]`) with a single dropdown "view switcher" that lists the PR (when present) and each dirty working repo as items. Also: - Moves the PR title to a dedicated row below the switcher, truncated with a hover tooltip for the full title. The row only appears while the PR view is active. - Removes the redundant PR-state badge from the RemoteDiffPanel sub-header (state is now shown as the colored pill on the switcher trigger). ## Why The common multi-view states are `PR only`, `Working only`, or `PR + one Working repo`. The tab strip was fine for one view but crowded the toolbar the moment two showed up. Collapsing to a single trigger + optional dropdown keeps the toolbar quiet in the 80% case and the switcher stays intuitive when there is more than one thing to look at. ## Screens Storybook coverage: - `PullRequestAndWorkingChanges` — collapsed switcher shows the PR state + `PR #<n>`, PR title below. - `ViewSwitcherOpen` (new) — dropdown open with PR and both working repos, then clicks a working entry to verify the view swaps. - `DraftPullRequest`, `MergedPullRequest`, `ClosedPullRequest` — trigger's colored pill reflects state (play assertions added). - `WorkingChangesOnly`, `BranchOnly`, `MultipleRepos`, `EmptyState`, `GitNotActive`, `GitStatusLoading` — all pass. - `EverDirtyRepoGoneClean`, `CleanRepoFromStart` — regression coverage updated to target the new switcher rather than the old `Working` tab buttons. <details> <summary>Decisions and scoping notes</summary> ### Why a switcher and not a PR-only dropdown Original design showed multiple PRs in the dropdown, but the server model only tracks **one PR per chat** (`ChatDiffStatus.pr_number` is a single field). Multi-PR would need a DB / watcher / SDK change out of scope for a UI polish PR. After reviewing frequency of the actual multi-view states (`PR only` and `Working only` dominate; `PR + Working` shows up during review iteration; 3+ views is rare), we scaled back to make the dropdown a **view switcher** that spans the existing tab strip's data: PR + working repos. Ships today, no backend work. ### Behavior notes - With a single item the trigger renders as a static pill (no chevron, no dropdown behavior); it becomes an interactive dropdown once there is more than one item. - The colored state pill on the trigger uses `text-git-*-bright` / `text-content-warning` / `text-content-secondary` (text color only; no background token). - Dropdown items render as `[icon] primary secondary` where primary/secondary is `PR #<n> / title` for the remote entry and `Working / repo` for local entries. - Local (Working) entries are visually nested under the remote/PR entry when one is present; otherwise they render flush-left. - When the head branch is known but no PR is opened, the trigger reads `Branch | <head-branch>`. </details> --- _Coder Agent generated on behalf of @tracyjohnsonux._
This commit is contained in:
@@ -3,10 +3,6 @@ import {
|
||||
CopyIcon,
|
||||
ExternalLinkIcon,
|
||||
GitBranchIcon,
|
||||
GitMergeIcon,
|
||||
GitPullRequestClosedIcon,
|
||||
GitPullRequestDraftIcon,
|
||||
GitPullRequestIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, type RefObject, useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
@@ -19,7 +15,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { parsePullRequestUrl } from "../../utils/pullRequest";
|
||||
import type { ChatMessageInputRef } from "../AgentChatInput";
|
||||
import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
|
||||
@@ -58,45 +53,6 @@ const BranchCopyButton: FC<{ branch: string }> = ({ branch }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// PR state badge
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const PullRequestStateBadge: FC<{
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
}> = ({ state, draft }) => {
|
||||
let Icon = GitPullRequestIcon;
|
||||
let label = "Open";
|
||||
let colorClasses = "bg-surface-git-added text-git-added-bright";
|
||||
|
||||
if (state === "merged") {
|
||||
Icon = GitMergeIcon;
|
||||
label = "Merged";
|
||||
colorClasses = "bg-surface-git-merged text-git-merged-bright";
|
||||
} else if (state === "closed") {
|
||||
Icon = GitPullRequestClosedIcon;
|
||||
label = "Closed";
|
||||
colorClasses = "bg-surface-git-deleted text-git-deleted-bright";
|
||||
} else if (draft) {
|
||||
Icon = GitPullRequestDraftIcon;
|
||||
label = "Draft";
|
||||
colorClasses = "text-content-secondary";
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-sm border border-solid border-border-default px-2 text-[13px] font-medium leading-5",
|
||||
colorClasses,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -152,8 +108,6 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
||||
// ---------------------------------------------------------------
|
||||
const pullRequestUrl = diffStatus?.url;
|
||||
const parsedPr = pullRequestUrl ? parsePullRequestUrl(pullRequestUrl) : null;
|
||||
const prState = diffStatus?.pull_request_state;
|
||||
const prDraft = diffStatus?.pull_request_draft;
|
||||
const baseBranch = diffStatus?.base_branch;
|
||||
const headBranch = diffStatus?.head_branch;
|
||||
|
||||
@@ -189,7 +143,6 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<PullRequestStateBadge state={prState} draft={prDraft} />
|
||||
{diffStatus?.additions || diffStatus?.deletions ? (
|
||||
<DiffStatBadge
|
||||
additions={diffStatus.additions}
|
||||
|
||||
@@ -140,12 +140,82 @@ export const PullRequestAndWorkingChanges: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// The branch row exposes a button that copies the PR head
|
||||
// branch name. The aria-label embeds the branch so a single
|
||||
// query is enough to assert both presence and target.
|
||||
// The aria-label embeds the head branch, so a single query
|
||||
// asserts both presence and target.
|
||||
await expect(
|
||||
canvas.getByLabelText("Copy branch name: feat/add-mcp-config"),
|
||||
).toBeVisible();
|
||||
|
||||
const switcher = canvas.getByTestId("git-panel-view-switcher");
|
||||
await expect(switcher).toHaveTextContent("Open");
|
||||
await expect(switcher).toHaveTextContent("PR #23020");
|
||||
|
||||
const title = canvas.getByTestId("git-panel-pr-title");
|
||||
await expect(title).toHaveTextContent(
|
||||
"feat(agents): add MCP server configuration to agents",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the dropdown, asserts the PR + working repos appear, then
|
||||
* clicks a working entry to verify the view swap.
|
||||
*/
|
||||
export const ViewSwitcherOpen: Story = {
|
||||
args: {
|
||||
prTab: { prNumber: 23020, chatId: "test-chat" },
|
||||
remoteDiffStats: makePrStatus({
|
||||
pull_request_title: "feat: multi-repo workspace support",
|
||||
head_branch: "feat/multi-repo",
|
||||
}),
|
||||
repositories: new Map([
|
||||
["/home/coder/coder", makeRepo()],
|
||||
[
|
||||
"/home/coder/other-project",
|
||||
makeRepo({
|
||||
repo_root: "/home/coder/other-project",
|
||||
branch: "main",
|
||||
remote_origin: "https://github.com/coder/other-project.git",
|
||||
unified_diff: secondRepoDiff,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getChatDiffContents").mockResolvedValue({
|
||||
...defaultDiffContents,
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const switcher = canvas.getByTestId("git-panel-view-switcher");
|
||||
await userEvent.click(switcher);
|
||||
|
||||
// The Radix menu portals to document.body, so query the full
|
||||
// document instead of the story canvas.
|
||||
const menu = await waitFor(() => {
|
||||
const el = document.querySelector("[role='menu']");
|
||||
if (!el) throw new Error("menu not found");
|
||||
return el as HTMLElement;
|
||||
});
|
||||
await expect(menu).toHaveTextContent("PR #23020");
|
||||
await expect(menu).toHaveTextContent("Working");
|
||||
await expect(menu).toHaveTextContent("coder");
|
||||
await expect(menu).toHaveTextContent("other-project");
|
||||
|
||||
// Selecting a menu item swaps the active view and the trigger
|
||||
// identifier reflects the new selection.
|
||||
const otherProjectItem = within(menu).getByText("other-project");
|
||||
await userEvent.click(otherProjectItem);
|
||||
await waitFor(() => {
|
||||
expect(canvas.getByTestId("git-panel-view-switcher")).toHaveTextContent(
|
||||
"other-project",
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
canvas.getByTestId("git-panel-view-switcher"),
|
||||
).toHaveTextContent("Working");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -172,6 +242,11 @@ export const DraftPullRequest: Story = {
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const switcher = canvas.getByTestId("git-panel-view-switcher");
|
||||
await expect(switcher).toHaveTextContent("Draft");
|
||||
},
|
||||
};
|
||||
|
||||
/** Merged PR. */
|
||||
@@ -194,6 +269,11 @@ export const MergedPullRequest: Story = {
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const switcher = canvas.getByTestId("git-panel-view-switcher");
|
||||
await expect(switcher).toHaveTextContent("Merged");
|
||||
},
|
||||
};
|
||||
|
||||
/** Closed PR. */
|
||||
@@ -216,6 +296,11 @@ export const ClosedPullRequest: Story = {
|
||||
diff: sampleDiff,
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const switcher = canvas.getByTestId("git-panel-view-switcher");
|
||||
await expect(switcher).toHaveTextContent("Closed");
|
||||
},
|
||||
};
|
||||
|
||||
/** Branch pushed but no PR opened yet. */
|
||||
@@ -367,10 +452,8 @@ export const LargeDiff: Story = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Regression: when a repo was dirty during this session and then went
|
||||
* clean (empty unified_diff), the tab must remain visible. Before the
|
||||
* ever-dirty fix, the tab vanished the moment the diff became empty,
|
||||
* which is what users saw as "diff disappears between edit_files".
|
||||
* Regression: a repo that was dirty earlier in the session must
|
||||
* keep its switcher entry even after its unified_diff empties.
|
||||
*/
|
||||
export const EverDirtyRepoGoneClean: Story = {
|
||||
args: {
|
||||
@@ -380,24 +463,24 @@ export const EverDirtyRepoGoneClean: Story = {
|
||||
everDirty: new Set(["/home/coder/coder"]),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// The repo tab is still present (identified by the 'Working'
|
||||
// prefix used by GitPanel's tab-strip button) even though the
|
||||
// current diff is empty, because it was dirty earlier in the
|
||||
// session.
|
||||
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
|
||||
(b) => (b.textContent ?? "").startsWith("Working"),
|
||||
// Before the ever-dirty fix, the entry vanished the moment the
|
||||
// diff emptied and users saw the diff "disappear between
|
||||
// edit_files". The entry must persist here.
|
||||
const switcher = canvasElement.querySelector(
|
||||
"[data-testid='git-panel-view-switcher']",
|
||||
);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(switcher).not.toBeNull();
|
||||
expect(switcher?.textContent ?? "").toContain("Working");
|
||||
|
||||
// The content pane shows the diff viewer's empty-diff state.
|
||||
// The content pane falls through to the diff viewer's empty state.
|
||||
expect(canvasElement.textContent ?? "").toContain("No file changes");
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Baseline: a repo reported clean from the start (never dirty in
|
||||
* this session) has no tab. Ensures the ever-dirty fix did not
|
||||
* regress the "nothing to show" case.
|
||||
* this session) has no switcher entry. Ensures the ever-dirty fix
|
||||
* did not regress the "nothing to show" case.
|
||||
*/
|
||||
export const CleanRepoFromStart: Story = {
|
||||
args: {
|
||||
@@ -407,11 +490,11 @@ export const CleanRepoFromStart: Story = {
|
||||
everDirty: new Set(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
// No local repo tab should appear in the tab strip. The
|
||||
// 'Working' prefix is GitPanel's tab-strip label contract.
|
||||
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
|
||||
(b) => (b.textContent ?? "").startsWith("Working"),
|
||||
const switcher = canvasElement.querySelector(
|
||||
"[data-testid='git-panel-view-switcher']",
|
||||
);
|
||||
expect(tabs).toHaveLength(0);
|
||||
expect(switcher).not.toBeNull();
|
||||
expect(switcher?.textContent ?? "").toContain("No changes");
|
||||
expect(switcher?.textContent ?? "").not.toContain("Working");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CircleDotIcon,
|
||||
ColumnsIcon,
|
||||
GitBranchIcon,
|
||||
@@ -18,7 +19,17 @@ import type {
|
||||
WorkspaceAgentRepoChanges,
|
||||
} from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import type { ChatMessageInputRef } from "../AgentChatInput";
|
||||
import { DiffStatBadge } from "../DiffViewer/DiffStats";
|
||||
@@ -54,7 +65,7 @@ interface GitPanelProps {
|
||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||
/** Callback to send a refresh to the git watcher. Returns false when disconnected. */
|
||||
onRefresh: () => boolean;
|
||||
/** Called when the user clicks the Commit button in any repo tab. */
|
||||
/** Called when the user clicks the Commit button for a working repo. */
|
||||
onCommit: (repoRoot: string) => void;
|
||||
/** Whether the panel is in expanded/fullscreen mode. */
|
||||
isExpanded?: boolean;
|
||||
@@ -66,18 +77,36 @@ interface GitPanelProps {
|
||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||
/**
|
||||
* Repo roots that have been dirty at some point during this session.
|
||||
* Used to keep a repo's tab visible after its diff goes empty, so the
|
||||
* tab strip does not visibly flip when the agent edits a file and
|
||||
* then reverts it.
|
||||
* Used to keep a repo's entry visible after its diff goes empty, so
|
||||
* the view switcher does not visibly flip when the agent edits a
|
||||
* file and then reverts it.
|
||||
*/
|
||||
everDirty?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function repoTabLabel(repoRoot: string): string {
|
||||
function repoLabel(repoRoot: string): string {
|
||||
const segments = repoRoot.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? repoRoot;
|
||||
}
|
||||
|
||||
interface ViewItemBase {
|
||||
id: string;
|
||||
/** Left-pill label on the trigger (e.g. "Open", "Merged", "Working"). */
|
||||
stateLabel: string;
|
||||
/** Right-side label on the trigger (e.g. "PR #4847", "coder"). */
|
||||
triggerIdentifier: string;
|
||||
/** Primary text in the dropdown item (e.g. "PR #4847", "Working"). */
|
||||
itemPrimary: string;
|
||||
/** Secondary text in the dropdown item (e.g. PR title, repo name). */
|
||||
itemSecondary?: string;
|
||||
stateClasses: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
type ViewItem =
|
||||
| (ViewItemBase & { kind: "remote" })
|
||||
| (ViewItemBase & { kind: "local"; repoRoot: string });
|
||||
|
||||
export const GitPanel: FC<GitPanelProps> = ({
|
||||
prTab,
|
||||
repositories,
|
||||
@@ -102,7 +131,8 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
const prState = remoteDiffStats?.pull_request_state;
|
||||
const prDraft = remoteDiffStats?.pull_request_draft;
|
||||
|
||||
// Compute per-repo diff stats from unified diffs.
|
||||
// Compute per-repo diff stats from unified diffs. The React
|
||||
// Compiler memoizes these derivations.
|
||||
const repoStats = (() => {
|
||||
const stats = new Map<string, DiffStats>();
|
||||
for (const [root, repo] of repositories.entries()) {
|
||||
@@ -124,7 +154,7 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
})();
|
||||
|
||||
// Union of currently-dirty and ever-dirty repos (still known to
|
||||
// the watcher) so a clean-revert does not hide the tab.
|
||||
// the watcher) so a clean-revert does not hide the entry.
|
||||
const localRepos = (() => {
|
||||
const roots = new Set<string>(repoStats.keys());
|
||||
if (everDirty) {
|
||||
@@ -137,8 +167,8 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
return Array.from(roots).sort((a, b) => a.localeCompare(b));
|
||||
})();
|
||||
|
||||
// Default to the first local repo when there are only local
|
||||
// changes and no remote stats.
|
||||
// Default to the first local repo when nothing has been pushed
|
||||
// upstream yet, so the panel opens on the diff the user just made.
|
||||
const [view, setView] = useState<GitView>(() => {
|
||||
if (!showRemoteTab && localRepos.length > 0) {
|
||||
return { type: "local", repoRoot: localRepos[0] };
|
||||
@@ -146,7 +176,7 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
return { type: "remote" };
|
||||
});
|
||||
|
||||
// If the active tab gets hidden, switch to the first available.
|
||||
// If the active view gets hidden, switch to the first available.
|
||||
useEffect(() => {
|
||||
if (view.type === "remote" && !showRemoteTab) {
|
||||
if (localRepos.length > 0) {
|
||||
@@ -154,7 +184,7 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
}
|
||||
} else if (view.type === "local") {
|
||||
// localRepos includes ever-dirty repos with empty diffs, so
|
||||
// the active tab stays valid until its root leaves the set.
|
||||
// the active view stays valid until its root leaves the set.
|
||||
if (!localRepos.includes(view.repoRoot)) {
|
||||
if (showRemoteTab) {
|
||||
setView({ type: "remote" });
|
||||
@@ -191,81 +221,121 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
spinTimerRef.current = setTimeout(() => setSpinning(false), 1000);
|
||||
};
|
||||
|
||||
// Reconcile a stale `view` inline so a repo removal never renders
|
||||
// as "No changes" for a frame before the effect above updates.
|
||||
// When nothing else is available, the remote view falls through;
|
||||
// RemoteContent handles its own empty/loading state.
|
||||
const effectiveView: GitView =
|
||||
view.type === "remote"
|
||||
? showRemoteTab
|
||||
? view
|
||||
: localRepos.length > 0
|
||||
? { type: "local", repoRoot: localRepos[0] }
|
||||
: view
|
||||
: localRepos.includes(view.repoRoot)
|
||||
? view
|
||||
: showRemoteTab
|
||||
? { type: "remote" }
|
||||
: localRepos.length > 0
|
||||
? { type: "local", repoRoot: localRepos[0] }
|
||||
: { type: "remote" };
|
||||
|
||||
const showPrTitleRow = effectiveView.type === "remote" && prTab && prTitle;
|
||||
|
||||
const [isPrTitleTruncated, setIsPrTitleTruncated] = useState(false);
|
||||
// Ref callback so the observer attaches whenever the title span
|
||||
// mounts: first render, switching back to the PR view, or a title
|
||||
// change (via `key` on the span). ResizeObserver fires once on
|
||||
// observe, which doubles as the initial truncation check.
|
||||
const observePrTitle = (el: HTMLSpanElement | null) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
setIsPrTitleTruncated(el.scrollWidth > el.clientWidth);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
const remoteHeadBranch = remoteDiffStats?.head_branch;
|
||||
const remoteItem: ViewItem | null = showRemoteTab
|
||||
? prTab
|
||||
? {
|
||||
kind: "remote",
|
||||
id: "remote",
|
||||
stateLabel: prStateLabel(prState, prDraft),
|
||||
triggerIdentifier: `PR #${prTab.prNumber}`,
|
||||
itemPrimary: `PR #${prTab.prNumber}`,
|
||||
itemSecondary: prTitle || undefined,
|
||||
stateClasses: prStateClasses(prState, prDraft),
|
||||
icon: (
|
||||
<PrStateIcon
|
||||
state={prState}
|
||||
draft={prDraft}
|
||||
className="!size-3.5 shrink-0"
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {
|
||||
kind: "remote",
|
||||
id: "remote",
|
||||
stateLabel: "Branch",
|
||||
triggerIdentifier: remoteHeadBranch || "Branch",
|
||||
itemPrimary: "Branch",
|
||||
itemSecondary: remoteHeadBranch || undefined,
|
||||
stateClasses: "text-content-secondary",
|
||||
icon: <GitBranchIcon className="!size-3.5 shrink-0" />,
|
||||
}
|
||||
: null;
|
||||
|
||||
const localItems: ViewItem[] = localRepos.map((repoRoot) => ({
|
||||
kind: "local" as const,
|
||||
id: `local:${repoRoot}`,
|
||||
repoRoot,
|
||||
stateLabel: "Working",
|
||||
triggerIdentifier: repoLabel(repoRoot),
|
||||
itemPrimary: "Working",
|
||||
itemSecondary: repoLabel(repoRoot),
|
||||
stateClasses: "text-content-warning",
|
||||
icon: <CircleDotIcon className="!size-3.5 shrink-0 text-content-warning" />,
|
||||
}));
|
||||
|
||||
const items: ViewItem[] = [
|
||||
...(remoteItem ? [remoteItem] : []),
|
||||
...localItems,
|
||||
];
|
||||
|
||||
const activeItem: ViewItem | undefined =
|
||||
effectiveView.type === "remote"
|
||||
? (remoteItem ?? undefined)
|
||||
: items.find(
|
||||
(item) =>
|
||||
item.kind === "local" && item.repoRoot === effectiveView.repoRoot,
|
||||
);
|
||||
|
||||
const handleSelectItem = (item: ViewItem) => {
|
||||
if (item.kind === "remote") {
|
||||
setView({ type: "remote" });
|
||||
} else {
|
||||
setView({ type: "local", repoRoot: item.repoRoot });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3">
|
||||
{/* Tabs — scrollable when they overflow */}
|
||||
<ScrollArea
|
||||
className="min-w-0 flex-1"
|
||||
orientation="horizontal"
|
||||
scrollBarClassName="h-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-0.5 py-1.5 text-xs">
|
||||
{showRemoteTab && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setView({ type: "remote" })}
|
||||
className={cn(
|
||||
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
|
||||
view.type === "remote" &&
|
||||
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
|
||||
)}
|
||||
>
|
||||
{prTab ? (
|
||||
<>
|
||||
<PrStateIcon
|
||||
state={prState}
|
||||
draft={prDraft}
|
||||
className="!size-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{prTitle || `PR #${prTab.prNumber}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitBranchIcon className="!size-3.5 shrink-0" />
|
||||
<span className="truncate">Branch</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{localRepos.map((repoRoot) => {
|
||||
const isActive =
|
||||
view.type === "local" && view.repoRoot === repoRoot;
|
||||
return (
|
||||
<Button
|
||||
key={repoRoot}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setView({ type: "local", repoRoot })}
|
||||
className={cn(
|
||||
"shrink-0 h-6 min-w-0 gap-1.5 px-2 py-0 bg-surface-primary",
|
||||
isActive &&
|
||||
"bg-surface-quaternary/25 text-content-primary hover:bg-surface-quaternary/50",
|
||||
)}
|
||||
>
|
||||
<CircleDotIcon
|
||||
className={cn(
|
||||
"!size-3.5 shrink-0",
|
||||
isActive
|
||||
? "text-content-warning"
|
||||
: "text-content-warning/60",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
Working{" "}
|
||||
<span className="opacity-50">{repoTabLabel(repoRoot)}</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 pt-1.5 pb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<GitViewSwitcher
|
||||
items={items}
|
||||
activeItem={activeItem}
|
||||
hasRemoteItem={remoteItem !== null}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
</div>
|
||||
{/* Controls */}
|
||||
<div className="flex shrink-0 items-center gap-1 py-1.5">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
|
||||
<button
|
||||
type="button"
|
||||
@@ -323,9 +393,34 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* PR title row: truncates with a hover tooltip only when the
|
||||
visible text is cut off. */}
|
||||
{showPrTitleRow && (
|
||||
<div className="flex shrink-0 items-center px-3">
|
||||
<Tooltip open={isPrTitleTruncated ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
key={prTitle}
|
||||
ref={observePrTitle}
|
||||
className="min-w-0 truncate text-sm font-medium text-content-primary"
|
||||
data-testid="git-panel-pr-title"
|
||||
>
|
||||
{prTitle}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-[260px]"
|
||||
>
|
||||
{prTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{view.type === "remote" ? (
|
||||
{effectiveView.type === "remote" ? (
|
||||
<RemoteContent
|
||||
prTab={prTab}
|
||||
hasGitContext={hasGitContext}
|
||||
@@ -337,10 +432,13 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<LocalRepoContent
|
||||
repoRoot={view.repoRoot}
|
||||
repo={repositories.get(view.repoRoot)}
|
||||
repoRoot={effectiveView.repoRoot}
|
||||
repo={repositories.get(effectiveView.repoRoot)}
|
||||
diffStats={
|
||||
repoStats.get(view.repoRoot) ?? { additions: 0, deletions: 0 }
|
||||
repoStats.get(effectiveView.repoRoot) ?? {
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
onCommit={onCommit}
|
||||
isExpanded={isExpanded}
|
||||
@@ -352,6 +450,127 @@ export const GitPanel: FC<GitPanelProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Git view switcher: dropdown for the active PR/Branch/Working view.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
interface GitViewSwitcherProps {
|
||||
items: ReadonlyArray<ViewItem>;
|
||||
activeItem?: ViewItem;
|
||||
/**
|
||||
* Whether a remote (PR or Branch) item exists in `items`. Controls
|
||||
* whether local entries are visually nested (indented) under it.
|
||||
*/
|
||||
hasRemoteItem: boolean;
|
||||
onSelect: (item: ViewItem) => void;
|
||||
}
|
||||
|
||||
const GitViewSwitcher: FC<GitViewSwitcherProps> = ({
|
||||
items,
|
||||
activeItem,
|
||||
hasRemoteItem,
|
||||
onSelect,
|
||||
}) => {
|
||||
if (!activeItem) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-6 items-center gap-1.5 rounded-md border border-solid border-border-default px-2 text-xs text-content-secondary"
|
||||
data-testid="git-panel-view-switcher"
|
||||
>
|
||||
<GitBranchIcon className="!size-3.5 shrink-0" />
|
||||
<span>No changes</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSingleItem = items.length <= 1;
|
||||
|
||||
const triggerContent = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-full items-center gap-1 rounded-l-md border-0 border-r border-solid border-border-default px-1.5 font-medium leading-none",
|
||||
activeItem.stateClasses,
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||
{activeItem.icon}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{activeItem.stateLabel}</span>
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1 pl-1.5 pr-1 text-content-primary">
|
||||
<span className="truncate">{activeItem.triggerIdentifier}</span>
|
||||
{!isSingleItem && (
|
||||
<ChevronDownIcon className="size-3 shrink-0 opacity-70" />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isSingleItem) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex h-6 min-w-0 max-w-full items-stretch overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-xs"
|
||||
data-testid="git-panel-view-switcher"
|
||||
>
|
||||
{triggerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-stretch overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary text-xs transition-colors hover:bg-surface-secondary"
|
||||
data-testid="git-panel-view-switcher"
|
||||
aria-label="Switch git view"
|
||||
>
|
||||
{triggerContent}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-[240px] p-1"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeItem.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => onSelect(item)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||||
// Nest local entries under the remote/PR entry
|
||||
// when one exists. Without a parent above them,
|
||||
// nesting reads as an orphan indent.
|
||||
item.kind === "local" && hasRemoteItem
|
||||
? "ml-4 mt-0.5"
|
||||
: "w-full",
|
||||
isActive && "bg-surface-secondary text-content-primary",
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="whitespace-nowrap font-medium">
|
||||
{item.itemPrimary}
|
||||
</span>
|
||||
{item.itemSecondary && (
|
||||
<span className="min-w-0 flex-1 truncate text-content-secondary">
|
||||
{item.itemSecondary}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Remote view (branch/PR diff)
|
||||
// ---------------------------------------------------------------
|
||||
@@ -470,7 +689,7 @@ const RepoHeader: FC<{
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-content-secondary">
|
||||
<GitBranchIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{repo.branch?.trim() || repoTabLabel(repoRoot)}
|
||||
{repo.branch?.trim() || repoLabel(repoRoot)}
|
||||
</span>
|
||||
<span className="truncate opacity-50">{repoRoot}</span>
|
||||
</div>
|
||||
@@ -494,7 +713,33 @@ const RepoHeader: FC<{
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// PR state icon (compact, for the tab bar)
|
||||
// PR state helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/** Human-readable state label for the view-switcher trigger. */
|
||||
function prStateLabel(state: string | undefined, draft: boolean | undefined) {
|
||||
if (state === "merged") return "Merged";
|
||||
if (state === "closed") return "Closed";
|
||||
if (draft) return "Draft";
|
||||
return "Open";
|
||||
}
|
||||
|
||||
/** Tailwind classes for the state pill on the view-switcher trigger. */
|
||||
function prStateClasses(state: string | undefined, draft: boolean | undefined) {
|
||||
if (state === "merged") {
|
||||
return "text-git-merged-bright";
|
||||
}
|
||||
if (state === "closed") {
|
||||
return "text-git-deleted-bright";
|
||||
}
|
||||
if (draft) {
|
||||
return "text-content-secondary";
|
||||
}
|
||||
return "text-git-added-bright";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// PR state icon (compact, for the view switcher)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
export const PrStateIcon: FC<{
|
||||
@@ -502,24 +747,15 @@ export const PrStateIcon: FC<{
|
||||
draft?: boolean;
|
||||
className?: string;
|
||||
}> = ({ state, draft, className }) => {
|
||||
const colorClass = prStateClasses(state, draft);
|
||||
if (state === "merged") {
|
||||
return <GitMergeIcon className={cn("text-git-merged-bright", className)} />;
|
||||
return <GitMergeIcon className={cn(colorClass, className)} />;
|
||||
}
|
||||
if (state === "closed") {
|
||||
return (
|
||||
<GitPullRequestClosedIcon
|
||||
className={cn("text-git-deleted-bright", className)}
|
||||
/>
|
||||
);
|
||||
return <GitPullRequestClosedIcon className={cn(colorClass, className)} />;
|
||||
}
|
||||
if (draft) {
|
||||
return (
|
||||
<GitPullRequestDraftIcon
|
||||
className={cn("text-content-secondary", className)}
|
||||
/>
|
||||
);
|
||||
return <GitPullRequestDraftIcon className={cn(colorClass, className)} />;
|
||||
}
|
||||
return (
|
||||
<GitPullRequestIcon className={cn("text-git-added-bright", className)} />
|
||||
);
|
||||
return <GitPullRequestIcon className={cn(colorClass, className)} />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user