diff --git a/docs/frontend-ui-audit-2026-07-22/GitHubStarSurfaces.md b/docs/frontend-ui-audit-2026-07-22/GitHubStarSurfaces.md new file mode 100644 index 000000000..aa58546b5 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-22/GitHubStarSurfaces.md @@ -0,0 +1,48 @@ +# Frontend UI Audit — GitHub Star Surfaces + +**Files:** `src/features/GitHubStar/GitHubStarSettingsRow.tsx`, `src/features/GitHubStar/GitHubStarReminder.tsx`, `src/modules/MainApp/Settings/sections/GeneralSection.tsx`, `src/modules/SetupWalkthrough/index.tsx`, `src/modules/shared/layouts/GlobalModals.tsx` +**Date:** 2026-07-22 +**Auditor:** ORGII agent session + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------- | ------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- | ---------------- | +| `GitHubStarSettingsRow.tsx:65-107` | Design-system `Button` actions | keep with reason | Star, fallback, error, and busy states all use the shared Button component. | — | +| `GitHubStarReminder.tsx:55-112` | Shared `Modal` and design-system `Button` actions | keep with reason | The reminder reuses the established modal and button contracts instead of introducing raw interactive HTML. | — | +| `GitHubStarSettingsRow.tsx:98` | Status `` | keep with reason | Non-interactive success text; semantic status is supplied by the adjacent live region. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ---------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------- | ---------------- | +| — | No arbitrary color, CSS-variable, hex, RGB, or HSL classes | keep with reason | Surfaces use project tokens such as `text-success-6` and `text-text-3`. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| -------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| `GitHubStarReminder.tsx:88,90` and `GitHubStarSettingsRow.tsx:68,79,90,99` | 14px Lucide icon sizes | keep with reason | Matches the shared small-Button icon grid and is a deliberate optical micro-size. | — | +| `GitHubStarReminder.tsx:58` | Modal width `420` | keep with reason | Modal accepts a numeric content width; 420px provides space for three actions without introducing a reusable token need. | — | +| — | No raw color literals or arbitrary Tailwind pixel classes | keep with reason | Layout uses the project spacing scale and design tokens. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ----------------------------------- | ------------------------- | ---------------- | --------------------------------------------------------------------------------------------------- | ---------------- | +| `GitHubStarSettingsRow.tsx:117-124` | Polite status live region | keep with reason | Announces loading, fallback reason, and confirmed success; `aria-busy` mirrors actual pending work. | — | +| `GitHubStarReminder.tsx:64-100` | Reminder actions | keep with reason | Every action is a semantic Button with visible translated text; icons are decorative. | — | +| `GitHubStarReminder.tsx:108-110` | Reminder live region | keep with reason | Announces controller status while preserving visible content stability. | — | + +## D5 — Visual Patterns Observed + +- Settings presentation reuses `SectionRow` and `SectionContainer`; it does not create a parallel settings-row pattern. +- Reminder presentation reuses the canonical `Modal` and Button footer vocabulary. +- Controller logic is shared by Settings and reminder surfaces, preventing visual state semantics from drifting between entry points. + +## Summary + +- 0 fixes recommended +- 10 findings kept with documented reason +- 0 abstraction candidates +- Total source changes in this audit: 0; this report reviews the already-implemented feature without mixing audit-only fixes into the report pass. diff --git a/src-tauri/crates/integrations/src/github/mod.rs b/src-tauri/crates/integrations/src/github/mod.rs index 00ab1f819..29e633709 100644 --- a/src-tauri/crates/integrations/src/github/mod.rs +++ b/src-tauri/crates/integrations/src/github/mod.rs @@ -8,3 +8,4 @@ pub mod client; pub mod commands; pub mod detect; pub mod profile; +pub mod star; diff --git a/src-tauri/crates/integrations/src/github/star.rs b/src-tauri/crates/integrations/src/github/star.rs new file mode 100644 index 000000000..39124c0c0 --- /dev/null +++ b/src-tauri/crates/integrations/src/github/star.rs @@ -0,0 +1,346 @@ +//! Fixed GitHub Star commands for the canonical ORG2 repository. +//! +//! These commands intentionally accept no endpoint or repository arguments. All +//! `gh` invocations use an explicit hostname and pass arguments directly without +//! a shell. + +use std::{io, process::Output, time::Duration}; + +use serde::Serialize; +use tokio::process::Command; + +const GITHUB_HOST: &str = "github.com"; +const ORG2_STAR_ENDPOINT: &str = "/user/starred/yorgai/ORG2"; +const GH_TIMEOUT: Duration = Duration::from_secs(8); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum OrgiiStarResult { + Starred, + NotStarred, + Unavailable { reason: OrgiiStarUnavailableReason }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OrgiiStarUnavailableReason { + GhMissing, + NotAuthenticated, + Network, + Permission, + Timeout, + Unexpected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StarOperation { + Check, + Star, +} + +impl StarOperation { + fn method(self) -> &'static str { + match self { + Self::Check => "GET", + Self::Star => "PUT", + } + } + + fn args(self) -> [&'static str; 7] { + [ + "api", + "--hostname", + GITHUB_HOST, + "--method", + self.method(), + "--include", + ORG2_STAR_ENDPOINT, + ] + } +} + +#[derive(Debug)] +enum ExecutionResult { + Completed(Output), + SpawnFailed(io::ErrorKind), + TimedOut, +} + +/// Check whether the authenticated GitHub CLI account starred yorgai/ORG2. +#[tauri::command] +pub async fn check_orgii_star() -> OrgiiStarResult { + execute(StarOperation::Check).await +} + +/// Star yorgai/ORG2 with the authenticated GitHub CLI account. +#[tauri::command] +pub async fn star_orgii() -> OrgiiStarResult { + execute(StarOperation::Star).await +} + +async fn execute(operation: StarOperation) -> OrgiiStarResult { + let mut command = Command::new("gh"); + command.args(operation.args()).kill_on_drop(true); + hide_console(&mut command); + + let execution = match tokio::time::timeout(GH_TIMEOUT, command.output()).await { + Ok(Ok(output)) => ExecutionResult::Completed(output), + Ok(Err(error)) => ExecutionResult::SpawnFailed(error.kind()), + Err(_) => ExecutionResult::TimedOut, + }; + + classify_execution(operation, execution) +} + +#[cfg(windows)] +fn hide_console(command: &mut Command) { + use std::os::windows::process::CommandExt; + + command.creation_flags(app_platform::CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_console(_command: &mut Command) {} + +fn classify_execution(operation: StarOperation, execution: ExecutionResult) -> OrgiiStarResult { + match execution { + ExecutionResult::TimedOut => unavailable(OrgiiStarUnavailableReason::Timeout), + ExecutionResult::SpawnFailed(io::ErrorKind::NotFound) => { + unavailable(OrgiiStarUnavailableReason::GhMissing) + } + ExecutionResult::SpawnFailed(io::ErrorKind::PermissionDenied) => { + unavailable(OrgiiStarUnavailableReason::Permission) + } + ExecutionResult::SpawnFailed(_) => unavailable(OrgiiStarUnavailableReason::Unexpected), + ExecutionResult::Completed(output) => classify_output(operation, &output), + } +} + +fn classify_output(operation: StarOperation, output: &Output) -> OrgiiStarResult { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let diagnostics = format!("{stdout}\n{stderr}").to_ascii_lowercase(); + let status = http_status(&diagnostics); + + if status == Some(404) { + return OrgiiStarResult::NotStarred; + } + + if output.status.success() { + return match operation { + StarOperation::Check | StarOperation::Star => OrgiiStarResult::Starred, + }; + } + + let reason = if status == Some(401) + || contains_any( + &diagnostics, + &[ + "gh auth login", + "not logged into", + "authentication required", + "authentication token missing", + "bad credentials", + ], + ) { + OrgiiStarUnavailableReason::NotAuthenticated + } else if status == Some(403) + || contains_any( + &diagnostics, + &[ + "forbidden", + "resource not accessible", + "permission denied", + "insufficient scope", + ], + ) + { + OrgiiStarUnavailableReason::Permission + } else if contains_any( + &diagnostics, + &[ + "could not resolve host", + "connection refused", + "connection reset", + "error connecting", + "network is unreachable", + "tls handshake", + "temporary failure in name resolution", + ], + ) { + OrgiiStarUnavailableReason::Network + } else { + OrgiiStarUnavailableReason::Unexpected + }; + + unavailable(reason) +} + +fn unavailable(reason: OrgiiStarUnavailableReason) -> OrgiiStarResult { + OrgiiStarResult::Unavailable { reason } +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +fn http_status(diagnostics: &str) -> Option { + diagnostics.lines().find_map(|line| { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("http/") { + return rest + .split_whitespace() + .nth(1) + .and_then(|status| status.parse().ok()); + } + + let marker = "(http "; + let start = line.find(marker)? + marker.len(); + let status = line.get(start..)?.split(')').next()?; + status.parse().ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::ExitStatus; + + #[cfg(unix)] + fn exit_status(code: i32) -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + + #[cfg(windows)] + fn exit_status(code: i32) -> ExitStatus { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } + + fn output(code: i32, stdout: &str, stderr: &str) -> Output { + Output { + status: exit_status(code), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } + + #[test] + fn commands_use_fixed_args_and_hostname() { + assert_eq!( + StarOperation::Check.args(), + [ + "api", + "--hostname", + "github.com", + "--method", + "GET", + "--include", + "/user/starred/yorgai/ORG2", + ] + ); + assert_eq!(StarOperation::Star.args()[4], "PUT"); + assert_eq!(StarOperation::Star.args()[6], ORG2_STAR_ENDPOINT); + } + + #[test] + fn check_maps_200_and_204_to_starred() { + for status in [200, 204] { + let result = classify_output( + StarOperation::Check, + &output(0, &format!("HTTP/2 {status}\r\n"), ""), + ); + assert_eq!(result, OrgiiStarResult::Starred); + } + } + + #[test] + fn put_success_maps_to_starred() { + assert_eq!( + classify_output(StarOperation::Star, &output(0, "HTTP/2 204\r\n", "")), + OrgiiStarResult::Starred + ); + } + + #[test] + fn only_404_maps_to_not_starred() { + assert_eq!( + classify_output( + StarOperation::Check, + &output(1, "", "gh: Not Found (HTTP 404)") + ), + OrgiiStarResult::NotStarred + ); + assert_ne!( + classify_output( + StarOperation::Check, + &output(1, "", "gh: server error (HTTP 500)") + ), + OrgiiStarResult::NotStarred + ); + } + + #[test] + fn classifies_unavailable_reasons() { + let cases = [ + ( + "gh: authentication required; run gh auth login", + OrgiiStarUnavailableReason::NotAuthenticated, + ), + ( + "gh: Resource not accessible by personal access token (HTTP 403)", + OrgiiStarUnavailableReason::Permission, + ), + ( + "error connecting to api.github.com: network is unreachable", + OrgiiStarUnavailableReason::Network, + ), + ( + "gh: server error (HTTP 500)", + OrgiiStarUnavailableReason::Unexpected, + ), + ]; + + for (stderr, reason) in cases { + assert_eq!( + classify_output(StarOperation::Check, &output(1, "", stderr)), + unavailable(reason) + ); + } + } + + #[test] + fn classifies_spawn_and_timeout_failures() { + assert_eq!( + classify_execution( + StarOperation::Check, + ExecutionResult::SpawnFailed(io::ErrorKind::NotFound) + ), + unavailable(OrgiiStarUnavailableReason::GhMissing) + ); + assert_eq!( + classify_execution(StarOperation::Check, ExecutionResult::TimedOut), + unavailable(OrgiiStarUnavailableReason::Timeout) + ); + assert_eq!( + classify_execution( + StarOperation::Check, + ExecutionResult::SpawnFailed(io::ErrorKind::PermissionDenied) + ), + unavailable(OrgiiStarUnavailableReason::Permission) + ); + } + + #[test] + fn serialization_is_tagged_and_does_not_include_diagnostics() { + assert_eq!( + serde_json::to_value(OrgiiStarResult::Starred).unwrap(), + serde_json::json!({ "status": "starred" }) + ); + assert_eq!( + serde_json::to_value(unavailable(OrgiiStarUnavailableReason::Network)).unwrap(), + serde_json::json!({ "status": "unavailable", "reason": "network" }) + ); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 97ea4390f..34a6f9575 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -191,6 +191,8 @@ integrations::github::commands::github_list_repo_labels, integrations::github::commands::github_list_repo_collaborators, integrations::github::detect::detect_github_credentials, integrations::github::profile::github_fetch_profile, +integrations::github::star::check_orgii_star, +integrations::github::star::star_orgii, project_management::sync::git_credentials::git_credential_for_remote, // Platform commands system_services::notifications::send_notification, diff --git a/src/api/tauri/githubStar.ts b/src/api/tauri/githubStar.ts new file mode 100644 index 000000000..922306f8b --- /dev/null +++ b/src/api/tauri/githubStar.ts @@ -0,0 +1,57 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type GitHubStarUnavailableReason = + | "gh_missing" + | "not_authenticated" + | "network" + | "permission" + | "timeout" + | "unexpected"; + +export type GitHubStarResult = + | { status: "starred" } + | { status: "not_starred" } + | { status: "unavailable"; reason: GitHubStarUnavailableReason }; + +let checkPromise: Promise | null = null; +let starPromise: Promise | null = null; + +function singleFlight( + current: Promise | null, + command: "check_orgii_star" | "star_orgii", + clear: (promise: Promise) => void +): Promise { + if (current) return current; + + const promise = invoke(command); + clear(promise); + return promise; +} + +export function checkOrgiiStar(): Promise { + return singleFlight(checkPromise, "check_orgii_star", (promise) => { + checkPromise = promise; + void promise.then( + () => { + if (checkPromise === promise) checkPromise = null; + }, + () => { + if (checkPromise === promise) checkPromise = null; + } + ); + }); +} + +export function starOrgii(): Promise { + return singleFlight(starPromise, "star_orgii", (promise) => { + starPromise = promise; + void promise.then( + () => { + if (starPromise === promise) starPromise = null; + }, + () => { + if (starPromise === promise) starPromise = null; + } + ); + }); +} diff --git a/src/config/settingsSchema/__tests__/githubStarPrompt.test.ts b/src/config/settingsSchema/__tests__/githubStarPrompt.test.ts new file mode 100644 index 000000000..8d43f4d64 --- /dev/null +++ b/src/config/settingsSchema/__tests__/githubStarPrompt.test.ts @@ -0,0 +1,55 @@ +import { + getSettingsDefaults, + validateSettings, +} from "@src/config/settingsSchema"; + +const SETUP_OUTCOME_KEY = "general.setupWalkthroughOutcome" as const; + +const PROMPT_KEYS = { + completed: "general.githubStarPromptCompleted", + disabled: "general.githubStarPromptDisabled", + deferredUntil: "general.githubStarPromptDeferredUntil", + lastShownAt: "general.githubStarPromptLastShownAt", + nextEligibleValueCount: "general.githubStarPromptNextEligibleValueCount", +} as const; + +describe("GitHub Star prompt settings", () => { + it("defaults to an eligible, incomplete prompt without a cooldown", () => { + const defaults = getSettingsDefaults(); + + expect(defaults[SETUP_OUTCOME_KEY]).toBe("open"); + expect(defaults[PROMPT_KEYS.completed]).toBe(false); + expect(defaults[PROMPT_KEYS.disabled]).toBe(false); + expect(defaults[PROMPT_KEYS.deferredUntil]).toBe(0); + expect(defaults[PROMPT_KEYS.lastShownAt]).toBe(0); + expect(defaults[PROMPT_KEYS.nextEligibleValueCount]).toBe(1); + }); + + it("preserves valid durable prompt state", () => { + const validated = validateSettings({ + [SETUP_OUTCOME_KEY]: "dismissed", + [PROMPT_KEYS.completed]: true, + [PROMPT_KEYS.disabled]: true, + [PROMPT_KEYS.deferredUntil]: 1234, + [PROMPT_KEYS.lastShownAt]: 1000, + [PROMPT_KEYS.nextEligibleValueCount]: 2, + }); + + expect(validated[SETUP_OUTCOME_KEY]).toBe("dismissed"); + expect(validated[PROMPT_KEYS.completed]).toBe(true); + expect(validated[PROMPT_KEYS.disabled]).toBe(true); + expect(validated[PROMPT_KEYS.deferredUntil]).toBe(1234); + expect(validated[PROMPT_KEYS.lastShownAt]).toBe(1000); + expect(validated[PROMPT_KEYS.nextEligibleValueCount]).toBe(2); + }); + + it("replaces invalid cooldown and threshold values with defaults", () => { + const validated = validateSettings({ + [PROMPT_KEYS.deferredUntil]: -1, + [PROMPT_KEYS.nextEligibleValueCount]: 0, + }); + + expect(validated[PROMPT_KEYS.deferredUntil]).toBe(0); + expect(validated[PROMPT_KEYS.nextEligibleValueCount]).toBe(1); + }); +}); diff --git a/src/config/settingsSchema/registry/general.ts b/src/config/settingsSchema/registry/general.ts index 3a6c9cdd7..8ef9d3208 100644 --- a/src/config/settingsSchema/registry/general.ts +++ b/src/config/settingsSchema/registry/general.ts @@ -264,6 +264,47 @@ export const GENERAL_SETTINGS_REGISTRY = { "Release channel for app updates. auto follows the installed build (prerelease builds track beta, release builds track stable); stable and beta pin the channel explicitly. Switching from beta to stable never downgrades — it takes effect at the next stable release", category: "general", }, + "general.setupWalkthroughOutcome": { + schema: z.enum(["open", "completed", "dismissed"]), + default: "open", + description: + "First-use setup walkthrough outcome. Open shows the walkthrough automatically; completed and dismissed keep it closed", + category: "general", + }, + "general.githubStarPromptCompleted": { + schema: z.boolean(), + default: false, + description: + "Whether GitHub has confirmed that the current user starred the canonical ORG2 repository", + category: "general", + }, + "general.githubStarPromptDisabled": { + schema: z.boolean(), + default: false, + description: "Permanently disable the optional GitHub Star reminder", + category: "general", + }, + "general.githubStarPromptDeferredUntil": { + schema: z.number().nonnegative(), + default: 0, + description: + "Unix timestamp in milliseconds before the GitHub Star reminder may appear again", + category: "general", + }, + "general.githubStarPromptLastShownAt": { + schema: z.number().nonnegative(), + default: 0, + description: + "Unix timestamp in milliseconds when the GitHub Star reminder was last shown", + category: "general", + }, + "general.githubStarPromptNextEligibleValueCount": { + schema: z.number().int().positive(), + default: 1, + description: + "Value-moment count required before the optional GitHub Star reminder is eligible", + category: "general", + }, "general.voiceInputEnabled": { schema: z.boolean(), default: true, diff --git a/src/features/GitHubStar/GitHubStarLocalization.test.ts b/src/features/GitHubStar/GitHubStarLocalization.test.ts new file mode 100644 index 000000000..944a24682 --- /dev/null +++ b/src/features/GitHubStar/GitHubStarLocalization.test.ts @@ -0,0 +1,52 @@ +import de from "@src/i18n/locales/de/settings.json"; +import en from "@src/i18n/locales/en/settings.json"; +import es from "@src/i18n/locales/es/settings.json"; +import fr from "@src/i18n/locales/fr/settings.json"; +import ja from "@src/i18n/locales/ja/settings.json"; +import ko from "@src/i18n/locales/ko/settings.json"; +import pl from "@src/i18n/locales/pl/settings.json"; +import pt from "@src/i18n/locales/pt/settings.json"; +import ru from "@src/i18n/locales/ru/settings.json"; +import tr from "@src/i18n/locales/tr/settings.json"; +import vi from "@src/i18n/locales/vi/settings.json"; +import zhHant from "@src/i18n/locales/zh-Hant/settings.json"; +import zh from "@src/i18n/locales/zh/settings.json"; + +const locales = { + de, + en, + es, + fr, + ja, + ko, + pl, + pt, + ru, + tr, + vi, + "zh-Hant": zhHant, + zh, +} as const; + +const requiredReminderKeys = [ + "star", + "openGitHub", + "reminderTitle", + "reminderDescription", + "later", + "neverAskAgain", +] as const; + +describe("GitHub Star reminder localization", () => { + it.each(Object.entries(locales))( + "%s provides every user-visible reminder string", + (_locale, resources) => { + const githubStar = resources.general.githubStar; + + for (const key of requiredReminderKeys) { + expect(githubStar[key]).toBeTypeOf("string"); + expect(githubStar[key].trim()).not.toBe(""); + } + } + ); +}); diff --git a/src/features/GitHubStar/GitHubStarReminder.test.ts b/src/features/GitHubStar/GitHubStarReminder.test.ts new file mode 100644 index 000000000..cb11ee286 --- /dev/null +++ b/src/features/GitHubStar/GitHubStarReminder.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + settingsAtom, + settingsLoadedAtom, +} from "@src/store/settings/settingsAtom"; + +import { + GITHUB_STAR_VALUE_MOMENT_EVENT, + GitHubStarReminderHost, + canConsumeGitHubStarValueMoment, + signalGitHubStarValueMoment, +} from "./GitHubStarReminder"; +import { + type GitHubStarPromptSettings, + githubStarPromptSettingsAtom, +} from "./promptSettings"; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +const invokeMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("@src/api/tauri/rpc/invoke", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + rpcCall: (...args: unknown[]) => invokeMock(...args), + }; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("./useGitHubStarController", () => ({ + useGitHubStarController: () => ({ + state: { status: "not-starred" }, + confirmStar: vi.fn(), + openFallback: vi.fn(), + }), +})); + +function settingsState( + overrides: Partial = {} +): GitHubStarPromptSettings { + return { + completed: false, + disabled: false, + deferredUntil: 0, + lastShownAt: 0, + nextEligibleValueCount: 1, + ...overrides, + }; +} + +describe("GitHub Star reminder state", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + invokeMock.mockClear(); + sessionStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + vi.restoreAllMocks(); + }); + + it("persists all prompt dimensions through the canonical settings atom", () => { + const store = createStore(); + store.set( + githubStarPromptSettingsAtom, + settingsState({ + completed: true, + disabled: true, + deferredUntil: 200, + lastShownAt: 100, + nextEligibleValueCount: 4, + }) + ); + + expect(store.get(settingsAtom)).toEqual( + expect.objectContaining({ + "general.githubStarPromptCompleted": true, + "general.githubStarPromptDisabled": true, + "general.githubStarPromptDeferredUntil": 200, + "general.githubStarPromptLastShownAt": 100, + "general.githubStarPromptNextEligibleValueCount": 4, + }) + ); + }); + + it("keeps a pending value moment untouched until onboarding has closed", () => { + expect(canConsumeGitHubStarValueMoment("/orgii/app/walkthrough")).toBe( + false + ); + expect(canConsumeGitHubStarValueMoment("/orgii/workstation/code")).toBe( + true + ); + }); + + it("delivers the pending reminder only after navigation leaves onboarding", async () => { + const store = createStore(); + store.set(settingsLoadedAtom, true); + store.set(settingsAtom, { + ...store.get(settingsAtom), + "general.githubStarPromptCompleted": false, + "general.githubStarPromptDisabled": false, + "general.githubStarPromptDeferredUntil": 0, + "general.githubStarPromptLastShownAt": 0, + "general.githubStarPromptNextEligibleValueCount": 1, + }); + sessionStorage.setItem("orgii.githubStar.pendingValueCount", "1"); + + const renderAt = (pathname: string) => + createElement( + Provider, + { store }, + createElement( + MemoryRouter, + { initialEntries: [pathname], key: pathname }, + createElement(GitHubStarReminderHost) + ) + ); + + await act(async () => { + root.render(renderAt("/orgii/app/walkthrough")); + await Promise.resolve(); + }); + expect(sessionStorage.getItem("orgii.githubStar.pendingValueCount")).toBe( + "1" + ); + expect(container.textContent).not.toContain( + "general.githubStar.reminderTitle" + ); + + await act(async () => { + root.render(renderAt("/orgii/workstation/code")); + await Promise.resolve(); + }); + expect(sessionStorage.getItem("orgii.githubStar.pendingValueCount")).toBe( + null + ); + expect(document.body.textContent).toContain( + "general.githubStar.reminderTitle" + ); + expect( + store.get(settingsAtom)["general.githubStarPromptLastShownAt"] + ).toBeGreaterThan(0); + }); + + it("records a pending onboarding value moment and dispatches once", () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + + signalGitHubStarValueMoment(2); + + expect(sessionStorage.getItem("orgii.githubStar.pendingValueCount")).toBe( + "2" + ); + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchEventSpy.mock.calls[0]?.[0].type).toBe( + GITHUB_STAR_VALUE_MOMENT_EVENT + ); + dispatchEventSpy.mockRestore(); + }); +}); diff --git a/src/features/GitHubStar/GitHubStarReminder.tsx b/src/features/GitHubStar/GitHubStarReminder.tsx new file mode 100644 index 000000000..adf4d8cce --- /dev/null +++ b/src/features/GitHubStar/GitHubStarReminder.tsx @@ -0,0 +1,198 @@ +import { useAtom, useAtomValue } from "jotai"; +import { ExternalLink, Star } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; + +import Button from "@src/components/Button"; +import { ROUTES } from "@src/config/routes"; +import Modal from "@src/scaffold/ModalSystem"; +import { settingsLoadedAtom } from "@src/store/settings/settingsAtom"; + +import { + deferGitHubStarPrompt, + githubStarPromptSettingsAtom, + isGitHubStarPromptEligible, +} from "./promptSettings"; +import { useGitHubStarController } from "./useGitHubStarController"; + +export const GITHUB_STAR_VALUE_MOMENT_EVENT = "orgii:github-star-value-moment"; +const GITHUB_STAR_PENDING_VALUE_COUNT_KEY = + "orgii.githubStar.pendingValueCount"; + +export function canConsumeGitHubStarValueMoment(pathname: string): boolean { + return pathname !== ROUTES.auth.setup.path; +} + +export function signalGitHubStarValueMoment(valueCount = 1): void { + const normalizedValueCount = Math.max(1, Math.floor(valueCount)); + sessionStorage.setItem( + GITHUB_STAR_PENDING_VALUE_COUNT_KEY, + String(normalizedValueCount) + ); + window.dispatchEvent( + new CustomEvent(GITHUB_STAR_VALUE_MOMENT_EVENT, { + detail: { valueCount: normalizedValueCount }, + }) + ); +} + +interface GitHubStarReminderDialogProps { + onClose: () => void; + onCompleted: () => void; + onDisable: () => void; + onLater: () => void; +} + +function GitHubStarReminderDialog({ + onClose, + onCompleted, + onDisable, + onLater, +}: GitHubStarReminderDialogProps) { + const { t } = useTranslation("settings"); + const { state, confirmStar, openFallback } = useGitHubStarController({ + source: "reminder", + onConfirmedStarred: onCompleted, + }); + const isBusy = state.status === "loading" || state.status === "starring"; + const needsBrowser = state.status === "web-fallback"; + + return ( + + +
+ + +
+ + } + > +

+ {t("general.githubStar.reminderDescription")} +

+ + {state.status} + +
+ ); +} + +export function GitHubStarReminderHost() { + const { pathname } = useLocation(); + const settingsLoaded = useAtomValue(settingsLoadedAtom); + const [settings, updateSettings] = useAtom(githubStarPromptSettingsAtom); + const settingsRef = useRef(settings); + const [visible, setVisible] = useState(false); + + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + + useEffect(() => { + if (!settingsLoaded || !canConsumeGitHubStarValueMoment(pathname)) return; + + const showIfEligible = (valueCount: number) => { + const now = Date.now(); + if (!isGitHubStarPromptEligible(settingsRef.current, now, valueCount)) { + return; + } + sessionStorage.removeItem(GITHUB_STAR_PENDING_VALUE_COUNT_KEY); + updateSettings({ lastShownAt: now }); + setVisible(true); + }; + + const pendingValueCount = Number( + sessionStorage.getItem(GITHUB_STAR_PENDING_VALUE_COUNT_KEY) + ); + if (Number.isFinite(pendingValueCount) && pendingValueCount > 0) { + showIfEligible(pendingValueCount); + } + + const handleValueMoment = (event: Event) => { + const valueCount = + event instanceof CustomEvent && + typeof event.detail?.valueCount === "number" + ? event.detail.valueCount + : 1; + showIfEligible(valueCount); + }; + + window.addEventListener(GITHUB_STAR_VALUE_MOMENT_EVENT, handleValueMoment); + return () => + window.removeEventListener( + GITHUB_STAR_VALUE_MOMENT_EVENT, + handleValueMoment + ); + }, [pathname, settingsLoaded, updateSettings]); + + const complete = useCallback(() => { + sessionStorage.removeItem(GITHUB_STAR_PENDING_VALUE_COUNT_KEY); + updateSettings({ completed: true, deferredUntil: 0 }); + setVisible(false); + }, [updateSettings]); + + const disable = useCallback(() => { + sessionStorage.removeItem(GITHUB_STAR_PENDING_VALUE_COUNT_KEY); + updateSettings({ disabled: true }); + setVisible(false); + }, [updateSettings]); + + const later = useCallback(() => { + sessionStorage.removeItem(GITHUB_STAR_PENDING_VALUE_COUNT_KEY); + updateSettings(deferGitHubStarPrompt(settingsRef.current)); + setVisible(false); + }, [updateSettings]); + + if (!visible) return null; + + return ( + + ); +} diff --git a/src/features/GitHubStar/GitHubStarSettingsRow.test.ts b/src/features/GitHubStar/GitHubStarSettingsRow.test.ts new file mode 100644 index 000000000..2c3dacd47 --- /dev/null +++ b/src/features/GitHubStar/GitHubStarSettingsRow.test.ts @@ -0,0 +1,61 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { GitHubStarSettingsRow } from "./GitHubStarSettingsRow"; +import type { GitHubStarController } from "./useGitHubStarController"; + +const { useControllerMock } = vi.hoisted(() => ({ + useControllerMock: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("./useGitHubStarController", () => ({ + useGitHubStarController: useControllerMock, +})); + +function controller( + state: GitHubStarController["state"] +): GitHubStarController { + return { + state, + source: "settings", + confirmStar: vi.fn(), + openFallback: vi.fn(), + retry: vi.fn(), + }; +} + +describe("GitHubStarSettingsRow", () => { + it("renders an accessible loading status", () => { + useControllerMock.mockReturnValue(controller({ status: "loading" })); + + const markup = renderToStaticMarkup(createElement(GitHubStarSettingsRow)); + + expect(markup).toContain("general.githubStar.label"); + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-live="polite"'); + expect(markup).toContain('aria-busy="true"'); + expect(markup).toContain("general.githubStar.loading"); + }); + + it("renders a confirmed state with a polite status", () => { + useControllerMock.mockReturnValue(controller({ status: "starred" })); + + const markup = renderToStaticMarkup( + createElement(GitHubStarSettingsRow, { + source: "reminder", + onConfirmedStarred: vi.fn(), + }) + ); + + expect(markup).toContain("general.githubStar.thanks"); + expect(markup).toContain('aria-busy="false"'); + expect(useControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ source: "reminder" }) + ); + }); +}); diff --git a/src/features/GitHubStar/GitHubStarSettingsRow.tsx b/src/features/GitHubStar/GitHubStarSettingsRow.tsx new file mode 100644 index 000000000..63efb6861 --- /dev/null +++ b/src/features/GitHubStar/GitHubStarSettingsRow.tsx @@ -0,0 +1,129 @@ +import { useSetAtom } from "jotai"; +import { Check, ExternalLink, Star } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { + SECTION_ACTION_GAP_CLASSES, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; + +import { type GitHubStarSource } from "./constants"; +import { githubStarPromptSettingsAtom } from "./promptSettings"; +import { useGitHubStarController } from "./useGitHubStarController"; + +export interface GitHubStarSettingsRowProps { + source?: GitHubStarSource; + onConfirmedStarred?: () => void; +} + +export function GitHubStarSettingsRow({ + source = "settings", + onConfirmedStarred, +}: GitHubStarSettingsRowProps) { + const { t } = useTranslation("settings"); + const updatePromptSettings = useSetAtom(githubStarPromptSettingsAtom); + const handleConfirmedStarred = () => { + updatePromptSettings({ completed: true, deferredUntil: 0 }); + onConfirmedStarred?.(); + }; + const { state, confirmStar, openFallback } = useGitHubStarController({ + source, + onConfirmedStarred: handleConfirmedStarred, + }); + + const statusText = (() => { + switch (state.status) { + case "loading": + return t("general.githubStar.loading"); + case "not-starred": + return t("general.githubStar.star"); + case "starring": + return t("general.githubStar.starring"); + case "starred": + return t("general.githubStar.thanks"); + case "web-fallback": { + const reasonKeys = { + gh_missing: "unavailableGhMissing", + not_authenticated: "unavailableNotAuthenticated", + network: "unavailableNetwork", + permission: "unavailablePermission", + timeout: "unavailableTimeout", + unexpected: "unavailableUnexpected", + } as const; + return t(`general.githubStar.${reasonKeys[state.reason]}`); + } + case "error": + return t("general.githubStar.unavailableUnexpected"); + } + })(); + const isBusy = state.status === "loading" || state.status === "starring"; + + let action = null; + if (state.status === "not-starred") { + action = ( + + ); + } else if (state.status === "web-fallback") { + action = ( + + ); + } else if (state.status === "error") { + action = ( + + ); + } else if (state.status === "starred") { + action = ( + + + ); + } else { + action = ( + + ); + } + + return ( + +
+ + {statusText} + + {action} +
+
+ ); +} diff --git a/src/features/GitHubStar/constants.ts b/src/features/GitHubStar/constants.ts new file mode 100644 index 000000000..c28c11b07 --- /dev/null +++ b/src/features/GitHubStar/constants.ts @@ -0,0 +1,4 @@ +export const ORGII_GITHUB_URL = "https://github.com/yorgai/ORG2"; +export const ORGII_GITHUB_STARGAZERS_URL = `${ORGII_GITHUB_URL}/stargazers`; + +export type GitHubStarSource = "settings" | "reminder" | "onboarding"; diff --git a/src/features/GitHubStar/githubStarClient.test.ts b/src/features/GitHubStar/githubStarClient.test.ts new file mode 100644 index 000000000..146e86552 --- /dev/null +++ b/src/features/GitHubStar/githubStarClient.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { checkOrgiiStar, starOrgii } from "@src/api/tauri/githubStar"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => vi.clearAllMocks()); + +describe("GitHub Star Tauri client", () => { + it("single-flights concurrent checks and clears the flight afterward", async () => { + const first = deferred<{ status: "not_starred" }>(); + invokeMock.mockReturnValueOnce(first.promise); + + const a = checkOrgiiStar(); + const b = checkOrgiiStar(); + + expect(a).toBe(b); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("check_orgii_star"); + + first.resolve({ status: "not_starred" }); + await a; + invokeMock.mockResolvedValueOnce({ status: "starred" }); + await checkOrgiiStar(); + expect(invokeMock).toHaveBeenCalledTimes(2); + }); + + it("single-flights star requests independently from checks", async () => { + const check = deferred<{ status: "not_starred" }>(); + const star = deferred<{ status: "starred" }>(); + invokeMock.mockImplementation((command: string) => + command === "check_orgii_star" ? check.promise : star.promise + ); + + const checkRequest = checkOrgiiStar(); + const firstStar = starOrgii(); + const secondStar = starOrgii(); + + expect(firstStar).toBe(secondStar); + expect(invokeMock).toHaveBeenCalledTimes(2); + expect(invokeMock).toHaveBeenCalledWith("star_orgii"); + + check.resolve({ status: "not_starred" }); + star.resolve({ status: "starred" }); + await Promise.all([checkRequest, firstStar]); + }); +}); diff --git a/src/features/GitHubStar/index.ts b/src/features/GitHubStar/index.ts new file mode 100644 index 000000000..b3b7f2f74 --- /dev/null +++ b/src/features/GitHubStar/index.ts @@ -0,0 +1,25 @@ +export { ORGII_GITHUB_STARGAZERS_URL, ORGII_GITHUB_URL } from "./constants"; +export type { GitHubStarSource } from "./constants"; +export { + GitHubStarReminderHost, + GITHUB_STAR_VALUE_MOMENT_EVENT, + signalGitHubStarValueMoment, +} from "./GitHubStarReminder"; +export { + githubStarPromptSettingsAtom, + isGitHubStarPromptEligible, + deferGitHubStarPrompt, + GITHUB_STAR_PROMPT_COOLDOWN_MS, + type GitHubStarPromptSettings, +} from "./promptSettings"; +export { + GitHubStarSettingsRow, + type GitHubStarSettingsRowProps, +} from "./GitHubStarSettingsRow"; +export { + useGitHubStarController, + type GitHubStarController, + type GitHubStarControllerDependencies, + type GitHubStarControllerState, + type UseGitHubStarControllerOptions, +} from "./useGitHubStarController"; diff --git a/src/features/GitHubStar/promptSettings.test.ts b/src/features/GitHubStar/promptSettings.test.ts new file mode 100644 index 000000000..b67d84e77 --- /dev/null +++ b/src/features/GitHubStar/promptSettings.test.ts @@ -0,0 +1,46 @@ +import { + GITHUB_STAR_PROMPT_COOLDOWN_MS, + type GitHubStarPromptSettings, + deferGitHubStarPrompt, + isGitHubStarPromptEligible, +} from "./promptSettings"; + +const ELIGIBLE: GitHubStarPromptSettings = { + completed: false, + disabled: false, + deferredUntil: 0, + lastShownAt: 0, + nextEligibleValueCount: 1, +}; + +describe("GitHub Star prompt gating", () => { + it("blocks completed, disabled, cooling-down, and below-threshold prompts", () => { + expect(isGitHubStarPromptEligible(ELIGIBLE, 100, 1)).toBe(true); + expect( + isGitHubStarPromptEligible({ ...ELIGIBLE, completed: true }, 100, 1) + ).toBe(false); + expect( + isGitHubStarPromptEligible({ ...ELIGIBLE, disabled: true }, 100, 1) + ).toBe(false); + expect( + isGitHubStarPromptEligible({ ...ELIGIBLE, deferredUntil: 101 }, 100, 1) + ).toBe(false); + expect( + isGitHubStarPromptEligible( + { ...ELIGIBLE, nextEligibleValueCount: 2 }, + 100, + 1 + ) + ).toBe(false); + }); + + it("defers for three days and doubles the next value threshold", () => { + expect( + deferGitHubStarPrompt({ ...ELIGIBLE, nextEligibleValueCount: 2 }, 1000) + ).toEqual({ + deferredUntil: 1000 + GITHUB_STAR_PROMPT_COOLDOWN_MS, + lastShownAt: 1000, + nextEligibleValueCount: 4, + }); + }); +}); diff --git a/src/features/GitHubStar/promptSettings.ts b/src/features/GitHubStar/promptSettings.ts new file mode 100644 index 000000000..4ede980ed --- /dev/null +++ b/src/features/GitHubStar/promptSettings.ts @@ -0,0 +1,71 @@ +import { atom } from "jotai"; + +import { + settingsAtom, + updateSettingsBatchAtom, +} from "@src/store/settings/settingsAtom"; + +export interface GitHubStarPromptSettings { + completed: boolean; + disabled: boolean; + deferredUntil: number; + lastShownAt: number; + nextEligibleValueCount: number; +} + +export const GITHUB_STAR_PROMPT_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; + +const githubStarPromptSettingsValueAtom = atom( + (get) => { + const settings = get(settingsAtom); + return { + completed: settings["general.githubStarPromptCompleted"], + disabled: settings["general.githubStarPromptDisabled"], + deferredUntil: settings["general.githubStarPromptDeferredUntil"], + lastShownAt: settings["general.githubStarPromptLastShownAt"], + nextEligibleValueCount: + settings["general.githubStarPromptNextEligibleValueCount"], + }; + } +); + +export const githubStarPromptSettingsAtom = atom( + (get) => get(githubStarPromptSettingsValueAtom), + (get, set, update: Partial) => { + const current = get(githubStarPromptSettingsValueAtom); + const next = { ...current, ...update }; + set(updateSettingsBatchAtom, { + "general.githubStarPromptCompleted": next.completed, + "general.githubStarPromptDisabled": next.disabled, + "general.githubStarPromptDeferredUntil": next.deferredUntil, + "general.githubStarPromptLastShownAt": next.lastShownAt, + "general.githubStarPromptNextEligibleValueCount": + next.nextEligibleValueCount, + }); + } +); +githubStarPromptSettingsAtom.debugLabel = "githubStarPromptSettingsAtom"; + +export function isGitHubStarPromptEligible( + settings: GitHubStarPromptSettings, + now = Date.now(), + valueCount = 1 +): boolean { + return ( + !settings.completed && + !settings.disabled && + settings.deferredUntil <= now && + valueCount >= settings.nextEligibleValueCount + ); +} + +export function deferGitHubStarPrompt( + settings: GitHubStarPromptSettings, + now = Date.now() +): Partial { + return { + deferredUntil: now + GITHUB_STAR_PROMPT_COOLDOWN_MS, + lastShownAt: now, + nextEligibleValueCount: Math.max(2, settings.nextEligibleValueCount * 2), + }; +} diff --git a/src/features/GitHubStar/useGitHubStarController.test.ts b/src/features/GitHubStar/useGitHubStarController.test.ts new file mode 100644 index 000000000..b393d138c --- /dev/null +++ b/src/features/GitHubStar/useGitHubStarController.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { GitHubStarResult } from "@src/api/tauri/githubStar"; + +import { ORGII_GITHUB_URL } from "./constants"; +import { + type GitHubStarController, + type GitHubStarControllerDependencies, + useGitHubStarController, +} from "./useGitHubStarController"; + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useGitHubStarController", () => { + let container: HTMLDivElement; + let root: Root; + let controller: GitHubStarController; + let dependencies: GitHubStarControllerDependencies; + let checkMock: ReturnType; + let starMock: ReturnType; + let openExternalMock: ReturnType; + let onConfirmedStarred: ReturnType; + let onController: (value: GitHubStarController) => void; + + function Harness() { + const currentController = useGitHubStarController({ + source: "reminder", + onConfirmedStarred, + dependencies, + }); + + useEffect(() => { + onController(currentController); + }, [currentController]); + + return createElement("span", null, currentController.state.status); + } + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + checkMock = vi.fn().mockResolvedValue({ status: "not_starred" }); + starMock = vi.fn().mockResolvedValue({ status: "starred" }); + openExternalMock = vi.fn().mockResolvedValue(undefined); + onConfirmedStarred = vi.fn(); + onController = (value) => { + controller = value; + }; + dependencies = { + check: checkMock as () => Promise, + star: starMock as () => Promise, + openExternal: openExternalMock as (url: string) => Promise, + }; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("moves from loading to not-starred and confirms only backend success", async () => { + const star = deferred(); + starMock.mockReturnValueOnce(star.promise); + + act(() => root.render(createElement(Harness))); + expect(container.textContent).toBe("loading"); + await flush(); + expect(container.textContent).toBe("not-starred"); + + let action!: Promise; + act(() => { + action = controller.confirmStar(); + }); + expect(container.textContent).toBe("starring"); + expect(onConfirmedStarred).not.toHaveBeenCalled(); + + star.resolve({ status: "starred" }); + await act(async () => action); + expect(container.textContent).toBe("starred"); + expect(onConfirmedStarred).toHaveBeenCalledTimes(1); + }); + + it("opening the fallback does not count as success", async () => { + starMock.mockResolvedValueOnce({ + status: "unavailable", + reason: "gh_missing", + }); + act(() => root.render(createElement(Harness))); + await flush(); + + await act(async () => controller.confirmStar()); + + expect(openExternalMock).toHaveBeenCalledWith(ORGII_GITHUB_URL); + expect(controller.state.status).toBe("web-fallback"); + expect(onConfirmedStarred).not.toHaveBeenCalled(); + }); + + it("rechecks on focus only after this controller opened a fallback", async () => { + act(() => root.render(createElement(Harness))); + await flush(); + + act(() => window.dispatchEvent(new Event("focus"))); + expect(checkMock).toHaveBeenCalledTimes(1); + + await act(async () => controller.openFallback()); + checkMock.mockResolvedValueOnce({ status: "starred" }); + act(() => window.dispatchEvent(new Event("focus"))); + await flush(); + + expect(checkMock).toHaveBeenCalledTimes(2); + expect(controller.state.status).toBe("starred"); + expect(onConfirmedStarred).toHaveBeenCalledTimes(1); + }); + + it("removes its action-driven focus listener on unmount", async () => { + const addSpy = vi.spyOn(window, "addEventListener"); + const removeSpy = vi.spyOn(window, "removeEventListener"); + act(() => root.render(createElement(Harness))); + await flush(); + await act(async () => controller.openFallback()); + + const focusHandler = addSpy.mock.calls.find( + ([type]) => type === "focus" + )?.[1]; + expect(focusHandler).toBeDefined(); + + act(() => root.unmount()); + expect(removeSpy).toHaveBeenCalledWith("focus", focusHandler); + root = createRoot(container); + }); +}); diff --git a/src/features/GitHubStar/useGitHubStarController.ts b/src/features/GitHubStar/useGitHubStarController.ts new file mode 100644 index 000000000..109aa61da --- /dev/null +++ b/src/features/GitHubStar/useGitHubStarController.ts @@ -0,0 +1,164 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + type GitHubStarResult, + checkOrgiiStar, + starOrgii, +} from "@src/api/tauri/githubStar"; + +import { type GitHubStarSource, ORGII_GITHUB_URL } from "./constants"; + +export type GitHubStarControllerState = + | { status: "loading" } + | { status: "not-starred" } + | { status: "starring" } + | { status: "starred" } + | { + status: "web-fallback"; + reason: Extract["reason"]; + } + | { status: "error"; error: unknown }; + +export interface GitHubStarControllerDependencies { + check: () => Promise; + star: () => Promise; + openExternal: (url: string) => Promise; +} + +export interface UseGitHubStarControllerOptions { + source: GitHubStarSource; + onConfirmedStarred?: () => void; + dependencies?: Partial; +} + +export interface GitHubStarController { + state: GitHubStarControllerState; + source: GitHubStarSource; + confirmStar: () => Promise; + openFallback: () => Promise; + retry: () => Promise; +} + +const DEFAULT_DEPENDENCIES: GitHubStarControllerDependencies = { + check: checkOrgiiStar, + star: starOrgii, + openExternal: openUrl, +}; + +export function useGitHubStarController({ + source, + onConfirmedStarred, + dependencies, +}: UseGitHubStarControllerOptions): GitHubStarController { + const resolvedDependencies = useRef({ + ...DEFAULT_DEPENDENCIES, + ...dependencies, + }); + const onConfirmedStarredRef = useRef(onConfirmedStarred); + const mountedRef = useRef(false); + const generationRef = useRef(0); + const notifiedRef = useRef(false); + const fallbackOpenedRef = useRef(false); + const [fallbackOpened, setFallbackOpened] = useState(false); + const [state, setState] = useState({ + status: "loading", + }); + + useEffect(() => { + onConfirmedStarredRef.current = onConfirmedStarred; + }, [onConfirmedStarred]); + + const commitResult = useCallback((result: GitHubStarResult): void => { + if (result.status === "starred") { + setState({ status: "starred" }); + if (!notifiedRef.current) { + notifiedRef.current = true; + onConfirmedStarredRef.current?.(); + } + return; + } + + if (result.status === "not_starred") { + setState({ status: "not-starred" }); + return; + } + + setState({ status: "web-fallback", reason: result.reason }); + }, []); + + const runCheck = useCallback( + async (showLoading: boolean): Promise => { + const generation = ++generationRef.current; + if (showLoading) setState({ status: "loading" }); + + try { + const result = await resolvedDependencies.current.check(); + if (!mountedRef.current || generation !== generationRef.current) return; + commitResult(result); + } catch (error: unknown) { + if (!mountedRef.current || generation !== generationRef.current) return; + setState({ status: "error", error }); + } + }, + [commitResult] + ); + + useEffect(() => { + mountedRef.current = true; + queueMicrotask(() => { + if (mountedRef.current) void runCheck(false); + }); + return () => { + mountedRef.current = false; + generationRef.current += 1; + }; + }, [runCheck]); + + useEffect(() => { + if (!fallbackOpened) return; + + const handleFocus = () => { + if (!fallbackOpenedRef.current) return; + void runCheck(false); + }; + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, [fallbackOpened, runCheck]); + + const openFallback = useCallback(async (): Promise => { + const generation = ++generationRef.current; + try { + await resolvedDependencies.current.openExternal(ORGII_GITHUB_URL); + if (!mountedRef.current || generation !== generationRef.current) return; + fallbackOpenedRef.current = true; + setFallbackOpened(true); + } catch (error: unknown) { + if (!mountedRef.current || generation !== generationRef.current) return; + setState({ status: "error", error }); + } + }, []); + + const confirmStar = useCallback(async (): Promise => { + const generation = ++generationRef.current; + setState({ status: "starring" }); + + try { + const result = await resolvedDependencies.current.star(); + if (!mountedRef.current || generation !== generationRef.current) return; + if (result.status === "unavailable") { + setState({ status: "web-fallback", reason: result.reason }); + await openFallback(); + return; + } + commitResult(result); + } catch (error: unknown) { + if (!mountedRef.current || generation !== generationRef.current) return; + setState({ status: "error", error }); + } + }, [commitResult, openFallback]); + + const retry = useCallback(() => runCheck(true), [runCheck]); + + return { state, source, confirmStar, openFallback, retry }; +} diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 53d839fc4..9eef3dc70 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Ich bin an der Tastatur. Du kannst mir jederzeit Rückfragen stellen und destruktive Aktionen vor der Ausführung von mir bestätigen lassen", "presenceGuidanceInvisibleDefault": "Ich bin in der Nähe, erscheine aber offline. Arbeite standardmäßig autonom und benachrichtige mich nur bei riskanten Aktionen oder größeren Refactorings; fasse alle anderen Fragen gesammelt zusammen, statt sie einzeln zu stellen", "presenceGuidanceAwayDefault": "Ich bin nicht an der Tastatur. Warte nicht auf mich, sondern triff mit den vorhandenen Informationen die beste Entscheidung, erledige alles Mögliche und hinterlasse eine knappe Zusammenfassung mit offenen Fragen für meine Rückkehr", - "followSystem": "System folgen" + "followSystem": "System folgen", + "githubStar": { + "label": "ORG2 auf GitHub markieren", + "description": "Unterstütze das Projekt mit einem GitHub-Star.", + "loading": "Wird geprüft...", + "star": "Star geben", + "starring": "Wird markiert...", + "starred": "Markiert", + "thanks": "Danke für deine Unterstützung!", + "openGitHub": "GitHub öffnen", + "openingGitHub": "Wird geöffnet...", + "unavailableGhMissing": "GitHub CLI ist nicht verfügbar", + "unavailableNotAuthenticated": "Bitte bei GitHub CLI anmelden", + "unavailableNetwork": "GitHub ist nicht erreichbar", + "unavailablePermission": "GitHub-Berechtigung erforderlich", + "unavailableTimeout": "GitHub-Anfrage abgelaufen", + "unavailableUnexpected": "GitHub-Status nicht verfügbar", + "reminderTitle": "ORG2 unterstützen", + "reminderDescription": "Wenn ORG2 hilfreich war, gib dem Projekt bitte einen Star auf GitHub.", + "later": "Später", + "neverAskAgain": "Nicht erneut fragen" + } }, "background": { "title": "Hintergrund", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d379b5736..cdee4261e 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "I am at the keyboard. Feel free to ask me clarifying questions at any time and confirm any destructive actions with me before running them", "presenceGuidanceInvisibleDefault": "I am around but appearing offline. Default to autonomous execution and only notify me for high-risk actions or significant refactoring work; batch any other questions into a single summary instead of asking one by one", "presenceGuidanceAwayDefault": "I am away from the keyboard. Do not block on me — make the best decision you can with the information you have, finish what you can finish, and leave a concise summary of what happened and any open questions for when I return", - "followSystem": "Follow system" + "followSystem": "Follow system", + "githubStar": { + "label": "Star ORG2 on GitHub", + "description": "Support the project with a GitHub star.", + "loading": "Checking...", + "star": "Star", + "starring": "Starring...", + "starred": "Starred", + "thanks": "Thanks for the support!", + "openGitHub": "Open GitHub", + "openingGitHub": "Opening...", + "unavailableGhMissing": "GitHub CLI is unavailable", + "unavailableNotAuthenticated": "Sign in with GitHub CLI to star directly", + "unavailableNetwork": "GitHub is unreachable", + "unavailablePermission": "GitHub permission is required", + "unavailableTimeout": "GitHub request timed out", + "unavailableUnexpected": "GitHub status is unavailable", + "reminderTitle": "Support ORG2", + "reminderDescription": "If ORG2 has been useful, would you consider starring it on GitHub?", + "later": "Later", + "neverAskAgain": "Don't ask again" + } }, "background": { "title": "Background", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 689a8c521..02aeab0bc 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Estoy frente al teclado. Puedes hacerme preguntas aclaratorias en cualquier momento y confirmar conmigo cualquier acción destructiva antes de ejecutarla", "presenceGuidanceInvisibleDefault": "Estoy disponible, pero aparezco sin conexión. Por defecto trabaja de forma autónoma y avísame solo ante acciones de alto riesgo o refactorizaciones importantes; agrupa las demás preguntas en un único resumen en vez de hacerlas una por una", "presenceGuidanceAwayDefault": "Estoy lejos del teclado. No te bloquees esperando mi respuesta; toma la mejor decisión con la información disponible, termina lo que puedas y deja un resumen breve con lo ocurrido y las preguntas abiertas para cuando vuelva", - "followSystem": "Seguir el sistema" + "followSystem": "Seguir el sistema", + "githubStar": { + "label": "Dar una estrella a ORG2 en GitHub", + "description": "Apoya el proyecto con una estrella en GitHub.", + "loading": "Comprobando...", + "star": "Dar estrella", + "starring": "Añadiendo...", + "starred": "Con estrella", + "thanks": "¡Gracias por tu apoyo!", + "openGitHub": "Abrir GitHub", + "openingGitHub": "Abriendo...", + "unavailableGhMissing": "GitHub CLI no está disponible", + "unavailableNotAuthenticated": "Inicia sesión en GitHub CLI", + "unavailableNetwork": "No se puede acceder a GitHub", + "unavailablePermission": "Se requiere permiso de GitHub", + "unavailableTimeout": "La solicitud a GitHub agotó el tiempo", + "unavailableUnexpected": "Estado de GitHub no disponible", + "reminderTitle": "Apoya ORG2", + "reminderDescription": "Si ORG2 te ha sido útil, ¿considerarías darle una estrella en GitHub?", + "later": "Más tarde", + "neverAskAgain": "No volver a preguntar" + } }, "background": { "title": "Fondo", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index d0bb08022..5013483e6 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Je suis au clavier. Tu peux me poser des questions de clarification à tout moment et confirmer avec moi toute action destructive avant de l'exécuter", "presenceGuidanceInvisibleDefault": "Je suis disponible mais affiché hors ligne. Par défaut, travaille de façon autonome et préviens-moi seulement pour les actions à risque élevé ou les refactorisations importantes ; regroupe les autres questions dans un seul résumé au lieu de les poser une par une", "presenceGuidanceAwayDefault": "Je suis loin du clavier. Ne bloque pas en m'attendant ; prends la meilleure décision avec les informations disponibles, termine ce qui peut l'être et laisse un bref résumé de ce qui s'est passé avec les questions ouvertes pour mon retour", - "followSystem": "Suivre le système" + "followSystem": "Suivre le système", + "githubStar": { + "label": "Ajouter une étoile à ORG2 sur GitHub", + "description": "Soutenez le projet avec une étoile GitHub.", + "loading": "Vérification...", + "star": "Ajouter une étoile", + "starring": "Ajout en cours...", + "starred": "Étoile ajoutée", + "thanks": "Merci pour votre soutien !", + "openGitHub": "Ouvrir GitHub", + "openingGitHub": "Ouverture...", + "unavailableGhMissing": "GitHub CLI n’est pas disponible", + "unavailableNotAuthenticated": "Connectez-vous à GitHub CLI", + "unavailableNetwork": "GitHub est inaccessible", + "unavailablePermission": "Autorisation GitHub requise", + "unavailableTimeout": "La requête GitHub a expiré", + "unavailableUnexpected": "État GitHub indisponible", + "reminderTitle": "Soutenir ORG2", + "reminderDescription": "Si ORG2 vous a été utile, pourriez-vous lui ajouter une étoile sur GitHub ?", + "later": "Plus tard", + "neverAskAgain": "Ne plus demander" + } }, "background": { "title": "Arrière-plan", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 95a20ab1c..9f3c2104e 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "私はキーボードの前にいます。不明点はいつでも確認し、破壊的な操作を実行する前に必ず確認してください", "presenceGuidanceInvisibleDefault": "近くにいますがオフライン表示です。基本的には自律的に進め、高リスクな操作や大きなリファクタリングのときだけ通知してください。その他の質問は一つずつではなく、まとめてください", "presenceGuidanceAwayDefault": "私は席を外しています。私の返答を待たず、手元の情報で最善の判断をして、完了できることを進め、戻ったとき用に結果と未解決の質問を簡潔に残してください", - "followSystem": "システムに従う" + "followSystem": "システムに従う", + "githubStar": { + "label": "GitHub で ORG2 に Star を付ける", + "description": "GitHub Star でプロジェクトを応援してください。", + "loading": "確認中...", + "star": "Star を付ける", + "starring": "追加中...", + "starred": "Star 済み", + "thanks": "ご支援ありがとうございます!", + "openGitHub": "GitHub を開く", + "openingGitHub": "開いています...", + "unavailableGhMissing": "GitHub CLI を利用できません", + "unavailableNotAuthenticated": "GitHub CLI にログインしてください", + "unavailableNetwork": "GitHub に接続できません", + "unavailablePermission": "GitHub の権限が必要です", + "unavailableTimeout": "GitHub リクエストがタイムアウトしました", + "unavailableUnexpected": "GitHub の状態を取得できません", + "reminderTitle": "ORG2 を応援", + "reminderDescription": "ORG2 が役に立ったら、GitHub で Star を付けていただけませんか?", + "later": "後で", + "neverAskAgain": "今後表示しない" + } }, "background": { "title": "背景", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index f9684ff35..ecc01932f 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "저는 키보드 앞에 있습니다. 언제든 확인 질문을 해도 되고, 파괴적인 작업을 실행하기 전에는 저에게 확인해 주세요", "presenceGuidanceInvisibleDefault": "근처에 있지만 오프라인으로 표시 중입니다. 기본적으로 자율적으로 진행하고, 고위험 작업이나 큰 리팩터링일 때만 알려 주세요. 그 외 질문은 하나씩 묻지 말고 한 번에 요약해 주세요", "presenceGuidanceAwayDefault": "저는 자리를 비웠습니다. 제 답변을 기다리지 말고 현재 정보로 최선의 결정을 내려 완료할 수 있는 일을 마치고, 돌아왔을 때 볼 수 있도록 결과와 열린 질문을 간단히 남겨 주세요", - "followSystem": "시스템 설정 따르기" + "followSystem": "시스템 설정 따르기", + "githubStar": { + "label": "GitHub에서 ORG2에 Star 주기", + "description": "GitHub Star로 프로젝트를 응원해 주세요.", + "loading": "확인 중...", + "star": "Star 주기", + "starring": "추가 중...", + "starred": "Star 완료", + "thanks": "지원해 주셔서 감사합니다!", + "openGitHub": "GitHub 열기", + "openingGitHub": "여는 중...", + "unavailableGhMissing": "GitHub CLI를 사용할 수 없습니다", + "unavailableNotAuthenticated": "GitHub CLI에 로그인해 주세요", + "unavailableNetwork": "GitHub에 연결할 수 없습니다", + "unavailablePermission": "GitHub 권한이 필요합니다", + "unavailableTimeout": "GitHub 요청 시간이 초과되었습니다", + "unavailableUnexpected": "GitHub 상태를 확인할 수 없습니다", + "reminderTitle": "ORG2 지원하기", + "reminderDescription": "ORG2가 도움이 되었다면 GitHub에서 Star를 주시겠어요?", + "later": "나중에", + "neverAskAgain": "다시 묻지 않기" + } }, "background": { "title": "배경", diff --git a/src/i18n/locales/pl/settings.json b/src/i18n/locales/pl/settings.json index f31238ca3..b7d9c3d95 100644 --- a/src/i18n/locales/pl/settings.json +++ b/src/i18n/locales/pl/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Jestem przy klawiaturze. Możesz w każdej chwili zadawać pytania doprecyzowujące i potwierdzać ze mną działania destrukcyjne przed ich uruchomieniem", "presenceGuidanceInvisibleDefault": "Jestem w pobliżu, ale widoczny jako offline. Domyślnie działaj autonomicznie i powiadamiaj mnie tylko o działaniach wysokiego ryzyka lub dużych refaktoryzacjach; pozostałe pytania zbierz w jednym podsumowaniu zamiast zadawać je pojedynczo", "presenceGuidanceAwayDefault": "Jestem z dala od klawiatury. Nie blokuj się, czekając na mnie; podejmij najlepszą decyzję na podstawie dostępnych informacji, dokończ co się da i zostaw krótkie podsumowanie oraz otwarte pytania na mój powrót", - "followSystem": "Zgodnie z systemem" + "followSystem": "Zgodnie z systemem", + "githubStar": { + "label": "Dodaj gwiazdkę ORG2 na GitHubie", + "description": "Wesprzyj projekt gwiazdką na GitHubie.", + "loading": "Sprawdzanie...", + "star": "Dodaj gwiazdkę", + "starring": "Dodawanie...", + "starred": "Dodano gwiazdkę", + "thanks": "Dziękujemy za wsparcie!", + "openGitHub": "Otwórz GitHub", + "openingGitHub": "Otwieranie...", + "unavailableGhMissing": "GitHub CLI jest niedostępny", + "unavailableNotAuthenticated": "Zaloguj się w GitHub CLI", + "unavailableNetwork": "GitHub jest nieosiągalny", + "unavailablePermission": "Wymagane uprawnienie GitHub", + "unavailableTimeout": "Upłynął limit czasu GitHub", + "unavailableUnexpected": "Stan GitHub jest niedostępny", + "reminderTitle": "Wesprzyj ORG2", + "reminderDescription": "Jeśli ORG2 jest pomocny, rozważ dodanie gwiazdki na GitHubie.", + "later": "Później", + "neverAskAgain": "Nie pytaj ponownie" + } }, "background": { "title": "Tło", diff --git a/src/i18n/locales/pt/settings.json b/src/i18n/locales/pt/settings.json index bf0727925..4e3dd71b4 100644 --- a/src/i18n/locales/pt/settings.json +++ b/src/i18n/locales/pt/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Estou ao teclado. Podes fazer perguntas de esclarecimento a qualquer momento e confirmar comigo qualquer ação destrutiva antes de a executar", "presenceGuidanceInvisibleDefault": "Estou por perto, mas a aparecer offline. Por padrão, trabalha de forma autónoma e avisa-me apenas sobre ações de alto risco ou refatorações significativas; junta as outras perguntas num único resumo em vez de as fazer uma a uma", "presenceGuidanceAwayDefault": "Estou longe do teclado. Não fiques bloqueado à minha espera; toma a melhor decisão com a informação disponível, termina o que conseguires e deixa um resumo breve do que aconteceu e das perguntas em aberto para quando eu voltar", - "followSystem": "Seguir o sistema" + "followSystem": "Seguir o sistema", + "githubStar": { + "label": "Dar uma estrela ao ORG2 no GitHub", + "description": "Apoie o projeto com uma estrela no GitHub.", + "loading": "Verificando...", + "star": "Dar estrela", + "starring": "Adicionando...", + "starred": "Com estrela", + "thanks": "Obrigado pelo apoio!", + "openGitHub": "Abrir GitHub", + "openingGitHub": "Abrindo...", + "unavailableGhMissing": "GitHub CLI não está disponível", + "unavailableNotAuthenticated": "Entre no GitHub CLI", + "unavailableNetwork": "GitHub está inacessível", + "unavailablePermission": "Permissão do GitHub necessária", + "unavailableTimeout": "A solicitação ao GitHub expirou", + "unavailableUnexpected": "Status do GitHub indisponível", + "reminderTitle": "Apoie o ORG2", + "reminderDescription": "Se o ORG2 foi útil, considere dar uma estrela no GitHub.", + "later": "Mais tarde", + "neverAskAgain": "Não perguntar novamente" + } }, "background": { "title": "Plano de fundo", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 72f13fc7f..ddbea42ba 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Я у клавиатуры. Можешь в любой момент задавать уточняющие вопросы и согласовывать со мной разрушительные действия перед запуском", "presenceGuidanceInvisibleDefault": "Я рядом, но отображаюсь офлайн. По умолчанию действуй автономно и уведомляй меня только о действиях с высоким риском или значительном рефакторинге; остальные вопросы собирай в одно резюме, а не задавай по одному", "presenceGuidanceAwayDefault": "Я отошёл от клавиатуры. Не жди моего ответа; прими лучшее решение по имеющейся информации, заверши всё возможное и оставь краткое резюме произошедшего и открытые вопросы к моему возвращению", - "followSystem": "Следовать системе" + "followSystem": "Следовать системе", + "githubStar": { + "label": "Поставить звезду ORG2 на GitHub", + "description": "Поддержите проект звездой на GitHub.", + "loading": "Проверка...", + "star": "Поставить звезду", + "starring": "Добавление...", + "starred": "Звезда поставлена", + "thanks": "Спасибо за поддержку!", + "openGitHub": "Открыть GitHub", + "openingGitHub": "Открытие...", + "unavailableGhMissing": "GitHub CLI недоступен", + "unavailableNotAuthenticated": "Войдите через GitHub CLI", + "unavailableNetwork": "GitHub недоступен", + "unavailablePermission": "Требуется разрешение GitHub", + "unavailableTimeout": "Время запроса GitHub истекло", + "unavailableUnexpected": "Статус GitHub недоступен", + "reminderTitle": "Поддержать ORG2", + "reminderDescription": "Если ORG2 был полезен, поставьте проекту звезду на GitHub.", + "later": "Позже", + "neverAskAgain": "Больше не спрашивать" + } }, "background": { "title": "Фон", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index ba1addd8d..83f673450 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Klavyenin başındayım. İstediğin zaman netleştirici sorular sorabilir ve yıkıcı işlemleri çalıştırmadan önce bana onaylatabilirsin", "presenceGuidanceInvisibleDefault": "Yakındayım ama çevrimdışı görünüyorum. Varsayılan olarak otonom ilerle ve yalnızca yüksek riskli işlemler ya da önemli refaktörler için beni bilgilendir; diğer soruları tek tek sormak yerine tek bir özette topla", "presenceGuidanceAwayDefault": "Klavyeden uzaktayım. Beni bekleyip takılma; elindeki bilgilerle en iyi kararı ver, bitirebildiklerini bitir ve döndüğümde bakmam için olanları ve açık soruları kısa bir özet olarak bırak", - "followSystem": "Sistemi izle" + "followSystem": "Sistemi izle", + "githubStar": { + "label": "GitHub'da ORG2'ye yıldız ver", + "description": "Projeyi bir GitHub yıldızıyla destekleyin.", + "loading": "Kontrol ediliyor...", + "star": "Yıldız ver", + "starring": "Ekleniyor...", + "starred": "Yıldız verildi", + "thanks": "Desteğiniz için teşekkürler!", + "openGitHub": "GitHub'ı aç", + "openingGitHub": "Açılıyor...", + "unavailableGhMissing": "GitHub CLI kullanılamıyor", + "unavailableNotAuthenticated": "GitHub CLI'da oturum açın", + "unavailableNetwork": "GitHub'a ulaşılamıyor", + "unavailablePermission": "GitHub izni gerekli", + "unavailableTimeout": "GitHub isteği zaman aşımına uğradı", + "unavailableUnexpected": "GitHub durumu kullanılamıyor", + "reminderTitle": "ORG2'yi destekle", + "reminderDescription": "ORG2 işinize yaradıysa GitHub'da yıldız vermeyi düşünür müsünüz?", + "later": "Daha sonra", + "neverAskAgain": "Bir daha sorma" + } }, "background": { "title": "Arka Plan", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index d121cd350..808042603 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "Tôi đang ở bàn phím. Bạn có thể hỏi tôi để làm rõ bất cứ lúc nào và xác nhận với tôi trước khi chạy bất kỳ hành động phá hủy nào", "presenceGuidanceInvisibleDefault": "Tôi đang ở gần nhưng hiển thị ngoại tuyến. Mặc định hãy tự chủ thực hiện và chỉ báo cho tôi khi có hành động rủi ro cao hoặc tái cấu trúc lớn; gom các câu hỏi khác vào một bản tóm tắt thay vì hỏi từng câu", "presenceGuidanceAwayDefault": "Tôi đang rời bàn phím. Đừng chờ tôi; hãy đưa ra quyết định tốt nhất với thông tin hiện có, hoàn thành những gì có thể và để lại bản tóm tắt ngắn gọn về những gì đã xảy ra cùng các câu hỏi còn mở khi tôi quay lại", - "followSystem": "Theo hệ thống" + "followSystem": "Theo hệ thống", + "githubStar": { + "label": "Gắn sao cho ORG2 trên GitHub", + "description": "Hỗ trợ dự án bằng một sao GitHub.", + "loading": "Đang kiểm tra...", + "star": "Gắn sao", + "starring": "Đang thêm...", + "starred": "Đã gắn sao", + "thanks": "Cảm ơn bạn đã hỗ trợ!", + "openGitHub": "Mở GitHub", + "openingGitHub": "Đang mở...", + "unavailableGhMissing": "GitHub CLI không khả dụng", + "unavailableNotAuthenticated": "Hãy đăng nhập GitHub CLI", + "unavailableNetwork": "Không thể kết nối GitHub", + "unavailablePermission": "Cần quyền GitHub", + "unavailableTimeout": "Yêu cầu GitHub hết thời gian", + "unavailableUnexpected": "Không thể lấy trạng thái GitHub", + "reminderTitle": "Hỗ trợ ORG2", + "reminderDescription": "Nếu ORG2 hữu ích, bạn có thể gắn sao cho dự án trên GitHub không?", + "later": "Để sau", + "neverAskAgain": "Không hỏi lại" + } }, "background": { "title": "Hình nền", diff --git a/src/i18n/locales/zh-Hant/settings.json b/src/i18n/locales/zh-Hant/settings.json index e7c4690b2..e61d08035 100644 --- a/src/i18n/locales/zh-Hant/settings.json +++ b/src/i18n/locales/zh-Hant/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "我在電腦前。你可以隨時向我提出澄清問題,執行任何破壞性操作前請先讓我確認", "presenceGuidanceInvisibleDefault": "我在附近但顯示為離線。預設自主執行;只有高風險操作或重大重構才通知我,其他問題請彙總成一段摘要,不要逐個詢問", "presenceGuidanceAwayDefault": "我暫時不在電腦前。不要等我回覆;根據現有資訊做出最佳判斷,完成能完成的事項,並留下簡明總結和待我回來處理的問題", - "followSystem": "跟隨系統" + "followSystem": "跟隨系統", + "githubStar": { + "label": "在 GitHub 上為 ORG2 點 Star", + "description": "用一個 GitHub Star 支持這個專案。", + "loading": "正在檢查...", + "star": "點 Star", + "starring": "正在加入...", + "starred": "已 Star", + "thanks": "感謝你的支持!", + "openGitHub": "開啟 GitHub", + "openingGitHub": "正在開啟...", + "unavailableGhMissing": "GitHub CLI 無法使用", + "unavailableNotAuthenticated": "請先登入 GitHub CLI", + "unavailableNetwork": "無法連接 GitHub", + "unavailablePermission": "需要 GitHub 權限", + "unavailableTimeout": "GitHub 請求逾時", + "unavailableUnexpected": "無法取得 GitHub 狀態", + "reminderTitle": "支持 ORG2", + "reminderDescription": "如果 ORG2 對你有幫助,可以在 GitHub 上點個 Star 嗎?", + "later": "稍後", + "neverAskAgain": "不再提醒" + } }, "background": { "title": "背景", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index b2073c874..c0b222087 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -230,7 +230,28 @@ "presenceGuidanceOnlineDefault": "我在电脑前。你可以随时向我提出澄清问题,执行任何破坏性操作前请先让我确认", "presenceGuidanceInvisibleDefault": "我在附近但显示为离线。默认自主执行;只有高风险操作或重大重构才通知我,其他问题请汇总成一段摘要,不要逐个询问", "presenceGuidanceAwayDefault": "我暂时不在电脑前。不要等我回复;根据现有信息做出最佳判断,完成能完成的事项,并留下简明总结和待我回来处理的问题", - "followSystem": "跟随系统" + "followSystem": "跟随系统", + "githubStar": { + "label": "在 GitHub 上为 ORG2 点 Star", + "description": "用一个 GitHub Star 支持这个项目。", + "loading": "正在检查...", + "star": "点 Star", + "starring": "正在添加...", + "starred": "已 Star", + "thanks": "感谢你的支持!", + "openGitHub": "打开 GitHub", + "openingGitHub": "正在打开...", + "unavailableGhMissing": "GitHub CLI 不可用", + "unavailableNotAuthenticated": "请先登录 GitHub CLI", + "unavailableNetwork": "无法连接 GitHub", + "unavailablePermission": "需要 GitHub 权限", + "unavailableTimeout": "GitHub 请求超时", + "unavailableUnexpected": "无法获取 GitHub 状态", + "reminderTitle": "支持 ORG2", + "reminderDescription": "如果 ORG2 对你有帮助,可以在 GitHub 上点个 Star 吗?", + "later": "稍后", + "neverAskAgain": "不再提醒" + } }, "background": { "title": "背景", diff --git a/src/modules/MainApp/Settings/sections/GeneralSection.tsx b/src/modules/MainApp/Settings/sections/GeneralSection.tsx index 3daa6d800..030a1cdb1 100644 --- a/src/modules/MainApp/Settings/sections/GeneralSection.tsx +++ b/src/modules/MainApp/Settings/sections/GeneralSection.tsx @@ -43,6 +43,7 @@ import Button from "@src/components/Button"; import Message from "@src/components/Message"; import Select from "@src/components/Select"; import Switch from "@src/components/Switch"; +import { GitHubStarSettingsRow } from "@src/features/GitHubStar"; import { useTimezoneSelect } from "@src/hooks/geo"; import { LANGUAGE_NAMES, @@ -427,6 +428,10 @@ const GeneralTabBody: React.FC = () => { + + + + General. + * A wizard-style onboarding flow entered automatically for first-time users. + * Completion persists a completed outcome and emits the GitHub Star value + * moment. Skipping persists a dismissed outcome without prompting for a Star. * * Renders outside AppShell (no sidebar) for a focused experience. */ +import { useSetAtom } from "jotai"; import { ArrowLeft, ArrowRight, Check } from "lucide-react"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Button from "@src/components/Button"; import "@src/components/DevPassport/devpassport.css"; +import Message from "@src/components/Message"; import { ROUTES } from "@src/config/routes"; import { CODEMIRROR_STYLE_NONCE } from "@src/features/CodeMirror/config/nonce"; +import { signalGitHubStarValueMoment } from "@src/features/GitHubStar"; import { OnboardingLayout } from "@src/modules/shared/layouts"; import { PanelFooter } from "@src/modules/shared/layouts/blocks"; +import { saveSettingAtom } from "@src/store/settings/settingsAtom"; +import { + type SetupWalkthroughOutcome, + shouldSignalGitHubStarAfterSetup, +} from "@src/store/settings/setupWalkthrough"; import { STEP_CONFIGS } from "./config"; import "./index.scss"; @@ -51,7 +60,10 @@ const WALKTHROUGH_STYLES = ` const SetupWalkthrough: React.FC = () => { const navigate = useNavigate(); const { t } = useTranslation("onboarding"); + const saveSetting = useSetAtom(saveSettingAtom); const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isClosing, setIsClosing] = useState(false); + const closingRef = useRef(false); // Add/remove body class for hiding tabbar React.useLayoutEffect(() => { @@ -65,15 +77,37 @@ const SetupWalkthrough: React.FC = () => { const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === STEP_CONFIGS.length - 1; + const closeWalkthrough = useCallback( + async (outcome: Exclude) => { + if (closingRef.current) return; + closingRef.current = true; + setIsClosing(true); + try { + await saveSetting({ + key: "general.setupWalkthroughOutcome", + value: outcome, + }); + if (shouldSignalGitHubStarAfterSetup(outcome)) { + signalGitHubStarValueMoment(); + } + navigate(ROUTES.workStation.base.path, { replace: true }); + } catch { + Message.error(t("common:status.saveFailed")); + } finally { + closingRef.current = false; + setIsClosing(false); + } + }, + [navigate, saveSetting, t] + ); + const handleNext = useCallback(() => { if (isLastStep) { - // Mark setup as complete and navigate to WorkStation - localStorage.setItem("setup_walkthrough_completed", "true"); - navigate(ROUTES.workStation.base.path, { replace: true }); + void closeWalkthrough("completed"); } else { setCurrentStepIndex((prev) => prev + 1); } - }, [isLastStep, navigate]); + }, [closeWalkthrough, isLastStep]); const handleBack = useCallback(() => { if (!isFirstStep) { @@ -82,10 +116,8 @@ const SetupWalkthrough: React.FC = () => { }, [isFirstStep]); const handleSkip = useCallback(() => { - // Mark setup as complete and navigate to WorkStation - localStorage.setItem("setup_walkthrough_completed", "true"); - navigate(ROUTES.workStation.base.path, { replace: true }); - }, [navigate]); + void closeWalkthrough("dismissed"); + }, [closeWalkthrough]); // Left content: Step navigation const leftContent = ( @@ -156,7 +188,13 @@ const SetupWalkthrough: React.FC = () => { } secondaryActions={ !isLastStep - ? [{ label: t("navigation.skipSetup"), onClick: handleSkip }] + ? [ + { + label: t("navigation.skipSetup"), + onClick: handleSkip, + disabled: isClosing, + }, + ] : undefined } primaryAction={{ @@ -164,6 +202,8 @@ const SetupWalkthrough: React.FC = () => { ? t("navigation.getStarted") : t("common:actions.continue"), onClick: handleNext, + loading: isClosing, + disabled: isClosing, icon: isLastStep ? : , }} /> diff --git a/src/router/guards/SetupWalkthroughGuard.tsx b/src/router/guards/SetupWalkthroughGuard.tsx new file mode 100644 index 000000000..3e1a5c672 --- /dev/null +++ b/src/router/guards/SetupWalkthroughGuard.tsx @@ -0,0 +1,41 @@ +import { useAtomValue } from "jotai"; +import React from "react"; +import { Navigate, useLocation } from "react-router-dom"; + +import { ROUTES } from "@src/config/routes"; +import { + settingsAtom, + settingsLoadedAtom, +} from "@src/store/settings/settingsAtom"; + +import { resolveSetupWalkthroughNavigation } from "./setupWalkthroughNavigation"; + +interface SetupWalkthroughGuardProps { + children: React.ReactNode; +} + +/** + * Authoritative first-use routing gate. Authentication runs outside this guard; + * this layer only decides whether persisted onboarding is still open. + */ +export const SetupWalkthroughGuard: React.FC = ({ + children, +}) => { + const location = useLocation(); + const settingsLoaded = useAtomValue(settingsLoadedAtom); + const outcome = useAtomValue(settingsAtom)["general.setupWalkthroughOutcome"]; + const navigation = resolveSetupWalkthroughNavigation({ + loaded: settingsLoaded, + outcome, + pathname: location.pathname, + }); + + if (navigation === "wait") return null; + if (navigation === "redirect-to-setup") { + return ; + } + if (navigation === "redirect-to-workstation") { + return ; + } + return <>{children}; +}; diff --git a/src/router/guards/index.ts b/src/router/guards/index.ts index 9e0e02e0e..0cff3867f 100644 --- a/src/router/guards/index.ts +++ b/src/router/guards/index.ts @@ -9,3 +9,4 @@ export { AuthGuard } from "./AuthGuard"; export { AuthRedirect } from "./AuthRedirect"; export { RepoGuard } from "./RepoGuard"; +export { SetupWalkthroughGuard } from "./SetupWalkthroughGuard"; diff --git a/src/router/guards/setupWalkthroughNavigation.test.ts b/src/router/guards/setupWalkthroughNavigation.test.ts new file mode 100644 index 000000000..707e25aae --- /dev/null +++ b/src/router/guards/setupWalkthroughNavigation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { resolveSetupWalkthroughNavigation } from "./setupWalkthroughNavigation"; + +describe("first-use setup navigation", () => { + it("waits for persisted state before routing a primary app window", () => { + expect( + resolveSetupWalkthroughNavigation({ + loaded: false, + outcome: "open", + pathname: "/orgii/workstation/code", + }) + ).toBe("wait"); + }); + + it("redirects an open primary app window to onboarding", () => { + expect( + resolveSetupWalkthroughNavigation({ + loaded: true, + outcome: "open", + pathname: "/orgii/workstation/code", + }) + ).toBe("redirect-to-setup"); + }); + + it("allows the open onboarding route and closes it after any terminal outcome", () => { + expect( + resolveSetupWalkthroughNavigation({ + loaded: true, + outcome: "open", + pathname: "/orgii/app/walkthrough", + }) + ).toBe("continue"); + + for (const outcome of ["completed", "dismissed"] as const) { + expect( + resolveSetupWalkthroughNavigation({ + loaded: true, + outcome, + pathname: "/orgii/app/walkthrough", + }) + ).toBe("redirect-to-workstation"); + } + }); + + it("does not intercept root, login, callback, or secondary windows", () => { + for (const pathname of [ + "/", + "/orgii/app/login", + "/orgii/marketplace/callback", + "/orgii/windows/tab/123", + ]) { + expect( + resolveSetupWalkthroughNavigation({ + loaded: true, + outcome: "open", + pathname, + }) + ).toBe("continue"); + } + }); +}); diff --git a/src/router/guards/setupWalkthroughNavigation.ts b/src/router/guards/setupWalkthroughNavigation.ts new file mode 100644 index 000000000..803162701 --- /dev/null +++ b/src/router/guards/setupWalkthroughNavigation.ts @@ -0,0 +1,32 @@ +import { ROUTES } from "@src/config/routes"; +import type { SetupWalkthroughOutcome } from "@src/store/settings/setupWalkthrough"; + +export type SetupWalkthroughNavigation = + | "continue" + | "redirect-to-setup" + | "redirect-to-workstation" + | "wait"; + +function bypassesSetupWalkthrough(pathname: string): boolean { + return ( + pathname === "/" || + pathname === ROUTES.auth.login.path || + pathname.startsWith("/orgii/windows/") || + pathname.includes("/marketplace/callback") + ); +} + +export function resolveSetupWalkthroughNavigation(args: { + loaded: boolean; + outcome: SetupWalkthroughOutcome; + pathname: string; +}): SetupWalkthroughNavigation { + if (bypassesSetupWalkthrough(args.pathname)) return "continue"; + if (!args.loaded) return "wait"; + + if (args.pathname === ROUTES.auth.setup.path) { + return args.outcome === "open" ? "continue" : "redirect-to-workstation"; + } + + return args.outcome === "open" ? "redirect-to-setup" : "continue"; +} diff --git a/src/router/index.tsx b/src/router/index.tsx index be7664ba1..2b1007bb7 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -19,7 +19,7 @@ import { } from "@src/router/routes/routeGroups"; import { RouteDebugModal } from "@src/scaffold/ModalSystem/variants/RouteDebug"; -import { AuthGuard, AuthRedirect } from "./guards"; +import { AuthGuard, AuthRedirect, SetupWalkthroughGuard } from "./guards"; // Root layout for global services and modals. const RootLayout = () => { @@ -62,7 +62,9 @@ const RootLayout = () => { {/* AuthGuard wraps Outlet - if not authenticated, redirects to login */} - + + + ); diff --git a/src/router/routes/routeGroups.tsx b/src/router/routes/routeGroups.tsx index 8457d6d44..94f394ed4 100644 --- a/src/router/routes/routeGroups.tsx +++ b/src/router/routes/routeGroups.tsx @@ -118,11 +118,7 @@ export const appStandaloneRouteGroup: RouteObject[] = [ { path: "app/select-repo", element: lazy(, false) }, { path: "app/walkthrough", - element: lazy( - - - - ), + element: lazy(), }, { path: "marketplace/callback", element: lazy() }, ]; diff --git a/src/store/settings/settingsAtom.ts b/src/store/settings/settingsAtom.ts index afa8c54e7..0cfa8c230 100644 --- a/src/store/settings/settingsAtom.ts +++ b/src/store/settings/settingsAtom.ts @@ -25,6 +25,11 @@ import { import { generateSettingsJsonSchema } from "@src/config/settingsSchema/generateJsonSchema"; import { createLogger } from "@src/hooks/logger"; +import { + SETUP_WALKTHROUGH_OUTCOME_KEY, + resolveSetupWalkthroughOutcome, +} from "./setupWalkthrough"; + const log = createLogger("Settings"); const settingsRpc = { @@ -207,8 +212,12 @@ export const initSettingsAtom = atom(null, async (_get, set) => { try { const rawSettings = await settingsRpc.read(); - // Validate and merge with defaults + // Validate and merge with defaults. The first-use setting needs a one-time + // migration: an empty object means a genuinely new install, while any + // existing settings file predating the key belongs to an established user. const validated = validateSettings(rawSettings); + const resolvedSetupOutcome = resolveSetupWalkthroughOutcome(rawSettings); + validated[SETUP_WALKTHROUGH_OUTCOME_KEY] = resolvedSetupOutcome; set(settingsAtom, validated); set(rawSettingsAtom, rawSettings); set(settingsLoadedAtom, true); @@ -223,6 +232,11 @@ export const initSettingsAtom = atom(null, async (_get, set) => { missingKeys[key] = (validated as Record)[key]; } } + // Persist the resolved migration outcome rather than the schema default. + // Existing profiles therefore stay closed on every subsequent launch. + if (!(SETUP_WALKTHROUGH_OUTCOME_KEY in rawSettings)) { + missingKeys[SETUP_WALKTHROUGH_OUTCOME_KEY] = resolvedSetupOutcome; + } // Both disk writes are non-blocking — the UI is unblocked as soon as // `settingsLoadedAtom` is set above. We fire both writes without awaiting diff --git a/src/store/settings/setupWalkthrough.test.ts b/src/store/settings/setupWalkthrough.test.ts new file mode 100644 index 000000000..5e5e11385 --- /dev/null +++ b/src/store/settings/setupWalkthrough.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + SETUP_WALKTHROUGH_OUTCOME_KEY, + resolveSetupWalkthroughOutcome, + shouldSignalGitHubStarAfterSetup, +} from "./setupWalkthrough"; + +describe("setup walkthrough outcome migration", () => { + it("keeps a new empty install open", () => { + expect(resolveSetupWalkthroughOutcome({})).toBe("open"); + expect( + resolveSetupWalkthroughOutcome({ $schema: "settings.schema.json" }) + ).toBe("open"); + }); + + it("does not interrupt existing installs that predate onboarding state", () => { + expect( + resolveSetupWalkthroughOutcome({ "general.theme": "github-dark" }) + ).toBe("completed"); + }); + + it("migrates the temporary completion boolean", () => { + expect( + resolveSetupWalkthroughOutcome({ + "general.setupWalkthroughCompleted": false, + }) + ).toBe("open"); + expect( + resolveSetupWalkthroughOutcome({ + "general.setupWalkthroughCompleted": true, + }) + ).toBe("completed"); + }); + + it("signals the Star value moment only after completion", () => { + expect(shouldSignalGitHubStarAfterSetup("completed")).toBe(true); + expect(shouldSignalGitHubStarAfterSetup("dismissed")).toBe(false); + }); + + it("preserves every explicit outcome", () => { + for (const outcome of ["open", "completed", "dismissed"] as const) { + expect( + resolveSetupWalkthroughOutcome({ + [SETUP_WALKTHROUGH_OUTCOME_KEY]: outcome, + "general.setupWalkthroughCompleted": outcome !== "open", + }) + ).toBe(outcome); + } + }); +}); diff --git a/src/store/settings/setupWalkthrough.ts b/src/store/settings/setupWalkthrough.ts new file mode 100644 index 000000000..c14fcfaa5 --- /dev/null +++ b/src/store/settings/setupWalkthrough.ts @@ -0,0 +1,44 @@ +export const SETUP_WALKTHROUGH_OUTCOME_KEY = + "general.setupWalkthroughOutcome" as const; + +const LEGACY_SETUP_WALKTHROUGH_COMPLETED_KEY = + "general.setupWalkthroughCompleted" as const; + +export type SetupWalkthroughOutcome = "open" | "completed" | "dismissed"; + +function isSetupWalkthroughOutcome( + value: unknown +): value is SetupWalkthroughOutcome { + return value === "open" || value === "completed" || value === "dismissed"; +} + +/** + * New installs read an empty settings object and must enter onboarding. + * Existing installs that predate onboarding state are migrated to completed so + * an app update never interrupts an established user with first-run UI. + * + * The temporary boolean key is accepted only as an upgrade bridge; all current + * readers and writers use the outcome key as the single source of truth. + */ +export function shouldSignalGitHubStarAfterSetup( + outcome: Exclude +): boolean { + return outcome === "completed"; +} + +export function resolveSetupWalkthroughOutcome( + rawSettings: Record +): SetupWalkthroughOutcome { + const explicit = rawSettings[SETUP_WALKTHROUGH_OUTCOME_KEY]; + if (isSetupWalkthroughOutcome(explicit)) return explicit; + + const legacyCompleted = rawSettings[LEGACY_SETUP_WALKTHROUGH_COMPLETED_KEY]; + if (typeof legacyCompleted === "boolean") { + return legacyCompleted ? "completed" : "open"; + } + + const isNewInstall = !Object.keys(rawSettings).some( + (key) => key !== "$schema" + ); + return isNewInstall ? "open" : "completed"; +}