From e34abda709949a9ecee56c3237c90ca09128f8a6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:34:30 +0100 Subject: [PATCH] Show New and Updated badges on recently added or revamped tools --- ADDING_TOOLS.md | 9 +- .../public/locales/en-US/translation.toml | 3 + .../tools/fullscreen/CompactToolItem.tsx | 9 +- .../tools/fullscreen/DetailedToolItem.tsx | 10 +- .../tools/shared/ToolStatusBadges.tsx | 68 ++++++++ .../tools/toolPicker/ToolButton.tsx | 16 +- .../src/core/contexts/ToolWorkflowContext.tsx | 9 + .../editor/src/core/data/toolsTaxonomy.ts | 4 + .../core/data/useTranslatedToolRegistry.tsx | 4 + .../core/hooks/tools/useToolFreshnessBadge.ts | 30 ++++ .../stubbed/tool-freshness-badges.spec.ts | 73 ++++++++ .../src/core/utils/toolFreshness.test.ts | 160 ++++++++++++++++++ .../editor/src/core/utils/toolFreshness.ts | 137 +++++++++++++++ scripts/lint/comment-lint-hook.mjs | 4 +- 14 files changed, 509 insertions(+), 27 deletions(-) create mode 100644 frontend/editor/src/core/components/tools/shared/ToolStatusBadges.tsx create mode 100644 frontend/editor/src/core/hooks/tools/useToolFreshnessBadge.ts create mode 100644 frontend/editor/src/core/tests/stubbed/tool-freshness-badges.spec.ts create mode 100644 frontend/editor/src/core/utils/toolFreshness.test.ts create mode 100644 frontend/editor/src/core/utils/toolFreshness.ts diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md index 9c22246a7c..3335abb1f7 100644 --- a/ADDING_TOOLS.md +++ b/ADDING_TOOLS.md @@ -128,7 +128,7 @@ export default [ToolName] as ToolComponent; ## 3. Register Tool in System Update these files to register your new tool: -**Tool Registry** (`frontend/editor/src/data/useTranslatedToolRegistry.tsx`): +**Tool Registry** (`frontend/editor/src/core/data/useTranslatedToolRegistry.tsx`): 1. Add imports at the top: ```typescript import [ToolName] from "../tools/[ToolName]"; @@ -147,11 +147,14 @@ import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Setting subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY, maxFiles: -1, // or specific number endpoints: ["endpoint-name"], - operationConfig: [toolName]OperationConfig, - settingsComponent: [ToolName]Settings, // if settings exist + operationConfig: asRegistryConfig([toolName]OperationConfig), + automationSettings: [ToolName]Settings, // or null if no automation settings + newInVersion: "2.16.0", // release the tool first ships in }, ``` +**"New"/"Updated" badges**: set `newInVersion` to the release the tool first ships in so the tool list shows a "New" badge; use `updatedInVersion` when an existing tool gets a major revamp to show "Updated". Badges expire automatically once that release is more than one minor behind the running app, and per user once they open the tool (see `frontend/editor/src/core/utils/toolFreshness.ts`). + ## 4. Add Tooltips (Optional but Recommended) Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:** diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 378157818c..ff1f9da23f 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -11275,13 +11275,16 @@ ZIP = "a ZIP archive" [toolPanel] alpha = "Alpha" backToAllTools = "Back to all tools" +beta = "Beta" collapse = "Collapse panel" expand = "Expand panel" goBack = "Go back" +new = "New" pdfTools = "PDF Tools" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" toolsHeader = "Tools" +updated = "Updated" viewAllTools = "View all tools" [toolPanel.fullscreen] diff --git a/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx index 37300cfeec..0f0a1aa57a 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { Text, Badge } from "@mantine/core"; +import { Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@app/components/shared/Tooltip"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; +import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges"; import { ToolRegistryEntry, getSubcategoryColor, @@ -82,11 +83,7 @@ const CompactToolItem: React.FC = ({
{tool.name} - {tool.versionStatus === "alpha" && ( - - {t("toolPanel.alpha", "Alpha")} - - )} +
{!disabled && ( diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 464403e286..62e18373b8 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { Text, Badge } from "@mantine/core"; +import { Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; +import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges"; import { ToolRegistryEntry, getSubcategoryColor, @@ -80,12 +81,7 @@ const DetailedToolItem: React.FC = ({
{tool.name} - {tool.versionStatus === "alpha" && ( - - {/* we can add more translations for different badges in future, like beta, etc. */} - {t("toolPanel.alpha", "Alpha")} - - )} +
{ + const { t } = useTranslation(); + const freshness = useToolFreshnessBadge(toolId, tool); + + const badges: { key: string; color: string; label: string }[] = []; + if (tool.versionStatus === "alpha") { + badges.push({ + key: "alpha", + color: "orange", + label: t("toolPanel.alpha", "Alpha"), + }); + } else if (tool.versionStatus === "beta") { + badges.push({ + key: "beta", + color: "orange", + label: t("toolPanel.beta", "Beta"), + }); + } + if (freshness === "new") { + badges.push({ + key: "new", + color: "teal", + label: t("toolPanel.new", "New"), + }); + } else if (freshness === "updated") { + badges.push({ + key: "updated", + color: "blue", + label: t("toolPanel.updated", "Updated"), + }); + } + if (badges.length === 0) return null; + + return ( + <> + {badges.map(({ key, color, label }) => ( + + {label} + + ))} + + ); +}; + +export default ToolStatusBadges; diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 8daf2cb019..4ebc0a4fb3 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -11,6 +11,7 @@ import FitText from "@app/components/shared/FitText"; import { useHotkeys } from "@app/contexts/HotkeyContext"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; +import ToolStatusBadges from "@app/components/tools/shared/ToolStatusBadges"; import { useToolWorkflowActions, useToolWorkflowData, @@ -176,16 +177,11 @@ const ToolButton: React.FC = ({ opacity: visuallyUnavailable ? 0.25 : 1, }} /> - {tool.versionStatus === "alpha" && ( - - {t("toolPanel.alpha", "Alpha")} - - )} + {typeof badgeCount === "number" && badgeCount > 0 && ( { + const toolId = navigationState.selectedTool; + if (!toolId) return; + const tool = allTools[toolId]; + if (tool) acknowledgeToolFreshness(toolId, tool); + }, [navigationState.selectedTool, allTools]); + // Tool reset methods const registerToolReset = useCallback( (toolId: string, resetFunction: () => void) => { diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts index 740b8a13ec..a1b4f4b078 100644 --- a/frontend/editor/src/core/data/toolsTaxonomy.ts +++ b/frontend/editor/src/core/data/toolsTaxonomy.ts @@ -78,6 +78,10 @@ export type ToolRegistryEntry = { synonyms?: string[]; // Version status indicator (e.g., "alpha", "beta") versionStatus?: "alpha" | "beta"; + // Release that introduced the tool; shows a "New" badge while that release is recent. + newInVersion?: string; + // Release of the tool's last major revamp; shows an "Updated" badge while recent. + updatedInVersion?: string; // Whether this tool requires premium access requiresPremium?: boolean; }; diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index ab4406abfd..cbd03d3294 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -239,6 +239,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { automationSettings: null, supportsAutomate: false, synonyms: getSynonyms(t, "sharedSign"), + newInVersion: "2.14.0", }, addText: { icon: ( @@ -480,6 +481,8 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { "modify", "builder", ], + // Fill Form became a full Form Editor (create/edit/delete fields). + updatedInVersion: "2.15.0", }, changePermissions: { icon: , @@ -677,6 +680,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { import("@app/components/tools/autoRotate/AutoRotateAutomationSettings"), ), synonyms: getSynonyms(t, "autoRotate"), + newInVersion: "2.15.0", }, split: { icon: ( diff --git a/frontend/editor/src/core/hooks/tools/useToolFreshnessBadge.ts b/frontend/editor/src/core/hooks/tools/useToolFreshnessBadge.ts new file mode 100644 index 0000000000..30ea2ec00e --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/useToolFreshnessBadge.ts @@ -0,0 +1,30 @@ +import { useSyncExternalStore } from "react"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import { + getAcknowledgedToolVersions, + getToolFreshness, + isToolFreshnessAcknowledged, + subscribeToolFreshness, + type ToolFreshnessBadge, +} from "@app/utils/toolFreshness"; + +// "New"/"Updated" badge for a tool; null once the tagged release is no longer +// recent or the user already opened the tool at that version. +export function useToolFreshnessBadge( + toolId: string, + tool: Pick, +): ToolFreshnessBadge | null { + const { config } = useAppConfig(); + const acknowledged = useSyncExternalStore( + subscribeToolFreshness, + getAcknowledgedToolVersions, + getAcknowledgedToolVersions, + ); + const freshness = getToolFreshness(tool, config?.appVersion); + if (!freshness) return null; + if (isToolFreshnessAcknowledged(acknowledged, toolId, freshness.version)) { + return null; + } + return freshness.badge; +} diff --git a/frontend/editor/src/core/tests/stubbed/tool-freshness-badges.spec.ts b/frontend/editor/src/core/tests/stubbed/tool-freshness-badges.spec.ts new file mode 100644 index 0000000000..17096724ff --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/tool-freshness-badges.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import type { Page, Route } from "@playwright/test"; + +/** + * "New"/"Updated" tool badges, driven by newInVersion/updatedInVersion tags in + * the registry compared against app-config's appVersion (see toolFreshness.ts). + * The app version is pinned per test so these stay hermetic as releases move on: + * autoRotate keeps newInVersion 2.15.0 forever, so at a pinned 2.15.x it is + * always "recent" here regardless of the real current version. + */ + +test.use({ autoGoto: false }); + +async function openEditorAtVersion(page: Page, appVersion: string) { + // Last-registered route wins, so this overrides the fixture's app-config stub. + await page.route("**/api/v1/config/app-config", (route: Route) => + route.fulfill({ + json: { + enableLogin: false, + isAdmin: false, + languages: ["en-US"], + defaultLocale: "en-US", + appVersion, + }, + }), + ); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); +} + +const toolButton = (page: Page, toolId: string) => + page.locator(`[data-tour="tool-button-${toolId}"]`).first(); +const badge = (page: Page, toolId: string, label: string) => + toolButton(page, toolId).getByText(label, { exact: true }); + +test.describe("Tool freshness badges", () => { + test("recent tools show New/Updated and opening a tool clears its badge", async ({ + page, + }) => { + await openEditorAtVersion(page, "2.15.1"); + + // autoRotate: newInVersion 2.15.0. formFill: updatedInVersion 2.15.0. + await expect(badge(page, "autoRotate", "New")).toBeVisible(); + await expect(badge(page, "formFill", "Updated")).toBeVisible(); + + // An untagged tool gets neither badge. + await expect(toolButton(page, "merge")).toBeVisible(); + await expect(badge(page, "merge", "New")).toHaveCount(0); + await expect(badge(page, "merge", "Updated")).toHaveCount(0); + + // Opening the tool acknowledges it; back on the list the badge is gone. + await toolButton(page, "autoRotate").click(); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await expect(toolButton(page, "autoRotate")).toBeVisible(); + await expect(badge(page, "autoRotate", "New")).toHaveCount(0); + await expect(badge(page, "formFill", "Updated")).toBeVisible(); + + // The acknowledgement is persisted, so a reload doesn't resurrect it. + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(toolButton(page, "autoRotate")).toBeVisible(); + await expect(badge(page, "autoRotate", "New")).toHaveCount(0); + }); + + test("badges expire once the tagged release is over a minor behind", async ({ + page, + }) => { + await openEditorAtVersion(page, "2.17.0"); + + await expect(toolButton(page, "autoRotate")).toBeVisible(); + await expect(badge(page, "autoRotate", "New")).toHaveCount(0); + await expect(toolButton(page, "formFill")).toBeVisible(); + await expect(badge(page, "formFill", "Updated")).toHaveCount(0); + }); +}); diff --git a/frontend/editor/src/core/utils/toolFreshness.test.ts b/frontend/editor/src/core/utils/toolFreshness.test.ts new file mode 100644 index 0000000000..2002c77bda --- /dev/null +++ b/frontend/editor/src/core/utils/toolFreshness.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { + acknowledgeToolFreshness, + getAcknowledgedToolVersions, + getToolFreshness, + isRecentRelease, + isToolFreshnessAcknowledged, + latestTaggedVersion, + resetToolFreshnessCache, +} from "@app/utils/toolFreshness"; + +const STORAGE_KEY = "stirling.toolFreshness.acknowledged"; + +beforeEach(() => { + window.localStorage.clear(); + resetToolFreshnessCache(); +}); + +describe("isRecentRelease", () => { + test("current and previous minor are recent", () => { + expect(isRecentRelease("2.15.0", "2.15.2")).toBe(true); + expect(isRecentRelease("2.14.0", "2.15.2")).toBe(true); + }); + + test("two minors back is stale", () => { + expect(isRecentRelease("2.13.0", "2.15.2")).toBe(false); + }); + + test("tagged version ahead of the build is recent", () => { + expect(isRecentRelease("2.16.0", "2.15.2")).toBe(true); + expect(isRecentRelease("3.0.0", "2.15.2")).toBe(true); + expect(isRecentRelease("2.15.0", "0.0.0")).toBe(true); + }); + + test("older major is stale", () => { + expect(isRecentRelease("1.9.0", "2.0.0")).toBe(false); + }); + + test("unknown app version keeps badges visible", () => { + expect(isRecentRelease("2.15.0", undefined)).toBe(true); + expect(isRecentRelease("2.15.0", null)).toBe(true); + }); + + test("unparseable tagged version never badges", () => { + expect(isRecentRelease("next", "2.15.0")).toBe(false); + expect(isRecentRelease("", "2.15.0")).toBe(false); + }); + + test("accepts a v prefix", () => { + expect(isRecentRelease("v2.15.0", "2.15.2")).toBe(true); + }); +}); + +describe("latestTaggedVersion", () => { + test("picks the higher of new and updated", () => { + expect( + latestTaggedVersion({ + newInVersion: "2.14.0", + updatedInVersion: "2.15.0", + }), + ).toBe("2.15.0"); + expect( + latestTaggedVersion({ + newInVersion: "2.15.0", + updatedInVersion: "2.14.0", + }), + ).toBe("2.15.0"); + expect(latestTaggedVersion({ newInVersion: "2.14.0" })).toBe("2.14.0"); + expect(latestTaggedVersion({})).toBeNull(); + }); +}); + +describe("getToolFreshness", () => { + test("recent newInVersion shows New", () => { + expect(getToolFreshness({ newInVersion: "2.15.0" }, "2.15.1")).toEqual({ + badge: "new", + version: "2.15.0", + }); + }); + + test("stale newInVersion with recent update shows Updated", () => { + expect( + getToolFreshness( + { newInVersion: "2.10.0", updatedInVersion: "2.15.0" }, + "2.15.1", + ), + ).toEqual({ badge: "updated", version: "2.15.0" }); + }); + + test("New outranks Updated and advertises the latest version", () => { + expect( + getToolFreshness( + { newInVersion: "2.14.0", updatedInVersion: "2.15.0" }, + "2.15.1", + ), + ).toEqual({ badge: "new", version: "2.15.0" }); + }); + + test("stale or missing versions produce no badge", () => { + expect(getToolFreshness({ newInVersion: "2.10.0" }, "2.15.1")).toBeNull(); + expect(getToolFreshness({}, "2.15.1")).toBeNull(); + }); +}); + +describe("acknowledgements", () => { + test("acknowledging hides that version but not a later one", () => { + acknowledgeToolFreshness("autoRotate", { newInVersion: "2.15.0" }); + let acknowledged = getAcknowledgedToolVersions(); + expect( + isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.15.0"), + ).toBe(true); + expect( + isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"), + ).toBe(false); + + // A later update re-surfaces the badge until acknowledged again. + acknowledgeToolFreshness("autoRotate", { + newInVersion: "2.15.0", + updatedInVersion: "2.16.0", + }); + acknowledged = getAcknowledgedToolVersions(); + expect( + isToolFreshnessAcknowledged(acknowledged, "autoRotate", "2.16.0"), + ).toBe(true); + }); + + test("persists to localStorage and survives a cache reset", () => { + acknowledgeToolFreshness("sharedSign", { newInVersion: "2.14.0" }); + resetToolFreshnessCache(); + expect( + isToolFreshnessAcknowledged( + getAcknowledgedToolVersions(), + "sharedSign", + "2.14.0", + ), + ).toBe(true); + }); + + test("untagged tools are never recorded", () => { + acknowledgeToolFreshness("merge", {}); + expect(getAcknowledgedToolVersions()).toEqual({}); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + test("corrupt stored state is ignored", () => { + window.localStorage.setItem(STORAGE_KEY, "not json"); + expect(getAcknowledgedToolVersions()).toEqual({}); + + resetToolFreshnessCache(); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(["a"])); + expect(getAcknowledgedToolVersions()).toEqual({}); + + resetToolFreshnessCache(); + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ good: "2.15.0", bad: 42 }), + ); + expect(getAcknowledgedToolVersions()).toEqual({ good: "2.15.0" }); + }); +}); diff --git a/frontend/editor/src/core/utils/toolFreshness.ts b/frontend/editor/src/core/utils/toolFreshness.ts new file mode 100644 index 0000000000..b413870b72 --- /dev/null +++ b/frontend/editor/src/core/utils/toolFreshness.ts @@ -0,0 +1,137 @@ +import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import { updateService } from "@app/services/updateService"; + +export type ToolFreshnessBadge = "new" | "updated"; + +export interface ToolFreshnessInfo { + badge: ToolFreshnessBadge; + // Version the badge advertises; acknowledging it (or newer) hides the badge. + version: string; +} + +type FreshnessFields = Pick< + ToolRegistryEntry, + "newInVersion" | "updatedInVersion" +>; + +// Badges expire on their own once the tagged release is this many minors behind. +const RECENT_MINOR_WINDOW = 1; + +const STORAGE_KEY = "stirling.toolFreshness.acknowledged"; + +function parseMajorMinor( + version: string, +): { major: number; minor: number } | null { + const match = /^v?(\d+)\.(\d+)/.exec(version.trim()); + if (!match) return null; + return { major: Number(match[1]), minor: Number(match[2]) }; +} + +// Recent = within RECENT_MINOR_WINDOW of the app's minor, or newer than the +// build itself (registry tagged ahead, e.g. dev builds reporting 0.0.0). +export function isRecentRelease( + taggedVersion: string, + appVersion: string | null | undefined, +): boolean { + const tagged = parseMajorMinor(taggedVersion); + if (!tagged) return false; + const current = appVersion ? parseMajorMinor(appVersion) : null; + if (!current) return true; + if (tagged.major !== current.major) return tagged.major > current.major; + return current.minor - tagged.minor <= RECENT_MINOR_WINDOW; +} + +// Highest version the tool's registry entry is tagged with, if any. +export function latestTaggedVersion(tool: FreshnessFields): string | null { + const { newInVersion, updatedInVersion } = tool; + if (newInVersion && updatedInVersion) { + return updateService.compareVersions(updatedInVersion, newInVersion) >= 0 + ? updatedInVersion + : newInVersion; + } + return updatedInVersion ?? newInVersion ?? null; +} + +// "New" outranks "Updated": a tool still inside its launch window is just new. +export function getToolFreshness( + tool: FreshnessFields, + appVersion: string | null | undefined, +): ToolFreshnessInfo | null { + const { newInVersion, updatedInVersion } = tool; + if (newInVersion && isRecentRelease(newInVersion, appVersion)) { + return { badge: "new", version: latestTaggedVersion(tool) ?? newInVersion }; + } + if (updatedInVersion && isRecentRelease(updatedInVersion, appVersion)) { + return { badge: "updated", version: updatedInVersion }; + } + return null; +} + +type AcknowledgedVersions = Readonly>; + +let acknowledgedCache: AcknowledgedVersions | null = null; +const listeners = new Set<() => void>(); + +function readAcknowledged(): AcknowledgedVersions { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} + +// Stable snapshot for useSyncExternalStore; replaced wholesale on writes. +export function getAcknowledgedToolVersions(): AcknowledgedVersions { + if (!acknowledgedCache) acknowledgedCache = readAcknowledged(); + return acknowledgedCache; +} + +export function subscribeToolFreshness(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function isToolFreshnessAcknowledged( + acknowledged: AcknowledgedVersions, + toolId: string, + version: string, +): boolean { + const seen = acknowledged[toolId]; + return !!seen && updateService.compareVersions(seen, version) >= 0; +} + +// Records that the user has opened the tool at its currently tagged version. +export function acknowledgeToolFreshness( + toolId: string, + tool: FreshnessFields, +): void { + const version = latestTaggedVersion(tool); + if (!version) return; + const current = getAcknowledgedToolVersions(); + if (isToolFreshnessAcknowledged(current, toolId, version)) return; + acknowledgedCache = { ...current, [toolId]: version }; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(acknowledgedCache)); + } catch { + // Storage being unavailable only means the badge reappears next visit. + } + listeners.forEach((listener) => listener()); +} + +// Test hook: drops the in-memory cache so the next read hits localStorage. +export function resetToolFreshnessCache(): void { + acknowledgedCache = null; +} diff --git a/scripts/lint/comment-lint-hook.mjs b/scripts/lint/comment-lint-hook.mjs index af573c6a91..8e9684ccfe 100644 --- a/scripts/lint/comment-lint-hook.mjs +++ b/scripts/lint/comment-lint-hook.mjs @@ -92,7 +92,9 @@ function taskCommand() { for (const candidate of candidates) { if (existsSync(candidate)) return { command: candidate, shell: false }; } - return { command: process.platform === "win32" ? "task.cmd" : "task", shell: process.platform === "win32" }; + // Plain "task" lets cmd resolve the extension via PATHEXT, so .exe shims + // (scoop, winget) work too; hard-coding .cmd made those exit 1 with no output. + return { command: "task", shell: process.platform === "win32" }; } function run() {