diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..780e490e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Development Commands + +```bash +# Development +npm run watch # Watch mode for development +npm run build # Production build +npm run build:pack # Build and create dist.zip package +npm run release # Build and package for release + +# Testing and Quality +npm test # Run Jest tests +npm run lint # Run ESLint +npm run lint:fix # Fix linting issues automatically +npm run prettier # Format code with Prettier + +# Utility +npm run clean # Clean dist directory +npm run prepare # Setup husky hooks + +# Chrome Extension Development +# Load the 'dist' directory in Chrome's extension developer mode after building +``` + +## Project Architecture + +This is a Chrome extension (Manifest v3) that enhances GitHub and GitHub Enterprise UI with productivity features. + +### Core Structure + +- **Content Scripts**: Three separate content scripts inject functionality into different GitHub pages: + - `content_pr_page.tsx` - Individual PR pages + - `content_prs_page.tsx` - PRs listing page + - `content_compare_page.tsx` - Compare/diff pages + +- **Background Service Worker**: `background.ts` handles JIRA API requests due to CORS restrictions + +- **Extension Pages**: + - `popup.tsx` - Extension popup interface + - `options.tsx` - Options/settings page with tabbed interface + +### Key Components + +- **Settings Management**: Uses Chrome storage API with Yup schema validation (`src/services/getSettings.ts`) +- **Multi-Instance Support**: Supports multiple GitHub instances (Enterprise + GitHub.com) via instance configuration +- **Feature Toggles**: All features are individually toggleable via settings +- **JIRA Integration**: Fetches issue data through background script to bypass CORS + +### Settings Architecture + +Settings are validated using Yup schemas and organized into: + +- **`instances[]`** - GitHub instance configurations + - `pat` - Personal Access Token (must start with `ghp_`, minimum 30 characters) + - `org` - GitHub organization name + - `repo` - Repository name + - `ghBaseUrl` - GitHub API base URL (defaults to `https://api.github.com`) + - `randomReviewers` - Comma-separated list of reviewer usernames + +- **`features{}`** - Boolean toggles for extension features: + - `baseBranchLabels` - Show base branch labels on PRs + - `changedFiles` - Display changed files count + - `totalLinesPrs` - Show total lines changed on PR list page + - `totalLinesPr` - Show total lines changed on individual PR page + - `reOrderPrs` - Reorder PRs by custom logic + - `addUpdateBranchButton` - Add update branch button + - `autoFilter` - Enable automatic PR filtering + - `prTitleFromJira` - Auto-populate PR title from JIRA ticket + - `descriptionTemplate` - Use custom PR description template + - `randomReviewer` - Enable random reviewer assignment + +- **`jira{}`** - JIRA integration settings (optional) + - `pat` - JIRA Personal Access Token (minimum 30 characters) + - `baseUrl` - JIRA instance base URL + - `issueKeyRegex` - Regex pattern for JIRA issue keys (e.g., "TEST-\\d+") + +- **`autoFilter`** - Custom PR filtering query string (optional) +- **`descriptionTemplate`** - Custom PR description template text (optional) + +### Options Page Structure + +The Options page uses a tabbed interface with the following tabs: + +- **Feature Toggles** - Individual toggles for all extension features +- **GH Instances** - GitHub instance configuration (PAT, org, repo, base URL) +- **Jira** - JIRA integration settings +- **Import/Export** - Settings backup and restore functionality + +Each tab is implemented as a separate component in `src/pages/Tabs/` with dedicated styling and logic. + +### Build System + +- **Webpack**: Multi-entry build with code splitting (vendor chunk for all except background) +- **SASS Modules**: CSS modules with local scope and hash-based class names +- **TypeScript**: Full TypeScript with strict validation +- **Entry Points**: Separate bundles for popup, options, content scripts, and background + +### Chrome Extension Permissions + +- `storage` - Chrome storage for settings +- `host_permissions: [""]` - Access to all GitHub instances +- Content scripts inject into `https://*/*` patterns + +### Development Workflow + +1. Run `npm run watch` for development +2. Load `dist` directory in Chrome extension developer mode +3. Changes auto-reload with webpack watch mode +4. Use `npm run clean` to clear dist before production builds +5. Run `npm test` to execute Jest unit tests +6. Use `npm run lint:fix` to automatically fix linting issues +7. Format code with `npm run prettier` before committing + +## Coding Guidelines + +- **Type Definitions**: Always use `type` instead of `interface` +- **React Component Props**: Always name component props type as `Props` and never export it +- **Exports**: Never use default exports - always use named exports +- **Component Structure**: Follow existing component folder structure with index.ts barrel exports +- **Type Safety**: Never use `any` type - use proper typing with `unknown` for uncertain types +- **Type Casting**: When type casting is necessary, always include safety checks before casting + +## Important Implementation Notes + +- Settings validation uses Yup schemas with strict type checking +- GitHub API integration uses Octokit with user-provided PATs +- JIRA API calls must go through background script due to CORS +- Features are conditionally loaded based on current page URL and user settings +- CSS modules prevent style conflicts with GitHub's native styles +- Pre-commit hooks with husky and lint-staged ensure code quality +- All settings are persisted to Chrome's local storage API +- The extension supports hot reloading during development via webpack watch mode diff --git a/README.md b/README.md index e123b796..e00304ab 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,176 @@ # GitHub UI Booster -## Setup +A Chrome extension that enhances GitHub and GitHub Enterprise UI with powerful productivity features. Built with TypeScript, React, and Manifest v3. -```sh +## Features + +### Pull Request Management + +- **Base Branch Labels**: Show base branch information for each pull request +- **Changed Files**: Display changed files count with search functionality +- **Total Lines Counter**: Show total lines added and removed in pull requests +- **Reorder Pull Requests**: Automatically organize PRs by base branch with visual hierarchy +- **Update Branch Button**: Quick access to update PR branches when behind base branch +- **Auto Filter**: Apply custom filters to pull requests list + +### JIRA Integration + +- **PR Title from JIRA**: Auto-populate PR titles from JIRA issue keys in branch names +- **Description Template**: Use custom PR description templates with JIRA ticket placeholders +- **Issue Information**: Display JIRA issue details directly on GitHub + +### Multi-Instance Support + +- **GitHub Enterprise**: Full support for GitHub Enterprise instances +- **Multiple Instances**: Configure multiple GitHub instances simultaneously +- **Personal Access Tokens**: Secure API access using your GitHub PATs + +### Settings & Sync + +- **Feature Toggles**: Individual control over all extension features +- **User Profile Sync**: Option to sync settings across Chrome instances using `chrome.storage.sync` +- **Import/Export**: Backup and restore extension settings + +## Development + +### Setup + +```bash npm install ``` -## Build +### Build for Development + +```bash +npm run watch +``` + +### Build for Production -```sh +```bash npm run build ``` -## Development +### Load Extension in Chrome -```sh -npm run watch +1. Navigate to `chrome://extensions/` +2. Enable "Developer mode" +3. Click "Load unpacked" +4. Select the `dist` directory + +### Testing & Quality + +```bash +npm test # Run Jest tests +npm run lint # Run ESLint +npm run lint:fix # Fix linting issues +npm run prettier # Format code ``` -## Visual Studio Code +## Architecture -Run watch mode. +This Chrome extension uses: -type `Ctrl + Shift + B` +- **Manifest v3** for modern Chrome extension standards +- **TypeScript** for type safety and better development experience +- **React** for UI components +- **SASS Modules** for scoped styling +- **Webpack** for bundling with code splitting +- **Chrome Storage API** for settings persistence -## Load extension to chrome +### Project Structure -Load `dist` directory +``` +src/ +├── components/ # Reusable React components +├── content/ # Content script functionality +├── pages/ # Extension pages (popup, options) +│ └── Tabs/ # Options page tabs +├── services/ # Core services and utilities +├── content_pr_page.tsx # Individual PR page content script +├── content_prs_page.tsx # PRs listing page content script +├── content_compare_page.tsx # Compare/diff page content script +├── background.ts # Service worker +├── popup.tsx # Extension popup +└── options.tsx # Options/settings page +``` -## Links +## Configuration -[manivest v3](https://stackoverflow.com/questions/63308160/how-to-migrate-manifest-version-2-to-v3-for-chrome-extension) +The extension requires configuration through the Options page: -[browser actions Google Dev Portal](https://developer.chrome.com/blog/mv3-actions?hl=de) +1. **GitHub Instances**: Add your GitHub instances with Personal Access Tokens +2. **Feature Toggles**: Enable/disable individual features +3. **JIRA Integration** (optional): Configure JIRA instance for enhanced functionality -[storge-api](https://dev.to/ambujsahu81/where-to-store-data-in-chrome-extension--1be6) +## Releasing -[tutorial](https://meenumatharu.medium.com/building-a-google-chrome-extension-with-manifest-v3-a-basic-example-to-get-started-0e976938bc70) +### Prepare Release -[youtube tutorial](https://www.youtube.com/watch?v=tIJrby96Oog) +1. Update version in `public/manifest.json` +2. Run `npm run release` to build and create distribution package +3. The release package will be created as `dist.zip` -## Examples and Inspirations +### Chrome Web Store - +1. Login to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) +2. Select the GitHub UI Booster extension +3. Go to Build > Package +4. Upload the new package +5. Submit for review - +### Update screenshot - +1. take screenshot and right-click > Open with > GIMP +2. CMD+A, CMD+C +3. CMD + N to create a new file +4. dimensions: 1280x800 +5. SHIFT+B, choose black and colorize the layer in black +6. CMD+V +7. SHIFT+S for the Resize Tool +8. CMD+two-fingers to zoom out +9. resize the pasted image as needed +10. SHIFT+CMD+E to export -## Releasing +## Contributing -### Prepare extension +1. Fork the repository +2. Create a feature branch +3. Make your changes following the existing code style +4. Run tests and linting +5. Submit a pull request -- update version in `manifest.json` -- run `npm run release` +### Chrome Web Store Description -### Upload to Chrome Web Store +**GitHub UI Booster - Supercharge Your GitHub Workflow!** -- Login to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) -- Click the GitHub UI Booster extension -- Go to Build > Package -- Click "Upload new package" -- Drag and drop the zip file into the dialog -- Click "Submit for review" +Transform your GitHub and GitHub Enterprise experience with powerful productivity features designed for developers and teams. -### Update screenshot +**🚀 Key Features:** +• **Smart PR Management** - Base branch labels, refined line counts, and hierarchical PR organization +• **Enhanced File Navigation** - Instant access to changed files with integrated search across all PRs +• **JIRA Integration** - Auto-populate PR titles and descriptions from JIRA tickets +• **Multi-Instance Support** - Seamlessly work with GitHub.com and multiple Enterprise instances +• **One-Click Actions** - Quick branch updates and custom PR filters +• **Team Collaboration** - Random reviewer assignment and template-based descriptions + +**🔧 Enterprise Ready:** +• Secure API access using your personal access tokens +• Support for multiple GitHub Enterprise instances +• All features individually toggleable +• Settings sync across Chrome instances +• Import/export configuration + +**✨ Perfect for teams using GitHub + JIRA who want to:** + +- Reduce PR review overhead +- Standardize development workflows +- Navigate large codebases efficiently +- Maintain clear PR hierarchies +- Automate repetitive tasks + +Built with TypeScript and React. Works with both GitHub.com and GitHub Enterprise. + +## License -- take screenshot and right-click > Open with > GIMP -- CMD+A, CMD+C -- CMD + N to create a new file -- dimensions: 1280x800 -- SHIFT+B, choose black and colorize the layer in black -- CMD+V -- SHIFT+S for the Resize Tool -- CMD+two-fingers to zoom out -- resize the pasted image as needed -- SHIFT+CMD+E to export - -### Webstore-Description - -Boost productivity on GitHub and GitHub Enterprise with this powerful extension! - -Highlights: - -- works for GitHub and GitHub Enterprise -- supports multiple GitHub instances -- powered by the GitHub API, using your personal access token for secure and seamless access -- shows the base branch for each pull request directly on the PRs page -- refined line count, excluding non-impactful files (e.g., package-lock.json) -- on-hover instant access to changed files and diffs from the PRs page -- integrated search for changed files across all open PRs -- shows merge conflicts on PRs page -- custom pull request filter to replace GitHub’s default -- integrated 'update branche' button in PRs page -- nested display of dependent PRs on the PRs page, showing clear hierarchy -- integrates with your Jira to display status, prio and assignee on the prs-page -- all of the above features are toggleable individually -- automatically inserts Jira-Link to issue based on your PR-template -- automatically inserts Jira-Description from Jira-Issue +This project is licensed under the MIT License. diff --git a/src/components/FeatureInput/FeatureInput.tsx b/src/components/FeatureInput/FeatureInput.tsx index af4b96cc..76e0c0ef 100644 --- a/src/components/FeatureInput/FeatureInput.tsx +++ b/src/components/FeatureInput/FeatureInput.tsx @@ -5,15 +5,17 @@ import { getSettingValue, Settings } from "../../services"; type Props = { storageKey: keyof Settings; placeholder: string; - ariaLabel?: string; onError: (message: string) => void; + disabled?: boolean; + ariaLabel?: string; }; export const FeatureInput: React.FC = ({ storageKey, + placeholder, onError, + disabled = false, ariaLabel, - placeholder, }) => { const [value, setValue] = useState(); @@ -48,6 +50,7 @@ export const FeatureInput: React.FC = ({ value={value} onChange={handleChange} aria-label={ariaLabel} + disabled={disabled} /> ); }; diff --git a/src/popup/Typography/Paragraph.tsx b/src/components/Paragraph/Paragraph.tsx similarity index 62% rename from src/popup/Typography/Paragraph.tsx rename to src/components/Paragraph/Paragraph.tsx index 78ec0033..91129fb4 100644 --- a/src/popup/Typography/Paragraph.tsx +++ b/src/components/Paragraph/Paragraph.tsx @@ -1,11 +1,12 @@ import { Text } from "@primer/react"; -import React, { CSSProperties } from "react"; +import React, { CSSProperties, ReactNode } from "react"; type Props = { + children: ReactNode; sx?: CSSProperties; - children: React.ReactNode; }; -export const Paragraph: React.FC = ({ sx, children }) => { + +export const Paragraph: React.FC = ({ children, sx }) => { return ( = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/SectionTitle/index.ts b/src/components/SectionTitle/index.ts new file mode 100644 index 00000000..26a2bb52 --- /dev/null +++ b/src/components/SectionTitle/index.ts @@ -0,0 +1 @@ +export { SectionTitle } from "./SectionTitle"; diff --git a/src/components/Subtitle/Subtitle.module.scss b/src/components/Subtitle/Subtitle.module.scss new file mode 100644 index 00000000..dd852f5c --- /dev/null +++ b/src/components/Subtitle/Subtitle.module.scss @@ -0,0 +1,4 @@ +.subtitle { + font-size: 14px; + font-style: italic; +} diff --git a/src/components/Subtitle/Subtitle.tsx b/src/components/Subtitle/Subtitle.tsx new file mode 100644 index 00000000..aafc72ee --- /dev/null +++ b/src/components/Subtitle/Subtitle.tsx @@ -0,0 +1,15 @@ +import { Text } from "@primer/react"; +import React, { ReactNode } from "react"; +import styles from "./Subtitle.module.scss"; + +type Props = { + children: ReactNode; +}; + +export const Subtitle: React.FC = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/Subtitle/index.ts b/src/components/Subtitle/index.ts new file mode 100644 index 00000000..366bcf9c --- /dev/null +++ b/src/components/Subtitle/index.ts @@ -0,0 +1 @@ +export { Subtitle } from "./Subtitle"; diff --git a/src/components/TabNavigation/TabNavigation.tsx b/src/components/TabNavigation/TabNavigation.tsx index 6e476f94..b74df5af 100644 --- a/src/components/TabNavigation/TabNavigation.tsx +++ b/src/components/TabNavigation/TabNavigation.tsx @@ -1,20 +1,15 @@ import { UnderlineNav } from "@primer/react"; import { UnderlineNavItem } from "@primer/react/lib-esm/UnderlineNav/UnderlineNavItem"; import React from "react"; - -export type Tab = "GH Instances" | "Auto filter" | "Jira"; +import { Tab } from "../../constants"; type Props = { - tabs: Tab[]; + tabs: readonly Tab[]; activeTab: Tab; onTabClick: (tab: Tab) => void; }; -export const TabNavigation: React.FC = ({ - tabs, - activeTab, - onTabClick, -}) => { +export const TabNavigation = ({ tabs, activeTab, onTabClick }: Props) => { return ( {tabs.map((tab) => ( diff --git a/src/components/index.ts b/src/components/index.ts index 661bac6a..b4e36d1a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,8 +2,11 @@ export * from "./ClosePopupButton"; export * from "./FeatureInput"; export * from "./FeatureItem"; export * from "./IconButton"; +export * from "./Paragraph"; export * from "./RandomReviewerButton"; export * from "./SearchInput"; +export * from "./SectionTitle"; +export * from "./Subtitle"; export * from "./TabNavigation"; export * from "./TotalLines"; export * from "./UpdateBranchButton"; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000..811d3d4a --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./tabs"; diff --git a/src/constants/tabs.ts b/src/constants/tabs.ts new file mode 100644 index 00000000..a3e0b549 --- /dev/null +++ b/src/constants/tabs.ts @@ -0,0 +1,8 @@ +export const TABS = [ + "Feature Toggles", + "GH Instances", + "Jira", + "Import/Export", +] as const; + +export type Tab = (typeof TABS)[number]; diff --git a/src/content/addTemplateDescription.ts b/src/content/addDescriptionTemplate.ts similarity index 64% rename from src/content/addTemplateDescription.ts rename to src/content/addDescriptionTemplate.ts index 26da35cf..035f1d0f 100644 --- a/src/content/addTemplateDescription.ts +++ b/src/content/addDescriptionTemplate.ts @@ -1,11 +1,11 @@ import { Settings } from "../services"; -import { TemplateDescriptionParameters } from "./types"; +import { DescriptionTemplatePlaceholders } from "./types"; import { extractJiraIssueKeyFromBranch } from "./utils/comparePageUtils"; function resolveJiraLink(settings: Settings): string { - const description = settings.templateDescription; + const description = settings.descriptionTemplate; const hasJiraTicket = description.includes( - TemplateDescriptionParameters.JIRA_TICKET, + DescriptionTemplatePlaceholders.JIRA_TICKET, ); if (!hasJiraTicket) return description; @@ -13,13 +13,13 @@ function resolveJiraLink(settings: Settings): string { if (!issueKey) return description; return description.replace( - new RegExp(TemplateDescriptionParameters.JIRA_TICKET, "g"), + new RegExp(DescriptionTemplatePlaceholders.JIRA_TICKET, "g"), `${settings.jira?.baseUrl}/browse/${issueKey}`, ); } -export function addTemplateDescription(settings: Settings) { - if (!settings.templateDescription) return; +export function addDescriptionTemplate(settings: Settings) { + if (!settings.descriptionTemplate) return; const textArea = document.querySelector("#pull_request_body"); if (!textArea) return; diff --git a/src/content/handlePrFilter.ts b/src/content/handlePrFilter.ts index 679bfab4..70d86455 100644 --- a/src/content/handlePrFilter.ts +++ b/src/content/handlePrFilter.ts @@ -8,7 +8,7 @@ let theInstanceConfig: InstanceConfig | undefined; */ export function handlePrFilter( instanceConfig: InstanceConfig, - { filter }: AutoFilter, + autoFilter: AutoFilter, filterIntercepted?: string, ) { theInstanceConfig = instanceConfig; @@ -20,7 +20,7 @@ export function handlePrFilter( } document.addEventListener("click", onQuickFilterClick); - replaceFilter(filter, filterIntercepted); + replaceFilter(autoFilter, filterIntercepted); } function replaceFilter(filter: string | undefined, filterIntercepted?: string) { @@ -66,11 +66,7 @@ function onQuickFilterClick(event: MouseEvent) { if (targetUrl.href.includes("/issues")) { event.preventDefault(); const decodedFilter = decodeQueryString(params.toString()); - handlePrFilter( - theInstanceConfig, - { filter: decodedFilter }, - decodedFilter, - ); + handlePrFilter(theInstanceConfig, decodedFilter, decodedFilter); intercepted = true; } } diff --git a/src/content/types.ts b/src/content/types.ts index 530cbeb7..b8b49ba9 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -36,6 +36,6 @@ export enum Messages { FETCH_JIRA_ISSUE = "FETCH_JIRA_ISSUE", } -export enum TemplateDescriptionParameters { +export enum DescriptionTemplatePlaceholders { JIRA_TICKET = "{{jiraTicket}}", } diff --git a/src/content/utils/comparePageUtils.ts b/src/content/utils/comparePageUtils.ts index fe042ed1..12012b9a 100644 --- a/src/content/utils/comparePageUtils.ts +++ b/src/content/utils/comparePageUtils.ts @@ -6,14 +6,8 @@ export function isOnComparePage(instanceConfig: InstanceConfig): boolean { } export const extractJiraIssueKeyFromBranch = (settings: Settings) => { - const isJiraEnabled = settings.features?.jira; const issueKeyRegex = settings.jira?.issueKeyRegex; - if (!isJiraEnabled || !issueKeyRegex) { - alert( - "Jira integration or Jira issue key regex is not set. Please configure it in the settings.", - ); - return null; - } + if (!issueKeyRegex) return null; const details = document.getElementById("head-ref-selector"); const span = details?.querySelector("span.css-truncate-target"); diff --git a/src/content_compare_page.tsx b/src/content_compare_page.tsx index 48ddf056..dd3153e2 100644 --- a/src/content_compare_page.tsx +++ b/src/content_compare_page.tsx @@ -1,5 +1,5 @@ import { addPrTitleFromJira } from "./content/addPrTitleFromJira"; -import { addTemplateDescription } from "./content/addTemplateDescription"; +import { addDescriptionTemplate } from "./content/addDescriptionTemplate"; import { isOnComparePage } from "./content/utils/comparePageUtils"; import { getInstanceConfig } from "./getInstanceConfig"; import { getSettings, InstanceConfig, Settings } from "./services"; @@ -22,8 +22,8 @@ async function executeScripts( if (!isOnComparePage(instanceConfig)) return; try { - if (settings.features.templateDescription) { - addTemplateDescription(settings); + if (settings.features.descriptionTemplate) { + addDescriptionTemplate(settings); } if (settings.features.prTitleFromJira) { await addPrTitleFromJira(settings); diff --git a/src/content_pr_page.tsx b/src/content_pr_page.tsx index bf778d6d..dfc0f67d 100644 --- a/src/content_pr_page.tsx +++ b/src/content_pr_page.tsx @@ -63,7 +63,7 @@ async function executeScripts( const octokit = getOctoInstance(instanceConfig); - if (features.totalLines) { + if (features.totalLinesPr) { await handleTotalLines(octokit, instanceConfig); } diff --git a/src/content_prs_page.tsx b/src/content_prs_page.tsx index 1eca7004..7b2370ea 100644 --- a/src/content_prs_page.tsx +++ b/src/content_prs_page.tsx @@ -81,7 +81,7 @@ async function executeScripts(settings: Settings) { promises.push(addChangedFiles(octokit, instanceConfig)); } - if (features.totalLines) { + if (features.totalLinesPrs) { promises.push(addTotalLines(octokit, instanceConfig)); } diff --git a/src/popup/Button/AddButton.tsx b/src/pages/Button/AddButton.tsx similarity index 100% rename from src/popup/Button/AddButton.tsx rename to src/pages/Button/AddButton.tsx diff --git a/src/popup/Button/Button.module.scss b/src/pages/Button/Button.module.scss similarity index 100% rename from src/popup/Button/Button.module.scss rename to src/pages/Button/Button.module.scss diff --git a/src/popup/Button/Button.tsx b/src/pages/Button/Button.tsx similarity index 100% rename from src/popup/Button/Button.tsx rename to src/pages/Button/Button.tsx diff --git a/src/pages/ExportButton.tsx b/src/pages/Button/ExportButton.tsx similarity index 95% rename from src/pages/ExportButton.tsx rename to src/pages/Button/ExportButton.tsx index 1af34fb3..f721fdc8 100644 --- a/src/pages/ExportButton.tsx +++ b/src/pages/Button/ExportButton.tsx @@ -1,7 +1,7 @@ import { DownloadIcon } from "@primer/octicons-react"; import React from "react"; -import { Button } from "../popup/Button"; -import { getSettings } from "../services/getSettings"; +import { Button } from "./Button"; +import { getSettings } from "../../services/getSettings"; type Props = { onSuccess?: () => void; diff --git a/src/pages/ImportButton.tsx b/src/pages/Button/ImportButton.tsx similarity index 92% rename from src/pages/ImportButton.tsx rename to src/pages/Button/ImportButton.tsx index 0367109c..51aed2cf 100644 --- a/src/pages/ImportButton.tsx +++ b/src/pages/Button/ImportButton.tsx @@ -1,8 +1,7 @@ import { UploadIcon } from "@primer/octicons-react"; import React, { useRef, useState } from "react"; -import { Button } from "../popup/Button"; -import { persistSettings } from "../services"; -import { settingsSchema } from "../services/getSettings"; +import { Button } from "./Button"; +import { persistSettings, settingsSchema } from "../../services"; type Props = { onSuccess?: () => void; diff --git a/src/popup/Button/RemoveButton.tsx b/src/pages/Button/RemoveButton.tsx similarity index 100% rename from src/popup/Button/RemoveButton.tsx rename to src/pages/Button/RemoveButton.tsx diff --git a/src/popup/Button/SubmitButton.tsx b/src/pages/Button/SubmitButton.tsx similarity index 100% rename from src/popup/Button/SubmitButton.tsx rename to src/pages/Button/SubmitButton.tsx diff --git a/src/popup/Button/index.ts b/src/pages/Button/index.ts similarity index 64% rename from src/popup/Button/index.ts rename to src/pages/Button/index.ts index 4b342589..cf36fd25 100644 --- a/src/popup/Button/index.ts +++ b/src/pages/Button/index.ts @@ -1,4 +1,6 @@ export { AddButton } from "./AddButton"; export { Button } from "./Button"; +export { ExportButton } from "./ExportButton"; +export { ImportButton } from "./ImportButton"; export { RemoveButton } from "./RemoveButton"; export { SubmitButton } from "./SubmitButton"; diff --git a/src/popup/FormField/FormField.module.scss b/src/pages/FormField/FormField.module.scss similarity index 100% rename from src/popup/FormField/FormField.module.scss rename to src/pages/FormField/FormField.module.scss diff --git a/src/popup/FormField/FormField.tsx b/src/pages/FormField/FormField.tsx similarity index 94% rename from src/popup/FormField/FormField.tsx rename to src/pages/FormField/FormField.tsx index f309d38e..86a76f96 100644 --- a/src/popup/FormField/FormField.tsx +++ b/src/pages/FormField/FormField.tsx @@ -1,7 +1,7 @@ import { ErrorMessage, Field } from "formik"; import React from "react"; +import { Paragraph } from "../../components"; import { SettingName } from "../../services"; -import { Paragraph } from "../Typography"; import styles from "./FormField.module.scss"; type Props = { diff --git a/src/popup/FormField/index.ts b/src/pages/FormField/index.ts similarity index 100% rename from src/popup/FormField/index.ts rename to src/pages/FormField/index.ts diff --git a/src/pages/Options.module.scss b/src/pages/Options.module.scss index 332d3cd8..dfd88ee7 100644 --- a/src/pages/Options.module.scss +++ b/src/pages/Options.module.scss @@ -1,4 +1,3 @@ - .container { width: 100%; height: 100%; @@ -30,23 +29,3 @@ font-weight: bold; margin-bottom: 2px; } - -.subtitle { - font-size: 14px; - font-style: italic; -} - -.sectionTitle { - font-size: 20px; - font-weight: bold; - margin-bottom: 24px; - margin-top: 24px; -} - -.featuresList { - display: flex; - flex-direction: column; - gap: 24px; -} - - diff --git a/src/pages/Options.tsx b/src/pages/Options.tsx index 1bdb47b4..cdadc442 100644 --- a/src/pages/Options.tsx +++ b/src/pages/Options.tsx @@ -1,23 +1,30 @@ import { Box, PageLayout, Text } from "@primer/react"; import { Banner } from "@primer/react/drafts"; +import { Form, Formik, FormikHelpers } from "formik"; import React, { useCallback, useEffect, useState } from "react"; -import { FeatureInput, FeatureItem } from "../components"; -import { TemplateDescriptionParameters } from "../content"; +import { TabNavigation, Subtitle } from "../components"; +import { TABS, Tab } from "../constants"; import { Features, getSettings, INITIAL_VALUES } from "../services/getSettings"; -import { ExportButton } from "./ExportButton"; -import { ImportButton } from "./ImportButton"; +import { Settings, persistSettings, settingsSchema } from "../services"; +import { SubmitButton } from "./Button"; +import { GhInstancesTab, ImportExportTab } from "./Tabs"; +import { FeatureTogglesTab } from "./Tabs/FeatureTogglesTab"; import styles from "./Options.module.scss"; export const Options = () => { + const [activeTab, setActiveTab] = useState("Feature Toggles"); const [features, setFeatures] = useState(INITIAL_VALUES.features); const [error, errorSet] = useState(); const [success, successSet] = useState(); + const [initialValues, initialValuesSet] = useState(INITIAL_VALUES); + const [result, resultSet] = useState(""); const loadSettings = useCallback( () => getSettings({ onSuccess: (settings) => { setFeatures(settings.features); + initialValuesSet(settings); }, }), [], @@ -63,6 +70,74 @@ export const Options = () => { successSet(message); }; + const handleSubmit = ( + values: Settings, + { setSubmitting, resetForm }: FormikHelpers, + ) => + persistSettings({ + values, + onSuccess: () => { + resetForm({ values }); + resultSet("Saved successfully"); + showSuccess("Settings saved successfully"); + }, + onError: () => { + resultSet("Couldn't save"); + showError("Couldn't save settings"); + }, + onSettled: () => setSubmitting(false), + }); + + const renderTabContent = () => { + switch (activeTab) { + case "Feature Toggles": + return ( + + ); + case "Import/Export": + return ( + + ); + case "GH Instances": + case "Jira": + return ( + + {({ isValid, dirty, isSubmitting, values }) => ( +
+ {activeTab === "GH Instances" && ( + + )} + {activeTab === "Jira"} + + + + + )} +
+ ); + } + }; + return ( { GitHub UI Booster Options - + Making your GitHub experience smoother than a freshly polished commit 🚀 - + - - Features - - - - handleToggle("baseBranchLabels")} - ariaLabel="Toggle base branch labels" - /> - - handleToggle("changedFiles")} - ariaLabel="Toggle changed files" - /> - - handleToggle("totalLines")} - ariaLabel="Toggle total lines counter" - /> - - handleToggle("reOrderPrs")} - ariaLabel="Toggle reorder pull requests" - /> - - handleToggle("addUpdateBranchButton")} - ariaLabel="Toggle add update branch button" - /> - - handleToggle("autoFilter")} - ariaLabel="Toggle auto filter" - /> - - handleToggle("jira")} - ariaLabel="Toggle Jira integration" - /> - - handleToggle("randomReviewer")} - ariaLabel="Toggle assign random reviewer" - /> - - handleToggle("prTitleFromJira")} - ariaLabel="Toggle add PR title from Jira" - /> - - handleToggle("templateDescription")} - ariaLabel="Toggle template description" - /> - {features.templateDescription && ( - - )} - - - - Export & Import Settings - - - - You can export your current settings as a JSON file. Your settings - contain access tokens. Be careful and make sure to remove your - tokens before sharing. - + - - showSuccess("Exported settings successfully")} - onClick={resetBanners} - /> - { - loadSettings(); - showSuccess("Imported settings successfully"); - }} - onClick={resetBanners} - /> - + {renderTabContent()} diff --git a/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.module.scss b/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.module.scss new file mode 100644 index 00000000..896ecba3 --- /dev/null +++ b/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.module.scss @@ -0,0 +1,6 @@ +@import "../../../shared-component-styles.scss"; + +.featuresList { + @extend .flexColumn; + gap: 24px; +} diff --git a/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.tsx b/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.tsx new file mode 100644 index 00000000..994ee4df --- /dev/null +++ b/src/pages/Tabs/FeatureTogglesTab/FeatureTogglesTab.tsx @@ -0,0 +1,148 @@ +import { Box } from "@primer/react"; +import React from "react"; +import { + FeatureInput, + FeatureItem, + SectionTitle, + Subtitle, +} from "../../../components"; +import { DescriptionTemplatePlaceholders } from "../../../content"; +import { Features } from "../../../services/getSettings"; +import styles from "./FeatureTogglesTab.module.scss"; + +type Props = { + features: Features; + onToggle: (key: keyof Features) => void; + onError: (message?: string) => void; +}; + +export const FeatureTogglesTab: React.FC = ({ + features, + onToggle, + onError, +}) => { + return ( + <> + + + Enable and disable Extension-features. Features are organized by + GitHub-page. + + + General Settings + + onToggle("persistToUserProfile")} + ariaLabel="Toggle persist to user profile" + /> + + Pull Requests List + + onToggle("baseBranchLabels")} + ariaLabel="Toggle base branch labels" + /> + + onToggle("changedFiles")} + ariaLabel="Toggle changed files" + /> + + onToggle("totalLinesPrs")} + ariaLabel="Toggle total lines counter" + /> + + onToggle("reOrderPrs")} + ariaLabel="Toggle reorder pull requests" + /> + + onToggle("addUpdateBranchButton")} + ariaLabel="Toggle add update branch button" + /> + + onToggle("autoFilter")} + ariaLabel="Toggle auto filter" + /> + + + Individual Pull Request + + onToggle("randomReviewer")} + ariaLabel="Toggle assign random reviewer" + /> + + {/* Same as for the prs list page, as of now */} + onToggle("totalLinesPr")} + ariaLabel="Toggle total lines counter" + /> + + Create Pull Request + + onToggle("prTitleFromJira")} + ariaLabel="Toggle add PR title from Jira" + /> + + onToggle("descriptionTemplate")} + ariaLabel="Toggle description template" + /> + + + + ); +}; diff --git a/src/pages/Tabs/FeatureTogglesTab/index.ts b/src/pages/Tabs/FeatureTogglesTab/index.ts new file mode 100644 index 00000000..4bc2c554 --- /dev/null +++ b/src/pages/Tabs/FeatureTogglesTab/index.ts @@ -0,0 +1 @@ +export { FeatureTogglesTab } from "./FeatureTogglesTab"; diff --git a/src/popup/Tabs/GhInstancesTab/GhInstancesTab.module.scss b/src/pages/Tabs/GhInstancesTab/GhInstancesTab.module.scss similarity index 90% rename from src/popup/Tabs/GhInstancesTab/GhInstancesTab.module.scss rename to src/pages/Tabs/GhInstancesTab/GhInstancesTab.module.scss index 124c447b..9f53b73d 100644 --- a/src/popup/Tabs/GhInstancesTab/GhInstancesTab.module.scss +++ b/src/pages/Tabs/GhInstancesTab/GhInstancesTab.module.scss @@ -13,7 +13,3 @@ margin-top: 0.5rem; } } - -.heading { - color: $fontColor; -} diff --git a/src/pages/Tabs/GhInstancesTab/GhInstancesTab.tsx b/src/pages/Tabs/GhInstancesTab/GhInstancesTab.tsx new file mode 100644 index 00000000..3b238e74 --- /dev/null +++ b/src/pages/Tabs/GhInstancesTab/GhInstancesTab.tsx @@ -0,0 +1,73 @@ +import { Pagehead } from "@primer/react"; +import { FieldArray } from "formik"; +import React from "react"; +import { isNonEmptyArray } from "ts-type-safe"; +import { SectionTitle, Subtitle } from "../../../components"; +import { Settings } from "../../../services"; +import { AddButton, RemoveButton } from "../../Button"; +import { FormField } from "../../FormField"; +import styles from "./GhInstancesTab.module.scss"; + +type Props = { + values: Settings; + isValid: boolean; +}; + +export const GhInstancesTab = ({ values, isValid }: Props) => { + return ( + <> + + Configure GitHub instances with API tokens and repository access. + + + {(arrayHelpers) => ( + <> + {isNonEmptyArray(values.instances) && + values.instances.map((_, index) => ( + + + {`GH Instance ${index + 1}`} + arrayHelpers.remove(index)} + /> + + + + + + {values.features.randomReviewer && ( + + )} + + ))} + arrayHelpers.push(obj)} + /> + + )} + + + ); +}; diff --git a/src/popup/Tabs/GhInstancesTab/index.ts b/src/pages/Tabs/GhInstancesTab/index.ts similarity index 100% rename from src/popup/Tabs/GhInstancesTab/index.ts rename to src/pages/Tabs/GhInstancesTab/index.ts diff --git a/src/pages/Tabs/ImportExportTab/ImportExportTab.tsx b/src/pages/Tabs/ImportExportTab/ImportExportTab.tsx new file mode 100644 index 00000000..065b2060 --- /dev/null +++ b/src/pages/Tabs/ImportExportTab/ImportExportTab.tsx @@ -0,0 +1,44 @@ +import { Box } from "@primer/react"; +import React from "react"; +import { Subtitle } from "../../../components"; +import { ExportButton, ImportButton } from "../../Button"; + +type Props = { + onError: (message?: string) => void; + onSuccess: (message: string) => void; + onReset: () => void; + onLoadSettings: () => void; +}; + +export const ImportExportTab: React.FC = ({ + onError, + onSuccess, + onReset, + onLoadSettings, +}) => { + return ( + <> + + You can export your current settings as a JSON file. Your settings + contain access tokens. Be careful and make sure to remove your tokens + before sharing. + + + + onSuccess("Exported settings successfully")} + onClick={onReset} + /> + { + onLoadSettings(); + onSuccess("Imported settings successfully"); + }} + onClick={onReset} + /> + + + ); +}; diff --git a/src/pages/Tabs/ImportExportTab/index.ts b/src/pages/Tabs/ImportExportTab/index.ts new file mode 100644 index 00000000..4e50ac4b --- /dev/null +++ b/src/pages/Tabs/ImportExportTab/index.ts @@ -0,0 +1 @@ +export { ImportExportTab } from "./ImportExportTab"; diff --git a/src/popup/Tabs/JiraTab/JiraTab.tsx b/src/pages/Tabs/JiraTab/JiraTab.tsx similarity index 83% rename from src/popup/Tabs/JiraTab/JiraTab.tsx rename to src/pages/Tabs/JiraTab/JiraTab.tsx index 802ad8ac..bb28a9bc 100644 --- a/src/popup/Tabs/JiraTab/JiraTab.tsx +++ b/src/pages/Tabs/JiraTab/JiraTab.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { Subtitle } from "../../../components"; import { FormField } from "../../FormField"; type Props = { @@ -8,6 +9,9 @@ type Props = { export const JiraTab: React.FC = ({ disabled }) => { return ( <> + + Configure Jira integration for automatic issue linking and PR titles. + - - - - , -); +// Immediately open options page and close popup +void chrome.tabs.create({ url: chrome.runtime.getURL("options.html") }); +window.close(); diff --git a/src/popup/Content.module.scss b/src/popup/Content.module.scss deleted file mode 100644 index e8c0f863..00000000 --- a/src/popup/Content.module.scss +++ /dev/null @@ -1,52 +0,0 @@ -.wrapper { - min-width: 450px; - max-height: 560px; - background-color: rgb(13, 17, 23); - display: flex; - flex-direction: column; -} - -.container { - background-color: rgb(22, 27, 34); - border-bottom-color: rgb(48, 54, 61); - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; - border-bottom-style: solid; - border-bottom-width: 0.8px; - border-left-color: rgb(48, 54, 61); - border-left-style: solid; - border-left-width: 0.8px; - border-right-color: rgb(48, 54, 61); - border-right-style: solid; - border-right-width: 0.8px; - border-top-color: rgb(48, 54, 61); - border-top-left-radius: 6px; - border-top-right-radius: 6px; - border-top-style: solid; - border-top-width: 0.8px; - box-sizing: border-box; -} - -.headingContainer { - display: flex; - justify-content: center; - flex-direction: column; -} - -.form { - padding: 1rem 2rem; -} - -$fontColor: rgba(255, 255, 255); - -.heading { - color: $fontColor; - font-size: 20px; - font-weight: 400; - padding: 1rem; -} - -.divider { - border-bottom: 0.8px solid rgb(33, 38, 45); -} - diff --git a/src/popup/Content.tsx b/src/popup/Content.tsx deleted file mode 100644 index 36bf00bf..00000000 --- a/src/popup/Content.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Link } from "@primer/react"; -import { Form, Formik, FormikHelpers } from "formik"; -import React, { useEffect, useState } from "react"; -import { Tab, TabNavigation } from "../components"; -import { - INITIAL_VALUES, - Settings, - getSettings, - persistSettings, - settingsSchema, -} from "../services"; -import { SubmitButton } from "./Button"; -import styles from "./Content.module.scss"; -import { GhInstancesTab, JiraTab } from "./Tabs"; -import { AutoFilterTab } from "./Tabs/AutoFilterTab"; -import { Paragraph } from "./Typography"; - -const tabs: Array = ["GH Instances", "Auto filter", "Jira"]; - -export const Content = () => { - const [activeTab, setActiveTab] = useState("GH Instances"); - const [result, resultSet] = useState(""); - const [initialValues, initialValuesSet] = useState(INITIAL_VALUES); - - useEffect(() => { - getSettings({ - onSuccess: initialValuesSet, - onError: () => resultSet("Couldn't load from chrome storage"), - }); - // eslint-disable-next-line react-hooks-addons/no-unused-deps - }, [initialValues]); - - const handleSubmit = ( - values: Settings, - { setSubmitting, resetForm }: FormikHelpers, - ) => - persistSettings({ - values, - onSuccess: () => { - // reset form-state, e.g. isDirty - resetForm({ values }); - resultSet("Saved successfully"); - }, - onError: () => resultSet("Couldn't save"), - onSettled: () => setSubmitting(false), - }); - - const mapTabToComponent = (tab: Tab, values: Settings, isValid: boolean) => { - switch (tab) { - case "Auto filter": - return ; - case "GH Instances": - return ; - case "Jira": - return ; - } - }; - - return ( -
-
-
-

GitHub UI Booster - Settings

- - Enable/disable features in the{" "} - - options page - - -
-
- - - {({ isValid, dirty, isSubmitting, values }) => { - return ( -
- {mapTabToComponent(activeTab, values, isValid)} - - - ); - }} -
-
-
- ); -}; diff --git a/src/popup/Tabs/AutoFilterTab/AutoFilterTab.tsx b/src/popup/Tabs/AutoFilterTab/AutoFilterTab.tsx deleted file mode 100644 index 2bd8c6ec..00000000 --- a/src/popup/Tabs/AutoFilterTab/AutoFilterTab.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react"; -import { FormField } from "../../FormField"; - -type Props = { - disabled: boolean; -}; - -export const AutoFilterTab: React.FC = ({ disabled }) => { - return ( - - ); -}; diff --git a/src/popup/Tabs/AutoFilterTab/index.ts b/src/popup/Tabs/AutoFilterTab/index.ts deleted file mode 100644 index 88e2228e..00000000 --- a/src/popup/Tabs/AutoFilterTab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AutoFilterTab } from "./AutoFilterTab"; diff --git a/src/popup/Tabs/GhInstancesTab/GhInstancesTab.tsx b/src/popup/Tabs/GhInstancesTab/GhInstancesTab.tsx deleted file mode 100644 index b76a9179..00000000 --- a/src/popup/Tabs/GhInstancesTab/GhInstancesTab.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Pagehead, Text } from "@primer/react"; -import { FieldArray } from "formik"; -import React from "react"; -import { isNonEmptyArray } from "ts-type-safe"; -import { Settings } from "../../../services"; -import { AddButton, RemoveButton } from "../../Button"; -import { FormField } from "../../FormField"; -import styles from "./GhInstancesTab.module.scss"; - -type Props = { - values: Settings; - isValid: boolean; -}; - -export const GhInstancesTab = ({ values, isValid }: Props) => { - return ( - - {/* eslint-disable-next-line @typescript-eslint/unbound-method */} - {({ push, remove }) => ( - <> - {isNonEmptyArray(values.instances) && - values.instances.map((_, index) => ( - - - - {`GH Instance ${index + 1}`} - - remove(index)} - /> - - - - - - {values.features.randomReviewer && ( - - )} - - ))} - - - )} - - ); -}; diff --git a/src/popup/Tabs/index.ts b/src/popup/Tabs/index.ts deleted file mode 100644 index 6ad31fcf..00000000 --- a/src/popup/Tabs/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./AutoFilterTab"; -export * from "./GhInstancesTab"; -export * from "./JiraTab"; diff --git a/src/popup/index.ts b/src/popup/index.ts deleted file mode 100644 index 7f2950d0..00000000 --- a/src/popup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Content } from "./Content"; diff --git a/src/services/getSettings.ts b/src/services/getSettings.ts index 09ffa1c7..6ee452d8 100644 --- a/src/services/getSettings.ts +++ b/src/services/getSettings.ts @@ -1,9 +1,7 @@ import { DeepKeysOf } from "ts-type-safe"; import { array, boolean, InferType, object, string } from "yup"; -const autoFilterSchema = object({ - filter: string().optional(), -}); +export const autoFilterSchema = string().optional(); export type AutoFilter = InferType; @@ -18,14 +16,15 @@ const instanceConfigSchema = object({ const featuresSchema = object({ baseBranchLabels: boolean().default(true), changedFiles: boolean().default(true), - totalLines: boolean().default(true), + totalLinesPrs: boolean().default(true), + totalLinesPr: boolean().default(true), reOrderPrs: boolean().default(true), addUpdateBranchButton: boolean().default(true), autoFilter: boolean().default(false), - jira: boolean().default(false), prTitleFromJira: boolean().default(false), - templateDescription: boolean().default(false), + descriptionTemplate: boolean().default(false), randomReviewer: boolean().default(false), + persistToUserProfile: boolean().default(false), }); const jiraSchema = object({ @@ -36,10 +35,10 @@ const jiraSchema = object({ export const settingsSchema = object({ instances: array(instanceConfigSchema).required(), - autoFilter: autoFilterSchema, + autoFilter: string().optional(), features: featuresSchema, jira: jiraSchema.optional(), - templateDescription: string().optional().default(""), + descriptionTemplate: string().optional().default(""), }); export type InstanceConfig = InferType; @@ -62,20 +61,20 @@ export const INITIAL_VALUES: Settings = { baseUrl: "https://your-jira-instance.atlassian.net", issueKeyRegex: "TEST-\\d+", }, - autoFilter: { filter: "" }, features: { baseBranchLabels: true, changedFiles: true, - totalLines: true, + totalLinesPrs: true, + totalLinesPr: true, reOrderPrs: true, addUpdateBranchButton: true, autoFilter: false, - jira: false, prTitleFromJira: false, - templateDescription: false, + descriptionTemplate: false, randomReviewer: false, + persistToUserProfile: false, }, - templateDescription: "", + descriptionTemplate: "", }; type Params = { @@ -89,6 +88,7 @@ const defaultOnError = (e?: unknown) => { }; export function getSettings({ onSuccess, onError = defaultOnError }: Params) { + // First try to get from local storage to check if persistToUserProfile is enabled chrome.storage.local .get(Object.keys(settingsSchema.fields)) .then((entries) => { @@ -97,7 +97,38 @@ export function getSettings({ onSuccess, onError = defaultOnError }: Params) { } else { settingsSchema .validate(entries, { strict: true }) - .then((settings) => onSuccess(settings)) + .then((settings) => { + // If persistToUserProfile is enabled, use sync storage + if (settings.features.persistToUserProfile) { + void chrome.storage.sync + .get(Object.keys(settingsSchema.fields)) + .then((syncEntries) => { + if (Object.keys(syncEntries).length === 0) { + // No sync data, use local settings + void onSuccess(settings); + } else { + void settingsSchema + .validate(syncEntries, { strict: true }) + .then((syncSettings) => onSuccess(syncSettings)) + .catch((e) => { + // Fallback to local if sync validation fails + console.warn( + "Sync storage validation failed, using local:", + e, + ); + void onSuccess(settings); + }); + } + }) + .catch((e) => { + // Fallback to local if sync fails + console.warn("Sync storage access failed, using local:", e); + void onSuccess(settings); + }); + } else { + void onSuccess(settings); + } + }) .catch((e) => { onError(e); }); diff --git a/src/services/persistSettings.ts b/src/services/persistSettings.ts index 025145c3..47a8a560 100644 --- a/src/services/persistSettings.ts +++ b/src/services/persistSettings.ts @@ -11,12 +11,32 @@ export function persistSettings({ onError?: (error: string) => void; onSettled?: () => void; }) { + const useSync = values.features.persistToUserProfile; + const storage = useSync ? chrome.storage.sync : chrome.storage.local; + const promises = Object.entries(values).map(([key, value]) => { - return chrome.storage.local.set({ + return storage.set({ [key]: value, }); }); + Promise.all(promises) + .then(() => { + // If switching to sync, also save to local as backup + // If switching to local, also clear sync to prevent confusion + if (useSync) { + // Always keep a local backup when using sync + const localPromises = Object.entries(values).map(([key, value]) => { + return chrome.storage.local.set({ + [key]: value, + }); + }); + return Promise.all(localPromises).then(() => void 0); + } else { + // Clear sync storage when switching back to local + return chrome.storage.sync.clear(); + } + }) .then(onSuccess) .catch(() => onError?.("Couldn't save")) .finally(onSettled);