From a12605116ce8725b5387cedd909d97c2978181ff Mon Sep 17 00:00:00 2001 From: kry-kylewhittle Date: Fri, 12 Jun 2026 16:40:34 +0100 Subject: [PATCH] Add README, LICENSE, fix manifest id for community plugin submission Co-Authored-By: Claude Sonnet 4.6 --- LICENSE | 21 + README.md | 79 ++++ main.ts | 1015 +++++++++++++++++++++++++++++++++++++++------ manifest.json | 8 +- package-lock.json | 579 ++++++++++++++++++++++++++ styles.css | 370 ++++++++++++++++- 6 files changed, 1932 insertions(+), 140 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package-lock.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e92511 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kyle Whittle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24c9d1e --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# GitHub Repo Tools + +An Obsidian plugin that puts a GitHub repository panel in your sidebar — monitor staleness, manage branches, stage and commit files, and track pull requests without leaving Obsidian. + +> **Desktop only.** Requires git to be installed and a local clone of the repository you want to monitor. + +--- + +## Features + +### Local repo status +- Shows your current branch and a dropdown to switch branches +- Detects uncommitted changes with an expandable file list +- Shows how many commits you are behind the remote with a one-click Pull button +- Ribbon icon badge turns red when your local repo is behind + +### Git operations +Fetch, Pull, Push, and Create Branch from the action bar. All operations run against the local clone you configure. + +### Commit modal +Stage individual files by checkbox, write a commit message, and commit (or commit + push) in one step. + +### PR dashboard +- **My PRs** — open PRs you authored +- **Awaiting My Review** — PRs where you are a requested reviewer +- **All Open PRs** — collapsible list of everyone else's PRs +- New activity badge on PRs that have been updated since you last opened them +- One-click to open any PR in the browser + +### Create PR workflow +Smart PR creation from the sidebar: +1. If you have uncommitted changes, prompts you to commit first +2. Pushes the branch if it hasn't been pushed yet +3. Opens a modal pre-filled with a title derived from your branch name + +### Status bar +Compact summary in the Obsidian status bar: sync status, uncommitted file count, open PR counts, and review request count. + +--- + +## Setup + +1. Install the plugin and enable it. +2. Open **Settings → GitHub Repo Tools**. +3. Set **Local repo path** — the absolute path to your local git clone (or use the Browse button). +4. Set your **GitHub personal access token** — a classic PAT with `repo` scope is sufficient. The plugin will auto-detect the repo owner/name and your GitHub username from the token and remote URL. + +--- + +## Settings + +| Setting | Default | Description | +|---|---|---| +| Local repo path | — | Absolute path to your local git clone | +| GitHub personal access token | — | Classic PAT with `repo` scope | +| Staleness check | On | Check if local repo is behind remote | +| PR dashboard | On | Show open PRs in the sidebar | +| PR activity alerts | On | Highlight PRs with new activity | +| Review requests | On | Show PRs where you are a requested reviewer | +| Status bar item | On | Show compact summary in the status bar | +| Auto-open sidebar on startup | On | Open the panel when Obsidian launches | +| Show draft PRs | On | Include draft PRs in the list | +| Poll interval | 15 min | How often to refresh automatically | +| Track branch | (current branch) | Remote branch to compare against for staleness | + +--- + +## Requirements + +- Obsidian 0.15.0 or later +- macOS, Windows, or Linux desktop (not mobile) +- git installed and available on the system PATH +- A GitHub personal access token with `repo` scope + +--- + +## License + +MIT diff --git a/main.ts b/main.ts index 9c5e3f0..32e010f 100644 --- a/main.ts +++ b/main.ts @@ -1,18 +1,20 @@ import { App, ItemView, + Modal, Notice, Plugin, PluginSettingTab, Setting, WorkspaceLeaf, + setIcon, } from "obsidian"; import { exec } from "child_process"; import { promisify } from "util"; const execAsync = promisify(exec); -const VIEW_TYPE = "github-wiki-sidebar"; +const VIEW_TYPE = "github-repo-sidebar"; // ── Settings ──────────────────────────────────────────────────────────────── @@ -22,8 +24,15 @@ interface Settings { repoOwner: string; repoName: string; githubUsername: string; + enableStalenessCheck: boolean; + enablePRDashboard: boolean; + enablePRActivityAlerts: boolean; + enableReviewRequests: boolean; + showStatusBar: boolean; + autoOpenSidebar: boolean; + showDraftPRs: boolean; pollIntervalMinutes: number; - // PR number → last-seen updatedAt timestamp (ISO string) + trackBranch: string; seenPRActivity: Record; } @@ -33,16 +42,33 @@ const DEFAULT_SETTINGS: Settings = { repoOwner: "", repoName: "", githubUsername: "", + enableStalenessCheck: true, + enablePRDashboard: true, + enablePRActivityAlerts: true, + enableReviewRequests: true, + showStatusBar: true, + autoOpenSidebar: true, + showDraftPRs: true, pollIntervalMinutes: 15, + trackBranch: "", seenPRActivity: {}, }; // ── Git service ────────────────────────────────────────────────────────────── +interface ChangedFile { + status: string; + path: string; +} + interface GitStatus { branch: string; + localBranches: string[]; hasUncommittedChanges: boolean; + changedFiles: ChangedFile[]; commitsBehind: number; + comparingTo: string; + incomingFiles: string[]; // files that would change on pull } async function gitExec(repoPath: string, args: string): Promise { @@ -50,26 +76,123 @@ async function gitExec(repoPath: string, args: string): Promise { return stdout.trim(); } -async function fetchGitStatus(repoPath: string): Promise { - await gitExec(repoPath, "fetch origin"); +function parseChangedFiles(porcelain: string): ChangedFile[] { + if (!porcelain) return []; + return porcelain + .split("\n") + .filter((l) => l.trim()) + .map((l) => ({ + status: l.substring(0, 2).trim() || "?", + path: l.substring(3), + })); +} + +function parseGitHubRemote(url: string): { owner: string; repo: string } | null { + const ssh = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/); + if (ssh) return { owner: ssh[1], repo: ssh[2] }; + const https = url.match(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/); + if (https) return { owner: https[1], repo: https[2] }; + return null; +} + +async function detectRepoIdentity(repoPath: string): Promise<{ owner: string; repo: string } | null> { + const url = await gitExec(repoPath, "remote get-url origin"); + return parseGitHubRemote(url); +} + +async function fetchGitStatus(repoPath: string, trackBranch: string): Promise { + // Fetch is best-effort — don't fail if offline or remote is unreachable + try { await gitExec(repoPath, "fetch origin"); } catch { /* non-fatal */ } const branch = await gitExec(repoPath, "branch --show-current"); - const porcelain = await gitExec(repoPath, "status --porcelain"); - const hasUncommittedChanges = porcelain.length > 0; + const comparingTo = trackBranch || branch; - const behindStr = await gitExec( - repoPath, - `rev-list --count HEAD..origin/${branch}` - ); - const commitsBehind = parseInt(behindStr, 10) || 0; + const [porcelain, branchesRaw] = await Promise.all([ + gitExec(repoPath, "status --porcelain"), + gitExec(repoPath, "branch"), + ]); - return { branch, hasUncommittedChanges, commitsBehind }; + const changedFiles = parseChangedFiles(porcelain); + // Plain `git branch` prefixes the current branch with "* " — strip it + const localBranches = branchesRaw + .split("\n") + .map((b) => b.replace(/^\*\s*/, "").trim()) + .filter(Boolean); + + // rev-list and diff can fail if the branch doesn't exist on the remote yet + let commitsBehind = 0; + let incomingFiles: string[] = []; + try { + const [behindStr, incomingRaw] = await Promise.all([ + gitExec(repoPath, `rev-list --count HEAD..origin/${comparingTo}`), + gitExec(repoPath, `diff --name-only HEAD..origin/${comparingTo}`), + ]); + commitsBehind = parseInt(behindStr, 10) || 0; + incomingFiles = incomingRaw.split("\n").map((f) => f.trim()).filter(Boolean); + } catch { /* non-fatal */ } + + return { + branch, + localBranches, + hasUncommittedChanges: changedFiles.length > 0, + changedFiles, + commitsBehind, + comparingTo, + incomingFiles, + }; } async function runGitPull(repoPath: string): Promise { await gitExec(repoPath, "pull"); } +async function runGitCheckout(repoPath: string, branch: string): Promise { + await gitExec(repoPath, `checkout "${branch}"`); +} + +async function runGitCreateBranch(repoPath: string, name: string, from: string | null): Promise { + const args = from ? `checkout -b "${name}" "${from}"` : `checkout -b "${name}"`; + await gitExec(repoPath, args); +} + +function detectDefaultBranch(branches: string[]): string { + return branches.find((b) => b === "main") + ?? branches.find((b) => b === "master") + ?? branches[0] + ?? "main"; +} + +async function runGitAdd(repoPath: string, files: string[]): Promise { + const quoted = files.map((f) => `'${f.replace(/'/g, "'\\''")}'`).join(" "); + await gitExec(repoPath, `add -- ${quoted}`); +} + +async function runGitCommit(repoPath: string, message: string): Promise { + const escaped = message.replace(/'/g, "'\\''"); + await gitExec(repoPath, `commit -m '${escaped}'`); +} + +async function runGitPush(repoPath: string, branch: string, setUpstream: boolean): Promise { + await gitExec(repoPath, setUpstream ? `push -u origin "${branch}"` : `push`); +} + +async function isBranchPushed(repoPath: string, branch: string): Promise { + try { + await gitExec(repoPath, `rev-parse --verify "origin/${branch}"`); + return true; + } catch { + return false; + } +} + +function branchToPRTitle(branch: string): string { + return branch + .replace(/^(feature|fix|chore|docs|test|refactor|style|perf)\//i, "") + .replace(/[-_/]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); +} + // ── GitHub service ─────────────────────────────────────────────────────────── interface PullRequest { @@ -79,29 +202,30 @@ interface PullRequest { author: string; updatedAt: string; draft: boolean; + requestedReviewers: string[]; } -async function fetchOpenPRs( - owner: string, - repo: string, - token: string -): Promise { - const response = await fetch( +async function ghFetch(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }); + if (!res.ok) throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); + return res.json(); +} + +async function detectGitHubUsername(token: string): Promise { + const data = await ghFetch("https://api.github.com/user", token); + return data.login; +} + +async function fetchOpenPRs(owner: string, repo: string, token: string): Promise { + const data: any[] = await ghFetch( `https://api.github.com/repos/${owner}/${repo}/pulls?state=open&per_page=50`, - { - headers: { - Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - }, - } + token ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } - - const data: any[] = await response.json(); - return data.map((pr) => ({ number: pr.number, title: pr.title, @@ -109,13 +233,49 @@ async function fetchOpenPRs( author: pr.user.login, updatedAt: pr.updated_at, draft: pr.draft ?? false, + requestedReviewers: (pr.requested_reviewers ?? []).map((r: any) => r.login), })); } +async function createGitHubPR( + owner: string, repo: string, token: string, + head: string, base: string, title: string, body: string +): Promise { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { + method: "POST", + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ title, body, head, base }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as any).message ?? `GitHub API error: ${res.status}`); + } + const data: any = await res.json(); + return data.html_url; +} + +// ── Electron folder picker ─────────────────────────────────────────────────── + +async function showFolderPicker(): Promise { + try { + const electron = (window as any).require("electron"); + const dialog = electron.remote?.dialog ?? (await import("@electron/remote" as any)).dialog; + const result = await dialog.showOpenDialog({ properties: ["openDirectory"] }); + if (!result.canceled && result.filePaths.length > 0) return result.filePaths[0]; + } catch (e) { + console.error("Folder picker unavailable:", e); + } + return null; +} + // ── Sidebar view ───────────────────────────────────────────────────────────── -class WikiSidebarView extends ItemView { - private plugin: GitHubWikiPlugin; +class GitHubRepoView extends ItemView { + plugin: GitHubWikiPlugin; private gitStatus: GitStatus | null = null; private prs: PullRequest[] = []; private loading = false; @@ -127,7 +287,7 @@ class WikiSidebarView extends ItemView { } getViewType(): string { return VIEW_TYPE; } - getDisplayText(): string { return "GitHub Wiki"; } + getDisplayText(): string { return "GitHub Repo"; } getIcon(): string { return "github"; } async onOpen(): Promise { @@ -142,19 +302,25 @@ class WikiSidebarView extends ItemView { try { const s = this.plugin.settings; - if (s.repoPath) { - this.gitStatus = await fetchGitStatus(s.repoPath); + if (s.enableStalenessCheck && s.repoPath) { + this.gitStatus = await fetchGitStatus(s.repoPath, s.trackBranch); + } else { + this.gitStatus = null; } - if (s.githubToken && s.repoOwner && s.repoName) { + if (s.enablePRDashboard && s.githubToken && s.repoOwner && s.repoName) { this.prs = await fetchOpenPRs(s.repoOwner, s.repoName, s.githubToken); - this.plugin.initSeenActivity(this.prs); + if (s.enablePRActivityAlerts) this.plugin.initSeenActivity(this.prs); + } else { + this.prs = []; } } catch (e: any) { this.errorMsg = e.message; } finally { this.loading = false; this.render(); + this.plugin.updateStatusBar(this.gitStatus, this.prs); + this.plugin.updateRibbonIcon(this.gitStatus?.commitsBehind ?? 0); } } @@ -164,7 +330,7 @@ class WikiSidebarView extends ItemView { root.addClass("gwt-sidebar"); const header = root.createDiv("gwt-header"); - header.createEl("h4", { text: "GitHub Wiki" }); + header.createEl("h4", { text: "GitHub Repo" }); const refreshBtn = header.createEl("button", { cls: "gwt-btn-icon", text: "↻" }); refreshBtn.setAttribute("aria-label", "Refresh"); refreshBtn.addEventListener("click", () => this.refresh()); @@ -178,71 +344,289 @@ class WikiSidebarView extends ItemView { root.createDiv("gwt-error").setText(this.errorMsg); } - this.renderGitStatus(root); - this.renderPRs(root); + const s = this.plugin.settings; + if (s.enableStalenessCheck) this.renderGitStatus(root); + if (s.enablePRDashboard) this.renderPRs(root); } private renderGitStatus(root: HTMLElement): void { - const s = this.gitStatus; - if (!s) { + if (!this.gitStatus) { if (!this.plugin.settings.repoPath) { - root.createDiv("gwt-muted").setText("Set repo path in settings to see git status."); + root.createDiv("gwt-muted").setText("Set repo path in settings."); } return; } + const { branch, localBranches, hasUncommittedChanges, changedFiles, commitsBehind, comparingTo } = + this.gitStatus; + const section = root.createDiv("gwt-section"); section.createEl("h5", { text: "Local Repo" }); - const branchRow = section.createDiv("gwt-row"); - branchRow.createSpan({ cls: "gwt-label", text: "Branch: " }); - branchRow.createSpan({ text: s.branch }); - - if (s.hasUncommittedChanges) { - section.createDiv("gwt-row gwt-warning").setText("⚠ Uncommitted changes"); + // Action bar + const actionBar = section.createDiv("gwt-action-bar"); + const actions: Array<{ icon: string; label: string; handler: () => void }> = [ + { icon: "rotate-cw", label: "Fetch", handler: () => this.handleFetch() }, + { icon: "download", label: "Pull", handler: () => this.handlePull() }, + { icon: "upload", label: "Push", handler: () => this.handlePush() }, + { icon: "git-pull-request", label: "Create PR", handler: () => this.handleCreatePR() }, + ]; + for (const a of actions) { + const btn = actionBar.createEl("button", { cls: "gwt-action-btn" }); + setIcon(btn, a.icon); + btn.setAttribute("aria-label", a.label); + btn.createSpan({ cls: "gwt-action-label", text: a.label }); + btn.addEventListener("click", a.handler); } - if (s.commitsBehind > 0) { - const behindRow = section.createDiv("gwt-row gwt-behind"); - behindRow.createSpan({ - text: `⬇ ${s.commitsBehind} commit${s.commitsBehind !== 1 ? "s" : ""} behind origin/${s.branch}`, + // Branch selector + 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", () => this.handleBranchSwitch(select.value, select, branch)); + + // New branch buttons + const newBranchRow = section.createDiv("gwt-new-branch-row"); + const newBtn = newBranchRow.createEl("button", { cls: "gwt-btn gwt-btn-sm", text: "+ New branch" }); + newBtn.addEventListener("click", () => { + new NewBranchModal(this.app, branch, null, localBranches, async (name, from) => { + await this.handleCreateBranch(name, from); + }).open(); + }); + const fromBtn = newBranchRow.createEl("button", { cls: "gwt-btn gwt-btn-sm", text: "+ New branch from…" }); + fromBtn.addEventListener("click", () => { + const defaultSource = detectDefaultBranch(localBranches); + new NewBranchModal(this.app, branch, defaultSource, localBranches, async (name, from) => { + await this.handleCreateBranch(name, from); + }).open(); + }); + + // Uncommitted changes + expandable file list + if (hasUncommittedChanges) { + const details = section.createEl("details", { cls: "gwt-files-details" }); + const summary = details.createEl("summary", { cls: "gwt-files-summary gwt-warning" }); + summary.createSpan({ text: `⚠ ${changedFiles.length} uncommitted change${changedFiles.length !== 1 ? "s" : ""}` }); + const commitBtn = summary.createEl("button", { cls: "gwt-btn gwt-btn-sm gwt-commit-inline-btn", text: "Commit…" }); + commitBtn.addEventListener("click", (e) => { + e.stopPropagation(); // don't toggle the
+ this.handleCommit(changedFiles); }); - const pullBtn = behindRow.createEl("button", { cls: "gwt-btn", text: "Pull" }); + + const list = details.createDiv("gwt-files-list"); + changedFiles.forEach((f) => { + const row = list.createDiv("gwt-file-row"); + row.createSpan({ cls: `gwt-file-status gwt-status-${f.status.replace("?", "untracked")}`, text: f.status }); + row.createSpan({ cls: "gwt-file-path", text: f.path }); + }); + } + + // Staleness + pull + if (commitsBehind > 0) { + const behindRow = section.createDiv("gwt-behind-row"); + behindRow.createSpan({ + cls: "gwt-behind-label", + text: `⬇ ${commitsBehind} commit${commitsBehind !== 1 ? "s" : ""} behind origin/${comparingTo}`, + }); + const pullBtn = behindRow.createEl("button", { cls: "gwt-btn gwt-btn-pull", text: "Pull" }); pullBtn.addEventListener("click", () => this.handlePull()); } else { - section.createDiv("gwt-row gwt-ok").setText("✓ Up to date"); + section.createDiv("gwt-row gwt-ok").setText(`✓ Up to date with origin/${comparingTo}`); } } + private async handleBranchSwitch( + newBranch: string, + select: HTMLSelectElement, + prevBranch: string + ): Promise { + if (newBranch === prevBranch) return; + + try { + await runGitCheckout(this.plugin.settings.repoPath, newBranch); + new Notice(`Switched to branch: ${newBranch}`); + await this.refresh(); + } catch (e: any) { + new Notice(`Could not switch branch: ${e.message}`, 8000); + // Revert the dropdown to the original branch + Array.from(select.options).forEach((opt) => { + opt.selected = opt.value === prevBranch; + }); + } + } + + private async handleCreateBranch(name: string, from: string | null): Promise { + try { + await runGitCreateBranch(this.plugin.settings.repoPath, name, from); + new Notice(`Created and switched to branch: ${name}`); + await this.refresh(); + } catch (e: any) { + new Notice(`Failed to create branch: ${e.message}`, 8000); + } + } + + private async handleFetch(): Promise { + try { + await gitExec(this.plugin.settings.repoPath, "fetch origin"); + new Notice("Fetched."); + await this.refresh(); + } catch (e: any) { + new Notice(`Fetch failed: ${e.message}`, 6000); + } + } + + private async handlePush(): Promise { + const { repoPath } = this.plugin.settings; + const branch = this.gitStatus?.branch; + if (!branch) return; + try { + const pushed = await isBranchPushed(repoPath, branch); + await runGitPush(repoPath, branch, !pushed); + new Notice("Pushed."); + await this.refresh(); + } catch (e: any) { + new Notice(`Push failed: ${e.message}`, 8000); + } + } + + private handleCommit(changedFiles: ChangedFile[]): void { + const { repoPath } = this.plugin.settings; + const branch = this.gitStatus?.branch ?? ""; + new CommitModal(this.app, changedFiles, async (files, message, push) => { + try { + await runGitAdd(repoPath, files); + await runGitCommit(repoPath, message); + if (push) { + const pushed = await isBranchPushed(repoPath, branch); + await runGitPush(repoPath, branch, !pushed); + new Notice("Committed and pushed."); + } else { + new Notice("Committed."); + } + await this.refresh(); + } catch (e: any) { + new Notice(`Commit failed: ${e.message}`, 8000); + } + }).open(); + } + + private async handleCreatePR(): Promise { + const { repoPath, repoOwner, repoName, githubToken } = this.plugin.settings; + const branch = this.gitStatus?.branch; + const localBranches = this.gitStatus?.localBranches ?? []; + + if (!branch || !repoOwner || !repoName || !githubToken) { + new Notice("Configure GitHub settings to create a PR.", 5000); + return; + } + + // Step 1: uncommitted changes → commit first, then continue + if (this.gitStatus?.hasUncommittedChanges) { + new CommitModal(this.app, this.gitStatus.changedFiles, async (files, message, push) => { + try { + await runGitAdd(repoPath, files); + await runGitCommit(repoPath, message); + if (push) { + const pushed = await isBranchPushed(repoPath, branch); + await runGitPush(repoPath, branch, !pushed); + } + await this.refresh(); + await this.proceedWithPR(branch, localBranches); + } catch (e: any) { + new Notice(`Commit failed: ${e.message}`, 8000); + } + }).open(); + return; + } + + await this.proceedWithPR(branch, localBranches); + } + + private async proceedWithPR(branch: string, localBranches: string[]): Promise { + const { repoPath, repoOwner, repoName, githubToken } = this.plugin.settings; + + // Step 2: push branch if not on remote + const pushed = await isBranchPushed(repoPath, branch); + if (!pushed) { + try { + new Notice("Branch not yet on remote. Pushing…"); + await runGitPush(repoPath, branch, true); + new Notice("Pushed."); + } catch (e: any) { + new Notice(`Push failed: ${e.message}`, 8000); + return; + } + } + + // Step 3: open PR creation modal + new CreatePRModal(this.app, branch, localBranches, async (title, body, base) => { + try { + const url = await createGitHubPR(repoOwner, repoName, githubToken, branch, base, title, body); + window.open(url, "_blank"); + new Notice("PR created."); + await this.refresh(); + } catch (e: any) { + new Notice(`Failed to create PR: ${e.message}`, 8000); + } + }).open(); + } + private renderPRs(root: HTMLElement): void { const s = this.plugin.settings; if (!s.githubToken || !s.repoOwner || !s.repoName) { - root.createDiv("gwt-muted").setText("Configure GitHub settings to see PRs."); + root.createDiv("gwt-muted").setText("Add a GitHub token in settings to see PRs."); return; } - const myPRs = this.prs.filter((pr) => pr.author === s.githubUsername); - const otherPRs = this.prs.filter((pr) => pr.author !== s.githubUsername); + const visible = s.showDraftPRs ? this.prs : this.prs.filter((pr) => !pr.draft); + const myPRs = visible.filter((pr) => pr.author === s.githubUsername); + const reviewRequests = s.enableReviewRequests + ? visible.filter( + (pr) => + pr.author !== s.githubUsername && + pr.requestedReviewers.includes(s.githubUsername) + ) + : []; + const otherPRs = visible.filter( + (pr) => + pr.author !== s.githubUsername && + !pr.requestedReviewers.includes(s.githubUsername) + ); - const mySection = root.createDiv("gwt-section"); - mySection.createEl("h5", { text: `My PRs (${myPRs.length})` }); - if (myPRs.length === 0) { - mySection.createDiv("gwt-empty").setText("No open PRs"); - } else { - myPRs.forEach((pr) => this.renderPR(mySection, pr)); + this.renderPRSection(root, `My PRs (${myPRs.length})`, myPRs, "No open PRs"); + if (s.enableReviewRequests) { + this.renderPRSection(root, `Awaiting My Review (${reviewRequests.length})`, reviewRequests, "None"); } + this.renderPRSectionCollapsible(root, `All Open PRs (${otherPRs.length})`, otherPRs, "No other open PRs"); + } - const allSection = root.createDiv("gwt-section"); - allSection.createEl("h5", { text: `All Open PRs (${otherPRs.length})` }); - if (otherPRs.length === 0) { - allSection.createDiv("gwt-empty").setText("No other open PRs"); + private renderPRSection(root: HTMLElement, title: string, prs: PullRequest[], emptyText: string): void { + const section = root.createDiv("gwt-section"); + section.createEl("h5", { text: title }); + if (prs.length === 0) { + section.createDiv("gwt-empty").setText(emptyText); } else { - otherPRs.forEach((pr) => this.renderPR(allSection, pr)); + prs.forEach((pr) => this.renderPR(section, pr)); + } + } + + private renderPRSectionCollapsible(root: HTMLElement, title: string, prs: PullRequest[], emptyText: string): void { + const details = root.createEl("details", { cls: "gwt-section gwt-pr-collapsible" }); + const summary = details.createEl("summary", { cls: "gwt-pr-collapsible-summary" }); + summary.createEl("h5", { text: title }); + if (prs.length === 0) { + details.createDiv("gwt-empty").setText(emptyText); + } else { + prs.forEach((pr) => this.renderPR(details, pr)); } } private renderPR(container: HTMLElement, pr: PullRequest): void { - const hasNew = this.plugin.hasNewActivity(pr); + const s = this.plugin.settings; + const hasNew = s.enablePRActivityAlerts && this.plugin.hasNewActivity(pr); const row = container.createDiv(hasNew ? "gwt-pr gwt-pr-new" : "gwt-pr"); const link = row.createEl("a", { @@ -253,9 +637,11 @@ class WikiSidebarView extends ItemView { link.addEventListener("click", (e) => { e.preventDefault(); window.open(pr.url, "_blank"); - this.plugin.markSeen(pr); - row.removeClass("gwt-pr-new"); - row.querySelector(".gwt-new-badge")?.remove(); + if (s.enablePRActivityAlerts) { + this.plugin.markSeen(pr); + row.removeClass("gwt-pr-new"); + row.querySelector(".gwt-new-badge")?.remove(); + } }); const meta = row.createDiv("gwt-pr-meta"); @@ -265,15 +651,22 @@ class WikiSidebarView extends ItemView { } private async handlePull(): Promise { - const { gitStatus } = this; - if (!gitStatus) return; + if (!this.gitStatus) return; - if (gitStatus.hasUncommittedChanges) { - new Notice( - "Cannot pull: you have uncommitted changes. Commit or stash them first.", - 8000 - ); - return; + if (this.gitStatus.hasUncommittedChanges) { + const localPaths = new Set(this.gitStatus.changedFiles.map((f) => f.path)); + const conflicts = this.gitStatus.incomingFiles.filter((f) => localPaths.has(f)); + + if (conflicts.length > 0) { + const fileList = conflicts.slice(0, 5).join("\n"); + const extra = conflicts.length > 5 ? `\n…and ${conflicts.length - 5} more` : ""; + new Notice( + `Cannot pull: ${conflicts.length} incoming file${conflicts.length !== 1 ? "s" : ""} overlap your local changes:\n\n${fileList}${extra}`, + 10000 + ); + return; + } + // Local changes don't touch any incoming files — safe to pull } try { @@ -291,18 +684,28 @@ class WikiSidebarView extends ItemView { export default class GitHubWikiPlugin extends Plugin { settings: Settings; private pollTimer: number | null = null; + private statusBarItem: HTMLElement | null = null; + private ribbonIcon: HTMLElement | null = null; async onload(): Promise { await this.loadSettings(); - this.registerView(VIEW_TYPE, (leaf) => new WikiSidebarView(leaf, this)); + this.registerView(VIEW_TYPE, (leaf) => new GitHubRepoView(leaf, this)); - this.addRibbonIcon("github", "GitHub Wiki", () => this.activateView()); + this.ribbonIcon = this.addRibbonIcon("github", "GitHub Repo", () => this.activateView()); - this.addSettingTab(new GitHubWikiSettingTab(this.app, this)); + this.addSettingTab(new GitHubRepoSettingTab(this.app, this)); - this.app.workspace.onLayoutReady(() => { - this.activateView(); + if (this.settings.showStatusBar) { + this.statusBarItem = this.addStatusBarItem(); + this.statusBarItem.addClass("gwt-status-bar"); + this.statusBarItem.setText("GH ···"); + this.statusBarItem.addEventListener("click", () => this.activateView()); + } + + this.app.workspace.onLayoutReady(async () => { + await this.detectAndCacheIdentity(); + if (this.settings.autoOpenSidebar) this.activateView(); this.startPolling(); }); } @@ -320,6 +723,30 @@ export default class GitHubWikiPlugin extends Plugin { await this.saveData(this.settings); } + async detectAndCacheIdentity(): Promise { + let changed = false; + + if (this.settings.repoPath && (!this.settings.repoOwner || !this.settings.repoName)) { + try { + const identity = await detectRepoIdentity(this.settings.repoPath); + if (identity) { + this.settings.repoOwner = identity.owner; + this.settings.repoName = identity.repo; + changed = true; + } + } catch { /* non-fatal */ } + } + + if (this.settings.githubToken && !this.settings.githubUsername) { + try { + this.settings.githubUsername = await detectGitHubUsername(this.settings.githubToken); + changed = true; + } catch { /* non-fatal */ } + } + + if (changed) await this.saveSettings(); + } + async activateView(): Promise { const { workspace } = this.app; let leaf = workspace.getLeavesOfType(VIEW_TYPE)[0]; @@ -330,6 +757,43 @@ export default class GitHubWikiPlugin extends Plugin { workspace.revealLeaf(leaf); } + updateRibbonIcon(commitsBehind: number): void { + if (!this.ribbonIcon) return; + if (commitsBehind > 0) { + this.ribbonIcon.addClass("gwt-ribbon-alert"); + this.ribbonIcon.setAttribute("aria-label", `GitHub Repo — ${commitsBehind} commit${commitsBehind !== 1 ? "s" : ""} behind`); + } else { + this.ribbonIcon.removeClass("gwt-ribbon-alert"); + this.ribbonIcon.setAttribute("aria-label", "GitHub Repo"); + } + } + + updateStatusBar(gitStatus: GitStatus | null, prs: PullRequest[]): void { + if (!this.statusBarItem) return; + + const parts: string[] = []; + + if (this.settings.enableStalenessCheck && gitStatus) { + parts.push(gitStatus.commitsBehind > 0 ? `⬇${gitStatus.commitsBehind}` : "✓"); + if (gitStatus.hasUncommittedChanges) parts.push(`~${gitStatus.changedFiles.length}`); + } + + if (this.settings.enablePRDashboard) { + const myPRs = prs.filter((pr) => pr.author === this.settings.githubUsername).length; + const reviewReqs = prs.filter( + (pr) => + pr.author !== this.settings.githubUsername && + pr.requestedReviewers.includes(this.settings.githubUsername) + ).length; + const unread = prs.filter((pr) => this.hasNewActivity(pr)).length; + if (myPRs > 0) parts.push(`PR:${myPRs}`); + if (reviewReqs > 0) parts.push(`Rev:${reviewReqs}`); + if (unread > 0) parts.push(`New:${unread}`); + } + + this.statusBarItem.setText(parts.length > 0 ? `GH ${parts.join(" · ")}` : "GH ✓"); + } + restartPolling(): void { this.stopPolling(); this.startPolling(); @@ -349,12 +813,10 @@ export default class GitHubWikiPlugin extends Plugin { private async refreshView(): Promise { for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE)) { - await (leaf.view as WikiSidebarView).refresh(); + await (leaf.view as GitHubRepoView).refresh(); } } - // Record updatedAt for PRs we haven't seen before so first load doesn't - // flag everything as new. initSeenActivity(prs: PullRequest[]): void { let changed = false; for (const pr of prs) { @@ -380,9 +842,260 @@ export default class GitHubWikiPlugin extends Plugin { } } +// ── Commit modal ───────────────────────────────────────────────────────────── + +class CommitModal extends Modal { + private changedFiles: ChangedFile[]; + private onSubmit: (files: string[], message: string, push: boolean) => Promise; + + constructor( + app: App, + changedFiles: ChangedFile[], + onSubmit: (files: string[], message: string, push: boolean) => Promise + ) { + super(app); + this.changedFiles = changedFiles; + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("gwt-modal"); + + contentEl.createEl("h3", { text: "Commit changes" }); + + // File list with checkboxes + contentEl.createEl("label", { cls: "gwt-modal-label", text: "Stage files" }); + const selectRow = contentEl.createDiv("gwt-modal-select-all"); + const selectAll = selectRow.createEl("a", { text: "Select all" }); + const deselectAll = selectRow.createEl("a", { text: "Deselect all" }); + + const fileList = contentEl.createDiv("gwt-modal-file-list"); + const checkboxes: HTMLInputElement[] = []; + + this.changedFiles.forEach((f) => { + const item = fileList.createDiv("gwt-modal-file-item"); + const cb = item.createEl("input", { type: "checkbox" }) as HTMLInputElement; + cb.checked = true; + checkboxes.push(cb); + item.createSpan({ cls: `gwt-file-status gwt-status-${f.status.replace("?", "untracked")}`, text: f.status }); + item.createSpan({ cls: "gwt-file-path", text: f.path }); + item.addEventListener("click", (e) => { if (e.target !== cb) cb.checked = !cb.checked; }); + }); + + selectAll.addEventListener("click", () => checkboxes.forEach((cb) => (cb.checked = true))); + deselectAll.addEventListener("click", () => checkboxes.forEach((cb) => (cb.checked = false))); + + // Commit message + contentEl.createEl("label", { cls: "gwt-modal-label", text: "Commit message" }); + const textarea = contentEl.createEl("textarea", { cls: "gwt-modal-textarea" }) as HTMLTextAreaElement; + textarea.rows = 3; + textarea.placeholder = "feat: describe your changes"; + + // Buttons + const btnRow = contentEl.createDiv("gwt-modal-btns"); + const commitBtn = btnRow.createEl("button", { cls: "mod-cta", text: "Commit" }); + const commitPushBtn = btnRow.createEl("button", { text: "Commit & Push" }); + const cancelBtn = btnRow.createEl("button", { text: "Cancel" }); + + const submit = async (push: boolean) => { + const selected = this.changedFiles + .filter((_, i) => checkboxes[i].checked) + .map((f) => f.path); + const message = textarea.value.trim(); + if (selected.length === 0) { new Notice("Select at least one file."); return; } + if (!message) { textarea.focus(); new Notice("Enter a commit message."); return; } + commitBtn.disabled = true; + commitPushBtn.disabled = true; + this.close(); + await this.onSubmit(selected, message, push); + }; + + commitBtn.addEventListener("click", () => submit(false)); + commitPushBtn.addEventListener("click", () => submit(true)); + cancelBtn.addEventListener("click", () => this.close()); + + setTimeout(() => textarea.focus(), 50); + } + + onClose(): void { this.contentEl.empty(); } +} + +// ── Create PR modal ─────────────────────────────────────────────────────────── + +class CreatePRModal extends Modal { + private currentBranch: string; + private availableBranches: string[]; + private onSubmit: (title: string, body: string, base: string) => Promise; + + constructor( + app: App, + currentBranch: string, + availableBranches: string[], + onSubmit: (title: string, body: string, base: string) => Promise + ) { + super(app); + this.currentBranch = currentBranch; + this.availableBranches = availableBranches; + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("gwt-modal"); + + contentEl.createEl("h3", { text: "Create pull request" }); + + // Base branch + const baseRow = contentEl.createDiv("gwt-modal-row"); + baseRow.createEl("label", { cls: "gwt-modal-label", text: "Base branch" }); + const baseSelect = baseRow.createEl("select", { cls: "gwt-modal-select" }); + const defaultBase = detectDefaultBranch( + this.availableBranches.filter((b) => b !== this.currentBranch) + ); + this.availableBranches + .filter((b) => b !== this.currentBranch) + .forEach((b) => { + const opt = baseSelect.createEl("option", { text: b, value: b }); + if (b === defaultBase) opt.selected = true; + }); + + // From hint + const hint = contentEl.createEl("p", { cls: "gwt-modal-from-hint" }); + const updateHint = () => { + hint.setText(`${this.currentBranch} → ${baseSelect.value}`); + }; + updateHint(); + baseSelect.addEventListener("change", updateHint); + + // PR title + const titleRow = contentEl.createDiv("gwt-modal-row"); + titleRow.createEl("label", { cls: "gwt-modal-label", text: "Title" }); + const titleInput = titleRow.createEl("input", { cls: "gwt-modal-input", type: "text" }) as HTMLInputElement; + titleInput.value = branchToPRTitle(this.currentBranch); + + // Description + const bodyRow = contentEl.createDiv("gwt-modal-row"); + bodyRow.createEl("label", { cls: "gwt-modal-label", text: "Description (optional)" }); + const bodyTextarea = bodyRow.createEl("textarea", { cls: "gwt-modal-textarea" }) as HTMLTextAreaElement; + bodyTextarea.rows = 4; + bodyTextarea.placeholder = "Describe your changes…"; + + // Buttons + const btnRow = contentEl.createDiv("gwt-modal-btns"); + const createBtn = btnRow.createEl("button", { cls: "mod-cta", text: "Create PR" }); + const cancelBtn = btnRow.createEl("button", { text: "Cancel" }); + + const submit = async () => { + const title = titleInput.value.trim(); + if (!title) { titleInput.focus(); return; } + createBtn.disabled = true; + createBtn.setText("Creating…"); + this.close(); + await this.onSubmit(title, bodyTextarea.value.trim(), baseSelect.value); + }; + + createBtn.addEventListener("click", submit); + cancelBtn.addEventListener("click", () => this.close()); + titleInput.addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); + + setTimeout(() => titleInput.focus(), 50); + } + + onClose(): void { this.contentEl.empty(); } +} + +// ── New branch modal ───────────────────────────────────────────────────────── + +class NewBranchModal extends Modal { + private currentBranch: string; + private sourceBranch: string | null; // null = from current HEAD + private availableBranches: string[]; + private onSubmit: (name: string, from: string | null) => Promise; + + constructor( + app: App, + currentBranch: string, + sourceBranch: string | null, + availableBranches: string[], + onSubmit: (name: string, from: string | null) => Promise + ) { + super(app); + this.currentBranch = currentBranch; + this.sourceBranch = sourceBranch; + this.availableBranches = availableBranches; + this.onSubmit = onSubmit; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("gwt-modal"); + + const fromPicker = this.sourceBranch !== null; + + contentEl.createEl("h3", { + text: fromPicker ? "Create new branch from…" : "Create new branch", + }); + + // Source branch picker (only shown for "from..." flow) + if (fromPicker) { + const row = contentEl.createDiv("gwt-modal-row"); + row.createEl("label", { cls: "gwt-modal-label", text: "Source branch" }); + const select = row.createEl("select", { cls: "gwt-modal-select" }); + this.availableBranches.forEach((b) => { + const opt = select.createEl("option", { text: b, value: b }); + if (b === this.sourceBranch) opt.selected = true; + }); + select.addEventListener("change", () => { this.sourceBranch = select.value; }); + } else { + contentEl.createEl("p", { + cls: "gwt-modal-hint", + text: `From: ${this.currentBranch}`, + }); + } + + // Branch name input + const nameRow = contentEl.createDiv("gwt-modal-row"); + nameRow.createEl("label", { cls: "gwt-modal-label", text: "Branch name" }); + const input = nameRow.createEl("input", { cls: "gwt-modal-input", type: "text" }); + input.placeholder = "feature/my-branch"; + + // Buttons + const btnRow = contentEl.createDiv("gwt-modal-btns"); + const createBtn = btnRow.createEl("button", { cls: "mod-cta", text: "Create branch" }); + const cancelBtn = btnRow.createEl("button", { text: "Cancel" }); + + const submit = async () => { + const name = input.value.trim(); + if (!name) { input.focus(); return; } + createBtn.disabled = true; + createBtn.setText("Creating…"); + this.close(); + await this.onSubmit(name, this.sourceBranch); + }; + + createBtn.addEventListener("click", submit); + cancelBtn.addEventListener("click", () => this.close()); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") submit(); + if (e.key === "Escape") this.close(); + }); + + // Focus the name input after a tick so the modal is fully rendered + setTimeout(() => input.focus(), 50); + } + + onClose(): void { + this.contentEl.empty(); + } +} + // ── Settings tab ───────────────────────────────────────────────────────────── -class GitHubWikiSettingTab extends PluginSettingTab { +class GitHubRepoSettingTab extends PluginSettingTab { plugin: GitHubWikiPlugin; constructor(app: App, plugin: GitHubWikiPlugin) { @@ -393,19 +1106,37 @@ class GitHubWikiSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); - containerEl.createEl("h2", { text: "GitHub Wiki Tools" }); + containerEl.createEl("h2", { text: "GitHub Repo Tools" }); + + containerEl.createEl("h3", { text: "Connection" }); new Setting(containerEl) .setName("Local repo path") - .setDesc("Absolute path to your local clone of the wiki repo") + .setDesc("Absolute path to your local clone of the repo") .addText((t) => t - .setPlaceholder("/Users/you/wiki-repo") + .setPlaceholder("/Users/you/my-repo") .setValue(this.plugin.settings.repoPath) .onChange(async (v) => { this.plugin.settings.repoPath = v; + this.plugin.settings.repoOwner = ""; + this.plugin.settings.repoName = ""; await this.plugin.saveSettings(); + await this.plugin.detectAndCacheIdentity(); + this.display(); }) + ) + .addButton((btn) => + btn.setButtonText("Browse…").onClick(async () => { + const folder = await showFolderPicker(); + if (!folder) return; + this.plugin.settings.repoPath = folder; + this.plugin.settings.repoOwner = ""; + this.plugin.settings.repoName = ""; + await this.plugin.saveSettings(); + await this.plugin.detectAndCacheIdentity(); + this.display(); + }) ); new Setting(containerEl) @@ -417,49 +1148,62 @@ class GitHubWikiSettingTab extends PluginSettingTab { .setValue(this.plugin.settings.githubToken) .onChange(async (v) => { this.plugin.settings.githubToken = v; + this.plugin.settings.githubUsername = ""; await this.plugin.saveSettings(); + await this.plugin.detectAndCacheIdentity(); + this.display(); }); t.inputEl.type = "password"; }); - new Setting(containerEl) - .setName("GitHub username") - .setDesc("Your GitHub username — used to identify your PRs") - .addText((t) => - t - .setPlaceholder("your-username") - .setValue(this.plugin.settings.githubUsername) - .onChange(async (v) => { - this.plugin.settings.githubUsername = v; - await this.plugin.saveSettings(); - }) - ); + const s = this.plugin.settings; + if (s.repoOwner && s.repoName) { + containerEl.createEl("p", { cls: "setting-item-description", text: `Detected repo: ${s.repoOwner}/${s.repoName}` }); + } + if (s.githubUsername) { + containerEl.createEl("p", { cls: "setting-item-description", text: `Detected GitHub user: @${s.githubUsername}` }); + } + + containerEl.createEl("h3", { text: "Features" }); new Setting(containerEl) - .setName("Repo owner") - .setDesc("GitHub org or user that owns the wiki repo") - .addText((t) => - t - .setPlaceholder("org-name") - .setValue(this.plugin.settings.repoOwner) - .onChange(async (v) => { - this.plugin.settings.repoOwner = v; - await this.plugin.saveSettings(); - }) - ); + .setName("Staleness check") + .setDesc("Check if your local repo is behind the remote and offer to pull") + .addToggle((t) => t.setValue(s.enableStalenessCheck).onChange(async (v) => { this.plugin.settings.enableStalenessCheck = v; await this.plugin.saveSettings(); })); new Setting(containerEl) - .setName("Repo name") - .setDesc("Name of the wiki repo on GitHub") - .addText((t) => - t - .setPlaceholder("wiki-repo") - .setValue(this.plugin.settings.repoName) - .onChange(async (v) => { - this.plugin.settings.repoName = v; - await this.plugin.saveSettings(); - }) - ); + .setName("PR dashboard") + .setDesc("Show open pull requests in the sidebar") + .addToggle((t) => t.setValue(s.enablePRDashboard).onChange(async (v) => { this.plugin.settings.enablePRDashboard = v; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName("PR activity alerts") + .setDesc("Highlight your PRs that have new comments or reviews since you last opened them") + .addToggle((t) => t.setValue(s.enablePRActivityAlerts).onChange(async (v) => { this.plugin.settings.enablePRActivityAlerts = v; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName("Review requests") + .setDesc("Show a section for PRs where you are a requested reviewer") + .addToggle((t) => t.setValue(s.enableReviewRequests).onChange(async (v) => { this.plugin.settings.enableReviewRequests = v; await this.plugin.saveSettings(); })); + + containerEl.createEl("h3", { text: "Display" }); + + new Setting(containerEl) + .setName("Status bar item") + .setDesc("Show a compact summary in the Obsidian status bar (restart plugin to apply)") + .addToggle((t) => t.setValue(s.showStatusBar).onChange(async (v) => { this.plugin.settings.showStatusBar = v; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName("Auto-open sidebar on startup") + .setDesc("Open the GitHub Repo panel automatically when Obsidian launches") + .addToggle((t) => t.setValue(s.autoOpenSidebar).onChange(async (v) => { this.plugin.settings.autoOpenSidebar = v; await this.plugin.saveSettings(); })); + + new Setting(containerEl) + .setName("Show draft PRs") + .setDesc("Include draft pull requests in the PR list") + .addToggle((t) => t.setValue(s.showDraftPRs).onChange(async (v) => { this.plugin.settings.showDraftPRs = v; await this.plugin.saveSettings(); })); + + containerEl.createEl("h3", { text: "Advanced" }); new Setting(containerEl) .setName("Poll interval (minutes)") @@ -467,7 +1211,7 @@ class GitHubWikiSettingTab extends PluginSettingTab { .addText((t) => t .setPlaceholder("15") - .setValue(String(this.plugin.settings.pollIntervalMinutes)) + .setValue(String(s.pollIntervalMinutes)) .onChange(async (v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) { @@ -477,5 +1221,18 @@ class GitHubWikiSettingTab extends PluginSettingTab { } }) ); + + new Setting(containerEl) + .setName("Track branch") + .setDesc("Remote branch to compare against (e.g. main). Leave empty to use your current branch.") + .addText((t) => + t + .setPlaceholder("main") + .setValue(s.trackBranch) + .onChange(async (v) => { + this.plugin.settings.trackBranch = v.trim(); + await this.plugin.saveSettings(); + }) + ); } } diff --git a/manifest.json b/manifest.json index ed20805..59e77f9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { - "id": "obsidian-github-tools", - "name": "GitHub Wiki Tools", + "id": "github-repo-tools", + "name": "GitHub Repo Tools", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Monitor your GitHub wiki repo — staleness alerts, PR dashboard, and activity notifications.", + "description": "Monitor a GitHub repo — staleness alerts, PR dashboard, and activity notifications.", "author": "Kyle Whittle", - "authorUrl": "", + "authorUrl": "https://github.com/kry-kylewhittle", "isDesktopOnly": true } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..945c6b0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,579 @@ +{ + "name": "obsidian-github-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-github-tools", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "builtin-modules": "^3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", + "integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz", + "integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.3.tgz", + "integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz", + "integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz", + "integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz", + "integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz", + "integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz", + "integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", + "integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz", + "integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz", + "integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz", + "integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz", + "integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz", + "integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz", + "integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz", + "integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz", + "integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz", + "integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz", + "integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz", + "integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz", + "integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz", + "integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", + "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.3", + "@esbuild/android-arm64": "0.17.3", + "@esbuild/android-x64": "0.17.3", + "@esbuild/darwin-arm64": "0.17.3", + "@esbuild/darwin-x64": "0.17.3", + "@esbuild/freebsd-arm64": "0.17.3", + "@esbuild/freebsd-x64": "0.17.3", + "@esbuild/linux-arm": "0.17.3", + "@esbuild/linux-arm64": "0.17.3", + "@esbuild/linux-ia32": "0.17.3", + "@esbuild/linux-loong64": "0.17.3", + "@esbuild/linux-mips64el": "0.17.3", + "@esbuild/linux-ppc64": "0.17.3", + "@esbuild/linux-riscv64": "0.17.3", + "@esbuild/linux-s390x": "0.17.3", + "@esbuild/linux-x64": "0.17.3", + "@esbuild/netbsd-x64": "0.17.3", + "@esbuild/openbsd-x64": "0.17.3", + "@esbuild/sunos-x64": "0.17.3", + "@esbuild/win32-arm64": "0.17.3", + "@esbuild/win32-ia32": "0.17.3", + "@esbuild/win32-x64": "0.17.3" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/styles.css b/styles.css index 048a62f..7004cba 100644 --- a/styles.css +++ b/styles.css @@ -29,6 +29,27 @@ background: var(--background-modifier-hover); } +/* ── Ribbon icon alert badge ─────────────────────────────────────────────── */ + +.gwt-ribbon-alert { + position: relative; +} + +.gwt-ribbon-alert::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background: var(--color-orange); + border-radius: 50%; + border: 2px solid var(--background-primary); + pointer-events: none; +} + +/* ── Section layout ──────────────────────────────────────────────────────── */ + .gwt-section { margin-bottom: 16px; } @@ -48,21 +69,303 @@ .gwt-label { color: var(--text-muted); + margin-right: 4px; +} + +/* ── Branch selector ─────────────────────────────────────────────────────── */ + +.gwt-branch-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.gwt-branch-select { + flex: 1; + font-size: 12px; + padding: 2px 4px; + border-radius: 4px; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + cursor: pointer; +} + +.gwt-branch-select:focus { + outline: none; + border-color: var(--interactive-accent); +} + +/* ── Action bar ──────────────────────────────────────────────────────────── */ + +.gwt-action-bar { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +.gwt-action-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 5px 2px; + border-radius: 4px; + cursor: pointer; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-muted); + font-size: 10px; +} + +.gwt-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.gwt-action-btn .svg-icon { + width: 14px; + height: 14px; +} + +.gwt-action-label { + font-size: 10px; + line-height: 1; +} + +/* ── Commit inline button ────────────────────────────────────────────────── */ + +.gwt-commit-inline-btn { + margin-left: auto; + font-size: 11px; + padding: 1px 7px; + vertical-align: middle; +} + +/* ── Modal: file list ────────────────────────────────────────────────────── */ + +.gwt-modal-file-list { + max-height: 200px; + overflow-y: auto; + margin: 4px 0 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 4px; +} + +.gwt-modal-file-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 4px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + font-family: var(--font-monospace); +} + +.gwt-modal-file-item:hover { + background: var(--background-modifier-hover); +} + +.gwt-modal-select-all { + font-size: 11px; + color: var(--text-muted); + display: flex; + gap: 10px; + margin-bottom: 2px; +} + +.gwt-modal-select-all a { + cursor: pointer; + color: var(--interactive-accent); + text-decoration: none; +} + +.gwt-modal-select-all a:hover { + text-decoration: underline; +} + +.gwt-modal-textarea { + width: 100%; + font-size: 13px; + padding: 6px 8px; + border-radius: 4px; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + resize: vertical; + font-family: inherit; + box-sizing: border-box; + margin-top: 4px; +} + +.gwt-modal-textarea:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.gwt-modal-from-hint { + font-size: 12px; + color: var(--text-muted); + margin: 0 0 10px; + font-family: var(--font-monospace); +} + +/* ── New branch buttons ──────────────────────────────────────────────────── */ + +.gwt-new-branch-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} + +.gwt-btn-sm { + font-size: 11px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + white-space: nowrap; + flex: 1; +} + +/* ── New branch modal ────────────────────────────────────────────────────── */ + +.gwt-modal h3 { + margin-top: 0; + margin-bottom: 12px; +} + +.gwt-modal-hint { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 12px; +} + +.gwt-modal-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.gwt-modal-label { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.gwt-modal-select, +.gwt-modal-input { + width: 100%; + font-size: 13px; + padding: 4px 8px; + border-radius: 4px; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); +} + +.gwt-modal-select:focus, +.gwt-modal-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.gwt-modal-btns { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 4px; +} + +/* ── Uncommitted files ───────────────────────────────────────────────────── */ + +.gwt-files-details { + margin-bottom: 8px; +} + +.gwt-files-summary { + cursor: pointer; + font-size: 12px; + user-select: none; + padding: 2px 0; + list-style: none; +} + +.gwt-files-summary::-webkit-details-marker { + display: none; +} + +.gwt-files-summary::before { + content: "▶ "; + font-size: 10px; + transition: transform 0.1s; +} + +details[open] .gwt-files-summary::before { + content: "▼ "; +} + +.gwt-files-list { + margin-top: 6px; + padding-left: 8px; +} + +.gwt-file-row { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 11px; + font-family: var(--font-monospace); + padding: 1px 0; +} + +.gwt-file-status { + font-weight: 700; + min-width: 16px; + text-align: center; + font-size: 10px; +} + +.gwt-status-M { color: var(--color-yellow); } +.gwt-status-A { color: var(--color-green); } +.gwt-status-D { color: var(--color-red); } +.gwt-status-R { color: var(--color-blue); } +.gwt-status-untracked { color: var(--text-muted); } +.gwt-status-UU { color: var(--color-orange); } + +.gwt-file-path { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Behind / pull ───────────────────────────────────────────────────────── */ + +.gwt-behind-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.gwt-behind-label { + color: var(--color-yellow); + font-size: 12px; + flex: 1; } .gwt-warning { color: var(--color-orange); } -.gwt-behind { - display: flex; - align-items: center; - gap: 8px; - color: var(--color-yellow); -} - .gwt-ok { color: var(--color-green); + font-size: 12px; } .gwt-btn { @@ -70,8 +373,49 @@ padding: 2px 8px; cursor: pointer; border-radius: 3px; + white-space: nowrap; } +.gwt-btn-pull { + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; +} + +.gwt-btn-pull:hover { + filter: brightness(1.1); +} + +/* ── Collapsible PR section ──────────────────────────────────────────────── */ + +.gwt-pr-collapsible-summary { + cursor: pointer; + list-style: none; + user-select: none; +} + +.gwt-pr-collapsible-summary::-webkit-details-marker { + display: none; +} + +.gwt-pr-collapsible-summary h5 { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.gwt-pr-collapsible-summary h5::before { + content: "▶"; + font-size: 9px; + color: var(--text-faint); +} + +details[open] .gwt-pr-collapsible-summary h5::before { + content: "▼"; +} + +/* ── PR list ─────────────────────────────────────────────────────────────── */ + .gwt-pr { margin-bottom: 6px; padding: 6px 8px; @@ -90,6 +434,7 @@ color: var(--text-normal); margin-bottom: 2px; line-height: 1.4; + font-size: 13px; } .gwt-pr-link:hover { @@ -110,6 +455,8 @@ font-style: italic; } +/* ── Misc ────────────────────────────────────────────────────────────────── */ + .gwt-empty { font-size: 12px; color: var(--text-faint); @@ -118,6 +465,7 @@ .gwt-muted { color: var(--text-muted); + font-size: 12px; } .gwt-error { @@ -126,3 +474,11 @@ margin-bottom: 8px; word-break: break-word; } + +.gwt-status-bar { + cursor: pointer; +} + +.gwt-status-bar:hover { + color: var(--text-normal); +}