From 691e677a7d8657d5180f7cc716c641055322c000 Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Fri, 16 Oct 2026 12:06:23 +0900 Subject: [PATCH 1/4] feat(ui): give submodule graph nodes the main graph's hover and menu Their commit menu offers no tag or branch, which would land in another repository, and the nodes now draw above the dashed links that covered them. --- CHANGELOG.md | 7 +++ docs/FEATURES.jp.md | 2 +- docs/FEATURES.md | 4 +- frontend/e2e/global-setup.ts | 11 +++- frontend/e2e/graph.spec.ts | 43 ++++++++++++++ frontend/src/CommitMenu.tsx | 40 +++++++------ frontend/src/RepoCard.tsx | 110 ++++++++++++++++++++++++++--------- 7 files changed, 168 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd70e5..5a369b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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..4d03e52 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -20,7 +20,7 @@ gitreant でできることを分野別にまとめる。起動方法とコマ - **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。 - **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。 - **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。 -- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。 +- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。サブモジュールのノードにホバーするとそのコミットを表示し、右クリックで ID か、サブモジュール自身の origin にあるコミットの Web ページをコピーできる。 - **作者のアイコン。** GitHub の作者アイコンを各行に表示する。設定で消せる。 ## コミットの詳細と差分 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f810e6a..58117a6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -46,7 +46,9 @@ see the [README](../README.md); design decisions live in [adr/](adr/). 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. 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..3801070 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; } diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index 2ba0512..100e0ec 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -1953,6 +1953,49 @@ 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 superproject draws its submodule graph with dashed pointer links", 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..9d5c030 100644 --- a/frontend/src/RepoCard.tsx +++ b/frontend/src/RepoCard.tsx @@ -88,6 +88,12 @@ function refsByCommit(repo: RepoView): Map { } /** What the diff pane shows: one file, or everything the commit changed. */ +/** 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 @@ -214,11 +220,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 +304,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 +326,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); @@ -446,21 +485,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)} /> @@ -514,19 +558,6 @@ export function RepoCard({ opacity={0.7} /> ))} - {region.graph.view.commits.map((commit) => ( - - {`${region.graph.name}: ${commit.summary}`} - - ))} ))} + {regions.map((region) => ( + // Submodule nodes sit above the dashed links, whose wide hit + // stroke would otherwise cover the node it ends on. + + {region.graph.view.commits.map((commit) => ( + + ))} + + ))} {view.edges.map((edge, i) => { // A stash's helper links are auxiliary structure: dashed (and From ebb605b858aa78e215cd36d8da7f30bdf168e205 Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Fri, 16 Oct 2026 12:14:48 +0900 Subject: [PATCH 2/4] feat(ui): collapse each submodule graph to a strip The rows lose width to every submodule's graph; a collapsed one keeps only its name, and the choice is kept per repository (#18). --- CHANGELOG.md | 3 ++ docs/FEATURES.jp.md | 2 +- docs/FEATURES.md | 4 +- frontend/e2e/graph.spec.ts | 38 ++++++++++++++++ frontend/src/RepoCard.tsx | 93 ++++++++++++++++++++++++++++++++++++-- frontend/src/graph.test.ts | 28 ++++++++++++ frontend/src/graph.ts | 25 +++++++--- frontend/src/i18n.ts | 4 ++ frontend/src/settings.tsx | 33 ++++++++++++++ frontend/src/styles.css | 37 +++++++++++++++ 10 files changed, 255 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a369b5..0846bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 diff --git a/docs/FEATURES.jp.md b/docs/FEATURES.jp.md index 4d03e52..6b72fdd 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -20,7 +20,7 @@ gitreant でできることを分野別にまとめる。起動方法とコマ - **ノードの操作。** ノードにホバーするとハッシュを表示し、クリックでそのコミットを選ぶ。右クリックでコミット行と同じメニューを開き、フル ID のコピー、タグやブランチの作成ができる。origin が github.com か gitlab.com にあれば、コミットの Web ページの URL をコピーしたり、そのページを開いたりもできる。ハッシュのクリックでフル ID をコピーする。 - **バッジ。** ブランチ・タグ・stash を別々のバッジで描く。リモートのブランチやリモートに push 済みのタグには、リモート名の区切りを付ける。 - **stash。** stash は 1 つのノードにたたんで描く。設定で、index と未追跡ファイルの内部コミットを点線で開いて見せられる。 -- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。サブモジュールのノードにホバーするとそのコミットを表示し、右クリックで ID か、サブモジュール自身の origin にあるコミットの Web ページをコピーできる。 +- **サブモジュールのグラフ。** サブモジュールを持つリポジトリでは、サブモジュールのグラフを左側に並べ、サブモジュールのポインタを動かしたコミットどうしを点線で結ぶ。設定で切り替えられる。サブモジュールごとにグラフを名前だけの細い帯にたため、行の文字に幅を譲れる。たたんだ状態はリポジトリごとに保存する。サブモジュールのノードにホバーするとそのコミットを表示し、右クリックで ID か、サブモジュール自身の origin にあるコミットの Web ページをコピーできる。 - **作者のアイコン。** GitHub の作者アイコンを各行に表示する。設定で消せる。 ## コミットの詳細と差分 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 58117a6..bbac53d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -46,7 +46,9 @@ see the [README](../README.md); design decisions live in [adr/](adr/). 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. Hovering a submodule's node names + 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 diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index 100e0ec..bd5c8af 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -1996,6 +1996,44 @@ test.describe.serial("gitreant UI", () => { 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. + await region.getByTestId("region-collapse").click(); + await expect(page.getByTestId("submodule-node")).toHaveCount(0); + await expect(page.getByTestId("submodule-edge")).toHaveCount(0); + await expect(region.getByTestId("region-expand")).toContainText("sub"); + 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, }) => { diff --git a/frontend/src/RepoCard.tsx b/frontend/src/RepoCard.tsx index 9d5c030..5508f94 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, @@ -88,6 +90,44 @@ 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, + label, + onToggle, + children, +}: { + testId: string; + className: string; + 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 { @@ -203,6 +243,19 @@ 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), + ); + useEffect(() => setCollapsedSubs(storedCollapsedSubmodules(repo.id)), [ + 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([]); @@ -439,14 +492,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 @@ -548,7 +603,35 @@ 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} + + + ) : ( + toggleSubmodule(region.graph.path)} + > + + + ‹ + + + )} + {!region.collapsed && + region.graph.view.edges.map((edge, i) => ( ))} - {regions.map((region) => ( + {regions + .filter((region) => !region.collapsed) + .map((region) => ( // Submodule nodes sit above the dashed links, whose wide hit // stroke would otherwise cover the node it ends on. { 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 +393,7 @@ test("submoduleCrossLinks connect pointer updates across regions", () => { }, offset: 0, width: graphWidth(1), + collapsed: false, }; const mainOffset = graphWidth(1) + REGION_GAP; @@ -389,6 +412,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", () => { diff --git a/frontend/src/graph.ts b/frontend/src/graph.ts index e7c7c02..a8b833c 100644 --- a/frontend/src/graph.ts +++ b/frontend/src/graph.ts @@ -122,27 +122,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 +176,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 +186,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/settings.tsx b/frontend/src/settings.tsx index 91d40e7..7a81bb4 100644 --- a/frontend/src/settings.tsx +++ b/frontend/src/settings.tsx @@ -152,6 +152,39 @@ export function storeSubmoduleLinks(on: boolean) { } } +export const COLLAPSED_SUBMODULES_KEY = "gitreant-collapsed-submodules"; + +/** The paths of a repository's submodules whose graph regions are collapsed + * (#18), kept per repository id. */ +export function storedCollapsedSubmodules(repoId: string): Set { + try { + const all = JSON.parse( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY) ?? "{}", + ) as Record; + const paths = all[repoId]; + return new Set( + Array.isArray(paths) + ? paths.filter((p): p is string => typeof p === "string") + : [], + ); + } catch { + return new Set(); + } +} + +export function storeCollapsedSubmodules(repoId: string, paths: Set) { + try { + const all = JSON.parse( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY) ?? "{}", + ) as Record; + if (paths.size === 0) delete all[repoId]; + else all[repoId] = [...paths]; + localStorage.setItem(COLLAPSED_SUBMODULES_KEY, JSON.stringify(all)); + } 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..20068e2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -902,6 +902,43 @@ 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: 12px; + 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; +} + /* The hash of the graph node under the pointer, beside the cursor. */ .node-tip { position: fixed; From 4acfa5539baf5db96f3006cf7406cc167761fde4 Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Fri, 16 Oct 2026 12:17:42 +0900 Subject: [PATCH 3/4] feat(ui): fade the rows a hovered branch cannot reach Following one branch through a busy graph meant reading every row (#17). --- CHANGELOG.md | 2 ++ docs/FEATURES.jp.md | 2 +- docs/FEATURES.md | 3 ++- frontend/e2e/graph.spec.ts | 27 +++++++++++++++++++++++++++ frontend/src/RepoCard.tsx | 16 +++++++++++++++- frontend/src/graph.test.ts | 26 ++++++++++++++++++++++++++ frontend/src/graph.ts | 20 ++++++++++++++++++++ frontend/src/styles.css | 9 +++++++++ 8 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0846bdc..cb569e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ 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)). diff --git a/docs/FEATURES.jp.md b/docs/FEATURES.jp.md index 6b72fdd..6ba3c04 100644 --- a/docs/FEATURES.jp.md +++ b/docs/FEATURES.jp.md @@ -18,7 +18,7 @@ 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 bbac53d..6c3aafc 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -41,7 +41,8 @@ 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 diff --git a/frontend/e2e/graph.spec.ts b/frontend/e2e/graph.spec.ts index bd5c8af..f649efc 100644 --- a/frontend/e2e/graph.spec.ts +++ b/frontend/e2e/graph.spec.ts @@ -2172,6 +2172,33 @@ 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); + }); + test("graph nodes show their hash on hover and open the commit menu", async ({ page, }) => { diff --git a/frontend/src/RepoCard.tsx b/frontend/src/RepoCard.tsx index 5508f94..3c298c5 100644 --- a/frontend/src/RepoCard.tsx +++ b/frontend/src/RepoCard.tsx @@ -64,6 +64,7 @@ import { laneColor, nodeX, nodeY, + reachableFrom, rowIndex, ROW_HEIGHT, NODE_RADIUS, @@ -204,6 +205,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. @@ -815,7 +823,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)} @@ -838,6 +846,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 e011e8d..47ac547 100644 --- a/frontend/src/graph.test.ts +++ b/frontend/src/graph.test.ts @@ -18,6 +18,7 @@ import { submoduleCrossLinks, submoduleRegions, COLLAPSED_REGION_WIDTH, + reachableFrom, } from "./graph"; function edge(fromLane: number, toLane: number, fork = false): GraphEdge { @@ -518,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 a8b833c..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); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 20068e2..78b905d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -939,6 +939,15 @@ body { 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; From 303b28b3d3ccd2b735b7641ccbc9f836cd844aed Mon Sep 17 00:00:00 2001 From: ShortArrow Date: Fri, 16 Oct 2026 12:34:22 +0900 Subject: [PATCH 4/4] fix(ui): keep the branch fade, region toggle and stored collapse state robust A reload under a hovered badge left rows faded, a link to the newest commit covered half the collapse button, the strip's name spilled past it, and a corrupted stored value stopped the collapse from being saved. --- frontend/e2e/global-setup.ts | 1 + frontend/e2e/graph.spec.ts | 39 ++++++++++++++++++++-- frontend/src/RepoCard.tsx | 55 +++++++++++++++++++------------ frontend/src/misc.test.ts | 24 +++++++++++++- frontend/src/settings.tsx | 63 ++++++++++++++++++++++++++++-------- frontend/src/styles.css | 2 +- 6 files changed, 146 insertions(+), 38 deletions(-) diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts index 3801070..fc09f06 100644 --- a/frontend/e2e/global-setup.ts +++ b/frontend/e2e/global-setup.ts @@ -428,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 f649efc..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"; @@ -2008,10 +2009,23 @@ test.describe.serial("gitreant UI", () => { const wide = (await page.getByTestId("graph").getAttribute("width"))!; // Collapsing leaves a named strip: no nodes, no links, a narrower graph. - await region.getByTestId("region-collapse").click(); + // 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); - await expect(region.getByTestId("region-expand")).toContainText("sub"); + 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)); @@ -2197,6 +2211,27 @@ test.describe.serial("gitreant UI", () => { // 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 ({ diff --git a/frontend/src/RepoCard.tsx b/frontend/src/RepoCard.tsx index 3c298c5..4f7e493 100644 --- a/frontend/src/RepoCard.tsx +++ b/frontend/src/RepoCard.tsx @@ -96,12 +96,15 @@ function refsByCommit(repo: RepoView): Map { 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; @@ -113,6 +116,7 @@ function RegionToggle({ role="button" tabIndex={0} aria-label={label} + aria-expanded={expanded} // A mouse press must not leave a focus ring on the strip that // replaces the button; the keyboard keeps focus across the swap. onMouseDown={(e) => e.preventDefault()} @@ -255,9 +259,6 @@ export function RepoCard({ const [collapsedSubs, setCollapsedSubs] = useState>(() => storedCollapsedSubmodules(repo.id), ); - useEffect(() => setCollapsedSubs(storedCollapsedSubmodules(repo.id)), [ - repo.id, - ]); const toggleSubmodule = (path: string) => { const next = new Set(collapsedSubs); if (!next.delete(path)) next.add(path); @@ -425,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 = @@ -615,29 +621,19 @@ export function RepoCard({ toggleSubmodule(region.graph.path)} > {region.graph.name} - ) : ( - toggleSubmodule(region.graph.path)} - > - - - ‹ - - - )} + ) : null} {!region.collapsed && region.graph.view.edges.map((edge, i) => ( !region.collapsed) .map((region) => ( - // Submodule nodes sit above the dashed links, whose wide hit - // stroke would otherwise cover the node it ends on. + // Submodule nodes and the collapse button sit above the dashed + // links, whose wide hit stroke would otherwise cover them. ))} + toggleSubmodule(region.graph.path)} + > + {/* Above the first row's nodes and the links they end in. */} + + + ‹ + + ))} 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 7a81bb4..93420b0 100644 --- a/frontend/src/settings.tsx +++ b/frontend/src/settings.tsx @@ -154,18 +154,51 @@ 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 { - const all = JSON.parse( - localStorage.getItem(COLLAPSED_SUBMODULES_KEY) ?? "{}", - ) as Record; - const paths = all[repoId]; - return new Set( - Array.isArray(paths) - ? paths.filter((p): p is string => typeof p === "string") - : [], + return collapsedSubmodulesIn( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY), + repoId, ); } catch { return new Set(); @@ -174,12 +207,14 @@ export function storedCollapsedSubmodules(repoId: string): Set { export function storeCollapsedSubmodules(repoId: string, paths: Set) { try { - const all = JSON.parse( - localStorage.getItem(COLLAPSED_SUBMODULES_KEY) ?? "{}", - ) as Record; - if (paths.size === 0) delete all[repoId]; - else all[repoId] = [...paths]; - localStorage.setItem(COLLAPSED_SUBMODULES_KEY, JSON.stringify(all)); + localStorage.setItem( + COLLAPSED_SUBMODULES_KEY, + withCollapsedSubmodules( + localStorage.getItem(COLLAPSED_SUBMODULES_KEY), + repoId, + paths, + ), + ); } catch { // localStorage may be unavailable; the state change alone is enough. } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 78b905d..03676b6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -922,7 +922,7 @@ body { .graph .region-collapse text { fill: var(--muted); - font-size: 12px; + font-size: 10px; text-anchor: middle; }