Skip to content
Draft
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
9 changes: 9 additions & 0 deletions extensions/aside/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Aside Changelog

## [Search Improvements] - {PR_MERGE_DATE}

- Added profile-aware bookmark results to Search Aside, with shared ranking and a five-item preview before typing.
- Collapsed Chromium redirect chains to their final destinations without hiding independent visits that share a title or timestamp.
- Made live tab loading significantly faster by fetching Aside tab details in bulk.
- Added total match counts to Search Aside's 25-item history preview.
- Renamed the non-pinned tab section from "Other Tabs" to "Open Tabs" for clarity.
- Added stable-color duotone avatars to profile switchers.

## [Initial Version] - 2026-08-19

- Added search, focus, duplicate, close, and deduplication workflows for open Aside tabs.
Expand Down
4 changes: 2 additions & 2 deletions extensions/aside/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Aside is a focused Raycast companion for [Aside](https://aside.com/). It lets yo

| Command | Description |
|:---|:---|
| Search Aside | Search Google, open URLs, revisit history, and jump to pinned or other open tabs. |
| Search Aside | Search Google, open URLs, find bookmarks and history, and jump to open tabs. |
| Search Bookmarks | Search Aside bookmarks, open saved pages, and manage bookmark ranking. |
| Search Browser History | Search recently visited pages from the configured Aside profile. |
| Open New Tab | Open a new tab in the frontmost Aside window. |
Expand All @@ -17,7 +17,7 @@ Aside is a focused Raycast companion for [Aside](https://aside.com/). It lets yo

**Capabilities include:**
* Focus, reload, close, duplicate, or deduplicate tabs (keeps the first tab per URL)
* Search nested bookmarks with frecency ranking, plus full history from a dedicated command
* Search nested bookmarks with shared frecency ranking from Search Aside or the dedicated command
* Auto-discover Aside profiles and switch bookmarks/history from the search-bar dropdown; open tabs stay shared across profiles
* Copy URLs, titles, Markdown links, and bookmark exports; create Raycast Quicklinks from results

Expand Down
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-blue.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-green.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-magenta.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-orange.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-purple.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/aside/assets/profile-avatar-yellow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion extensions/aside/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@
"name": "search-web",
"title": "Search Aside",
"subtitle": "Aside",
"description": "Search pinned tabs, other open tabs, browser history, and the web from one bar.",
"description": "Search open tabs, bookmarks, browser history, and the web from one bar.",
"mode": "view",
"keywords": [
"google",
"bookmarks",
"history",
"tabs",
"url"
Expand Down
35 changes: 35 additions & 0 deletions extensions/aside/src/components/bookmark-list-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ActionPanel, Icon, List } from "@raycast/api";
import { getFavicon } from "@raycast/utils";
import type { ReactNode } from "react";
import type { Bookmark } from "../lib/types";
import { OpenBookmarkAction, OpenInDefaultBrowserAction, UrlActions } from "./actions";

interface BookmarkListItemProps {
bookmark: Bookmark;
onOpen: () => Promise<void> | void;
additionalActions?: ReactNode;
}

export function BookmarkListItem({ bookmark, onOpen, additionalActions }: BookmarkListItemProps) {
const displayTitle = bookmark.title || bookmark.url;

return (
<List.Item
id={`bookmark-${bookmark.id}`}
icon={getFavicon(bookmark.url, { fallback: Icon.Bookmark })}
title={displayTitle}
subtitle={bookmark.url}
accessories={bookmark.folder ? [{ icon: Icon.Folder, text: bookmark.folder }] : undefined}
actions={
<ActionPanel>
<ActionPanel.Section>
<OpenBookmarkAction bookmark={bookmark} onOpen={onOpen} />
<OpenInDefaultBrowserAction url={bookmark.url} onOpen={onOpen} />
</ActionPanel.Section>
<UrlActions url={bookmark.url} title={displayTitle} />
{additionalActions}
</ActionPanel>
}
/>
);
}
24 changes: 23 additions & 1 deletion extensions/aside/src/components/profile-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { List } from "@raycast/api";
import type { AsideProfile } from "../lib/profiles";

const PROFILE_AVATARS = [
"profile-avatar-blue.svg",
"profile-avatar-purple.svg",
"profile-avatar-green.svg",
"profile-avatar-orange.svg",
"profile-avatar-magenta.svg",
"profile-avatar-yellow.svg",
];

function getProfileAvatar(directory: string): string {
if (directory === "Default") return PROFILE_AVATARS[0];

const profileNumber = /^Profile (\d+)$/.exec(directory)?.[1];
if (profileNumber) return PROFILE_AVATARS[Number(profileNumber) % PROFILE_AVATARS.length];

let hash = 0;
for (const character of directory) {
hash = (hash * 31 + (character.codePointAt(0) ?? 0)) >>> 0;
}
return PROFILE_AVATARS[hash % PROFILE_AVATARS.length];
}

interface ProfileDropdownProps {
profiles: AsideProfile[];
value: string;
Expand All @@ -16,7 +38,7 @@ export function ProfileDropdown({ profiles, value, onChange, tooltip = "Aside Pr
key={profile.directory}
value={profile.directory}
title={profile.name}
icon="👤"
icon={getProfileAvatar(profile.directory)}
keywords={[profile.directory]}
/>
))}
Expand Down
13 changes: 7 additions & 6 deletions extensions/aside/src/lib/applescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,22 @@ export async function getAsideTabSnapshot(): Promise<AsideTabSnapshotResult> {
try
set activeId to id of active tab of w as text
end try
set tabPosition to 0
repeat with t in tabs of w
set tabPosition to tabPosition + 1
set tabIds to id of every tab of w
set tabTitles to title of every tab of w
set tabUrls to URL of every tab of w
repeat with tabPosition from 1 to count of tabIds
set tId to ""
try
set tId to id of t as text
set tId to item tabPosition of tabIds as text
end try
if tId is not "" then
set tTitle to ""
set tUrl to ""
try
set tTitle to title of t as text
set tTitle to item tabPosition of tabTitles as text
end try
try
set tUrl to URL of t as text
set tUrl to item tabPosition of tabUrls as text
end try
set tActive to (tId is activeId) as text
set end of output to tId & fieldSep & tTitle & fieldSep & tUrl & fieldSep & tActive & fieldSep & wId & fieldSep & windowPosition & fieldSep & tabPosition & fieldSep & wMode
Expand Down
61 changes: 54 additions & 7 deletions extensions/aside/src/lib/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { promises as fs } from "fs";
import { join } from "path";
import { getPreferenceValues } from "@raycast/api";
import { useFrecencySorting, usePromise } from "@raycast/utils";
import { ASIDE_USER_DATA_DIR, resolveAsideProfile } from "./constants";
import { filterSearchable } from "./search";
import type { Bookmark } from "./types";
Expand All @@ -27,6 +28,15 @@ const ROOT_LABELS: Record<string, string> = {
synced: "Mobile Bookmarks",
};

class BookmarkProfileError extends Error {
constructor(
readonly profile: string,
error: unknown,
) {
super(error instanceof Error ? error.message : String(error));
}
}

function collectBookmarks(node: ChromiumBookmarkNode, folderPath: string[], bookmarks: Bookmark[]): void {
if (node.type === "url" && node.url) {
bookmarks.push({
Expand All @@ -44,34 +54,34 @@ function collectBookmarks(node: ChromiumBookmarkNode, folderPath: string[], book
}

export async function getBookmarks(profile?: string): Promise<Bookmark[]> {
const configuredProfile = resolveAsideProfile(profile);
const filePath = join(ASIDE_USER_DATA_DIR, configuredProfile, "Bookmarks");
const resolvedProfile = resolveAsideProfile(profile);
const filePath = join(ASIDE_USER_DATA_DIR, resolvedProfile, "Bookmarks");

let bookmarkFileText: string;
try {
bookmarkFileText = await fs.readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
try {
await fs.access(join(ASIDE_USER_DATA_DIR, configuredProfile));
await fs.access(join(ASIDE_USER_DATA_DIR, resolvedProfile));
return [];
} catch {
// Fall through to the actionable profile error below.
}
}
throw new Error(
`Could not read Aside bookmarks for profile "${configuredProfile}". Check the profile setting and Raycast Full Disk Access.`,
`Could not read Aside bookmarks for profile "${resolvedProfile}". Check the profile setting and Raycast Full Disk Access.`,
);
}

let bookmarkFile: ChromiumBookmarksFile;
try {
bookmarkFile = JSON.parse(bookmarkFileText) as ChromiumBookmarksFile;
} catch {
throw new Error(`Aside bookmarks for profile "${configuredProfile}" contain malformed JSON.`);
throw new Error(`Aside bookmarks for profile "${resolvedProfile}" contain malformed JSON.`);
}
if (!bookmarkFile.roots || typeof bookmarkFile.roots !== "object" || Array.isArray(bookmarkFile.roots)) {
throw new Error(`Aside bookmarks for profile "${configuredProfile}" have an unsupported structure.`);
throw new Error(`Aside bookmarks for profile "${resolvedProfile}" have an unsupported structure.`);
}

const bookmarks: Bookmark[] = [];
Expand All @@ -90,6 +100,43 @@ export async function getBookmarks(profile?: string): Promise<Bookmark[]> {
});
}

async function getProfileBookmarks(profile: string) {
try {
return { profile, bookmarks: await getBookmarks(profile) };
} catch (error) {
throw new BookmarkProfileError(profile, error);
}
}

export function useProfileBookmarks(profile: string, onError?: (error: Error) => void) {
const { data, error, isLoading, revalidate } = usePromise(getProfileBookmarks, [profile], { onError });
const isCurrentProfile = data?.profile === profile;
const currentError = error instanceof BookmarkProfileError && error.profile === profile ? error : undefined;
const bookmarks = isCurrentProfile ? data.bookmarks : undefined;
const {
data: sortedBookmarks,
visitItem: visitBookmark,
resetRanking,
} = useFrecencySorting(bookmarks, {
namespace: "aside-bookmarks",
key: (bookmark) => bookmark.id,
});

return {
bookmarks,
sortedBookmarks,
error: currentError,
isLoading: isLoading || (!isCurrentProfile && !currentError),
revalidate,
visitBookmark,
resetRanking,
};
}

export function filterBookmarks(bookmarks: Bookmark[], query: string): Bookmark[] {
return filterSearchable(bookmarks, query, (bookmark) => bookmark.folder);
}

interface BookmarkSearchResult {
totalMatches: number;
bookmarks: Bookmark[];
Expand All @@ -99,7 +146,7 @@ interface BookmarkSearchResult {
export async function searchBookmarks(query: string, limit = 20): Promise<BookmarkSearchResult> {
const { profile } = getPreferenceValues<Preferences>();
const bookmarks = await getBookmarks(profile);
const matches = filterSearchable(bookmarks, query, (bookmark) => bookmark.folder);
const matches = filterBookmarks(bookmarks, query);
return {
totalMatches: matches.length,
bookmarks: matches.slice(0, Math.min(50, Math.max(1, Math.floor(limit)))),
Expand Down
68 changes: 58 additions & 10 deletions extensions/aside/src/lib/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function historyDbPath(profile: string): string {

// Chromium stores `last_visit_time` as microseconds since 1601. Convert to ISO-8601 UTC.
const TIME_EXPR =
"strftime('%Y-%m-%dT%H:%M:%SZ', last_visit_time / 1000000 + (strftime('%s', '1601-01-01')), 'unixepoch')";
"strftime('%Y-%m-%dT%H:%M:%SZ', visible_history.last_visit_time / 1000000 + (strftime('%s', '1601-01-01')), 'unixepoch')";

function buildHistoryQuery(searchText: string, limit: number, options: HistoryQueryOptions = {}): string {
const terms = searchText
Expand All @@ -38,17 +38,56 @@ function buildHistoryQuery(searchText: string, limit: number, options: HistoryQu
.filter(Boolean)
.map((term) => term.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_").replace(/'/g, "''"));

const where = terms.length
? `WHERE last_visit_time > 0 AND ${terms.map((term) => `(url LIKE '%${term}%' ESCAPE '\\' OR title LIKE '%${term}%' ESCAPE '\\')`).join(" AND ")}`
: "WHERE last_visit_time > 0";
const searchPredicate = terms.length
? terms
.map(
(term) =>
`(visible_history.url LIKE '%${term}%' ESCAPE '\\' OR visible_history.title LIKE '%${term}%' ESCAPE '\\')`,
)
.join(" AND ")
: "1";

const totalMatchesColumn = options.includeTotalMatches ? ", COUNT(*) OVER() AS totalMatches" : "";

// Redirect hops share a timestamp. Keep only final destinations, while preserving URLs
// without a matching local visit and unrelated navigations that share a title or time.
return `
WITH canonical_visits AS MATERIALIZED (
SELECT urls.id,
urls.url,
urls.title,
urls.last_visit_time,
current_visit.id AS visit_id,
current_visit.from_visit,
current_visit.transition
FROM urls
LEFT JOIN visits AS current_visit
ON current_visit.id = (
SELECT MAX(candidate.id)
FROM visits AS candidate
WHERE candidate.url = urls.id
AND candidate.visit_time = urls.last_visit_time
)
WHERE urls.last_visit_time > 0
),
visible_history AS (
SELECT current.id, current.url, current.title, current.last_visit_time, current.visit_id
FROM canonical_visits AS current
WHERE current.visit_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM canonical_visits AS child
WHERE child.from_visit = current.visit_id
AND child.last_visit_time = current.last_visit_time
AND (child.transition & 0xC0000000) != 0
)
)
SELECT id, url, title, ${TIME_EXPR} AS lastVisitedAt${totalMatchesColumn}
FROM urls
${where}
ORDER BY last_visit_time DESC
FROM visible_history
WHERE ${searchPredicate}
ORDER BY visible_history.last_visit_time DESC,
visible_history.visit_id DESC,
visible_history.id DESC
LIMIT ${Math.max(1, Math.floor(limit))};
`;
}
Expand Down Expand Up @@ -94,10 +133,18 @@ export async function searchHistory(searchText = "", limit = 20): Promise<Histor
}

/** Search one Aside profile without an extension-managed history cache. */
export function useHistorySearch(searchText: string, limit = 25, profile = configuredProfile()) {
export function useHistorySearch(
searchText: string,
limit = 25,
profile = configuredProfile(),
options: HistoryQueryOptions = {},
) {
const dbPath = historyDbPath(resolveAsideProfile(profile));
const dbExists = existsSync(dbPath);
const query = useMemo(() => (dbExists ? buildHistoryQuery(searchText, limit) : ""), [searchText, limit, dbExists]);
const query = useMemo(
() => (dbExists ? buildHistoryQuery(searchText, limit, options) : ""),
[searchText, limit, dbExists, options.includeTotalMatches],
);
const { data, error, isLoading, permissionView, revalidate } = useSQL<HistoryRow>(
dbExists ? dbPath : __filename,
query,
Expand All @@ -109,6 +156,7 @@ export function useHistorySearch(searchText: string, limit = 25, profile = confi
);

const entries = useMemo<HistoryEntry[]>(() => (data ?? []).map(mapHistoryRow), [data]);
const totalMatches = data?.[0]?.totalMatches ?? entries.length;

return { data: entries, error, isAvailable: dbExists, isLoading, permissionView, revalidate };
return { data: entries, totalMatches, error, isAvailable: dbExists, isLoading, permissionView, revalidate };
}
Loading
Loading