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
119 changes: 119 additions & 0 deletions desktop/src/apps/BrowserApp/BookmarksBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { BookmarksBar } from "./BookmarksBar";
import * as bookmarksApi from "@/lib/browser-bookmarks-api";
import { useBrowserStore } from "@/stores/browser-store";

vi.mock("@/lib/browser-bookmarks-api");

const WINDOW_ID = "win-bbar";
const PROFILE_ID = "personal";

function makeBookmark(overrides: Partial<bookmarksApi.Bookmark> = {}): bookmarksApi.Bookmark {
return {
bookmark_id: "bm-1",
url: "https://example.com",
title: "Example Site",
created_at: Date.now(),
...overrides,
};
}

beforeEach(() => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([]);
vi.mocked(bookmarksApi.removeBookmark).mockResolvedValue(true);
useBrowserStore.setState({ windows: {} });
useBrowserStore.getState().createWindow(WINDOW_ID, PROFILE_ID);
});

afterEach(() => {
vi.clearAllMocks();
});

describe("BookmarksBar", () => {
it("renders null (nothing) when bookmark list is empty", async () => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([]);
const { container } = render(
<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />,
);
// Wait for the async load to settle
await act(async () => {});
expect(container.firstChild).toBeNull();
});

it("renders one chip per bookmark", async () => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([
makeBookmark({ bookmark_id: "bm-1", title: "Alpha", url: "https://alpha.com" }),
makeBookmark({ bookmark_id: "bm-2", title: "Beta", url: "https://beta.com" }),
]);

render(<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />);
await waitFor(() => screen.getByText("Alpha"));
expect(screen.getByText("Beta")).toBeTruthy();
});

it("click on chip calls navigateTab with bookmark URL", async () => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([
makeBookmark({ bookmark_id: "bm-1", title: "Docs", url: "https://docs.example.com" }),
]);
const navigateSpy = vi.spyOn(useBrowserStore.getState(), "navigateTab");

render(<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />);
await waitFor(() => screen.getByText("Docs"));

fireEvent.click(screen.getByRole("button", { name: /Go to Docs/i }));
expect(navigateSpy).toHaveBeenCalledWith(
WINDOW_ID,
expect.any(String),
"https://docs.example.com",
);
});

it("right-click opens context menu, Remove calls removeBookmark with bookmark id", async () => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([
makeBookmark({ bookmark_id: "bm-42", title: "My Page", url: "https://my.example.com" }),
]);

render(<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />);
await waitFor(() => screen.getByText("My Page"));

const chip = screen.getByRole("button", { name: /Go to My Page/i });
fireEvent.contextMenu(chip);

const removeBtn = await waitFor(() => screen.getByRole("menuitem", { name: /Remove bookmark/i }));
await act(async () => {
fireEvent.click(removeBtn);
});

expect(bookmarksApi.removeBookmark).toHaveBeenCalledWith(PROFILE_ID, "bm-42");
});

it("after successful remove the chip disappears", async () => {
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([
makeBookmark({ bookmark_id: "bm-1", title: "ToRemove", url: "https://remove.me" }),
]);
vi.mocked(bookmarksApi.removeBookmark).mockResolvedValue(true);

render(<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />);
await waitFor(() => screen.getByText("ToRemove"));

const chip = screen.getByRole("button", { name: /Go to ToRemove/i });
fireEvent.contextMenu(chip);
const removeBtn = await waitFor(() => screen.getByRole("menuitem", { name: /Remove bookmark/i }));
await act(async () => {
fireEvent.click(removeBtn);
});

await waitFor(() => expect(screen.queryByText("ToRemove")).toBeNull());
});

it("truncates long titles to ~20 chars", async () => {
const longTitle = "A Very Long Title That Should Be Truncated";
vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([
makeBookmark({ bookmark_id: "bm-1", title: longTitle, url: "https://long.example.com" }),
]);

render(<BookmarksBar windowId={WINDOW_ID} profileId={PROFILE_ID} />);
await waitFor(() => screen.getByText("A Very Long Title Th…"));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Test expectation mismatch - the truncate function slices to 20 characters and appends "…", resulting in "A Very Long Title Tha…", but the test expects "A Very Long Title Th…" (missing 'a'). This will cause the test to fail.

Suggested change
await waitFor(() => screen.getByText("A Very Long Title Th…"));
expect(screen.getByText("A Very Long Title Tha…"));

});
});
176 changes: 176 additions & 0 deletions desktop/src/apps/BrowserApp/BookmarksBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* BrowserApp v2 — BookmarksBar.
*
* Horizontal row of bookmark chips shown below the tab strip when the
* active profile has at least one bookmark. Auto-hides when empty.
*
* Each chip:
* - Favicon (Google S2 service, 32px)
* - Title truncated to ~20 characters
* - Click → navigates active tab
* - Right-click → context menu with "Remove bookmark"
*/
import { useEffect, useRef, useState } from "react";
import { listBookmarks, removeBookmark, type Bookmark } from "@/lib/browser-bookmarks-api";
import { useBrowserStore } from "@/stores/browser-store";

interface BookmarksBarProps {
windowId: string;
profileId: string;
}

function faviconUrl(url: string): string {
try {
const { hostname } = new URL(url);
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`;
} catch {
return "";
}
}

function truncate(text: string, max = 20): string {
if (text.length <= max) return text;
return text.slice(0, max) + "…";
}

interface ContextMenuState {
x: number;
y: number;
bookmarkId: string;
}

export function BookmarksBar({ windowId, profileId }: BookmarksBarProps) {
const navigateTab = useBrowserStore((s) => s.navigateTab);
const win = useBrowserStore((s) => s.windows[windowId]);

const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const loadSeqRef = useRef(0);

async function load() {
const seq = ++loadSeqRef.current;
const list = await listBookmarks(profileId);
if (seq !== loadSeqRef.current) return;
setBookmarks(list);
}

useEffect(() => {
load();
}, [profileId]);

// React to bookmark-changed events fired by other components (AddressBar star,
// etc.) so the bar stays in sync. Skip events we dispatched ourselves.
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ profileId: string; url: string; bookmarkId: string | null; source?: string }>;
if (ce.detail.profileId !== profileId) return;
if (ce.detail.source === "bookmarks-bar") return;
load();
};
window.addEventListener("taos-browser:bookmark-changed", handler);
return () => window.removeEventListener("taos-browser:bookmark-changed", handler);
}, [profileId]);

// Dismiss context menu on outside click
useEffect(() => {
if (!contextMenu) return;
const handler = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) {
setContextMenu(null);
}
};
window.addEventListener("mousedown", handler);
return () => window.removeEventListener("mousedown", handler);
}, [contextMenu]);

if (bookmarks.length === 0) return null;

const activeTabId = win?.activeTabId;

function handleChipClick(url: string) {
if (!activeTabId) return;
navigateTab(windowId, activeTabId, url);
}

function handleChipContextMenu(e: React.MouseEvent, bookmarkId: string) {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, bookmarkId });
}

async function handleRemove(bookmarkId: string) {
setContextMenu(null);
const removed = bookmarks.find((b) => b.bookmark_id === bookmarkId);
const ok = await removeBookmark(profileId, bookmarkId);
if (ok) {
// Update local state immediately — don't wait for a re-fetch
setBookmarks((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId));
// Notify other components (AddressBar star icon, etc.) but mark the
// event as originating here so our own listener doesn't trigger a reload
window.dispatchEvent(
new CustomEvent("taos-browser:bookmark-changed", {
detail: {
profileId,
url: removed?.url ?? "",
bookmarkId: null,
source: "bookmarks-bar",
},
}),
);
}
}

return (
<>
<div
role="toolbar"
aria-label="Bookmarks bar"
className="flex items-center gap-1 px-2 h-8 bg-shell-surface border-b border-shell-border-subtle overflow-x-auto"
style={{ scrollbarWidth: "none" }}
>
{bookmarks.map((bm) => (
<button
key={bm.bookmark_id}
type="button"
aria-label={`Go to ${bm.title}`}
onClick={() => handleChipClick(bm.url)}
onContextMenu={(e) => handleChipContextMenu(e, bm.bookmark_id)}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-shell-bg-deep border border-shell-border-subtle text-xs hover:bg-shell-hover whitespace-nowrap flex-shrink-0"
>
{faviconUrl(bm.url) && (
<img
src={faviconUrl(bm.url)}
alt=""
aria-hidden="true"
width={14}
height={14}
className="w-3.5 h-3.5 object-contain"
/>
)}
<span>{truncate(bm.title)}</span>
</button>
))}
</div>

{contextMenu && (
<div
ref={menuRef}
role="menu"
aria-label="Bookmark actions"
className="fixed z-[80] rounded border border-shell-border-subtle bg-shell-surface shadow-lg py-1 text-xs"
style={{ top: contextMenu.y, left: contextMenu.x }}
>
<button
type="button"
role="menuitem"
aria-label="Remove bookmark"
onClick={() => handleRemove(contextMenu.bookmarkId)}
className="w-full text-left px-3 py-1.5 hover:bg-shell-hover text-shell-text"
>
Remove bookmark
</button>
</div>
)}
</>
);
}
2 changes: 2 additions & 0 deletions desktop/src/apps/BrowserApp/BrowserApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { FindInPage } from "./FindInPage";
import { TabOverview } from "./TabOverview";
import { WindowChooser } from "./WindowChooser";
import { CapabilityPromptModal } from "./CapabilityPromptModal";
import { BookmarksBar } from "./BookmarksBar";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { Layers, ListChecks } from "lucide-react";
import { bootstrapPushSubscription } from "../../lib/browser-push-bootstrap";
Expand Down Expand Up @@ -145,6 +146,7 @@ export function BrowserApp({ windowId }: BrowserAppProps) {
<div className="flex items-center gap-1 px-2 py-1 bg-shell-surface border-b border-shell-border-subtle">
<AddressBar windowId={windowId} />
</div>
<BookmarksBar windowId={windowId} profileId={win.profileId} />
<TabStrip windowId={windowId} />
<TabRenderer windowId={windowId} />
{findOpen && (
Expand Down
31 changes: 28 additions & 3 deletions desktop/src/apps/BrowserApp/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type SearchEngine,
} from "@/stores/browser-settings-store";
import { AgentCapabilitiesPanel } from "./AgentCapabilitiesPanel";
import { SitePermissionsPanel } from "./SitePermissionsPanel";
import { bootstrapPushSubscription } from "../../lib/browser-push-bootstrap";

interface SettingsPanelProps {
Expand All @@ -33,6 +34,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) {
const setSearchEngine = useBrowserSettingsStore((s) => s.setSearchEngine);
const ref = useRef<HTMLDivElement | null>(null);
const [capsOpen, setCapsOpen] = useState(false);
const [sitePermsOpen, setSitePermsOpen] = useState(false);
const [notifPermission, setNotifPermission] = useState<NotificationPermission>(
typeof Notification !== "undefined" ? Notification.permission : "default",
);
Expand Down Expand Up @@ -66,20 +68,24 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) {
};
}, [onClose]);

// Escape key dismiss — when the capabilities sub-modal is open, Escape
// should close it first; a second Escape then closes the settings panel.
// Escape key dismiss — when a sub-modal is open, Escape closes it first;
// a second Escape then closes the settings panel.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (capsOpen) {
setCapsOpen(false);
return;
}
if (sitePermsOpen) {
setSitePermsOpen(false);
return;
}
onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose, capsOpen]);
}, [onClose, capsOpen, sitePermsOpen]);

return (
<div
Expand Down Expand Up @@ -181,6 +187,25 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) {
/>
)}

{/* Site permissions */}
<div className="border-t border-shell-border-subtle pt-3">
<button
type="button"
onClick={() => setSitePermsOpen(true)}
className="w-full text-left text-xs text-shell-text hover:text-accent flex items-center justify-between"
>
<span>Site permissions</span>
<span className="text-shell-text-secondary">›</span>
</button>
</div>

{sitePermsOpen && (
<SitePermissionsPanel
profileId={profileId}
onClose={() => setSitePermsOpen(false)}
/>
)}

{/* Notifications */}
<div className="border-t border-shell-border-subtle pt-3 flex flex-col gap-2">
<span className="text-xs text-shell-text-secondary">Notifications</span>
Expand Down
Loading
Loading