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
6 changes: 3 additions & 3 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@
/extensions/git-worktrees @philstainer
/extensions/gitee @koinzhang
/extensions/gitfox @azlekov
/extensions/github @thomaslombart @unnamedd @tonka3000 @khasbilegt @pernielsentikaer @loxygenK @oilbeater @LunaticMuch @aeorge @daquinoaldo @peppy @aeither @marcotf @qeude @nesl247 @xilopaint @antonengelhardt @bangerang @wottpal @LitoMore @d-mitrofanov-v @j3lte @jfkisafk @vlasischar @JavaLangRuntimeException @shyakadavis @sushichan044 @luarmr @nicolas-marien @ionTea @ridemountainpig @kud @MartinGonzalez @brandonnly
/extensions/github @thomaslombart @unnamedd @tonka3000 @khasbilegt @pernielsentikaer @loxygenK @oilbeater @LunaticMuch @aeorge @daquinoaldo @peppy @aeither @marcotf @qeude @nesl247 @xilopaint @antonengelhardt @bangerang @wottpal @LitoMore @d-mitrofanov-v @j3lte @jfkisafk @vlasischar @JavaLangRuntimeException @shyakadavis @sushichan044 @luarmr @nicolas-marien @ionTea @ridemountainpig @kud @MartinGonzalez @brandonnly @emergerrrd
/extensions/github-cli-manual @demartini
/extensions/github-codespaces @mkwng
/extensions/github-copilot @timrogers @pernielsentikaer @thomaspaulmann @gdarchen
Expand Down Expand Up @@ -987,7 +987,7 @@
/extensions/jetpack-commands @sejas
/extensions/jira @thomaslombart @FezVrasta @teziovsky @gavinroderick @gavinroderick @michael-par @literallyjustroy @mheidinger @luarmr @horumyy @rsperezn @Silv-1 @EyLuismi @pernielsentikaer @BehnH @JokeyChen @Murreey @erayack
/extensions/jira-search @svenwiegand
/extensions/jira-search-self-hosted @emanguy @svenwiegand @koseduhemak @LunaticMuch @nick318 @marinsokol
/extensions/jira-search-self-hosted @emanguy @svenwiegand @koseduhemak @LunaticMuch @marinsokol
/extensions/jira-time-tracking @niallpaterson @0w0miki @haydencbarnes
/extensions/jira2git @ipiranhaa
/extensions/jisho @dmacdermott @mmtftr
Expand Down Expand Up @@ -1215,7 +1215,7 @@
/extensions/memo @mt40
/extensions/memorable-generate-password @gandli @pernielsentikaer
/extensions/memory @EvanZhouDev
/extensions/memos @JakeLaoyu @LittleQuartZ
/extensions/memos @JakeLaoyu @LittleQuartZ @0xdhrv
/extensions/mempool @dillionverma @xmok
/extensions/menubar-calendar @koinzhang
/extensions/menubar-weather @koinzhang @koinzhang @fuksman @xilopaint
Expand Down
3 changes: 2 additions & 1 deletion .github/raycast2github.json
Original file line number Diff line number Diff line change
Expand Up @@ -2438,5 +2438,6 @@
"raihan_khan": "raihankhan-rk",
"pcho": "pcho",
"jouper": "Juoper",
"zebapy": "zebapy"
"zebapy": "zebapy",
"emergerrrd": "emergerrrd"
}
14 changes: 4 additions & 10 deletions docs/api-reference/user-interface/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,9 @@ export default function Command() {

### List.EmptyView

A view to display when there aren't any items available. Use to greet users with a friendly message if the
extension requires user input before it can show any list items e.g. when searching for a package, an article etc.
A view to display when there aren't any items available. Use to greet users with a friendly message if the extension requires user input before it can show any list items e.g. when searching for a package, an article etc.

Raycast provides a default `EmptyView` that will be displayed if the List component either has no children,
or if it has children, but none of them match the query in the search bar. This too can be overridden by passing an
empty view alongside the other `List.Item`s.
Raycast provides a default `EmptyView` that will be displayed if the List component either has no children, or if it has children, but none of them match the query in the search bar. This too can be overridden by passing an empty view alongside the other `List.Item`s.

Note that the `EmptyView` is _never_ displayed if the `List`'s `isLoading` property is true and the search bar is empty.

Expand Down Expand Up @@ -628,9 +625,7 @@ export default function CommandWithCustomEmptyView() {

A item in the [List](#list).

This is one of the foundational UI components of Raycast. A list item represents a single entity. It can be a
GitHub pull request, a file, or anything else. You most likely want to perform actions on this item, so make it clear
to the user what this list item is about.
This is one of the foundational UI components of Raycast. A list item represents a single entity. It can be a GitHub pull request, a file, or anything else. You most likely want to perform actions on this item, so make it clear to the user what this list item is about.

#### Example

Expand Down Expand Up @@ -945,8 +940,7 @@ export default function Metadata() {

A group of related [List.Item](#list.item).

Sections are a great way to structure your list. For example, group GitHub issues with the same status and order them by priority.
This way, the user can quickly access what is most relevant.
Sections are a great way to structure your list. For example, group GitHub issues with the same status and order them by priority. This way, the user can quickly access what is most relevant.

#### Example

Expand Down
4 changes: 4 additions & 0 deletions extensions/browser-bookmarks/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Browser Bookmarks Changelog

## [Support for libreWolf] - 2025-12-07

- Added support for `LibreWolf` browser.

## [Bug Fixes] - 2025-11-10

- Improved error handling for directory reading.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const BROWSERS_BUNDLE_ID = {
prismaAccess: "com.talon-sec.work",
vivaldi: "com.vivaldi.vivaldi",
zen: "app.zen-browser.zen",
libreWolf: "org.mozilla.librewolf",
whale: "com.naver.whale",
};

Expand Down
259 changes: 259 additions & 0 deletions extensions/browser-bookmarks/src/hooks/useLibreWolfBookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import fs from "fs";
import { readFile } from "fs";
import path from "path";
import { promisify } from "util";

import { environment } from "@raycast/api";
import { useCachedPromise, useCachedState } from "@raycast/utils";
import ini from "ini";
import { useMemo, useEffect } from "react";
import initSqlJs, { Database } from "sql.js";

import { BROWSERS_BUNDLE_ID } from "./useAvailableBrowsers";

const read = promisify(readFile);

const LIBREWOLF_FOLDER = path.join(process.env.HOME || "", "Library", "Application Support", "librewolf");

const folderNames: Record<string, string> = {
menu: "Bookmark Menu",
mobile: "Mobile Bookmarks",
tags: "Tags",
toolbar: "Bookmarks Toolbar",
unfiled: "Other Bookmarks",
};

async function getLibreWolfProfiles() {
if (!fs.existsSync(`${LIBREWOLF_FOLDER}/profiles.ini`)) {
return { profiles: [], defaultProfile: "" };
}

const file = await read(`${LIBREWOLF_FOLDER}/profiles.ini`, "utf-8");
const iniFile = ini.parse(file);

const profiles = Object.keys(iniFile)
.filter((key) => {
if (key.startsWith("Profile")) {
const profilePath = iniFile[key].Path;
const fullProfilePath = path.join(LIBREWOLF_FOLDER, profilePath);
return fs.existsSync(path.join(fullProfilePath, "places.sqlite"));
}
return false;
})
.map((key) => ({
name: iniFile[key].Name || iniFile[key].Path,
path: iniFile[key].Path,
isDefault: iniFile[key].Default === "1" || iniFile[key].Path.includes(".default"),
}));

let defaultProfile = profiles.find((p) => p.isDefault)?.path;
if (!defaultProfile && profiles.length > 0) {
defaultProfile = profiles[0].path;
}

profiles.sort((a, b) => {
if (a.path === defaultProfile) return -1;
if (b.path === defaultProfile) return 1;
return a.name.localeCompare(b.name);
});

return { profiles, defaultProfile };
}

type Folder = {
id: number;
parentId: number;
title: string;
guid: string;
};

function getLibreWolfFolders(db: Database) {
const folders = [];
const statement = db.prepare(
`
SELECT moz_bookmarks.id AS id,
moz_bookmarks.parent AS parentId,
moz_bookmarks.title AS title,
moz_bookmarks.guid AS guid
FROM moz_bookmarks
WHERE moz_bookmarks.type = 2
AND moz_bookmarks.title IS NOT NULL
AND moz_bookmarks.title <> ''
AND moz_bookmarks.fk IS NULL;
`,
);

while (statement.step()) {
const row = statement.getAsObject() as Folder;
folders.push(row);
}

statement.free();
return folders;
}

type Bookmark = {
id: number;
parentId: number;
title: string;
urlString: string;
};

function getLibreWolfBookmarks(db: Database) {
const bookmarks = [];
const statement = db.prepare(
`
SELECT moz_places.id AS id,
moz_bookmarks.parent AS parentId,
moz_bookmarks.title AS title,
moz_places.url AS urlString
FROM moz_bookmarks LEFT JOIN moz_places ON moz_bookmarks.fk = moz_places.id
WHERE moz_bookmarks.type = 1
AND moz_bookmarks.title IS NOT NULL
AND moz_places.url IS NOT NULL;
`,
);

while (statement.step()) {
const row = statement.getAsObject() as Bookmark;
bookmarks.push(row);
}

statement.free();
return bookmarks;
}

function processFolderHierarchy(folders: Folder[]): Folder[] {
const processedFolders = [...folders];

// Find the toolbar folder ID
const toolbarFolder = processedFolders.find((f) => f.parentId === 1 && f.title.toLowerCase() === "toolbar");
const toolbarId = toolbarFolder?.id;

return processedFolders.map((folder) => {
// For root-level folders, use friendly names
if (folder.parentId === 1) {
const friendlyName = folderNames[folder.title.toLowerCase()];
return {
...folder,
title: friendlyName || folder.title,
};
}

// Build hierarchy for non-root folders
const hierarchy = [folder.title];
let currentFolder = folder;

while (currentFolder.parentId !== 1) {
const parent = processedFolders.find((f) => f.id === currentFolder.parentId);
if (!parent) break;

// If we hit the toolbar folder, mark it but don't add to hierarchy
if (parent.id === toolbarId) {
break;
}

hierarchy.unshift(parent.title);
currentFolder = parent;
}

// If the folder is directly under toolbar, return just its title
if (folder.parentId === toolbarId) {
return {
...folder,
title: folder.title,
};
}

// For nested folders under toolbar or other paths, join hierarchy
return {
...folder,
title: hierarchy.join("/"),
};
});
}

export default function useLibreWolfBookmarks(enabled: boolean) {
const [currentProfile, setCurrentProfile] = useCachedState(`librewolf-profile`, "");

const { data: profileData, isLoading: isLoadingProfiles } = useCachedPromise(
async (enabled) => {
if (!enabled) return null;
return getLibreWolfProfiles();
},
[enabled],
);

useEffect(() => {
if (profileData && currentProfile === "" && profileData.defaultProfile) {
setCurrentProfile(profileData.defaultProfile);
}
}, [profileData, currentProfile, setCurrentProfile]);

const {
data,
isLoading: isLoadingBookmarks,
mutate,
} = useCachedPromise(
async (profile, enabled) => {
if (!profile || !enabled) return null;

const dbPath = path.join(LIBREWOLF_FOLDER, profile, "places.sqlite");

if (!fs.existsSync(dbPath)) {
console.log("Database not found at:", dbPath);
return null;
}

const buffer = new Uint8Array(await read(dbPath));
const wasmBinary = await read(path.join(environment.assetsPath, "sql-wasm.wasm"));
const SQL = await initSqlJs({ wasmBinary });
const db = new SQL.Database(buffer);

const rawFolders = getLibreWolfFolders(db);
const folders = processFolderHierarchy(rawFolders);
const bookmarks = getLibreWolfBookmarks(db);

return { folders, bookmarks };
},
[currentProfile, enabled],
);

const folders = useMemo(
() =>
data?.folders?.map((folder) => ({
...folder,
id: `${folder.id}`,
icon: "librewolf.png",
browser: BROWSERS_BUNDLE_ID.libreWolf,
})) || [],
[data],
);

const bookmarks = useMemo(
() =>
data?.bookmarks?.map((bookmark) => {
const folder = folders.find((folder) => folder.id === `${bookmark.parentId}`);
return {
id: `${bookmark.id}`,
title: bookmark.title,
url: bookmark.urlString,
folder: folder ? folder.title : "",
browser: BROWSERS_BUNDLE_ID.libreWolf,
};
}) || [],
[data, folders],
);

const isLoading = isLoadingProfiles || isLoadingBookmarks;

return {
profiles: profileData?.profiles || [],
currentProfile,
setCurrentProfile,
bookmarks,
folders,
isLoading,
mutate,
};
}
Loading
Loading