diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 93dfde4..96fb51d 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -114,10 +114,21 @@
"validatingToken": { "message": "Validating token…" },
"tokenValid": {
- "message": "Valid — authenticated as $user$",
- "placeholders": { "user": { "content": "$1", "example": "octocat" } }
+ "message": "Saved — $user$ · $expiration$",
+ "placeholders": {
+ "user": { "content": "$1", "example": "octocat" },
+ "expiration": { "content": "$2", "example": "expires 2026-07-08" }
+ }
+ },
+ "tokenExpirationDate": {
+ "message": "expires $date$",
+ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } }
+ },
+ "tokenExpirationNone": { "message": "no expiration date" },
+ "tokenInvalid": { "message": "Invalid token — not saved" },
+ "tokenStoredInvalid": {
+ "message": "Saved token is invalid — update it to keep token-only features working"
},
- "tokenInvalid": { "message": "Invalid token — authentication failed" },
"tokenValidationFailed": {
"message": "Validation failed (HTTP $status$)",
"placeholders": { "status": { "content": "$1", "example": "500" } }
diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json
index e27fec3..fbd736e 100644
--- a/src/_locales/zh_CN/messages.json
+++ b/src/_locales/zh_CN/messages.json
@@ -114,10 +114,21 @@
"validatingToken": { "message": "正在验证令牌…" },
"tokenValid": {
- "message": "有效 — 已认证为 $user$",
- "placeholders": { "user": { "content": "$1", "example": "octocat" } }
+ "message": "已保存 — $user$ · $expiration$",
+ "placeholders": {
+ "user": { "content": "$1", "example": "octocat" },
+ "expiration": { "content": "$2", "example": "有效期至 2026-07-08" }
+ }
+ },
+ "tokenExpirationDate": {
+ "message": "有效期至 $date$",
+ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } }
+ },
+ "tokenExpirationNone": { "message": "无过期时间" },
+ "tokenInvalid": { "message": "无效令牌 — 未保存" },
+ "tokenStoredInvalid": {
+ "message": "已保存的令牌无效 — 请更新令牌,否则需要令牌的功能无法使用"
},
- "tokenInvalid": { "message": "无效令牌 — 认证失败" },
"tokenValidationFailed": {
"message": "验证失败(HTTP $status$)",
"placeholders": { "status": { "content": "$1", "example": "500" } }
diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json
index 2b96822..8fa4ce4 100644
--- a/src/_locales/zh_TW/messages.json
+++ b/src/_locales/zh_TW/messages.json
@@ -114,10 +114,21 @@
"validatingToken": { "message": "正在驗證權杖…" },
"tokenValid": {
- "message": "有效 — 已驗證為 $user$",
- "placeholders": { "user": { "content": "$1", "example": "octocat" } }
+ "message": "已儲存 — $user$ · $expiration$",
+ "placeholders": {
+ "user": { "content": "$1", "example": "octocat" },
+ "expiration": { "content": "$2", "example": "有效期至 2026-07-08" }
+ }
+ },
+ "tokenExpirationDate": {
+ "message": "有效期至 $date$",
+ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } }
+ },
+ "tokenExpirationNone": { "message": "無過期時間" },
+ "tokenInvalid": { "message": "無效權杖 — 未儲存" },
+ "tokenStoredInvalid": {
+ "message": "已儲存的權杖無效 — 請更新權杖,否則需要權杖的功能無法使用"
},
- "tokenInvalid": { "message": "無效權杖 — 驗證失敗" },
"tokenValidationFailed": {
"message": "驗證失敗(HTTP $status$)",
"placeholders": { "status": { "content": "$1", "example": "500" } }
diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts
index ac08dcc..bb6ca37 100644
--- a/src/lib/i18n.test.ts
+++ b/src/lib/i18n.test.ts
@@ -26,11 +26,14 @@ describe("i18n", () => {
it("substitutes positional placeholders in both locales", () => {
setLocale("en");
- expect(t("tokenValid", "octocat")).toBe("Valid — authenticated as octocat");
+ expect(t("tokenValid", ["octocat", "expires 2026-07-08"])).toBe(
+ "Saved — octocat · expires 2026-07-08",
+ );
expect(t("diffStatsTitleWithFiles", ["1,234", "56", "3 files"])).toBe(
"1,234 additions, 56 deletions across 3 files",
);
setLocale("zh_CN");
+ expect(t("tokenExpirationNone")).toBe("无过期时间");
expect(t("commitTagTitle", "v1.0.0")).toBe("Tag: v1.0.0");
});
diff --git a/src/options.test.ts b/src/options.test.ts
index 02fb123..b4aa697 100644
--- a/src/options.test.ts
+++ b/src/options.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
const FEATURE_KEYS = [
"feature-pr-branch-names",
@@ -23,14 +23,10 @@ interface ChromeStub {
}
function buildDom(): void {
- const checkboxes = FEATURE_KEYS.map(
- (k) => ``,
- ).join("");
+ const checkboxes = FEATURE_KEYS.map((k) => ``).join("");
document.body.innerHTML = `
-
-
@@ -84,12 +80,23 @@ const $ = (id: string) => document.getElementById(id) as
describe("options page", () => {
afterEach(() => {
+ vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.innerHTML = "";
});
- it("reflects stored settings into the form on load", async () => {
+ it("reflects stored settings and validates a stored token on load", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ login: "octocat" }), {
+ status: 200,
+ headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" },
+ }),
+ ),
+ );
+
await loadOptions({ githubToken: "ghp_abc", "feature-default-sort": false });
expect($("token").value).toBe("ghp_abc");
@@ -97,31 +104,23 @@ describe("options page", () => {
expect($("feature-pr-branch-names").checked).toBe(true);
expect($("feature-default-sort").checked).toBe(false);
expect($("footer").innerHTML).toContain("9.9.9");
+ await vi.waitFor(() =>
+ expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08"),
+ );
});
- it("persists the token and every flag when Save is clicked", async () => {
- const chrome = await loadOptions({});
- $("token").value = " ghp_new ";
- $("feature-commit-tags").checked = false;
-
- $("save").click();
-
- expect(chrome.set).toHaveBeenCalledTimes(1);
- const saved = chrome.set.mock.calls[0][0] as Record;
- expect(saved.githubToken).toBe("ghp_new"); // trimmed
- expect(saved["feature-commit-tags"]).toBe(false);
- expect(saved["feature-pr-branch-names"]).toBe(true);
- expect($("status").textContent).toBe("Saved!");
- });
-
- it("surfaces a storage error from Save", async () => {
- const chrome = await loadOptions({});
- chrome.lastError = { message: "quota exceeded" };
-
- $("save").click();
+ it("shows stored invalid token copy without clearing the saved token", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 401 })));
+ const chrome = await loadOptions({ githubToken: "ghp_revoked" });
- expect($("status").textContent).toBe("quota exceeded");
- expect($("status").className).toContain("error");
+ await vi.waitFor(() =>
+ expect($("tokenStatus").textContent).toBe(
+ "Saved token is invalid — update it to keep token-only features working",
+ ),
+ );
+ expect($("token").value).toBe("ghp_revoked");
+ expect(chrome.store.githubToken).toBe("ghp_revoked");
+ expect(chrome.set).not.toHaveBeenCalled();
});
it("auto-saves a single flag when its checkbox is toggled", async () => {
@@ -134,6 +133,30 @@ describe("options page", () => {
expect(chrome.set).toHaveBeenCalledWith({ "feature-release-tab": false });
});
+ it("auto-saves a valid token after input validation", async () => {
+ vi.useFakeTimers();
+ const chrome = await loadOptions({});
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ login: "octocat" }), {
+ status: 200,
+ headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" },
+ }),
+ ),
+ );
+
+ const token = $("token");
+ token.value = " ghp_valid ";
+ token.dispatchEvent(new Event("input"));
+ await vi.advanceTimersByTimeAsync(600);
+
+ await vi.waitFor(() =>
+ expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)),
+ );
+ expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08");
+ });
+
it("filters feature items and hides groups with no match while searching", async () => {
await loadOptions({});
const input = $("searchInput");
@@ -150,11 +173,14 @@ describe("options page", () => {
});
it("validates the token against the GitHub API on blur", async () => {
- await loadOptions({});
+ const chrome = await loadOptions({});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
- new Response(JSON.stringify({ login: "octocat" }), { status: 200 }),
+ new Response(JSON.stringify({ login: "octocat" }), {
+ status: 200,
+ headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" },
+ }),
),
);
@@ -162,8 +188,9 @@ describe("options page", () => {
token.value = "ghp_valid";
token.dispatchEvent(new Event("blur"));
await vi.waitFor(() =>
- expect($("tokenStatus").textContent).toBe("Valid — authenticated as octocat"),
+ expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08"),
);
expect($("tokenStatus").className).toContain("valid");
+ expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function));
});
});
diff --git a/src/options.ts b/src/options.ts
index d4563db..3c6ac6a 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -3,7 +3,7 @@ import { t, localizePage, initLocale, setLocale, LOCALE_KEY, type LocalePref } f
const langSelect = document.getElementById("langSelect") as HTMLSelectElement | null;
// Resolve the stored locale preference, reflect it in the picker, then localize.
-initLocale().then((pref) => {
+const localeReady = initLocale().then((pref) => {
if (langSelect) langSelect.value = pref;
localizePage();
});
@@ -18,10 +18,6 @@ langSelect?.addEventListener("change", () => {
const tokenInput = document.getElementById("token") as HTMLInputElement;
const tokenStatus = document.getElementById("tokenStatus") as HTMLDivElement;
-const saveBtn = document.getElementById("save") as HTMLButtonElement;
-// Not named `status`: as a top-level script var that would shadow the DOM
-// global `window.status` (a string), breaking `.className`/`.textContent`.
-const statusEl = document.getElementById("status") as HTMLDivElement;
interface StoredSettings {
githubToken?: string;
@@ -44,10 +40,14 @@ const FEATURE_KEYS = [
"feature-contributor-card",
] as const;
+let storedToken = "";
+
// --- Load saved settings ---
chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (result) => {
if (result.githubToken) {
- tokenInput.value = result.githubToken;
+ storedToken = result.githubToken.trim();
+ tokenInput.value = storedToken;
+ localeReady.then(() => validateToken(storedToken, { invalidMessageKey: "tokenStoredInvalid" }));
}
for (const key of FEATURE_KEYS) {
const checkbox = document.getElementById(key) as HTMLInputElement;
@@ -55,16 +55,56 @@ chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (resu
}
});
-// --- Token validation on blur ---
+// --- Token validation and auto-save ---
let lastValidatedToken = "";
+let validationRun = 0;
+let tokenValidationTimer: number | undefined;
+
+interface TokenValidationOptions {
+ shouldPersist?: boolean;
+ invalidMessageKey?: "tokenInvalid" | "tokenStoredInvalid";
+}
+
+function renderValidTokenStatus(user: string, expiration: string | null) {
+ const expirationDate = expiration?.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? null;
+ const expirationText = expirationDate
+ ? t("tokenExpirationDate", expirationDate)
+ : t("tokenExpirationNone");
+ tokenStatus.className = "token-status valid";
+ tokenStatus.textContent = t("tokenValid", [user, expirationText]);
+}
+
+function persistToken(token: string, run: number) {
+ if (token === storedToken) return;
-async function validateToken(token: string) {
+ chrome.storage.local.set({ githubToken: token }, () => {
+ if (run !== validationRun) return;
+ if (chrome.runtime.lastError) {
+ tokenStatus.className = "token-status invalid";
+ tokenStatus.textContent = chrome.runtime.lastError.message ?? t("saveFailed");
+ lastValidatedToken = "";
+ return;
+ }
+ storedToken = token;
+ });
+}
+
+async function validateToken(
+ token: string,
+ { shouldPersist = false, invalidMessageKey = "tokenInvalid" }: TokenValidationOptions = {},
+) {
+ const run = ++validationRun;
if (!token) {
tokenStatus.className = "token-status";
tokenStatus.textContent = "";
+ lastValidatedToken = "";
+ if (shouldPersist) persistToken("", run);
+ return;
+ }
+ if (token === lastValidatedToken) {
+ if (shouldPersist) persistToken(token, run);
return;
}
- if (token === lastValidatedToken) return;
tokenStatus.className = "token-status checking";
tokenStatus.textContent = t("validatingToken");
@@ -74,14 +114,20 @@ async function validateToken(token: string) {
headers: { Authorization: `Bearer ${token}` },
});
+ if (run !== validationRun) return;
+
if (response.ok) {
const user = await response.json();
- tokenStatus.className = "token-status valid";
- tokenStatus.textContent = t("tokenValid", user.login);
+ if (run !== validationRun) return;
+ renderValidTokenStatus(
+ user.login,
+ response.headers.get("GitHub-Authentication-Token-Expiration")?.trim() || null,
+ );
lastValidatedToken = token;
+ if (shouldPersist) persistToken(token, run);
} else if (response.status === 401) {
tokenStatus.className = "token-status invalid";
- tokenStatus.textContent = t("tokenInvalid");
+ tokenStatus.textContent = t(invalidMessageKey);
lastValidatedToken = "";
} else {
tokenStatus.className = "token-status invalid";
@@ -89,45 +135,27 @@ async function validateToken(token: string) {
lastValidatedToken = "";
}
} catch {
+ if (run !== validationRun) return;
tokenStatus.className = "token-status invalid";
tokenStatus.textContent = t("tokenNetworkError");
lastValidatedToken = "";
}
}
-tokenInput.addEventListener("blur", () => {
- validateToken(tokenInput.value.trim());
-});
-
-// --- Save button ---
-let statusTimer: number | undefined;
-
-function showStatus(kind: "success" | "error", message: string) {
- statusEl.className = `status ${kind}`;
- statusEl.textContent = message;
- if (statusTimer) clearTimeout(statusTimer);
- statusTimer = window.setTimeout(() => {
- statusEl.className = "status";
- statusEl.textContent = "";
- }, 2000);
+function scheduleTokenValidation() {
+ if (tokenValidationTimer) clearTimeout(tokenValidationTimer);
+ tokenValidationTimer = window.setTimeout(() => {
+ validateToken(tokenInput.value.trim(), { shouldPersist: true });
+ }, 600);
}
-saveBtn.addEventListener("click", () => {
- const token = tokenInput.value.trim();
- const settings: Record = { githubToken: token };
-
- for (const key of FEATURE_KEYS) {
- const checkbox = document.getElementById(key) as HTMLInputElement;
- settings[key] = checkbox.checked;
+tokenInput.addEventListener("input", scheduleTokenValidation);
+tokenInput.addEventListener("blur", () => {
+ if (tokenValidationTimer) {
+ clearTimeout(tokenValidationTimer);
+ tokenValidationTimer = undefined;
}
-
- chrome.storage.local.set(settings, () => {
- if (chrome.runtime.lastError) {
- showStatus("error", chrome.runtime.lastError.message ?? t("saveFailed"));
- } else {
- showStatus("success", t("saved"));
- }
- });
+ validateToken(tokenInput.value.trim(), { shouldPersist: true });
});
// --- Auto-save feature toggles on change ---
diff --git a/static/options.html b/static/options.html
index 025ff3f..5cfc66a 100644
--- a/static/options.html
+++ b/static/options.html
@@ -214,24 +214,6 @@
display: block;
color: #656d76;
}
- .save-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-top: 12px;
- }
- .status {
- font-size: 13px;
- display: none;
- }
- .status.success {
- display: block;
- color: #1a7f37;
- }
- .status.error {
- display: block;
- color: #cf222e;
- }
button {
padding: 6px 16px;
background: #2da44e;
@@ -469,10 +451,6 @@ Settings
-
⬆️ A token is required for accessing private repositories. The plugin itself will never