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 danger zone settings for app reset and restart functionality

+352 -166
+23 -20
docs/specs/settings.md
··· 17 17 18 18 ### Settings Keys 19 19 20 - | Key | Type | Default | Description | 21 - | ----------------------- | ------- | ------------------------------------------ | ------------------------------------------ | 22 - | `theme` | string | `"auto"` | `"light"`, `"dark"`, or `"auto"` (OS sync) | 23 - | `timeline_refresh_secs` | integer | `60` | Feed auto-refresh interval in seconds | 24 - | `notifications_desktop` | boolean | `true` | Show OS desktop notifications | 25 - | `notifications_badge` | boolean | `true` | Show unread badge on app icon / tray | 26 - | `notifications_sound` | boolean | `false` | Play sound on new notification | 27 - | `embeddings_enabled` | boolean | `false` | Enable optional semantic search | 28 - | `constellation_url` | string | `"https://constellation.microcosm.blue"` | Constellation instance base URL | 29 - | `spacedust_url` | string | `"https://spacedust.microcosm.blue"` | Spacedust instance base URL | 30 - | `spacedust_instant` | boolean | `false` | Bypass Spacedust 21-second debounce buffer | 31 - | `spacedust_enabled` | boolean | `false` | Use Spacedust for real-time notifications | 32 - | `global_shortcut` | string | `"Ctrl+Shift+N"` | Global composer shortcut | 20 + | Key | Type | Default | Description | 21 + | ----------------------- | ------- | ---------------------------------------- | ------------------------------------------ | 22 + | `theme` | string | `"auto"` | `"light"`, `"dark"`, or `"auto"` (OS sync) | 23 + | `timeline_refresh_secs` | integer | `60` | Feed auto-refresh interval in seconds | 24 + | `notifications_desktop` | boolean | `true` | Show OS desktop notifications | 25 + | `notifications_badge` | boolean | `true` | Show unread badge on app icon / tray | 26 + | `notifications_sound` | boolean | `false` | Play sound on new notification | 27 + | `embeddings_enabled` | boolean | `false` | Enable optional semantic search | 28 + | `constellation_url` | string | `"https://constellation.microcosm.blue"` | Constellation instance base URL | 29 + | `spacedust_url` | string | `"https://spacedust.microcosm.blue"` | Spacedust instance base URL | 30 + | `spacedust_instant` | boolean | `false` | Bypass Spacedust 21-second debounce buffer | 31 + | `spacedust_enabled` | boolean | `false` | Use Spacedust for real-time notifications | 32 + | `global_shortcut` | string | `"Ctrl+Shift+N"` | Global composer shortcut | 33 33 34 34 ## Tauri Commands 35 35 ··· 105 105 - **Cache size display**: breakdown by category (feeds, embeddings, FTS index) with total 106 106 - **Clear cache**: scoped clearing — all, or by category. Confirmation dialog for "clear all" 107 107 - **Export**: export user data (liked posts, bookmarks, settings) as JSON or CSV. Uses Tauri's save dialog to pick destination. 108 - - **Reset app**: full data wipe — drops all user tables, clears auth tokens, re-runs migrations. Behind a two-step confirmation: type "RESET" to confirm. This is the nuclear option. 108 + 109 + ### 8. Danger Zone 110 + 111 + - **Reset and restart**: full data wipe — drops all user tables, clears auth tokens, removes the downloaded embeddings model cache, re-runs migrations, and restarts the app. Behind a two-step confirmation: type "RESET" to confirm. This is the nuclear option. 109 112 110 - ### 8. Logs 113 + ### 9. Logs 111 114 112 115 In-app log viewer for debugging. 113 116 ··· 117 120 - "Copy all" and "Open log file" actions 118 121 - Collapsible by default — expands inline within the settings view 119 122 120 - ### 9. About 123 + ### 10. About 121 124 122 125 - App version (from `tauri.conf.json`) 123 126 - License (MIT) ··· 133 136 134 137 ## Keyboard Shortcuts 135 138 136 - | Key | Action | 137 - | ------- | ----------------------------- | 138 - | `,` | Open/focus settings from anywhere | 139 - | `Escape`| Close settings (navigate back) | 139 + | Key | Action | 140 + | -------- | --------------------------------- | 141 + | `,` | Open/focus settings from anywhere | 142 + | `Escape` | Close settings (navigate back) | 140 143 141 144 ## UX Polish 142 145
+10
index.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 6 <meta name="theme-color" content="#000000" /> 7 7 <link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" /> 8 + <style> 9 + html, 10 + body { 11 + margin: 0; 12 + min-height: 100%; 13 + background: #000; 14 + color: #f4f6fb; 15 + font-family: "Google Sans Variable", "Segoe UI", "Avenir Next", sans-serif; 16 + } 17 + </style> 8 18 <title>Tauri + Solid + Typescript App</title> 9 19 </head> 10 20
+51 -5
src-tauri/src/search.rs
··· 786 786 Ok(dir) 787 787 } 788 788 789 + fn models_dir_path(app: &AppHandle) -> Result<PathBuf> { 790 + let mut dir = app 791 + .path() 792 + .app_data_dir() 793 + .map_err(|error| AppError::PathResolve(error.to_string()))?; 794 + dir.push("models"); 795 + Ok(dir) 796 + } 797 + 789 798 fn required_embedding_files() -> Vec<&'static str> { 790 799 let mut files = vec![EMBEDDING_MODEL_FILE]; 791 800 files.extend(EMBEDDING_TOKENIZER_FILES); ··· 845 854 state.started_at = None; 846 855 state.last_error = Some(message); 847 856 } 857 + } 858 + 859 + fn clear_embeddings_model_cache_dir(models_dir: &Path) -> Result<()> { 860 + if models_dir.exists() { 861 + fs::remove_dir_all(models_dir)?; 862 + } 863 + set_download_idle_state(0, required_embedding_files().len()); 864 + Ok(()) 848 865 } 849 866 850 867 fn ensure_model_downloaded(models_dir: &Path) -> Result<()> { ··· 1360 1377 get_embeddings_config(app, state) 1361 1378 } 1362 1379 1380 + pub fn clear_embeddings_model_cache(app: &AppHandle) -> Result<()> { 1381 + let models_dir = models_dir_path(app)?; 1382 + clear_embeddings_model_cache_dir(&models_dir) 1383 + } 1384 + 1363 1385 fn sync_due(active_did: Option<&str>, last_synced_did: Option<&str>, last_synced_at: Option<Instant>) -> bool { 1364 1386 match active_did { 1365 1387 None => false, ··· 1427 1449 #[cfg(test)] 1428 1450 mod tests { 1429 1451 use super::{ 1430 - build_fts_match_query, build_search_posts_request, db_get_embeddings_enabled, db_get_embeddings_preflight_seen, 1431 - db_list_saved_posts, db_load_sync_cursor, db_post_count, db_save_sync_state, db_semantic_search, 1432 - db_set_embeddings_enabled, db_set_embeddings_preflight_seen, db_sync_status, db_upsert_embedding, 1433 - db_upsert_post, normalize_identifier_filter, normalize_tag_filter, run_local_search, storage_key, sync_due, 1434 - validate_limit, validate_query, validate_search_mode, validate_source, NetworkSearchQueryParams, SearchMode, 1452 + build_fts_match_query, build_search_posts_request, clear_embeddings_model_cache_dir, db_get_embeddings_enabled, 1453 + db_get_embeddings_preflight_seen, db_list_saved_posts, db_load_sync_cursor, db_post_count, db_save_sync_state, 1454 + db_semantic_search, db_set_embeddings_enabled, db_set_embeddings_preflight_seen, db_sync_status, 1455 + db_upsert_embedding, db_upsert_post, normalize_identifier_filter, normalize_tag_filter, run_local_search, 1456 + storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, 1457 + NetworkSearchQueryParams, SearchMode, 1435 1458 }; 1436 1459 use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 1437 1460 use sqlite_vec::sqlite3_vec_init; 1461 + use std::fs; 1462 + use std::time::{SystemTime, UNIX_EPOCH}; 1438 1463 1439 1464 fn test_db() -> Connection { 1440 1465 unsafe { ··· 1503 1528 "record": { "$type": "app.bsky.feed.post", "text": text, "createdAt": created_at } 1504 1529 } 1505 1530 }) 1531 + } 1532 + 1533 + fn temp_models_dir() -> std::path::PathBuf { 1534 + let unique = SystemTime::now() 1535 + .duration_since(UNIX_EPOCH) 1536 + .expect("clock should be after epoch") 1537 + .as_nanos(); 1538 + std::env::temp_dir().join(format!("lazurite-model-cache-{unique}")) 1539 + } 1540 + 1541 + #[test] 1542 + fn clear_embeddings_model_cache_dir_removes_cached_files() { 1543 + let models_dir = temp_models_dir(); 1544 + let nested_dir = models_dir.join("nested"); 1545 + fs::create_dir_all(&nested_dir).expect("nested models dir should be created"); 1546 + fs::write(models_dir.join("model.onnx"), "model").expect("model file should be created"); 1547 + fs::write(nested_dir.join("tokenizer.json"), "tokenizer").expect("tokenizer file should be created"); 1548 + 1549 + clear_embeddings_model_cache_dir(&models_dir).expect("model cache should clear"); 1550 + 1551 + assert!(!models_dir.exists(), "models dir should be removed after clearing"); 1506 1552 } 1507 1553 1508 1554 fn insert_post(conn: &Connection, owner_did: &str, uri: &str, source: &str, text: &str, created_at: &str) {
+2
src-tauri/src/settings.rs
··· 2 2 use super::db; 3 3 use super::error::{AppError, Result}; 4 4 use super::notifications; 5 + use super::search; 5 6 use super::state::AppState; 6 7 use super::tray; 7 8 use reqwest::Url; ··· 618 619 db_reset_app(&conn)?; 619 620 drop(conn); 620 621 622 + search::clear_embeddings_model_cache(app)?; 621 623 state.clear_runtime_state()?; 622 624 notifications::clear_unread_badge(app); 623 625 tray::sync_global_shortcut(app)?;
+3 -3
src/components/search/EmbeddingsSettings.test.tsx
··· 78 78 renderEmbeddingsSettings(); 79 79 80 80 expect(await screen.findByText("Optional Semantic Search")).toBeInTheDocument(); 81 - expect(await screen.findAllByText(/nomic-embed-text-v1\.5/)).toHaveLength(2); 82 - expect(await screen.findAllByText(/768D/)).toHaveLength(2); 83 - expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(2); 81 + expect(await screen.findAllByText(/nomic-embed-text-v1\.5/)).toHaveLength(1); 82 + expect(await screen.findAllByText(/768D/)).toHaveLength(1); 83 + expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(1); 84 84 expect(await screen.findAllByText(/off by default/i)).toHaveLength(2); 85 85 }); 86 86
+60 -41
src/components/search/EmbeddingsSettings.tsx
··· 23 23 function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { 24 24 return ( 25 25 <div class="flex items-start justify-between gap-4"> 26 - <div class="flex items-start gap-3"> 27 - <Icon 28 - kind="search" 29 - class="h-11 w-11 items-center justify-center rounded-full bg-primary/15 text-lg text-primary" /> 26 + <div class="flex items-start gap-2"> 27 + <div> 28 + <Icon 29 + kind="search" 30 + class="h-11 w-11 items-center justify-center rounded-2xl bg-primary/12 text-lg text-primary" /> 31 + </div> 30 32 31 33 <div class="grid gap-1"> 32 - <p class="m-0 text-lg font-medium text-on-surface">Optional Semantic Search</p> 34 + <p class="m-0 text-base font-medium text-on-surface">Optional Semantic Search</p> 33 35 <p class="m-0 text-sm leading-relaxed text-on-surface-variant"> 34 36 Off by default. Turn this on to download a local model and unlock semantic plus hybrid search for synced 35 37 posts. 36 38 </p> 37 39 </div> 38 40 </div> 39 - 40 - <Show when={props.config}> 41 - {(current) => ( 42 - <ToggleSwitch 43 - checked={current().enabled} 44 - disabled={props.isLoading || current().downloadActive} 45 - onChange={() => void props.handleToggle()} /> 46 - )} 47 - </Show> 41 + <div> 42 + <Show when={props.config}> 43 + {(current) => ( 44 + <ToggleSwitch 45 + checked={current().enabled} 46 + disabled={props.isLoading || current().downloadActive} 47 + onChange={() => void props.handleToggle()} /> 48 + )} 49 + </Show> 50 + </div> 48 51 </div> 49 52 ); 50 53 } ··· 195 198 <section class="panel-surface grid gap-4 p-5"> 196 199 <EmbedSettingsHeader config={config()} isLoading={preferences.embeddingsLoading} handleToggle={handleToggle} /> 197 200 198 - <Show when={!config()?.enabled}> 199 - <div class="grid gap-3 rounded-3xl bg-white/[0.035] p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 200 - <div class="flex items-center justify-between gap-3"> 201 - <div class="grid gap-1"> 202 - <p class="m-0 text-sm font-medium text-on-surface">Keyword and network search are ready now</p> 203 - <p class="m-0 text-xs text-on-surface-variant"> 204 - Turn this on only if you want concept matching across synced posts. The model downloads locally and 205 - nothing happens until you opt in. 206 - </p> 207 - </div> 208 - <span class="rounded-full bg-primary/10 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 209 - Off by default 210 - </span> 201 + <div class="grid gap-3 rounded-3xl bg-black/20 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 202 + <div class="flex items-start justify-between gap-3"> 203 + <div class="grid gap-1"> 204 + <p class="m-0 text-sm font-medium text-on-surface"> 205 + <Show when={config()?.enabled} fallback="Keyword and network search are ready now"> 206 + Semantic search is enabled 207 + </Show> 208 + </p> 209 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> 210 + <Show 211 + when={config()?.enabled} 212 + fallback="Nothing downloads until you opt in. Exact keyword search works immediately without a local model."> 213 + Lazurite keeps the model on-device and uses it only for your local search index. 214 + </Show> 215 + </p> 211 216 </div> 212 - <ModelDescriptor config={config()} /> 217 + <span 218 + class="rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em]" 219 + classList={{ 220 + "bg-emerald-400/15 text-emerald-300": !!config()?.downloaded, 221 + "bg-primary/10 text-primary": !config()?.downloaded, 222 + }}> 223 + <Show 224 + when={config()?.downloaded} 225 + fallback={<Show when={config()?.enabled} fallback="Off by default">Setting up</Show>}> 226 + Ready 227 + </Show> 228 + </span> 213 229 </div> 214 - </Show> 230 + 231 + <ModelDescriptor config={config()} /> 232 + </div> 215 233 216 234 <Presence> 217 - <Show when={config()?.enabled && (!config()?.downloaded || config()?.downloadActive || config()?.lastError || prepareRequested())}> 235 + <Show 236 + when={config()?.enabled 237 + && (!config()?.downloaded || config()?.downloadActive || config()?.lastError || prepareRequested())}> 218 238 <Motion.div 219 - class="grid gap-3 rounded-2xl bg-white/5 p-4" 239 + class="grid gap-3 rounded-3xl bg-primary/8 p-4 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]" 220 240 initial={{ opacity: 0, height: 0 }} 221 241 animate={{ opacity: 1, height: "auto" }} 222 242 exit={{ opacity: 0, height: 0 }} ··· 249 269 {(value) => <p class="m-0">ETA: {formatEtaSeconds(value())}</p>} 250 270 </Show> 251 271 252 - <Show when={config()?.lastError}> 253 - {(message) => <p class="m-0 text-red-300">{message()}</p>} 254 - </Show> 272 + <Show when={config()?.lastError}>{(message) => <p class="m-0 text-red-300">{message()}</p>}</Show> 255 273 </div> 256 274 257 275 <Show when={!config()?.downloadActive && !prepareRequested() && !config()?.downloaded}> ··· 261 279 </Show> 262 280 </Presence> 263 281 264 - <p class="m-0 text-xs leading-relaxed text-on-surface-variant/80"> 265 - Semantic search is optional. It stays off until you opt in, and it only improves local search over your synced 266 - likes and bookmarks. 267 - </p> 268 - <div class="flex items-center gap-2"> 269 - <StatusLabel config={config()} /> 270 - <ModelDescriptor config={config()} /> 282 + <div class="grid gap-3 text-xs leading-relaxed text-on-surface-variant"> 283 + <div class="flex items-center gap-2"> 284 + <StatusLabel config={config()} /> 285 + </div> 286 + <p class="m-0"> 287 + Semantic search is optional. It only improves local search over synced likes and bookmarks, and reset clears 288 + the downloaded model as part of a full wipe. 289 + </p> 271 290 </div> 272 291 </section> 273 292 );
+30 -25
src/components/search/SearchPanel.tsx
··· 375 375 } 376 376 377 377 return ( 378 - <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_20rem]": !props.embedded }}> 378 + <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_22rem]": !props.embedded }}> 379 379 <section 380 380 class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden" 381 381 classList={{ ··· 428 428 </section> 429 429 430 430 <Show when={!props.embedded}> 431 - <aside class="grid content-start gap-4 overflow-y-auto"> 431 + <aside class="grid content-start gap-3 overflow-y-auto xl:sticky xl:top-0 xl:max-h-[calc(100vh-2rem)] xl:pr-1"> 432 432 <Show when={session.activeDid}> 433 433 {(did) => <SyncStatusPanel did={did()} onStatusChange={(status) => setSearch("syncStatus", status)} />} 434 434 </Show> ··· 880 880 881 881 function SearchTipsCard() { 882 882 return ( 883 - <section class="panel-surface grid gap-3 p-5"> 884 - <p class="m-0 text-sm font-medium text-on-surface">Search Tips</p> 885 - <div class="grid grid-cols-2 gap-2 text-xs text-on-surface-variant"> 886 - <p class="m-0 flex items-center gap-2"> 887 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> 888 - <span>Focus search from anywhere</span> 883 + <section class="panel-surface grid gap-4 p-4"> 884 + <div class="flex items-center justify-between gap-3"> 885 + <p class="m-0 text-sm font-medium text-on-surface">Search Tips</p> 886 + <span class="rounded-full bg-white/7 px-2.5 py-1 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant"> 887 + Workflow 888 + </span> 889 + </div> 890 + 891 + <div class="grid gap-2"> 892 + <div class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-2xl bg-black/25 px-3 py-2 text-xs text-on-surface-variant"> 893 + <kbd class="rounded bg-white/10 px-1.5 py-0.5 text-on-surface">/</kbd> 894 + <span>Focus search from anywhere.</span> 895 + </div> 896 + <div class="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-2xl bg-black/25 px-3 py-2 text-xs text-on-surface-variant"> 897 + <kbd class="rounded bg-white/10 px-1.5 py-0.5 text-on-surface">Tab</kbd> 898 + <span>Cycle search modes while the query field is focused.</span> 899 + </div> 900 + </div> 901 + 902 + <div class="grid gap-2 text-xs leading-relaxed text-on-surface-variant"> 903 + <p class="m-0 rounded-2xl bg-white/[0.035] px-3 py-2"> 904 + Network filters stay in the URL, so exact search states are shareable and bookmarkable. 905 + </p> 906 + <p class="m-0 rounded-2xl bg-white/[0.035] px-3 py-2"> 907 + Use keyword mode for exact terms. Hybrid becomes available after semantic search finishes setup. 889 908 </p> 890 - <p class="m-0 flex items-center gap-2"> 891 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> 892 - <span>Cycle search modes</span> 909 + <p class="m-0 rounded-2xl bg-white/[0.035] px-3 py-2"> 910 + Switch to Profiles when you want people, not posts. Suggestions open immediately. 893 911 </p> 894 - <div class="col-span-2 flex flex-col items-start gap-1"> 895 - <div class="m-0 flex items-start gap-2"> 896 - <div>·</div> 897 - <div>Network filters are URL-synced, so you can bookmark or share exact search states.</div> 898 - </div> 899 - <div class="m-0 flex items-start gap-2"> 900 - <div>·</div> 901 - <div>Use keyword mode for exact terms. Hybrid becomes available after embeddings finish setting up.</div> 902 - </div> 903 - <div class="m-0 flex items-start gap-2"> 904 - <div>·</div> 905 - <div>Switch to Profiles when you want people, not posts. Actor suggestions open immediately.</div> 906 - </div> 907 - </div> 908 912 </div> 913 + 909 914 <a 910 915 class="inline-flex w-fit items-center gap-2 rounded-full bg-white/6 px-3 py-2 text-xs font-medium text-on-surface no-underline transition hover:bg-white/10 hover:text-primary" 911 916 href="#/settings">
+69 -28
src/components/search/SyncStatusPanel.tsx
··· 62 62 ); 63 63 } 64 64 65 + function SyncHeader( 66 + props: { 67 + hasAnyPosts: boolean; 68 + icon: "db"; 69 + lastSync: string | null; 70 + totalPosts: number; 71 + tone: { className: string; label: string }; 72 + }, 73 + ) { 74 + return ( 75 + <div class="flex items-center justify-between gap-3"> 76 + <div class="grid gap-1"> 77 + <div class="flex items-center gap-2"> 78 + <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p> 79 + <span class={`rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] ${props.tone.className}`}> 80 + {props.tone.label} 81 + </span> 82 + </div> 83 + 84 + <Show 85 + when={props.hasAnyPosts} 86 + fallback={ 87 + <p class="m-0 text-xs text-on-surface-variant"> 88 + Local search stays empty until likes or bookmarks are indexed. 89 + </p> 90 + }> 91 + <PostCount totalPosts={props.totalPosts} lastSync={props.lastSync} /> 92 + </Show> 93 + </div> 94 + 95 + <span class="grid h-10 w-10 place-items-center rounded-2xl bg-primary/10 text-primary"> 96 + <Icon kind={props.icon} class="text-lg" /> 97 + </span> 98 + </div> 99 + ); 100 + } 101 + 102 + function SyncActions(props: { 103 + hasAnyPosts: boolean; 104 + isReindexing: boolean; 105 + isSyncing: boolean; 106 + onReindex: () => void; 107 + onSync: () => void; 108 + }) { 109 + return ( 110 + <div class="flex flex-wrap items-center gap-2"> 111 + <Show when={props.hasAnyPosts}> 112 + <ReindexButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onReindex={props.onReindex} /> 113 + </Show> 114 + 115 + <SyncButton isSyncing={props.isSyncing} isReindexing={props.isReindexing} onSync={props.onSync} /> 116 + </div> 117 + ); 118 + } 119 + 65 120 type SyncStatusPanelProps = { did: string; onStatusChange?: (status: SyncStatus[]) => void }; 66 121 67 122 export function SyncStatusPanel(props: SyncStatusPanelProps) { ··· 140 195 141 196 return ( 142 197 <section class="panel-surface grid gap-4 p-5"> 143 - <div class="flex flex-col items-start justify-between gap-4"> 144 - <div class="grid gap-1"> 145 - <div class="flex items-center gap-2"> 146 - <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p> 147 - <span class={`rounded-full px-2 py-0.5 text-[0.68rem] font-medium ${statusTone().className}`}> 148 - {statusTone().label} 149 - </span> 150 - </div> 151 - 152 - <Show 153 - when={hasAnyPosts()} 154 - fallback={ 155 - <p class="m-0 text-xs text-on-surface-variant"> 156 - Local search is empty until likes or bookmarks are indexed. 157 - </p> 158 - }> 159 - <PostCount totalPosts={totalPosts()} lastSync={lastSync()} /> 160 - </Show> 161 - </div> 162 - 163 - <div class="flex items-center gap-2"> 164 - <Show when={hasAnyPosts()}> 165 - <ReindexButton isSyncing={isSyncing()} isReindexing={isReindexing()} onReindex={handleReindex} /> 166 - </Show> 167 - 168 - <SyncButton isSyncing={isSyncing()} isReindexing={isReindexing()} onSync={handleSync} /> 169 - </div> 198 + <div class="grid gap-3"> 199 + <SyncHeader 200 + hasAnyPosts={hasAnyPosts()} 201 + icon="db" 202 + lastSync={lastSync()} 203 + totalPosts={totalPosts()} 204 + tone={statusTone()} /> 205 + <SyncActions 206 + hasAnyPosts={hasAnyPosts()} 207 + isReindexing={isReindexing()} 208 + isSyncing={isSyncing()} 209 + onReindex={handleReindex} 210 + onSync={handleSync} /> 170 211 </div> 171 212 172 213 <Presence> ··· 186 227 </Show> 187 228 </Presence> 188 229 189 - <div class="grid gap-3"> 230 + <div class="grid gap-3 rounded-3xl bg-black/20 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 190 231 <For each={syncStatus()}> 191 232 {(status) => ( 192 233 <SourceStatusRow
+49
src/components/settings/SettingsDangerZone.tsx
··· 1 + import { SettingsCard } from "./SettingsCard"; 2 + 3 + type SettingsDangerZoneProps = { 4 + handleResetAndRestartApp: () => Promise<void>; 5 + openConfirmation: ( 6 + options: { 7 + title: string; 8 + message: string; 9 + confirmText?: string; 10 + type?: "default" | "danger"; 11 + onConfirm: () => void; 12 + }, 13 + ) => void; 14 + }; 15 + 16 + export function SettingsDangerZone(props: SettingsDangerZoneProps) { 17 + return ( 18 + <SettingsCard icon="danger" title="Danger Zone"> 19 + <div class="grid gap-4"> 20 + <div class="rounded-2xl bg-[rgba(138,31,31,0.16)] p-4 text-sm text-on-surface"> 21 + <p class="font-medium text-red-300">Clear local data and restart</p> 22 + <p class="mt-2 text-xs text-on-surface-variant"> 23 + This removes every local account, cache entry, saved setting, and synced record, then restarts Lazurite. 24 + </p> 25 + </div> 26 + <div class="flex items-center justify-between gap-4 rounded-2xl bg-black/30 p-4"> 27 + <div> 28 + <p class="text-sm font-medium text-red-300">Reset application</p> 29 + <p class="text-xs text-on-surface-variant">Return Lazurite to a clean install state.</p> 30 + </div> 31 + <button 32 + type="button" 33 + onClick={() => 34 + props.openConfirmation({ 35 + title: "Reset And Restart", 36 + message: 37 + "This will clear all local data, return Lazurite to its defaults, and restart the app. Type RESET to confirm.", 38 + confirmText: "RESET", 39 + type: "danger", 40 + onConfirm: () => void props.handleResetAndRestartApp(), 41 + })} 42 + class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 transition hover:bg-red-500/20"> 43 + Reset &amp; restart 44 + </button> 45 + </div> 46 + </div> 47 + </SettingsCard> 48 + ); 49 + }
-28
src/components/settings/SettingsData.tsx
··· 5 5 type SettingsDataProps = { 6 6 cacheSize: { feedsBytes: number; embeddingsBytes: number; ftsBytes: number; totalBytes?: number } | null; 7 7 handleClearCache: (scope: "feeds" | "embeddings" | "fts" | "all") => Promise<void>; 8 - handleResetApp: () => Promise<void>; 9 8 openConfirmation: ( 10 9 options: { 11 10 title: string; ··· 74 73 </button> 75 74 </div> 76 75 <ExportControl /> 77 - <ResetControl handleResetApp={props.handleResetApp} openConfirmation={props.openConfirmation} /> 78 76 </div> 79 77 </SettingsCard> 80 78 ); ··· 106 104 </div> 107 105 ); 108 106 } 109 - 110 - function ResetControl(props: Pick<SettingsDataProps, "handleResetApp" | "openConfirmation">) { 111 - return ( 112 - <div class="border-t border-white/10 pt-4"> 113 - <div class="flex items-center justify-between"> 114 - <div> 115 - <p class="text-sm font-medium text-red-400">Reset application</p> 116 - <p class="text-xs text-on-surface-variant">Remove all data and reset to defaults</p> 117 - </div> 118 - <button 119 - type="button" 120 - onClick={() => 121 - props.openConfirmation({ 122 - title: "Reset Application", 123 - message: "This will delete ALL data including accounts, settings, and cache. Type RESET to confirm.", 124 - confirmText: "RESET", 125 - type: "danger", 126 - onConfirm: () => void props.handleResetApp(), 127 - })} 128 - class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 transition hover:bg-red-500/20"> 129 - Reset... 130 - </button> 131 - </div> 132 - </div> 133 - ); 134 - }
+24 -4
src/components/settings/SettingsPanel.test.tsx
··· 9 9 const clearCacheMock = vi.hoisted(() => vi.fn()); 10 10 const exportDataMock = vi.hoisted(() => vi.fn()); 11 11 const resetAppMock = vi.hoisted(() => vi.fn()); 12 + const resetAndRestartAppMock = vi.hoisted(() => vi.fn()); 12 13 const getLogEntriesMock = vi.hoisted(() => vi.fn()); 13 14 const navigateMock = vi.hoisted(() => vi.fn()); 14 15 const infoMock = vi.hoisted(() => vi.fn()); ··· 32 33 clearCache: clearCacheMock, 33 34 exportData: exportDataMock, 34 35 resetApp: resetAppMock, 36 + resetAndRestartApp: resetAndRestartAppMock, 35 37 getLogEntries: getLogEntriesMock, 36 38 }), 37 39 ); ··· 98 100 clearCacheMock.mockResolvedValue(void 0); 99 101 exportDataMock.mockResolvedValue(void 0); 100 102 resetAppMock.mockResolvedValue(void 0); 103 + resetAndRestartAppMock.mockResolvedValue(void 0); 101 104 }); 102 105 103 106 it("loads and displays settings", async () => { ··· 110 113 expect(await screen.findByText("Accounts")).toBeInTheDocument(); 111 114 expect(await screen.findByText("Services")).toBeInTheDocument(); 112 115 expect(await screen.findByText("Data")).toBeInTheDocument(); 116 + expect(await screen.findByText("Danger Zone")).toBeInTheDocument(); 113 117 expect(await screen.findByText("Logs")).toBeInTheDocument(); 114 118 expect(await screen.findByText("About")).toBeInTheDocument(); 115 - expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(2); 119 + expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(1); 116 120 }); 117 121 118 122 it("displays cache size information", async () => { ··· 203 207 await waitFor(() => expect(exportDataMock).toHaveBeenCalledWith("csv")); 204 208 }); 205 209 206 - it("shows confirmation modal with RESET text for app reset", async () => { 210 + it("shows confirmation modal with RESET text for app reset and restart", async () => { 207 211 renderSettingsPanel(); 208 212 209 213 await screen.findByText("Settings"); 210 - const resetButton = await screen.findByRole("button", { name: /reset\.\.\./i }); 214 + const resetButton = await screen.findByRole("button", { name: /reset & restart/i }); 211 215 212 216 fireEvent.click(resetButton); 213 - expect(await screen.findByText("Reset Application")).toBeInTheDocument(); 217 + expect(await screen.findByText("Reset And Restart")).toBeInTheDocument(); 214 218 expect(await screen.findByPlaceholderText(/type "reset" to confirm/i)).toBeInTheDocument(); 219 + }); 220 + 221 + it("invokes reset-and-restart after confirmation", async () => { 222 + renderSettingsPanel(); 223 + 224 + await screen.findByText("Settings"); 225 + const resetButton = await screen.findByRole("button", { name: /reset & restart/i }); 226 + 227 + fireEvent.click(resetButton); 228 + const input = await screen.findByPlaceholderText(/type "reset" to confirm/i); 229 + fireEvent.input(input, { target: { value: "RESET" } }); 230 + 231 + const confirmButton = await screen.findByRole("button", { name: /^confirm$/i }); 232 + fireEvent.click(confirmButton); 233 + 234 + await waitFor(() => expect(resetAndRestartAppMock).toHaveBeenCalled()); 215 235 }); 216 236 217 237 it("navigates back when close button is clicked", async () => {
+14 -12
src/components/settings/SettingsPanel.tsx
··· 1 1 import { EmbeddingsSettings } from "$/components/search/EmbeddingsSettings"; 2 2 import { useAppPreferences } from "$/contexts/app-preferences"; 3 - import { clearCache, getCacheSize, getLogEntries, resetApp } from "$/lib/api/settings"; 3 + import { clearCache, getCacheSize, getLogEntries, resetAndRestartApp } from "$/lib/api/settings"; 4 4 import type { 5 5 AppSettings, 6 6 CacheClearScope, ··· 20 20 import { SettingsAbout } from "./SettingsAbout"; 21 21 import { AccountControl } from "./SettingsAccount"; 22 22 import { SettingsData } from "./SettingsData"; 23 + import { SettingsDangerZone } from "./SettingsDangerZone"; 23 24 import { SettingsLogs } from "./SettingsLogs"; 24 25 import { NotificationsControl } from "./SettingsNotification"; 25 26 import { SettingsService } from "./SettingsService"; ··· 153 154 ); 154 155 } 155 156 157 + async function handleResetAndRestartApp() { 158 + try { 159 + await resetAndRestartApp(); 160 + } catch (err) { 161 + logger.error("failed to reset and restart app", { keyValues: { error: normalizeError(err) } }); 162 + } 163 + } 164 + 156 165 export function SettingsPanel() { 157 166 const preferences = useAppPreferences(); 158 167 const navigate = useNavigate(); ··· 197 206 } 198 207 } 199 208 200 - async function handleResetApp() { 201 - try { 202 - await resetApp(); 203 - navigate("/auth"); 204 - } catch (err) { 205 - logger.error("failed to reset app", { keyValues: { error: normalizeError(err) } }); 206 - } 207 - } 208 - 209 209 function openConfirmation( 210 210 config: { 211 211 title: string; ··· 287 287 <SettingsData 288 288 cacheSize={panel.cacheSize} 289 289 handleClearCache={handleClearCache} 290 - openConfirmation={openConfirmation} 291 - handleResetApp={handleResetApp} /> 290 + openConfirmation={openConfirmation} /> 291 + <SettingsDangerZone 292 + handleResetAndRestartApp={handleResetAndRestartApp} 293 + openConfirmation={openConfirmation} /> 292 294 <SettingsLogs 293 295 expanded={panel.logsExpanded} 294 296 logLevel={panel.logLevel}
+4
src/components/shared/Icon.tsx
··· 6 6 7 7 export type SettingsIconKind = 8 8 | "computer" 9 + | "danger" 9 10 | "info" 10 11 | "timeline" 11 12 | "db" ··· 193 194 <Switch> 194 195 <Match when={local.kind === "info"}> 195 196 <i class="i-ri-information-line" /> 197 + </Match> 198 + <Match when={local.kind === "danger"}> 199 + <i class="i-ri-alarm-warning-line" /> 196 200 </Match> 197 201 <Match when={local.kind === "computer"}> 198 202 <i class="i-ri-computer-line" />
+13
src/lib/api/settings.ts
··· 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 import * as logger from "@tauri-apps/plugin-log"; 4 4 import { normalizeError } from "../utils/text"; 5 + 5 6 export function getSettings() { 6 7 return invoke<AppSettings>("get_settings"); 7 8 } ··· 31 32 return invoke("reset_app"); 32 33 } 33 34 35 + export async function resetAndRestartApp() { 36 + await resetApp(); 37 + restartClient("/auth"); 38 + } 39 + 34 40 export function getLogEntries(limit: number, level?: LogLevelFilter) { 35 41 const filterLevel = level === "all" ? null : level; 36 42 return invoke<LogEntry[]>("get_log_entries", { limit, level: filterLevel }); 37 43 } 44 + 45 + function restartClient(hash: string) { 46 + const url = new URL(globalThis.location.href); 47 + url.hash = hash; 48 + globalThis.location.replace(url.toString()); 49 + globalThis.setTimeout(() => globalThis.location.reload(), 0); 50 + }