diff --git a/src/glide/browser/base/content/browser.mts b/src/glide/browser/base/content/browser.mts index 0b012550..8f4deea0 100644 --- a/src/glide/browser/base/content/browser.mts +++ b/src/glide/browser/base/content/browser.mts @@ -869,6 +869,7 @@ class GlideBrowserClass { "nsISupportsWeakReference", ]), + $last_selected_tab: null as BrowserTab | null, $last_location: null as string | null, /** @@ -911,9 +912,32 @@ class GlideBrowserClass { return; // ignore iframes etc. } + if (location.schemeIs("about") && location.spec === "about:blank") { + return; + } + GlideBrowser._log.debug("onLocationChange - clearing buffer"); await GlideBrowser.clear_buffer(); + const current_tab = gBrowser.selectedTab; + + if (current_tab) { + const is_tab_switch = this.$last_selected_tab == null || this.$last_selected_tab !== current_tab; + + GlideBrowser._log.debug("TabEnter check:", { + last_tab: this.$last_selected_tab, + current_tab, + is_tab_switch, + }); + + if (is_tab_switch) { + GlideBrowser._log.debug("TabEnter: firing for", location.spec); + await GlideBrowser.#invoke_tabenter_autocmd(location); + } + + this.$last_selected_tab = current_tab; + } + await GlideBrowser.#invoke_urlenter_autocmd(location); }, }); @@ -1005,8 +1029,64 @@ class GlideBrowserClass { } } + async #invoke_tabenter_autocmd(location: nsIURI) { + const cmds = GlideBrowser.autocmds.TabEnter ?? []; + GlideBrowser._log.debug("TabEnter autocmds registered:", cmds.length); + + if (!cmds.length) { + GlideBrowser._log.debug("TabEnter: no autocmds registered, skipping"); + return; + } + + if (!GlideBrowser.extension?.tabManager) { + GlideBrowser._log.debug("TabEnter autocmd skipped: extension not ready yet"); + return; + } + + const args: glide.AutocmdArgs["TabEnter"] = { + url: location.spec, + get tab_id() { + return assert_present( + GlideBrowser.extension.tabManager.getWrapper(gBrowser.selectedTab), + "could not resolve tab wrapper", + ).id; + }, + }; + + const results = await Promise.allSettled(cmds.map((cmd) => + (async () => { + if (!GlideBrowser.#test_url_autocmd_pattern(cmd.pattern, location)) { + return; + } + + const cleanup = await cmd.callback(args); + if (typeof cleanup === "function") { + GlideBrowser.buffer_cleanups.push({ callback: cleanup, source: "TabEnter cleanup" }); + } + })() + )); + + for (const result of results) { + if (result.status === "fulfilled") { + continue; + } + + GlideBrowser._log.error(result.reason); + + // TODO: if there are many errors this would be overwhelming... + // maybe limit the number of errors we display at once? + + const loc = GlideBrowser.#clean_stack(result.reason, "#invoke_tabenter_autocmd") ?? ""; + GlideBrowser.add_notification("glide-autocmd-error", { + label: `Error occurred in TabEnter autocmd \`${loc}\` - ${result.reason}`, + priority: MozElements.NotificationBox.prototype.PRIORITY_CRITICAL_HIGH, + buttons: [GlideBrowser.remove_all_notifications_button], + }); + } + } + #test_url_autocmd_pattern( - pattern: glide.AutocmdPatterns["UrlEnter"], + pattern: glide.AutocmdPatterns["UrlEnter"] | glide.AutocmdPatterns["TabEnter"], location: nsIURI, ): boolean { if ("test" in pattern) { diff --git a/src/glide/browser/base/content/glide.d.ts b/src/glide/browser/base/content/glide.d.ts index 5106deb5..4a88b125 100644 --- a/src/glide/browser/base/content/glide.d.ts +++ b/src/glide/browser/base/content/glide.d.ts @@ -139,6 +139,15 @@ declare global { callback: (args: glide.AutocmdArgs[Event]) => void, ): void; + /** + * Create an autocmd that will be invoked whenever the focused tab changes. + */ + create( + event: Event, + pattern: glide.AutocmdPatterns[Event], + callback: (args: glide.AutocmdArgs[Event]) => void, + ): void; + /** * Create an autocmd that will be invoked whenever the mode changes. * @@ -2209,6 +2218,7 @@ declare global { type AutocmdEvent = | "UrlEnter" + | "TabEnter" | "ModeChanged" | "ConfigLoaded" | "WindowLoaded" @@ -2216,6 +2226,7 @@ declare global { | "KeyStateChanged"; type AutocmdPatterns = { UrlEnter: RegExp | { hostname?: string }; + TabEnter: RegExp | { hostname?: string }; ModeChanged: "*" | `${GlideMode | "*"}:${GlideMode | "*"}`; ConfigLoaded: null; WindowLoaded: null; @@ -2224,6 +2235,7 @@ declare global { }; type AutocmdArgs = { UrlEnter: { readonly url: string; readonly tab_id: number }; + TabEnter: { readonly url: string; readonly tab_id: number }; ModeChanged: { /** * This may be `null` when first loading Glide or when reloading the config. diff --git a/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts b/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts index 272a0d73..e6f3d546 100644 --- a/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts +++ b/src/glide/browser/base/content/test/autocmds/browser_autocmds.ts @@ -524,6 +524,169 @@ add_task(async function test_autocmd_remove() { ); }); +add_task(async function test_tabenter_only_fires_on_tab_switch() { + await GlideTestUtils.reload_config(function _() { + glide.g.calls = []; + + glide.autocmds.create("TabEnter", /input_test/, () => { + glide.g.calls!.push("tabenter"); + }); + + glide.autocmds.create("UrlEnter", /input_test/, () => { + glide.g.calls!.push("urlenter"); + }); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + await waiter(() => glide.g.calls).isjson(["tabenter", "urlenter"]); + is(num_calls(), 2, "Initial navigation should trigger both TabEnter and UrlEnter"); + + const tab1 = gBrowser.selectedTab; + + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, INPUT_TEST_URI + "?navigate"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await waiter(() => glide.g.calls).isjson(["tabenter", "urlenter", "urlenter"]); + is(num_calls(), 3, "Same-tab navigation should only trigger UrlEnter"); + + await BrowserTestUtils.withNewTab("about:mozilla", async _ => { + await sleep_frames(5); + is(num_calls(), 3, "Opening non-matching page should not trigger any autocmd"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await waiter(() => glide.g.calls).isjson(["tabenter", "urlenter", "urlenter", "tabenter", "urlenter"]); + is(num_calls(), 5, "Switching tabs should trigger both TabEnter and UrlEnter"); + }); + }); +}); + +add_task(async function test_tabenter_regexp_filter() { + await GlideTestUtils.reload_config(function _() { + glide.g.calls = []; + + glide.autocmds.create("TabEnter", /input_test\.html/, () => { + glide.g.calls!.push("expected-call"); + }); + + glide.autocmds.create("TabEnter", /definitely-wont-match/, () => { + glide.g.calls!.push("bad-call"); + }); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + const tab1 = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab("about:blank", async _ => { + await BrowserTestUtils.switchTab(gBrowser, tab1); + await waiter(() => glide.g.calls).isjson( + ["expected-call"], + "TabEnter autocmd should be triggered only for matching URL pattern", + ); + }); + }); +}); + +add_task(async function test_tabenter_host_filter() { + await GlideTestUtils.reload_config(function _() { + glide.g.calls = []; + + glide.autocmds.create("TabEnter", { hostname: "mochi.test" }, () => { + glide.g.calls!.push("expected-call"); + }); + + glide.autocmds.create("TabEnter", { hostname: "definitely-wont-match" }, () => { + glide.g.calls!.push("bad-call"); + }); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + const tab1 = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab("about:blank", async _ => { + await BrowserTestUtils.switchTab(gBrowser, tab1); + await waiter(() => glide.g.calls).isjson( + ["expected-call"], + "TabEnter autocmd should be triggered only for matching hostname", + ); + }); + }); +}); + +add_task(async function test_tabenter_tab_id() { + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + const tab1 = gBrowser.selectedTab; + + await GlideTestUtils.reload_config(function _() { + glide.autocmds.create("TabEnter", /input_test/, ({ tab_id }) => { + glide.g.value = tab_id; + }); + }); + + await BrowserTestUtils.withNewTab("about:blank", async _ => { + await BrowserTestUtils.switchTab(gBrowser, tab1); + await until(() => glide.g.value !== undefined); + + is( + glide.g.value, + (await glide.tabs.active()).id, + "TabEnter autocmd should be passed a tab ID that matches the active tab ID", + ); + }); + }); +}); + +add_task(async function test_tabenter_error_handling() { + await GlideTestUtils.reload_config(function _() { + glide.autocmds.create("TabEnter", /input_test/, () => { + throw new Error("tabenter failed"); + }); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + const tab1 = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab("about:blank", async _ => { + await BrowserTestUtils.switchTab(gBrowser, tab1); + + let notification = await until(() => gNotificationBox.getNotificationWithValue("glide-autocmd-error")); + + ok(notification, "Error notification should be shown"); + ok( + notification.shadowRoot + .querySelector(".message") + ?.textContent?.includes("Error occurred in TabEnter autocmd"), + "Notification should indicate TabEnter autocmd error", + ); + gNotificationBox.removeNotification(notification); + }); + }); +}); + +add_task(async function test_tabenter_multiple_callbacks() { + await GlideTestUtils.reload_config(function _() { + glide.g.calls = []; + + glide.autocmds.create("TabEnter", /input_test/, () => { + glide.g.calls!.push("first"); + }); + + glide.autocmds.create("TabEnter", /input_test/, () => { + glide.g.calls!.push("second"); + }); + }); + + await BrowserTestUtils.withNewTab(INPUT_TEST_URI, async _ => { + const tab1 = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab("about:blank", async _ => { + await BrowserTestUtils.switchTab(gBrowser, tab1); + await waiter(() => glide.g.calls).isjson( + ["first", "second"], + "Multiple TabEnter autocmds should fire in registration order", + ); + }); + }); +}); + function num_calls() { return (glide.g.calls ?? []).length; } diff --git a/src/glide/docs/autocmds.md b/src/glide/docs/autocmds.md index b998f1bd..77db727e 100644 --- a/src/glide/docs/autocmds.md +++ b/src/glide/docs/autocmds.md @@ -100,7 +100,7 @@ Fired when the focused URL changes, which can happen under the following circums The `pattern` is either a `RegExp` that matches against the entire URL, or an object with `{ hostname: string }`. -The callback can also `return` a function that will be called when _another_ `UrlEnter` event would be fired. +The callback can also `return` a function that will be called when _another_ `UrlEnter` event would be fired so that you can run any necessary cleanup. ```typescript glide.autocmds.create("UrlEnter", { @@ -119,6 +119,31 @@ Callback arguments: } ``` +## TabEnter + +Fired when the focused tab changes. + +The `pattern` is either a `RegExp` that matches against the entire URL, or an object with `{ hostname: string }`. + +The callback can also `return` a function that will be called when _another_ `TabEnter` event would be fired so that you can run any necessary cleanup. + +```typescript +glide.autocmds.create("TabEnter", { + hostname: "example.com", +}, () => { + // +}); +``` + +Callback arguments: + +```typescript {% highlight_prefix="interface x " %} +{ + tab_id: number; + url: string; +} +``` + ## ModeChanged Fired when the mode changes.