diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd70e5..cb569e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Hovering a branch badge fades the rows its tip cannot reach + ([#17](https://github.com/ShortArrow/gitreant/issues/17)). +- Each submodule graph collapses to a narrow strip with its name, giving + the commit rows their width back; the choice is kept per repository + ([#18](https://github.com/ShortArrow/gitreant/issues/18)). - Debian packages for amd64 and arm64, attached to each release and installable with `apt` from `pkg.shortarrow.jp`, and a `SHA256SUMS` for every release asset ([ADR 0034](https://github.com/ShortArrow/gitreant/blob/main/docs/adr/0034-debian-packages.md)). +### Changed + +- A submodule graph's nodes behave like the main graph's: hovering shows the + commit's hash, and the context menu copies its id and its web page on the + submodule's own origin. The dashed pointer links no longer cover the node + they end on. + ### Fixed - The arm64 Linux binary starts on glibc 2.35 and later, Debian 12 and diff --git a/docs/FEATURES.jp.md b/docs/FEATURES.jp.md index 8b47099..6ba3c04 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -18,9 +18,9 @@ gitreant でできることを分野別にまとめる。起動方法とコマ - **レーンと色はサーバで計算する。** チェックアウト中のブランチを左端の直線に置き、分岐は右へ、マージは右から合流する。ブランチの線は端から端まで同じ色で描く。 - **HEAD とマージ。** HEAD のコミットは中抜きの輪で描く。マージコミットのメッセージは薄く表示する。 - **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。 -- **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。 +- **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。ブランチのバッジにホバーすると、読み込み済みのコミットのうち、その先端からたどれない行を薄くする。 - **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。 -- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。 +- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。サブモジュールごとにグラフを名前だけの細い帯にたため、行の文字に幅を譲れる。たたんだ状態はリポジトリごとに保存する。サブモジュールのノードにホバーするとそのコミットを表示し、右クリックで ID か、サブモジュール自身の origin にあるコミットの Web ページをコピーできる。 - **作者のアイコン。** GitHub の作者アイコンを各行に表示する。設定で消せる。 ## コミットの詳細と差分 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f810e6a..6c3aafc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -41,12 +41,17 @@ see the [README](../README.md); design decisions live in [adr/](adr/). or open the commit's web page. Clicking a hash copies the full id. - **Badges.** Branches, tags and the stash render as distinct badges. Remote branches, and tags pushed to a remote, carry the remote's name as a - separate segment. + separate segment. Hovering a branch badge fades the rows its tip cannot + reach, among the loaded commits. - **Stashes.** A stash folds to one node. A setting reveals its index and untracked-files commits, linked by dashed lines. - **Submodule graphs.** For a repository with submodules, each submodule's graph sits to the left, with dashed links between the commits that moved - its pointer. A setting turns this off. + its pointer. A setting turns this off. Each submodule's graph collapses + to a narrow strip with its name, giving the rows their width back; the + choice is kept per repository. Hovering a submodule's node names + its commit, and its context menu copies the id or, from the submodule's + own origin, the commit's web page. - **Author avatars.** Rows show the author's GitHub avatar; a setting hides them. diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index 4144f3b..fc09f06 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -316,7 +316,10 @@ function makeRepoN(): string { } /** repoO: a superproject with one submodule, so the drawer can group them. - * The submodule is added over the file transport (opt-in for recent git). */ + * The submodule is added over the file transport (opt-in for recent git). + * The submodule's clone then names a github.com origin (never fetched) and + * the superproject has none, so a web page can only come from the + * submodule's own origin. */ function makeRepoO(): string { const child = initRepo("repoO-sub"); commit(child, "sub-1", 1_700_000_000); @@ -331,6 +334,12 @@ function makeRepoO(): string { "sub", ]); commit(parent, "add submodule", 1_700_000_010); + git(path.join(parent, "sub"), [ + "remote", + "set-url", + "origin", + "https://github.com/example/repoO-sub.git", + ]); return parent; } @@ -419,6 +428,7 @@ export default async function globalSetup(_config: FullConfig) { path.join(tmpDir, "fixtures.json"), JSON.stringify( { + repoA, repoC, repoD, repoE, diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index 2ba0512..6a67330 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { commit, git } from "./git"; @@ -1953,6 +1954,100 @@ test.describe.serial("gitreant UI", () => { await expect(subRow).toHaveCount(0); }); + test("submodule graph nodes name their commit and open its menu", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await page.getByTestId("add-input").fill(fixtures.repoO); + await page.getByTestId("add-submit").click(); + await page.locator('[data-repo-name="repoO"]').click(); + const node = page.getByTestId("submodule-node"); + await expect(node).toHaveCount(1); + + // Hovering names the commit the way a main graph node does, with the + // submodule it belongs to. + await node.hover(); + const tip = page.getByTestId("node-tip"); + await expect(tip).toHaveText(/^[0-9a-f]{7} sub: sub-1$/); + const short = (await tip.textContent())!.slice(0, 7); + await page.mouse.move(0, 0); + + // The menu copies the id and the page on the submodule's own origin; + // refs cannot be created there, since they would land in another + // repository. + await node.click({ button: "right" }); + const menu = page.getByTestId("commit-menu"); + await expect(menu.locator(".ctx-menu-note")).toHaveText(short); + await expect(menu.getByTestId("commit-menu-tag")).toHaveCount(0); + await expect(menu.getByTestId("commit-menu-branch")).toHaveCount(0); + await menu.getByTestId("commit-menu-copy-hash").click(); + const id = await page.evaluate(() => navigator.clipboard.readText()); + expect(id.startsWith(short)).toBe(true); + await node.click({ button: "right" }); + await menu.getByTestId("commit-menu-copy-url").click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + `https://github.com/example/repoO-sub/commit/${id}`, + ); + + // Restore the served set. + const repoO = page.locator('[data-repo-name="repoO"]'); + await repoO.hover(); + await repoO.getByTestId("repo-remove").click(); + await expect(page.locator('[data-repo-name="repoO"]')).toHaveCount(0); + }); + + test("a submodule graph collapses to a strip and stays collapsed", async ({ + page, + }) => { + await page.getByTestId("add-input").fill(fixtures.repoO); + await page.getByTestId("add-submit").click(); + await page.locator('[data-repo-name="repoO"]').click(); + const region = page.getByTestId("submodule-region"); + await expect(page.getByTestId("submodule-node")).toHaveCount(1); + await expect(page.getByTestId("submodule-edge")).toHaveCount(1); + const wide = (await page.getByTestId("graph").getAttribute("width"))!; + + // Collapsing leaves a named strip: no nodes, no links, a narrower graph. + // The whole button takes the press, even where the dashed link to the + // submodule's newest commit runs past it. + const toggle = (await page.getByTestId("region-collapse").boundingBox())!; + await page.mouse.click( + toggle.x + toggle.width / 2, + toggle.y + toggle.height - 3, + ); + await expect(page.getByTestId("submodule-node")).toHaveCount(0); + await expect(page.getByTestId("submodule-edge")).toHaveCount(0); + const strip = region.getByTestId("region-expand"); + await expect(strip).toContainText("sub"); + const inside = await strip.evaluate((g) => { + const rect = g.querySelector("rect")!.getBoundingClientRect(); + const text = g.querySelector("text")!.getBoundingClientRect(); + return text.left >= rect.left - 0.5 && text.right <= rect.right + 0.5; + }); + expect(inside).toBe(true); + const narrow = (await page.getByTestId("graph").getAttribute("width"))!; + expect(Number(narrow)).toBeLessThan(Number(wide)); + + // The choice is kept per repository across a reload. + await page.reload(); + await page.locator('[data-repo-name="repoO"]').click(); + await expect(region.getByTestId("region-expand")).toBeVisible(); + await expect(page.getByTestId("submodule-node")).toHaveCount(0); + + // The strip expands it again, from the keyboard too. + await region.getByTestId("region-expand").focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("submodule-node")).toHaveCount(1); + await expect(page.getByTestId("submodule-edge")).toHaveCount(1); + + // Restore the served set. + const repoO = page.locator('[data-repo-name="repoO"]'); + await repoO.hover(); + await repoO.getByTestId("repo-remove").click(); + await expect(page.locator('[data-repo-name="repoO"]')).toHaveCount(0); + }); + test("a superproject draws its submodule graph with dashed pointer links", async ({ page, }) => { @@ -2091,6 +2186,54 @@ test.describe.serial("gitreant UI", () => { await expect(hash).toHaveText(shown, { timeout: 3000 }); }); + test("hovering a branch badge fades the rows it cannot reach", async ({ + page, + }) => { + await page.locator('[data-repo-name="repoA"]').click(); + const rows = page.getByTestId("commit-row"); + await expect(rows).toHaveCount(5); + const faded = page.locator(".commit-faded"); + + // feature reaches feature-1, main-1 and root; the merge and main-2 + // came later on main. + await page.locator(".badge-kind-branch", { hasText: "feature" }).hover(); + await expect(faded).toHaveCount(2); + await expect(rows.filter({ hasText: "merge feature" })).toHaveClass( + /commit-faded/, + ); + await expect(rows.filter({ hasText: "main-2" })).toHaveClass( + /commit-faded/, + ); + await expect(rows.filter({ hasText: "feature-1" })).not.toHaveClass( + /commit-faded/, + ); + + // Leaving the badge restores every row. + await page.mouse.move(0, 0); + await expect(faded).toHaveCount(0); + + // A reload that moves the hovered badge to another row unmounts it + // before the pointer leaves; the fade must not outlive it. + const featureTip = execFileSync("git", ["rev-parse", "feature"], { + cwd: fixtures.repoA, + encoding: "utf8", + }).trim(); + try { + await page.locator(".badge-kind-branch", { hasText: "feature" }).hover(); + await expect(faded).toHaveCount(2); + git(fixtures.repoA, ["branch", "-f", "feature", "feature~1"]); + await page.request.post("/api/refresh"); + await expect( + rows.filter({ hasText: "main-1" }).locator(".badge-kind-branch"), + ).toHaveCount(1); + await page.mouse.move(0, 0); + await expect(faded).toHaveCount(0); + } finally { + git(fixtures.repoA, ["branch", "-f", "feature", featureTip]); + await page.request.post("/api/refresh"); + } + }); + test("graph nodes show their hash on hover and open the commit menu", async ({ page, }) => { diff --git a/frontend/src/CommitMenu.tsx b/frontend/src/CommitMenu.tsx index aa07a27..a8fe93d 100644 --- a/frontend/src/CommitMenu.tsx +++ b/frontend/src/CommitMenu.tsx @@ -23,8 +23,10 @@ export function CommitMenu({ commit: string; /** The commit's page on the origin's host; absent when it has none. */ webUrl?: string; - onCreateTag: (name: string) => void; - onCreateBranch: (name: string) => void; + /** Absent when tags cannot be created here (a submodule's commit). */ + onCreateTag?: (name: string) => void; + /** Absent when branches cannot be created here. */ + onCreateBranch?: (name: string) => void; onClose: () => void; }) { const t = useT(); @@ -86,20 +88,24 @@ export function CommitMenu({ )} - - + {onCreateTag && ( + + )} + {onCreateBranch && ( + + )} ) : (
diff --git a/frontend/src/RepoCard.tsx b/frontend/src/RepoCard.tsx index facc766..4f7e493 100644 --- a/frontend/src/RepoCard.tsx +++ b/frontend/src/RepoCard.tsx @@ -45,6 +45,8 @@ import { format, MESSAGES } from "./i18n"; import { useCommands, type PaletteCommand } from "./palette"; import { SettingsContext, + storeCollapsedSubmodules, + storedCollapsedSubmodules, useAvatars, useSquashLinks, useStashInternals, @@ -62,6 +64,7 @@ import { laneColor, nodeX, nodeY, + reachableFrom, rowIndex, ROW_HEIGHT, NODE_RADIUS, @@ -88,6 +91,54 @@ function refsByCommit(repo: RepoView): Map { } /** What the diff pane shows: one file, or everything the commit changed. */ +/** A clickable part of the graph drawing that collapses or expands a + * submodule region; a button to the keyboard and assistive tech too. */ +function RegionToggle({ + testId, + className, + expanded, + label, + onToggle, + children, +}: { + testId: string; + className: string; + /** Whether the region it toggles is expanded. */ + expanded: boolean; + label: string; + onToggle: () => void; + children: React.ReactNode; +}) { + return ( + e.preventDefault()} + onClick={onToggle} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + onToggle(); + }} + > + {label} + {children} + + ); +} + +/** A commit's page on the origin of the repository `view` shows, when that + * origin is on a known host. */ +function webUrlOf(view: RepoView, commit: string): string | undefined { + return view.commit_url_base && `${view.commit_url_base}${commit}`; +} + export type DiffTarget = { kind: "file"; path: string } | { kind: "all" }; /** Badge for a signed commit, following GitHub's wording: "Verified" only @@ -158,6 +209,13 @@ export function RepoCard({ [repo, showStashInternals], ); const rowOf = useMemo(() => rowIndex(view.commits), [view.commits]); + // While a branch badge is hovered, the commits its tip reaches (#17); + // every other row fades. + const [focusTip, setFocusTip] = useState(null); + const reached = useMemo( + () => (focusTip ? reachableFrom(view.commits, focusTip) : null), + [view.commits, focusTip], + ); const refMap = useMemo(() => refsByCommit(repo), [repo.refs]); // Which other worktree has each local branch checked out: its badge gets // a marker, and its menu cannot check it out here. @@ -197,6 +255,16 @@ export function RepoCard({ // a repository without submodules answers an empty list. const showSubmodules = useSubmoduleLinks(); const [subGraphs, setSubGraphs] = useState([]); + // Submodule regions collapsed to a strip, by path, kept per repository. + const [collapsedSubs, setCollapsedSubs] = useState>(() => + storedCollapsedSubmodules(repo.id), + ); + const toggleSubmodule = (path: string) => { + const next = new Set(collapsedSubs); + if (!next.delete(path)) next.add(path); + setCollapsedSubs(next); + storeCollapsedSubmodules(repo.id, next); + }; useEffect(() => { if (!showSubmodules) { setSubGraphs([]); @@ -214,11 +282,16 @@ export function RepoCard({ }, [repo.id, showSubmodules, loadSubmodules]); const [refMenu, setRefMenu] = useState(null); - // Right-clicking a commit row offers actions on that commit. + // Right-clicking a commit row or node offers actions on that commit. + // `webUrl` is the commit's page on its own repository's origin; `refs` + // offers tag and branch creation, which only this repository takes (a + // submodule's commit lives in another repository). const [commitMenu, setCommitMenu] = useState<{ x: number; y: number; commit: string; + webUrl?: string; + refs: boolean; } | null>(null); // Result of the last branch operation that failed; cleared on the next one. const [opError, setOpError] = useState(null); @@ -293,7 +366,13 @@ export function RepoCard({ const openCommitMenu = (e: React.MouseEvent, commit: CommitView) => { e.preventDefault(); if (commit.uncommitted !== undefined) return; - setCommitMenu({ x: e.clientX, y: e.clientY, commit: commit.id }); + setCommitMenu({ + x: e.clientX, + y: e.clientY, + commit: commit.id, + webUrl: webUrlOf(repo, commit.id), + refs: true, + }); }; const nodeLabel = (commit: CommitView) => commit.uncommitted !== undefined @@ -309,6 +388,28 @@ export function RepoCard({ setHoverNode({ x: e.clientX, y: e.clientY, label: nodeLabel(commit) }), onMouseLeave: () => setHoverNode(null), }); + // A submodule's nodes name and offer their commit the same way, from the + // submodule's own view; they have no row to select. + const submoduleNodeProps = (graph: SubmoduleGraph, commit: CommitView) => ({ + "data-testid": "submodule-node", + onContextMenu: (e: React.MouseEvent) => { + e.preventDefault(); + setCommitMenu({ + x: e.clientX, + y: e.clientY, + commit: commit.id, + webUrl: webUrlOf(graph.view, commit.id), + refs: false, + }); + }, + onMouseEnter: (e: React.MouseEvent) => + setHoverNode({ + x: e.clientX, + y: e.clientY, + label: `${shortId(commit.id)} ${graph.name}: ${commit.summary}`, + }), + onMouseLeave: () => setHoverNode(null), + }); const [detail, setDetail] = useState(null); const [detailError, setDetailError] = useState(null); const [diffTarget, setDiffTarget] = useState(null); @@ -325,7 +426,12 @@ export function RepoCard({ }, [repo.commits, selected]); // The tooltip follows a node; a node that vanishes under the pointer // (reload, paging) never fires mouseleave. - useEffect(() => setHoverNode(null), [view.commits]); + // A reload can unmount the hovered node or badge before the pointer + // leaves it, so its tooltip and branch focus go with the old view. + useEffect(() => { + setHoverNode(null); + setFocusTip(null); + }, [view.commits]); // Commits are immutable, the working tree is not: while the changes row // is selected, every reload re-reads its files and diffs. const uncommittedGeneration = @@ -400,14 +506,16 @@ export function RepoCard({ const links = showSquashLinks ? squashLinks(view, mergedPrs) : []; // Submodule regions sit left of the main graph; everything main-graph // shaped below draws inside a group shifted by mainOffset. - const { regions, mainOffset } = submoduleRegions(subGraphs); + const { regions, mainOffset } = submoduleRegions(subGraphs, collapsedSubs); const subLinks = submoduleCrossLinks(view, mainOffset, regions); // Link corridors beyond the real lanes widen the drawing. const width = mainOffset + graphWidth(laneSpan(view.lane_count, links)); const height = graphHeight( Math.max( view.commits.length, - ...regions.map((r) => r.graph.view.commits.length), + ...regions + .filter((r) => !r.collapsed) + .map((r) => r.graph.view.commits.length), ), ); const selectedCommit = selected @@ -446,21 +554,26 @@ export function RepoCard({ x={commitMenu.x} y={commitMenu.y} commit={commitMenu.commit} - webUrl={ - repo.commit_url_base && - `${repo.commit_url_base}${commitMenu.commit}` - } - onCreateTag={(name) => - runBranchOp( - (repoId, value) => createTag(repoId, value, commitMenu.commit), - name, - ) + webUrl={commitMenu.webUrl} + onCreateTag={ + commitMenu.refs + ? (name) => + runBranchOp( + (repoId, value) => + createTag(repoId, value, commitMenu.commit), + name, + ) + : undefined } - onCreateBranch={(name) => - runBranchOp( - (repoId, value) => createBranch(repoId, value, commitMenu.commit), - name, - ) + onCreateBranch={ + commitMenu.refs + ? (name) => + runBranchOp( + (repoId, value) => + createBranch(repoId, value, commitMenu.commit), + name, + ) + : undefined } onClose={() => setCommitMenu(null)} /> @@ -504,7 +617,25 @@ export function RepoCard({ data-testid="submodule-region" transform={`translate(${region.offset},0)`} > - {region.graph.view.edges.map((edge, i) => ( + {region.collapsed ? ( + toggleSubmodule(region.graph.path)} + > + + + {region.graph.name} + + + ) : null} + {!region.collapsed && + region.graph.view.edges.map((edge, i) => ( ))} - {region.graph.view.commits.map((commit) => ( - - {`${region.graph.name}: ${commit.summary}`} - - ))} ))} + {regions + .filter((region) => !region.collapsed) + .map((region) => ( + // Submodule nodes and the collapse button sit above the dashed + // links, whose wide hit stroke would otherwise cover them. + + {region.graph.view.commits.map((commit) => ( + + ))} + toggleSubmodule(region.graph.path)} + > + {/* Above the first row's nodes and the links they end in. */} + + + ‹ + + + + ))} {view.edges.map((edge, i) => { // A stash's helper links are auxiliary structure: dashed (and @@ -678,7 +838,7 @@ export function RepoCard({ key={commit.id} className={`commit${commit.id === selected ? " selected" : ""}${ uncommitted !== undefined ? " commit-uncommitted" : "" - }`} + }${reached && !reached.has(commit.id) ? " commit-faded" : ""}`} data-testid="commit-row" style={{ height: ROW_HEIGHT }} onClick={() => toggleSelected(commit.id)} @@ -701,6 +861,12 @@ export function RepoCard({ className={`badge badge-ref badge-kind-${ref.kind}${ ref.remote ? " badge-remote" : "" }`} + onMouseEnter={ + isBranch ? () => setFocusTip(commit.id) : undefined + } + onMouseLeave={ + isBranch ? () => setFocusTip(null) : undefined + } onContextMenu={(e) => { // Branches get checkout/merge, tags get delete; the // stash and other refs have no operations. diff --git a/frontend/src/graph.test.ts b/frontend/src/graph.test.ts index fe57d8a..47ac547 100644 --- a/frontend/src/graph.test.ts +++ b/frontend/src/graph.test.ts @@ -17,6 +17,8 @@ import { stashView, submoduleCrossLinks, submoduleRegions, + COLLAPSED_REGION_WIDTH, + reachableFrom, } from "./graph"; function edge(fromLane: number, toLane: number, fork = false): GraphEdge { @@ -352,6 +354,27 @@ test("submoduleRegions place each graph left of the main graph", () => { expect(submoduleRegions([]).mainOffset).toBe(0); }); +test("a collapsed submodule region narrows to a strip", () => { + const sub = (id: string, lanes: number) => ({ + name: id, + path: `/p/${id}`, + view: repoWith({ id, lane_count: lanes }), + updates: [], + }); + const { regions, mainOffset } = submoduleRegions( + [sub("a", 3), sub("b", 2)], + new Set(["/p/a"]), + ); + + expect(regions[0].collapsed).toBe(true); + expect(regions[0].width).toBe(COLLAPSED_REGION_WIDTH); + expect(regions[1].collapsed).toBe(false); + expect(regions[1].offset).toBe(COLLAPSED_REGION_WIDTH + REGION_GAP); + expect(mainOffset).toBe( + COLLAPSED_REGION_WIDTH + REGION_GAP + graphWidth(2) + REGION_GAP, + ); +}); + test("submoduleCrossLinks connect pointer updates across regions", () => { // Main commit "tip" (row 1, lane 1) moved the pointer to sub commit "s1" // (row 1, lane 0); an update to an unknown commit or sha is dropped. @@ -371,6 +394,7 @@ test("submoduleCrossLinks connect pointer updates across regions", () => { }, offset: 0, width: graphWidth(1), + collapsed: false, }; const mainOffset = graphWidth(1) + REGION_GAP; @@ -389,6 +413,11 @@ test("submoduleCrossLinks connect pointer updates across regions", () => { const path = crossPath(links[0]); expect(path.startsWith(`M${links[0].x1},${links[0].y1}`)).toBe(true); expect(path.endsWith(`${links[0].x2},${links[0].y2}`)).toBe(true); + + // A collapsed region draws no nodes, so nothing links into it. + expect( + submoduleCrossLinks(main, mainOffset, [{ ...region, collapsed: true }]), + ).toEqual([]); }); test("grabParameter measures how far along the chord a drag started", () => { @@ -490,3 +519,28 @@ test("loadedCommitCount leaves the uncommitted-changes row out", () => { expect(loadedCommitCount(repo)).toBe(2); expect(loadedCommitCount({ commits: [] } as unknown as RepoView)).toBe(0); }); + +test("reachableFrom follows every parent from a tip", () => { + // merge ← (main-2, feature-1); main-2 ← main-1; feature-1 ← main-1 ← root + const c = (id: string, parents: string[]) => ({ + ...commit(id, 0, 0, 0), + parents, + }); + const commits = [ + c("merge", ["main-2", "feature-1"]), + c("main-2", ["main-1"]), + c("feature-1", ["main-1"]), + c("main-1", ["root"]), + c("root", []), + ]; + + expect(reachableFrom(commits, "feature-1")).toEqual( + new Set(["feature-1", "main-1", "root"]), + ); + expect(reachableFrom(commits, "merge").size).toBe(5); + // A parent beyond the loaded rows is skipped; an unknown tip reaches none. + expect(reachableFrom([c("tip", ["unloaded"])], "tip")).toEqual( + new Set(["tip"]), + ); + expect(reachableFrom(commits, "gone")).toEqual(new Set()); +}); diff --git a/frontend/src/graph.ts b/frontend/src/graph.ts index e7c7c02..18413bf 100644 --- a/frontend/src/graph.ts +++ b/frontend/src/graph.ts @@ -115,6 +115,26 @@ export function rowIndex(commits: CommitView[]): Map { return map; } +/** The commits reachable from `tip` through any parent, `tip` included, + * among the loaded `commits`; a parent beyond them ends that path. Empty + * when `tip` is not loaded. */ +export function reachableFrom( + commits: CommitView[], + tip: string, +): Set { + const byId = new Map(commits.map((c) => [c.id, c])); + const reached = new Set(); + const stack = [tip]; + while (stack.length > 0) { + const id = stack.pop()!; + const commit = byId.get(id); + if (!commit || reached.has(id)) continue; + reached.add(id); + stack.push(...commit.parents); + } + return reached; +} + export function shortId(id: string): string { return id.slice(0, 7); } @@ -122,27 +142,39 @@ export function shortId(id: string): string { /** Horizontal gap between a submodule's graph region and the next one. */ export const REGION_GAP = 10; +/** Width of a collapsed submodule region: a strip that shows its name. */ +export const COLLAPSED_REGION_WIDTH = 16; + /** One submodule graph placed as its own region left of the main graph. */ export interface SubmoduleRegion { graph: SubmoduleGraph; /** X offset of the region's own coordinate origin within the shared SVG. */ offset: number; width: number; + /** Drawn as a strip with no nodes, edges or links. */ + collapsed: boolean; } /** * Split the drawing into one region per submodule, left of the main graph - * (each region keeps its own lane coordinates; the offset places it). Returns - * the regions and the main graph's X offset after them. + * (each region keeps its own lane coordinates; the offset places it). A + * submodule whose path is in `collapsed` narrows to a strip. Returns the + * regions and the main graph's X offset after them. */ -export function submoduleRegions(graphs: SubmoduleGraph[]): { +export function submoduleRegions( + graphs: SubmoduleGraph[], + collapsed: ReadonlySet = new Set(), +): { regions: SubmoduleRegion[]; mainOffset: number; } { let x = 0; const regions = graphs.map((graph) => { - const width = graphWidth(graph.view.lane_count); - const region = { graph, offset: x, width }; + const isCollapsed = collapsed.has(graph.path); + const width = isCollapsed + ? COLLAPSED_REGION_WIDTH + : graphWidth(graph.view.lane_count); + const region = { graph, offset: x, width, collapsed: isCollapsed }; x += width + REGION_GAP; return region; }); @@ -164,7 +196,7 @@ export interface SubmoduleLink { * The cross-region links: for every pointer update whose superproject commit * is on screen and whose submodule sha is in that region's (truncated) view, * one dashed line between the two nodes. Updates pointing at rows outside - * either view are dropped silently. + * either view, or into a collapsed region, are dropped silently. */ export function submoduleCrossLinks( main: RepoView, @@ -174,6 +206,7 @@ export function submoduleCrossLinks( const mainById = new Map(main.commits.map((c) => [c.id, c])); const links: SubmoduleLink[] = []; for (const region of regions) { + if (region.collapsed) continue; const subById = new Map(region.graph.view.commits.map((c) => [c.id, c])); for (const update of region.graph.updates) { const from = mainById.get(update.commit); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 854b84f..a3a3f74 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -73,6 +73,8 @@ const en = { cmdToggleStashInternals: "Toggle stash internals", cmdToggleAvatars: "Toggle GitHub avatars", cmdToggleSubmoduleLinks: "Toggle submodule graphs", + collapseSubmodule: "Collapse {name}", + expandSubmodule: "Expand {name}", cmdOpenRepo: "Open repository: {name}", cmdOpenRepoRight: "Open in right pane: {name}", cmdCheckoutBranch: "Checkout branch: {name}", @@ -238,6 +240,8 @@ const ja: Record = { cmdToggleStashInternals: "stash内部構造を切替", cmdToggleAvatars: "GitHubアイコンを切替", cmdToggleSubmoduleLinks: "サブモジュールグラフを切替", + collapseSubmodule: "{name} をたたむ", + expandSubmodule: "{name} を開く", cmdOpenRepo: "リポジトリを開く: {name}", cmdOpenRepoRight: "右のペインで開く: {name}", cmdCheckoutBranch: "ブランチをチェックアウト: {name}", diff --git a/frontend/src/misc.test.ts b/frontend/src/misc.test.ts index 1a2115c..7f6b4e1 100644 --- a/frontend/src/misc.test.ts +++ b/frontend/src/misc.test.ts @@ -9,7 +9,11 @@ import { analyzeNote } from "./Drawer"; import { format, MESSAGES, type MsgKey } from "./i18n"; import { signatureBadge } from "./RepoCard"; import { clampWidth } from "./Resizer"; -import { parseButtonStyle } from "./settings"; +import { + collapsedSubmodulesIn, + parseButtonStyle, + withCollapsedSubmodules, +} from "./settings"; import { resolveTheme, toggleTheme } from "./theme"; test("parseButtonStyle falls back to icon-label for unknown values", () => { @@ -113,3 +117,21 @@ test("resolveTheme prefers the stored value over the OS preference", () => { expect(resolveTheme("garbage", false)).toBe("light"); expect(toggleTheme("dark")).toBe("light"); }); + +test("collapsed submodules are kept per repository in one stored value", () => { + const one = withCollapsedSubmodules(null, "r1", new Set(["/a"])); + const two = withCollapsedSubmodules(one, "r2", new Set(["/b", "/c"])); + expect(collapsedSubmodulesIn(two, "r1")).toEqual(new Set(["/a"])); + expect(collapsedSubmodulesIn(two, "r2")).toEqual(new Set(["/b", "/c"])); + // Expanding everything drops the repository's entry. + const cleared = withCollapsedSubmodules(two, "r1", new Set()); + expect(JSON.parse(cleared)).toEqual({ r2: ["/b", "/c"] }); +}); + +test("a corrupted collapsed-submodules value is read as empty and replaced", () => { + for (const raw of ["[]", "null", "7", "not json", '{"r1":"/a"}']) { + expect(collapsedSubmodulesIn(raw, "r1")).toEqual(new Set()); + const written = withCollapsedSubmodules(raw, "r1", new Set(["/a"])); + expect(collapsedSubmodulesIn(written, "r1")).toEqual(new Set(["/a"])); + } +}); diff --git a/frontend/src/settings.tsx b/frontend/src/settings.tsx index 91d40e7..93420b0 100644 --- a/frontend/src/settings.tsx +++ b/frontend/src/settings.tsx @@ -152,6 +152,74 @@ export function storeSubmoduleLinks(on: boolean) { } } +export const COLLAPSED_SUBMODULES_KEY = "gitreant-collapsed-submodules"; + +/** The stored value as an object of repository id to paths; anything else + * (unset, corrupted) reads as empty. */ +function collapsedSubmodulesMap(raw: string | null): Record { + try { + const parsed: unknown = JSON.parse(raw ?? "{}"); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** The collapsed submodule paths of `repoId` in a stored value. */ +export function collapsedSubmodulesIn( + raw: string | null, + repoId: string, +): Set { + const paths = collapsedSubmodulesMap(raw)[repoId]; + return new Set( + Array.isArray(paths) + ? paths.filter((p): p is string => typeof p === "string") + : [], + ); +} + +/** The stored value with `repoId`'s collapsed paths replaced by `paths`. */ +export function withCollapsedSubmodules( + raw: string | null, + repoId: string, + paths: Set, +): string { + const all = collapsedSubmodulesMap(raw); + if (paths.size === 0) delete all[repoId]; + else all[repoId] = [...paths]; + return JSON.stringify(all); +} + +/** The paths of a repository's submodules whose graph regions are collapsed + * (#18), kept per repository id. */ +export function storedCollapsedSubmodules(repoId: string): Set { + try { + return collapsedSubmodulesIn( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY), + repoId, + ); + } catch { + return new Set(); + } +} + +export function storeCollapsedSubmodules(repoId: string, paths: Set) { + try { + localStorage.setItem( + COLLAPSED_SUBMODULES_KEY, + withCollapsedSubmodules( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY), + repoId, + paths, + ), + ); + } catch { + // localStorage may be unavailable; the state change alone is enough. + } +} + export const SettingsContext = createContext<{ buttonStyle: ButtonStyle; lang: Lang; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 630a441..03676b6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -902,6 +902,52 @@ body { stroke-width: 2.5; } +/* Collapsing and expanding a submodule region (#18). */ +.graph .region-divider { + pointer-events: none; +} + +.graph .region-toggle { + cursor: pointer; +} + +.graph .region-toggle:focus-visible { + outline: 2px solid var(--accent); +} + +.graph .region-collapse rect { + fill: var(--bg-elev); + stroke: var(--border); +} + +.graph .region-collapse text { + fill: var(--muted); + font-size: 10px; + text-anchor: middle; +} + +.graph .region-strip rect { + fill: color-mix(in srgb, var(--border) 40%, transparent); +} + +.graph .region-strip:hover rect { + fill: color-mix(in srgb, var(--accent) 20%, transparent); +} + +.graph .region-strip text { + fill: var(--muted); + font-size: 11px; +} + +/* Rows a hovered branch cannot reach (#17). */ +.commits .commit > * { + transition: opacity 0.12s; +} + +.commits .commit-faded > * { + opacity: 0.35; +} + /* The hash of the graph node under the pointer, beside the cursor. */ .node-tip { position: fixed;