/
Focus search from anywhere
@@ -612,14 +622,14 @@ function SearchTipsCard() {
Tab
Cycle search modes
-
-
+
+
·
Use keyword mode for exact terms and hybrid mode for broader recall.
-
+
·
-
Network search queries public Bluesky posts without using your local index.
+
Semantic mode follows the embeddings setting and model status shown above.
diff --git a/src/components/settings/SettingsAbout.tsx b/src/components/settings/SettingsAbout.tsx
new file mode 100644
index 0000000..499a520
--- /dev/null
+++ b/src/components/settings/SettingsAbout.tsx
@@ -0,0 +1,48 @@
+import * as logger from "@tauri-apps/plugin-log";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { SettingsCard } from "./SettingsCard";
+
+export function SettingsAbout() {
+ return (
+
+
+
+
+
Version
+
0.1.0-alpha
+
+
+
+
+
+
License
+
MIT License
+
+
+
+
+
+
Source code
+
github.com/stormlightlabs/lazurite
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsAccount.tsx b/src/components/settings/SettingsAccount.tsx
new file mode 100644
index 0000000..c448b5c
--- /dev/null
+++ b/src/components/settings/SettingsAccount.tsx
@@ -0,0 +1,103 @@
+import { useAppSession } from "$/contexts/app-session";
+import type { AccountSummary } from "$/lib/types";
+import { useNavigate } from "@solidjs/router";
+import { For, Show } from "solid-js";
+import { Icon } from "../shared/Icon";
+import { SettingsCard } from "./SettingsCard";
+
+function AccountItem(props: { account: AccountSummary; active: boolean; onRemove: () => void; onSwitch: () => void }) {
+ return (
+
+
+
+
+
+ {props.account.handle.slice(0, 2).toUpperCase()}
+
+ }>
+ {(avatar) =>
})
}
+
+
+
+
+
+
+
+
@{props.account.handle}
+
{props.account.did}
+
+
+
+ props.onSwitch()}
+ class="rounded-lg border border-white/20 px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-white/5">
+ Switch
+
+ }>
+ Active
+
+
+
+
+ );
+}
+
+export function AccountControl(
+ props: {
+ openConfirmation: (
+ config: {
+ title: string;
+ message: string;
+ confirmText?: string;
+ type?: "danger" | "default";
+ onConfirm: () => void;
+ },
+ ) => void;
+ },
+) {
+ const session = useAppSession();
+ const navigate = useNavigate();
+ return (
+
+
+
+ {(account) => (
+ void session.switchAccount(account.did)}
+ onRemove={() =>
+ props.openConfirmation({
+ title: "Remove Account",
+ message:
+ `Are you sure you want to remove @${account.handle}? This will delete all local data for this account.`,
+ type: "danger",
+ onConfirm: () => void session.logout(account.did),
+ })} />
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsCard.tsx b/src/components/settings/SettingsCard.tsx
new file mode 100644
index 0000000..1b966d0
--- /dev/null
+++ b/src/components/settings/SettingsCard.tsx
@@ -0,0 +1,14 @@
+import { SettingsIcon, type SettingsIconKind } from "$/components/shared/Icon";
+import type { ParentProps } from "solid-js";
+
+export function SettingsCard(props: ParentProps & { icon: SettingsIconKind; title: string }) {
+ return (
+
+
+
+
{props.title}
+
+ {props.children}
+
+ );
+}
diff --git a/src/components/settings/SettingsData.tsx b/src/components/settings/SettingsData.tsx
new file mode 100644
index 0000000..2d4edf2
--- /dev/null
+++ b/src/components/settings/SettingsData.tsx
@@ -0,0 +1,134 @@
+import { exportData } from "$/lib/api/settings";
+import { formatBytes } from "$/lib/utils/text";
+import { SettingsCard } from "./SettingsCard";
+
+type SettingsDataProps = {
+ cacheSize: { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes?: number } | null;
+ handleClearCache: (scope: "feeds" | "embeddings" | "fts" | "all") => Promise
;
+ handleResetApp: () => Promise;
+ openConfirmation: (
+ options: {
+ title: string;
+ message: string;
+ confirmText?: string;
+ type?: "default" | "danger";
+ onConfirm: () => void;
+ },
+ ) => void;
+};
+export function SettingsData(props: SettingsDataProps) {
+ const cacheSize = () => props.cacheSize;
+
+ return (
+
+
+
+
+
{formatBytes(cacheSize()?.feedsBytes ?? 0)}
+
Feeds cache
+
+
+
{formatBytes(cacheSize()?.embeddingsBytes ?? 0)}
+
Embeddings
+
+
+
{formatBytes(cacheSize()?.ftsBytes ?? 0)}
+
Search index
+
+
+
{formatBytes(cacheSize()?.totalBytes ?? 0)}
+
Total local data
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ExportControl() {
+ return (
+
+
+
+
Export your data
+
Download all your data as JSON or CSV
+
+
+
+
+
+
+
+ );
+}
+
+function ResetControl(props: Pick) {
+ return (
+
+
+
+
Reset application
+
Remove all data and reset to defaults
+
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsLogs.tsx b/src/components/settings/SettingsLogs.tsx
new file mode 100644
index 0000000..f0f9f35
--- /dev/null
+++ b/src/components/settings/SettingsLogs.tsx
@@ -0,0 +1,97 @@
+import type { LogEntry, LogLevelFilter } from "$/lib/types";
+import { For, Show } from "solid-js";
+import { Motion, Presence } from "solid-motionone";
+import { Icon } from "../shared/Icon";
+import { SegmentedControl } from "../shared/SegmentedControl";
+import { SettingsCard } from "./SettingsCard";
+
+const LOG_LEVEL_OPTIONS: { value: LogLevelFilter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "info", label: "Info" },
+ { value: "warn", label: "Warn" },
+ { value: "error", label: "Error" },
+];
+
+type SettingsLogsProps = {
+ expanded: boolean;
+ logLevel: LogLevelFilter;
+ handleChange: (level: LogLevelFilter) => void;
+ logs: LogEntry[];
+ loadLogs: () => Promise;
+ expand: (expanded: boolean) => void;
+};
+
+export function SettingsLogs(props: SettingsLogsProps) {
+ const expanded = () => props.expanded;
+ const level = () => props.logLevel;
+ const logs = () => props.logs;
+ return (
+
+
+
+
props.handleChange(v)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LogDisplay(props: { logs: LogEntry[] }) {
+ return (
+
+
+
No log entries found}>
+ {(log) => (
+
+ {log.timestamp?.split("T")[1]?.slice(0, 8) ?? "--:--:--"}
+
+ {log.level}
+
+ {log.message}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/settings/SettingsNotification.tsx b/src/components/settings/SettingsNotification.tsx
new file mode 100644
index 0000000..3697f01
--- /dev/null
+++ b/src/components/settings/SettingsNotification.tsx
@@ -0,0 +1,36 @@
+import type { AppSettings } from "$/lib/types";
+import { SettingsCard } from "./SettingsCard";
+import { ToggleRow } from "./SettingsToggleRow";
+
+export function NotificationsControl(
+ props: {
+ handleUpdateSetting?: (key: keyof AppSettings, value: string | boolean) => void;
+ settings?: AppSettings | null;
+ },
+) {
+ const notificationsDesktop = () => props.settings?.notificationsDesktop ?? true;
+ const notificationsBadge = () => props.settings?.notificationsBadge ?? true;
+ const notificationsSound = () => props.settings?.notificationsSound ?? false;
+
+ return (
+
+
+ void props.handleUpdateSetting?.("notificationsDesktop", !notificationsDesktop())} />
+ void props.handleUpdateSetting?.("notificationsBadge", !notificationsBadge())} />
+ void props.handleUpdateSetting?.("notificationsSound", !notificationsSound())} />
+
+
+ );
+}
diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx
new file mode 100644
index 0000000..9998751
--- /dev/null
+++ b/src/components/settings/SettingsPanel.test.tsx
@@ -0,0 +1,277 @@
+import { AppTestProviders } from "$/test/providers";
+import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SettingsPanel } from "./SettingsPanel";
+
+const getSettingsMock = vi.hoisted(() => vi.fn());
+const updateSettingMock = vi.hoisted(() => vi.fn());
+const getCacheSizeMock = vi.hoisted(() => vi.fn());
+const clearCacheMock = vi.hoisted(() => vi.fn());
+const exportDataMock = vi.hoisted(() => vi.fn());
+const resetAppMock = vi.hoisted(() => vi.fn());
+const getLogEntriesMock = vi.hoisted(() => vi.fn());
+const navigateMock = vi.hoisted(() => vi.fn());
+const infoMock = vi.hoisted(() => vi.fn());
+
+const DEFAULT_EMBEDDINGS_CONFIG = {
+ enabled: true,
+ modelName: "nomic-embed-text-v1.5",
+ dimensions: 768,
+ downloaded: true,
+ downloadActive: false,
+};
+
+vi.mock(
+ "$/lib/api/settings",
+ () => ({
+ getSettings: getSettingsMock,
+ updateSetting: updateSettingMock,
+ getCacheSize: getCacheSizeMock,
+ clearCache: clearCacheMock,
+ exportData: exportDataMock,
+ resetApp: resetAppMock,
+ getLogEntries: getLogEntriesMock,
+ }),
+);
+
+vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock }));
+
+vi.mock("@tauri-apps/plugin-log", () => ({ info: infoMock }));
+
+function createMockSettings(overrides = {}) {
+ return {
+ theme: "auto",
+ timelineRefreshSecs: 60,
+ notificationsDesktop: true,
+ notificationsBadge: true,
+ notificationsSound: false,
+ embeddingsEnabled: true,
+ constellationUrl: "https://constellation.microcosm.blue",
+ spacedustUrl: "https://spacedust.microcosm.blue",
+ spacedustInstant: false,
+ spacedustEnabled: false,
+ globalShortcut: "Ctrl+Shift+N",
+ ...overrides,
+ };
+}
+
+function createMockCacheSize(overrides = {}) {
+ return {
+ feedsBytes: 1024 * 1024 * 100,
+ embeddingsBytes: 1024 * 1024 * 200,
+ ftsBytes: 1024 * 1024 * 50,
+ totalBytes: 1024 * 1024 * 350,
+ ...overrides,
+ };
+}
+
+function createMockLogEntry(level = "INFO", message = "Test log message") {
+ return { timestamp: new Date().toISOString(), level, target: "test", message };
+}
+
+function renderSettingsPanel(
+ options: { preferences?: Record; session?: Record } = {},
+) {
+ render(() => (
+
+
+
+ ));
+}
+
+describe("SettingsPanel", () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ getSettingsMock.mockResolvedValue(createMockSettings());
+ getCacheSizeMock.mockResolvedValue(createMockCacheSize());
+ getLogEntriesMock.mockResolvedValue([createMockLogEntry()]);
+ updateSettingMock.mockResolvedValue(void 0);
+ clearCacheMock.mockResolvedValue(void 0);
+ exportDataMock.mockResolvedValue(void 0);
+ resetAppMock.mockResolvedValue(void 0);
+ });
+
+ it("loads and displays settings", async () => {
+ renderSettingsPanel();
+
+ expect(await screen.findByText("Settings")).toBeInTheDocument();
+ expect(await screen.findByText("Appearance")).toBeInTheDocument();
+ expect(await screen.findByText("Timeline")).toBeInTheDocument();
+ expect(await screen.findByText("Notifications")).toBeInTheDocument();
+ expect(await screen.findByText("Accounts")).toBeInTheDocument();
+ expect(await screen.findByText("Services")).toBeInTheDocument();
+ expect(await screen.findByText("Data")).toBeInTheDocument();
+ expect(await screen.findByText("Logs")).toBeInTheDocument();
+ expect(await screen.findByText("About")).toBeInTheDocument();
+ });
+
+ it("displays cache size information", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ expect(await screen.findByText("100 MB")).toBeInTheDocument();
+ expect(await screen.findByText("Feeds cache")).toBeInTheDocument();
+ });
+
+ it("allows toggling desktop notifications", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const toggle = await screen.findByRole("switch", { name: /desktop notifications/i });
+
+ fireEvent.click(toggle);
+ await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("notificationsDesktop", false));
+ });
+
+ it("allows toggling badge count", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const toggle = await screen.findByRole("switch", { name: /badge count/i });
+
+ fireEvent.click(toggle);
+ await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("notificationsBadge", false));
+ });
+
+ it("allows changing theme", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const darkButton = await screen.findByRole("button", { name: /dark/i });
+
+ fireEvent.click(darkButton);
+ await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("theme", "dark"));
+ });
+
+ it("allows changing refresh interval", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const manualButton = await screen.findByRole("button", { name: /manual/i });
+
+ fireEvent.click(manualButton);
+ await waitFor(() => expect(updateSettingMock).toHaveBeenCalledWith("timelineRefreshSecs", 0));
+ });
+
+ it("allows clearing feeds cache", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const clearFeedsButton = await screen.findByRole("button", { name: /clear feeds/i });
+
+ fireEvent.click(clearFeedsButton);
+ await waitFor(() => expect(clearCacheMock).toHaveBeenCalledWith("feeds"));
+ });
+
+ it("shows confirmation modal before clearing all cache", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const clearAllButton = await screen.findByRole("button", { name: /clear all/i });
+
+ fireEvent.click(clearAllButton);
+ expect(await screen.findByText("Clear All Cache")).toBeInTheDocument();
+ });
+
+ it("allows exporting data as JSON", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const jsonButton = await screen.findByRole("button", { name: /json/i });
+
+ fireEvent.click(jsonButton);
+ await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("json"));
+ });
+
+ it("allows exporting data as CSV", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const csvButton = await screen.findByRole("button", { name: /csv/i });
+
+ fireEvent.click(csvButton);
+ await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("csv"));
+ });
+
+ it("shows confirmation modal with RESET text for app reset", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const resetButton = await screen.findByRole("button", { name: /reset\.\.\./i });
+
+ fireEvent.click(resetButton);
+ expect(await screen.findByText("Reset Application")).toBeInTheDocument();
+ expect(await screen.findByPlaceholderText(/type "reset" to confirm/i)).toBeInTheDocument();
+ });
+
+ it("navigates back when close button is clicked", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const closeButton = await screen.findByRole("button", { name: /close settings/i });
+
+ fireEvent.click(closeButton);
+ await waitFor(() => expect(navigateMock).toHaveBeenCalledWith(-1));
+ });
+
+ it("expands and collapses log viewer", async () => {
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const expandButton = await screen.findByRole("button", { name: /expand log viewer/i });
+
+ fireEvent.click(expandButton);
+ expect(await screen.findByRole("button", { name: /collapse log viewer/i })).toBeInTheDocument();
+ });
+
+ it("copies logs to clipboard", async () => {
+ const clipboardWriteText = vi.fn().mockResolvedValue(void 0);
+ Object.assign(navigator, { clipboard: { writeText: clipboardWriteText } });
+
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const copyButton = await screen.findByRole("button", { name: /copy all/i });
+
+ fireEvent.click(copyButton);
+ await waitFor(() => expect(clipboardWriteText).toHaveBeenCalled());
+ });
+
+ it("filters logs by level", async () => {
+ getLogEntriesMock.mockResolvedValue([
+ createMockLogEntry("INFO", "Info message"),
+ createMockLogEntry("WARN", "Warning message"),
+ createMockLogEntry("ERROR", "Error message"),
+ ]);
+
+ renderSettingsPanel();
+
+ await screen.findByText("Settings");
+ const warnButton = await screen.findByRole("button", { name: /warn/i });
+
+ fireEvent.click(warnButton);
+ await waitFor(() => expect(getLogEntriesMock).toHaveBeenCalledWith(100, "warn"));
+ });
+
+ it("displays accounts from session", async () => {
+ const accounts = [{
+ did: "did:plc:abc123",
+ handle: "user.bsky.social",
+ pdsUrl: "https://bsky.social",
+ active: true,
+ }, { did: "did:plc:xyz789", handle: "alt.bsky.social", pdsUrl: "https://bsky.social", active: false }];
+
+ renderSettingsPanel({ session: { accounts } });
+
+ await screen.findByText("Settings");
+ expect(await screen.findByText("@user.bsky.social")).toBeInTheDocument();
+ expect(await screen.findByText("@alt.bsky.social")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
new file mode 100644
index 0000000..f8a5e82
--- /dev/null
+++ b/src/components/settings/SettingsPanel.tsx
@@ -0,0 +1,321 @@
+import { EmbeddingsSettings } from "$/components/search/EmbeddingsSettings";
+import { useAppPreferences } from "$/contexts/app-preferences";
+import { clearCache, getCacheSize, getLogEntries, resetApp } from "$/lib/api/settings";
+import type {
+ AppSettings,
+ CacheClearScope,
+ CacheSize,
+ LogEntry,
+ LogLevelFilter,
+ RefreshInterval,
+ Theme,
+} from "$/lib/types";
+import { normalizeError } from "$/lib/utils/text";
+import { useNavigate } from "@solidjs/router";
+import * as logger from "@tauri-apps/plugin-log";
+import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
+import { createStore } from "solid-js/store";
+import { Motion, Presence } from "solid-motionone";
+import { Icon } from "../shared/Icon";
+import { SettingsAbout } from "./SettingsAbout";
+import { AccountControl } from "./SettingsAccount";
+import { SettingsData } from "./SettingsData";
+import { SettingsLogs } from "./SettingsLogs";
+import { NotificationsControl } from "./SettingsNotification";
+import { SettingsService } from "./SettingsService";
+import { AppearanceControl } from "./SettingsTheme";
+import { TimelineControl } from "./SettingsTimeline";
+
+type SettingsPanelState = {
+ cacheSize: CacheSize | null;
+ logLevel: LogLevelFilter;
+ logs: LogEntry[];
+ logsExpanded: boolean;
+ modalConfig: {
+ title: string;
+ message: string;
+ confirmText?: string;
+ type?: "danger" | "default";
+ onConfirm: () => void;
+ } | null;
+ modalOpen: boolean;
+};
+
+function ConfirmationModal(
+ props: {
+ confirmText?: string;
+ isOpen: boolean;
+ message: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+ title: string;
+ type?: "danger" | "default";
+ },
+) {
+ const [inputValue, setInputValue] = createSignal("");
+ const requiresConfirmText = () => props.confirmText !== undefined;
+ const canConfirm = () => !requiresConfirmText() || inputValue() === props.confirmText;
+
+ return (
+
+
+
+
+ {props.title}
+ {props.message}
+
+
+
+
+
+
+
+ );
+}
+
+function ConfirmTextInput(
+ props: { required: boolean; value: string; handleInput: (value: string) => void; confirmText: string },
+) {
+ return (
+
+ props.handleInput(e.currentTarget.value)}
+ placeholder={`Type "${props.confirmText}" to confirm`}
+ class="mb-4 w-full rounded-lg border border-white/10 bg-black/40 px-4 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" />
+
+ );
+}
+
+function Actions(
+ props: { confirmable: boolean; type?: "danger" | "default"; onConfirm: () => void; onCancel: () => void },
+) {
+ return (
+
+
+
+
+ );
+}
+
+function SettingsSkeleton() {
+ return (
+
+ );
+}
+
+export function SettingsPanel() {
+ const preferences = useAppPreferences();
+ const navigate = useNavigate();
+ const [panel, setPanel] = createStore({
+ cacheSize: null,
+ logLevel: "all",
+ logs: [],
+ logsExpanded: false,
+ modalConfig: null,
+ modalOpen: false,
+ });
+
+ const settings = () => preferences.settings;
+ const loading = () => preferences.settingsLoading;
+
+ async function loadCacheSize() {
+ try {
+ setPanel("cacheSize", await getCacheSize());
+ } catch (err) {
+ logger.error("failed to load cache size", { keyValues: { error: normalizeError(err) } });
+ }
+ }
+
+ async function loadLogs(level = panel.logLevel) {
+ try {
+ setPanel("logs", await getLogEntries(100, level));
+ } catch (err) {
+ logger.error("failed to load logs", { keyValues: { error: normalizeError(err) } });
+ }
+ }
+
+ async function handleUpdateSetting(key: keyof AppSettings, value: string | boolean | number) {
+ await preferences.updateSetting(key, value);
+ }
+
+ async function handleClearCache(scope: CacheClearScope) {
+ try {
+ await clearCache(scope);
+ await loadCacheSize();
+ } catch (err) {
+ logger.error("failed to clear cache", { keyValues: { scope, error: normalizeError(err) } });
+ }
+ }
+
+ async function handleResetApp() {
+ try {
+ await resetApp();
+ navigate("/auth");
+ } catch (err) {
+ logger.error("failed to reset app", { keyValues: { error: normalizeError(err) } });
+ }
+ }
+
+ function openConfirmation(
+ config: {
+ title: string;
+ message: string;
+ confirmText?: string;
+ type?: "danger" | "default";
+ onConfirm: () => void;
+ },
+ ) {
+ setPanel("modalConfig", config);
+ setPanel("modalOpen", true);
+ }
+
+ onMount(() => {
+ void loadCacheSize();
+ globalThis.addEventListener("keydown", handleKeyDown);
+ onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown));
+ });
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && panel.modalOpen) {
+ setPanel("modalOpen", false);
+ }
+ };
+
+ createEffect(() => {
+ void loadLogs(panel.logLevel);
+ });
+
+ const currentTheme = createMemo((): Theme => {
+ const s = settings();
+ if (!s) return "auto";
+ const t = s.theme;
+ return t === "light" || t === "dark" || t === "auto" ? t : "auto";
+ });
+
+ const currentRefresh = createMemo((): RefreshInterval => {
+ const s = settings();
+ if (!s) return 60;
+ const secs = s.timelineRefreshSecs;
+ return [30, 60, 120, 300, 0].includes(secs) ? (secs as RefreshInterval) : 60;
+ });
+
+ return (
+
+
+
+
+
Configuration
+
Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setPanel("logLevel", level)}
+ logs={panel.logs}
+ loadLogs={loadLogs}
+ expand={(expanded) => setPanel("logsExpanded", expanded)} />
+
+
+
+ }>
+
+
+
+
+
+ setPanel("modalOpen", false)}
+ onConfirm={() => {
+ panel.modalConfig?.onConfirm();
+ setPanel("modalOpen", false);
+ }} />
+
+ );
+}
diff --git a/src/components/settings/SettingsService.tsx b/src/components/settings/SettingsService.tsx
new file mode 100644
index 0000000..549533c
--- /dev/null
+++ b/src/components/settings/SettingsService.tsx
@@ -0,0 +1,51 @@
+import type { AppSettings } from "$/lib/types";
+import { SettingsCard } from "./SettingsCard";
+import { ToggleRow } from "./SettingsToggleRow";
+
+export function SettingsService(
+ props: {
+ settings: AppSettings | null;
+ handleUpdateSetting: (key: keyof AppSettings, value: string | boolean | number) => Promise;
+ },
+) {
+ const constellationUrl = () => props.settings?.constellationUrl ?? "https://constellation.microcosm.blue";
+ const spacedustUrl = () => props.settings?.spacedustUrl ?? "https://spacedust.microcosm.blue";
+ const spacedustEnabled = () => props.settings?.spacedustEnabled ?? false;
+ const spacedustInstant = () => props.settings?.spacedustInstant ?? false;
+ return (
+
+
+
+
+
+ void props.handleUpdateSetting("constellationUrl", e.currentTarget.value)}
+ class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" />
+
+
+
+
+
+ void props.handleUpdateSetting("spacedustUrl", e.currentTarget.value)}
+ class="flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none transition focus:border-primary/50" />
+
+
+
void props.handleUpdateSetting("spacedustEnabled", !spacedustEnabled())} />
+ void props.handleUpdateSetting("spacedustInstant", !spacedustInstant())} />
+
+
+ );
+}
diff --git a/src/components/settings/SettingsTheme.tsx b/src/components/settings/SettingsTheme.tsx
new file mode 100644
index 0000000..8d4656c
--- /dev/null
+++ b/src/components/settings/SettingsTheme.tsx
@@ -0,0 +1,27 @@
+import type { AppSettings, Theme } from "$/lib/types";
+import { SegmentedControl } from "../shared/SegmentedControl";
+import { SettingsCard } from "./SettingsCard";
+
+const THEME_OPTIONS: { value: Theme; label: string }[] = [{ value: "light", label: "Light" }, {
+ value: "dark",
+ label: "Dark",
+}, { value: "auto", label: "Auto" }];
+
+export function AppearanceControl(
+ props: { currentTheme: Theme; handleUpdateSetting: (key: keyof AppSettings, value: string) => void },
+) {
+ return (
+
+
+
+
Theme
+
Choose your preferred color scheme
+
+
void props.handleUpdateSetting("theme", v)} />
+
+
+ );
+}
diff --git a/src/components/settings/SettingsTimeline.tsx b/src/components/settings/SettingsTimeline.tsx
new file mode 100644
index 0000000..be3cec9
--- /dev/null
+++ b/src/components/settings/SettingsTimeline.tsx
@@ -0,0 +1,33 @@
+import type { AppSettings, RefreshInterval } from "$/lib/types";
+import { SegmentedControl } from "../shared/SegmentedControl";
+import { SettingsCard } from "./SettingsCard";
+
+const REFRESH_OPTIONS: { value: RefreshInterval; label: string }[] = [
+ { value: 30, label: "30s" },
+ { value: 60, label: "1m" },
+ { value: 120, label: "2m" },
+ { value: 300, label: "5m" },
+ { value: 0, label: "Manual" },
+];
+
+export function TimelineControl(
+ props: {
+ currentRefresh: RefreshInterval;
+ handleUpdateSetting: (key: keyof AppSettings, value: string | number) => void;
+ },
+) {
+ return (
+
+
+
+
Auto-refresh interval
+
How often to check for new posts
+
+
void props.handleUpdateSetting("timelineRefreshSecs", v)} />
+
+
+ );
+}
diff --git a/src/components/settings/SettingsToggleRow.tsx b/src/components/settings/SettingsToggleRow.tsx
new file mode 100644
index 0000000..68e9e80
--- /dev/null
+++ b/src/components/settings/SettingsToggleRow.tsx
@@ -0,0 +1,28 @@
+import { Motion } from "solid-motionone";
+
+export function ToggleRow(
+ props: { checked: boolean; description: string; disabled?: boolean; label: string; onChange: () => void },
+) {
+ return (
+
+
+
{props.label}
+
{props.description}
+
+
+
+ );
+}
diff --git a/src/components/shared/Icon.tsx b/src/components/shared/Icon.tsx
index 47177af..54657db 100644
--- a/src/components/shared/Icon.tsx
+++ b/src/components/shared/Icon.tsx
@@ -2,6 +2,16 @@ import type { SearchMode } from "$/lib/api/search";
import type { ExplorerTargetKind } from "$/lib/api/types/explorer";
import { type JSX, Match, splitProps, Switch } from "solid-js";
+export type SettingsIconKind =
+ | "computer"
+ | "info"
+ | "timeline"
+ | "db"
+ | "notifications"
+ | "user"
+ | "services"
+ | "theme";
+
export type IconKind =
| "explorer"
| "ext-link"
@@ -32,7 +42,15 @@ export type IconKind =
| "repost"
| "reply"
| "follow"
- | "download";
+ | "download"
+ | "info"
+ | "computer"
+ | "timeline"
+ | "db"
+ | "notifications"
+ | "user"
+ | "services"
+ | "theme";
type IconProps = JSX.HTMLAttributes & {
class?: string;
@@ -142,6 +160,40 @@ export function Icon(props: IconProps) {
);
}
+export function SettingsIcon(props: IconProps & { kind: SettingsIconKind }) {
+ const [local, rest] = splitProps(props, ["class", "iconClass", "kind", "name"]);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
export function ArrowIcon(props: { class?: string; direction: "up" | "down" | "left" | "right" }) {
return (
diff --git a/src/components/shared/SegmentedControl.tsx b/src/components/shared/SegmentedControl.tsx
new file mode 100644
index 0000000..abffe31
--- /dev/null
+++ b/src/components/shared/SegmentedControl.tsx
@@ -0,0 +1,24 @@
+import { For } from "solid-js";
+
+export function SegmentedControl(
+ props: { options: { value: T; label: string }[]; value: T; onChange: (value: T) => void },
+) {
+ return (
+
+
+ {(option) => (
+
+ )}
+
+
+ );
+}
diff --git a/src/contexts/app-preferences.tsx b/src/contexts/app-preferences.tsx
new file mode 100644
index 0000000..1dcce7d
--- /dev/null
+++ b/src/contexts/app-preferences.tsx
@@ -0,0 +1,180 @@
+import {
+ getEmbeddingsConfig,
+ prepareEmbeddingsModel as prepareEmbeddingsModelRequest,
+ setEmbeddingsEnabled as setEmbeddingsEnabledRequest,
+} from "$/lib/api/search";
+import type { EmbeddingsConfig } from "$/lib/api/search";
+import { getSettings, updateSetting as updateSettingRequest } from "$/lib/api/settings";
+import type { AppSettings } from "$/lib/types";
+import * as logger from "@tauri-apps/plugin-log";
+import { createContext, onMount, type ParentProps, splitProps, untrack, useContext } from "solid-js";
+import { createStore } from "solid-js/store";
+
+type AppPreferencesState = {
+ embeddingsConfig: EmbeddingsConfig | null;
+ embeddingsLoading: boolean;
+ settings: AppSettings | null;
+ settingsLoading: boolean;
+};
+
+export type AppPreferencesContextValue = {
+ readonly embeddingsConfig: EmbeddingsConfig | null;
+ readonly embeddingsEnabled: boolean;
+ readonly embeddingsLoading: boolean;
+ readonly settings: AppSettings | null;
+ readonly settingsLoading: boolean;
+ loadEmbeddingsConfig: () => Promise;
+ loadSettings: () => Promise;
+ prepareEmbeddingsModel: () => Promise;
+ refresh: () => Promise;
+ setEmbeddingsEnabled: (enabled: boolean) => Promise;
+ updateSetting: (key: keyof AppSettings, value: string | boolean | number) => Promise;
+};
+
+const AppPreferencesContext = createContext();
+
+function createInitialAppPreferencesState(): AppPreferencesState {
+ return { embeddingsConfig: null, embeddingsLoading: true, settings: null, settingsLoading: true };
+}
+
+function createAppPreferencesValue(): AppPreferencesContextValue {
+ const [preferences, setPreferences] = createStore(createInitialAppPreferencesState());
+
+ async function loadSettings() {
+ setPreferences("settingsLoading", true);
+
+ try {
+ setPreferences("settings", await getSettings());
+ } catch (error) {
+ logger.error("failed to load settings", { keyValues: { error: String(error) } });
+ } finally {
+ setPreferences("settingsLoading", false);
+ }
+ }
+
+ async function updateSetting(key: keyof AppSettings, value: string | boolean | number) {
+ const serialized = typeof value === "boolean" ? (value ? "1" : "0") : String(value);
+
+ try {
+ await updateSettingRequest(key, serialized);
+
+ setPreferences("settings", (current) => {
+ if (!current) {
+ return current;
+ }
+
+ return { ...current, [key]: value };
+ });
+ } catch (error) {
+ logger.error("failed to update setting", { keyValues: { key, error: String(error) } });
+ }
+ }
+
+ async function loadEmbeddingsConfig() {
+ setPreferences("embeddingsLoading", true);
+
+ try {
+ const nextConfig = await getEmbeddingsConfig();
+ setPreferences("embeddingsConfig", nextConfig);
+ setPreferences("settings", (current) => {
+ if (!current) {
+ return current;
+ }
+
+ return { ...current, embeddingsEnabled: nextConfig.enabled };
+ });
+ } catch (error) {
+ logger.error("failed to load embeddings config", { keyValues: { error: String(error) } });
+ } finally {
+ setPreferences("embeddingsLoading", false);
+ }
+ }
+
+ async function prepareEmbeddingsModel() {
+ try {
+ const nextConfig = await prepareEmbeddingsModelRequest();
+ setPreferences("embeddingsConfig", nextConfig);
+ setPreferences("settings", (current) => {
+ if (!current) {
+ return current;
+ }
+
+ return { ...current, embeddingsEnabled: nextConfig.enabled };
+ });
+ } catch (error) {
+ logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } });
+ }
+ }
+
+ async function setEmbeddingsEnabled(enabled: boolean) {
+ try {
+ await setEmbeddingsEnabledRequest(enabled);
+ setPreferences("settings", (current) => {
+ if (!current) {
+ return current;
+ }
+
+ return { ...current, embeddingsEnabled: enabled };
+ });
+ await loadEmbeddingsConfig();
+ } catch (error) {
+ logger.error("failed to set embeddings enabled", {
+ keyValues: { enabled: String(enabled), error: String(error) },
+ });
+ }
+ }
+
+ async function refresh() {
+ await Promise.all([loadSettings(), loadEmbeddingsConfig()]);
+ }
+
+ onMount(() => {
+ void refresh();
+ });
+
+ return {
+ get embeddingsConfig() {
+ return preferences.embeddingsConfig;
+ },
+ get embeddingsEnabled() {
+ return preferences.embeddingsConfig?.enabled ?? preferences.settings?.embeddingsEnabled ?? true;
+ },
+ get embeddingsLoading() {
+ return preferences.embeddingsLoading;
+ },
+ get settings() {
+ return preferences.settings;
+ },
+ get settingsLoading() {
+ return preferences.settingsLoading;
+ },
+ loadEmbeddingsConfig,
+ loadSettings,
+ prepareEmbeddingsModel,
+ refresh,
+ setEmbeddingsEnabled,
+ updateSetting,
+ };
+}
+
+export function AppPreferencesProvider(props: ParentProps) {
+ const value = createAppPreferencesValue();
+
+ return {props.children};
+}
+
+export function AppPreferencesContextProvider(props: ParentProps<{ value: AppPreferencesContextValue }>) {
+ const [local] = splitProps(props, ["children", "value"]);
+ const value = untrack(() => local.value);
+
+ return {local.children};
+}
+
+export function useAppPreferences() {
+ const context = useContext(AppPreferencesContext);
+ if (!context) {
+ throw new Error("useAppPreferences must be used within an AppPreferencesProvider");
+ }
+
+ return context;
+}
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
new file mode 100644
index 0000000..456ad8e
--- /dev/null
+++ b/src/lib/api/settings.ts
@@ -0,0 +1,37 @@
+import type { AppSettings, CacheClearScope, CacheSize, ExportFormat, LogEntry, LogLevelFilter } from "$/lib/types";
+import { invoke } from "@tauri-apps/api/core";
+import * as logger from "@tauri-apps/plugin-log";
+import { normalizeError } from "../utils/text";
+export function getSettings() {
+ return invoke("get_settings");
+}
+
+export function updateSetting(key: string, value: string) {
+ return invoke("update_setting", { key, value });
+}
+
+export function getCacheSize() {
+ return invoke("get_cache_size");
+}
+
+export function clearCache(scope: CacheClearScope) {
+ return invoke("clear_cache", { scope });
+}
+
+export async function exportData(format: ExportFormat, path?: string) {
+ try {
+ const now = Date.now();
+ await invoke("export_data", { format, path: path ?? `lazurite_${now}_export.${format}` });
+ } catch (err) {
+ logger.error("failed to export data", { keyValues: { error: normalizeError(err) } });
+ }
+}
+
+export function resetApp() {
+ return invoke("reset_app");
+}
+
+export function getLogEntries(limit: number, level?: LogLevelFilter) {
+ const filterLevel = level === "all" ? null : level;
+ return invoke("get_log_entries", { limit, level: filterLevel });
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 34f4bb0..9923e15 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -200,3 +200,31 @@ export type ReplyRefInput = { parent: StrongRefInput; root: StrongRefInput };
export type EmbedInput = { type: "record"; record: StrongRefInput };
export type CreateRecordResult = { cid: string; uri: string };
+
+export type AppSettings = {
+ theme: string;
+ timelineRefreshSecs: number;
+ notificationsDesktop: boolean;
+ notificationsBadge: boolean;
+ notificationsSound: boolean;
+ embeddingsEnabled: boolean;
+ constellationUrl: string;
+ spacedustUrl: string;
+ spacedustInstant: boolean;
+ spacedustEnabled: boolean;
+ globalShortcut: string;
+};
+
+export type CacheSize = { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes: number };
+
+export type LogEntry = { timestamp: string | null; level: string; target: string | null; message: string };
+
+export type CacheClearScope = "all" | "feeds" | "embeddings" | "fts";
+
+export type ExportFormat = "json" | "csv";
+
+export type LogLevelFilter = "all" | "info" | "warn" | "error";
+
+export type RefreshInterval = 30 | 60 | 120 | 300 | 0;
+
+export type Theme = "light" | "dark" | "auto";
diff --git a/src/lib/utils/text.ts b/src/lib/utils/text.ts
index 8713da6..6ddade8 100644
--- a/src/lib/utils/text.ts
+++ b/src/lib/utils/text.ts
@@ -41,3 +41,11 @@ export function formatProgress(value: number | null | undefined) {
return `${Math.round(value)}%`;
}
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
+}
diff --git a/src/router.tsx b/src/router.tsx
index 11d0c91..bcd9ffc 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -1,21 +1,16 @@
import { useAppSession } from "$/contexts/app-session";
import { useAppShellUi } from "$/contexts/app-shell-ui";
-import {
- HashRouter,
- Navigate,
- Route,
- type RouteSectionProps,
- useLocation,
- useNavigate,
- useParams,
-} from "@solidjs/router";
+import { HashRouter, Navigate, Route, useLocation, useNavigate, useParams } from "@solidjs/router";
+import type { RouteSectionProps } from "@solidjs/router";
import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js";
import { Dynamic } from "solid-js/web";
import { ExplorerPanel } from "./components/explorer/ExplorerPanel";
import { SearchPanel } from "./components/search/SearchPanel";
+import { SettingsPanel } from "./components/settings/SettingsPanel";
import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds";
type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } };
+
type AppShellProps = ParentProps<{ fullWidth?: boolean }>;
type AppRouterProps = {
@@ -91,6 +86,12 @@ export function AppRouter(props: AppRouterProps) {
);
+ const SettingsRoute = () => (
+
+
+
+ );
+
const NotFoundRoute = () => (
}>
@@ -107,6 +108,7 @@ export function AppRouter(props: AppRouterProps) {
+
);
diff --git a/src/test/providers.tsx b/src/test/providers.tsx
index 95c8a1f..ed48737 100644
--- a/src/test/providers.tsx
+++ b/src/test/providers.tsx
@@ -1,3 +1,4 @@
+import { AppPreferencesContextProvider, type AppPreferencesContextValue } from "$/contexts/app-preferences";
import { AppSessionContextProvider, type AppSessionContextValue } from "$/contexts/app-session";
import { AppShellUiContextProvider, type AppShellUiContextValue } from "$/contexts/app-shell-ui";
import type { AccountSummary, ActiveSession } from "$/lib/types";
@@ -15,6 +16,28 @@ const DEFAULT_ACCOUNT: AccountSummary = {
function noop() {}
+const DEFAULT_SETTINGS = {
+ theme: "auto",
+ timelineRefreshSecs: 60,
+ notificationsDesktop: true,
+ notificationsBadge: true,
+ notificationsSound: false,
+ embeddingsEnabled: true,
+ constellationUrl: "https://constellation.microcosm.blue",
+ spacedustUrl: "https://spacedust.microcosm.blue",
+ spacedustInstant: false,
+ spacedustEnabled: false,
+ globalShortcut: "Ctrl+Shift+N",
+};
+
+const DEFAULT_EMBEDDINGS_CONFIG = {
+ enabled: true,
+ modelName: "nomic-embed-text-v1.5",
+ dimensions: 768,
+ downloaded: true,
+ downloadActive: false,
+};
+
export function createAppSessionTestValue(overrides: Partial = {}): AppSessionContextValue {
const accounts = overrides.accounts ?? [DEFAULT_ACCOUNT];
const activeSession = overrides.activeSession === undefined ? DEFAULT_SESSION : overrides.activeSession;
@@ -68,16 +91,44 @@ export function createAppShellUiTestValue(overrides: Partial = {},
+): AppPreferencesContextValue {
+ return {
+ embeddingsConfig: overrides.embeddingsConfig ?? DEFAULT_EMBEDDINGS_CONFIG,
+ embeddingsEnabled: overrides.embeddingsEnabled ?? overrides.embeddingsConfig?.enabled
+ ?? DEFAULT_EMBEDDINGS_CONFIG.enabled,
+ embeddingsLoading: overrides.embeddingsLoading ?? false,
+ settings: overrides.settings ?? DEFAULT_SETTINGS,
+ settingsLoading: overrides.settingsLoading ?? false,
+ loadEmbeddingsConfig: overrides.loadEmbeddingsConfig ?? (async () => {}),
+ loadSettings: overrides.loadSettings ?? (async () => {}),
+ prepareEmbeddingsModel: overrides.prepareEmbeddingsModel ?? (async () => {}),
+ refresh: overrides.refresh ?? (async () => {}),
+ setEmbeddingsEnabled: overrides.setEmbeddingsEnabled ?? (async () => {}),
+ updateSetting: overrides.updateSetting ?? (async () => {}),
+ };
+}
+
export function AppTestProviders(
- props: ParentProps<{ session?: Partial; shell?: Partial }>,
+ props: ParentProps<
+ {
+ preferences?: Partial;
+ session?: Partial;
+ shell?: Partial;
+ }
+ >,
) {
- const [local] = splitProps(props, ["children", "session", "shell"]);
+ const [local] = splitProps(props, ["children", "preferences", "session", "shell"]);
+ const preferencesValue = createAppPreferencesTestValue(untrack(() => local.preferences));
const sessionValue = createAppSessionTestValue(untrack(() => local.session));
const shellValue = createAppShellUiTestValue(untrack(() => local.shell));
return (
-
- {local.children}
-
+
+
+ {local.children}
+
+
);
}