mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03743b55a3 | ||
|
|
62fa9bc39d |
No files matched your search
@@ -507,6 +507,11 @@ impl Repo {
|
||||
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
|
||||
refs::branch_info(self)
|
||||
}
|
||||
/// Current branch (short name; `None` when detached) and HEAD commit, without
|
||||
/// upstream tracking (cheap enough to call for every repository on each refresh).
|
||||
pub fn head(&self) -> IdeResult<(Option<String>, Option<String>)> {
|
||||
refs::head(&self.gix()?)
|
||||
}
|
||||
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
|
||||
pub fn operation(&self) -> IdeResult<Option<String>> {
|
||||
Ok(refs::operation(&self.gix()?))
|
||||
|
||||
@@ -30,6 +30,11 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult<BranchInfo> {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub(crate) fn head(repo: &gix::Repository) -> IdeResult<(Option<String>, Option<String>)> {
|
||||
let name = repo.head_name().map_err(gx)?.map(|n| to_string(n.as_ref().shorten()));
|
||||
Ok((name, repo.head_id().ok().map(|id| id.to_string())))
|
||||
}
|
||||
|
||||
pub(crate) fn operation(repo: &gix::Repository) -> Option<String> {
|
||||
use gix::state::InProgress::*;
|
||||
let op = match repo.state()? {
|
||||
|
||||
@@ -865,6 +865,7 @@ fn checkout_create_detached_delete_rebase() {
|
||||
|
||||
repo.checkout("topic", true).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
|
||||
assert_eq!(repo.head().unwrap(), (Some("topic".to_string()), Some(c1.clone())));
|
||||
fx.write("t", "topic\n");
|
||||
fx.commit_all("topic work");
|
||||
repo.checkout("main", false).unwrap();
|
||||
@@ -879,6 +880,7 @@ fn checkout_create_detached_delete_rebase() {
|
||||
let info = repo.branch_info().unwrap();
|
||||
assert_eq!(info.head, None);
|
||||
assert_eq!(info.head_commit.as_deref(), Some(c1.as_str()));
|
||||
assert_eq!(repo.head().unwrap(), (None, Some(c1.clone())));
|
||||
assert!(
|
||||
repo.log(&LogQuery { branch: Some("HEAD".into()), ..Default::default() }).unwrap()[0]
|
||||
.refs
|
||||
|
||||
@@ -41,6 +41,10 @@ pub struct VcsRoot {
|
||||
pub root: ide_core::IpcPath,
|
||||
/// Merge / rebase / cherry-pick / revert in progress.
|
||||
pub operation: Option<String>,
|
||||
/// Current branch; `None` when HEAD is detached (or unreadable).
|
||||
pub head: Option<String>,
|
||||
/// HEAD commit; `None` before the first commit.
|
||||
pub head_commit: Option<String>,
|
||||
}
|
||||
|
||||
/// The project's repositories, the primary one first.
|
||||
@@ -53,7 +57,10 @@ pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<Vc
|
||||
all.sort_by_key(|r| Some(r.root()) != primary.as_deref());
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() })
|
||||
.map(|r| {
|
||||
let (head, head_commit) = r.head().unwrap_or_default();
|
||||
VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten(), head, head_commit }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
||||
@@ -5150,6 +5150,10 @@ export type VcsRoot = {
|
||||
root: IpcPath,
|
||||
/** Merge / rebase / cherry-pick / revert in progress. */
|
||||
operation: string | null,
|
||||
/** Current branch; `None` when HEAD is detached (or unreadable). */
|
||||
head: string | null,
|
||||
/** HEAD commit; `None` before the first commit. */
|
||||
headCommit: string | null,
|
||||
};
|
||||
|
||||
/** Version Control (main page) and its sub-pages. */
|
||||
|
||||
@@ -29,37 +29,6 @@
|
||||
content: " /";
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.repos {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
}
|
||||
.repoButton {
|
||||
max-width: 160px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
border-radius: var(--radius-small);
|
||||
background: transparent;
|
||||
color: var(--fg-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.repoButton:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.repoSelected,
|
||||
.repoSelected:hover {
|
||||
background: var(--bg-selected);
|
||||
color: var(--fg);
|
||||
}
|
||||
.operation {
|
||||
color: var(--warning);
|
||||
font-size: var(--fs-tiny);
|
||||
@@ -75,6 +44,9 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup.wide {
|
||||
width: 440px;
|
||||
}
|
||||
.search {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -83,6 +55,9 @@
|
||||
padding: 6px 6px 6px 4px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
}
|
||||
.searchGrow {
|
||||
flex: 1;
|
||||
}
|
||||
.listWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -194,3 +169,33 @@
|
||||
[role="option"]:hover .star:not(.starOn) .branchIcon {
|
||||
visibility: hidden;
|
||||
}
|
||||
.separator {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
margin: 0 4px;
|
||||
background: var(--border-popup);
|
||||
}
|
||||
.repoColor {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.repoHead {
|
||||
flex: 0 4 auto;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.subChevron {
|
||||
flex: none;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.groupRow {
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import type { Branch, BranchInfo } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import {
|
||||
BarButton,
|
||||
HighlightedText,
|
||||
@@ -19,10 +21,20 @@ import {
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { buildBranchRows, type BranchRow, type PopupAction } from "./branches";
|
||||
import { branchMenu, tagMenu } from "./branchMenu";
|
||||
import { branchMenu, commonBranchMenu, inRepo, tagMenu } from "./branchMenu";
|
||||
import { loadFavoriteBranches, recentBranches, toggleFavoriteBranch, useFavoriteBranches } from "./branchOps";
|
||||
import { inProgress, operationTitle, type InProgress } from "./operations";
|
||||
import { currentRepo, loadBranches, repoName, selectRepo, useVcs } from "./store";
|
||||
import {
|
||||
buildMultiRepoRows,
|
||||
currentRepoFor,
|
||||
DEFAULT_EXPANDED,
|
||||
repoHeadLabel,
|
||||
toggleExpanded,
|
||||
widgetShowsRepo,
|
||||
type RepoEntry,
|
||||
type RepoRow,
|
||||
} from "./repoBranches";
|
||||
import { currentRepo, focusRepo, loadBranches, repoName, useVcs } from "./store";
|
||||
import styles from "./BranchWidget.module.css";
|
||||
|
||||
export const useBranchesPopup = create<{ open: boolean }>(() => ({ open: false }));
|
||||
@@ -34,14 +46,29 @@ export function headLabel(branch: { head: string | null; headCommit: string | nu
|
||||
return branch.headCommit ? branch.headCommit.slice(0, 8) : "No commits";
|
||||
}
|
||||
|
||||
/** The current repository (see `currentRepoFor`): the widget and the popup show the same one. */
|
||||
function useCurrentRepo(): string | null {
|
||||
const path = useEditorRegistry((s) => s.active?.ctx.path ?? null);
|
||||
return useVcs((s) => currentRepoFor(path, s.roots, s.repo));
|
||||
}
|
||||
|
||||
export function BranchWidget() {
|
||||
const available = useVcs((s) => s.available);
|
||||
const branch = useVcs((s) => s.branch);
|
||||
// With several repositories the widget names the selected one, like IDEA's.
|
||||
const repo = useVcs((s) => (s.roots.length > 1 && s.repo ? repoName(s.repo) : null));
|
||||
const selected = useVcs((s) => s.branch);
|
||||
const count = useVcs((s) => s.roots.length);
|
||||
const current = useCurrentRepo();
|
||||
// Several repositories on different branches: the widget names the current one.
|
||||
const diverged = useVcs((s) => widgetShowsRepo(s.roots.map((r) => ({ head: s.heads[r]?.head ?? null }))));
|
||||
// The current repository is not the selected one: its HEAD from the last scan (no upstream).
|
||||
const other = useVcs((s) => (diverged && current && current !== s.repo ? (s.heads[current] ?? null) : null));
|
||||
const otherOp = useVcs((s) => (current ? (s.operations[current] ?? null) : null));
|
||||
const open = useBranchesPopup((s) => s.open);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
if (!available || !branch) return null;
|
||||
if (!available || !selected) return null;
|
||||
const repo = diverged && current ? repoName(current) : null;
|
||||
const branch: BranchInfo = other
|
||||
? { head: other.head, headCommit: other.headCommit, upstream: null, ahead: 0, behind: 0, operation: otherOp }
|
||||
: selected;
|
||||
const op = branch.operation ? ` (${branch.operation})` : "";
|
||||
const sync = [
|
||||
branch.behind > 0 ? `${branch.behind} incoming` : "",
|
||||
@@ -52,6 +79,7 @@ export function BranchWidget() {
|
||||
(branch.upstream
|
||||
? `Git branch: ${headLabel(branch)}${op}\nTracking ${branch.upstream}`
|
||||
: `Git branch: ${headLabel(branch)}${op}`) +
|
||||
(count > 1 && !repo ? `\nIn all ${count} repositories` : "") +
|
||||
(sync.length ? `\n${sync.join(", ")} commits` : "");
|
||||
return (
|
||||
<>
|
||||
@@ -92,6 +120,12 @@ const POPUP_ACTIONS: PopupAction[] = [
|
||||
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" },
|
||||
];
|
||||
|
||||
/** A single repository's own entries (its submenu in the popup with several repositories). */
|
||||
const REPO_ACTIONS: PopupAction[] = [
|
||||
{ id: "vcs.newBranch", label: "New Branch…", icon: "plus" },
|
||||
{ id: "vcs.checkoutRevision", label: "Checkout Tag or Revision…", icon: "branch" },
|
||||
];
|
||||
|
||||
/** Entries IDEA puts at the top of the Branches popup while an operation is stopped. */
|
||||
export function operationActions(op: InProgress | null, conflicts: number): PopupAction[] {
|
||||
if (!op) return [];
|
||||
@@ -118,42 +152,105 @@ export function operationActions(op: InProgress | null, conflicts: number): Popu
|
||||
return out;
|
||||
}
|
||||
|
||||
const NO_BRANCHES: readonly Branch[] = [];
|
||||
const INDENT = 16;
|
||||
const ROW_HEIGHT = 24;
|
||||
const SEPARATOR_HEIGHT = 9;
|
||||
const listHeight = (rows: readonly { type: string; key: string }[]) =>
|
||||
Math.min(
|
||||
440,
|
||||
Math.max(
|
||||
ROW_HEIGHT * 3,
|
||||
rows.reduce(
|
||||
(h, r) => h + (r.type === "section" && r.key.startsWith("separator:") ? SEPARATOR_HEIGHT : ROW_HEIGHT),
|
||||
0,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/** The option element of the row with `key`, to anchor a submenu on. */
|
||||
function rowElement(list: HTMLElement | null, key: string): HTMLElement | null {
|
||||
const el = list?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(key)}"]`);
|
||||
return el ? ((el.closest('[role="option"]') as HTMLElement | null) ?? el) : null;
|
||||
}
|
||||
|
||||
/** Caret at the end (→ opens) / start (← closes) of the search field. */
|
||||
const caretAt = (e: KeyboardEvent, where: "start" | "end") => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.selectionStart !== input.selectionEnd) return false;
|
||||
return where === "end" ? input.selectionStart === input.value.length : input.selectionStart === 0;
|
||||
};
|
||||
|
||||
function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | null>; onClose: () => void }) {
|
||||
const branches = useVcs((s) => s.branches);
|
||||
const loaded = useVcs((s) => s.branchesLoaded);
|
||||
const current = useVcs((s) => s.branch?.head ?? null);
|
||||
const multi = useVcs((s) => s.roots.length > 1);
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
onClose={onClose}
|
||||
placement="bottom-start"
|
||||
aria-label="Git Branches"
|
||||
className={clsx(styles.popup, multi && styles.wide)}
|
||||
>
|
||||
{multi ? <MultiRepoPanel onClose={onClose} /> : <BranchesPanel onClose={onClose} actions={POPUP_ACTIONS} />}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
interface BranchesPanelProps {
|
||||
onClose: () => void;
|
||||
actions: readonly PopupAction[];
|
||||
/** A repository other than the selected one (the submenu of a repository): it is selected before acting. */
|
||||
root?: string;
|
||||
/** Its branches until the selected repository's are loaded. */
|
||||
fallback?: readonly Branch[];
|
||||
/** ← at the start of the search field (back to the repositories). */
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
/** Search, actions and the branches of one repository (the selected one, or `root`). */
|
||||
function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }: BranchesPanelProps) {
|
||||
const selected = useVcs((s) => s.repo);
|
||||
const live = root === undefined || selected === root;
|
||||
const storeBranches = useVcs((s) => s.branches);
|
||||
const storeLoaded = useVcs((s) => s.branchesLoaded);
|
||||
const branches = live && (storeLoaded || !fallback) ? storeBranches : (fallback ?? NO_BRANCHES);
|
||||
const loaded = (live && storeLoaded) || !!fallback;
|
||||
const current = useVcs((s) =>
|
||||
live ? (s.branch?.head ?? null) : root ? (s.heads[root]?.head ?? null) : (s.branch?.head ?? null),
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [recent] = useState(recentBranches);
|
||||
const [active, setActive] = useState(0);
|
||||
const [menuFor, setMenuFor] = useState<{ items: MenuItem[]; label: string; el: HTMLElement } | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const repo = useVcs((s) => s.repo);
|
||||
const favorites = useFavoriteBranches((s) => s.names);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
void loadBranches().catch(() => undefined);
|
||||
if (root === undefined) void loadBranches().catch(() => undefined);
|
||||
loadFavoriteBranches();
|
||||
}, []);
|
||||
}, [root]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
call(commands.vcsTags(currentRepo())).then(
|
||||
call(commands.vcsTags(root ?? currentRepo())).then(
|
||||
(t) => live && setTags(t),
|
||||
() => live && setTags([]),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [repo, branches]);
|
||||
}, [root, selected, branches]);
|
||||
|
||||
const op = useVcs((s) => inProgress(s.branch?.operation));
|
||||
const op = useVcs((s) => inProgress(root ? s.operations[root] : s.branch?.operation));
|
||||
const conflicts = useVcs((s) => s.status.filter((f) => f.unstaged === "conflicted").length);
|
||||
const actions = useMemo(() => [...operationActions(op, conflicts), ...POPUP_ACTIONS], [op, conflicts]);
|
||||
const actions = useMemo(() => [...operationActions(op, conflicts), ...baseActions], [op, conflicts, baseActions]);
|
||||
const rows = useMemo(
|
||||
() => buildBranchRows(branches, recent, query, actions, favorites, tags),
|
||||
[branches, recent, query, actions, favorites, tags],
|
||||
);
|
||||
const isSection = (r: BranchRow) => r.type === "section";
|
||||
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
|
||||
const scoped = (items: MenuItem[]) => (root ? inRepo(root, items) : items);
|
||||
|
||||
const closeAll = () => {
|
||||
setMenuFor(null);
|
||||
@@ -162,13 +259,12 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
|
||||
const openMenu = (i: number) => {
|
||||
const row = rows[i];
|
||||
if (row?.type !== "branch" && row?.type !== "tag") return;
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(row.key)}"]`);
|
||||
const el = rowElement(listRef.current, row.key);
|
||||
if (!el) return;
|
||||
const anchorEl = (el.closest('[role="option"]') as HTMLElement | null) ?? el;
|
||||
setMenuFor(
|
||||
row.type === "branch"
|
||||
? { items: branchMenu(row.branch, current, closeAll), label: `Branch ${row.branch.name}`, el: anchorEl }
|
||||
: { items: tagMenu(row.tag, closeAll), label: `Tag ${row.tag}`, el: anchorEl },
|
||||
? { items: scoped(branchMenu(row.branch, current, closeAll)), label: `Branch ${row.branch.name}`, el }
|
||||
: { items: scoped(tagMenu(row.tag, closeAll)), label: `Tag ${row.tag}`, el },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -177,7 +273,8 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
|
||||
if (!row) return;
|
||||
if (row.type === "action") {
|
||||
onClose();
|
||||
void executeAction(row.action.id, row.action.arg);
|
||||
const { id, arg } = row.action;
|
||||
void (root ? focusRepo(root) : Promise.resolve()).then(() => executeAction(id, arg));
|
||||
} else if (row.type === "branch" || row.type === "tag") {
|
||||
openMenu(i);
|
||||
}
|
||||
@@ -191,13 +288,17 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
|
||||
}
|
||||
const type = rows[activeIndex]?.type;
|
||||
if (e.key === "ArrowRight" && (type === "branch" || type === "tag")) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.selectionStart === input.value.length) {
|
||||
if (caretAt(e, "end")) {
|
||||
e.preventDefault();
|
||||
openMenu(activeIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" && onBack && caretAt(e, "start")) {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: rows, active: activeIndex, isSection, wrap: true });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
@@ -205,35 +306,26 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
|
||||
}
|
||||
};
|
||||
|
||||
const rowHeight = 24;
|
||||
const height = Math.min(440, Math.max(rowHeight * 3, rows.length * rowHeight));
|
||||
|
||||
const label = root ? `Search branches of ${repoName(root)}` : "Search branches and actions";
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={anchor}
|
||||
onClose={onClose}
|
||||
placement="bottom-start"
|
||||
aria-label="Git Branches"
|
||||
className={styles.popup}
|
||||
>
|
||||
<RepositoryBar />
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<SearchField
|
||||
className={root ? styles.searchGrow : undefined}
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setActive(0);
|
||||
}}
|
||||
placeholder="Search branches and actions"
|
||||
aria-label="Search branches and actions"
|
||||
placeholder={label}
|
||||
aria-label={label}
|
||||
variant="bare"
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />
|
||||
{!root && <IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />}
|
||||
</div>
|
||||
<div ref={listRef} style={{ height }} className={styles.listWrap}>
|
||||
<div ref={listRef} style={{ height: listHeight(rows) }} className={styles.listWrap}>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
@@ -244,60 +336,371 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
|
||||
onActivate={(_r, i) => activate(i)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={rowHeight}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
aria-label="Branches"
|
||||
className={styles.list}
|
||||
empty={<span className={styles.empty}>{loaded ? "No matching branches" : "Loading…"}</span>}
|
||||
renderItem={(r) => <BranchRowView row={r} />}
|
||||
/>
|
||||
</div>
|
||||
{menuFor && (
|
||||
<Popup
|
||||
open
|
||||
anchor={menuFor.el}
|
||||
placement="right-start"
|
||||
offset={2}
|
||||
role="presentation"
|
||||
autoFocus={false}
|
||||
onClose={() => setMenuFor(null)}
|
||||
className={styles.menuPopup}
|
||||
>
|
||||
<MenuList
|
||||
items={menuFor.items}
|
||||
onDone={() => setMenuFor(null)}
|
||||
onCloseLevel={() => setMenuFor(null)}
|
||||
aria-label={menuFor.label}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
{menuFor && <SubMenu {...menuFor} onClose={() => setMenuFor(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SubMenu({
|
||||
items,
|
||||
label,
|
||||
el,
|
||||
onClose,
|
||||
}: {
|
||||
items: MenuItem[];
|
||||
label: string;
|
||||
el: HTMLElement;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={el}
|
||||
placement="right-start"
|
||||
offset={2}
|
||||
role="presentation"
|
||||
autoFocus={false}
|
||||
onClose={onClose}
|
||||
className={styles.menuPopup}
|
||||
>
|
||||
<MenuList items={items} onDone={onClose} onCloseLevel={onClose} aria-label={label} />
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
/** Repository switcher (several repositories): branches, log and push act on the selected one. */
|
||||
function RepositoryBar() {
|
||||
const EXPANDED_KEY = "openidea.vcs.branchesPopup.expanded";
|
||||
|
||||
function loadExpanded(): Set<string> {
|
||||
try {
|
||||
const v: unknown = JSON.parse(localStorage.getItem(EXPANDED_KEY) ?? "null");
|
||||
if (Array.isArray(v)) return new Set(v.filter((x): x is string => typeof x === "string"));
|
||||
} catch {
|
||||
// Storage unavailable: defaults.
|
||||
}
|
||||
return new Set(DEFAULT_EXPANDED);
|
||||
}
|
||||
|
||||
function saveExpanded(expanded: ReadonlySet<string>) {
|
||||
try {
|
||||
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]));
|
||||
} catch {
|
||||
// Expansion is remembered for the session only.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branches popup of a project with several repositories: actions; the repositories,
|
||||
* each opening its own branches (on hover or →); then the branches they all have
|
||||
* (acting on all of them) or, when their branches differ, the current repository's.
|
||||
*/
|
||||
function MultiRepoPanel({ onClose }: { onClose: () => void }) {
|
||||
const roots = useVcs((s) => s.roots);
|
||||
const repo = useVcs((s) => s.repo);
|
||||
if (roots.length < 2) return null;
|
||||
const heads = useVcs((s) => s.heads);
|
||||
const operations = useVcs((s) => s.operations);
|
||||
const version = useVcs((s) => s.version);
|
||||
const favorites = useFavoriteBranches((s) => s.names);
|
||||
const current = useCurrentRepo();
|
||||
const [recent] = useState(recentBranches);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(loadExpanded);
|
||||
const [all, setAll] = useState<Record<string, Branch[]>>({});
|
||||
const [menuFor, setMenuFor] = useState<{ items: MenuItem[]; label: string; el: HTMLElement } | null>(null);
|
||||
const [repoMenu, setRepoMenu] = useState<{ root: string; el: HTMLElement; focus: boolean } | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
/** Back from a repository's submenu to the search field. */
|
||||
const closeRepoMenu = () => {
|
||||
setRepoMenu(null);
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => loadFavoriteBranches(), []);
|
||||
// Every repository's branches (common ones, submenus, search); refreshed after operations.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void Promise.all(
|
||||
roots.map((r) =>
|
||||
call(commands.vcsBranches(r)).then(
|
||||
(list) => [r, list] as const,
|
||||
() => [r, [] as Branch[]] as const,
|
||||
),
|
||||
),
|
||||
).then((entries) => live && setAll(Object.fromEntries(entries)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [roots, version]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
if (current)
|
||||
call(commands.vcsTags(current)).then(
|
||||
(t) => live && setTags(t),
|
||||
() => live && setTags([]),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [current, version]);
|
||||
|
||||
const repos = useMemo<RepoEntry[]>(
|
||||
() =>
|
||||
roots.map((root) => ({
|
||||
root,
|
||||
name: repoName(root),
|
||||
head: heads[root]?.head ?? null,
|
||||
headCommit: heads[root]?.headCommit ?? null,
|
||||
})),
|
||||
[roots, heads],
|
||||
);
|
||||
const op = useVcs((s) => inProgress(s.branch?.operation));
|
||||
const conflicts = useVcs((s) => s.status.filter((f) => f.unstaged === "conflicted").length);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
...operationActions(op, conflicts),
|
||||
...POPUP_ACTIONS.map((a) => (a.id === "vcs.newBranch" ? { ...a, arg: { repos: roots } } : a)),
|
||||
],
|
||||
[op, conflicts, roots],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildMultiRepoRows({ repos, branches: all, query, actions, expanded, favorites, current, recent, tags }),
|
||||
[repos, all, query, actions, expanded, favorites, current, recent, tags],
|
||||
);
|
||||
const isSection = (r: RepoRow) => r.type === "section";
|
||||
const activeIndex = rows[active] && !isSection(rows[active]!) ? active : firstSelectable(rows, isSection);
|
||||
|
||||
const closeAll = () => {
|
||||
setMenuFor(null);
|
||||
setRepoMenu(null);
|
||||
onClose();
|
||||
};
|
||||
/** Opens the submenu of row `i`; `focus` moves the keyboard into a repository's submenu. */
|
||||
const openSub = (i: number, focus: boolean) => {
|
||||
const row = rows[i];
|
||||
if (!row) return;
|
||||
const el = rowElement(listRef.current, row.key);
|
||||
if (!el) return;
|
||||
if (row.type === "repo") {
|
||||
setMenuFor(null);
|
||||
setRepoMenu((prev) =>
|
||||
prev?.root === row.repo.root && prev.el === el && prev.focus === focus
|
||||
? prev
|
||||
: { root: row.repo.root, el, focus },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRepoMenu(null);
|
||||
if (row.type === "common") {
|
||||
setMenuFor({ items: commonBranchMenu(row.branch, roots, closeAll), label: `Branch ${row.branch.name}`, el });
|
||||
} else if (row.type === "branch") {
|
||||
const items = inRepo(row.repo.root, branchMenu(row.branch, row.repo.head, closeAll));
|
||||
setMenuFor({ items, label: `Branch ${row.branch.name} of ${row.repo.name}`, el });
|
||||
} else if (row.type === "tag") {
|
||||
setMenuFor({ items: inRepo(row.repo.root, tagMenu(row.tag, closeAll)), label: `Tag ${row.tag}`, el });
|
||||
}
|
||||
};
|
||||
const setOpen = (key: string, open: boolean) =>
|
||||
setExpanded((prev) => {
|
||||
if (prev.has(key) === open) return prev;
|
||||
const next = toggleExpanded(prev, key);
|
||||
saveExpanded(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
const activate = (i: number) => {
|
||||
const row = rows[i];
|
||||
if (!row) return;
|
||||
if (row.type === "action") {
|
||||
onClose();
|
||||
void executeAction(row.action.id, row.action.arg);
|
||||
} else if (row.type === "group") {
|
||||
if (!query.trim()) setOpen(row.key, !row.expanded);
|
||||
} else {
|
||||
openSub(i, true);
|
||||
}
|
||||
};
|
||||
// The pointer on a repository opens its branches right away; elsewhere it closes them.
|
||||
const hover = (i: number) => {
|
||||
setActive(i);
|
||||
if (rows[i]?.type === "repo") openSub(i, false);
|
||||
else setRepoMenu(null);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
activate(activeIndex);
|
||||
return;
|
||||
}
|
||||
const row = rows[activeIndex];
|
||||
if (e.key === "ArrowRight" && row && row.type !== "action" && row.type !== "section") {
|
||||
if (!caretAt(e, "end")) return;
|
||||
e.preventDefault();
|
||||
if (row.type === "group") {
|
||||
if (!query.trim()) setOpen(row.key, true);
|
||||
} else openSub(activeIndex, true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" && row?.type === "group" && row.expanded) {
|
||||
if (!caretAt(e, "start") || query.trim()) return;
|
||||
e.preventDefault();
|
||||
setOpen(row.key, false);
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: rows, active: activeIndex, isSection, wrap: true });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
setActive(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.repos} role="group" aria-label="Repository">
|
||||
{roots.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={clsx(styles.repoButton, r === repo && styles.repoSelected)}
|
||||
aria-pressed={r === repo}
|
||||
title={r}
|
||||
onClick={() => void selectRepo(r).then(() => loadBranches().catch(() => undefined))}
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<SearchField
|
||||
ref={searchRef}
|
||||
className={styles.searchGrow}
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setActive(0);
|
||||
}}
|
||||
placeholder="Search for branches, actions, repositories"
|
||||
aria-label="Search for branches, actions, repositories"
|
||||
variant="bare"
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<IconButton icon="git-fetch" title="Fetch" size="small" action="vcs.fetch" />
|
||||
<IconButton
|
||||
icon="settings"
|
||||
title="Git Settings"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
closeAll();
|
||||
void executeAction("file.settings", "vcs.git");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} style={{ height: listHeight(rows) }} className={styles.listWrap}>
|
||||
<List
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={isSection}
|
||||
sectionHeightOf={(r) => (r.type === "section" && !r.label ? SEPARATOR_HEIGHT : undefined)}
|
||||
renderSection={(r) =>
|
||||
r.type === "section" && r.label ? (
|
||||
<span className={styles.section}>{r.label}</span>
|
||||
) : (
|
||||
<span className={styles.separator} role="separator" />
|
||||
)
|
||||
}
|
||||
activeIndex={activeIndex}
|
||||
onActiveChange={hover}
|
||||
onActivate={(_r, i) => activate(i)}
|
||||
focusable={false}
|
||||
hoverSelects
|
||||
rowHeight={ROW_HEIGHT}
|
||||
aria-label="Repositories and branches"
|
||||
className={styles.list}
|
||||
empty={<span className={styles.empty}>No matching repositories or branches</span>}
|
||||
renderItem={(r) => (
|
||||
<RepoRowView row={r} operation={r.type === "repo" ? operations[r.repo.root] : undefined} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{menuFor && <SubMenu {...menuFor} onClose={() => setMenuFor(null)} />}
|
||||
{repoMenu && (
|
||||
<Popup
|
||||
open
|
||||
anchor={repoMenu.el}
|
||||
placement="right-start"
|
||||
offset={2}
|
||||
autoFocus={repoMenu.focus}
|
||||
aria-label={`Branches of ${repoName(repoMenu.root)}`}
|
||||
onClose={closeRepoMenu}
|
||||
className={styles.popup}
|
||||
>
|
||||
{repoName(r)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<BranchesPanel
|
||||
key={repoMenu.root}
|
||||
root={repoMenu.root}
|
||||
fallback={all[repoMenu.root]}
|
||||
actions={REPO_ACTIONS}
|
||||
onClose={closeAll}
|
||||
onBack={closeRepoMenu}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchRowView({ row }: { row: BranchRow }) {
|
||||
function RepoRowView({ row, operation }: { row: RepoRow; operation?: string }) {
|
||||
switch (row.type) {
|
||||
case "action":
|
||||
case "branch":
|
||||
case "tag":
|
||||
return <BranchRowView row={row} />;
|
||||
case "repo":
|
||||
return (
|
||||
<span className={styles.row} data-row-key={row.key}>
|
||||
<span
|
||||
className={styles.repoColor}
|
||||
style={{ background: `var(--project-color-${row.color})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<HighlightedText className={styles.label} text={row.repo.name} indices={row.matches} tone="accent" />
|
||||
{operation && <span className={styles.operation}>{operation}</span>}
|
||||
<span className={styles.repoHead}>{repoHeadLabel(row.repo)}</span>
|
||||
<Icon name="chevron-right" size={10} className={styles.subChevron} />
|
||||
</span>
|
||||
);
|
||||
case "group":
|
||||
return (
|
||||
<span
|
||||
className={clsx(styles.row, row.depth === 0 && styles.groupRow)}
|
||||
data-row-key={row.key}
|
||||
style={row.depth ? { paddingLeft: row.depth * INDENT } : undefined}
|
||||
aria-expanded={row.expanded}
|
||||
>
|
||||
<Icon name={row.expanded ? "chevron-down" : "chevron-right"} size={12} className={styles.rowIcon} />
|
||||
{row.depth > 0 && <Icon name="folder" size={14} className={styles.rowIcon} />}
|
||||
<span className={styles.label}>{row.label}</span>
|
||||
</span>
|
||||
);
|
||||
case "common":
|
||||
return (
|
||||
<span className={styles.row} data-row-key={row.key} style={{ paddingLeft: row.depth * INDENT }}>
|
||||
<Icon
|
||||
name={row.branch.current ? "current-branch" : row.branch.favorite ? "star" : "branch"}
|
||||
size={14}
|
||||
className={clsx(styles.rowIcon, row.branch.current && styles.currentIcon)}
|
||||
/>
|
||||
<HighlightedText
|
||||
className={clsx(styles.label, styles.branchName, row.branch.current && styles.current)}
|
||||
text={row.label}
|
||||
indices={row.matches}
|
||||
tone="accent"
|
||||
/>
|
||||
{row.branch.current && <span className={styles.tag}>current</span>}
|
||||
<Icon name="chevron-right" size={10} className={styles.chevron} />
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function BranchRowView({ row }: { row: BranchRow | Extract<RepoRow, { type: "action" | "branch" | "tag" }> }) {
|
||||
const indent = "depth" in row && row.depth ? { paddingLeft: row.depth * INDENT } : undefined;
|
||||
if (row.type === "action") {
|
||||
const keys = shortcutLabel(row.action.id);
|
||||
return (
|
||||
@@ -310,7 +713,7 @@ function BranchRowView({ row }: { row: BranchRow }) {
|
||||
}
|
||||
if (row.type === "tag") {
|
||||
return (
|
||||
<span className={styles.row} data-row-key={row.key}>
|
||||
<span className={styles.row} data-row-key={row.key} style={indent}>
|
||||
<Icon name="tag" size={14} className={styles.rowIcon} />
|
||||
<HighlightedText
|
||||
className={clsx(styles.label, styles.branchName)}
|
||||
@@ -325,7 +728,7 @@ function BranchRowView({ row }: { row: BranchRow }) {
|
||||
if (row.type !== "branch") return null;
|
||||
const b = row.branch;
|
||||
return (
|
||||
<span className={styles.row} data-row-key={row.key}>
|
||||
<span className={styles.row} data-row-key={row.key} style={indent}>
|
||||
{b.current ? (
|
||||
<Icon name="current-branch" size={14} className={clsx(styles.rowIcon, styles.currentIcon)} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Branch } from "@/bindings";
|
||||
import {
|
||||
buildMultiRepoRows,
|
||||
commonBranches,
|
||||
currentRepoFor,
|
||||
DEFAULT_EXPANDED,
|
||||
repoColorIndex,
|
||||
repoHeadLabel,
|
||||
toggleExpanded,
|
||||
widgetShowsRepo,
|
||||
type RepoEntry,
|
||||
type RepoRow,
|
||||
} from "../repoBranches";
|
||||
|
||||
const b = (name: string, opts: Partial<Branch> = {}): Branch => ({
|
||||
name,
|
||||
remote: name.startsWith("origin/") || name.startsWith("upstream/"),
|
||||
current: false,
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
lastCommitMs: 0,
|
||||
commit: "",
|
||||
...opts,
|
||||
});
|
||||
|
||||
const repo = (name: string, head: string | null = "dev"): RepoEntry => ({
|
||||
root: `/p/${name}`,
|
||||
name,
|
||||
head,
|
||||
headCommit: "0123456789abcdef",
|
||||
});
|
||||
|
||||
// The reported layout: several repositories, all on `dev`.
|
||||
const repos = [repo("ology-collect"), repo("ology-dependencies"), repo("ology-gateway")];
|
||||
const branches: Record<string, Branch[]> = {
|
||||
"/p/ology-collect": [
|
||||
b("dev", { current: true }),
|
||||
b("main"),
|
||||
b("collect-only"),
|
||||
b("origin/HEAD"),
|
||||
b("origin/dev"),
|
||||
b("origin/main"),
|
||||
b("origin/feature/x"),
|
||||
],
|
||||
"/p/ology-dependencies": [b("dev", { current: true }), b("main"), b("origin/dev"), b("origin/main")],
|
||||
"/p/ology-gateway": [b("dev", { current: true }), b("main"), b("origin/dev"), b("origin/main"), b("gw-fix")],
|
||||
};
|
||||
const actions = [
|
||||
{ id: "vcs.update", label: "Update Project…" },
|
||||
{ id: "vcs.newBranch", label: "New Branch…" },
|
||||
];
|
||||
|
||||
const summary = (rows: RepoRow[]) =>
|
||||
rows.map((r) => {
|
||||
switch (r.type) {
|
||||
case "section":
|
||||
return r.label ? `# ${r.label}` : "---";
|
||||
case "action":
|
||||
return `> ${r.action.label}`;
|
||||
case "repo":
|
||||
return `repo ${r.repo.name} [${r.repo.head}] c${r.color}`;
|
||||
case "group":
|
||||
return `${" ".repeat(r.depth)}${r.expanded ? "v" : ">"} ${r.label}`;
|
||||
case "common":
|
||||
return `${" ".repeat(r.depth)}${r.label}${r.branch.current ? " (current)" : ""}`;
|
||||
case "branch":
|
||||
return `${" ".repeat(r.depth)}${r.repo.name}:${r.branch.name}`;
|
||||
case "tag":
|
||||
return `${" ".repeat(r.depth)}tag ${r.tag}`;
|
||||
}
|
||||
});
|
||||
|
||||
describe("currentRepoFor (shared by the widget and the popup)", () => {
|
||||
const roots = ["/p", "/p/ology-collect", "/p/ology-collect/sub", "/p/ology-gateway"];
|
||||
it.each([
|
||||
[
|
||||
"the innermost repository of the active file",
|
||||
"/p/ology-collect/src/A.java",
|
||||
"/p/ology-gateway",
|
||||
"/p/ology-collect",
|
||||
],
|
||||
["a nested repository's file", "/p/ology-collect/sub/x.ts", null, "/p/ology-collect/sub"],
|
||||
["the project's own repository", "/p/README.md", "/p/ology-gateway", "/p"],
|
||||
["no active file: the selected repository", null, "/p/ology-gateway", "/p/ology-gateway"],
|
||||
["a file outside every repository: the selected one", "/elsewhere/a.txt", "/p/ology-gateway", "/p/ology-gateway"],
|
||||
["a selected repository that is gone: the first", null, "/gone", "/p"],
|
||||
["nothing selected: the first", null, null, "/p"],
|
||||
])("%s", (_name, active, selected, expected) => {
|
||||
expect(currentRepoFor(active, roots, selected)).toBe(expected);
|
||||
});
|
||||
|
||||
it("is null without repositories", () => {
|
||||
expect(currentRepoFor("/p/a", [], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("widgetShowsRepo", () => {
|
||||
it.each([
|
||||
["one repository", [{ head: "main" }], false],
|
||||
["all on the same branch", [{ head: "dev" }, { head: "dev" }, { head: "dev" }], false],
|
||||
["one on another branch", [{ head: "dev" }, { head: "feature" }, { head: "dev" }], true],
|
||||
["one detached", [{ head: "dev" }, { head: null }], true],
|
||||
["all detached", [{ head: null }, { head: null }], true],
|
||||
["no repositories", [], false],
|
||||
])("%s", (_name, heads, shows) => {
|
||||
expect(widgetShowsRepo(heads)).toBe(shows);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commonBranches", () => {
|
||||
it("keeps the branches every repository has, current in all first, then favourites", () => {
|
||||
const common = commonBranches(Object.values(branches));
|
||||
expect(common.local.map((x) => [x.name, x.current, x.favorite])).toEqual([
|
||||
["dev", true, false],
|
||||
["main", false, true],
|
||||
]);
|
||||
expect(common.remote.map((x) => x.name)).toEqual(["origin/main", "origin/dev"]);
|
||||
});
|
||||
|
||||
it("is current only when checked out everywhere", () => {
|
||||
const common = commonBranches([[b("dev", { current: true })], [b("dev")]]);
|
||||
expect(common.local).toEqual([{ name: "dev", remote: false, current: false, favorite: false }]);
|
||||
});
|
||||
|
||||
it("does not match a local branch with a remote one of the same name", () => {
|
||||
expect(commonBranches([[b("origin/x", { remote: false })], [b("origin/x")]])).toEqual({ local: [], remote: [] });
|
||||
expect(commonBranches([])).toEqual({ local: [], remote: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMultiRepoRows", () => {
|
||||
const input = { repos, branches, actions, expanded: new Set(DEFAULT_EXPANDED) };
|
||||
|
||||
it("lists actions, repositories with their branch, then the common groups", () => {
|
||||
expect(summary(buildMultiRepoRows({ ...input, query: "" }))).toEqual([
|
||||
"> Update Project…",
|
||||
"> New Branch…",
|
||||
"---",
|
||||
"repo ology-collect [dev] c1",
|
||||
"repo ology-dependencies [dev] c2",
|
||||
"repo ology-gateway [dev] c3",
|
||||
"---",
|
||||
"v Common Local Branches",
|
||||
" dev (current)",
|
||||
" main",
|
||||
"> Common Remote Branches",
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups common remote branches by remote when expanded", () => {
|
||||
const expanded = toggleExpanded(toggleExpanded(new Set(), "common:remote"), "common:folder:origin");
|
||||
expect(summary(buildMultiRepoRows({ ...input, expanded, query: "" })).slice(-6)).toEqual([
|
||||
"---",
|
||||
"> Common Local Branches",
|
||||
"v Common Remote Branches",
|
||||
" v origin",
|
||||
" main",
|
||||
" dev",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters repositories and branches, expands every group and lists each repository's own matches", () => {
|
||||
expect(summary(buildMultiRepoRows({ ...input, expanded: new Set(), query: "dev" }))).toEqual([
|
||||
"v Common Local Branches",
|
||||
" dev (current)",
|
||||
"v Common Remote Branches",
|
||||
" v origin",
|
||||
" dev",
|
||||
]);
|
||||
expect(summary(buildMultiRepoRows({ ...input, query: "deps" }))).toEqual(["repo ology-dependencies [dev] c2"]);
|
||||
expect(summary(buildMultiRepoRows({ ...input, query: "fix" }))).toEqual([
|
||||
"# ology-gateway",
|
||||
"ology-gateway:gw-fix",
|
||||
]);
|
||||
expect(summary(buildMultiRepoRows({ ...input, query: "collect" }))).toEqual([
|
||||
"repo ology-collect [dev] c1",
|
||||
"# ology-collect",
|
||||
"ology-collect:collect-only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("highlights the shown part of a remote branch name", () => {
|
||||
const rows = buildMultiRepoRows({ ...input, query: "origin/dev" });
|
||||
const dev = rows.find((r) => r.type === "common" && r.branch.name === "origin/dev");
|
||||
expect(dev?.type === "common" && [dev.label, dev.matches]).toEqual(["dev", [0, 1, 2]]);
|
||||
});
|
||||
|
||||
it("shows no common branches until every repository's branches are loaded", () => {
|
||||
const partial = { "/p/ology-collect": branches["/p/ology-collect"]! };
|
||||
expect(summary(buildMultiRepoRows({ ...input, branches: partial, query: "" })).at(-1)).toBe(
|
||||
"repo ology-gateway [dev] c3",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMultiRepoRows with repositories on different branches", () => {
|
||||
const diverged = [repo("ology-collect", "release/1.0"), repo("ology-dependencies"), repo("ology-gateway")];
|
||||
const own: Record<string, Branch[]> = {
|
||||
...branches,
|
||||
"/p/ology-collect": [
|
||||
b("release/1.0", { current: true }),
|
||||
b("dev"),
|
||||
b("main"),
|
||||
b("origin/dev"),
|
||||
b("origin/release/1.0"),
|
||||
],
|
||||
};
|
||||
const input = {
|
||||
repos: diverged,
|
||||
branches: own,
|
||||
actions: [],
|
||||
expanded: new Set(DEFAULT_EXPANDED),
|
||||
recent: ["dev", "release/1.0", "gone"],
|
||||
tags: ["v1.2", "v1.10"],
|
||||
};
|
||||
|
||||
it("lists the current repository's recent, local, remote branches and tags instead of the common ones", () => {
|
||||
expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-collect", query: "" }))).toEqual([
|
||||
"repo ology-collect [release/1.0] c1",
|
||||
"repo ology-dependencies [dev] c2",
|
||||
"repo ology-gateway [dev] c3",
|
||||
"---",
|
||||
"> Recent Branches in ology-collect",
|
||||
"v Local Branches in ology-collect",
|
||||
" ology-collect:release/1.0",
|
||||
" ology-collect:main",
|
||||
" ology-collect:dev",
|
||||
"> Remote Branches in ology-collect",
|
||||
"> Tags in ology-collect",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the first repository and expands the matching lists on search", () => {
|
||||
const expanded = new Set(["repo:tags", "repo:recent"]);
|
||||
const rows = summary(buildMultiRepoRows({ ...input, expanded, current: "/elsewhere", query: "" }));
|
||||
expect(rows.slice(4)).toEqual([
|
||||
"v Recent Branches in ology-collect",
|
||||
" ology-collect:dev",
|
||||
"> Local Branches in ology-collect",
|
||||
"> Remote Branches in ology-collect",
|
||||
"v Tags in ology-collect",
|
||||
" tag v1.10",
|
||||
" tag v1.2",
|
||||
]);
|
||||
// Search: the current repository's lists, then the other repositories' branches.
|
||||
expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-gateway", query: "fix" }))).toEqual([
|
||||
"v Local Branches in ology-gateway",
|
||||
" ology-gateway:gw-fix",
|
||||
]);
|
||||
expect(summary(buildMultiRepoRows({ ...input, current: "/p/ology-gateway", query: "rel" }))).toEqual([
|
||||
"# ology-collect",
|
||||
"ology-collect:release/1.0",
|
||||
"ology-collect:origin/release/1.0",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repository colours and heads", () => {
|
||||
it("gives up to nine repositories distinct colours, then cycles", () => {
|
||||
expect(Array.from({ length: 10 }, (_, i) => repoColorIndex(i))).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 1]);
|
||||
});
|
||||
|
||||
it("names a detached HEAD by its short commit", () => {
|
||||
expect(repoHeadLabel({ head: "dev", headCommit: "0123456789" })).toBe("dev");
|
||||
expect(repoHeadLabel({ head: null, headCommit: "0123456789" })).toBe("01234567");
|
||||
expect(repoHeadLabel({ head: null, headCommit: null })).toBe("No commits");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { Menu, type MenuItem } from "@/ui";
|
||||
import { editorService, maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { annotationsShown, toggleAnnotations } from "./blame";
|
||||
import { showBranchesPopup } from "./BranchWidget";
|
||||
import { checkoutRevision, newBranch } from "./branchOps";
|
||||
import { checkoutRevision, newBranch, newBranchInRepos } from "./branchOps";
|
||||
import {
|
||||
addToVcs,
|
||||
commitChanges,
|
||||
@@ -401,7 +401,11 @@ export function registerVcsActions(): () => void {
|
||||
category: "Git",
|
||||
icon: "plus",
|
||||
when: VCS,
|
||||
run: () => void newBranch(),
|
||||
// `{ repos }` (Branches popup with several repositories): in each of them.
|
||||
run: ({ arg }) => {
|
||||
const repos = (arg as { repos?: unknown } | undefined)?.repos;
|
||||
void (Array.isArray(repos) && repos.length ? newBranchInRepos(repos as string[]) : newBranch());
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vcs.checkoutRevision",
|
||||
|
||||
@@ -7,8 +7,13 @@ import {
|
||||
checkoutAndRebase,
|
||||
checkoutBranch,
|
||||
checkoutDetached,
|
||||
checkoutInRepos,
|
||||
deleteBranch,
|
||||
deleteInRepos,
|
||||
deleteRemoteBranch,
|
||||
mergeInRepos,
|
||||
newBranchInRepos,
|
||||
rebaseInRepos,
|
||||
loadFavoriteBranches,
|
||||
mergeIntoCurrent,
|
||||
newBranch,
|
||||
@@ -20,6 +25,8 @@ import {
|
||||
} from "./branchOps";
|
||||
import { showCompareBranches } from "./CompareBranches";
|
||||
import { showDiffWithWorkingTree } from "./operations";
|
||||
import type { CommonBranch } from "./repoBranches";
|
||||
import { focusRepo } from "./store";
|
||||
|
||||
export function branchMenu(b: Branch, current: string | null, done: () => void = () => {}): MenuItem[] {
|
||||
const run = (fn: () => unknown) => () => {
|
||||
@@ -98,3 +105,56 @@ export function tagMenu(tag: string, done: () => void = () => {}): MenuItem[] {
|
||||
{ label: "Show History", icon: "history", run: run(() => executeAction("vcs.showLog", { branch: tag })) },
|
||||
];
|
||||
}
|
||||
|
||||
/** Entries for a branch common to all repositories: each acts in every one of `roots`. */
|
||||
export function commonBranchMenu(b: CommonBranch, roots: readonly string[], done: () => void = () => {}): MenuItem[] {
|
||||
const run = (fn: () => unknown) => () => {
|
||||
done();
|
||||
void fn();
|
||||
};
|
||||
const newFrom: MenuItem = {
|
||||
label: `New Branch from '${b.name}'…`,
|
||||
icon: "branch",
|
||||
run: run(() => newBranchInRepos(roots, b.name)),
|
||||
};
|
||||
if (b.current)
|
||||
return [
|
||||
newFrom,
|
||||
{ kind: "separator" },
|
||||
{ label: "Update", icon: "git-pull", run: run(() => executeAction("vcs.update")) },
|
||||
{ label: "Push…", icon: "git-push", run: run(() => executeAction("vcs.push")) },
|
||||
];
|
||||
const items: MenuItem[] = [
|
||||
{ label: "Checkout", run: run(() => checkoutInRepos(b.name, roots)) },
|
||||
newFrom,
|
||||
{ kind: "separator" },
|
||||
{ label: `Rebase Current onto '${b.name}'`, run: run(() => rebaseInRepos(b.name, roots)) },
|
||||
{ label: `Merge '${b.name}' into Current`, run: run(() => mergeInRepos(b.name, roots)) },
|
||||
];
|
||||
if (!b.remote)
|
||||
items.push(
|
||||
{ kind: "separator" },
|
||||
{ label: "Delete", danger: true, icon: "trash", run: run(() => deleteInRepos(b.name, roots)) },
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
/** `items` acting on repository `root`: it is selected (with its branches loaded) before each entry runs. */
|
||||
export function inRepo(root: string, items: MenuItem[]): MenuItem[] {
|
||||
return items.map((item): MenuItem => {
|
||||
if (item.kind === "separator" || item.kind === "header") return item;
|
||||
if (item.kind === "action")
|
||||
return {
|
||||
label: item.label ?? item.action,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
run: () => void focusRepo(root).then(() => executeAction(item.action, item.arg)),
|
||||
};
|
||||
const run = item.run;
|
||||
return {
|
||||
...item,
|
||||
run: run && (() => void focusRepo(root).then(run)),
|
||||
submenu: item.submenu && inRepo(root, item.submenu),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches";
|
||||
import { showCommitRange } from "./CompareBranches";
|
||||
import { confirmDialog, promptText } from "./dialogs";
|
||||
import { runOp, runRemote, saveAll } from "./ops";
|
||||
import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store";
|
||||
import { conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
|
||||
|
||||
const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`;
|
||||
|
||||
@@ -68,18 +68,23 @@ async function askSmartCheckout(files: readonly string[]): Promise<CheckoutOptio
|
||||
* to rebase that branch onto the remote or drop its commits (one that is only behind is
|
||||
* fast-forwarded). Resolves true once switched.
|
||||
*/
|
||||
async function performCheckout(rev: string, success: () => string): Promise<boolean> {
|
||||
async function performCheckout(
|
||||
rev: string,
|
||||
success: () => string,
|
||||
repo: string | null = currentRepo(),
|
||||
quiet = false,
|
||||
): Promise<boolean> {
|
||||
await saveAll();
|
||||
let options: CheckoutOptions = { mode: "normal", diverged: null };
|
||||
for (;;) {
|
||||
const opts = options;
|
||||
const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(currentRepo(), rev, false, opts)));
|
||||
const outcome = await runOp(`Checkout ${rev}`, () => call(commands.vcsCheckout(repo, rev, false, opts)));
|
||||
if (!outcome) return false;
|
||||
// `success` may name the branch now checked out.
|
||||
if (outcome.kind === "done" || outcome.kind === "conflicts") await refreshNow();
|
||||
switch (outcome.kind) {
|
||||
case "done":
|
||||
notifications().notify({ group: "vcs", level: "info", title: success() });
|
||||
if (!quiet) notifications().notify({ group: "vcs", level: "info", title: success() });
|
||||
return true;
|
||||
case "conflicts":
|
||||
notifications().notify({
|
||||
@@ -316,6 +321,109 @@ export async function updateBranch(name: string) {
|
||||
await runRemote({ op: "updateBranch", branch: name }, `${name} ← ${b.upstream}`);
|
||||
}
|
||||
|
||||
// --- several repositories ----------------------------------------------------
|
||||
|
||||
/** "in 3 repositories", or "in 2 of 3 repositories" when some were left out. */
|
||||
function inRepos(done: number, total: number): string {
|
||||
return done === total ? `in ${total} repositories` : `in ${done} of ${total} repositories`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `step` in each repository in turn, stopping at the first one that fails (its
|
||||
* error is reported by `step`), then reports how many repositories it was done in.
|
||||
*/
|
||||
async function eachRepo(roots: readonly string[], step: (root: string) => Promise<boolean>, title: string) {
|
||||
let done = 0;
|
||||
for (const root of roots) {
|
||||
if (!(await step(root))) break;
|
||||
done++;
|
||||
}
|
||||
await refreshNow();
|
||||
if (done) notifications().notify({ group: "vcs", level: "info", title: `${title} ${inRepos(done, roots.length)}` });
|
||||
}
|
||||
|
||||
/** Checks out `name` (local, or remote: its local namesake) in every repository of `roots`. */
|
||||
export async function checkoutInRepos(name: string, roots: readonly string[]) {
|
||||
const label = useVcs.getState().branches.some((b) => b.remote && b.name === name) ? shortRemoteName(name) : name;
|
||||
const previous = useVcs.getState().branch?.head;
|
||||
await eachRepo(roots, (root) => performCheckout(name, () => "", root, true), `Checked out ${label}`);
|
||||
rememberBranch(previous);
|
||||
rememberBranch(useVcs.getState().branch?.head);
|
||||
}
|
||||
|
||||
/** New Branch in every repository of `roots` (from HEAD or `from`), checked out by default. */
|
||||
export async function newBranchInRepos(roots: readonly string[], from?: string) {
|
||||
const r = await promptText({
|
||||
title: from ? `Create New Branch from ${from}` : "Create New Branch",
|
||||
label: `New branch name (in ${roots.length} repositories):`,
|
||||
initial: from?.includes("/") ? shortRemoteName(from) : "",
|
||||
mono: true,
|
||||
okLabel: "Create",
|
||||
validate: validateBranchName,
|
||||
checkbox: { label: "Checkout branch", initial: true },
|
||||
});
|
||||
if (!r) return;
|
||||
const name = r.value.trim();
|
||||
if (r.checked) await saveAll();
|
||||
await eachRepo(
|
||||
roots,
|
||||
async (root) =>
|
||||
(await runOp(`Create branch ${name}`, () => call(commands.vcsCreateBranch(root, name, from ?? null, r.checked)), {
|
||||
refresh: false,
|
||||
})) !== undefined,
|
||||
r.checked ? `Created and checked out ${name}` : `Created branch ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Merges `branch` into the current branch of every repository of `roots`; stops on conflicts. */
|
||||
export async function mergeInRepos(branch: string, roots: readonly string[]) {
|
||||
await saveAll();
|
||||
await eachRepo(
|
||||
roots,
|
||||
async (root) =>
|
||||
(await runOp(`Merge ${branch} (${repoName(root)})`, () => call(commands.vcsMerge(root, branch)), {
|
||||
refresh: false,
|
||||
errorActions: conflictActions,
|
||||
onError: openConflictsInstead,
|
||||
})) !== undefined,
|
||||
`Merged ${branch}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rebases the current branch of every repository of `roots` onto `branch`; stops on conflicts. */
|
||||
export async function rebaseInRepos(branch: string, roots: readonly string[]) {
|
||||
await saveAll();
|
||||
await eachRepo(
|
||||
roots,
|
||||
async (root) =>
|
||||
(await runOp(`Rebase onto ${branch} (${repoName(root)})`, () => call(commands.vcsRebase(root, branch)), {
|
||||
refresh: false,
|
||||
errorActions: conflictActions,
|
||||
onError: openConflictsInstead,
|
||||
})) !== undefined,
|
||||
`Rebased onto ${branch}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes local branch `name` in every repository of `roots`. */
|
||||
export async function deleteInRepos(name: string, roots: readonly string[]) {
|
||||
const ok = await confirmDialog({
|
||||
title: "Delete Branch",
|
||||
message: `Delete branch '${name}' in ${roots.length} repositories? Unmerged commits are discarded.`,
|
||||
okLabel: "Delete",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await eachRepo(
|
||||
roots,
|
||||
async (root) =>
|
||||
(await runOp(`Delete branch ${name} (${repoName(root)})`, () => call(commands.vcsDeleteBranch(root, name)), {
|
||||
refresh: false,
|
||||
})) !== undefined,
|
||||
`Deleted branch ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- favorites ---------------------------------------------------------------
|
||||
|
||||
const favoritesKey = () => `openidea.vcs.favoriteBranches:${useVcs.getState().root ?? ""}`;
|
||||
@@ -341,7 +449,12 @@ export function toggleFavoriteBranch(b: Branch) {
|
||||
loadFavoriteBranches();
|
||||
const stored = useFavoriteBranches.getState().names;
|
||||
// First edit: start from the defaults that are currently shown.
|
||||
const base = stored ?? useVcs.getState().branches.filter((x) => isFavoriteBranch(x, null)).map((x) => x.name);
|
||||
const base =
|
||||
stored ??
|
||||
useVcs
|
||||
.getState()
|
||||
.branches.filter((x) => isFavoriteBranch(x, null))
|
||||
.map((x) => x.name);
|
||||
const names = base.includes(b.name) ? base.filter((n) => n !== b.name) : [...base, b.name];
|
||||
useFavoriteBranches.setState({ names });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/** Branches popup model for a project with several repositories (pure). */
|
||||
import type { Branch } from "@/bindings";
|
||||
import { fuzzyMatch } from "@/ui/match";
|
||||
import { rootOf } from "./model";
|
||||
import { isFavoriteBranch, MAX_RECENT, sortLocal, sortRemote, type PopupAction } from "./branches";
|
||||
|
||||
export interface RepoEntry {
|
||||
root: string;
|
||||
name: string;
|
||||
/** Current branch; `null` when HEAD is detached. */
|
||||
head: string | null;
|
||||
headCommit: string | null;
|
||||
}
|
||||
|
||||
/** Number of `--project-color-N` tokens repositories are coloured with. */
|
||||
export const REPO_COLORS = 9;
|
||||
|
||||
/** Colour slot (1-based) of the repository at `index`: distinct for up to `REPO_COLORS` repositories. */
|
||||
export function repoColorIndex(index: number): number {
|
||||
return (index % REPO_COLORS) + 1;
|
||||
}
|
||||
|
||||
/** Display name of a repository's HEAD: its branch, else the short commit. */
|
||||
export function repoHeadLabel(r: Pick<RepoEntry, "head" | "headCommit">): string {
|
||||
if (r.head) return r.head;
|
||||
return r.headCommit ? r.headCommit.slice(0, 8) : "No commits";
|
||||
}
|
||||
|
||||
/**
|
||||
* The current repository, shown by the branch widget and the lower part of the Branches
|
||||
* popup: the one holding the active editor's file, else the selected one, else the first.
|
||||
*/
|
||||
export function currentRepoFor(
|
||||
activePath: string | null,
|
||||
roots: readonly string[],
|
||||
selected: string | null,
|
||||
): string | null {
|
||||
const ofFile = activePath ? rootOf(activePath, roots) : null;
|
||||
return ofFile ?? (selected && roots.includes(selected) ? selected : null) ?? roots[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the branch widget names the repository besides the branch: only with several
|
||||
* repositories that are not all on the same branch.
|
||||
*/
|
||||
export function widgetShowsRepo(repos: readonly Pick<RepoEntry, "head">[]): boolean {
|
||||
if (repos.length < 2) return false;
|
||||
const first = repos[0]!.head;
|
||||
return !first || repos.some((r) => r.head !== first);
|
||||
}
|
||||
|
||||
export interface CommonBranch {
|
||||
name: string;
|
||||
remote: boolean;
|
||||
/** Checked out in every repository. */
|
||||
current: boolean;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
/** Branches present in every repository (by name), local and remote, sorted like the single-repository lists. */
|
||||
export function commonBranches(
|
||||
branchesByRepo: readonly (readonly Branch[])[],
|
||||
favorites: readonly string[] | null = null,
|
||||
): { local: CommonBranch[]; remote: CommonBranch[] } {
|
||||
if (!branchesByRepo.length) return { local: [], remote: [] };
|
||||
const pick = (sorted: (list: readonly Branch[]) => Branch[]): CommonBranch[] => {
|
||||
const [first, ...rest] = branchesByRepo;
|
||||
const others = rest.map((list) => new Map(list.map((b) => [`${b.remote}:${b.name}`, b])));
|
||||
return sorted(first!).flatMap((b) => {
|
||||
const all = others.map((m) => m.get(`${b.remote}:${b.name}`));
|
||||
if (all.some((x) => !x)) return [];
|
||||
return [
|
||||
{
|
||||
name: b.name,
|
||||
remote: b.remote,
|
||||
current: b.current && all.every((x) => x!.current),
|
||||
favorite: isFavoriteBranch(b, favorites),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
const byRank = (a: CommonBranch, b: CommonBranch) =>
|
||||
Number(b.current) - Number(a.current) || Number(b.favorite) - Number(a.favorite);
|
||||
return {
|
||||
local: pick((l) => sortLocal(l, favorites)).sort(byRank),
|
||||
remote: pick((l) => sortRemote(l, favorites)).sort(byRank),
|
||||
};
|
||||
}
|
||||
|
||||
export type BranchGroupKind = "recent" | "local" | "remote";
|
||||
|
||||
export type RepoRow =
|
||||
/** Section header; an empty label draws a separator. */
|
||||
| { type: "section"; key: string; label: string }
|
||||
| { type: "action"; key: string; action: PopupAction; matches: number[] | null }
|
||||
| { type: "repo"; key: string; repo: RepoEntry; color: number; matches: number[] | null }
|
||||
/** Collapsible group (common branches, or one of the current repository's lists) or remote folder. */
|
||||
| { type: "group"; key: string; label: string; expanded: boolean; depth: number }
|
||||
| {
|
||||
type: "common";
|
||||
key: string;
|
||||
branch: CommonBranch;
|
||||
/** Shown name: remote branches without their remote (it is the folder). */
|
||||
label: string;
|
||||
depth: number;
|
||||
matches: number[] | null;
|
||||
}
|
||||
/** A branch of one repository. */
|
||||
| {
|
||||
type: "branch";
|
||||
key: string;
|
||||
repo: RepoEntry;
|
||||
branch: Branch;
|
||||
group: BranchGroupKind;
|
||||
depth: number;
|
||||
matches: number[] | null;
|
||||
favorite: boolean;
|
||||
}
|
||||
| { type: "tag"; key: string; repo: RepoEntry; tag: string; depth: number; matches: number[] | null };
|
||||
|
||||
/**
|
||||
* Initially expanded groups: the common local branches, and the current repository's
|
||||
* local branches (switching a repository's branch stays one click away).
|
||||
*/
|
||||
export const DEFAULT_EXPANDED: readonly string[] = ["common:local", "repo:local"];
|
||||
|
||||
export interface MultiRepoInput {
|
||||
repos: readonly RepoEntry[];
|
||||
/** Root → its branches (missing while loading). */
|
||||
branches: Readonly<Record<string, readonly Branch[]>>;
|
||||
query: string;
|
||||
actions: readonly PopupAction[];
|
||||
/** Expanded group / folder keys (`common:local`, `repo:remote`, `common:folder:origin`…). */
|
||||
expanded: ReadonlySet<string>;
|
||||
favorites?: readonly string[] | null;
|
||||
/** The current repository (of the active file), whose lists are shown when the repositories' branches differ. */
|
||||
current?: string | null;
|
||||
/** Recently checked-out branches (newest first). */
|
||||
recent?: readonly string[];
|
||||
/** Tags of the current repository. */
|
||||
tags?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows of the Branches popup with several repositories: actions; the repositories,
|
||||
* each with its current branch and opening its own branches; then, when they are all
|
||||
* on the same branch, the branches common to all of them (acting on all of them),
|
||||
* otherwise the current repository's recent, local and remote branches and tags, in
|
||||
* collapsible groups. A query filters actions, repositories (by name) and branches,
|
||||
* expands every group, and adds the other repositories' matching branches.
|
||||
*/
|
||||
export function buildMultiRepoRows({
|
||||
repos,
|
||||
branches,
|
||||
query,
|
||||
actions,
|
||||
expanded,
|
||||
favorites = null,
|
||||
current = null,
|
||||
recent = [],
|
||||
tags = [],
|
||||
}: MultiRepoInput): RepoRow[] {
|
||||
const q = query.trim();
|
||||
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
|
||||
const open = (key: string) => !!q || expanded.has(key);
|
||||
const blocks: RepoRow[][] = [];
|
||||
|
||||
const actionRows: RepoRow[] = [];
|
||||
for (const a of actions) {
|
||||
const m = match(a.label);
|
||||
if (m !== false) actionRows.push({ type: "action", key: `action:${a.id}`, action: a, matches: m });
|
||||
}
|
||||
blocks.push(actionRows);
|
||||
|
||||
const repoRows: RepoRow[] = [];
|
||||
repos.forEach((repo, i) => {
|
||||
const m = match(repo.name);
|
||||
if (m !== false)
|
||||
repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m });
|
||||
});
|
||||
blocks.push(repoRows);
|
||||
|
||||
/** A group row followed (when expanded) by its children; nothing when it has none. */
|
||||
const group = (out: RepoRow[], key: string, label: string, depth: number, children: RepoRow[]) => {
|
||||
if (!children.length) return;
|
||||
const expandedNow = open(key);
|
||||
out.push({ type: "group", key, label, expanded: expandedNow, depth });
|
||||
if (expandedNow) out.push(...children);
|
||||
};
|
||||
const branchRows = (repo: RepoEntry, list: readonly Branch[], kind: BranchGroupKind, depth: number) =>
|
||||
list.flatMap((b): RepoRow[] => {
|
||||
const m = match(b.name);
|
||||
if (m === false) return [];
|
||||
const key = `branch:${repo.root}:${kind}:${b.name}`;
|
||||
return [
|
||||
{
|
||||
type: "branch",
|
||||
key,
|
||||
repo,
|
||||
branch: b,
|
||||
group: kind,
|
||||
depth,
|
||||
matches: m,
|
||||
favorite: isFavoriteBranch(b, favorites),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const shared = !widgetShowsRepo(repos);
|
||||
const groups: RepoRow[] = [];
|
||||
let shown = new Set<string>();
|
||||
const currentRepo = repos.find((r) => r.root === current) ?? repos[0];
|
||||
if (shared) {
|
||||
const loaded = repos.every((r) => branches[r.root]);
|
||||
const common = loaded
|
||||
? commonBranches(
|
||||
repos.map((r) => branches[r.root]!),
|
||||
favorites,
|
||||
)
|
||||
: { local: [], remote: [] };
|
||||
shown = new Set([...common.local, ...common.remote].map((b) => `${b.remote}:${b.name}`));
|
||||
const local = common.local.flatMap((b): RepoRow[] => {
|
||||
const m = match(b.name);
|
||||
return m === false
|
||||
? []
|
||||
: [{ type: "common", key: `common:${b.name}`, branch: b, label: b.name, depth: 1, matches: m }];
|
||||
});
|
||||
group(groups, "common:local", "Common Local Branches", 0, local);
|
||||
const byRemote = new Map<string, CommonBranch[]>();
|
||||
for (const b of common.remote) {
|
||||
const remote = b.name.slice(0, Math.max(0, b.name.indexOf("/")));
|
||||
byRemote.set(remote, [...(byRemote.get(remote) ?? []), b]);
|
||||
}
|
||||
const remote: RepoRow[] = [];
|
||||
for (const [name, items] of byRemote) {
|
||||
const leaves = items.flatMap((b): RepoRow[] => {
|
||||
const m = match(b.name);
|
||||
if (m === false) return [];
|
||||
// Highlights are of the full name: shift them onto the shown part.
|
||||
const part = m && m.map((i) => i - name.length - 1).filter((i) => i >= 0);
|
||||
const label = b.name.slice(name.length + 1);
|
||||
return [{ type: "common", key: `common:${b.name}`, branch: b, label, depth: 2, matches: part }];
|
||||
});
|
||||
group(remote, `common:folder:${name}`, name, 1, leaves);
|
||||
}
|
||||
group(groups, "common:remote", "Common Remote Branches", 0, remote);
|
||||
} else if (currentRepo) {
|
||||
const list = branches[currentRepo.root] ?? [];
|
||||
const local = sortLocal(list, favorites);
|
||||
const byName = new Map(local.map((b) => [b.name, b]));
|
||||
const recentList = q
|
||||
? []
|
||||
: recent
|
||||
.map((n) => byName.get(n))
|
||||
.filter((b): b is Branch => !!b && !b.current)
|
||||
.slice(0, MAX_RECENT);
|
||||
const name = currentRepo.name;
|
||||
group(groups, "repo:recent", `Recent Branches in ${name}`, 0, branchRows(currentRepo, recentList, "recent", 1));
|
||||
group(groups, "repo:local", `Local Branches in ${name}`, 0, branchRows(currentRepo, local, "local", 1));
|
||||
group(
|
||||
groups,
|
||||
"repo:remote",
|
||||
`Remote Branches in ${name}`,
|
||||
0,
|
||||
branchRows(currentRepo, sortRemote(list, favorites), "remote", 1),
|
||||
);
|
||||
const tagRows = [...tags]
|
||||
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
.flatMap((t): RepoRow[] => {
|
||||
const m = match(t);
|
||||
return m === false
|
||||
? []
|
||||
: [{ type: "tag", key: `tag:${currentRepo.root}:${t}`, repo: currentRepo, tag: t, depth: 1, matches: m }];
|
||||
});
|
||||
group(groups, "repo:tags", `Tags in ${name}`, 0, tagRows);
|
||||
}
|
||||
blocks.push(groups);
|
||||
|
||||
if (q) {
|
||||
for (const repo of repos) {
|
||||
if (!shared && repo === currentRepo) continue;
|
||||
const list = branches[repo.root] ?? [];
|
||||
const own = (l: Branch[]) => l.filter((b) => !shown.has(`${b.remote}:${b.name}`));
|
||||
const rows = [
|
||||
...branchRows(repo, own(sortLocal(list, favorites)), "local", 0),
|
||||
...branchRows(repo, own(sortRemote(list, favorites)), "remote", 0),
|
||||
];
|
||||
if (rows.length) blocks.push([{ type: "section", key: `section:${repo.root}`, label: repo.name }, ...rows]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: RepoRow[] = [];
|
||||
for (const block of blocks) {
|
||||
if (!block.length) continue;
|
||||
if (rows.length && block[0]!.type !== "section")
|
||||
rows.push({ type: "section", key: `separator:${rows.length}`, label: "" });
|
||||
rows.push(...block);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Toggles a group / folder key. */
|
||||
export function toggleExpanded(expanded: ReadonlySet<string>, key: string): Set<string> {
|
||||
const next = new Set(expanded);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ import { useProject } from "@/platform/project";
|
||||
import { setFileDecorations } from "@/workbench/contributions";
|
||||
import { effectiveKind, repoLabel, rootOf, statusDecorations, stripSlash } from "./model";
|
||||
|
||||
export interface RepoHead {
|
||||
head: string | null;
|
||||
headCommit: string | null;
|
||||
}
|
||||
|
||||
export interface VcsState {
|
||||
/** The open project is a git repository. */
|
||||
available: boolean;
|
||||
@@ -25,6 +30,8 @@ export interface VcsState {
|
||||
roots: string[];
|
||||
/** Root → merge / rebase / cherry-pick / revert in progress there. */
|
||||
operations: Record<string, string>;
|
||||
/** Root → its current branch (`head` null when detached) and HEAD commit. */
|
||||
heads: Record<string, RepoHead>;
|
||||
/** The selected repository's root (branch widget, log, push…). */
|
||||
repo: string | null;
|
||||
status: FileStatus[];
|
||||
@@ -50,6 +57,7 @@ const initial: VcsState = {
|
||||
root: null,
|
||||
roots: [],
|
||||
operations: {},
|
||||
heads: {},
|
||||
repo: null,
|
||||
status: [],
|
||||
statusLoaded: false,
|
||||
@@ -109,6 +117,12 @@ export function selectRepo(root: string): Promise<void> {
|
||||
return refreshNow();
|
||||
}
|
||||
|
||||
/** Selects `root` and loads its branches, so branch operations act on it. */
|
||||
export async function focusRepo(root: string): Promise<void> {
|
||||
await selectRepo(root);
|
||||
if (!useVcs.getState().branchesLoaded) await loadBranches();
|
||||
}
|
||||
|
||||
export function conflictedFiles(status: readonly FileStatus[] = useVcs.getState().status): FileStatus[] {
|
||||
return status.filter((f) => effectiveKind(f) === "conflicted");
|
||||
}
|
||||
@@ -180,7 +194,8 @@ export function refreshNow(): Promise<void> {
|
||||
// `null` (before the first refresh) is the primary repository, listed first.
|
||||
const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null);
|
||||
const repo = attentionRepo(shown, s.operations, operations) ?? shown;
|
||||
useVcs.setState({ operations });
|
||||
const heads = Object.fromEntries(vcsRoots.map((r) => [r.root, { head: r.head, headCommit: r.headCommit }]));
|
||||
useVcs.setState({ operations, heads });
|
||||
if (repo !== s.repo || roots.join("\n") !== s.roots.join("\n")) useVcs.setState({ roots, repo });
|
||||
applyStatus(status, branch);
|
||||
useVcs.setState({ branches, stashes, error: null });
|
||||
|
||||
+4
-1
@@ -29,6 +29,8 @@ export interface ListProps<T> {
|
||||
onContextMenu?: (item: T, index: number, e: MouseEvent) => void;
|
||||
rowHeight?: number;
|
||||
sectionHeight?: number;
|
||||
/** Height of a given section row (e.g. thin separators), else `sectionHeight`. */
|
||||
sectionHeightOf?: (item: T) => number | undefined;
|
||||
/** `false` when an external input drives navigation (rows still respond to the mouse). */
|
||||
focusable?: boolean;
|
||||
/** Move the active row with the pointer (popup choosers). */
|
||||
@@ -109,6 +111,7 @@ export function List<T>({
|
||||
onContextMenu,
|
||||
rowHeight = 24,
|
||||
sectionHeight = 24,
|
||||
sectionHeightOf,
|
||||
focusable = true,
|
||||
hoverSelects = false,
|
||||
empty,
|
||||
@@ -127,7 +130,7 @@ export function List<T>({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (i) => (isSection?.(items[i]!) ? sectionHeight : rowHeight),
|
||||
estimateSize: (i) => (isSection?.(items[i]!) ? (sectionHeightOf?.(items[i]!) ?? sectionHeight) : rowHeight),
|
||||
overscan,
|
||||
getItemKey: (i) => getKey(items[i]!, i),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user