diff --git a/src/backend/queries/loadCommits.ts b/src/backend/queries/loadCommits.ts index 9a38393..3502c05 100644 --- a/src/backend/queries/loadCommits.ts +++ b/src/backend/queries/loadCommits.ts @@ -12,7 +12,7 @@ const eolRegex = /\r\n|\r|\n/g; const gitLogSeparator = "XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb"; type LoadCommitsInput = { - branchName: string; + branchNames: string[]; maxCommits: number; showRemoteBranches: boolean; hard: boolean; @@ -55,7 +55,7 @@ async function getRefs(git: SimpleGit, showRemoteBranches: boolean): Promise> { - const { branchName, maxCommits, showRemoteBranches, hard, dateType, showUncommittedChanges } = + const { branchNames, maxCommits, showRemoteBranches, hard, dateType, showUncommittedChanges } = input; const [rawCommits, refData] = await Promise.all([ - getLog(git, branchName, maxCommits + 1, showRemoteBranches, dateType), + getLog(git, branchNames, maxCommits + 1, showRemoteBranches, dateType), getRefs(git, showRemoteBranches) ]); diff --git a/src/backend/types/queries.types.ts b/src/backend/types/queries.types.ts index b29d6ac..6c92a67 100644 --- a/src/backend/types/queries.types.ts +++ b/src/backend/types/queries.types.ts @@ -12,7 +12,7 @@ type QueryPayloads = { loadCommits: { request: { repo: string; - branchName: string; + branchNames: string[]; maxCommits: number; showRemoteBranches: boolean; hard: boolean; diff --git a/src/extension/messageHandler.ts b/src/extension/messageHandler.ts index 5a00af5..9a84c3a 100644 --- a/src/extension/messageHandler.ts +++ b/src/extension/messageHandler.ts @@ -111,7 +111,7 @@ export function registerMessageHandlers( bridge.post({ command: "loadCommits", ...(await loadCommits(gitClient.getInstance(), { - branchName: msg.branchName, + branchNames: msg.branchNames, maxCommits: msg.maxCommits, showRemoteBranches: msg.showRemoteBranches, hard: msg.hard, diff --git a/src/webview/dropdown.ts b/src/webview/dropdown.ts index c0fb403..e44003e 100644 --- a/src/webview/dropdown.ts +++ b/src/webview/dropdown.ts @@ -6,28 +6,20 @@ interface DropdownOption { value: string; } -export class Dropdown { - private options: DropdownOption[] = []; - private selectedOption: number = 0; - private dropdownVisible: boolean = false; - private showInfo: boolean; - private changeCallback: { (value: string): void }; +abstract class AbstractDropdown { + protected options: DropdownOption[] = []; + protected dropdownVisible: boolean = false; + protected showInfo: boolean; - private elem: HTMLElement; - private currentValueElem: HTMLDivElement; - private menuElem: HTMLDivElement; - private optionsElem: HTMLDivElement; - private noResultsElem: HTMLDivElement; - private filterInput: HTMLInputElement; + protected elem: HTMLElement; + protected currentValueElem: HTMLDivElement; + protected menuElem: HTMLDivElement; + protected optionsElem: HTMLDivElement; + protected noResultsElem: HTMLDivElement; + protected filterInput: HTMLInputElement; - constructor( - id: string, - showInfo: boolean, - dropdownType: string, - changeCallback: { (value: string): void } - ) { + constructor(id: string, showInfo: boolean, dropdownType: string) { this.showInfo = showInfo; - this.changeCallback = changeCallback; this.elem = document.getElementById(id)!; let filter = document.createElement("div"); @@ -59,7 +51,7 @@ export class Dropdown { this.dropdownVisible = !this.dropdownVisible; if (this.dropdownVisible) { this.filterInput.value = ""; - this.filter(); + this.onFilterChange(); } this.elem.classList.toggle("dropdownOpen"); if (this.dropdownVisible) this.filterInput.focus(); @@ -67,20 +59,7 @@ export class Dropdown { if ((e.target).closest(".dropdown") !== this.elem) { this.close(); } else { - let option = (e.target).closest(".dropdownOption"); - if ( - option !== null && - option.parentNode === this.optionsElem && - typeof option.dataset.id !== "undefined" - ) { - let selectedOption = parseInt(option.dataset.id!); - this.close(); - if (this.selectedOption !== selectedOption) { - this.selectedOption = selectedOption; - this.render(); - this.changeCallback(this.options[this.selectedOption].value); - } - } + this.handleOptionClick(e.target as HTMLElement); } } }, @@ -94,7 +73,69 @@ export class Dropdown { }, true ); - this.filterInput.addEventListener("keyup", () => this.filter()); + this.filterInput.addEventListener("keyup", () => this.onFilterChange()); + } + + protected handleOptionClick(target: HTMLElement) { + let option = target.closest(".dropdownOption"); + if ( + option !== null && + option.parentNode === this.optionsElem && + typeof option.dataset.id !== "undefined" + ) { + this.onOptionSelected(parseInt(option.dataset.id!)); + } + } + + protected abstract onOptionSelected(index: number): void; + protected abstract render(): void; + protected abstract onFilterChange(): void; + + public abstract setOptions(options: DropdownOption[], ...args: unknown[]): void; + + public refresh() { + if (this.options.length > 0) this.render(); + } + + protected filter() { + let val = this.filterInput.value.toLowerCase(), + match, + matches = false; + for (let i = 0; i < this.options.length; i++) { + match = this.options[i].name.toLowerCase().indexOf(val) > -1; + (this.optionsElem.children[i]).style.display = match ? "block" : "none"; + if (match) matches = true; + } + this.filterInput.style.display = "block"; + this.noResultsElem.style.display = matches ? "none" : "block"; + } + + protected close() { + this.elem.classList.remove("dropdownOpen"); + this.dropdownVisible = false; + } + + protected updateCurrentValueWidth() { + this.currentValueElem.style.width = + Math.max( + this.menuElem.offsetWidth + (this.showInfo && this.menuElem.offsetHeight < 272 ? 0 : 12), + 130 + ) + "px"; + } +} + +export class Dropdown extends AbstractDropdown { + private selectedOption: number = 0; + private changeCallback: { (value: string): void }; + + constructor( + id: string, + showInfo: boolean, + dropdownType: string, + changeCallback: { (value: string): void } + ) { + super(id, showInfo, dropdownType); + this.changeCallback = changeCallback; } public setOptions(options: DropdownOption[], selected: string) { @@ -110,11 +151,16 @@ export class Dropdown { this.render(); } - public refresh() { - if (this.options.length > 0) this.render(); + protected onOptionSelected(index: number): void { + this.close(); + if (this.selectedOption !== index) { + this.selectedOption = index; + this.render(); + this.changeCallback(this.options[this.selectedOption].value); + } } - private render() { + protected render() { this.elem.classList.add("loaded"); this.currentValueElem.innerHTML = escapeHtml(this.options[this.selectedOption].name); let html = ""; @@ -142,30 +188,127 @@ export class Dropdown { this.menuElem.style.cssText = "opacity:0; display:block;"; // Width must be at least 130px for the filter elements. Max height for the dropdown is [filter (31px) + 9.5 * dropdown item (28px) = 297px] // Don't need to add 12px if showing info icons and scrollbar isn't needed. The scrollbar isn't needed if: menuElem height + filter input (25px) < 297px - this.currentValueElem.style.width = - Math.max( - this.menuElem.offsetWidth + (this.showInfo && this.menuElem.offsetHeight < 272 ? 0 : 12), - 130 - ) + "px"; + this.updateCurrentValueWidth(); this.menuElem.style.cssText = "right:0; overflow-y:auto; max-height:297px;"; if (this.dropdownVisible) this.filter(); } - private filter() { - let val = this.filterInput.value.toLowerCase(), - match, - matches = false; + protected onFilterChange() { + this.filter(); + } +} + +export class CheckboxDropdown extends AbstractDropdown { + private selectedOptions: Set = new Set(); + private multipleChangeCallback: { (values: string[], change: string): void }; + + constructor( + id: string, + showInfo: boolean, + dropdownType: string, + changeCallback: { (values: string[], change: string): void } + ) { + super(id, showInfo, dropdownType); + this.multipleChangeCallback = changeCallback; + } + + public setOptions(options: DropdownOption[], selectedValues: string[]) { + this.options = options; + this.selectedOptions.clear(); + for (const value of selectedValues) { + this.selectedOptions.add(value); + } + if (options.length <= 1) this.close(); + this.render(); + } + + public setSelected(value: string) { + if (!this.options.some((i) => i.value === value)) + throw new Error( + `unknown option: "${value}", available options: ${this.options.map((i) => `"${i.value}"`).join(", ")}` + ); + this.selectedOptions.add(value); + this.render(); + } + + public setUnSelected(value: string) { + if (!this.options.some((i) => i.value === value)) + throw new Error( + `unknown option: "${value}", available options: ${this.options.map((i) => `"${i.value}"`).join(", ")}` + ); + this.selectedOptions.delete(value); + this.render(); + } + + protected onOptionSelected(index: number): void { + const value = this.options[index].value; + if (this.selectedOptions.has(value)) { + this.selectedOptions.delete(value); + } else { + this.selectedOptions.add(value); + } + this.render(); + this.emitChange(value); + } + + protected render() { + this.elem.classList.add("loaded"); + + const selectedNames = this.options + .filter((i) => this.selectedOptions.has(i.value)) + .map((i) => i.name) + .join(" & "); + this.currentValueElem.innerHTML = escapeHtml(selectedNames); + + let html = ""; + for (const [i, option] of Object.entries(this.options)) { + const isSelected = this.selectedOptions.has(option.value); + html += + '"; + } + this.optionsElem.className = "dropdownOptions" + (this.showInfo ? " showInfo" : ""); + this.optionsElem.innerHTML = html; + this.filterInput.style.display = "none"; + this.noResultsElem.style.display = "none"; + this.menuElem.style.cssText = "opacity:0; display:block;"; + this.updateCurrentValueWidth(); + this.menuElem.style.cssText = "right:0; overflow-y:auto; max-height:297px;"; + + if (this.dropdownVisible) this.filterCheckboxes(); + } + + protected onFilterChange() { + this.filterCheckboxes(); + } + + private filterCheckboxes() { + const val = this.filterInput.value.toLowerCase(); + let matches = false; for (let i = 0; i < this.options.length; i++) { - match = this.options[i].name.toLowerCase().indexOf(val) > -1; - (this.optionsElem.children[i]).style.display = match ? "block" : "none"; + const match = this.options[i].name.toLowerCase().indexOf(val) > -1; + (this.optionsElem.children[i] as HTMLElement).style.display = match ? "block" : "none"; if (match) matches = true; } this.filterInput.style.display = "block"; this.noResultsElem.style.display = matches ? "none" : "block"; } - private close() { - this.elem.classList.remove("dropdownOpen"); - this.dropdownVisible = false; + private emitChange(change: string) { + this.multipleChangeCallback(Array.from(this.selectedOptions), change); } } diff --git a/src/webview/global.d.ts b/src/webview/global.d.ts index 03f3d20..8557bde 100644 --- a/src/webview/global.d.ts +++ b/src/webview/global.d.ts @@ -108,7 +108,7 @@ declare global { commits: GitCommitNode[]; commitHead: string | null; avatars: AvatarImageCollection; - currentBranch: string | null; + currentBranches: string[] | null; currentRepo: string; moreCommitsAvailable: boolean; maxCommits: number; diff --git a/src/webview/main.ts b/src/webview/main.ts index 9609360..007ab74 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -7,7 +7,7 @@ import type { GitResetMode } from "@/backend/types"; -import { Dropdown } from "./dropdown"; +import { CheckboxDropdown, Dropdown } from "./dropdown"; import { Graph } from "./graph"; import { getMonth, pad2 } from "./utils/date"; import { addListenerToClass, blinkHeadRow, insertAfter } from "./utils/dom"; @@ -16,6 +16,8 @@ import { escapeHtml, unescapeHtml } from "./utils/html"; import { svgIcons } from "./utils/icons"; import { getVSCodeStyle, sendMessage, vscode } from "./utils/vscode"; +const SHOW_ALL = ""; + class GitGraphView { private gitRepos: GG.GitRepoSet; private gitBranches: string[] = []; @@ -24,7 +26,7 @@ class GitGraphView { private commitHead: string | null = null; private commitLookup: { [hash: string]: number } = {}; private avatars: AvatarImageCollection = {}; - private currentBranch: string | null = null; + private currentBranches: string[] | null = null; private currentRepo!: string; private graph: Graph; @@ -37,7 +39,7 @@ class GitGraphView { private tableElem: HTMLElement; private footerElem: HTMLElement; private repoDropdown: Dropdown; - private branchDropdown: Dropdown; + private branchDropdown: CheckboxDropdown; private showRemoteBranchesElem: HTMLInputElement; private scrollShadowElem: HTMLElement; @@ -60,19 +62,50 @@ class GitGraphView { this.currentRepo = value; this.maxCommits = this.config.initialLoadCommits; this.expandedCommit = null; - this.currentBranch = null; + this.currentBranches = null; this.saveState(); sendMessage({ command: "selectRepo", repo: value }); this.refresh(true); }); - this.branchDropdown = new Dropdown("branchSelect", false, l10n.branch, (value) => { - this.currentBranch = value; - this.maxCommits = this.config.initialLoadCommits; - this.expandedCommit = null; - this.saveState(); - this.renderShowLoading(); - this.requestLoadCommits(true, () => {}); - }); + this.branchDropdown = new CheckboxDropdown( + "branchSelect", + false, + l10n.branch, + (values, change) => { + // special handling show all + + if (values.length === 0) { + // no selected, should add show all / current branch to selected + let defaultBranch: string; + if (this.config.showCurrentBranchByDefault && this.gitBranchHead) + defaultBranch = this.gitBranchHead; + else defaultBranch = SHOW_ALL; + this.branchDropdown.setSelected(defaultBranch); + values = [defaultBranch]; + } else if (change === SHOW_ALL && values.includes(SHOW_ALL)) { + // when selected show all, should clear other selections and keep show all selected + const options = [{ name: l10n.showAll, value: SHOW_ALL }]; + for (const branch of this.gitBranches) { + options.push({ + name: branch.startsWith("remotes/") ? branch.substring(8) : branch, + value: branch + }); + } + this.branchDropdown.setOptions(options, [SHOW_ALL]); + values = [SHOW_ALL]; + } else if (change !== SHOW_ALL && values.includes(SHOW_ALL)) { + // when selected another, should remove show all from selected + this.branchDropdown.setUnSelected(SHOW_ALL); + values = values.filter((v) => v !== SHOW_ALL); + } + this.currentBranches = values; + this.maxCommits = this.config.initialLoadCommits; + this.expandedCommit = null; + this.saveState(); + this.renderShowLoading(); + this.requestLoadCommits(true, () => {}); + } + ); this.showRemoteBranchesElem = ( document.getElementById("showRemoteBranchesCheckbox")! ); @@ -97,7 +130,7 @@ class GitGraphView { this.renderShowLoading(); if (prevState) { - this.currentBranch = prevState.currentBranch; + this.currentBranches = prevState.currentBranches; this.showRemoteBranches = prevState.showRemoteBranches; this.showRemoteBranchesElem.checked = this.showRemoteBranches; if (typeof this.gitRepos[prevState.currentRepo] !== "undefined") { @@ -171,18 +204,20 @@ class GitGraphView { this.gitBranches = branchOptions; this.gitBranchHead = branchHead; - if ( - this.currentBranch === null || - (this.currentBranch !== "" && this.gitBranches.indexOf(this.currentBranch) === -1) - ) { - this.currentBranch = - this.config.showCurrentBranchByDefault && this.gitBranchHead !== null - ? this.gitBranchHead - : ""; + if (this.currentBranches === null) { + if (this.config.showCurrentBranchByDefault && this.gitBranchHead) + this.currentBranches = [this.gitBranchHead]; + else this.currentBranches = [SHOW_ALL]; + } else { + const newData = []; + for (const branch of this.currentBranches) { + if (this.gitBranches.includes(branch)) newData.push(branch); + } + this.currentBranches = newData.length > 0 ? newData : [SHOW_ALL]; } this.saveState(); - let options = [{ name: l10n.showAll, value: "" }]; + let options = [{ name: l10n.showAll, value: SHOW_ALL }]; for (let i = 0; i < this.gitBranches.length; i++) { options.push({ name: @@ -192,7 +227,7 @@ class GitGraphView { value: this.gitBranches[i] }); } - this.branchDropdown.setOptions(options, this.currentBranch); + this.branchDropdown.setOptions(options, this.currentBranches!); this.triggerLoadBranchesCallback(true, isRepo); } @@ -324,7 +359,7 @@ class GitGraphView { sendMessage({ command: "loadCommits", repo: this.currentRepo!, - branchName: this.currentBranch !== null ? this.currentBranch : "", + branchNames: this.currentBranches !== null ? this.currentBranches : [SHOW_ALL], maxCommits: this.maxCommits, showRemoteBranches: this.showRemoteBranches, hard: hard @@ -364,7 +399,7 @@ class GitGraphView { commits: this.commits, commitHead: this.commitHead, avatars: this.avatars, - currentBranch: this.currentBranch, + currentBranches: this.currentBranches, currentRepo: this.currentRepo, moreCommitsAvailable: this.moreCommitsAvailable, maxCommits: this.maxCommits, @@ -470,14 +505,17 @@ class GitGraphView { this.makeTableResizable(); if (this.moreCommitsAvailable) { - document.getElementById("loadMoreCommitsBtn")!.addEventListener("click", () => { - (document.getElementById("loadMoreCommitsBtn")!.parentNode!).innerHTML = - '

' + svgIcons.loading + l10n.loading + "

"; - this.maxCommits += this.config.loadMoreCommits; - this.hideCommitDetails(); - this.saveState(); - this.requestLoadCommits(true, () => {}); - }); + const loadMoreBtn = document.getElementById("loadMoreCommitsBtn"); + if (loadMoreBtn) { + loadMoreBtn.addEventListener("click", () => { + (document.getElementById("loadMoreCommitsBtn")!.parentNode!).innerHTML = + '

' + svgIcons.loading + l10n.loading + "

"; + this.maxCommits += this.config.loadMoreCommits; + this.hideCommitDetails(); + this.saveState(); + this.requestLoadCommits(true, () => {}); + }); + } } if (this.expandedCommit !== null) { @@ -963,6 +1001,10 @@ class GitGraphView { mouseX = -1, col = -1; + if (cols.length === 0) { + return; + } + const makeTableFixedLayout = () => { if (columnWidths !== null) { cols[0].style.width = columnWidths[0] + "px"; diff --git a/tests/backend/queries/loadCommits/list.test.ts b/tests/backend/queries/loadCommits/list.test.ts index fccd1a8..2d43244 100644 --- a/tests/backend/queries/loadCommits/list.test.ts +++ b/tests/backend/queries/loadCommits/list.test.ts @@ -8,6 +8,8 @@ import { loadCommits } from "@/backend/queries/loadCommits"; import { git, makeRepo } from "@tests/backend/helpers"; +const SHOW_ALL = ""; + let repo: string; let repoWithRemote: string; let remoteRepo: string; @@ -33,7 +35,7 @@ afterAll(() => { describe("loadCommits", () => { it("returns commits with expected fields", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -60,7 +62,7 @@ describe("loadCommits", () => { it("attaches HEAD ref to the current commit and sets head correctly", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -75,7 +77,7 @@ describe("loadCommits", () => { it("limits to maxCommits and sets moreCommitsAvailable: true", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 1, showRemoteBranches: false, hard: false, @@ -93,7 +95,7 @@ describe("loadCommits", () => { it("moreCommitsAvailable is false when all commits fit", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -108,9 +110,61 @@ describe("loadCommits", () => { }); }); + it("filters commits by multiple branches", async () => { + const multiBranchRepo = makeRepo(); + try { + fs.writeFileSync(path.join(multiBranchRepo, "file1"), "content1"); + git(["add", "."], multiBranchRepo); + git(["commit", "-m", "commit on main"], multiBranchRepo); + + git(["checkout", "-b", "branch1"], multiBranchRepo); + fs.writeFileSync(path.join(multiBranchRepo, "file2"), "content2"); + git(["add", "."], multiBranchRepo); + git(["commit", "-m", "commit on branch1"], multiBranchRepo); + + git(["checkout", "main"], multiBranchRepo); + git(["checkout", "-b", "branch2"], multiBranchRepo); + fs.writeFileSync(path.join(multiBranchRepo, "file3"), "content3"); + git(["add", "."], multiBranchRepo); + git(["commit", "-m", "commit on branch2"], multiBranchRepo); + + git(["checkout", "main"], multiBranchRepo); + + const resultBranch1And2 = await loadCommits(simpleGit(multiBranchRepo), { + branchNames: ["branch1", "branch2"], + maxCommits: 300, + showRemoteBranches: false, + hard: false, + dateType: "Author Date", + showUncommittedChanges: false + }); + + const resultAllBranches = await loadCommits(simpleGit(multiBranchRepo), { + branchNames: [SHOW_ALL], + maxCommits: 300, + showRemoteBranches: false, + hard: false, + dateType: "Author Date", + showUncommittedChanges: false + }); + + const branch1And2Messages = resultBranch1And2.commits.map((commit) => commit.message); + + expect(resultBranch1And2.commits.length).toBeGreaterThan(0); + expect(branch1And2Messages).toContain("commit on branch1"); + expect(branch1And2Messages).toContain("commit on branch2"); + expect(resultAllBranches.commits.length).toBeGreaterThanOrEqual( + resultBranch1And2.commits.length + ); + expect(resultBranch1And2.moreCommitsAvailable).toBe(false); + } finally { + fs.rmSync(multiBranchRepo, { recursive: true, force: true }); + } + }); + it("filters commits to the given branch", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "main", + branchNames: ["main"], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -125,7 +179,7 @@ describe("loadCommits", () => { try { fs.writeFileSync(path.join(dirtyRepo, "untracked"), "z"); const result = await loadCommits(simpleGit(dirtyRepo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -151,7 +205,7 @@ describe("loadCommits", () => { try { fs.writeFileSync(path.join(dirtyRepo, "untracked"), "z"); const result = await loadCommits(simpleGit(dirtyRepo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -166,7 +220,7 @@ describe("loadCommits", () => { it("does not include remote refs when showRemoteBranches is false", async () => { const result = await loadCommits(simpleGit(repoWithRemote), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -179,7 +233,7 @@ describe("loadCommits", () => { it("uses commit date when dateType is Commit Date", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: false, @@ -192,7 +246,7 @@ describe("loadCommits", () => { it("passes hard flag through to the result", async () => { const result = await loadCommits(simpleGit(repo), { - branchName: "", + branchNames: [SHOW_ALL], maxCommits: 300, showRemoteBranches: false, hard: true, diff --git a/tests/webview/rendering.test.ts b/tests/webview/rendering.test.ts index f6509af..c1da9de 100644 --- a/tests/webview/rendering.test.ts +++ b/tests/webview/rendering.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { GitCommitNode } from "@/backend/types"; import type * as GG from "@/types"; @@ -67,3 +67,126 @@ describe("webview rendering", () => { expect(document.getElementById("loadMoreCommitsBtn")).not.toBeNull(); }); }); + +describe("branch selection", () => { + let branchSelectElem: HTMLElement; + + beforeEach(async () => { + vi.resetModules(); + createVscodeMock(); + setupHtml(defaultViewState); + await import("@/webview/main"); + receive({ + command: "loadBranches", + branches: ["main", "develop", "feature/test"], + head: "main", + hard: true, + isRepo: true + }); + receive({ + command: "loadCommits", + commits: twoCommits, + head: "abc123", + moreCommitsAvailable: true, + hard: true + }); + branchSelectElem = document.getElementById("branchSelect")!; + }); + + it("branch options should match the loaded branches", () => { + const dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const optionNames = Array.from(dropdownOptions).map((option) => option.textContent?.trim()); + + // Should have "Show All" + 3 branches + expect(optionNames).toHaveLength(4); + expect(optionNames[0]).toBe("Show All"); + expect(optionNames).toContain("main"); + expect(optionNames).toContain("develop"); + expect(optionNames).toContain("feature/test"); + }); + + it("should deselect 'Show All' when selecting another branch, and will should clear other selections when selecting 'Show All'", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + + let dropdownOptions: NodeListOf; + let showAllOption: HTMLElement; + let mainOption: HTMLElement; + let developOption: HTMLElement; + + // Open the dropdown + currentValueElem.click(); + + // Click other branches to select it + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; + + mainOption.click(); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + developOption = dropdownOptions[2] as HTMLElement; + + developOption.click(); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + developOption = dropdownOptions[2] as HTMLElement; + + // After selecting other branches, "Show All" should not be selected + expect(showAllOption.classList.contains("selected")).toBe(false); + expect(mainOption.classList.contains("selected")).toBe(true); + expect(developOption.classList.contains("selected")).toBe(true); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + + // click "Show All" + showAllOption.click(); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + developOption = dropdownOptions[2] as HTMLElement; + + // After selecting "Show All", other selections will be cleared + expect(showAllOption.classList.contains("selected")).toBe(true); + expect(mainOption.classList.contains("selected")).toBe(false); + expect(developOption.classList.contains("selected")).toBe(false); + }); + + it("should select default branch (Show All) when no branches are selected", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + currentValueElem.click(); + + let dropdownOptions: NodeListOf; + let showAllOption: HTMLElement; + let mainOption: HTMLElement; + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; + + mainOption.click(); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + + // After selecting "main", "Show All" should not be selected + expect(showAllOption.classList.contains("selected")).toBe(false); + expect(mainOption.classList.contains("selected")).toBe(true); + + // remove the only selected branch + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; + + mainOption.click(); + + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + + // After clear the selected, default branch (Show All) should be selected + expect(showAllOption.classList.contains("selected")).toBe(true); + expect(mainOption.classList.contains("selected")).toBe(false); + }); +}); diff --git a/tests/webview/setup.ts b/tests/webview/setup.ts index 8593483..eb90025 100644 --- a/tests/webview/setup.ts +++ b/tests/webview/setup.ts @@ -36,7 +36,17 @@ export function setupHtml(viewState: GG.GitGraphViewState) {
-
+
+ + + + + + + + +
GraphDescriptionDateAuthorCommit
+