diff --git a/browser-extension/README.md b/browser-extension/README.md index 0070352..cb2641f 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -34,8 +34,32 @@ This is a [WXT](https://wxt.dev/)-based browser extension that ### Entry points - `src/entrypoints/content.ts` - injected into every webpage +- `src/entrypoints/background.ts` - service worker that manages state and handles messages - `src/entrypoints/popup` - html/css/ts which opens when the extension's button gets clicked +```mermaid +graph TD + Content[Content Script
content.ts] + Background[Background Script
background.ts] + Popup[Popup Script
popup/main.ts] + + Content -->|ENHANCED/DESTROYED
CommentEvent| Background + Popup -->|GET_OPEN_SPOTS
SWITCH_TO_TAB| Background + Background -->|GetOpenSpotsResponse
spots array| Popup + + Background -.->|manages| Storage[Comment State Storage
openSpots JsonMap] + Content -.->|enhances| TextArea[textarea elements
on web pages] + Popup -.->|displays| UI[Extension UI
list of comment spots] + + classDef entrypoint fill:#e1f5fe + classDef storage fill:#f3e5f5 + classDef ui fill:#e8f5e8 + + class Content,Background,Popup entrypoint + class Storage storage + class TextArea,UI ui +``` + ### Architecture Every time a `textarea` shows up on a page, on initial load or later on, it gets passed to a list of `CommentEnhancer`s. Each one gets a turn to say "I can enhance this box!". They show that they can enhance it by returning a [`CommentSpot`, `Overtype`]. diff --git a/browser-extension/package.json b/browser-extension/package.json index 7a6b835..128aa79 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -41,6 +41,7 @@ "build": "wxt build", "build:dev": "wxt build --mode development", "build:firefox": "wxt build -b firefox", + "precommit": "npm run biome:fix && npm run typecheck && npm run test", "typecheck": "tsc --noEmit", "dev": "wxt", "dev:firefox": "wxt -b firefox", diff --git a/browser-extension/src/entrypoints/background.ts b/browser-extension/src/entrypoints/background.ts new file mode 100644 index 0000000..3e071a1 --- /dev/null +++ b/browser-extension/src/entrypoints/background.ts @@ -0,0 +1,85 @@ +import type { CommentDraft, CommentEvent, CommentSpot } from '../lib/enhancer' +import type { GetOpenSpotsResponse, ToBackgroundMessage } from '../lib/messages' +import { + CLOSE_MESSAGE_PORT, + isContentToBackgroundMessage, + isGetOpenSpotsMessage, + isSwitchToTabMessage, + KEEP_PORT_OPEN, +} from '../lib/messages' + +export interface Tab { + tabId: number + windowId: number +} +export interface CommentState { + tab: Tab + spot: CommentSpot + drafts: [number, CommentDraft][] +} + +export const openSpots = new Map() + +export function handleCommentEvent(message: CommentEvent, sender: any): boolean { + if ( + (message.type === 'ENHANCED' || message.type === 'DESTROYED') && + sender.tab?.id && + sender.tab?.windowId + ) { + if (message.type === 'ENHANCED') { + const tab: Tab = { + tabId: sender.tab.id, + windowId: sender.tab.windowId, + } + const commentState: CommentState = { + drafts: [], + spot: message.spot, + tab, + } + openSpots.set(message.spot.unique_key, commentState) + } else if (message.type === 'DESTROYED') { + openSpots.delete(message.spot.unique_key) + } else { + throw new Error(`Unhandled comment event type: ${message.type}`) + } + } + return CLOSE_MESSAGE_PORT +} + +export function handlePopupMessage( + message: any, + _sender: any, + sendResponse: (response: any) => void, +): typeof CLOSE_MESSAGE_PORT | typeof KEEP_PORT_OPEN { + if (isGetOpenSpotsMessage(message)) { + const spots: CommentState[] = [] + for (const [, commentState] of openSpots) { + spots.push(commentState) + } + const response: GetOpenSpotsResponse = { spots } + sendResponse(response) + return KEEP_PORT_OPEN + } else if (isSwitchToTabMessage(message)) { + browser.windows + .update(message.windowId, { focused: true }) + .then(() => { + return browser.tabs.update(message.tabId, { active: true }) + }) + .catch((error) => { + console.error('Error switching to tab:', error) + }) + return CLOSE_MESSAGE_PORT + } else { + throw new Error(`Unhandled popup message type: ${message?.type || 'unknown'}`) + } +} + +export default defineBackground(() => { + browser.runtime.onMessage.addListener((message: ToBackgroundMessage, sender, sendResponse) => { + if (isContentToBackgroundMessage(message)) { + return handleCommentEvent(message, sender) + } else { + return handlePopupMessage(message, sender, sendResponse) + } + }) +}) diff --git a/browser-extension/src/entrypoints/content.ts b/browser-extension/src/entrypoints/content.ts index 01a886d..83de338 100644 --- a/browser-extension/src/entrypoints/content.ts +++ b/browser-extension/src/entrypoints/content.ts @@ -1,4 +1,5 @@ import { CONFIG, type ModeType } from '../lib/config' +import type { CommentEvent, CommentSpot } from '../lib/enhancer' import { logger } from '../lib/logger' import { EnhancerRegistry, TextareaRegistry } from '../lib/registries' import { githubPrNewCommentContentScript } from '../playgrounds/github-playground' @@ -6,6 +7,21 @@ import { githubPrNewCommentContentScript } from '../playgrounds/github-playgroun const enhancers = new EnhancerRegistry() const enhancedTextareas = new TextareaRegistry() +function sendEventToBackground(type: 'ENHANCED' | 'DESTROYED', spot: CommentSpot): void { + const message: CommentEvent = { + spot, + type, + } + browser.runtime.sendMessage(message).catch((error) => { + logger.debug('Failed to send event to background:', error) + }) +} + +enhancedTextareas.setEventHandlers( + (spot) => sendEventToBackground('ENHANCED', spot), + (spot) => sendEventToBackground('DESTROYED', spot), +) + export default defineContentScript({ main() { if ((CONFIG.MODE as ModeType) === 'PLAYGROUNDS_PR') { diff --git a/browser-extension/src/entrypoints/popup/index.html b/browser-extension/src/entrypoints/popup/index.html index a9c17b6..af3b409 100644 --- a/browser-extension/src/entrypoints/popup/index.html +++ b/browser-extension/src/entrypoints/popup/index.html @@ -3,17 +3,10 @@ - Gitcasso Markdown Assistant + Gitcasso
-
- -
Syntax highlighting and autosave for comments on GitHub (and other other markdown-friendly websites).
-
-
-

Loading drafts from local storage...

-

diff --git a/browser-extension/src/entrypoints/popup/main.ts b/browser-extension/src/entrypoints/popup/main.ts index cdb6d2d..4ccea9a 100644 --- a/browser-extension/src/entrypoints/popup/main.ts +++ b/browser-extension/src/entrypoints/popup/main.ts @@ -1,27 +1,97 @@ import './style.css' +import { logger } from '../../lib/logger' +import type { + GetOpenSpotsMessage, + GetOpenSpotsResponse, + SwitchToTabMessage, +} from '../../lib/messages' +import { EnhancerRegistry } from '../../lib/registries' +import type { CommentState } from '../background' -document.addEventListener('DOMContentLoaded', async () => { - const statusDiv = document.getElementById('scan-results') as HTMLElement +// Test basic DOM access +try { + const app = document.getElementById('app')! + logger.debug('Found app element:', app) + app.innerHTML = '
Script is running...
' +} catch (error) { + logger.error('Error accessing DOM:', error) +} +const enhancers = new EnhancerRegistry() + +async function getOpenSpots(): Promise { + logger.debug('Sending message to background script...') try { - // get current active tab - const tabs = await browser.tabs.query({ active: true, currentWindow: true }) - const tab = tabs[0] - - if (!tab?.id) { - statusDiv.textContent = 'Cannot access current tab' - return - } - - // send message to content script to get scan results - const results = await browser.tabs.sendMessage(tab.id, { - action: 'getScanResults', - }) - if (results) { - // TODO: statusDiv.textContent = {{show drafts}} - } + const message: GetOpenSpotsMessage = { type: 'GET_OPEN_SPOTS' } + const response = (await browser.runtime.sendMessage(message)) as GetOpenSpotsResponse + logger.debug('Received response:', response) + return response.spots || [] } catch (error) { - console.error('Popup error:', error) - statusDiv.textContent = 'Unable to load saved drafts.' + logger.error('Error sending message to background:', error) + return [] + } +} + +function switchToTab(tabId: number, windowId: number): void { + // Send message to background script to handle tab switching + // This avoids the popup context being destroyed before completion + const message: SwitchToTabMessage = { + tabId, + type: 'SWITCH_TO_TAB', + windowId, + } + browser.runtime.sendMessage(message) + window.close() +} + +function createSpotElement(commentState: CommentState): HTMLElement { + const item = document.createElement('div') + item.className = 'spot-item' + + logger.debug('Creating spot element for:', commentState.spot) + const enhancer = enhancers.enhancerFor(commentState.spot) + if (!enhancer) { + logger.error('No enhancer found for:', commentState.spot) + logger.error('Only have enhancers for:', enhancers.byType) } + + const title = document.createElement('div') + title.className = 'spot-title' + title.textContent = enhancer.tableTitle(commentState.spot) + item.appendChild(title) + item.addEventListener('click', () => { + switchToTab(commentState.tab.tabId, commentState.tab.windowId) + }) + return item +} + +async function renderOpenSpots(): Promise { + logger.debug('renderOpenSpots called') + const app = document.getElementById('app')! + const spots = await getOpenSpots() + logger.debug('Got spots:', spots) + + if (spots.length === 0) { + app.innerHTML = '
No open comment spots
' + return + } + + const header = document.createElement('h2') + header.textContent = 'Open Comment Spots' + app.appendChild(header) + + const list = document.createElement('div') + list.className = 'spots-list' + + spots.forEach((spot) => { + list.appendChild(createSpotElement(spot)) + }) + + app.appendChild(list) +} + +renderOpenSpots().catch((error) => { + logger.error('Error in renderOpenSpots:', error) + const app = document.getElementById('app')! + app.innerHTML = `
Error loading spots: ${error.message}
` }) diff --git a/browser-extension/src/entrypoints/popup/style.css b/browser-extension/src/entrypoints/popup/style.css index 40c75e1..0d63390 100644 --- a/browser-extension/src/entrypoints/popup/style.css +++ b/browser-extension/src/entrypoints/popup/style.css @@ -7,19 +7,47 @@ body { margin: 0; } -.header { - text-align: center; - margin-bottom: 15px; +h2 { + margin: 0 0 15px 0; + font-size: 16px; + font-weight: 600; + color: #333; +} + +.spots-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.spot-item { + padding: 10px; + border: 1px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + background: white; +} + +.spot-item:hover { + background: #f5f5f5; + border-color: #d0d0d0; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } -.logo { - font-size: 18px; - font-weight: bold; - color: #0066cc; - margin-bottom: 8px; +.spot-title { + font-weight: 500; + color: #333; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.subtitle { +.no-spots { + text-align: center; color: #666; - font-size: 12px; + padding: 40px 20px; + font-style: italic; } diff --git a/browser-extension/src/lib/enhancer.ts b/browser-extension/src/lib/enhancer.ts index e1e86fc..0895454 100644 --- a/browser-extension/src/lib/enhancer.ts +++ b/browser-extension/src/lib/enhancer.ts @@ -10,6 +10,19 @@ export interface CommentSpot { type: string } +export interface CommentDraft { + title?: string + body: string +} + +export type CommentEventType = 'ENHANCED' | 'LOST_FOCUS' | 'DESTROYED' + +export interface CommentEvent { + type: CommentEventType + spot: CommentSpot + draft?: CommentDraft +} + /** Wraps the textareas of a given platform with Gitcasso's enhancements. */ export interface CommentEnhancer { /** Guarantees to only return a type within this list. */ @@ -30,5 +43,4 @@ export interface CommentEnhancer { tableIcon(spot: Spot): string tableTitle(spot: Spot): string - buildUrl(spot: Spot): string } diff --git a/browser-extension/src/lib/messages.ts b/browser-extension/src/lib/messages.ts new file mode 100644 index 0000000..e04e8c5 --- /dev/null +++ b/browser-extension/src/lib/messages.ts @@ -0,0 +1,80 @@ +import type { CommentDraft, CommentEvent, CommentSpot } from './enhancer' + +// Message handler response types +export const CLOSE_MESSAGE_PORT = false as const // No response will be sent +export const KEEP_PORT_OPEN = true as const // Response will be sent (possibly async) + +// Content -> Background messages (already well-typed as CommentEvent) +export type ContentToBackgroundMessage = CommentEvent + +// Popup -> Background messages +export interface GetOpenSpotsMessage { + type: 'GET_OPEN_SPOTS' +} + +export interface SwitchToTabMessage { + type: 'SWITCH_TO_TAB' + tabId: number + windowId: number +} + +export type PopupToBackgroundMessage = GetOpenSpotsMessage | SwitchToTabMessage + +// All messages sent to background +export type ToBackgroundMessage = ContentToBackgroundMessage | PopupToBackgroundMessage + +// Background -> Popup responses +export interface GetOpenSpotsResponse { + spots: Array<{ + tab: { + tabId: number + windowId: number + } + spot: CommentSpot + drafts: Array<[number, CommentDraft]> + }> +} + +// Type guard functions +export function isContentToBackgroundMessage(message: any): message is ContentToBackgroundMessage { + return ( + message && + typeof message.type === 'string' && + (message.type === 'ENHANCED' || message.type === 'DESTROYED') && + message.spot + ) +} + +export function isPopupToBackgroundMessage(message: any): message is PopupToBackgroundMessage { + return ( + message && + typeof message.type === 'string' && + (message.type === 'GET_OPEN_SPOTS' || message.type === 'SWITCH_TO_TAB') + ) +} + +export function isGetOpenSpotsMessage(message: any): message is GetOpenSpotsMessage { + return message && message.type === 'GET_OPEN_SPOTS' +} + +export function isSwitchToTabMessage(message: any): message is SwitchToTabMessage { + return ( + message && + message.type === 'SWITCH_TO_TAB' && + typeof message.tabId === 'number' && + typeof message.windowId === 'number' + ) +} + +// Message handler types +export type BackgroundMessageHandler = ( + message: ToBackgroundMessage, + sender: any, + sendResponse: (response?: any) => void, +) => boolean | undefined + +export type PopupMessageSender = { + sendMessage( + message: T, + ): Promise +} diff --git a/browser-extension/src/lib/registries.ts b/browser-extension/src/lib/registries.ts index 367533a..b6d8ad9 100644 --- a/browser-extension/src/lib/registries.ts +++ b/browser-extension/src/lib/registries.ts @@ -13,6 +13,7 @@ export interface EnhancedTextarea { export class EnhancerRegistry { private enhancers = new Set() private preparedEnhancers = new Set() + byType = new Map() constructor() { // Register all available handlers @@ -20,8 +21,15 @@ export class EnhancerRegistry { this.register(new GitHubPRAddCommentEnhancer()) } - private register(handler: CommentEnhancer): void { - this.enhancers.add(handler) + private register(enhancer: CommentEnhancer): void { + this.enhancers.add(enhancer) + for (const spotType of enhancer.forSpotTypes()) { + this.byType.set(spotType, enhancer) + } + } + + enhancerFor(spot: T): CommentEnhancer { + return this.byType.get(spot.type)! as CommentEnhancer } tryToEnhance(textarea: HTMLTextAreaElement): EnhancedTextarea | null { @@ -51,15 +59,26 @@ export class EnhancerRegistry { export class TextareaRegistry { private textareas = new Map() + private onEnhanced?: (spot: CommentSpot) => void + private onDestroyed?: (spot: CommentSpot) => void + + setEventHandlers( + onEnhanced: (spot: CommentSpot) => void, + onDestroyed: (spot: CommentSpot) => void, + ): void { + this.onEnhanced = onEnhanced + this.onDestroyed = onDestroyed + } register(textareaInfo: EnhancedTextarea): void { this.textareas.set(textareaInfo.textarea, textareaInfo) - // TODO: register as a draft in progress with the global list + this.onEnhanced?.(textareaInfo.spot) } unregisterDueToModification(textarea: HTMLTextAreaElement): void { - if (this.textareas.has(textarea)) { - // TODO: register as abandoned or maybe submitted with the global list + const textareaInfo = this.textareas.get(textarea) + if (textareaInfo) { + this.onDestroyed?.(textareaInfo.spot) this.textareas.delete(textarea) } } diff --git a/browser-extension/tests/background.test.ts b/browser-extension/tests/background.test.ts new file mode 100644 index 0000000..961e1d0 --- /dev/null +++ b/browser-extension/tests/background.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { handleCommentEvent, openSpots } from '../src/entrypoints/background' +import type { CommentEvent, CommentSpot } from '../src/lib/enhancer' + +const mockSender = { + tab: { + id: 123, + windowId: 456, + }, +} +const mockSpot = { + type: 'TEST_SPOT', + unique_key: 'test-key', +} +describe('Background Event Handler', () => { + beforeEach(() => { + openSpots.clear() + }) + describe('ENHANCED Event', () => { + it('should create new comment state when textarea is enhanced', () => { + handleCommentEvent( + { + spot: mockSpot, + type: 'ENHANCED', + }, + mockSender, + ) + expect(Array.from(openSpots)).toMatchInlineSnapshot(` + [ + [ + "test-key", + { + "drafts": [], + "spot": { + "type": "TEST_SPOT", + "unique_key": "test-key", + }, + "tab": { + "tabId": 123, + "windowId": 456, + }, + }, + ], + ] + `) + }) + it('should not handle ENHANCED event without tab info', () => { + const senderWithoutTab = { tab: null } + handleCommentEvent( + { + spot: mockSpot, + type: 'ENHANCED', + }, + senderWithoutTab, + ) + expect(openSpots.size).toBe(0) + }) + }) + + describe('DESTROYED Event', () => { + it('should remove comment state when textarea is destroyed', () => { + // First create a state using the actual handler + const enhanceMessage: CommentEvent = { + spot: mockSpot, + type: 'ENHANCED', + } + handleCommentEvent(enhanceMessage, mockSender) + expect(openSpots.size).toBe(1) + + // Then destroy it + const destroyMessage: CommentEvent = { + spot: mockSpot, + type: 'DESTROYED', + } + handleCommentEvent(destroyMessage, mockSender) + expect(openSpots.size).toBe(0) + }) + + it('should handle DESTROYED event for non-existent state gracefully', () => { + const message: CommentEvent = { + spot: mockSpot, + type: 'DESTROYED', + } + // Should not throw error + handleCommentEvent(message, mockSender) + expect(openSpots.size).toBe(0) + }) + }) + + describe('Invalid Events', () => { + it('should ignore events with unsupported type', () => { + const message: CommentEvent = { + spot: mockSpot, + type: 'LOST_FOCUS', + } + handleCommentEvent(message, mockSender) + expect(openSpots.size).toBe(0) + }) + }) + + describe('State Management', () => { + it('should handle multiple enhanced textareas from different tabs', () => { + const spot1: CommentSpot = { type: 'SPOT1', unique_key: 'key1' } + const spot2: CommentSpot = { type: 'SPOT2', unique_key: 'key2' } + const sender1 = { tab: { id: 123, windowId: 456 } } + const sender2 = { tab: { id: 789, windowId: 456 } } + handleCommentEvent({ spot: spot1, type: 'ENHANCED' }, sender1) + handleCommentEvent({ spot: spot2, type: 'ENHANCED' }, sender2) + expect(openSpots.size).toBe(2) + }) + + it('should handle same spot from same tab (overwrite)', () => { + const message: CommentEvent = { + spot: mockSpot, + type: 'ENHANCED', + } + + // Enhance same spot twice + handleCommentEvent(message, mockSender) + handleCommentEvent(message, mockSender) + + // Should still be 1 entry (overwritten) + expect(openSpots.size).toBe(1) + }) + }) +}) diff --git a/browser-extension/wxt.config.ts b/browser-extension/wxt.config.ts index edc2589..7a4699a 100644 --- a/browser-extension/wxt.config.ts +++ b/browser-extension/wxt.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ 128: '/icons/icon-128.png', }, name: 'Gitcasso', - permissions: ['activeTab'], + permissions: ['activeTab', 'tabs'], version: '1.0.0', }, modules: ['@wxt-dev/webextension-polyfill'],