From b43010096fc452b4bdfca83d8f3d3bdabf9e9dba Mon Sep 17 00:00:00 2001 From: "Mr.Lee" Date: Sat, 11 Apr 2026 17:18:42 +0800 Subject: [PATCH 1/5] feat: support multiple choice branches --- src/backend/queries/loadCommits.ts | 27 +- src/backend/types/queries.types.ts | 9 +- src/extension/messageHandler.ts | 2 +- src/webview/dropdown.ts | 247 ++++++++++++++---- src/webview/global.d.ts | 10 +- src/webview/main.ts | 135 +++++++--- .../backend/queries/loadCommits/list.test.ts | 22 +- 7 files changed, 343 insertions(+), 109 deletions(-) diff --git a/src/backend/queries/loadCommits.ts b/src/backend/queries/loadCommits.ts index 9a38393..e4b5c43 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 } = - input; + const { + branchNames: branchName, + maxCommits, + showRemoteBranches, + hard, + dateType, + showUncommittedChanges + } = input; const [rawCommits, refData] = await Promise.all([ getLog(git, branchName, maxCommits + 1, showRemoteBranches, dateType), @@ -156,5 +162,10 @@ export async function loadCommits( } } - return { commits: commitNodes, head: refData.head, moreCommitsAvailable, hard }; + return { + commits: commitNodes, + head: refData.head, + moreCommitsAvailable, + hard + }; } diff --git a/src/backend/types/queries.types.ts b/src/backend/types/queries.types.ts index b29d6ac..5945f54 100644 --- a/src/backend/types/queries.types.ts +++ b/src/backend/types/queries.types.ts @@ -7,12 +7,17 @@ type QueryPayloads = { }; loadBranches: { request: { showRemoteBranches: boolean; hard: boolean }; - response: { branches: string[]; head: string | null; hard: boolean; isRepo: boolean }; + response: { + branches: string[]; + head: string | null; + hard: boolean; + isRepo: boolean; + }; }; 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..99fc058 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 options: ${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 options: ${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..ed88e83 100644 --- a/src/webview/global.d.ts +++ b/src/webview/global.d.ts @@ -15,7 +15,13 @@ declare global { fetchAvatars: boolean; graphColours: string[]; graphStyle: "rounded" | "angular"; - grid: { x: number; y: number; offsetX: number; offsetY: number; expandY: number }; + grid: { + x: number; + y: number; + offsetX: number; + offsetY: number; + expandY: number; + }; initialLoadCommits: number; loadMoreCommits: number; showCurrentBranchByDefault: boolean; @@ -108,7 +114,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..aee1877 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 remove show all from 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") { @@ -139,7 +172,10 @@ class GitGraphView { i; for (i = 0; i < repoPaths.length; i++) { repoComps = repoPaths[i].split("/"); - options.push({ name: repoComps[repoComps.length - 1], value: repoPaths[i] }); + options.push({ + name: repoComps[repoComps.length - 1], + value: repoPaths[i] + }); } document.getElementById("repoControl")!.style.display = repoPaths.length > 1 ? "inline" : "none"; @@ -171,18 +207,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 +230,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 +362,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 +402,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, @@ -517,14 +555,24 @@ class GitGraphView { showFormDialog( l10n.dialogAddTagTitle.replace("{0}", "" + abbrevCommit(hash) + ""), [ - { type: "text-ref" as const, name: l10n.dialogAddTagName, default: "" }, + { + type: "text-ref" as const, + name: l10n.dialogAddTagName, + default: "" + }, { type: "select" as const, name: l10n.dialogAddTagType, default: "annotated", options: [ - { name: l10n.dialogAddTagTypeAnnotated, value: "annotated" }, - { name: l10n.dialogAddTagTypeLightweight, value: "lightweight" } + { + name: l10n.dialogAddTagTypeAnnotated, + value: "annotated" + }, + { + name: l10n.dialogAddTagTypeLightweight, + value: "lightweight" + } ] }, { @@ -746,7 +794,11 @@ class GitGraphView { { title: l10n.copyCommitHash, onClick: () => { - sendMessage({ command: "copyToClipboard", type: "Commit Hash", data: hash }); + sendMessage({ + command: "copyToClipboard", + type: "Commit Hash", + data: hash + }); } } ], @@ -778,7 +830,11 @@ class GitGraphView { .replace("{0}", l10n.labelTag) .replace("{1}", "" + escapeHtml(refName) + ""), () => { - sendMessage({ command: "deleteTag", repo: this.currentRepo!, tagName: refName }); + sendMessage({ + command: "deleteTag", + repo: this.currentRepo!, + tagName: refName + }); }, null ); @@ -793,7 +849,11 @@ class GitGraphView { "" + escapeHtml(refName) + "" ), () => { - sendMessage({ command: "pushTag", repo: this.currentRepo!, tagName: refName }); + sendMessage({ + command: "pushTag", + repo: this.currentRepo!, + tagName: refName + }); showActionRunningDialog(l10n.pushingTag); }, null @@ -896,7 +956,11 @@ class GitGraphView { menu.push(null, { title: copyTitle, onClick: () => { - sendMessage({ command: "copyToClipboard", type: copyType, data: refName }); + sendMessage({ + command: "copyToClipboard", + type: copyType, + data: refName + }); } }); showContextMenu(e, menu, sourceElem); @@ -1073,7 +1137,10 @@ class GitGraphView { this.repoDropdown.refresh(); this.branchDropdown.refresh(); } - }).observe(document.documentElement, { attributes: true, attributeFilter: ["style"] }); + }).observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"] + }); } private observeWebviewScroll() { let active = window.scrollY > 0; diff --git a/tests/backend/queries/loadCommits/list.test.ts b/tests/backend/queries/loadCommits/list.test.ts index fccd1a8..ee0b650 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, @@ -110,7 +112,7 @@ describe("loadCommits", () => { 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 +127,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 +153,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 +168,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 +181,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 +194,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, From 50a791d652a746b64d6270936d7fb9c49c365386 Mon Sep 17 00:00:00 2001 From: "Mr.Lee" Date: Sat, 11 Apr 2026 17:48:34 +0800 Subject: [PATCH 2/5] fix: some small bug feedback by copilot --- src/webview/dropdown.ts | 4 ++-- src/webview/main.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/webview/dropdown.ts b/src/webview/dropdown.ts index 99fc058..e44003e 100644 --- a/src/webview/dropdown.ts +++ b/src/webview/dropdown.ts @@ -225,7 +225,7 @@ export class CheckboxDropdown extends AbstractDropdown { public setSelected(value: string) { if (!this.options.some((i) => i.value === value)) throw new Error( - `unknown options: ${value}, available options: ${this.options.map((i) => i.value).join(", ")}` + `unknown option: "${value}", available options: ${this.options.map((i) => `"${i.value}"`).join(", ")}` ); this.selectedOptions.add(value); this.render(); @@ -234,7 +234,7 @@ export class CheckboxDropdown extends AbstractDropdown { public setUnSelected(value: string) { if (!this.options.some((i) => i.value === value)) throw new Error( - `unknown options: ${value}, available options: ${this.options.map((i) => i.value).join(", ")}` + `unknown option: "${value}", available options: ${this.options.map((i) => `"${i.value}"`).join(", ")}` ); this.selectedOptions.delete(value); this.render(); diff --git a/src/webview/main.ts b/src/webview/main.ts index aee1877..f639f87 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -83,7 +83,7 @@ class GitGraphView { this.branchDropdown.setSelected(defaultBranch); values = [defaultBranch]; } else if (change === SHOW_ALL && values.includes(SHOW_ALL)) { - // when selected show all, should remove show all from selected + // 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({ From 3a49ed552eeacd942851811486fe3354e8ec02bf Mon Sep 17 00:00:00 2001 From: "Mr.Lee" Date: Sun, 12 Apr 2026 20:35:34 +0800 Subject: [PATCH 3/5] revert: useless format --- src/backend/queries/loadCommits.ts | 7 +---- src/backend/types/queries.types.ts | 7 +---- src/webview/global.d.ts | 8 +---- src/webview/main.ts | 50 ++++++------------------------ 4 files changed, 12 insertions(+), 60 deletions(-) diff --git a/src/backend/queries/loadCommits.ts b/src/backend/queries/loadCommits.ts index e4b5c43..570e8d8 100644 --- a/src/backend/queries/loadCommits.ts +++ b/src/backend/queries/loadCommits.ts @@ -162,10 +162,5 @@ export async function loadCommits( } } - return { - commits: commitNodes, - head: refData.head, - moreCommitsAvailable, - hard - }; + return { commits: commitNodes, head: refData.head, moreCommitsAvailable, hard }; } diff --git a/src/backend/types/queries.types.ts b/src/backend/types/queries.types.ts index 5945f54..6c92a67 100644 --- a/src/backend/types/queries.types.ts +++ b/src/backend/types/queries.types.ts @@ -7,12 +7,7 @@ type QueryPayloads = { }; loadBranches: { request: { showRemoteBranches: boolean; hard: boolean }; - response: { - branches: string[]; - head: string | null; - hard: boolean; - isRepo: boolean; - }; + response: { branches: string[]; head: string | null; hard: boolean; isRepo: boolean }; }; loadCommits: { request: { diff --git a/src/webview/global.d.ts b/src/webview/global.d.ts index ed88e83..8557bde 100644 --- a/src/webview/global.d.ts +++ b/src/webview/global.d.ts @@ -15,13 +15,7 @@ declare global { fetchAvatars: boolean; graphColours: string[]; graphStyle: "rounded" | "angular"; - grid: { - x: number; - y: number; - offsetX: number; - offsetY: number; - expandY: number; - }; + grid: { x: number; y: number; offsetX: number; offsetY: number; expandY: number }; initialLoadCommits: number; loadMoreCommits: number; showCurrentBranchByDefault: boolean; diff --git a/src/webview/main.ts b/src/webview/main.ts index f639f87..d52944a 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -172,10 +172,7 @@ class GitGraphView { i; for (i = 0; i < repoPaths.length; i++) { repoComps = repoPaths[i].split("/"); - options.push({ - name: repoComps[repoComps.length - 1], - value: repoPaths[i] - }); + options.push({ name: repoComps[repoComps.length - 1], value: repoPaths[i] }); } document.getElementById("repoControl")!.style.display = repoPaths.length > 1 ? "inline" : "none"; @@ -555,24 +552,14 @@ class GitGraphView { showFormDialog( l10n.dialogAddTagTitle.replace("{0}", "" + abbrevCommit(hash) + ""), [ - { - type: "text-ref" as const, - name: l10n.dialogAddTagName, - default: "" - }, + { type: "text-ref" as const, name: l10n.dialogAddTagName, default: "" }, { type: "select" as const, name: l10n.dialogAddTagType, default: "annotated", options: [ - { - name: l10n.dialogAddTagTypeAnnotated, - value: "annotated" - }, - { - name: l10n.dialogAddTagTypeLightweight, - value: "lightweight" - } + { name: l10n.dialogAddTagTypeAnnotated, value: "annotated" }, + { name: l10n.dialogAddTagTypeLightweight, value: "lightweight" } ] }, { @@ -794,11 +781,7 @@ class GitGraphView { { title: l10n.copyCommitHash, onClick: () => { - sendMessage({ - command: "copyToClipboard", - type: "Commit Hash", - data: hash - }); + sendMessage({ command: "copyToClipboard", type: "Commit Hash", data: hash }); } } ], @@ -830,11 +813,7 @@ class GitGraphView { .replace("{0}", l10n.labelTag) .replace("{1}", "" + escapeHtml(refName) + ""), () => { - sendMessage({ - command: "deleteTag", - repo: this.currentRepo!, - tagName: refName - }); + sendMessage({ command: "deleteTag", repo: this.currentRepo!, tagName: refName }); }, null ); @@ -849,11 +828,7 @@ class GitGraphView { "" + escapeHtml(refName) + "" ), () => { - sendMessage({ - command: "pushTag", - repo: this.currentRepo!, - tagName: refName - }); + sendMessage({ command: "pushTag", repo: this.currentRepo!, tagName: refName }); showActionRunningDialog(l10n.pushingTag); }, null @@ -956,11 +931,7 @@ class GitGraphView { menu.push(null, { title: copyTitle, onClick: () => { - sendMessage({ - command: "copyToClipboard", - type: copyType, - data: refName - }); + sendMessage({ command: "copyToClipboard", type: copyType, data: refName }); } }); showContextMenu(e, menu, sourceElem); @@ -1137,10 +1108,7 @@ class GitGraphView { this.repoDropdown.refresh(); this.branchDropdown.refresh(); } - }).observe(document.documentElement, { - attributes: true, - attributeFilter: ["style"] - }); + }).observe(document.documentElement, { attributes: true, attributeFilter: ["style"] }); } private observeWebviewScroll() { let active = window.scrollY > 0; From 5695dfb0f7118d4c8948061269522920230bbc15 Mon Sep 17 00:00:00 2001 From: "Mr.Lee" Date: Sun, 12 Apr 2026 21:34:09 +0800 Subject: [PATCH 4/5] test: add test for multiple choice branches --- src/webview/main.ts | 23 +- .../backend/queries/loadCommits/list.test.ts | 47 +++++ tests/webview/rendering.test.ts | 198 +++++++++++++++++- tests/webview/setup.ts | 12 +- 4 files changed, 270 insertions(+), 10 deletions(-) diff --git a/src/webview/main.ts b/src/webview/main.ts index d52944a..007ab74 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -505,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) { @@ -998,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 ee0b650..2ff8145 100644 --- a/tests/backend/queries/loadCommits/list.test.ts +++ b/tests/backend/queries/loadCommits/list.test.ts @@ -110,6 +110,53 @@ 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", "-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 + }); + + expect(resultBranch1And2.commits.length).toBeGreaterThan(0); + 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), { branchNames: ["main"], diff --git a/tests/webview/rendering.test.ts b/tests/webview/rendering.test.ts index f6509af..8521656 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,199 @@ describe("webview rendering", () => { expect(document.getElementById("loadMoreCommitsBtn")).not.toBeNull(); }); }); + +describe("branch selection", () => { + let branchSelectElem: HTMLElement; + + beforeAll(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 + }); + }); + + beforeEach(() => { + branchSelectElem = document.getElementById("branchSelect")!; + }); + + it("should deselect 'Show All' when selecting another branch", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + + // Open the dropdown + currentValueElem.click(); + + const dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOption = dropdownOptions[1] as HTMLElement; + + // Click "main" branch to select it + mainOption.click(); + + // Re-query after event handling + const updatedOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const showAllOption = updatedOptions[0]; + const mainOptionAfter = updatedOptions[1]; + + // After selecting "main", "Show All" should not be selected + expect(showAllOption.classList.contains("selected")).toBe(false); + expect(mainOptionAfter.classList.contains("selected")).toBe(true); + }); + + it("should clear other selections when selecting 'Show All'", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + + // First ensure dropdown is closed so we have clean state + const dropdownElem = branchSelectElem as HTMLElement; + if (dropdownElem.classList.contains("dropdownOpen")) { + currentValueElem.click(); + } + + // Open the dropdown + currentValueElem.click(); + + // Get the options and click main branch + let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOption = dropdownOptions[1] as HTMLElement; + mainOption.click(); + + // Re-open dropdown to verify state and select Show All + let currentValueElemAgain = branchSelectElem.querySelector( + ".dropdownCurrentValue" + ) as HTMLElement; + currentValueElemAgain.click(); + + // Now click "Show All" + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const showAllOption = dropdownOptions[0] as HTMLElement; + showAllOption.click(); + + // Check the state even though dropdown is now closed + // The DOM should reflect the current selection state + const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const showAllAfter = allOptions[0]; + const mainAfter = allOptions[1]; + + // After selecting "Show All", only "Show All" should be selected + expect(showAllAfter.classList.contains("selected")).toBe(true); + expect(mainAfter.classList.contains("selected")).toBe(false); + }); + + 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 select default branch (Show All) when no branches are selected", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + + // Ensure dropdown is closed first + if (branchSelectElem.classList.contains("dropdownOpen")) { + currentValueElem.click(); + } + + // Open dropdown + currentValueElem.click(); + + let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOption = dropdownOptions[1] as HTMLElement; + const developOption = dropdownOptions[2] as HTMLElement; + + // Select main and develop + mainOption.click(); + developOption.click(); + + // Now deselect both branches to trigger the default selection behavior + // Ensure dropdown is open again + const currentValueElem2 = branchSelectElem.querySelector( + ".dropdownCurrentValue" + ) as HTMLElement; + + if (!branchSelectElem.classList.contains("dropdownOpen")) { + currentValueElem2.click(); + } + + let dropdownOptions2 = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOptionToDeselect = dropdownOptions2[1] as HTMLElement; + const developOptionToDeselect = dropdownOptions2[2] as HTMLElement; + + // Deselect first branch + mainOptionToDeselect.click(); + + // Deselect second branch - this should trigger auto-selection of Show All + developOptionToDeselect.click(); + + // Query the options again to check the current state + const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const showAllOption = allOptions[0]; + + // Show All should be automatically selected when all others are deselected + expect(showAllOption.classList.contains("selected")).toBe(true); + }); + + it("should select Show All as default when no branches are selected", () => { + const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; + + // Ensure dropdown is closed first + if (branchSelectElem.classList.contains("dropdownOpen")) { + currentValueElem.click(); + } + + // Open dropdown + currentValueElem.click(); + + let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOption = dropdownOptions[1] as HTMLElement; + const developOption = dropdownOptions[2] as HTMLElement; + + // Select main and develop + mainOption.click(); + developOption.click(); + + // Now deselect both branches + // First, ensure dropdown is open + const currentValueElem2 = branchSelectElem.querySelector( + ".dropdownCurrentValue" + ) as HTMLElement; + + if (!branchSelectElem.classList.contains("dropdownOpen")) { + currentValueElem2.click(); + } + + let dropdownOptions2 = branchSelectElem.querySelectorAll(".dropdownOption"); + const mainOptionToDeselect = dropdownOptions2[1] as HTMLElement; + const developOptionToDeselect = dropdownOptions2[2] as HTMLElement; + + // Deselect first branch + mainOptionToDeselect.click(); + + // Deselect second branch - this should trigger auto-selection of Show All + developOptionToDeselect.click(); + + // Query the options again to check the current state + const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + const showAllOption = allOptions[0]; + + // Show All should be automatically selected when all others are deselected + expect(showAllOption.classList.contains("selected")).toBe(true); + }); +}); 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
+
    From 552222eb51ed0d53523743cbc17687372db25a59 Mon Sep 17 00:00:00 2001 From: "Mr.Lee" Date: Mon, 13 Apr 2026 12:11:42 +0800 Subject: [PATCH 5/5] fix: fix some issues feedback by copilot --- src/backend/queries/loadCommits.ts | 12 +- .../backend/queries/loadCommits/list.test.ts | 5 + tests/webview/rendering.test.ts | 183 ++++++------------ 3 files changed, 63 insertions(+), 137 deletions(-) diff --git a/src/backend/queries/loadCommits.ts b/src/backend/queries/loadCommits.ts index 570e8d8..3502c05 100644 --- a/src/backend/queries/loadCommits.ts +++ b/src/backend/queries/loadCommits.ts @@ -105,17 +105,11 @@ export async function loadCommits( git: SimpleGit, input: LoadCommitsInput ): Promise> { - const { - branchNames: branchName, - maxCommits, - showRemoteBranches, - hard, - dateType, - showUncommittedChanges - } = input; + 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/tests/backend/queries/loadCommits/list.test.ts b/tests/backend/queries/loadCommits/list.test.ts index 2ff8145..2d43244 100644 --- a/tests/backend/queries/loadCommits/list.test.ts +++ b/tests/backend/queries/loadCommits/list.test.ts @@ -122,6 +122,7 @@ describe("loadCommits", () => { 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); @@ -147,7 +148,11 @@ describe("loadCommits", () => { 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 ); diff --git a/tests/webview/rendering.test.ts b/tests/webview/rendering.test.ts index 8521656..c1da9de 100644 --- a/tests/webview/rendering.test.ts +++ b/tests/webview/rendering.test.ts @@ -71,7 +71,7 @@ describe("webview rendering", () => { describe("branch selection", () => { let branchSelectElem: HTMLElement; - beforeAll(async () => { + beforeEach(async () => { vi.resetModules(); createVscodeMock(); setupHtml(defaultViewState); @@ -90,73 +90,9 @@ describe("branch selection", () => { moreCommitsAvailable: true, hard: true }); - }); - - beforeEach(() => { branchSelectElem = document.getElementById("branchSelect")!; }); - it("should deselect 'Show All' when selecting another branch", () => { - const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; - - // Open the dropdown - currentValueElem.click(); - - const dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOption = dropdownOptions[1] as HTMLElement; - - // Click "main" branch to select it - mainOption.click(); - - // Re-query after event handling - const updatedOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const showAllOption = updatedOptions[0]; - const mainOptionAfter = updatedOptions[1]; - - // After selecting "main", "Show All" should not be selected - expect(showAllOption.classList.contains("selected")).toBe(false); - expect(mainOptionAfter.classList.contains("selected")).toBe(true); - }); - - it("should clear other selections when selecting 'Show All'", () => { - const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; - - // First ensure dropdown is closed so we have clean state - const dropdownElem = branchSelectElem as HTMLElement; - if (dropdownElem.classList.contains("dropdownOpen")) { - currentValueElem.click(); - } - - // Open the dropdown - currentValueElem.click(); - - // Get the options and click main branch - let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOption = dropdownOptions[1] as HTMLElement; - mainOption.click(); - - // Re-open dropdown to verify state and select Show All - let currentValueElemAgain = branchSelectElem.querySelector( - ".dropdownCurrentValue" - ) as HTMLElement; - currentValueElemAgain.click(); - - // Now click "Show All" - dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const showAllOption = dropdownOptions[0] as HTMLElement; - showAllOption.click(); - - // Check the state even though dropdown is now closed - // The DOM should reflect the current selection state - const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const showAllAfter = allOptions[0]; - const mainAfter = allOptions[1]; - - // After selecting "Show All", only "Show All" should be selected - expect(showAllAfter.classList.contains("selected")).toBe(true); - expect(mainAfter.classList.contains("selected")).toBe(false); - }); - it("branch options should match the loaded branches", () => { const dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); const optionNames = Array.from(dropdownOptions).map((option) => option.textContent?.trim()); @@ -169,97 +105,88 @@ describe("branch selection", () => { expect(optionNames).toContain("feature/test"); }); - it("should select default branch (Show All) when no branches are selected", () => { + 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; - // Ensure dropdown is closed first - if (branchSelectElem.classList.contains("dropdownOpen")) { - currentValueElem.click(); - } + let dropdownOptions: NodeListOf; + let showAllOption: HTMLElement; + let mainOption: HTMLElement; + let developOption: HTMLElement; - // Open dropdown + // Open the dropdown currentValueElem.click(); - let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOption = dropdownOptions[1] as HTMLElement; - const developOption = dropdownOptions[2] as HTMLElement; + // Click other branches to select it + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; - // Select main and develop mainOption.click(); - developOption.click(); - // Now deselect both branches to trigger the default selection behavior - // Ensure dropdown is open again - const currentValueElem2 = branchSelectElem.querySelector( - ".dropdownCurrentValue" - ) as HTMLElement; + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + developOption = dropdownOptions[2] as HTMLElement; - if (!branchSelectElem.classList.contains("dropdownOpen")) { - currentValueElem2.click(); - } + developOption.click(); - let dropdownOptions2 = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOptionToDeselect = dropdownOptions2[1] as HTMLElement; - const developOptionToDeselect = dropdownOptions2[2] as HTMLElement; + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + developOption = dropdownOptions[2] as HTMLElement; - // Deselect first branch - mainOptionToDeselect.click(); + // 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); - // Deselect second branch - this should trigger auto-selection of Show All - developOptionToDeselect.click(); + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + + // click "Show All" + showAllOption.click(); - // Query the options again to check the current state - const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const showAllOption = allOptions[0]; + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; + developOption = dropdownOptions[2] as HTMLElement; - // Show All should be automatically selected when all others are deselected + // 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 Show All as default when no branches are selected", () => { + it("should select default branch (Show All) when no branches are selected", () => { const currentValueElem = branchSelectElem.querySelector(".dropdownCurrentValue") as HTMLElement; - - // Ensure dropdown is closed first - if (branchSelectElem.classList.contains("dropdownOpen")) { - currentValueElem.click(); - } - - // Open dropdown currentValueElem.click(); - let dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOption = dropdownOptions[1] as HTMLElement; - const developOption = dropdownOptions[2] as HTMLElement; + let dropdownOptions: NodeListOf; + let showAllOption: HTMLElement; + let mainOption: HTMLElement; - // Select main and develop - mainOption.click(); - developOption.click(); + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; - // Now deselect both branches - // First, ensure dropdown is open - const currentValueElem2 = branchSelectElem.querySelector( - ".dropdownCurrentValue" - ) as HTMLElement; + mainOption.click(); - if (!branchSelectElem.classList.contains("dropdownOpen")) { - currentValueElem2.click(); - } + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; - let dropdownOptions2 = branchSelectElem.querySelectorAll(".dropdownOption"); - const mainOptionToDeselect = dropdownOptions2[1] as HTMLElement; - const developOptionToDeselect = dropdownOptions2[2] 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); - // Deselect first branch - mainOptionToDeselect.click(); + // remove the only selected branch + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + mainOption = dropdownOptions[1] as HTMLElement; - // Deselect second branch - this should trigger auto-selection of Show All - developOptionToDeselect.click(); + mainOption.click(); - // Query the options again to check the current state - const allOptions = branchSelectElem.querySelectorAll(".dropdownOption"); - const showAllOption = allOptions[0]; + dropdownOptions = branchSelectElem.querySelectorAll(".dropdownOption"); + showAllOption = dropdownOptions[0] as HTMLElement; + mainOption = dropdownOptions[1] as HTMLElement; - // Show All should be automatically selected when all others are deselected + // 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); }); });