notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 0a41daa1ee219a4287f7eb2af080ffcc38c2ae5c
parent 6073de570cf90400c88d772251bcea13ee38fa10
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 22 Feb 2026 12:08:53 -0800

settings: add database compaction UI

Add a Database section to settings with db size display and a
compact button. Compaction runs in the background via JobPool,
keeping profiles and the user's own notes while discarding
everything else. The compacted database is swapped into place
on next startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mcrates/notedeck/src/app.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck/src/args.rs | 16+++++++++++++++-
Acrates/notedeck/src/compact.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/lib.rs | 1+
Mcrates/notedeck_columns/src/nav.rs | 30+++++++++++++++---------------
Mcrates/notedeck_columns/src/timeline/mod.rs | 4+++-
Mcrates/notedeck_columns/src/ui/settings.rs | 219++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/notedeck_columns/src/view_state.rs | 4++++
10 files changed, 361 insertions(+), 47 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3951,7 +3951,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.10.0" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=8c1af378d1c296abfccb9a11ce5a3ae48f857be6#8c1af378d1c296abfccb9a11ce5a3ae48f857be6" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=59a1900#59a19008973a6f5eb0dd2089964f60e095e25477" dependencies = [ "bindgen 0.69.5", "cc", diff --git a/Cargo.toml b/Cargo.toml @@ -63,7 +63,7 @@ md5 = "0.7.0" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "8c1af378d1c296abfccb9a11ce5a3ae48f857be6" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "59a1900" } #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -200,10 +200,7 @@ impl Notedeck { .clone() .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string()); let path = DataPath::new(&data_path); - let dbpath_str = parsed_args - .dbpath - .clone() - .unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string()); + let dbpath_str = parsed_args.db_path(&path).to_str().unwrap().to_string(); let _ = std::fs::create_dir_all(&dbpath_str); @@ -249,6 +246,7 @@ impl Notedeck { } let mut unknown_ids = UnknownIds::default(); + try_swap_compacted_db(&dbpath_str); let mut ndb = Ndb::new(&dbpath_str, &config).expect("ndb"); let txn = Transaction::new(&ndb).expect("txn"); @@ -550,3 +548,51 @@ fn process_message_core(ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessag } } } + +/// If a compacted database exists at `{dbpath}/compact/`, swap it into place +/// before opening ndb. This replaces the main data.mdb with the compacted one. +fn try_swap_compacted_db(dbpath: &str) { + let dbpath = Path::new(dbpath); + let compact_path = dbpath.join("compact"); + let compact_data = compact_path.join("data.mdb"); + + info!( + "compact swap: checking for compacted db at '{}'", + compact_data.display() + ); + + if !compact_data.exists() { + info!("compact swap: no compacted db found, skipping"); + return; + } + + let compact_size = std::fs::metadata(&compact_data) + .map(|m| m.len()) + .unwrap_or(0); + info!("compact swap: found compacted db ({compact_size} bytes)"); + + let db_data = dbpath.join("data.mdb"); + let db_old = dbpath.join("data.mdb.old"); + + let old_size = std::fs::metadata(&db_data).map(|m| m.len()).unwrap_or(0); + info!( + "compact swap: current db at '{}' ({old_size} bytes)", + db_data.display() + ); + + if let Err(e) = std::fs::rename(&db_data, &db_old) { + error!("compact swap: failed to rename old db: {e}"); + return; + } + + if let Err(e) = std::fs::rename(&compact_data, &db_data) { + error!("compact swap: failed to move compacted db: {e}"); + // Try to restore the original + let _ = std::fs::rename(&db_old, &db_data); + return; + } + + let _ = std::fs::remove_file(&db_old); + let _ = std::fs::remove_dir_all(&compact_path); + info!("compact swap: success! {old_size} -> {compact_size} bytes"); +} diff --git a/crates/notedeck/src/args.rs b/crates/notedeck/src/args.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; +use std::path::PathBuf; -use crate::NotedeckOptions; +use crate::{DataPath, DataPathType, NotedeckOptions}; use enostr::{Keypair, Pubkey, SecretKey}; use tracing::error; use unic_langid::{LanguageIdentifier, LanguageIdentifierError}; @@ -15,6 +16,19 @@ pub struct Args { } impl Args { + /// Resolve the effective database path, respecting --dbpath override. + pub fn db_path(&self, data_path: &DataPath) -> PathBuf { + self.dbpath + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| data_path.path(DataPathType::Db)) + } + + /// Resolve the compact output path inside the db folder. + pub fn db_compact_path(&self, data_path: &DataPath) -> PathBuf { + self.db_path(data_path).join("compact") + } + // parse arguments, return set of unrecognized args pub fn parse(args: &[String]) -> (Self, BTreeSet<String>) { let mut unrecognized_args = BTreeSet::new(); diff --git a/crates/notedeck/src/compact.rs b/crates/notedeck/src/compact.rs @@ -0,0 +1,76 @@ +use std::path::Path; +use tokio::sync::oneshot; + +pub struct CompactResult { + pub old_size: u64, + pub new_size: u64, +} + +#[derive(Default)] +pub enum CompactStatus { + #[default] + Idle, + Running(oneshot::Receiver<Result<CompactResult, String>>), + Done(CompactResult), + Error(String), +} + +impl CompactStatus { + /// Poll a running compaction job. Returns true if the status changed. + pub fn poll(&mut self) -> bool { + let receiver = match self { + CompactStatus::Running(rx) => rx, + _ => return false, + }; + + match receiver.try_recv() { + Ok(Ok(result)) => { + *self = CompactStatus::Done(result); + true + } + Ok(Err(e)) => { + *self = CompactStatus::Error(e); + true + } + Err(oneshot::error::TryRecvError::Empty) => false, + Err(oneshot::error::TryRecvError::Closed) => { + *self = CompactStatus::Error("Compaction job was dropped".to_string()); + true + } + } + } +} + +/// Tracks compaction status and cached database size. +pub struct CompactState { + pub status: CompactStatus, + pub cached_db_size: Option<u64>, +} + +impl Default for CompactState { + fn default() -> Self { + Self { + status: CompactStatus::Idle, + cached_db_size: None, + } + } +} + +impl CompactState { + /// Get the database size, reading from cache or refreshing from disk. + pub fn db_size(&mut self, db_path: &Path) -> u64 { + if let Some(size) = self.cached_db_size { + return size; + } + let size = std::fs::metadata(db_path.join("data.mdb")) + .map(|m| m.len()) + .unwrap_or(0); + self.cached_db_size = Some(size); + size + } + + /// Invalidate the cached size so it gets re-read next time. + pub fn invalidate_size(&mut self) { + self.cached_db_size = None; + } +} diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -4,6 +4,7 @@ pub mod abbrev; mod account; mod app; mod args; +pub mod compact; pub mod contacts; mod context; pub mod debouncer; diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -604,14 +604,9 @@ fn process_render_nav_action( .process_relay_action(ui.ctx(), ctx.pool, action); None } - RenderNavAction::SettingsAction(action) => action.process_settings_action( - app, - ctx.settings, - ctx.i18n, - ctx.img_cache, - ui.ctx(), - ctx.accounts, - ), + RenderNavAction::SettingsAction(action) => { + action.process_settings_action(app, ctx, ui.ctx()) + } RenderNavAction::RepostAction(action) => { action.process(ctx.ndb, &ctx.accounts.get_selected_account().key, ctx.pool) } @@ -726,13 +721,18 @@ fn render_nav_body( .ui(ui) .map_output(RenderNavAction::RelayAction), - Route::Settings => SettingsView::new( - ctx.settings.get_settings_mut(), - &mut note_context, - &mut app.note_options, - ) - .ui(ui) - .map_output(RenderNavAction::SettingsAction), + Route::Settings => { + let db_path = ctx.args.db_path(ctx.path); + SettingsView::new( + ctx.settings.get_settings_mut(), + &mut note_context, + &mut app.note_options, + &db_path, + &mut app.view_state.compact, + ) + .ui(ui) + .map_output(RenderNavAction::SettingsAction) + } Route::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -189,7 +189,9 @@ impl TimelineTab { match merge_kind { // TODO: update egui_virtual_list to support spliced inserts MergeKind::Spliced => { - debug!("spliced when inserting {num_refs} new notes, resetting virtual list",); + tracing::trace!( + "spliced when inserting {num_refs} new notes, resetting virtual list", + ); list.reset(); } MergeKind::FrontInsert => 's: { diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -6,9 +6,8 @@ use egui_extras::{Size, StripBuilder}; use enostr::NoteId; use nostrdb::Transaction; use notedeck::{ - tr, ui::richtext_small, DragResponse, Images, LanguageIdentifier, Localization, NoteContext, - NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE, - DEFAULT_NOTE_BODY_FONT_SIZE, + tr, ui::richtext_small, DragResponse, LanguageIdentifier, NoteContext, NotedeckTextStyle, + Settings, DEFAULT_MAX_HASHTAGS_PER_NOTE, DEFAULT_NOTE_BODY_FONT_SIZE, }; use notedeck_ui::{ app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image}, @@ -35,17 +34,15 @@ pub enum SettingsAction { OpenRelays, OpenCacheFolder, ClearCacheFolder, + CompactDatabase, } impl SettingsAction { - pub fn process_settings_action<'a>( + pub fn process_settings_action( self, app: &mut Damus, - settings: &'a mut SettingsHandler, - i18n: &'a mut Localization, - img_cache: &mut Images, - ctx: &egui::Context, - accounts: &mut notedeck::Accounts, + app_ctx: &mut notedeck::AppContext<'_>, + egui_ctx: &egui::Context, ) -> Option<RouterAction> { let mut route_action: Option<RouterAction> = None; @@ -54,47 +51,80 @@ impl SettingsAction { route_action = Some(RouterAction::route_to(Route::Relays)); } Self::SetZoomFactor(zoom_factor) => { - ctx.set_zoom_factor(zoom_factor); - settings.set_zoom_factor(zoom_factor); + egui_ctx.set_zoom_factor(zoom_factor); + app_ctx.settings.set_zoom_factor(zoom_factor); } Self::SetTheme(theme) => { - ctx.set_theme(theme); - settings.set_theme(theme); + egui_ctx.set_theme(theme); + app_ctx.settings.set_theme(theme); } Self::SetLocale(language) => { - if i18n.set_locale(language.clone()).is_ok() { - settings.set_locale(language.to_string()); + if app_ctx.i18n.set_locale(language.clone()).is_ok() { + app_ctx.settings.set_locale(language.to_string()); } } Self::SetRepliestNewestFirst(value) => { app.note_options.set(NoteOptions::RepliesNewestFirst, value); - settings.set_show_replies_newest_first(value); + app_ctx.settings.set_show_replies_newest_first(value); } Self::OpenCacheFolder => { use opener; - let _ = opener::open(img_cache.base_path.clone()); + let _ = opener::open(app_ctx.img_cache.base_path.clone()); } Self::ClearCacheFolder => { - let _ = img_cache.clear_folder_contents(); + let _ = app_ctx.img_cache.clear_folder_contents(); } Self::SetNoteBodyFontSize(size) => { - let mut style = (*ctx.style()).clone(); + let mut style = (*egui_ctx.style()).clone(); style.text_styles.insert( NotedeckTextStyle::NoteBody.text_style(), FontId::proportional(size), ); - ctx.set_style(style); + egui_ctx.set_style(style); - settings.set_note_body_font_size(size); + app_ctx.settings.set_note_body_font_size(size); } Self::SetAnimateNavTransitions(value) => { - settings.set_animate_nav_transitions(value); + app_ctx.settings.set_animate_nav_transitions(value); } Self::SetMaxHashtagsPerNote(value) => { - settings.set_max_hashtags_per_note(value); - accounts.update_max_hashtags_per_note(value); + app_ctx.settings.set_max_hashtags_per_note(value); + app_ctx.accounts.update_max_hashtags_per_note(value); + } + Self::CompactDatabase => { + let own_pubkeys: Vec<[u8; 32]> = app_ctx + .accounts + .cache + .accounts() + .map(|a| *a.key.pubkey.bytes()) + .collect(); + + let db_path = app_ctx.args.db_path(app_ctx.path); + let compact_path = app_ctx.args.db_compact_path(app_ctx.path); + let _ = std::fs::create_dir_all(&compact_path); + + let old_size = std::fs::metadata(db_path.join("data.mdb")) + .map(|m| m.len()) + .unwrap_or(0); + + let compact_path_str = compact_path.to_str().unwrap_or("").to_string(); + let ndb = app_ctx.ndb.clone(); + + let receiver = app_ctx.job_pool.schedule_receivable(move || { + ndb.compact(&compact_path_str, &own_pubkeys) + .map(|()| { + let new_size = + std::fs::metadata(format!("{compact_path_str}/data.mdb")) + .map(|m| m.len()) + .unwrap_or(0); + notedeck::compact::CompactResult { old_size, new_size } + }) + .map_err(|e| format!("{e}")) + }); + + app.view_state.compact.status = notedeck::compact::CompactStatus::Running(receiver); } } route_action @@ -105,6 +135,8 @@ pub struct SettingsView<'a> { settings: &'a mut Settings, note_context: &'a mut NoteContext<'a>, note_options: &'a mut NoteOptions, + db_path: &'a std::path::Path, + compact: &'a mut notedeck::compact::CompactState, } fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui)) @@ -131,11 +163,15 @@ impl<'a> SettingsView<'a> { settings: &'a mut Settings, note_context: &'a mut NoteContext<'a>, note_options: &'a mut NoteOptions, + db_path: &'a std::path::Path, + compact: &'a mut notedeck::compact::CompactState, ) -> Self { Self { settings, note_context, note_options, + db_path, + compact, } } @@ -440,6 +476,135 @@ impl<'a> SettingsView<'a> { action } + pub fn database_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let id = ui.id(); + let mut action: Option<SettingsAction> = None; + + // Poll compaction status; invalidate cached size when done + if self.compact.status.poll() { + self.compact.invalidate_size(); + } + + let title = tr!( + self.note_context.i18n, + "Database", + "Label for database settings section" + ); + settings_group(ui, title, |ui| { + ui.horizontal_wrapped(|ui| { + let db_size = self.compact.db_size(self.db_path); + + ui.label( + RichText::new(format!( + "{} {}", + tr!( + self.note_context.i18n, + "Database size:", + "Label for database size in settings" + ), + format_size(db_size) + )) + .text_style(NotedeckTextStyle::Small.text_style()), + ); + + ui.end_row(); + + match self.compact.status { + notedeck::compact::CompactStatus::Running(_) => { + ui.label( + richtext_small(tr!( + self.note_context.i18n, + "Compacting...", + "Status label while database compaction is running" + )), + ); + } + notedeck::compact::CompactStatus::Done(ref result) => { + ui.label(richtext_small(format!( + "{} {} → {}. {}", + tr!( + self.note_context.i18n, + "Compacted!", + "Status label after database compaction completes" + ), + format_size(result.old_size), + format_size(result.new_size), + tr!( + self.note_context.i18n, + "Restart to apply.", + "Instruction to restart after compaction" + ), + ))); + } + notedeck::compact::CompactStatus::Error(ref e) => { + ui.label( + richtext_small(format!( + "{} {e}", + tr!( + self.note_context.i18n, + "Compaction error:", + "Status label when database compaction fails" + ), + )) + .color(Color32::LIGHT_RED), + ); + } + notedeck::compact::CompactStatus::Idle => { + let compact_resp = ui.button(richtext_small(tr!( + self.note_context.i18n, + "Compact database", + "Button to compact the database" + ))); + + let id_compact = id.with("compact_db"); + if compact_resp.clicked() { + ui.data_mut(|d| d.insert_temp(id_compact, true)); + } + + if ui.data_mut(|d| *d.get_temp_mut_or_default(id_compact)) { + let mut confirm_pressed = false; + compact_resp.show_tooltip_ui(|ui| { + ui.label(tr!( + self.note_context.i18n, + "Keeps all profiles and your notes. The smaller database will be used on next restart.", + "Confirmation prompt for database compaction" + )); + let confirm_resp = ui.button(tr!( + self.note_context.i18n, + "Confirm", + "Label for confirm compact database" + )); + if confirm_resp.clicked() { + confirm_pressed = true; + } + + if confirm_resp.clicked() + || ui + .button(tr!( + self.note_context.i18n, + "Cancel", + "Label for cancel compact database" + )) + .clicked() + { + ui.data_mut(|d| d.insert_temp(id_compact, false)); + } + }); + + if confirm_pressed { + action = Some(SettingsAction::CompactDatabase); + } else if !confirm_pressed && compact_resp.clicked_elsewhere() { + ui.data_mut(|d| d.insert_temp(id_compact, false)); + } + } + } + } + }); + }); + + action + } + fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { let mut action = None; @@ -727,6 +892,12 @@ impl<'a> SettingsView<'a> { ui.add_space(5.0); + if let Some(new_action) = self.database_section(ui) { + action = Some(new_action); + } + + ui.add_space(5.0); + self.keys_section(ui); ui.add_space(5.0); diff --git a/crates/notedeck_columns/src/view_state.rs b/crates/notedeck_columns/src/view_state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use enostr::Pubkey; +use notedeck::compact::CompactState; use notedeck::ReportType; use notedeck_ui::nip51_set::Nip51SetUiCache; @@ -37,6 +38,9 @@ pub struct ViewState { /// Report screen selected report type pub selected_report_type: Option<ReportType>, + + /// Database compaction state + pub compact: CompactState, } impl ViewState {