diff --git a/docs/tasks/06-settings.md b/docs/tasks/06-settings.md index c296e61..f5aa8ef 100644 --- a/docs/tasks/06-settings.md +++ b/docs/tasks/06-settings.md @@ -16,8 +16,8 @@ Spec: [settings.md](../specs/settings.md) ### Frontend - Settings View -- [ ] Settings route (`/settings`) accessible from app rail icon (`Icon` with kind `settings`) -- [ ] Section-based layout using `surface_container` cards with `lg` radius: +- [x] Settings route (`/settings`) accessible from app rail icon (`Icon` with kind `settings`) +- [x] Section-based layout using `surface_container` cards with `lg` radius: 1. **Appearance** - Theme toggle (light/dark/auto), `Motion` crossfade on theme switch 2. **Timeline** - Refresh interval selector (30s, 1m, 2m, 5m, manual) 3. **Notifications** - Toggle desktop notifications, badge count, notification sound @@ -26,6 +26,6 @@ Spec: [settings.md](../specs/settings.md) 6. **Logs** - Collapsible log viewer with level filtering (`info`, `warn`, `error`) 7. **Services** - Constellation instance URL, Spacedust instance URL 8. **About** - Version info, license (MIT), contributors, support links -- [ ] `Presence` slide transitions between setting sections -- [ ] Keyboard shortcut: `,` to open settings from anywhere -- [ ] Confirmation modal for destructive actions (clear cache, reset app, remove account) using glass overlay +- [x] `Presence` slide transitions between setting sections +- [x] Keyboard shortcut: `,` to open settings from anywhere +- [x] Confirmation modal for destructive actions (clear cache, reset app, remove account) using glass overlay diff --git a/src/App.tsx b/src/App.tsx index 56efc46..56342b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import "@fontsource-variable/google-sans"; +import { useNavigate } from "@solidjs/router"; import type { ParentProps } from "solid-js"; -import { Show } from "solid-js"; +import { createEffect, onCleanup, Show } from "solid-js"; import "./App.css"; import { AccountLedger } from "./components/account/AccountLedger"; import { AppRail } from "./components/AppRail"; @@ -12,6 +13,7 @@ import { NotificationsPanel } from "./components/notifications/NotificationsPane import { HeaderPanel } from "./components/panels/Header"; import { SessionSpotlight } from "./components/Session"; import { ErrorToast } from "./components/shared/ErrorToast"; +import { AppPreferencesProvider } from "./contexts/app-preferences"; import { AppSessionProvider, useAppSession } from "./contexts/app-session"; import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; import { AppRouter } from "./router"; @@ -20,9 +22,28 @@ const COMPOSER_WINDOW_LABEL = "composer"; type AppShellProps = ParentProps<{ fullWidth?: boolean }>; +function createSettingsShortcutHandler(hasSession: boolean, navigate: (path: string) => void) { + return (e: KeyboardEvent) => { + if (e.key === "," && !e.ctrlKey && !e.metaKey && !e.altKey) { + const activeElement = document.activeElement; + const isInputFocused = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement; + if (!isInputFocused && hasSession) { + navigate("/settings"); + } + } + }; +} + function AppShell(props: AppShellProps) { const session = useAppSession(); const shell = useAppShellUi(); + const navigate = useNavigate(); + + createEffect(() => { + const handler = createSettingsShortcutHandler(session.hasSession, navigate); + globalThis.addEventListener("keydown", handler); + onCleanup(() => globalThis.removeEventListener("keydown", handler)); + }); return ( <> @@ -99,9 +120,11 @@ function AppContent() { function App() { return ( - - - + + + + + ); } diff --git a/src/components/AppRail.tsx b/src/components/AppRail.tsx index 3f27240..012abfa 100644 --- a/src/components/AppRail.tsx +++ b/src/components/AppRail.tsx @@ -42,6 +42,7 @@ function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unread label="Notifications" icon="notifications" /> + ); diff --git a/src/components/search/EmbeddingsSettings.test.tsx b/src/components/search/EmbeddingsSettings.test.tsx index bb59a63..165c7d9 100644 --- a/src/components/search/EmbeddingsSettings.test.tsx +++ b/src/components/search/EmbeddingsSettings.test.tsx @@ -1,3 +1,4 @@ +import { AppPreferencesProvider } from "$/contexts/app-preferences"; import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { EmbeddingsSettings } from "./EmbeddingsSettings"; @@ -5,6 +6,8 @@ import { EmbeddingsSettings } from "./EmbeddingsSettings"; const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); +const getSettingsMock = vi.hoisted(() => vi.fn()); +const updateSettingMock = vi.hoisted(() => vi.fn()); vi.mock( "$/lib/api/search", @@ -15,14 +18,39 @@ vi.mock( }), ); +vi.mock("$/lib/api/settings", () => ({ getSettings: getSettingsMock, updateSetting: updateSettingMock })); + vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); +function renderEmbeddingsSettings() { + render(() => ( + + + + )); +} + describe("EmbeddingsSettings", () => { beforeEach(() => { getEmbeddingsConfigMock.mockReset(); prepareEmbeddingsModelMock.mockReset(); setEmbeddingsEnabledMock.mockReset(); - + getSettingsMock.mockReset(); + updateSettingMock.mockReset(); + + getSettingsMock.mockResolvedValue({ + 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", + }); getEmbeddingsConfigMock.mockResolvedValue({ enabled: true, modelName: "nomic-embed-text-v1.5", @@ -41,7 +69,7 @@ describe("EmbeddingsSettings", () => { }); it("renders embeddings settings with model info", async () => { - render(() => ); + renderEmbeddingsSettings(); expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); @@ -49,7 +77,7 @@ describe("EmbeddingsSettings", () => { }); it("shows toggle in enabled state when embeddings are enabled", async () => { - render(() => ); + renderEmbeddingsSettings(); const toggle = await screen.findByRole("switch"); expect(toggle).toHaveAttribute("aria-checked", "true"); @@ -64,7 +92,7 @@ describe("EmbeddingsSettings", () => { downloadActive: false, }); - render(() => ); + renderEmbeddingsSettings(); const toggle = await screen.findByRole("switch"); expect(toggle).toHaveAttribute("aria-checked", "false"); @@ -85,7 +113,7 @@ describe("EmbeddingsSettings", () => { downloadActive: false, }); - render(() => ); + renderEmbeddingsSettings(); const toggle = await screen.findByRole("switch"); expect(toggle).toHaveAttribute("aria-checked", "true"); @@ -114,32 +142,32 @@ describe("EmbeddingsSettings", () => { downloadFileTotal: 5, }); - render(() => ); + renderEmbeddingsSettings(); expect(await screen.findAllByText(/downloading model files/i)).toHaveLength(2); expect(await screen.findByText(/0%/)).toBeInTheDocument(); }); it("displays semantic search description", async () => { - render(() => ); + renderEmbeddingsSettings(); expect(await screen.findByText(/conceptually similar posts/i)).toBeInTheDocument(); }); it("handles errors when loading config gracefully", async () => { getEmbeddingsConfigMock.mockRejectedValue(new Error("Failed to load")); - render(() => ); + renderEmbeddingsSettings(); - // Should still render without crashing await waitFor(() => { expect(getEmbeddingsConfigMock).toHaveBeenCalled(); }); + expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); }); it("handles errors when toggling gracefully", async () => { setEmbeddingsEnabledMock.mockRejectedValue(new Error("Failed to save")); - render(() => ); + renderEmbeddingsSettings(); const toggle = await screen.findByRole("switch"); fireEvent.click(toggle); @@ -148,7 +176,6 @@ describe("EmbeddingsSettings", () => { expect(setEmbeddingsEnabledMock).toHaveBeenCalled(); }); - // Toggle state should remain unchanged on error expect(toggle).toHaveAttribute("aria-checked", "true"); }); }); diff --git a/src/components/search/EmbeddingsSettings.tsx b/src/components/search/EmbeddingsSettings.tsx index f984cbb..86af0f3 100644 --- a/src/components/search/EmbeddingsSettings.tsx +++ b/src/components/search/EmbeddingsSettings.tsx @@ -1,9 +1,8 @@ /* eslint react/jsx-max-depth: ["error", { "max": 5 }] */ import { Icon } from "$/components/shared/Icon"; +import { useAppPreferences } from "$/contexts/app-preferences"; import type { EmbeddingsConfig } from "$/lib/api/search"; -import { getEmbeddingsConfig, prepareEmbeddingsModel, setEmbeddingsEnabled } from "$/lib/api/search"; import { formatEtaSeconds, formatProgress } from "$/lib/utils/text"; -import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { Motion, Presence } from "solid-motionone"; @@ -123,45 +122,13 @@ function StatusLabelWithIcon(props: { config: EmbeddingsConfig | null }) { ); } -type EmbeddingsSettingsProps = { onConfigChange?: (config: EmbeddingsConfig) => void }; - -export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { - const [config, setConfig] = createSignal(null); - const [loading, setLoading] = createSignal(true); +export function EmbeddingsSettings() { + const preferences = useAppPreferences(); const [autoPrepareStarted, setAutoPrepareStarted] = createSignal(false); - - async function loadConfig() { - try { - setLoading(true); - const nextConfig = await getEmbeddingsConfig(); - setConfig(nextConfig); - props.onConfigChange?.(nextConfig); - } catch (error) { - logger.error("failed to load embeddings config", { keyValues: { error: String(error) } }); - } finally { - setLoading(false); - } - } - - async function refreshConfig() { - try { - const nextConfig = await getEmbeddingsConfig(); - setConfig(nextConfig); - props.onConfigChange?.(nextConfig); - } catch (error) { - logger.error("failed to refresh embeddings config", { keyValues: { error: String(error) } }); - } - } + const config = () => preferences.embeddingsConfig; async function prepareModel() { - try { - const nextConfig = await prepareEmbeddingsModel(); - setConfig(nextConfig); - props.onConfigChange?.(nextConfig); - } catch (error) { - logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } }); - await refreshConfig(); - } + await preferences.prepareEmbeddingsModel(); } async function handleToggle() { @@ -171,15 +138,9 @@ export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { } const nextEnabled = !current.enabled; - try { - await setEmbeddingsEnabled(nextEnabled); - if (!nextEnabled) { - setAutoPrepareStarted(false); - } - - await loadConfig(); - } catch (error) { - logger.error("failed to set embeddings enabled", { keyValues: { error: String(error) } }); + await preferences.setEmbeddingsEnabled(nextEnabled); + if (!nextEnabled) { + setAutoPrepareStarted(false); } } @@ -195,7 +156,9 @@ export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { }); createEffect(() => { - void loadConfig(); + if (!config() && !preferences.embeddingsLoading) { + void preferences.loadEmbeddingsConfig(); + } }); createEffect(() => { @@ -219,8 +182,8 @@ export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { onMount(() => { const interval = setInterval(() => { - if (config()?.downloadActive) { - void refreshConfig(); + if (preferences.embeddingsConfig?.downloadActive) { + void preferences.loadEmbeddingsConfig(); } }, 1000); @@ -229,7 +192,7 @@ export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { return (
- + diff --git a/src/components/search/SearchEmptyState.tsx b/src/components/search/SearchEmptyState.tsx index 462969a..d234ac5 100644 --- a/src/components/search/SearchEmptyState.tsx +++ b/src/components/search/SearchEmptyState.tsx @@ -1,13 +1,13 @@ import { Icon } from "$/components/shared/Icon"; import { Match, Show, Switch } from "solid-js"; -type SearchEmptyStateProps = { reason: "initial" | "no-results" | "no-sync" }; +type SearchEmptyStateProps = { reason: "error" | "initial" | "no-results" | "no-sync"; scope?: "local" | "network" }; export function SearchEmptyState(props: SearchEmptyStateProps) { return (
- +
); } @@ -24,31 +24,45 @@ function EmptyStateIcon(props: { reason: string }) { ); } -function EmptyStateContent(props: { reason: string }) { +function EmptyStateContent(props: { reason: string; scope: "local" | "network" }) { return ( - + - + + + + + ); } -function InitialContent() { +function InitialContent(props: { scope: "local" | "network" }) { return ( <> -

Search your saved & liked posts

-

- Type a query above to search through the posts you liked or bookmarked. -

+ + +

Search public posts across the network

+

+ Type a query above to search Bluesky directly without relying on your local index. +

+
+ +

Search your saved & liked posts

+

+ Type a query above to search through the posts you liked or bookmarked. +

+
+
); @@ -69,13 +83,22 @@ function KeyboardShortcuts() { ); } -function NoResultsContent() { +function NoResultsContent(props: { scope: "local" | "network" }) { return ( <>

No results found

-

- Try adjusting your search terms or switch to a different search mode. -

+ + +

+ Try a broader query or switch to local search if you want to search your synced posts instead. +

+
+ +

+ Try adjusting your search terms or switch to a different search mode. +

+
+
); } @@ -90,3 +113,23 @@ function NoSyncContent() { ); } + +function ErrorContent(props: { scope: "local" | "network" }) { + return ( + <> +

Search failed

+ + +

+ The network request did not complete. Retry the query or switch to local search while the network recovers. +

+
+ +

+ The local index request did not complete. Retry the query or sync again if your index is stale. +

+
+
+ + ); +} diff --git a/src/components/search/SearchPanel.test.tsx b/src/components/search/SearchPanel.test.tsx index 8529efe..2aa76d8 100644 --- a/src/components/search/SearchPanel.test.tsx +++ b/src/components/search/SearchPanel.test.tsx @@ -7,16 +7,10 @@ const searchPostsMock = vi.hoisted(() => vi.fn()); const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); const getSyncStatusMock = vi.hoisted(() => vi.fn()); const syncPostsMock = vi.hoisted(() => vi.fn()); -const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); -const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); -const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); vi.mock( "$/lib/api/search", () => ({ - getEmbeddingsConfig: getEmbeddingsConfigMock, - prepareEmbeddingsModel: prepareEmbeddingsModelMock, - setEmbeddingsEnabled: setEmbeddingsEnabledMock, searchPosts: searchPostsMock, searchPostsNetwork: searchPostsNetworkMock, getSyncStatus: getSyncStatusMock, @@ -41,9 +35,6 @@ describe("SearchPanel", () => { searchPostsNetworkMock.mockReset(); getSyncStatusMock.mockReset(); syncPostsMock.mockReset(); - getEmbeddingsConfigMock.mockReset(); - prepareEmbeddingsModelMock.mockReset(); - setEmbeddingsEnabledMock.mockReset(); getSyncStatusMock.mockResolvedValue([]); syncPostsMock.mockResolvedValue({ @@ -52,27 +43,12 @@ describe("SearchPanel", () => { postCount: 100, lastSyncedAt: "2026-03-29T12:00:00.000Z", }); - getEmbeddingsConfigMock.mockResolvedValue({ - enabled: true, - modelName: "nomic-embed-text-v1.5", - dimensions: 768, - downloaded: true, - downloadActive: false, - }); - prepareEmbeddingsModelMock.mockResolvedValue({ - enabled: true, - modelName: "nomic-embed-text-v1.5", - dimensions: 768, - downloaded: true, - downloadActive: false, - }); - setEmbeddingsEnabledMock.mockResolvedValue(void 0); }); it("renders the search panel with initial state", async () => { renderSearchPanel(); - expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); + expect(await screen.findByPlaceholderText("Search public posts across Bluesky...")).toBeInTheDocument(); expect(screen.getByText("Network")).toBeInTheDocument(); expect(screen.getByText("Keyword")).toBeInTheDocument(); expect(screen.getByText("Semantic")).toBeInTheDocument(); @@ -103,7 +79,7 @@ describe("SearchPanel", () => { renderSearchPanel(); - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); + const input = await screen.findByRole("textbox"); fireEvent.input(input, { target: { value: "test query" } }); vi.advanceTimersByTime(350); @@ -136,7 +112,7 @@ describe("SearchPanel", () => { fireEvent.click(keywordButton); expect(keywordButton).toHaveAttribute("aria-pressed", "true"); - const input = screen.getByPlaceholderText("Search your saved & liked posts..."); + const input = screen.getByRole("textbox"); fireEvent.input(input, { target: { value: "test query" } }); await vi.advanceTimersByTimeAsync(350); @@ -150,7 +126,7 @@ describe("SearchPanel", () => { it("cycles through modes with Tab key", async () => { renderSearchPanel(); - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); + const input = await screen.findByRole("textbox"); input.focus(); fireEvent.keyDown(input, { key: "Tab" }); @@ -170,7 +146,7 @@ describe("SearchPanel", () => { renderSearchPanel(); - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); + const input = await screen.findByRole("textbox"); fireEvent.input(input, { target: { value: "test" } }); vi.advanceTimersByTime(350); @@ -188,7 +164,7 @@ describe("SearchPanel", () => { renderSearchPanel(); - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); + const input = await screen.findByRole("textbox"); fireEvent.input(input, { target: { value: "test" } }); vi.advanceTimersByTime(350); @@ -206,7 +182,7 @@ describe("SearchPanel", () => { const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); - const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); + const input = await screen.findByRole("textbox"); fireEvent.input(input, { target: { value: "nonexistent" } }); vi.advanceTimersByTime(350); diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx index ebccbc3..efc2268 100644 --- a/src/components/search/SearchPanel.tsx +++ b/src/components/search/SearchPanel.tsx @@ -1,8 +1,7 @@ import { Icon, SearchModeIcon } from "$/components/shared/Icon"; +import { useAppPreferences } from "$/contexts/app-preferences"; import { useAppSession } from "$/contexts/app-session"; import { - type EmbeddingsConfig, - getEmbeddingsConfig, type LocalPostResult, type NetworkSearchResult, type SearchMode, @@ -14,6 +13,7 @@ import { formatRelativeTime } from "$/lib/feeds"; import { normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { createStore } from "solid-js/store"; import { Motion, Presence } from "solid-motionone"; import { PostCount } from "../shared/PostCount"; import { EmbeddingsSettings } from "./EmbeddingsSettings"; @@ -23,6 +23,18 @@ import { SyncStatusPanel } from "./SyncStatusPanel"; const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; +type SearchPanelState = { + error: string | null; + hasSearched: boolean; + loading: boolean; + mode: SearchMode; + networkResults: NetworkSearchResult | null; + query: string; + resultCount: number; + results: LocalPostResult[]; + syncStatus: SyncStatus[]; +}; + function ModeLabel(props: { mode: SearchMode }) { return ( @@ -38,27 +50,31 @@ function ModeLabel(props: { mode: SearchMode }) { } export function SearchPanel() { + const preferences = useAppPreferences(); const session = useAppSession(); - const [mode, setMode] = createSignal("network"); - const [query, setQuery] = createSignal(""); - const [results, setResults] = createSignal([]); - const [networkResults, setNetworkResults] = createSignal(null); - const [loading, setLoading] = createSignal(false); - const [error, setError] = createSignal(null); - const [resultCount, setResultCount] = createSignal(0); - const [hasSearched, setHasSearched] = createSignal(false); - const [syncStatus, setSyncStatus] = createSignal([]); - const [embeddingsConfig, setEmbeddingsConfig] = createSignal(null); + const [search, setSearch] = createStore({ + error: null, + hasSearched: false, + loading: false, + mode: "network", + networkResults: null, + query: "", + resultCount: 0, + results: [], + syncStatus: [], + }); let searchInputRef: HTMLInputElement | undefined; let debounceTimer: ReturnType | undefined; - const isLocalMode = createMemo(() => mode() !== "network"); - const semanticEnabled = createMemo(() => embeddingsConfig()?.enabled ?? true); - const totalIndexedPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); + const isLocalMode = createMemo(() => search.mode !== "network"); + const semanticEnabled = createMemo(() => preferences.embeddingsEnabled); + const totalIndexedPosts = createMemo(() => + search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) + ); const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); const lastSync = createMemo(() => { - const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; + const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; if (timestamps.length === 0) { return null; } @@ -67,14 +83,6 @@ export function SearchPanel() { }); const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); - async function loadEmbeddingsConfig() { - try { - setEmbeddingsConfig(await getEmbeddingsConfig()); - } catch (err) { - logger.error("failed to load embeddings config", { keyValues: { error: normalizeError(err) } }); - } - } - async function performSearch(searchQuery: string, searchMode: SearchMode) { if (!searchQuery.trim()) { clearResults(); @@ -82,56 +90,44 @@ export function SearchPanel() { } if (searchMode === "semantic" && !semanticEnabled()) { - setError("Semantic search is disabled. Re-enable embeddings to use this mode."); - setHasSearched(true); - setResults([]); - setNetworkResults(null); - setResultCount(0); + setSearch({ + error: "Semantic search is disabled. Re-enable embeddings to use this mode.", + hasSearched: true, + networkResults: null, + resultCount: 0, + results: [], + }); return; } - setLoading(true); - setError(null); + setSearch({ error: null, loading: true }); try { if (searchMode === "network") { const response = await searchPostsNetwork(searchQuery, "top", 25); - setNetworkResults(response); - setResults([]); - setResultCount(response.posts.length); + setSearch({ hasSearched: true, networkResults: response, resultCount: response.posts.length, results: [] }); } else { const response = await searchPosts(searchQuery, searchMode, 50); - setResults(response); - setNetworkResults(null); - setResultCount(response.length); + setSearch({ hasSearched: true, networkResults: null, resultCount: response.length, results: response }); } - setHasSearched(true); - } catch (err) { - const errorMsg = normalizeError(err); - setError(errorMsg); - setResults([]); - setNetworkResults(null); - setResultCount(0); - setHasSearched(true); - logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } }); + } catch (error) { + const errorMessage = normalizeError(error); + setSearch({ error: errorMessage, hasSearched: true, networkResults: null, resultCount: 0, results: [] }); + logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMessage } }); } finally { - setLoading(false); + setSearch("loading", false); } } function clearResults() { - setResults([]); - setNetworkResults(null); - setResultCount(0); - setError(null); - setHasSearched(false); + setSearch({ error: null, hasSearched: false, networkResults: null, resultCount: 0, results: [] }); } function handleInput(value: string) { - setQuery(value); + setSearch("query", value); clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - void performSearch(value, mode()); + void performSearch(value, search.mode); }, 300); } @@ -140,23 +136,24 @@ export function SearchPanel() { return; } - setMode(newMode); - if (query().trim()) { - void performSearch(query(), newMode); - } else { - setError(null); + setSearch("mode", newMode); + if (search.query.trim()) { + void performSearch(search.query, newMode); + return; } + + setSearch("error", null); } function cycleMode() { const availableModes = cycleModes(); - const currentIndex = availableModes.indexOf(mode()); + const currentIndex = availableModes.indexOf(search.mode); const nextIndex = (currentIndex + 1) % availableModes.length; handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); } function clearSearch() { - setQuery(""); + setSearch("query", ""); clearResults(); searchInputRef?.focus(); } @@ -165,7 +162,10 @@ export function SearchPanel() { if (event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { event.preventDefault(); cycleMode(); - } else if (event.key === "Escape" && query()) { + return; + } + + if (event.key === "Escape" && search.query) { clearSearch(); } } @@ -182,7 +182,6 @@ export function SearchPanel() { onMount(() => { document.addEventListener("keydown", handleGlobalKeyDown); - void loadEmbeddingsConfig(); onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown); @@ -191,8 +190,11 @@ export function SearchPanel() { }); createEffect(() => { - if (mode() === "semantic" && !semanticEnabled()) { - setMode("keyword"); + if (search.mode === "semantic" && !semanticEnabled()) { + setSearch("mode", "keyword"); + if (search.query.trim()) { + void performSearch(search.query, "keyword"); + } } }); @@ -200,41 +202,39 @@ export function SearchPanel() {
{ searchInputRef = element; }} + lastSync={lastSync()} + loading={search.loading} + mode={search.mode} + onClear={clearSearch} onKeyDown={handleKeyDown} - onClear={clearSearch} /> + onModeChange={handleModeChange} + onQueryChange={handleInput} + query={search.query} + resultCount={search.resultCount} + semanticEnabled={semanticEnabled()} + totalIndexedPosts={totalIndexedPosts()} /> + loading={search.loading} + localResults={search.results} + networkResults={search.networkResults} + query={search.query} />
@@ -265,6 +265,9 @@ function SearchHeader( error={props.error} inputRef={props.inputRef} loading={props.loading} + placeholder={props.mode === "network" + ? "Search public posts across Bluesky..." + : "Search your saved & liked posts..."} query={props.query} onClear={props.onClear} onKeyDown={props.onKeyDown} @@ -282,22 +285,31 @@ function SearchHeader( + totalIndexedPosts={props.totalIndexedPosts} /> ); } function ResultMeta( - props: { hasSearched: boolean; lastSync: string | null; resultCount: number; totalIndexedPosts: number }, + props: { + hasSearched: boolean; + lastSync: string | null; + mode: SearchMode; + resultCount: number; + totalIndexedPosts: number; + }, ) { return (
+ fallback={props.mode === "network" + ? "Search public posts across Bluesky or switch to your synced archive." + : "Search your liked and bookmarked posts locally, or search the network."}> Found {props.resultCount} results @@ -318,6 +330,7 @@ function SearchInput( error: string | null; inputRef: (el: HTMLInputElement) => void; loading: boolean; + placeholder: string; query: string; onClear: () => void; onKeyDown: (event: KeyboardEvent) => void; @@ -335,7 +348,7 @@ function SearchInput( ref={props.inputRef} type="text" value={props.query} - placeholder="Search your saved & liked posts..." + placeholder={props.placeholder} class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" onInput={(event) => props.onQueryChange(event.currentTarget.value)} onKeyDown={(event) => props.onKeyDown(event)} /> @@ -446,7 +459,6 @@ function SearchViewport( isLocalMode: boolean; loading: boolean; localResults: LocalPostResult[]; - mode: SearchMode; networkResults: NetworkSearchResult | null; query: string; }, @@ -468,9 +480,7 @@ function SearchState( hasLocalPosts: boolean; hasSearched: boolean; isLocalMode: boolean; - loading: boolean; localResults: LocalPostResult[]; - mode: SearchMode; networkResults: NetworkSearchResult | null; query: string; }, @@ -479,23 +489,23 @@ function SearchState( - + - + - + - + - + @@ -510,7 +520,7 @@ function SearchState( ); } -function EmptyStateView(props: { reason: "initial" | "no-results" | "no-sync" }) { +function EmptyStateView(props: { reason: "error" | "initial" | "no-results" | "no-sync"; scope: "local" | "network" }) { return ( - + ); } @@ -603,7 +613,7 @@ function SearchTipsCard() { return (

Search Tips

-
+

/ 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.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} + + ); }