Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint-staged": "lint-staged",
"fix": "pnpm run lint:fix && pnpm run format && pnpm run check",
"generate-icons": "node scripts/generate-icons.mjs",
"postinstall": "wxt prepare",
"clean": "git clean -fXd",
Expand Down
100 changes: 31 additions & 69 deletions src/entrypoints/content/GithubPRFilter.svelte
Original file line number Diff line number Diff line change
@@ -1,149 +1,111 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { isGitHubPRPage, applyFilters, addFilter, removeFilter } from "@/lib/github";
import { isGitHubPRPage, applyFilters, addFilter, removeFilter, SELECTORS } from "@/lib/github";
import { filterStore } from "@/lib/stores/filters";
import { waitForElement, debounce } from "@/lib/utils/dom";
import { logger } from "@/lib/utils/logger";
import type { PRFilter } from "@/lib/types/filter";
import type { ExtensionMessage } from "@/lib/types/messages";
import { MESSAGE_ACTIONS, WAIT_TIMEOUTS, DEBOUNCE_DELAYS, TOAST_DURATION } from "@/lib/constants";
import Toast from "@/lib/components/Toast.svelte";
import { TOAST_TYPES, ToastType } from "@/lib/types/toast";

// Component state
let _filtersApplied = $state(false);
let toastMessage = $state("");
let toastType = $state<"info" | "success" | "error" | "warning">("success");
let toastType = $state<ToastType>(TOAST_TYPES.SUCCESS);
let enabledFilters = $state<string[]>([]);

// Apply filters when on GitHub PR page
async function tryApplyFilters() {
logger.debug("tryApplyFilters");

// Only run on GitHub PR pages
if (!isGitHubPRPage(window.location.href)) {
logger.debug("Not a GitHub PR page");
return;
}

try {
// Wait for the search input to be available
await waitForElement("#js-issues-search", WAIT_TIMEOUTS.INITIAL_LOAD);

logger.debug("Enabled filters:", enabledFilters);
await waitForElement(SELECTORS.GITHUB_SEARCH, WAIT_TIMEOUTS.INITIAL_LOAD);

// If we have filters, apply them
if (enabledFilters.length > 0) {
const success = applyFilters(enabledFilters);
_filtersApplied = success;

// Show success toast
if (success) {
toastMessage = `Applied ${enabledFilters.length} filter(s)`;
toastType = "success";
toastType = TOAST_TYPES.SUCCESS;
} else {
toastMessage = "Filters already applied";
toastType = "info";
toastType = TOAST_TYPES.INFO;
}
}
} catch (err) {
logger.error("Failed to apply filters:", err);
toastMessage = "Failed to apply filters";
toastType = "error";
toastType = TOAST_TYPES.ERROR;
}
}

// Debounced URL change handler to prevent excessive filter applications
const debouncedURLChange = debounce(() => {
if (isGitHubPRPage(window.location.href)) {
// Reset state
_filtersApplied = false;
tryApplyFilters();
}
}, DEBOUNCE_DELAYS.URL_CHANGE);

// Handle navigation within GitHub (for SPA navigation)
function handleURLChange() {
debouncedURLChange();
}

/**
* Handle real-time filter toggle from popup/options
* @param filter The filter that was toggled
*/
async function handleFilterToggle(filter: PRFilter) {
logger.debug("handleFilterToggle", filter);

// Only act if we're on a GitHub PR page
if (!isGitHubPRPage(window.location.href)) {
return;
}
if (!isGitHubPRPage(window.location.href)) return;

try {
// Wait for search input to be available
await waitForElement("#js-issues-search", WAIT_TIMEOUTS.FILTER_TOGGLE);
await waitForElement(SELECTORS.GITHUB_SEARCH, WAIT_TIMEOUTS.FILTER_TOGGLE);

if (filter.enabled) {
// Filter was enabled - add it
const success = addFilter(filter.value);
if (success) {
toastMessage = `Added filter: ${filter.name}`;
toastType = "success";
}
} else {
// Filter was disabled - remove it
const success = removeFilter(filter.value);
if (success) {
toastMessage = `Removed filter: ${filter.name}`;
toastType = "info";
}
const success = filter.enabled ? addFilter(filter.value) : removeFilter(filter.value);

if (success) {
toastMessage = `Filter ${filter.enabled ? "enabled" : "disabled"}: ${filter.name}`;
toastType = TOAST_TYPES.SUCCESS;
}
} catch (err) {
logger.error("Failed to toggle filter in real-time", err);
} catch (error) {
logger.error("Error toggling filter:", error);
toastMessage = "Failed to toggle filter";
toastType = TOAST_TYPES.ERROR;
}
}

// Create cleanup functions at the component level
let cleanupFunctions: (() => void)[] = [];
const cleanupFunctions: (() => void)[] = [];

onMount(async () => {
// Subscribe to filter store
const unsubscribe = filterStore.subscribe((state) => {
enabledFilters = state.filters.filter((f) => f.enabled).map((f) => f.value);
});
cleanupFunctions.push(unsubscribe);

// Initialize store
await filterStore.init();
cleanupFunctions.push(() => filterStore.destroy());

// Try to apply filters initially
await tryApplyFilters();

// Watch for URL changes (GitHub is a SPA) - using debounced handler
const observer = new MutationObserver(handleURLChange);

// Start observing with narrower scope
const observer = new MutationObserver(debouncedURLChange);
observer.observe(document.body, { childList: true, subtree: false });
cleanupFunctions.push(() => observer.disconnect());

// Also listen for popstate events (browser back/forward)
window.addEventListener("popstate", handleURLChange);
cleanupFunctions.push(() => window.removeEventListener("popstate", handleURLChange));
window.addEventListener("popstate", debouncedURLChange);
cleanupFunctions.push(() => window.removeEventListener("popstate", debouncedURLChange));

// Set up message listener
const messageHandler = (message: ExtensionMessage) => {
if (message.action === MESSAGE_ACTIONS.APPLY_FILTERS_NOW) {
tryApplyFilters();
} else if (message.action === MESSAGE_ACTIONS.TOGGLE_FILTER) {
handleFilterToggle(message.filter);
switch (message.action) {
case MESSAGE_ACTIONS.APPLY_FILTERS_NOW:
tryApplyFilters();
break;
case MESSAGE_ACTIONS.TOGGLE_FILTER:
handleFilterToggle(message.filter);
break;
default:
logger.debug("Ignoring unknown message action:", message.action);
}
};

browser.runtime.onMessage.addListener(messageHandler);
cleanupFunctions.push(() => browser.runtime.onMessage.removeListener(messageHandler));
});

// This is called directly during component initialization
onDestroy(() => {
cleanupFunctions.forEach((cleanup) => cleanup());
});
Expand Down
26 changes: 11 additions & 15 deletions src/lib/components/ErrorDisplay.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<script lang="ts">
/**
* Reusable error display component
*/
let { message, onRetry, onDismiss } = $props<{
let {
message,
onRetry,
onDismiss,
}: {
message: string;
onRetry?: () => void;
onDismiss?: () => void;
}>();
} = $props();

const errorButtonClasses =
"focus:ring-error rounded text-sm underline transition-opacity hover:no-underline hover:opacity-80 focus:outline-none focus:ring-2";
</script>

{#if message}
Expand All @@ -22,20 +26,12 @@
</div>
<div class="ml-4 flex gap-2">
{#if onRetry}
<button
class="focus:ring-error rounded text-sm underline transition-opacity hover:no-underline hover:opacity-80 focus:outline-none focus:ring-2"
onclick={onRetry}
aria-label="Retry action"
>
<button class={errorButtonClasses} onclick={onRetry} aria-label="Retry action">
Try again
</button>
{/if}
{#if onDismiss}
<button
class="focus:ring-error rounded text-sm underline transition-opacity hover:no-underline hover:opacity-80 focus:outline-none focus:ring-2"
onclick={onDismiss}
aria-label="Dismiss error"
>
<button class={errorButtonClasses} onclick={onDismiss} aria-label="Dismiss error">
Dismiss
</button>
{/if}
Expand Down
33 changes: 13 additions & 20 deletions src/lib/components/FilterEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import { cn } from "@/lib/utils/cn";
import ErrorDisplay from "./ErrorDisplay.svelte";

// Props
let { filter, onSave, onCancel } = $props<{
let {
filter,
onSave,
onCancel,
}: {
filter?: PRFilter;
onSave: () => void;
onCancel: () => void;
}>();
} = $props();

// Form state
let name = $state(filter?.name || "");
Expand All @@ -21,20 +24,17 @@
let nameError = $state("");
let valueError = $state("");

function validateForm(): boolean {
// Reset errors
function validateForm() {
nameError = "";
valueError = "";
error = "";

// Validate name
const nameValidation = validateFilterName(name);
if (!nameValidation.isValid) {
nameError = nameValidation.error || "";
return false;
}

// Validate value
const valueValidation = validateFilter(value);
if (!valueValidation.isValid) {
valueError = valueValidation.error || "";
Expand All @@ -47,7 +47,6 @@
async function handleSubmit(event: Event) {
event.preventDefault();

// Validate form
if (!validateForm()) {
return;
}
Expand All @@ -59,15 +58,13 @@
let success = false;

if (filter) {
// Update existing filter
success = await filterStore.update({
...filter,
name: name.trim(),
value: value.trim(),
enabled,
});
} else {
// Add new filter
const newFilter = await filterStore.add({
name: name.trim(),
value: value.trim(),
Expand All @@ -77,7 +74,6 @@
}

if (success) {
// Call the onSave callback to notify parent
onSave();
} else {
error = "Failed to save filter. Please try again.";
Expand All @@ -88,6 +84,9 @@
isSaving = false;
}
}

const inputClasses =
"focus:ring-primary w-full rounded border px-3 py-2 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
</script>

<div class="bg-bg-primary rounded-lg p-6 shadow">
Expand All @@ -106,10 +105,7 @@
type="text"
id="name"
bind:value={name}
class={cn(
"focus:ring-primary w-full rounded border px-3 py-2 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
nameError ? "border-error" : "border-border"
)}
class={cn(inputClasses, nameError ? "border-error" : "border-border")}
placeholder="e.g., Hide Dependabot PRs"
aria-invalid={!!nameError}
aria-describedby={nameError ? "name-error" : undefined}
Expand All @@ -131,10 +127,7 @@
type="text"
id="value"
bind:value
class={cn(
"focus:ring-primary w-full rounded border px-3 py-2 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
valueError ? "border-error" : "border-border"
)}
class={cn(inputClasses, valueError ? "border-error" : "border-border")}
placeholder="e.g., -author:app/dependabot"
aria-invalid={!!valueError}
aria-describedby={valueError ? "value-error" : undefined}
Expand All @@ -157,7 +150,7 @@
type="checkbox"
id="enabled"
bind:checked={enabled}
class="text-primary focus:ring-primary h-4 w-4 rounded transition-colors focus:ring-2 focus:ring-offset-2"
class="text-primary focus:ring-primary size-4 rounded transition-colors focus:ring-2 focus:ring-offset-2"
/>
<label for="enabled" class="text-text-primary ml-2 block text-sm">
Enable this filter
Expand Down
27 changes: 12 additions & 15 deletions src/lib/components/Icon.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,5 @@
<script lang="ts">
/**
* Reusable icon component with consistent SVG icons
* Note: Using @html with static, hardcoded SVG paths is safe
* The Firefox warning is a false positive - no user input is used
*/
type IconName = "toggle-on" | "toggle-off" | "edit" | "delete" | "check" | "x" | "plus";

let { name, class: className = "size-5" } = $props<{
name: IconName;
class?: string;
}>();

// Static SVG path data - no user input, completely safe
const icons: Record<IconName, string> = {
const icons = {
"toggle-on": `
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
`,
Expand All @@ -34,11 +21,21 @@
plus: `
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
`,
};
} as const;

type IconName = keyof typeof icons;

let {
name,
class: className = "size-5",
}: {
name: IconName;
class?: string;
} = $props();

const iconPath = $derived(icons[name as IconName]);
</script>

<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
{@html iconPath}

Check warning on line 40 in src/lib/components/Icon.svelte

View workflow job for this annotation

GitHub Actions / Lint & Format Check

`{@html}` can lead to XSS attack
</svg>
Loading