diff --git a/main.ts b/main.ts index 98cb7e1..ce28b7d 100644 --- a/main.ts +++ b/main.ts @@ -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 { - 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 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 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(".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(".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 { 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; } diff --git a/manifest.json b/manifest.json index af0f7a7..e75ac51 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package.json b/package.json index 00674bd..db7d8cd 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/styles.css b/styles.css index 7004cba..fad4499 100644 --- a/styles.css +++ b/styles.css @@ -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 {