BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add download directory settings

+272
+1
package.json
··· 21 21 "@solidjs/router": "^0.16.1", 22 22 "@tauri-apps/api": "^2", 23 23 "@tauri-apps/plugin-deep-link": "~2.4.7", 24 + "@tauri-apps/plugin-dialog": "~2.7.0", 24 25 "@tauri-apps/plugin-log": "~2", 25 26 "@tauri-apps/plugin-notification": "~2.3.3", 26 27 "@tauri-apps/plugin-opener": "^2",
+10
pnpm-lock.yaml
··· 20 20 '@tauri-apps/plugin-deep-link': 21 21 specifier: ~2.4.7 22 22 version: 2.4.7 23 + '@tauri-apps/plugin-dialog': 24 + specifier: ~2.7.0 25 + version: 2.7.0 23 26 '@tauri-apps/plugin-log': 24 27 specifier: ~2 25 28 version: 2.8.0 ··· 860 863 861 864 '@tauri-apps/plugin-deep-link@2.4.7': 862 865 resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==} 866 + 867 + '@tauri-apps/plugin-dialog@2.7.0': 868 + resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==} 863 869 864 870 '@tauri-apps/plugin-log@2.8.0': 865 871 resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} ··· 3370 3376 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 3371 3377 3372 3378 '@tauri-apps/plugin-deep-link@2.4.7': 3379 + dependencies: 3380 + '@tauri-apps/api': 2.10.1 3381 + 3382 + '@tauri-apps/plugin-dialog@2.7.0': 3373 3383 dependencies: 3374 3384 '@tauri-apps/api': 2.10.1 3375 3385
+1
src/components/search/EmbeddingsSettings.test.tsx
··· 53 53 spacedustInstant: false, 54 54 spacedustEnabled: false, 55 55 globalShortcut: "Ctrl+Shift+N", 56 + downloadDirectory: "/Users/test/Downloads", 56 57 }); 57 58 getEmbeddingsConfigMock.mockResolvedValue({ 58 59 enabled: false,
+188
src/components/settings/SettingsDownloads.tsx
··· 1 + import { getDownloadDirectory, setDownloadDirectory } from "$/lib/api/media"; 2 + import type { AppSettings } from "$/lib/types"; 3 + import { normalizeError } from "$/lib/utils/text"; 4 + import { open } from "@tauri-apps/plugin-dialog"; 5 + import * as logger from "@tauri-apps/plugin-log"; 6 + import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; 7 + import { Icon } from "../shared/Icon"; 8 + import { SettingsCard } from "./SettingsCard"; 9 + 10 + type SettingsDownloadsProps = { settings: AppSettings | null }; 11 + 12 + type DirectoryFeedback = { kind: "error" | "success"; message: string }; 13 + 14 + export function SettingsDownloads(props: SettingsDownloadsProps) { 15 + const [directory, setDirectory] = createSignal(""); 16 + const [pending, setPending] = createSignal(false); 17 + const [feedback, setFeedback] = createSignal<DirectoryFeedback | null>(null); 18 + let feedbackTimer: ReturnType<typeof setTimeout> | null = null; 19 + 20 + createEffect(() => { 21 + const currentDirectory = directory(); 22 + const settingsDirectory = props.settings?.downloadDirectory ?? ""; 23 + if (!currentDirectory && settingsDirectory) { 24 + setDirectory(settingsDirectory); 25 + } 26 + }); 27 + 28 + onMount(() => { 29 + void refreshDirectory(); 30 + }); 31 + 32 + onCleanup(() => { 33 + if (feedbackTimer !== null) { 34 + clearTimeout(feedbackTimer); 35 + } 36 + }); 37 + 38 + function dismissFeedback() { 39 + setFeedback(null); 40 + if (feedbackTimer !== null) { 41 + clearTimeout(feedbackTimer); 42 + feedbackTimer = null; 43 + } 44 + } 45 + 46 + function queueFeedback(nextFeedback: DirectoryFeedback) { 47 + dismissFeedback(); 48 + setFeedback(nextFeedback); 49 + feedbackTimer = setTimeout(() => { 50 + setFeedback(null); 51 + feedbackTimer = null; 52 + }, 5000); 53 + } 54 + 55 + async function refreshDirectory() { 56 + try { 57 + setDirectory(await getDownloadDirectory()); 58 + } catch (error) { 59 + logger.error("failed to load download directory", { keyValues: { error: normalizeError(error) } }); 60 + queueFeedback({ kind: "error", message: "Couldn't load your download folder." }); 61 + } 62 + } 63 + 64 + async function browseForDirectory() { 65 + if (pending()) { 66 + return; 67 + } 68 + 69 + setPending(true); 70 + dismissFeedback(); 71 + try { 72 + const selected = await open({ defaultPath: directory() || undefined, directory: true, multiple: false }); 73 + const nextDirectory = coerceDirectorySelection(selected); 74 + if (!nextDirectory) { 75 + return; 76 + } 77 + 78 + await setDownloadDirectory(nextDirectory); 79 + await refreshDirectory(); 80 + queueFeedback({ kind: "success", message: "Download folder updated." }); 81 + } catch (error) { 82 + logger.error("failed to set download directory", { keyValues: { error: normalizeError(error) } }); 83 + queueFeedback({ kind: "error", message: toDirectoryErrorMessage(error) }); 84 + } finally { 85 + setPending(false); 86 + } 87 + } 88 + 89 + async function resetToDefaultDirectory() { 90 + if (pending()) { 91 + return; 92 + } 93 + 94 + setPending(true); 95 + dismissFeedback(); 96 + try { 97 + await setDownloadDirectory("~/Downloads"); 98 + await refreshDirectory(); 99 + queueFeedback({ kind: "success", message: "Download folder reset to default." }); 100 + } catch (error) { 101 + logger.error("failed to reset download directory", { keyValues: { error: normalizeError(error) } }); 102 + queueFeedback({ kind: "error", message: toDirectoryErrorMessage(error) }); 103 + } finally { 104 + setPending(false); 105 + } 106 + } 107 + 108 + return ( 109 + <SettingsCard icon="download" title="Downloads"> 110 + <div class="grid gap-4"> 111 + <div> 112 + <p class="text-sm font-medium text-on-surface">Download folder</p> 113 + <p class="text-xs text-on-surface-variant">Images and videos are saved here.</p> 114 + </div> 115 + 116 + <div class="grid gap-2"> 117 + <div class="flex flex-wrap items-center gap-2"> 118 + <input 119 + type="text" 120 + readOnly 121 + value={directory()} 122 + placeholder="Loading download folder..." 123 + class="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-on-surface outline-none" /> 124 + <button 125 + type="button" 126 + disabled={pending()} 127 + onClick={() => void browseForDirectory()} 128 + class="rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/5 disabled:cursor-wait disabled:opacity-60"> 129 + {pending() ? "Saving..." : "Browse"} 130 + </button> 131 + </div> 132 + 133 + <button 134 + type="button" 135 + disabled={pending()} 136 + onClick={() => void resetToDefaultDirectory()} 137 + class="w-fit border-0 bg-transparent p-0 text-xs font-medium text-primary transition hover:text-primary-dim disabled:cursor-wait disabled:opacity-60"> 138 + Reset to default 139 + </button> 140 + </div> 141 + 142 + <Show when={feedback()}> 143 + {(currentFeedback) => ( 144 + <div 145 + role={currentFeedback().kind === "error" ? "alert" : "status"} 146 + aria-live={currentFeedback().kind === "error" ? "assertive" : "polite"} 147 + class="inline-flex w-fit items-center gap-2 rounded-full bg-surface-container-high px-3 py-1.5 text-sm" 148 + classList={{ 149 + "text-emerald-300": currentFeedback().kind === "success", 150 + "text-red-300": currentFeedback().kind === "error", 151 + }}> 152 + <Icon kind={currentFeedback().kind === "success" ? "complete" : "danger"} aria-hidden="true" /> 153 + <span>{currentFeedback().message}</span> 154 + </div> 155 + )} 156 + </Show> 157 + </div> 158 + </SettingsCard> 159 + ); 160 + } 161 + 162 + function coerceDirectorySelection(selection: string | string[] | null): string | null { 163 + if (!selection) { 164 + return null; 165 + } 166 + 167 + if (typeof selection === "string") { 168 + const trimmed = selection.trim(); 169 + return trimmed.length > 0 ? trimmed : null; 170 + } 171 + 172 + const first = selection[0]; 173 + if (typeof first !== "string") { 174 + return null; 175 + } 176 + 177 + const trimmed = first.trim(); 178 + return trimmed.length > 0 ? trimmed : null; 179 + } 180 + 181 + function toDirectoryErrorMessage(error: unknown) { 182 + const message = normalizeError(error); 183 + if (/download|directory|folder|writable|exists/iu.test(message)) { 184 + return "Couldn't save — choose an existing writable folder."; 185 + } 186 + 187 + return "Couldn't update the download folder."; 188 + }
+64
src/components/settings/SettingsPanel.test.tsx
··· 11 11 const resetAppMock = vi.hoisted(() => vi.fn()); 12 12 const resetAndRestartAppMock = vi.hoisted(() => vi.fn()); 13 13 const getLogEntriesMock = vi.hoisted(() => vi.fn()); 14 + const getDownloadDirectoryMock = vi.hoisted(() => vi.fn()); 15 + const setDownloadDirectoryMock = vi.hoisted(() => vi.fn()); 16 + const dialogOpenMock = vi.hoisted(() => vi.fn()); 14 17 const navigateMock = vi.hoisted(() => vi.fn()); 15 18 const infoMock = vi.hoisted(() => vi.fn()); 16 19 ··· 38 41 }), 39 42 ); 40 43 44 + vi.mock( 45 + "$/lib/api/media", 46 + () => ({ getDownloadDirectory: getDownloadDirectoryMock, setDownloadDirectory: setDownloadDirectoryMock }), 47 + ); 48 + 49 + vi.mock("@tauri-apps/plugin-dialog", () => ({ open: dialogOpenMock })); 50 + 41 51 vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 42 52 43 53 vi.mock("@tauri-apps/plugin-log", () => ({ info: infoMock })); ··· 55 65 spacedustInstant: false, 56 66 spacedustEnabled: false, 57 67 globalShortcut: "Ctrl+Shift+N", 68 + downloadDirectory: "/Users/test/Downloads", 58 69 ...overrides, 59 70 }; 60 71 } ··· 101 112 exportDataMock.mockResolvedValue(void 0); 102 113 resetAppMock.mockResolvedValue(void 0); 103 114 resetAndRestartAppMock.mockResolvedValue(void 0); 115 + getDownloadDirectoryMock.mockResolvedValue("/Users/test/Downloads"); 116 + setDownloadDirectoryMock.mockResolvedValue(void 0); 117 + dialogOpenMock.mockResolvedValue(null); 104 118 }); 105 119 106 120 it("loads and displays settings", async () => { ··· 113 127 expect(await screen.findByText("Accounts")).toBeInTheDocument(); 114 128 expect(await screen.findByText("Services")).toBeInTheDocument(); 115 129 expect(await screen.findByText("Data")).toBeInTheDocument(); 130 + expect(await screen.findByText("Downloads")).toBeInTheDocument(); 116 131 expect(await screen.findByText("Danger Zone")).toBeInTheDocument(); 117 132 expect(await screen.findByText("Logs")).toBeInTheDocument(); 118 133 expect(await screen.findByText("About")).toBeInTheDocument(); 119 134 expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(1); 135 + }); 136 + 137 + it("places downloads between data and danger zone", async () => { 138 + renderSettingsPanel(); 139 + await screen.findByText("Settings"); 140 + 141 + const headings = await screen.findAllByRole("heading", { level: 2 }); 142 + const titles = headings.map((heading) => heading.textContent?.trim() ?? ""); 143 + const dataIndex = titles.indexOf("Data"); 144 + const downloadsIndex = titles.indexOf("Downloads"); 145 + const dangerIndex = titles.indexOf("Danger Zone"); 146 + 147 + expect(dataIndex).toBeGreaterThanOrEqual(0); 148 + expect(downloadsIndex).toBeGreaterThanOrEqual(0); 149 + expect(dangerIndex).toBeGreaterThanOrEqual(0); 150 + expect(dataIndex).toBeLessThan(downloadsIndex); 151 + expect(downloadsIndex).toBeLessThan(dangerIndex); 120 152 }); 121 153 122 154 it("displays cache size information", async () => { ··· 205 237 206 238 fireEvent.click(csvButton); 207 239 await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("csv")); 240 + }); 241 + 242 + it("allows selecting the download folder from the directory picker", async () => { 243 + getDownloadDirectoryMock.mockResolvedValueOnce("/Users/test/Downloads").mockResolvedValueOnce( 244 + "/Users/test/Pictures", 245 + ); 246 + dialogOpenMock.mockResolvedValue("/Users/test/Pictures"); 247 + 248 + renderSettingsPanel(); 249 + 250 + await screen.findByText("Settings"); 251 + const browseButton = await screen.findByRole("button", { name: /browse/i }); 252 + fireEvent.click(browseButton); 253 + 254 + await waitFor(() => expect(setDownloadDirectoryMock).toHaveBeenCalledWith("/Users/test/Pictures")); 255 + await waitFor(() => expect(screen.getByDisplayValue("/Users/test/Pictures")).toBeInTheDocument()); 256 + expect(await screen.findByText("Download folder updated.")).toBeInTheDocument(); 257 + }); 258 + 259 + it("resets the download folder to the default path", async () => { 260 + getDownloadDirectoryMock.mockResolvedValueOnce("/Users/test/Pictures").mockResolvedValueOnce( 261 + "/Users/test/Downloads", 262 + ); 263 + 264 + renderSettingsPanel(); 265 + 266 + await screen.findByText("Settings"); 267 + const resetButton = await screen.findByRole("button", { name: /reset to default/i }); 268 + fireEvent.click(resetButton); 269 + 270 + await waitFor(() => expect(setDownloadDirectoryMock).toHaveBeenCalledWith("~/Downloads")); 271 + expect(await screen.findByText("Download folder reset to default.")).toBeInTheDocument(); 208 272 }); 209 273 210 274 it("shows confirmation modal with RESET text for app reset and restart", async () => {
+2
src/components/settings/SettingsPanel.tsx
··· 21 21 import { AccountControl } from "./SettingsAccount"; 22 22 import { SettingsDangerZone } from "./SettingsDangerZone"; 23 23 import { SettingsData } from "./SettingsData"; 24 + import { SettingsDownloads } from "./SettingsDownloads"; 24 25 import { SettingsLogs } from "./SettingsLogs"; 25 26 import { NotificationsControl } from "./SettingsNotification"; 26 27 import { SettingsService } from "./SettingsService"; ··· 288 289 cacheSize={panel.cacheSize} 289 290 handleClearCache={handleClearCache} 290 291 openConfirmation={openConfirmation} /> 292 + <SettingsDownloads settings={settings()} /> 291 293 <SettingsDangerZone 292 294 handleResetAndRestartApp={handleResetAndRestartApp} 293 295 openConfirmation={openConfirmation} />
+4
src/components/shared/Icon.tsx
··· 7 7 export type SettingsIconKind = 8 8 | "computer" 9 9 | "danger" 10 + | "download" 10 11 | "info" 11 12 | "timeline" 12 13 | "db" ··· 218 219 </Match> 219 220 <Match when={local.kind === "theme"}> 220 221 <i class="i-ri-paint-line" /> 222 + </Match> 223 + <Match when={local.kind === "download"}> 224 + <i class="i-ri-download-2-line" /> 221 225 </Match> 222 226 </Switch> 223 227 </span>
+1
src/lib/types.ts
··· 266 266 spacedustInstant: boolean; 267 267 spacedustEnabled: boolean; 268 268 globalShortcut: string; 269 + downloadDirectory: string; 269 270 }; 270 271 271 272 export type CacheSize = { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes: number };
+1
src/test/providers.tsx
··· 28 28 spacedustInstant: false, 29 29 spacedustEnabled: false, 30 30 globalShortcut: "Ctrl+Shift+N", 31 + downloadDirectory: "/Users/test/Downloads", 31 32 }; 32 33 33 34 const DEFAULT_EMBEDDINGS_CONFIG = {