v1.0.5: GitKraken-style branch picker with remote branch support

Replace the <select> dropdown with a searchable custom picker that shows
all local and remote branches grouped separately. Remote-only branches
check out with --track so upstream is configured automatically. Fetch now
uses --prune to keep remote refs in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kry-kylewhittle 2026-06-19 18:30:33 +01:00
parent 9c31a5a8e7
commit cf106190e8
4 changed files with 330 additions and 30 deletions

175
main.ts
View file

@ -65,6 +65,7 @@ interface ChangedFile {
interface GitStatus {
branch: string;
localBranches: string[];
remoteBranches: string[];
hasUncommittedChanges: boolean;
changedFiles: ChangedFile[];
commitsBehind: number;
@ -149,14 +150,15 @@ async function detectRepoIdentity(repoPath: string): Promise<{ owner: string; re
}
async function fetchGitStatus(repoPath: string, trackBranch: string): Promise<GitStatus> {
try { await gitExec(repoPath, "fetch origin"); } catch { /* non-fatal */ }
try { await gitExec(repoPath, "fetch origin --prune"); } catch { /* non-fatal */ }
const branch = await gitExec(repoPath, "branch --show-current");
const comparingTo = trackBranch || branch;
const [porcelain, branchesRaw] = await Promise.all([
const [porcelain, branchesRaw, remoteBranchesRaw] = await Promise.all([
gitExec(repoPath, "status --porcelain"),
gitExec(repoPath, "branch"),
gitExec(repoPath, "branch -r"),
]);
const changedFiles = parseChangedFiles(porcelain);
@ -165,6 +167,13 @@ async function fetchGitStatus(repoPath: string, trackBranch: string): Promise<Gi
.map((b) => b.replace(/^\*\s*/, "").trim())
.filter(Boolean);
const remoteBranches = remoteBranchesRaw
.split("\n")
.map((b) => b.trim())
.filter((b) => b && !b.includes("HEAD ->"))
.map((b) => b.replace(/^origin\//, ""))
.filter((b) => !localBranches.includes(b));
let commitsBehind = 0;
let incomingFiles: string[] = [];
try {
@ -179,6 +188,7 @@ async function fetchGitStatus(repoPath: string, trackBranch: string): Promise<Gi
return {
branch,
localBranches,
remoteBranches,
hasUncommittedChanges: changedFiles.length > 0,
changedFiles,
commitsBehind,
@ -416,7 +426,7 @@ class GitHubRepoView extends ItemView {
return;
}
const { branch, localBranches, hasUncommittedChanges, changedFiles, commitsBehind, comparingTo } =
const { branch, localBranches, remoteBranches, hasUncommittedChanges, changedFiles, commitsBehind, comparingTo } =
this.gitStatus;
const section = root.createDiv("gwt-section");
@ -437,14 +447,7 @@ class GitHubRepoView extends ItemView {
btn.addEventListener("click", a.handler);
}
const branchRow = section.createDiv("gwt-branch-row");
branchRow.createSpan({ cls: "gwt-label", text: "Branch" });
const select = branchRow.createEl("select", { cls: "gwt-branch-select" });
localBranches.forEach((b) => {
const opt = select.createEl("option", { text: b, value: b });
if (b === branch) opt.selected = true;
});
select.addEventListener("change", () => { void this.handleBranchSwitch(select.value, select, branch); });
this.renderBranchPicker(section, branch, localBranches, remoteBranches);
const newBranchRow = section.createDiv("gwt-new-branch-row");
const newBtn = newBranchRow.createEl("button", { cls: "gwt-btn gwt-btn-sm", text: "+ New branch" });
@ -492,9 +495,148 @@ class GitHubRepoView extends ItemView {
}
}
private renderBranchPicker(
container: HTMLElement,
branch: string,
localBranches: string[],
remoteBranches: string[]
): void {
type BranchItem = { name: string; isRemote: boolean };
const wrapper = container.createDiv("gwt-branch-picker");
const btn = wrapper.createEl("button", { cls: "gwt-branch-btn" });
setIcon(btn.createSpan({ cls: "gwt-branch-btn-icon" }), "git-branch");
btn.createSpan({ cls: "gwt-branch-btn-name", text: branch });
const chevron = btn.createSpan({ cls: "gwt-branch-btn-chevron" });
setIcon(chevron, "chevron-down");
const dropdown = wrapper.createDiv("gwt-branch-dropdown");
dropdown.style.display = "none";
const filterInput = dropdown.createEl("input", {
cls: "gwt-branch-filter",
type: "text",
});
filterInput.setAttribute("placeholder", "Filter branches…");
filterInput.setAttribute("spellcheck", "false");
const listEl = dropdown.createDiv("gwt-branch-list");
let isOpen = false;
let highlightedIndex = -1;
let visibleItems: BranchItem[] = [];
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const renderList = (filter: string) => {
listEl.empty();
highlightedIndex = -1;
const q = filter.toLowerCase();
const matchLocal = localBranches.filter((b) => b.toLowerCase().includes(q));
const matchRemote = remoteBranches.filter((b) => b.toLowerCase().includes(q));
visibleItems = [
...matchLocal.map((b) => ({ name: b, isRemote: false })),
...matchRemote.map((b) => ({ name: b, isRemote: true })),
];
if (matchLocal.length > 0) {
listEl.createDiv({ cls: "gwt-branch-group-label", text: "LOCAL" });
matchLocal.forEach((b) => {
const item = listEl.createDiv({
cls: "gwt-branch-item" + (b === branch ? " gwt-branch-current" : ""),
});
setIcon(item.createSpan({ cls: "gwt-branch-item-icon" }), "git-branch");
item.createSpan({ cls: "gwt-branch-item-name", text: b });
if (b === branch) item.createSpan({ cls: "gwt-branch-item-check", text: "✓" });
item.addEventListener("click", () => {
closeDropdown();
void this.handleBranchSwitch(b, false, branch);
});
});
}
if (matchRemote.length > 0) {
listEl.createDiv({ cls: "gwt-branch-group-label", text: "REMOTE" });
matchRemote.forEach((b) => {
const item = listEl.createDiv({ cls: "gwt-branch-item gwt-branch-remote-item" });
setIcon(item.createSpan({ cls: "gwt-branch-item-icon" }), "cloud");
item.createSpan({ cls: "gwt-branch-item-name", text: b });
item.createSpan({ cls: "gwt-branch-item-badge", text: "remote" });
item.addEventListener("click", () => {
closeDropdown();
void this.handleBranchSwitch(b, true, branch);
});
});
}
if (visibleItems.length === 0) {
listEl.createDiv({ cls: "gwt-branch-no-results", text: "No matching branches" });
}
};
const setHighlight = (idx: number) => {
const items = listEl.querySelectorAll<HTMLElement>(".gwt-branch-item");
items.forEach((el, i) => el.classList.toggle("gwt-branch-highlighted", i === idx));
if (idx >= 0 && idx < items.length) items[idx].scrollIntoView({ block: "nearest" });
highlightedIndex = idx;
};
const openDropdown = () => {
isOpen = true;
dropdown.style.display = "block";
btn.addClass("gwt-branch-btn-open");
filterInput.value = "";
renderList("");
window.setTimeout(() => filterInput.focus(), 20);
outsideClickHandler = (e: MouseEvent) => {
if (!wrapper.contains(e.target as Node)) closeDropdown();
};
window.setTimeout(() => {
document.addEventListener("click", outsideClickHandler!, { capture: true });
}, 0);
};
const closeDropdown = () => {
isOpen = false;
dropdown.style.display = "none";
btn.removeClass("gwt-branch-btn-open");
if (outsideClickHandler) {
document.removeEventListener("click", outsideClickHandler, { capture: true });
outsideClickHandler = null;
}
};
btn.addEventListener("click", (e) => {
e.stopPropagation();
isOpen ? closeDropdown() : openDropdown();
});
filterInput.addEventListener("input", () => renderList(filterInput.value));
filterInput.addEventListener("keydown", (e) => {
const items = listEl.querySelectorAll<HTMLElement>(".gwt-branch-item");
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight(Math.min(highlightedIndex + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight(Math.max(highlightedIndex - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < visibleItems.length) {
const item = visibleItems[highlightedIndex];
closeDropdown();
void this.handleBranchSwitch(item.name, item.isRemote, branch);
}
} else if (e.key === "Escape") {
closeDropdown();
}
});
}
private async handleBranchSwitch(
newBranch: string,
select: HTMLSelectElement,
isRemote: boolean,
prevBranch: string
): Promise<void> {
if (newBranch === prevBranch) return;
@ -505,15 +647,16 @@ class GitHubRepoView extends ItemView {
: false;
try {
await runGitCheckout(repoPath, newBranch);
if (isRemote) {
await gitExec(repoPath, `checkout --track "origin/${newBranch}"`);
} else {
await runGitCheckout(repoPath, newBranch);
}
} catch (e: unknown) {
if (stashed) {
try { await runGitStashPop(repoPath); } catch { /* best-effort */ }
}
new Notice(`Could not switch branch: ${errorMessage(e)}`, 8000);
Array.from(select.options).forEach((opt) => {
opt.selected = opt.value === prevBranch;
});
return;
}

View file

@ -1,7 +1,7 @@
{
"id": "github-repo-tools",
"name": "GitHub Repo Tools",
"version": "1.0.4",
"version": "1.0.5",
"minAppVersion": "1.7.2",
"description": "Monitor a GitHub repo — staleness alerts, PR dashboard, and activity notifications.",
"author": "Kyle Whittle",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-github-tools",
"version": "1.0.4",
"version": "1.0.5",
"description": "GitHub wiki repo monitor for Obsidian",
"main": "main.js",
"scripts": {

View file

@ -72,31 +72,188 @@
margin-right: 4px;
}
/* ── Branch selector ─────────────────────────────────────────────────────── */
/* ── Branch picker ───────────────────────────────────────────────────────── */
.gwt-branch-row {
display: flex;
align-items: center;
gap: 8px;
.gwt-branch-picker {
position: relative;
margin-bottom: 8px;
}
.gwt-branch-select {
flex: 1;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
.gwt-branch-btn {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 4px 8px;
border-radius: 5px;
background: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
cursor: pointer;
font-size: 12px;
text-align: left;
}
.gwt-branch-select:focus {
outline: none;
.gwt-branch-btn:hover {
background: var(--background-modifier-hover);
border-color: var(--interactive-accent);
}
.gwt-branch-btn.gwt-branch-btn-open {
border-color: var(--interactive-accent);
}
.gwt-branch-btn-icon {
color: var(--text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.gwt-branch-btn-icon .svg-icon {
width: 13px;
height: 13px;
}
.gwt-branch-btn-name {
flex: 1;
font-family: var(--font-monospace);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gwt-branch-btn-chevron {
color: var(--text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
transition: transform 0.15s;
}
.gwt-branch-btn-chevron .svg-icon {
width: 13px;
height: 13px;
}
.gwt-branch-btn-open .gwt-branch-btn-chevron {
transform: rotate(180deg);
}
.gwt-branch-dropdown {
position: absolute;
top: calc(100% + 3px);
left: 0;
right: 0;
z-index: 100;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.gwt-branch-filter {
display: block;
width: 100%;
padding: 7px 10px;
font-size: 12px;
border: none;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
box-sizing: border-box;
}
.gwt-branch-filter:focus {
outline: none;
}
.gwt-branch-list {
max-height: 240px;
overflow-y: auto;
padding: 4px 0;
}
.gwt-branch-group-label {
padding: 4px 10px 2px;
font-size: 10px;
font-weight: 700;
color: var(--text-faint);
letter-spacing: 0.07em;
text-transform: uppercase;
user-select: none;
}
.gwt-branch-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font-size: 12px;
font-family: var(--font-monospace);
cursor: pointer;
border-radius: 0;
}
.gwt-branch-item:hover,
.gwt-branch-highlighted {
background: var(--background-modifier-hover);
}
.gwt-branch-item-icon {
color: var(--text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.gwt-branch-item-icon .svg-icon {
width: 12px;
height: 12px;
}
.gwt-branch-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gwt-branch-current .gwt-branch-item-name {
font-weight: 600;
color: var(--interactive-accent);
}
.gwt-branch-item-check {
color: var(--interactive-accent);
font-size: 11px;
flex-shrink: 0;
}
.gwt-branch-item-badge {
font-size: 10px;
font-family: var(--font-interface);
color: var(--text-faint);
background: var(--background-modifier-border);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
}
.gwt-branch-remote-item .gwt-branch-item-icon {
color: var(--color-blue);
}
.gwt-branch-no-results {
padding: 8px 10px;
font-size: 12px;
font-family: var(--font-interface);
color: var(--text-faint);
font-style: italic;
}
/* ── Action bar ──────────────────────────────────────────────────────────── */
.gwt-action-bar {