Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
146 changes: 144 additions & 2 deletions desktop/src/apps/BrowserApp/AddressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AddressBar } from "./AddressBar";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { AddressBar, READER_MIN_WORD_COUNT } from "./AddressBar";
import { useBrowserStore } from "@/stores/browser-store";
import * as extractApi from "@/lib/browser-extract-api";

const TEST_WINDOW_ID = "win-test";
const originalFetch = global.fetch;
Expand Down Expand Up @@ -173,3 +174,144 @@ describe("AddressBar — focus event", () => {
expect(document.activeElement).not.toBe(input);
});
});

describe("AddressBar — reader toggle", () => {
it("does NOT render reader toggle when readerAvailable is not true", () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://a.test/");
// readerAvailable is undefined by default after navigateTab

render(<AddressBar windowId={TEST_WINDOW_ID} />);
expect(
screen.queryByRole("button", { name: /toggle reader mode/i }),
).toBeNull();
});

it("renders reader toggle when readerAvailable is true", () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://a.test/");
useBrowserStore.getState().setTabReader(TEST_WINDOW_ID, tabId, {
readerAvailable: true,
readerExtract: {
title: "Test",
text: "content",
html: "<p>content</p>",
word_count: 300,
},
});

render(<AddressBar windowId={TEST_WINDOW_ID} />);
expect(
screen.getByRole("button", { name: /toggle reader mode/i }),
).toBeInTheDocument();
});

it("clicking the reader toggle flips readerActive in the store", () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://a.test/");
useBrowserStore.getState().setTabReader(TEST_WINDOW_ID, tabId, {
readerAvailable: true,
readerActive: false,
readerExtract: {
title: "Test",
text: "content",
html: "<p>content</p>",
word_count: 300,
},
});

const setTabReaderSpy = vi.spyOn(useBrowserStore.getState(), "setTabReader");
render(<AddressBar windowId={TEST_WINDOW_ID} />);
fireEvent.click(screen.getByRole("button", { name: /toggle reader mode/i }));

expect(setTabReaderSpy).toHaveBeenCalledWith(
TEST_WINDOW_ID,
tabId,
{ readerActive: true },
);
});

it("focusing address bar with readerAvailable=undefined triggers extractReadable", async () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://a.test/");

const extractSpy = vi.spyOn(extractApi, "extractReadable").mockResolvedValue({
title: "Article",
text: "some content",
html: "<p>some content</p>",
word_count: 250,
});

render(<AddressBar windowId={TEST_WINDOW_ID} />);
const input = screen.getByLabelText("Address") as HTMLInputElement;
await act(async () => {
fireEvent.focus(input);
});

expect(extractSpy).toHaveBeenCalledWith("personal", "https://a.test/");
});

it("stale extract result is discarded when URL changes before the fetch resolves", async () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://original.test/");

// Deferred promise — we control when it resolves
let resolveExtract!: (v: Awaited<ReturnType<typeof extractApi.extractReadable>>) => void;
const deferredPromise = new Promise<Awaited<ReturnType<typeof extractApi.extractReadable>>>(
(res) => { resolveExtract = res; },
);
vi.spyOn(extractApi, "extractReadable").mockReturnValue(deferredPromise);

render(<AddressBar windowId={TEST_WINDOW_ID} />);
const input = screen.getByLabelText("Address") as HTMLInputElement;

// Focus triggers the fetch for https://original.test/
await act(async () => {
fireEvent.focus(input);
});

// Navigate to a different URL before the fetch resolves
await act(async () => {
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://new.test/");
});

// Now resolve the extract with data for the OLD url
await act(async () => {
resolveExtract({
title: "Stale Article",
text: "stale content",
html: "<p>stale content</p>",
word_count: READER_MIN_WORD_COUNT + 50,
});
});

// The tab should NOT have readerExtract set (stale write discarded)
const tab = useBrowserStore.getState().windows[TEST_WINDOW_ID]?.tabs.find(
(t) => t.id === tabId,
);
expect(tab?.readerExtract).toBeFalsy();
});

it("focusing address bar with readerAvailable already set does NOT re-trigger extractReadable", async () => {
const tabId = useBrowserStore.getState().getWindow(TEST_WINDOW_ID)!.tabs[0].id;
useBrowserStore.getState().navigateTab(TEST_WINDOW_ID, tabId, "https://a.test/");
useBrowserStore.getState().setTabReader(TEST_WINDOW_ID, tabId, {
readerAvailable: true,
readerExtract: {
title: "Article",
text: "content",
html: "<p>content</p>",
word_count: 300,
},
});

const extractSpy = vi.spyOn(extractApi, "extractReadable").mockResolvedValue(null);

render(<AddressBar windowId={TEST_WINDOW_ID} />);
const input = screen.getByLabelText("Address") as HTMLInputElement;
fireEvent.focus(input);

await new Promise((r) => setTimeout(r, 50));
expect(extractSpy).not.toHaveBeenCalled();
});
});
70 changes: 67 additions & 3 deletions desktop/src/apps/BrowserApp/AddressBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
* Settings.
*/
import { useEffect, useRef, useState } from "react";
import { BookOpen } from "lucide-react";
import { useBrowserStore } from "@/stores/browser-store";
import { useBrowserSettingsStore, searchUrlFor } from "@/stores/browser-settings-store";
import { fetchSuggestions, type Suggestion } from "@/lib/browser-suggest-api";
import { extractReadable } from "@/lib/browser-extract-api";
import { AddressSuggest } from "./AddressSuggest";

const SUGGEST_DEBOUNCE_MS = 150;
const DEFAULT_SEARCH_URL = "https://duckduckgo.com/?q=";
export const READER_MIN_WORD_COUNT = 200;

interface AddressBarProps {
windowId: string;
Expand All @@ -32,12 +35,16 @@ export function AddressBar({ windowId }: AddressBarProps) {

const activeTab = win?.tabs.find((t) => t.id === win?.activeTabId);

const setTabReader = useBrowserStore((s) => s.setTabReader);

const [inputValue, setInputValue] = useState(activeTab?.url ?? "");
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [hasFocus, setHasFocus] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const suggestTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
// Guard against duplicate in-flight extract requests for the same URL
const inflightUrlRef = useRef<string | null>(null);

// Focus the address bar when Cmd+L fires for this window
useEffect(() => {
Expand Down Expand Up @@ -107,6 +114,41 @@ export function AddressBar({ windowId }: AddressBarProps) {
setHasFocus(true);
// Select all on focus (Safari / Chrome behavior)
e.currentTarget.select();
// Lazy reader extract — fire once per URL when the address bar is opened
if (
activeTab &&
activeTab.readerAvailable === undefined &&
!activeTab.readerExtract &&
/^https?:\/\//i.test(activeTab.url) &&
inflightUrlRef.current !== activeTab.url
) {
const targetUrl = activeTab.url;
inflightUrlRef.current = targetUrl;
extractReadable(win.profileId, targetUrl)
.then((result) => {
const currentTab = useBrowserStore
.getState()
.windows[windowId]
?.tabs.find((t) => t.id === activeTab.id);
if (!currentTab || currentTab.url !== targetUrl) return;
if (result) {
setTabReader(windowId, activeTab.id, {
readerAvailable: result.word_count > READER_MIN_WORD_COUNT,
readerExtract: result,
});
} else {
setTabReader(windowId, activeTab.id, { readerAvailable: false });
}
})
.catch(() => {
// Silent — match other browser-* api wrappers
})
.finally(() => {
if (inflightUrlRef.current === targetUrl) {
inflightUrlRef.current = null;
}
});
}
}}
onBlur={() => {
setHasFocus(false);
Expand Down Expand Up @@ -135,8 +177,30 @@ export function AddressBar({ windowId }: AddressBarProps) {
setSelectedIndex((i) => Math.max(i - 1, -1));
}
}}
className="w-full bg-shell-bg-deep text-shell-text px-2 py-0.5 rounded text-xs border border-shell-border-subtle focus:border-accent focus:outline-none"
className={`w-full bg-shell-bg-deep text-shell-text px-2 py-0.5 rounded text-xs border border-shell-border-subtle focus:border-accent focus:outline-none ${
activeTab?.readerAvailable ? "pr-7" : ""
}`}
/>
{activeTab?.readerAvailable && (
<button
type="button"
aria-label="Toggle Reader mode"
aria-pressed={!!activeTab?.readerActive}
onClick={() => {
if (!activeTab) return;
setTabReader(windowId, activeTab.id, {
readerActive: !activeTab.readerActive,
});
}}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded ${
activeTab?.readerActive
? "text-accent"
: "text-shell-text-secondary hover:text-shell-text"
}`}
>
<BookOpen size={12} />
</button>
)}
{hasFocus && (
<AddressSuggest
suggestions={suggestions}
Expand Down Expand Up @@ -166,5 +230,5 @@ function resolveFinalUrl(input: string): string {
if (input.includes(".") && !input.includes(" ")) {
return `https://${input}`;
}
return `${DEFAULT_SEARCH_URL}${encodeURIComponent(input)}`;
return searchUrlFor(useBrowserSettingsStore.getState().searchEngine, input);
}
26 changes: 25 additions & 1 deletion desktop/src/apps/BrowserApp/Chrome.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Chrome } from "./Chrome";
import { useBrowserStore } from "@/stores/browser-store";

vi.mock("@/lib/browser-profile-api", () => ({
listProfiles: vi.fn().mockResolvedValue([
{ profile_id: "personal", name: "Personal", color: "#6c8df0", created_at: 0 },
{ profile_id: "work", name: "Work", color: "#f5b86b", created_at: 1 },
]),
}));

const TEST_WINDOW_ID = "win-test";

beforeEach(() => {
Expand Down Expand Up @@ -71,6 +78,23 @@ describe("Chrome — profile chip", () => {
expect(chip).toBeTruthy();
expect(chip.textContent?.toLowerCase()).toContain("personal");
});

it("shows the custom color from the loaded profiles list on the chip dot", async () => {
const { listProfiles } = await import("@/lib/browser-profile-api");
(listProfiles as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{ profile_id: "personal", name: "Personal", color: "#abcdef", created_at: 0 },
]);

render(<Chrome windowId={TEST_WINDOW_ID} />);

// Wait for the async listProfiles call to resolve and the color dot to appear
await waitFor(() => {
const chip = screen.getByLabelText(/profile: personal/i);
const dot = chip.querySelector("span[aria-hidden='true']") as HTMLElement | null;
expect(dot).not.toBeNull();
expect(dot!.style.backgroundColor).toBe("rgb(171, 205, 239)"); // #abcdef in rgb
});
});
});

describe("Chrome — graceful handling of missing window", () => {
Expand Down
Loading
Loading