diff --git a/src/content/approveHandler.ts b/src/content/approveHandler.ts new file mode 100644 index 0000000..5f774b7 --- /dev/null +++ b/src/content/approveHandler.ts @@ -0,0 +1,76 @@ +import { ShowSettings } from './showSettings'; +import { isPullRequestPage } from './pageUtils'; + +export interface ApprovalSuccessOptions { + showSettings: ShowSettings; + onApproved: () => void; +} + +export interface ApprovalButtonOptions { + showSettings: ShowSettings; +} + +export const checkForPRApprovalSuccess = (options: ApprovalSuccessOptions): void => { + if (!isPullRequestPage()) return; + + chrome.storage.local.get(['prApprovalTriggered', 'prApprovalTime'], (result) => { + if (result.prApprovalTriggered && result.prApprovalTime) { + const timeDiff = Date.now() - result.prApprovalTime; + if (timeDiff < 30000 && options.showSettings.isEnabled('approved')) { + console.log('✅ PR approval detected via storage flag, showing banner'); + options.onApproved(); + chrome.storage.local.remove(['prApprovalTriggered', 'prApprovalTime']); + } else if (timeDiff < 30000 && !options.showSettings.isEnabled('approved')) { + console.log('🚫 PR approval detected but disabled in settings'); + chrome.storage.local.remove(['prApprovalTriggered', 'prApprovalTime']); + } + } + }); +}; + +export const detectPRApprovalButtons = (options: ApprovalButtonOptions): void => { + if (!isPullRequestPage()) { + return; + } + + const dialogElement = document.querySelector('div[role="dialog"]'); + if (dialogElement) { + const buttons = Array.from(dialogElement.querySelectorAll('button')); + const submitButton = buttons.find((button) => + button.textContent?.toLowerCase().includes('submit review'), + ); + + if (submitButton && !submitButton.hasAttribute('data-elden-ring-approval-listener')) { + submitButton.addEventListener('click', () => { + const approveRadio = dialogElement.querySelector( + 'input[name="reviewEvent"][value="approve"]', + ) as HTMLInputElement; + + if (approveRadio && approveRadio.checked) { + if (options.showSettings.isEnabled('approved')) { + console.log('🎯 Submit review with approval selected'); + chrome.storage.local.set({ + prApprovalTriggered: true, + prApprovalTime: Date.now(), + }); + } else { + console.log('🚫 PR approval banner disabled in settings'); + } + } + }); + submitButton.setAttribute('data-elden-ring-approval-listener', 'true'); + } + } + + const approveRadios = document.querySelectorAll('input[name="reviewEvent"][value="approve"]'); + approveRadios.forEach((radio) => { + if (!radio.hasAttribute('data-elden-ring-approval-listener')) { + radio.addEventListener('change', () => { + if ((radio as HTMLInputElement).checked) { + console.log('🎯 Approve option selected'); + } + }); + radio.setAttribute('data-elden-ring-approval-listener', 'true'); + } + }); +}; diff --git a/src/content/closeHandler.ts b/src/content/closeHandler.ts new file mode 100644 index 0000000..a0863bf --- /dev/null +++ b/src/content/closeHandler.ts @@ -0,0 +1,34 @@ +import { ShowSettings } from './showSettings'; +import { isPullRequestPage } from './pageUtils'; +import { waitForCloseComplete } from './closeWatcher'; + +export interface CloseHandlerOptions { + showSettings: ShowSettings; + onClosed: () => void; +} + +export const detectCloseButtons = (options: CloseHandlerOptions): void => { + if (!isPullRequestPage() || !options.showSettings.isEnabled('closed')) { + return; + } + + const closeButtonContainer = document.querySelector('#partial-new-comment-form-actions'); + if (!closeButtonContainer) { + return; + } + + const closeButtons = closeButtonContainer.querySelectorAll('button'); + closeButtons.forEach((button) => { + const buttonText = button.textContent?.toLowerCase().trim() || ''; + if ( + buttonText.includes('close pull request') && + !button.hasAttribute('data-elden-ring-close-listener') + ) { + button.addEventListener('click', () => { + console.log('🛑 Close pull request button clicked'); + waitForCloseComplete(() => options.onClosed()); + }); + button.setAttribute('data-elden-ring-close-listener', 'true'); + } + }); +}; diff --git a/src/content/content.test.ts b/src/content/content.test.ts index 8f5b61c..6c772d1 100644 --- a/src/content/content.test.ts +++ b/src/content/content.test.ts @@ -37,7 +37,7 @@ afterEach(() => { document.body.innerHTML = ''; }); -describe('EldenRingMerger', () => { +describe('EldenRingOrchestrator', () => { it('should call storage API for loading settings', () => { // Simulate the loadSettings method behavior chrome.storage.sync.get(['soundEnabled', 'showOnPRMerged', 'showOnPRCreate'], () => {}); diff --git a/src/content/content.ts b/src/content/content.ts index b3b4bc2..a866b00 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,19 +1,28 @@ import { renderBanner, type BannerType } from './banner'; -import { waitForCloseComplete } from './closeWatcher'; - -class EldenRingMerger { +import { + MergeFeature, + CreationFeature, + ApprovalFeature, + CloseFeature, + type GitHubFeature, +} from './features'; +import { ShowSettings } from './showSettings'; +import { SettingsStore, type SettingsState } from './settingsStore'; + +class EldenRingOrchestrator { private bannerShown: boolean = false; private soundEnabled: boolean = true; - private showOnPRMerged: boolean = true; - private showOnPRCreate: boolean = true; - private showOnPRApprove: boolean = true; - private showOnPRClose: boolean = true; private soundType: 'you-die-sound' | 'lost-grace-discovered' = 'you-die-sound'; private soundUrl: string; + private features: GitHubFeature[] = []; + private settingsStore = new SettingsStore(); + private showSettings = new ShowSettings(() => this.settingsStore.getState()); constructor() { this.soundUrl = this.getSoundUrl(); - this.loadSettings(); + this.subscribeToSettings(); + this.settingsStore.init(); + this.features = this.createFeatures(); this.init(); } @@ -25,355 +34,43 @@ class EldenRingMerger { this.soundUrl = this.getSoundUrl(); } - private loadSettings(): void { - chrome.storage.sync.get( - [ - 'soundEnabled', - 'showOnPRMerged', - 'showOnPRCreate', - 'showOnPRApprove', - 'showOnPRClose', - 'soundType', - ], - (result: { - soundEnabled?: boolean; - showOnPRMerged?: boolean; - showOnPRCreate?: boolean; - showOnPRApprove?: boolean; - showOnPRClose?: boolean; - soundType?: 'you-die-sound' | 'lost-grace-discovered'; - }) => { - this.soundEnabled = result.soundEnabled !== false; // default true - this.showOnPRMerged = result.showOnPRMerged !== false; // default true - this.showOnPRCreate = result.showOnPRCreate !== false; // default true - this.showOnPRApprove = result.showOnPRApprove !== false; // default true - this.showOnPRClose = result.showOnPRClose !== false; // default true - this.soundType = result.soundType || 'you-die-sound'; // default you-die-sound - this.updateSoundUrl(); - }, - ); + private subscribeToSettings(): void { + this.settingsStore.subscribe((state) => this.applySettings(state)); + } - // Listen for settings changes - chrome.storage.onChanged.addListener( - (changes: { [key: string]: chrome.storage.StorageChange }) => { - if (changes.soundEnabled) { - this.soundEnabled = changes.soundEnabled.newValue; - } - if (changes.showOnPRMerged) { - this.showOnPRMerged = changes.showOnPRMerged.newValue; - } - if (changes.showOnPRCreate) { - this.showOnPRCreate = changes.showOnPRCreate.newValue; - } - if (changes.showOnPRApprove) { - this.showOnPRApprove = changes.showOnPRApprove.newValue; - } - if (changes.showOnPRClose) { - this.showOnPRClose = changes.showOnPRClose.newValue; - } - if (changes.soundType) { - this.soundType = changes.soundType.newValue; - this.updateSoundUrl(); - } - }, - ); + private applySettings(state: SettingsState): void { + this.soundEnabled = state.soundEnabled; + this.soundType = state.soundType; + this.updateSoundUrl(); } private init(): void { // Wait for page to load if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => this.setupMergeDetection()); + document.addEventListener('DOMContentLoaded', () => this.initializeFeatures()); } else { - this.setupMergeDetection(); + this.initializeFeatures(); } } - private setupMergeDetection(): void { - // Check if we just created a PR and should show banner - this.checkForPRCreationSuccess(); - - // Check if we just approved a PR and should show banner - this.checkForPRApprovalSuccess(); - - // Detect merge button clicks - this.detectMergeButtons(); - - // Detect close button clicks - this.detectCloseButtons(); - - // Detect PR creation button clicks - this.detectPRCreationButtons(); - - // Detect PR approval button clicks - this.detectPRApprovalButtons(); - - // Observe DOM changes for dynamically loaded content + private initializeFeatures(): void { + this.features.forEach((feature) => feature.initialize()); this.observeDOMChanges(); } - private detectMergeButtons(): void { - // First check if we're on a PR page - const currentUrl = window.location.href; - const isPRPage = /\/pull\/\d+/.test(currentUrl); - if (!isPRPage) { - return; - } - - // Look for buttons with merge text in .merge-pr container - const mergePrContainer = document.querySelector('.merge-pr'); - if (mergePrContainer) { - const buttons = mergePrContainer.querySelectorAll('button'); - buttons.forEach((button) => { - const buttonText = button.textContent?.toLowerCase().trim() || ''; - const specificMergeTexts = [ - 'confirm merge', - 'confirm squash and merge', - 'confirm rebase and merge', - ]; - - if ( - specificMergeTexts.some((text) => buttonText.includes(text)) && - !button.hasAttribute('data-elden-ring-listener') - ) { - button.addEventListener('click', () => { - console.log('🎯 Merge button clicked:', buttonText); - this.waitForMergeComplete(); - }); - button.setAttribute('data-elden-ring-listener', 'true'); - } - }); - } - } - - private detectCloseButtons(): void { - const currentUrl = window.location.href; - const isPRPage = /\/pull\/\d+/.test(currentUrl); - if (!isPRPage || !this.showOnPRClose) { - return; - } - - const closeButtonContainer = document.querySelector('#partial-new-comment-form-actions'); - if (!closeButtonContainer) { - return; - } - - const closeButtons = closeButtonContainer.querySelectorAll('button'); - closeButtons.forEach((button) => { - const buttonText = button.textContent?.toLowerCase().trim() || ''; - if ( - buttonText.includes('close pull request') && - !button.hasAttribute('data-elden-ring-close-listener') - ) { - button.addEventListener('click', () => { - console.log('🛑 Close pull request button clicked'); - waitForCloseComplete(() => this.handleCloseCelebration()); - }); - button.setAttribute('data-elden-ring-close-listener', 'true'); - } - }); - } - private observeDOMChanges(): void { // Observe for GitHub's dynamic content loading const observer = new MutationObserver(() => { - this.detectMergeButtons(); - this.detectCloseButtons(); - this.detectPRCreationButtons(); - this.detectPRApprovalButtons(); - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - }); - } - - private checkForPRCreationSuccess(): void { - // Check if we're on a PR page and if creation was recently triggered - const currentUrl = window.location.href; - const isPRPage = /\/pull\/\d+/.test(currentUrl); - - if (!isPRPage) return; - - chrome.storage.local.get(['prCreationTriggered', 'prCreationTime'], (result) => { - if (result.prCreationTriggered && result.prCreationTime) { - const timeDiff = Date.now() - result.prCreationTime; - // If the PR was created within the last 30 seconds and the setting is enabled, show banner - if (timeDiff < 30000 && this.showOnPRCreate) { - console.log('✅ PR creation detected via storage flag, showing banner'); - this.showEldenRingBanner('created'); - - // Clear the flag so we don't show banner again - chrome.storage.local.remove(['prCreationTriggered', 'prCreationTime']); - } else if (timeDiff < 30000 && !this.showOnPRCreate) { - console.log('🚫 PR creation detected but disabled in settings'); - // Still clear the flag even if disabled - chrome.storage.local.remove(['prCreationTriggered', 'prCreationTime']); - } - } - }); - } - - private checkForPRApprovalSuccess(): void { - // Check if we're on a PR page and if approval was recently triggered - const currentUrl = window.location.href; - const isPRPage = /\/pull\/\d+/.test(currentUrl); - - if (!isPRPage) return; - - chrome.storage.local.get(['prApprovalTriggered', 'prApprovalTime'], (result) => { - if (result.prApprovalTriggered && result.prApprovalTime) { - const timeDiff = Date.now() - result.prApprovalTime; - // If the PR was approved within the last 30 seconds and the setting is enabled, show banner - if (timeDiff < 30000 && this.showOnPRApprove) { - console.log('✅ PR approval detected via storage flag, showing banner'); - this.showEldenRingBanner('approved'); - - // Clear the flag so we don't show banner again - chrome.storage.local.remove(['prApprovalTriggered', 'prApprovalTime']); - } else if (timeDiff < 30000 && !this.showOnPRApprove) { - console.log('🚫 PR approval detected but disabled in settings'); - // Still clear the flag even if disabled - chrome.storage.local.remove(['prApprovalTriggered', 'prApprovalTime']); - } - } - }); - } - - private detectPRCreationButtons(): void { - // Check if we're on a compare page - const currentUrl = window.location.href; - const isComparePage = /\/compare/.test(currentUrl); - if (!isComparePage) { - return; - } - - // Look for "Create pull request" button using GitHub's specific selector - const createButton = document.querySelector('.hx_create-pr-button'); - if (createButton && !createButton.hasAttribute('data-elden-ring-listener')) { - createButton.addEventListener('click', () => { - console.log('🎯 Create pull request button clicked'); - // Only set the flag if the feature is enabled - if (this.showOnPRCreate) { - console.log('📝 Setting PR creation flag in storage'); - chrome.storage.local.set({ - prCreationTriggered: true, - prCreationTime: Date.now(), - }); - } else { - console.log('🚫 PR creation banner disabled in settings'); - } - }); - createButton.setAttribute('data-elden-ring-listener', 'true'); - } - } - - private detectPRApprovalButtons(): void { - // Check if we're on a PR page (including files view) - const currentUrl = window.location.href; - const isPRPage = /\/pull\/\d+/.test(currentUrl); - if (!isPRPage) { - return; - } - - // Look for "Submit review" buttons within dialog - const dialogElement = document.querySelector('div[role="dialog"]'); - if (dialogElement) { - const buttons = Array.from(dialogElement.querySelectorAll('button')); - const submitButton = buttons.find((button) => - button.textContent?.toLowerCase().includes('submit review'), - ); - - if (submitButton && !submitButton.hasAttribute('data-elden-ring-approval-listener')) { - submitButton.addEventListener('click', () => { - // Check if approve radio button is selected within the same dialog at click time - const approveRadio = dialogElement.querySelector( - 'input[name="reviewEvent"][value="approve"]', - ) as HTMLInputElement; - - if (approveRadio && approveRadio.checked) { - if (this.showOnPRApprove) { - console.log('🎯 Submit review with approval selected'); - // Store approval flag for when we navigate back to PR page - chrome.storage.local.set({ - prApprovalTriggered: true, - prApprovalTime: Date.now(), - }); - } else { - console.log('🚫 PR approval banner disabled in settings'); - } - } - }); - submitButton.setAttribute('data-elden-ring-approval-listener', 'true'); - } - } - - // Also look for the approve radio button directly - const approveRadios = document.querySelectorAll('input[name="reviewEvent"][value="approve"]'); - approveRadios.forEach((radio) => { - if (!radio.hasAttribute('data-elden-ring-approval-listener')) { - radio.addEventListener('change', () => { - if ((radio as HTMLInputElement).checked) { - console.log('🎯 Approve option selected'); - } - }); - radio.setAttribute('data-elden-ring-approval-listener', 'true'); - } - }); - } - - private waitForMergeComplete(): void { - // Watch for the merged state to appear - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as Element; - // Check for merge success indicator - if ( - element.querySelector('.State.State--merged') || - element.matches('.State.State--merged') - ) { - console.log('✅ Merge completed successfully!'); - if (this.showOnPRMerged) { - this.showEldenRingBanner(); - } else { - console.log('🚫 PR merge banner disabled in settings'); - } - observer.disconnect(); // Stop observing once we find the merged state - } - } - }); - }); + this.features.forEach((feature) => feature.onDomChange()); }); observer.observe(document.body, { childList: true, subtree: true, }); - - // Also check if the merged state already exists (in case we missed it) - setTimeout(() => { - const mergedElement = document.querySelector('.State.State--merged'); - if (mergedElement) { - console.log('✅ Merge state already present!'); - if (this.showOnPRMerged) { - this.showEldenRingBanner(); - } else { - console.log('🚫 PR merge banner disabled in settings'); - } - observer.disconnect(); - } - }, 100); - - // Timeout after 10 seconds to avoid indefinite waiting - setTimeout(() => { - observer.disconnect(); - console.log('⏰ Merge detection timeout'); - }, 10000); } - public showEldenRingBanner(type: BannerType = 'merged'): void { + public showEldenRingBanner(type: BannerType): void { if (this.bannerShown) return; this.bannerShown = true; @@ -394,14 +91,27 @@ class EldenRingMerger { } } - private handleCloseCelebration(): void { - if (!this.showOnPRClose) { - console.log('🚫 PR close banner disabled in settings'); - return; - } - this.showEldenRingBanner('closed'); + private createFeatures(): GitHubFeature[] { + return [ + new MergeFeature({ + showSettings: this.showSettings, + onMerged: () => this.showEldenRingBanner('merged'), + }), + new CreationFeature({ + showSettings: this.showSettings, + onCreated: () => this.showEldenRingBanner('created'), + }), + new ApprovalFeature({ + showSettings: this.showSettings, + onApproved: () => this.showEldenRingBanner('approved'), + }), + new CloseFeature({ + showSettings: this.showSettings, + onCloseCelebration: () => this.showEldenRingBanner('closed'), + }), + ]; } } // Initialize the extension -new EldenRingMerger(); +new EldenRingOrchestrator(); diff --git a/src/content/createHandler.ts b/src/content/createHandler.ts new file mode 100644 index 0000000..2c3ed24 --- /dev/null +++ b/src/content/createHandler.ts @@ -0,0 +1,54 @@ +import { ShowSettings } from './showSettings'; +import { isPullRequestPage } from './pageUtils'; + +export interface CreationSuccessOptions { + showSettings: ShowSettings; + onCreated: () => void; +} + +export interface CreationButtonOptions { + showSettings: ShowSettings; +} + +export const checkForPRCreationSuccess = (options: CreationSuccessOptions): void => { + if (!isPullRequestPage()) return; + + chrome.storage.local.get(['prCreationTriggered', 'prCreationTime'], (result) => { + if (result.prCreationTriggered && result.prCreationTime) { + const timeDiff = Date.now() - result.prCreationTime; + if (timeDiff < 30000 && options.showSettings.isEnabled('created')) { + console.log('✅ PR creation detected via storage flag, showing banner'); + options.onCreated(); + chrome.storage.local.remove(['prCreationTriggered', 'prCreationTime']); + } else if (timeDiff < 30000 && !options.showSettings.isEnabled('created')) { + console.log('🚫 PR creation detected but disabled in settings'); + chrome.storage.local.remove(['prCreationTriggered', 'prCreationTime']); + } + } + }); +}; + +export const detectPRCreationButtons = (options: CreationButtonOptions): void => { + const currentUrl = window.location.href; + const isComparePage = /\/compare/.test(currentUrl); + if (!isComparePage) { + return; + } + + const createButton = document.querySelector('.hx_create-pr-button'); + if (createButton && !createButton.hasAttribute('data-elden-ring-listener')) { + createButton.addEventListener('click', () => { + console.log('🎯 Create pull request button clicked'); + if (options.showSettings.isEnabled('created')) { + console.log('📝 Setting PR creation flag in storage'); + chrome.storage.local.set({ + prCreationTriggered: true, + prCreationTime: Date.now(), + }); + } else { + console.log('🚫 PR creation banner disabled in settings'); + } + }); + createButton.setAttribute('data-elden-ring-listener', 'true'); + } +}; diff --git a/src/content/features.ts b/src/content/features.ts new file mode 100644 index 0000000..993379f --- /dev/null +++ b/src/content/features.ts @@ -0,0 +1,107 @@ +import { detectMergeButtons } from './mergeHandler'; +import { checkForPRCreationSuccess, detectPRCreationButtons } from './createHandler'; +import { checkForPRApprovalSuccess, detectPRApprovalButtons } from './approveHandler'; +import { detectCloseButtons } from './closeHandler'; +import { ShowSettings } from './showSettings'; + +export interface GitHubFeature { + initialize(): void; + onDomChange(): void; +} + +interface MergeFeatureOptions { + showSettings: ShowSettings; + onMerged: () => void; +} + +export class MergeFeature implements GitHubFeature { + constructor(private options: MergeFeatureOptions) {} + + private runDetection(): void { + detectMergeButtons({ + showSettings: this.options.showSettings, + onMerged: this.options.onMerged, + }); + } + + initialize(): void { + this.runDetection(); + } + + onDomChange(): void { + this.runDetection(); + } +} + +interface CreationFeatureOptions { + showSettings: ShowSettings; + onCreated: () => void; +} + +export class CreationFeature implements GitHubFeature { + constructor(private options: CreationFeatureOptions) {} + + initialize(): void { + checkForPRCreationSuccess({ + showSettings: this.options.showSettings, + onCreated: this.options.onCreated, + }); + detectPRCreationButtons({ + showSettings: this.options.showSettings, + }); + } + + onDomChange(): void { + detectPRCreationButtons({ + showSettings: this.options.showSettings, + }); + } +} + +interface ApprovalFeatureOptions { + showSettings: ShowSettings; + onApproved: () => void; +} + +export class ApprovalFeature implements GitHubFeature { + constructor(private options: ApprovalFeatureOptions) {} + + initialize(): void { + checkForPRApprovalSuccess({ + showSettings: this.options.showSettings, + onApproved: this.options.onApproved, + }); + detectPRApprovalButtons({ + showSettings: this.options.showSettings, + }); + } + + onDomChange(): void { + detectPRApprovalButtons({ + showSettings: this.options.showSettings, + }); + } +} + +interface CloseFeatureOptions { + showSettings: ShowSettings; + onCloseCelebration: () => void; +} + +export class CloseFeature implements GitHubFeature { + constructor(private options: CloseFeatureOptions) {} + + initialize(): void { + detectCloseButtons({ + showSettings: this.options.showSettings, + onClosed: this.options.onCloseCelebration, + }); + } + + onDomChange(): void { + detectCloseButtons({ + showSettings: this.options.showSettings, + onClosed: this.options.onCloseCelebration, + }); + } +} diff --git a/src/content/mergeHandler.test.ts b/src/content/mergeHandler.test.ts new file mode 100644 index 0000000..701dfc5 --- /dev/null +++ b/src/content/mergeHandler.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { detectMergeButtons } from './mergeHandler'; +import { ShowSettings } from './showSettings'; + +describe('mergeHandler', () => { + let observerCallback: ((mutations: MutationRecord[]) => void) | null = null; + + beforeEach(() => { + document.body.innerHTML = ` +