gearheaded_obsidian-github-.../main.js

1152 lines
50 KiB
JavaScript

/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
ContributionsView: () => ContributionsView,
default: () => GitHubContributionsPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian = require("obsidian");
var VIEW_TYPE = "github-contributions";
var GITHUB_GRAPHQL = "https://api.github.com/graphql";
var GITHUB_CLIENT_ID = "Ov23litfj5GbQ8mw81VV";
var GITHUB_DEVICE_URL = "https://github.com/login/device/code";
var GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
var GITHUB_USER_URL = "https://api.github.com/user";
var MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
var DEFAULT_SETTINGS = {
authMethod: "oauth",
githubToken: "",
githubUsername: "",
dataSource: "github",
localRepoRoot: "",
scanDepth: 2,
demoMode: false,
showLegend: true,
sidebarSide: "right",
selectedYear: new Date().getFullYear(),
sizePreset: "medium",
defaultView: "year",
palette: "classic",
statsStyle: "default",
dailyNoteFolder: "",
dailyNoteDateFormat: "YYYY-MM-DD"
};
var PRESET_SIZES = {
"ultra-compact": { cell: 7, gap: 1 },
"compact": { cell: 9, gap: 1 },
"medium": { cell: 11, gap: 2 },
"large": { cell: 14, gap: 3 },
"fit": { cell: 0, gap: 2 }
// calculated at render time
};
var PALETTES = {
"classic": {
dark: ["var(--background-modifier-border)", "#0e4429", "#006d32", "#26a641", "#39d353"],
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]
},
"high-contrast": {
dark: ["var(--background-modifier-border)", "#1a5e2a", "#21a045", "#32d463", "#7fffb0"],
light: ["#ebedf0", "#b6f0c2", "#3dd668", "#1a9e40", "#0a5c25"]
},
"cobalt": {
dark: ["var(--background-modifier-border)", "#0a3a6b", "#0e6eb5", "#1ab3d8", "#57e8f5"],
light: ["#edf4fb", "#b3d4f0", "#4aa8e0", "#1478c8", "#064a8a"]
},
"neon": {
dark: ["var(--background-modifier-border)", "#2d0a4e", "#7b00c2", "#e0008c", "#ffe600"],
light: ["#f5eaff", "#c97df5", "#9400d3", "#d4006a", "#c8a800"]
},
"ember": {
dark: ["var(--background-modifier-border)", "#3d1f00", "#b45200", "#e8820c", "#ffc832"],
light: ["#f5f0e8", "#f5d5a0", "#e8820c", "#b45200", "#7a3200"]
}
};
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
async function requestDeviceCode() {
let res;
try {
res = await (0, import_obsidian.requestUrl)({
url: GITHUB_DEVICE_URL,
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: "read:user" })
});
} catch (e) {
throw new Error("Could not reach GitHub. Check your internet connection.");
}
if (res.status !== 200)
throw new Error(`GitHub returned an error (${res.status}). Try again.`);
return res.json;
}
async function pollForToken(deviceCode, intervalSecs, expiresIn, onCancel) {
var _a, _b;
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
let currentInterval = Math.max(intervalSecs, 5) * 1e3;
const deadline = Date.now() + expiresIn * 1e3;
while (true) {
if (onCancel())
throw new Error("Cancelled");
await delay(currentInterval);
if (onCancel())
throw new Error("Cancelled");
if (Date.now() > deadline)
throw new Error("Code expired. Please try again.");
let data;
try {
const res = await (0, import_obsidian.requestUrl)({
url: GITHUB_TOKEN_URL,
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
})
});
data = res.json;
} catch (e) {
await delay(5e3);
if (onCancel())
throw new Error("Cancelled");
continue;
}
if (data.access_token)
return data.access_token;
switch (data.error) {
case "authorization_pending":
continue;
case "slow_down":
currentInterval += 5e3;
continue;
case "expired_token":
throw new Error("Code expired. Please try again.");
case "access_denied":
throw new Error("Access was denied. You can try again anytime.");
case "incorrect_device_code":
throw new Error("Invalid device code. Please try again.");
default:
throw new Error((_b = (_a = data.error_description) != null ? _a : data.error) != null ? _b : "Unknown OAuth error");
}
}
}
async function fetchGitHubUsername(token) {
const res = await (0, import_obsidian.requestUrl)({
url: GITHUB_USER_URL,
headers: { Authorization: `bearer ${token}` }
});
if (res.status !== 200)
throw new Error("Could not fetch GitHub username");
return res.json.login;
}
async function fetchGitHubContributions(username, token, year) {
var _a, _b, _c, _d, _e, _f;
const from = `${year}-01-01T00:00:00Z`;
const to = `${year}-12-31T23:59:59Z`;
const query = `query($login:String!,$from:DateTime!,$to:DateTime!){user(login:$login){contributionsCollection(from:$from,to:$to){contributionCalendar{weeks{contributionDays{date contributionCount}}}}}}`;
const res = await fetch(GITHUB_GRAPHQL, {
method: "POST",
headers: { Authorization: `bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { login: username, from, to } })
});
if (!res.ok)
throw new Error(`GitHub API error: ${res.status}`);
const json = await res.json();
if (json.errors)
throw new Error((_b = (_a = json.errors[0]) == null ? void 0 : _a.message) != null ? _b : "GitHub API error");
const weeks = (_f = (_e = (_d = (_c = json == null ? void 0 : json.data) == null ? void 0 : _c.user) == null ? void 0 : _d.contributionsCollection) == null ? void 0 : _e.contributionCalendar) == null ? void 0 : _f.weeks;
if (!weeks)
throw new Error("No data returned. Check your username.");
const map = /* @__PURE__ */ new Map();
for (const week of weeks) {
for (const day of week.contributionDays) {
map.set(day.date, day.contributionCount);
}
}
return map;
}
var isDesktop = !!window.require;
function runGit(args, cwd) {
if (!isDesktop)
return "";
try {
const { execSync } = window.require("child_process");
return execSync(`git ${args}`, { cwd, timeout: 8e3, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
} catch (e) {
return "";
}
}
async function discoverRepos(rootPath, maxDepth = 2) {
if (!isDesktop)
return [];
const repos = [];
try {
const fs = window.require("fs");
const path = window.require("path");
const scanDir = (dir, depth) => {
if (maxDepth !== 0 && depth > maxDepth)
return;
let entries;
try {
entries = fs.readdirSync(dir);
} catch (e) {
return;
}
if (entries.includes(".git")) {
const name = path.basename(dir);
const lastCommitRaw = runGit(`log -1 --format=%ai`, dir).trim();
const lastCommit = lastCommitRaw ? lastCommitRaw.split(" ")[0] : null;
repos.push({ name, path: dir, lastCommit });
return;
}
for (const entry of entries) {
if (entry.startsWith("."))
continue;
const full = path.join(dir, entry);
try {
if (fs.statSync(full).isDirectory())
scanDir(full, depth + 1);
} catch (e) {
}
}
};
scanDir(rootPath, 0);
} catch (e) {
console.error("gh-contributions: repo discovery failed", e);
}
return repos;
}
function fetchLocalCommits(repo, year) {
var _a;
const map = /* @__PURE__ */ new Map();
const after = `${year - 1}-12-31`;
const before = `${year + 1}-01-01`;
const raw = runGit(
`log --after="${after}" --before="${before}" --format=%ad --date=format:%Y-%m-%d`,
repo.path
);
if (!raw)
return map;
for (const line of raw.split("\n")) {
const date = line.trim();
if (!date || !date.startsWith(String(year)))
continue;
map.set(date, ((_a = map.get(date)) != null ? _a : 0) + 1);
}
return map;
}
async function buildDayMap(settings, year, repos) {
var _a;
const days = /* @__PURE__ */ new Map();
const getOrCreate = (date) => {
if (!days.has(date))
days.set(date, { date, count: 0, githubCount: 0, localCount: 0, repos: {} });
return days.get(date);
};
let totalGH = 0, totalLocal = 0;
if (settings.dataSource === "github" || settings.dataSource === "both") {
if (settings.githubToken && settings.githubUsername) {
const ghMap = await fetchGitHubContributions(settings.githubUsername, settings.githubToken, year);
for (const [date, count] of ghMap) {
const d = getOrCreate(date);
d.githubCount = count;
d.count += count;
totalGH += count;
}
}
}
if (settings.dataSource === "local" || settings.dataSource === "both") {
for (const repo of repos) {
const commits = fetchLocalCommits(repo, year);
for (const [date, count] of commits) {
const d = getOrCreate(date);
d.localCount += count;
d.count += count;
d.repos[repo.name] = ((_a = d.repos[repo.name]) != null ? _a : 0) + count;
totalLocal += count;
}
}
}
return { days, totalGH, totalLocal, repoList: repos };
}
function buildYearWeeks(year, days) {
var _a;
const jan1 = new Date(year, 0, 1);
const startOffset = jan1.getDay();
const weeks = [];
let week = [];
for (let i = 0; i < startOffset; i++)
week.push({ date: "", count: 0, githubCount: 0, localCount: 0, repos: {} });
const isLeap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
const daysInYear = isLeap ? 366 : 365;
for (let i = 0; i < daysInYear; i++) {
const d = new Date(year, 0, i + 1);
const dateStr = `${year}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
week.push((_a = days.get(dateStr)) != null ? _a : { date: dateStr, count: 0, githubCount: 0, localCount: 0, repos: {} });
if (week.length === 7) {
weeks.push(week);
week = [];
}
}
if (week.length > 0) {
while (week.length < 7)
week.push({ date: "", count: 0, githubCount: 0, localCount: 0, repos: {} });
weeks.push(week);
}
return weeks;
}
function buildMonthDays(year, month, days) {
var _a;
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const weeks = [];
let week = [];
for (let i = 0; i < firstDay; i++)
week.push({ date: "", count: 0, githubCount: 0, localCount: 0, repos: {} });
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(i).padStart(2, "0")}`;
week.push((_a = days.get(dateStr)) != null ? _a : { date: dateStr, count: 0, githubCount: 0, localCount: 0, repos: {} });
if (week.length === 7) {
weeks.push(week);
week = [];
}
}
if (week.length > 0) {
while (week.length < 7)
week.push({ date: "", count: 0, githubCount: 0, localCount: 0, repos: {} });
weeks.push(week);
}
return weeks;
}
function calculateStreaks(days, year) {
var _a, _b, _c, _d, _e, _f;
const isLeap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
const daysInYear = isLeap ? 366 : 365;
const today = (0, import_obsidian.moment)().format("YYYY-MM-DD");
const allDates = [];
for (let i = 0; i < daysInYear; i++) {
const d = new Date(year, 0, i + 1);
allDates.push(`${year}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`);
}
let longest = 0, run = 0;
for (const date of allDates) {
const count = (_b = (_a = days.get(date)) == null ? void 0 : _a.count) != null ? _b : 0;
if (count > 0) {
run++;
if (run > longest)
longest = run;
} else
run = 0;
}
let current = 0;
const past = allDates.filter((d) => d <= today).reverse();
let skipFirst = past.length > 0 && ((_d = (_c = days.get(past[0])) == null ? void 0 : _c.count) != null ? _d : 0) === 0;
for (const date of past) {
if (skipFirst) {
skipFirst = false;
continue;
}
if (((_f = (_e = days.get(date)) == null ? void 0 : _e.count) != null ? _f : 0) > 0)
current++;
else
break;
}
return { current, longest };
}
function daysSinceLastCommit(days) {
const today = (0, import_obsidian.moment)().format("YYYY-MM-DD");
const sorted = [...days.entries()].filter(([d]) => d <= today && d !== "").sort((a, b) => b[0].localeCompare(a[0]));
for (const [date, data] of sorted) {
if (data.count > 0) {
return (0, import_obsidian.moment)(today).diff((0, import_obsidian.moment)(date), "days");
}
}
return null;
}
function buildDemoData(year) {
const days = /* @__PURE__ */ new Map();
const isLeap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
const daysInYear = isLeap ? 366 : 365;
let seed = 42;
const rand = () => {
seed = seed * 1664525 + 1013904223 & 4294967295;
return (seed >>> 0) / 4294967295;
};
const demoRepos = ["my-project", "obsidian-plugin", "dotfiles"];
for (let i = 0; i < daysInYear; i++) {
const d = new Date(year, 0, i + 1);
const dateStr = `${year}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const r = rand();
const recencyBoost = i / daysInYear;
const active = r < 0.45 + recencyBoost * 0.3;
if (!active)
continue;
const count = Math.floor(rand() * 8) + 1;
const repos = {};
let remaining = count;
for (const repo of demoRepos) {
if (remaining <= 0)
break;
const n = Math.min(remaining, Math.floor(rand() * 4) + 1);
if (rand() > 0.4) {
repos[repo] = n;
remaining -= n;
}
}
days.set(dateStr, { date: dateStr, count, githubCount: count, localCount: 0, repos });
}
return days;
}
function mostRecentRepo(repos) {
var _a, _b;
if (!repos.length)
return null;
const sorted = [...repos].filter((r) => r.lastCommit).sort((a, b) => {
var _a2, _b2;
return ((_a2 = b.lastCommit) != null ? _a2 : "").localeCompare((_b2 = a.lastCommit) != null ? _b2 : "");
});
return (_b = (_a = sorted[0]) == null ? void 0 : _a.name) != null ? _b : null;
}
var ContributionsView = class extends import_obsidian.ItemView {
constructor(leaf, plugin) {
super(leaf);
this.tooltipEl = null;
this.resizeObserver = null;
this.panelWidth = 0;
this.plugin = plugin;
this.displayYear = plugin.settings.selectedYear;
this.displayMonth = new Date().getMonth();
this.viewMode = plugin.settings.defaultView;
}
getViewType() {
return VIEW_TYPE;
}
getDisplayText() {
return "GitHub Contributions";
}
getIcon() {
return "github";
}
async onOpen() {
this.resizeObserver = new ResizeObserver(() => {
const w = this.containerEl.clientWidth;
if (Math.abs(w - this.panelWidth) > 5) {
this.panelWidth = w;
if (this.plugin.settings.sizePreset === "fit")
this.render();
}
});
this.resizeObserver.observe(this.containerEl);
this.panelWidth = this.containerEl.clientWidth;
await this.render();
}
async onClose() {
var _a, _b;
(_a = this.tooltipEl) == null ? void 0 : _a.remove();
this.tooltipEl = null;
(_b = this.resizeObserver) == null ? void 0 : _b.disconnect();
}
async refresh() {
this.displayYear = this.plugin.settings.selectedYear;
this.viewMode = this.plugin.settings.defaultView;
await this.render();
}
getCellSize() {
const preset = this.plugin.settings.sizePreset;
if (preset !== "fit")
return PRESET_SIZES[preset];
const cols = this.viewMode === "year" ? 53 : 7;
const gap = 2;
const available = Math.max(100, (this.panelWidth || 260) - 28);
const cell = Math.max(5, Math.floor((available - gap * (cols - 1)) / cols));
return { cell, gap };
}
async render() {
var _a;
const container = this.containerEl.children[1];
container.empty();
container.addClass("gh-contributions-view");
if (!this.tooltipEl) {
this.tooltipEl = document.body.createDiv({ cls: "gh-tooltip" });
}
const s = this.plugin.settings;
const needsGH = s.dataSource === "github" || s.dataSource === "both";
const needsLocal = s.dataSource === "local" || s.dataSource === "both";
if (needsGH && (!s.githubToken || !s.githubUsername)) {
this.renderEmpty(container, "github");
return;
}
if (needsLocal && !s.localRepoRoot) {
this.renderEmpty(container, "local");
return;
}
this.renderSkeleton(container);
try {
let repos = [];
if (needsLocal && s.localRepoRoot) {
repos = await discoverRepos(s.localRepoRoot, s.scanDepth);
}
let days;
let totalGH = 0, totalLocal = 0;
if (s.demoMode) {
days = buildDemoData(this.displayYear);
totalGH = [...days.values()].reduce((a, d) => a + d.githubCount, 0);
} else {
const result = await buildDayMap(s, this.displayYear, repos);
days = result.days;
totalGH = result.totalGH;
totalLocal = result.totalLocal;
}
container.empty();
const streaks = calculateStreaks(days, this.displayYear);
const sinceCommit = daysSinceLastCommit(days);
const recentRepo = s.demoMode ? "my-project" : mostRecentRepo(repos);
this.renderHeader(container);
this.renderStats(container, { totalGH, totalLocal, streaks, sinceCommit, recentRepo });
const style = (_a = this.plugin.settings.statsStyle) != null ? _a : "default";
if (recentRepo && style === "default") {
const repoLine = container.createDiv({ cls: "gh-repo-line" });
repoLine.createEl("span", { cls: "gh-repo-line-icon", text: "\u{1F4C1} " });
repoLine.createEl("span", { cls: "gh-repo-line-name", text: recentRepo });
}
if (this.viewMode === "year") {
const weeks = buildYearWeeks(this.displayYear, days);
this.renderYearGrid(container, weeks);
} else {
const weeks = buildMonthDays(this.displayYear, this.displayMonth, days);
this.renderMonthGrid(container, weeks);
}
if (this.plugin.settings.showLegend)
this.renderLegend(container);
} catch (e) {
container.empty();
this.renderError(container, e.message);
}
}
renderHeader(container) {
const s = this.plugin.settings;
const header = container.createDiv({ cls: "gh-header" });
if (s.githubUsername)
header.createEl("span", { cls: "gh-username", text: s.githubUsername });
const nav = header.createDiv({ cls: "gh-nav" });
const currentYear = new Date().getFullYear();
if (this.viewMode === "year") {
const prev = nav.createEl("button", { cls: "gh-nav-btn", text: "\u2039" });
nav.createEl("span", { cls: "gh-nav-label", text: String(this.displayYear) });
const next = nav.createEl("button", { cls: "gh-nav-btn", text: "\u203A" });
next.disabled = this.displayYear >= currentYear;
prev.onclick = async () => {
this.displayYear--;
await this.render();
};
next.onclick = async () => {
if (this.displayYear < currentYear) {
this.displayYear++;
await this.render();
}
};
} else {
const prev = nav.createEl("button", { cls: "gh-nav-btn", text: "\u2039" });
nav.createEl("span", { cls: "gh-nav-label", text: `${MONTHS_SHORT[this.displayMonth]} ${this.displayYear}` });
const next = nav.createEl("button", { cls: "gh-nav-btn", text: "\u203A" });
const isLatest = this.displayYear >= currentYear && this.displayMonth >= new Date().getMonth();
next.disabled = isLatest;
prev.onclick = async () => {
if (this.displayMonth === 0) {
this.displayMonth = 11;
this.displayYear--;
} else
this.displayMonth--;
await this.render();
};
next.onclick = async () => {
if (isLatest)
return;
if (this.displayMonth === 11) {
this.displayMonth = 0;
this.displayYear++;
} else
this.displayMonth++;
await this.render();
};
}
const refreshBtn = nav.createEl("button", { cls: "gh-nav-btn gh-refresh-icon", text: "\u21BB" });
refreshBtn.title = "Refresh";
refreshBtn.onclick = () => this.render();
}
renderStats(container, info) {
var _a;
const s = this.plugin.settings;
const style = (_a = s.statsStyle) != null ? _a : "default";
const total = info.totalGH + info.totalLocal;
const showLocal = s.dataSource === "local" || s.dataSource === "both";
if (style === "compact") {
const row = container.createDiv({ cls: "gh-stats-compact" });
const items = [
["\u2191", String(total), "Total contributions"],
["\u{1F525}", info.streaks.current + "d", "Current streak"],
["\u2B50", info.streaks.longest + "d", "Best streak"]
];
if (showLocal && info.sinceCommit !== null) {
const sinceLabel = info.sinceCommit === 0 ? "committed today" : info.sinceCommit + "d ago";
items.push(["\u23F1", info.sinceCommit === 0 ? "today" : info.sinceCommit + "d", sinceLabel]);
}
if (info.recentRepo)
items.push(["\u{1F4C1}", info.recentRepo, info.recentRepo]);
for (const [icon, val, title] of items) {
const chip = row.createEl("span", { cls: "gh-chip", title });
chip.createEl("span", { cls: "gh-chip-icon", text: icon });
chip.createEl("span", { cls: "gh-chip-val", text: val });
}
} else if (style === "grid") {
const stats = container.createDiv({ cls: "gh-stats gh-stats--grid" });
this.pill(stats, String(total), "contributions");
this.pill(stats, info.streaks.current + "d", "streak");
this.pill(stats, info.streaks.longest + "d", "best");
if (showLocal && info.sinceCommit !== null)
this.pill(stats, info.sinceCommit === 0 ? "today" : info.sinceCommit + "d ago", info.sinceCommit === 0 ? "committed" : "since commit");
if (info.recentRepo) {
const maxLen = 16;
const name = info.recentRepo.length > maxLen ? info.recentRepo.slice(0, maxLen - 1) + "\u2026" : info.recentRepo;
const pill = stats.createDiv({ cls: "gh-stat gh-stat--wide" });
pill.title = info.recentRepo;
pill.createEl("span", { cls: "gh-stat-val", text: name });
pill.createEl("span", { cls: "gh-stat-lbl", text: "recent repo" });
}
} else {
const list = container.createDiv({ cls: "gh-stats-list" });
const items = [
["\u2191", String(total), "contributions"],
["\u{1F525}", info.streaks.current + "d", "streak"],
["\u2B50", info.streaks.longest + "d", "best"]
];
if (showLocal && info.sinceCommit !== null) {
const val = info.sinceCommit === 0 ? "today" : info.sinceCommit + "d ago";
const lbl = info.sinceCommit === 0 ? "committed today" : "since commit";
items.push(["\u23F1", val, lbl]);
}
for (const [icon, val, label] of items) {
const row = list.createDiv({ cls: "gh-stats-list-row" });
row.createEl("span", { cls: "gh-stats-list-icon", text: icon });
row.createEl("span", { cls: "gh-stats-list-val", text: val });
row.createEl("span", { cls: "gh-stats-list-lbl", text: " " + label });
}
}
}
pill(parent, value, label) {
const p = parent.createDiv({ cls: "gh-stat" });
p.createEl("span", { cls: "gh-stat-val", text: value });
p.createEl("span", { cls: "gh-stat-lbl", text: label });
}
renderYearGrid(container, weeks) {
const { cell, gap } = this.getCellSize();
const graphWrap = container.createDiv({ cls: "gh-graph-wrap" });
graphWrap.style.overflowX = "auto";
const monthRow = graphWrap.createDiv({ cls: "gh-month-row" });
monthRow.style.cssText = `display:grid;grid-template-columns:repeat(${weeks.length},${cell}px);gap:${gap}px;margin-bottom:3px`;
let lastMonth = -1;
weeks.forEach((week, wi) => {
const first = week.find((d) => d.date);
if (!first)
return;
const m = new Date(first.date).getUTCMonth();
if (m !== lastMonth) {
lastMonth = m;
const lbl = monthRow.createEl("span", { cls: "gh-month-lbl", text: MONTHS_SHORT[m] });
lbl.style.gridColumn = String(wi + 1);
lbl.style.fontSize = Math.max(8, cell - 2) + "px";
}
});
const grid = graphWrap.createDiv({ cls: "gh-grid" });
grid.style.cssText = `display:flex;gap:${gap}px`;
weeks.forEach((week) => {
const col = grid.createDiv({ cls: "gh-col" });
col.style.cssText = `display:flex;flex-direction:column;gap:${gap}px`;
week.forEach((day) => this.renderCell(col, day, cell));
});
}
renderMonthGrid(container, weeks) {
const { cell, gap } = this.getCellSize();
const graphWrap = container.createDiv({ cls: "gh-graph-wrap" });
const colTemplate = `repeat(7,${cell}px)`;
const dowRow = graphWrap.createDiv({ cls: "gh-dow-row" });
dowRow.style.cssText = `display:grid;grid-template-columns:${colTemplate};gap:${gap}px;margin-bottom:4px`;
["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].forEach((d) => {
const lbl = dowRow.createEl("span", { cls: "gh-dow-lbl", text: d });
lbl.style.cssText = `font-size:10px;color:var(--text-faint);text-align:center;overflow:hidden`;
});
const grid = graphWrap.createDiv({ cls: "gh-month-grid" });
grid.style.cssText = `display:flex;flex-direction:column;gap:${gap}px`;
weeks.forEach((week) => {
const row = grid.createDiv({ cls: "gh-month-row-cells" });
row.style.cssText = `display:grid;grid-template-columns:${colTemplate};gap:${gap}px`;
week.forEach((day) => this.renderCell(row, day, cell));
});
}
renderCell(parent, day, size) {
const cell = parent.createDiv({ cls: "gh-cell" });
cell.style.cssText = `width:${size}px;height:${size}px`;
if (!day.date) {
cell.addClass("gh-cell--empty");
return;
}
const today = (0, import_obsidian.moment)().format("YYYY-MM-DD");
cell.dataset.level = String(countToLevel(day.count));
if (day.date === today)
cell.addClass("gh-today");
cell.addEventListener("mouseenter", (e) => this.showTooltip(e, day));
cell.addEventListener("mouseleave", () => {
if (this.tooltipEl)
this.tooltipEl.style.display = "none";
});
cell.addEventListener("click", () => this.openOrCreateDailyNote(day.date));
}
showTooltip(e, day) {
if (!this.tooltipEl)
return;
const s = this.plugin.settings;
const dateStr = (0, import_obsidian.moment)(day.date).format("YYYY-MM-DD");
const lines = [dateStr];
if (day.count === 0) {
lines.push("No contributions");
} else {
lines.push(`${day.count} contribution${day.count !== 1 ? "s" : ""}`);
if (s.dataSource === "both") {
if (day.githubCount > 0)
lines.push(` GitHub: ${day.githubCount}`);
if (day.localCount > 0)
lines.push(` Local: ${day.localCount}`);
}
const repoEntries = Object.entries(day.repos).sort((a, b) => b[1] - a[1]);
for (const [repo, count] of repoEntries) {
lines.push(` ${repo} (${count})`);
}
}
this.tooltipEl.empty();
lines.forEach((line, i) => {
if (i > 0)
this.tooltipEl.createEl("br");
if (i === 0) {
this.tooltipEl.createEl("strong", { text: line });
} else {
this.tooltipEl.appendText(line);
}
});
this.tooltipEl.style.display = "block";
this.tooltipEl.style.top = e.pageY - 34 + "px";
const tooltipWidth = this.tooltipEl.offsetWidth || 180;
const spaceOnRight = window.innerWidth - e.pageX;
if (spaceOnRight < tooltipWidth + 20) {
this.tooltipEl.style.left = e.pageX - tooltipWidth - 12 + "px";
} else {
this.tooltipEl.style.left = e.pageX + 12 + "px";
}
}
renderLegend(container) {
const { cell, gap } = this.getCellSize();
const legend = container.createDiv({ cls: "gh-legend" });
legend.createEl("span", { cls: "gh-legend-lbl", text: "Less" });
for (let i = 0; i <= 4; i++) {
const sq = legend.createDiv({ cls: "gh-cell gh-legend-cell" });
sq.dataset.level = String(i);
sq.style.cssText = `width:${cell}px;height:${cell}px`;
}
legend.createEl("span", { cls: "gh-legend-lbl", text: "More" });
}
renderEmpty(container, missing) {
const wrap = container.createDiv({ cls: "gh-empty" });
wrap.createEl("div", { cls: "gh-empty-icon", text: "\u{1F419}" });
const msg = missing === "github" ? "Connect your GitHub account in Settings to see your contribution graph." : "Set a local repo root folder in Settings to scan for git commits.";
wrap.createEl("p", { text: msg });
const btn = wrap.createEl("button", { cls: "gh-btn", text: "Open Settings" });
btn.onclick = () => {
this.app.setting.open();
this.app.setting.openTabById("github-contributions");
};
}
renderSkeleton(container) {
const wrap = container.createDiv({ cls: "gh-skeleton-wrap" });
wrap.createDiv({ cls: "gh-skeleton gh-skeleton-header" });
wrap.createDiv({ cls: "gh-skeleton gh-skeleton-stats" });
const grid = wrap.createDiv({ cls: "gh-skeleton-grid" });
const cols = this.viewMode === "year" ? 53 : 7;
const rows = this.viewMode === "year" ? 7 : 6;
const { cell, gap } = this.getCellSize();
grid.style.cssText = `display:grid;grid-template-columns:repeat(${cols},${cell}px);grid-template-rows:repeat(${rows},${cell}px);gap:${gap}px`;
for (let i = 0; i < cols * rows; i++)
grid.createDiv({ cls: "gh-skeleton gh-skeleton-cell" });
}
renderError(container, msg) {
container.createDiv({ cls: "gh-error", text: `\u26A0 ${msg}` });
}
async openOrCreateDailyNote(date) {
const s = this.plugin.settings;
const fmt = s.dailyNoteDateFormat || "YYYY-MM-DD";
const folder = s.dailyNoteFolder ? s.dailyNoteFolder.replace(/\/$/, "") + "/" : "";
const name = (0, import_obsidian.moment)(date).format(fmt);
const filePath = `${folder}${name}.md`;
let file = this.app.vault.getAbstractFileByPath(filePath);
if (!file) {
try {
file = await this.app.vault.create(filePath, `# ${name}
`);
new import_obsidian.Notice(`Created: ${name}`);
} catch (err) {
new import_obsidian.Notice(`Error creating note: ${err.message}`);
return;
}
}
await this.app.workspace.getLeaf(false).openFile(file);
}
};
function countToLevel(n) {
if (n === 0)
return 0;
if (n <= 2)
return 1;
if (n <= 5)
return 2;
if (n <= 9)
return 3;
return 4;
}
var GitHubContributionsSettingTab = class extends import_obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "GitHub Contributions" });
containerEl.createEl("h3", { text: "Data Sources" });
new import_obsidian.Setting(containerEl).setName("Source").setDesc("Which contributions to display").addDropdown(
(d) => d.addOption("github", "GitHub only").addOption("local", "Local git only").addOption("both", "Both").setValue(this.plugin.settings.dataSource).onChange(async (v) => {
this.plugin.settings.dataSource = v;
await this.plugin.saveSettings();
this.display();
})
);
const src = this.plugin.settings.dataSource;
if (src === "github" || src === "both") {
new import_obsidian.Setting(containerEl).setName("Authentication").setDesc("Connect GitHub to fetch your contribution data").addDropdown(
(d) => d.addOption("oauth", "Connect with GitHub (recommended)").addOption("pat", "Personal Access Token (advanced)").setValue(this.plugin.settings.authMethod).onChange(async (v) => {
this.plugin.settings.authMethod = v;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.authMethod === "oauth") {
const isConnected = !!this.plugin.settings.githubToken && !!this.plugin.settings.githubUsername;
if (isConnected) {
new import_obsidian.Setting(containerEl).setName("Connected as " + this.plugin.settings.githubUsername).setDesc("Your GitHub account is connected via OAuth").addButton(
(btn) => btn.setButtonText("Disconnect").setWarning().onClick(async () => {
this.plugin.settings.githubToken = "";
this.plugin.settings.githubUsername = "";
await this.plugin.saveSettings();
this.display();
})
);
} else {
let cancelled = false;
const oauthSetting = new import_obsidian.Setting(containerEl).setName("Connect GitHub account").setDesc("Click to start the authorization flow");
oauthSetting.addButton(
(btn) => btn.setButtonText("Connect GitHub").setCta().onClick(async () => {
btn.setButtonText("Connecting...").setDisabled(true);
cancelled = false;
try {
const device = await requestDeviceCode();
const expiryMins = Math.floor(device.expires_in / 60);
oauthSetting.setDesc(
createFragment((f) => {
f.appendText("Enter this code at ");
f.createEl("strong", { text: "github.com/login/device" });
f.createEl("br");
f.createEl("span", { cls: "gh-oauth-code", text: device.user_code });
f.createEl("br");
f.createEl("em", { text: `Waiting for approval\u2026 (code expires in ${expiryMins} minutes)` });
})
);
window.open(device.verification_uri);
const token = await pollForToken(device.device_code, device.interval, device.expires_in, () => cancelled);
const username = await fetchGitHubUsername(token);
this.plugin.settings.githubToken = token;
this.plugin.settings.githubUsername = username;
await this.plugin.saveSettings();
new import_obsidian.Notice("Connected to GitHub as " + username);
this.display();
} catch (e) {
const msg = e.message;
if (msg === "Cancelled") {
oauthSetting.setDesc("Connection cancelled. Click Connect GitHub to try again.");
} else {
oauthSetting.setDesc("\u26A0 " + msg);
new import_obsidian.Notice("GitHub connection failed: " + msg);
}
btn.setButtonText("Connect GitHub").setDisabled(false);
}
})
);
oauthSetting.addButton(
(btn) => btn.setButtonText("Cancel").onClick(() => {
cancelled = true;
oauthSetting.setDesc("Cancelled.");
btn.setDisabled(true);
})
);
}
} else {
new import_obsidian.Setting(containerEl).setName("GitHub username").addText((t) => t.setPlaceholder("username").setValue(this.plugin.settings.githubUsername).onChange(async (v) => {
this.plugin.settings.githubUsername = v.trim();
await this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Personal Access Token").setDesc("PAT with read:user scope \u2014 github.com/settings/tokens").addText((t) => {
t.inputEl.type = "password";
t.setPlaceholder("ghp_\u2026").setValue(this.plugin.settings.githubToken).onChange(async (v) => {
this.plugin.settings.githubToken = v.trim();
await this.plugin.saveSettings();
});
});
}
}
if (src === "local" || src === "both") {
if (!isDesktop) {
containerEl.createEl("p", { cls: "gh-settings-notice", text: "\u26A0 Local git scanning is not available on mobile." });
} else {
new import_obsidian.Setting(containerEl).setName("Local repo root").setDesc("Folder to scan for git repositories").addText((t) => {
t.setPlaceholder("C:\\Users\\You\\Projects").setValue(this.plugin.settings.localRepoRoot);
t.onChange((v) => {
this.plugin.settings.localRepoRoot = v.trim();
});
}).addButton(
(btn) => btn.setButtonText("Scan").setTooltip("Save path and scan for repositories").onClick(async () => {
btn.setButtonText("Scanning\u2026").setDisabled(true);
await this.plugin.saveSettings();
btn.setButtonText("Scan").setDisabled(false);
new import_obsidian.Notice("Repo scan complete \u2014 refresh the panel to see results");
})
);
new import_obsidian.Setting(containerEl).setName("Scan depth").setDesc("How many folder levels deep to search for git repos").addDropdown(
(d) => d.addOption("2", "2 levels").addOption("3", "3 levels").addOption("4", "4 levels").addOption("5", "5 levels").addOption("0", "Unlimited (slow on large drives)").setValue(String(this.plugin.settings.scanDepth)).onChange(async (v) => {
this.plugin.settings.scanDepth = parseInt(v);
await this.plugin.saveSettings();
})
);
}
}
containerEl.createEl("h3", { text: "Display" });
new import_obsidian.Setting(containerEl).setName("Sidebar side").addDropdown((d) => d.addOption("left", "Left").addOption("right", "Right").setValue(this.plugin.settings.sidebarSide).onChange(async (v) => {
this.plugin.settings.sidebarSide = v;
await this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Stats display").setDesc("How the stats bar is shown").addDropdown(
(d) => d.addOption("default", "Default (pills with labels)").addOption("compact", "Compact (icons + numbers)").addOption("grid", "Grid (2-column)").setValue(this.plugin.settings.statsStyle).onChange(async (v) => {
this.plugin.settings.statsStyle = v;
await this.plugin.saveSettings();
})
);
new import_obsidian.Setting(containerEl).setName("Colour palette").setDesc("Colour scheme for contribution cells").addDropdown(
(d) => d.addOption("classic", "Classic (GitHub greens)").addOption("high-contrast", "High contrast (vivid greens)").addOption("cobalt", "Cobalt (blue to cyan)").addOption("neon", "Neon (purple to yellow)").addOption("ember", "Ember (amber to gold)").setValue(this.plugin.settings.palette).onChange(async (v) => {
this.plugin.settings.palette = v;
await this.plugin.saveSettings();
})
);
new import_obsidian.Setting(containerEl).setName("Size").setDesc('Cell size. "Fit to sidebar" auto-sizes to fill the panel width.').addDropdown((d) => d.addOption("ultra-compact", "Ultra compact").addOption("compact", "Compact").addOption("medium", "Medium").addOption("large", "Large").addOption("fit", "Fit to sidebar").setValue(this.plugin.settings.sizePreset).onChange(async (v) => {
this.plugin.settings.sizePreset = v;
await this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Default view").addDropdown((d) => d.addOption("year", "Year").addOption("month", "Month").setValue(this.plugin.settings.defaultView).onChange(async (v) => {
this.plugin.settings.defaultView = v;
await this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Default year").addText((t) => t.setPlaceholder(String(new Date().getFullYear())).setValue(String(this.plugin.settings.selectedYear)).onChange(async (v) => {
const y = parseInt(v);
if (!isNaN(y) && y >= 2008 && y <= new Date().getFullYear()) {
this.plugin.settings.selectedYear = y;
await this.plugin.saveSettings();
}
}));
new import_obsidian.Setting(containerEl).setName("Demo mode").setDesc("Show fake contribution data \u2014 useful for screenshots or testing").addToggle((t) => t.setValue(this.plugin.settings.demoMode).onChange(async (v) => {
this.plugin.settings.demoMode = v;
await this.plugin.saveSettings();
}));
new import_obsidian.Setting(containerEl).setName("Show legend").setDesc("Show the Less / More colour legend below the graph").addToggle((t) => t.setValue(this.plugin.settings.showLegend).onChange(async (v) => {
this.plugin.settings.showLegend = v;
await this.plugin.saveSettings();
}));
containerEl.createEl("h3", { text: "Daily Notes" });
new import_obsidian.Setting(containerEl).setName("Daily notes folder").setDesc("Leave blank for vault root").addText((t) => {
t.setPlaceholder("Daily Notes/").setValue(this.plugin.settings.dailyNoteFolder);
t.onChange(debounce(async (v) => {
this.plugin.settings.dailyNoteFolder = v;
await this.plugin.saveSettings();
}, 800));
});
new import_obsidian.Setting(containerEl).setName("Date format").setDesc("Moment.js format for filenames. Default: YYYY-MM-DD").addText((t) => {
t.setPlaceholder("YYYY-MM-DD").setValue(this.plugin.settings.dailyNoteDateFormat);
t.onChange(debounce(async (v) => {
this.plugin.settings.dailyNoteDateFormat = v;
await this.plugin.saveSettings();
}, 800));
});
}
};
var GitHubContributionsPlugin = class extends import_obsidian.Plugin {
constructor() {
super(...arguments);
this.settings = { ...DEFAULT_SETTINGS };
}
async onload() {
await this.loadSettings();
this.addSettingTab(new GitHubContributionsSettingTab(this.app, this));
this.registerView(VIEW_TYPE, (leaf) => new ContributionsView(leaf, this));
this.addRibbonIcon("github", "GitHub Contributions", () => this.activateView());
this.addCommand({ id: "open-github-contributions", name: "Open GitHub Contributions panel", callback: () => this.activateView() });
this.injectStyles();
this.injectPaletteStyles();
}
onunload() {
var _a, _b;
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
(_a = document.getElementById("gh-contributions-styles")) == null ? void 0 : _a.remove();
(_b = document.getElementById("gh-palette-styles")) == null ? void 0 : _b.remove();
document.querySelectorAll(".gh-tooltip").forEach((el) => el.remove());
}
async activateView() {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE);
if (existing.length > 0) {
this.app.workspace.revealLeaf(existing[0]);
return;
}
const leaf = this.settings.sidebarSide === "left" ? this.app.workspace.getLeftLeaf(false) : this.app.workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: VIEW_TYPE, active: true });
this.app.workspace.revealLeaf(leaf);
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
if (this.settings.selectedYear > new Date().getFullYear())
this.settings.selectedYear = new Date().getFullYear();
}
async saveSettings() {
await this.saveData(this.settings);
this.injectPaletteStyles();
this.app.workspace.getLeavesOfType(VIEW_TYPE).forEach((leaf) => {
if (leaf.view instanceof ContributionsView)
leaf.view.refresh();
});
}
injectPaletteStyles() {
var _a;
const existing = document.getElementById("gh-palette-styles");
if (existing)
existing.remove();
const p = (_a = PALETTES[this.settings.palette]) != null ? _a : PALETTES["classic"];
const style = document.createElement("style");
style.id = "gh-palette-styles";
style.textContent = `
body{--gh-c0:${p.dark[0]};--gh-c1:${p.dark[1]};--gh-c2:${p.dark[2]};--gh-c3:${p.dark[3]};--gh-c4:${p.dark[4]}}
body.theme-light{--gh-c0:${p.light[0]};--gh-c1:${p.light[1]};--gh-c2:${p.light[2]};--gh-c3:${p.light[3]};--gh-c4:${p.light[4]}}
`;
document.head.appendChild(style);
}
injectStyles() {
if (document.getElementById("gh-contributions-styles"))
return;
const style = document.createElement("style");
style.id = "gh-contributions-styles";
style.textContent = `
.gh-contributions-view{padding:12px 10px 16px;overflow-y:auto;overflow-x:hidden;user-select:none}
.gh-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;gap:6px}
.gh-username{font-size:13px;font-weight:600;color:var(--text-normal);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
.gh-nav{display:flex;align-items:center;gap:3px;flex-shrink:0}
.gh-nav-label{font-size:12px;color:var(--text-muted);min-width:52px;text-align:center;white-space:nowrap}
.gh-nav-btn{background:none;border:1px solid var(--background-modifier-border);border-radius:4px;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:1px 6px;transition:background .15s}
.gh-nav-btn:hover:not(:disabled){background:var(--background-modifier-hover);color:var(--text-normal)}
.gh-nav-btn:disabled{opacity:.3;cursor:default}
.gh-stats{display:flex;gap:5px;margin-bottom:10px;flex-wrap:wrap}
.gh-stats--grid{display:grid!important;grid-template-columns:1fr 1fr}
.gh-stat{display:flex;flex-direction:column;align-items:center;background:var(--background-secondary);border-radius:6px;padding:5px 7px;flex:1;min-width:0}
.gh-stats--grid .gh-stat{flex:unset}
.gh-stat--wide{grid-column:1 / -1}
.gh-stat-val{font-size:13px;font-weight:700;color:var(--interactive-accent);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
.gh-stat-lbl{font-size:9px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.04em;margin-top:2px;text-align:center}
.gh-stats-compact{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;align-items:center}
.gh-chip{display:inline-flex;align-items:center;gap:3px;background:var(--background-secondary);border-radius:4px;padding:3px 6px;font-size:11px;white-space:nowrap}
.gh-chip-icon{font-size:10px}
.gh-chip-val{font-weight:700;color:var(--interactive-accent)}
.gh-stats-list{display:flex;flex-direction:column;gap:1px;margin-bottom:8px}
.gh-stats-list-row{display:flex;align-items:baseline;gap:4px;font-size:11px;line-height:1.6}
.gh-stats-list-icon{font-size:10px;width:14px;text-align:center;flex-shrink:0}
.gh-stats-list-val{font-weight:700;color:var(--interactive-accent);font-size:12px}
.gh-stats-list-lbl{color:var(--text-muted);font-size:11px}
.gh-oauth-code{display:inline-block;font-size:22px;font-weight:700;letter-spacing:4px;color:var(--interactive-accent);font-family:var(--font-monospace);margin:6px 0;padding:4px 8px;background:var(--background-secondary);border-radius:6px}
.gh-repo-line{margin-bottom:6px;padding-bottom:5px;border-bottom:1px solid var(--background-modifier-border)}
.gh-repo-line-icon{font-size:10px;color:var(--text-faint)}
.gh-repo-line-name{font-size:11px;color:var(--text-muted)}
.gh-graph-wrap{overflow-x:auto;padding-bottom:4px}
.gh-month-lbl{font-size:9px;color:var(--text-faint)}
.gh-cell{border-radius:2px;cursor:pointer;transition:transform .1s;flex-shrink:0;box-sizing:border-box}
.gh-cell:hover{transform:scale(1.3);z-index:1;position:relative}
.gh-cell--empty{background:transparent!important;cursor:default!important}
.gh-cell--empty:hover{transform:none!important}
.gh-today{outline:2px solid var(--interactive-accent)!important;outline-offset:1px}
.gh-cell[data-level="0"]{background:var(--gh-c0)}
.gh-cell[data-level="1"]{background:var(--gh-c1)}
.gh-cell[data-level="2"]{background:var(--gh-c2)}
.gh-cell[data-level="3"]{background:var(--gh-c3)}
.gh-cell[data-level="4"]{background:var(--gh-c4)}
.gh-legend{display:flex;align-items:center;gap:3px;margin-top:8px;justify-content:flex-end}
.gh-legend-lbl{font-size:9px;color:var(--text-faint)}
.gh-legend-cell{cursor:default!important}
.gh-legend-cell:hover{transform:none!important}
.gh-refresh-icon{margin-left:2px;font-size:13px!important}
.gh-empty{display:flex;flex-direction:column;align-items:center;padding:24px 12px;text-align:center;gap:10px}
.gh-empty-icon{font-size:32px}
.gh-empty p{font-size:12px;color:var(--text-muted);line-height:1.5;margin:0}
.gh-btn{background:var(--interactive-accent);border:none;border-radius:6px;color:var(--text-on-accent);cursor:pointer;font-size:12px;padding:6px 14px}
.gh-btn:hover{filter:brightness(1.1)}
.gh-error{padding:10px 12px;background:var(--background-modifier-error);border-radius:6px;color:var(--text-error);font-size:12px;margin:8px 0}
.gh-skeleton-wrap{padding:4px 0}
.gh-skeleton-header{height:14px;width:55%;margin-bottom:10px;border-radius:4px}
.gh-skeleton-stats{height:44px;width:100%;margin-bottom:10px;border-radius:6px}
.gh-skeleton-cell{border-radius:2px}
.gh-skeleton{animation:gh-shimmer 1.4s infinite linear;background:linear-gradient(90deg,var(--background-modifier-border) 25%,var(--background-secondary) 50%,var(--background-modifier-border) 75%);background-size:200% 100%}
@keyframes gh-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.gh-tooltip{position:fixed;background:#1a1a1a;color:#eee;border-radius:6px;padding:7px 10px;font-size:11px;line-height:1.6;pointer-events:none;display:none;z-index:9999;white-space:pre;box-shadow:0 2px 10px rgba(0,0,0,.35);font-family:var(--font-monospace)}
.gh-tooltip strong{color:#fff;display:block;margin-bottom:2px;font-family:var(--font-interface)}
.theme-light .gh-tooltip{background:#222}
`;
document.head.appendChild(style);
}
};