Skip to content
Closed
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
288 changes: 231 additions & 57 deletions src-tauri/src/lib.rs

Large diffs are not rendered by default.

67 changes: 64 additions & 3 deletions src/components/task/GitPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
ChevronRight, ChevronDown, ArrowDown, ArrowUp, List, ListTree, Rows3, Check, Eye, Search, Trash2, MessageSquare, Loader2, GitBranch, GitMerge, RotateCw, FileText,
} from "lucide-react";
import type { Task, GitStatus, GitRepo, GitFile, UpdateMode, UpdateInfo } from "@/lib/types";
import { taskStage, taskUnstage, taskCommit, taskDiscard, taskGitBranches, taskGitCheckout, taskGitUpdate, taskGitUpdateInfo, taskGitPush } from "@/lib/ipc";
import { taskStage, taskUnstage, taskCommit, taskDiscard, taskGitBranches, taskGitCheckout, taskGitUpdate, taskGitUpdateAll, taskGitUpdateInfo, taskGitPush } from "@/lib/ipc";
import { useApp } from "@/store/app";
import { useUI } from "@/store/ui";
import { usePrefs } from "@/store/prefs";
Expand All @@ -46,7 +46,7 @@ import { CopyPathItems } from "./CopyPathItems";
import { HistoryPanel, ScopePicker } from "./HistoryPanel";
import { ComparePanel } from "./ComparePanel";
import { fileIconUrl, folderIconUrl } from "@/lib/explorer/iconResolver";
import { PrCard } from "./PrCard";
import { PrCard, MemberPrRows } from "./PrCard";
import { usePr } from "@/store/pr";

// Per-side status → glyph / fill / ink / label, shared with Compare (GH #208)
Expand Down Expand Up @@ -476,6 +476,9 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub
return (
<div className="flex h-full flex-col">
{!task.is_main_checkout && <PrCard task={task} />}
{/* Members are their own checkouts - they keep their rows even on a
main-checkout task (only the HOST card is wrong there). */}
<MemberPrRows task={task} />
{!nonGit && <BranchBar task={task} branch={status.repos[0]?.branch ?? task.branch} dir="" />}
<div className="px-3 py-3 text-[13.5px] text-[var(--color-fg-faint)]">
{nonGit
Expand Down Expand Up @@ -543,6 +546,11 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub
never a PR/MR whose head is that branch, so there's nothing here
worth polling for. */}
{!task.is_main_checkout && <PrCard task={task} />}
{/* Multi-repo members each carry their own PR/MR - the task card
above only ever polls the host worktree. Members are their own
checkouts, so they keep their rows even on a main-checkout task
(where only the host card is hidden). */}
<MemberPrRows task={task} />
{/* 0. Repo sub-tabs (wrapping pills). OUTERMOST of the three controls
here: which repo you are looking at is what the branch bar and all
three sub-tabs below are ABOUT, so it cannot sit inside them. */}
Expand Down Expand Up @@ -594,6 +602,7 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub
task={task}
branch={repo?.branch ?? task.branch}
dir={dir}
multi={repos.length > 1}
right={<div className="relative ml-auto flex min-w-[30%] flex-1 items-center">
<Search className="pointer-events-none absolute left-2 h-3.5 w-3.5 text-[var(--color-fg-faint)]" />
<input
Expand Down Expand Up @@ -852,10 +861,12 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub
// rebase) via task_git_update. Conflicts are surfaced as error toasts, not
// swallowed - the op is left in progress for the user to resolve in the
// terminal.
function BranchBar({ task, branch, dir, right }: {
function BranchBar({ task, branch, dir, multi, right }: {
task: Task;
branch: string;
dir: string;
/** More than one repo on this task — offers the "all repos" update items. */
multi?: boolean;
/** Rendered on the right of the same row. The branch chip is one short
* control on a full-width row, so the filter rides with it rather than
* spending a row of its own in a panel that drags down to 220px. */
Expand Down Expand Up @@ -923,6 +934,38 @@ function BranchBar({ task, branch, dir, right }: {
.finally(() => setUpdating(false));
};

// Same op across the host and every member repo, one summary toast. A repo
// the mode can't serve (no upstream for pull, no base for merge/rebase)
// reports its own reason — the batch does not stop at it.
const runUpdateAll = (mode: UpdateMode) => {
if (updating || switching) return;
setUpdating(true);
taskGitUpdateAll(task.id, mode)
.then(items => {
setBranches(null); // stale after an update - reload on next open
setInfo(null);
useApp.getState().bumpFsRevision(task.id);
const bad = items.filter(i => i.error || i.result?.conflicted || i.result?.stash_conflicted);
if (bad.length === 0) {
const updated = items.filter(i => i.result && !i.result.up_to_date).length;
pushToast(t(updated === 0
? "gitPanel.allUpToDate"
: updated === items.length
? "gitPanel.updatedAll"
: "gitPanel.updatedSome", { updated, count: items.length }));
return;
}
const conflictKey = mode === "rebase" ? "gitPanel.rebaseConflict" : "gitPanel.mergeConflict";
const detail = bad.map(i => `${i.name}: ${i.error?.replace(/[.;]+$/, "") ?? t(
i.result!.conflicted ? conflictKey : "gitPanel.stashConflict",
{ branch: i.result!.branch, target: i.result!.target },
)}`).join("; ");
pushToast(t("gitPanel.updateAttention", { bad: bad.length, total: items.length, detail }), "error", { ttlMs: 8000 });
})
.catch(e => pushToast(String(e), "error"))
.finally(() => setUpdating(false));
};

const switchTo = (target: string) => {
if (switching || target === branch) return;
setSwitching(true);
Expand Down Expand Up @@ -1023,6 +1066,24 @@ function BranchBar({ task, branch, dir, right }: {
</>
);
})()}
{multi && (
<>
<DropdownLabel>{t("gitPanel.allRepos")}</DropdownLabel>
<DropdownItem onSelect={() => runUpdateAll("pull")} className="items-center">
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-[var(--color-fg-dim)]" />
<span className="truncate text-[12px]">{t("gitPanel.pullAll")}</span>
</DropdownItem>
<DropdownItem onSelect={() => runUpdateAll("merge")} className="items-center">
<GitMerge className="h-3.5 w-3.5 shrink-0 text-[var(--color-fg-dim)]" />
<span className="truncate text-[12px]">{t("gitPanel.mergeAll")}</span>
</DropdownItem>
<DropdownItem onSelect={() => runUpdateAll("rebase")} className="items-center">
<RotateCw className="h-3.5 w-3.5 shrink-0 text-[var(--color-fg-dim)]" />
<span className="truncate text-[12px]">{t("gitPanel.rebaseAll")}</span>
</DropdownItem>
<DropdownSeparator />
</>
)}
{!branches || branches.length === 0 ? (
<div className="px-2 py-1.5 text-[12px] text-[var(--color-fg-faint)]">{t("gitPanel.noBranches")}</div>
) : (
Expand Down
74 changes: 71 additions & 3 deletions src/components/task/PrCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
GitPullRequest, GitPullRequestDraft, GitPullRequestClosed, GitMerge,
CircleCheck, CircleX, ExternalLink, RefreshCw, Plus, Bell, BellOff,
} from "lucide-react";
import type { PrStatus, Task } from "@/lib/types";
import type { MemberPrLookup, PrStatus, Task } from "@/lib/types";
import { azurePatLoginCmd, forgeCli, forgeInstallCmd, forgeLoginCmd, forgeName, prRef } from "@/lib/forge";
import { openPath } from "@/lib/ipc";
import { usePr, watchTickNow } from "@/store/pr";
Expand Down Expand Up @@ -292,9 +292,9 @@ export function PrCard({ task }: { task: Task }) {
);
}

function Card({ children }: { children: ReactNode }) {
function Card({ children, testId = "pr-card" }: { children: ReactNode; testId?: string }) {
return (
<div data-testid="pr-card" className="shrink-0 border-b border-[var(--color-border-soft)] px-2.5 py-2">
<div data-testid={testId} className="shrink-0 border-b border-[var(--color-border-soft)] px-2.5 py-2">
{children}
</div>
);
Expand Down Expand Up @@ -368,3 +368,71 @@ function RefreshBtn({ spinning, onClick }: { spinning: boolean; onClick: () => v
</Tip>
);
}

/** Per-member PR/MR rows for a multi-repo task. The task-level card above
* covers the host worktree (the task's own `path`; `composition` holds
* only NON-host members); these cover the members, each resolved by the
* branch checked out in its own repo. Rendered only for members that
* resolved to a forge-backed result (status "ok"): no-remote/unsupported
* rows are noise, and an error/cli-missing hint would read as "no PR" on
* a compact row - the board card applies the same rule. Renders nothing
* until a refresh landed, so a task whose members were never polled never
* flashes empty rows. A member with no PR keeps the bare name+branch
* row: on a multi-repo task "this repo has no PR yet" is the answer. */
export function MemberPrRows({ task }: { task: Task }) {
const members = usePr(s => s.byTask[task.id]?.members);
const refresh = usePr(s => s.refresh);
const hasMembers = (task.composition?.length ?? 0) > 0;
// PrCard owns the mount/tick polling while it's mounted. On a
// main-checkout task it never mounts, so member rows tick for
// themselves - refresh() polls only members for such a task. A
// main checkout with NO members has no PR surface at all: skip
// the interval rather than churn a fresh PrEntry every minute.
useEffect(() => {
if (!task.is_main_checkout || !hasMembers) return;
void refresh(task.id);
const t = window.setInterval(() => refresh(task.id, true), POLL_MS);
return () => window.clearInterval(t);
}, [task.id, task.is_main_checkout, hasMembers, refresh]);
if (!members?.length) return null;
const rows = members.filter(m => m.status === "ok");
if (!rows.length) return null;
return (
<Card testId="member-pr-rows">
{rows.map(m => <MemberPrRow key={m.dir_name} m={m} />)}
</Card>
);
}

function MemberPrRow({ m }: { m: MemberPrLookup }) {
const { t } = useTranslation("panels");
const pr = m.pr;
const st = pr ? STATE[pr.state] : null;
return (
<div className="flex items-center gap-2 py-0.5" data-testid="member-pr-row">
<span className="min-w-0 flex-1 truncate text-[12px] leading-none text-[var(--color-fg-dim)]">
{m.dir_name}
{m.branch ? <span className="font-mono text-[var(--color-fg-faint)]"> · {m.branch}</span> : null}
</span>
{pr && st ? (
<>
<Tip content={t("pr.stateTip", { state: t(st.labelKey), provider: forgeName(pr.provider) })} side="bottom">
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[11.5px] font-medium leading-none" style={{ color: st.color }}>
<st.Icon className="h-3 w-3 -translate-y-px" />
{prRef(pr.provider, pr.number)}
</span>
</Tip>
<ChecksChip checks={pr.checks} />
<Tip content={t("pr.openOn", { provider: forgeName(pr.provider) })} side="bottom">
<button
onClick={() => openPath(pr.url).catch(() => {})}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[var(--color-fg-dim)] hover:bg-[var(--color-hover)] hover:text-[var(--color-fg)]"
>
<ExternalLink className="h-3 w-3" />
</button>
</Tip>
</>
) : null}
</div>
);
}
4 changes: 2 additions & 2 deletions src/components/task/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useApp, useActiveTask } from "@/store/app";
import { useUI } from "@/store/ui";
import { usePr, watchTickNow } from "@/store/pr";
import { usePr, watchTickNow, prRelevant } from "@/store/pr";
import { useAlignedSpin } from "@/hooks/useAlignedSpin";
import {
taskGitStatus, taskRunScriptStream, repoConfigLoad, repoConfigLoadAt,
Expand Down Expand Up @@ -248,7 +248,7 @@ export function RightPanel() {
const doRefresh = () => {
refreshGit();
setFileTreeReload(n => n + 1);
if (task && !task.is_main_checkout) {
if (task && prRelevant(task)) {
usePr.getState().refresh(task.id, true);
watchTickNow();
}
Expand Down
9 changes: 5 additions & 4 deletions src/components/task/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { PopoverRoot, PopoverTrigger, PopoverContent } from "@/components/ui/Pop
import { useUI } from "@/store/ui";
import { EMPTY_TABS, isUserWatching, useApp } from "@/store/app";
import { logWorkState } from "@/lib/workStateLog";
import { usePr } from "@/store/pr";
import { usePr, prRelevant } from "@/store/pr";
import {
QUIET_MS, SAMPLE_MS, SCROLLBACK_STABLE_SAMPLES, SETTLE_SAMPLES,
} from "@/lib/settleTiming";
Expand Down Expand Up @@ -2920,11 +2920,12 @@ const captureArmedRef = useRef(false);
// Launching a task is exactly the moment its PR/MR status is worth
// knowing, not something to wait on the user opening the Git tab
// for - only the primary agent tab counts as "the task launched",
// and only for a task that could ever have one (main checkouts
// never do, see GitPanel). Unforced: the store's own 30s floor still
// and only for a task with any PR surface (a main checkout has no
// host PR of its own, but member repos still can - see GitPanel).
// Unforced: the store's own 30s floor still
// applies, so a crash-looping agent respawning repeatedly doesn't
// turn this into a hammer.
if (isAgent && isPrimaryTab && !task.is_main_checkout) {
if (isAgent && isPrimaryTab && prRelevant(task)) {
usePr.getState().refresh(task.id);
}
// If the spawn survives RESUME_FAILURE_MS without exiting, we
Expand Down
Loading