max / audiofiles
8 files changed,
+159 insertions,
-1 deletion
| @@ -4987,15 +4987,17 @@ version = "0.2.1" | |||
| 4987 | 4987 | dependencies = [ | |
| 4988 | 4988 | "argon2", | |
| 4989 | 4989 | "base64", | |
| 4990 | + | "bytes", | |
| 4990 | 4991 | "chacha20poly1305", | |
| 4991 | 4992 | "chrono", | |
| 4992 | 4993 | "keyring", | |
| 4994 | + | "parking_lot", | |
| 4993 | 4995 | "rand 0.8.5", | |
| 4994 | 4996 | "reqwest", | |
| 4995 | 4997 | "serde", | |
| 4996 | 4998 | "serde_json", | |
| 4997 | 4999 | "sha2", | |
| 4998 | - | "thiserror 1.0.69", | |
| 5000 | + | "thiserror 2.0.18", | |
| 4999 | 5001 | "tokio", | |
| 5000 | 5002 | "tracing", | |
| 5001 | 5003 | "unicode-normalization", |
| @@ -286,5 +286,13 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 286 | 286 | if input.key_pressed(egui::Key::I) { | |
| 287 | 287 | state.toggle_instrument(); | |
| 288 | 288 | } | |
| 289 | + | // "S" toggles sidebar | |
| 290 | + | if input.key_pressed(egui::Key::S) { | |
| 291 | + | state.sidebar_visible = !state.sidebar_visible; | |
| 292 | + | } | |
| 293 | + | // "D" toggles detail panel | |
| 294 | + | if input.key_pressed(egui::Key::D) { | |
| 295 | + | state.detail_visible = !state.detail_visible; | |
| 296 | + | } | |
| 289 | 297 | }); | |
| 290 | 298 | } |
| @@ -7,6 +7,25 @@ use super::theme; | |||
| 7 | 7 | ||
| 8 | 8 | /// Draw the filter panel content. | |
| 9 | 9 | pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 10 | + | // Column visibility toggles | |
| 11 | + | ui.label(egui::RichText::new("Columns").strong().color(theme::text_secondary())); | |
| 12 | + | let mut col_changed = false; | |
| 13 | + | ui.horizontal_wrapped(|ui| { | |
| 14 | + | col_changed |= ui.checkbox(&mut state.column_config.show_classification, "Class").changed(); | |
| 15 | + | col_changed |= ui.checkbox(&mut state.column_config.show_bpm, "BPM").changed(); | |
| 16 | + | col_changed |= ui.checkbox(&mut state.column_config.show_key, "Key").changed(); | |
| 17 | + | }); | |
| 18 | + | ui.horizontal_wrapped(|ui| { | |
| 19 | + | col_changed |= ui.checkbox(&mut state.column_config.show_duration, "Dur").changed(); | |
| 20 | + | col_changed |= ui.checkbox(&mut state.column_config.show_peak_db, "Peak").changed(); | |
| 21 | + | col_changed |= ui.checkbox(&mut state.column_config.show_tags, "Tags").changed(); | |
| 22 | + | }); | |
| 23 | + | if col_changed { | |
| 24 | + | state.save_column_config(); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | ui.add_space(8.0); | |
| 28 | + | ||
| 10 | 29 | ui.label(egui::RichText::new("Filters").strong().color(theme::text_secondary())); | |
| 11 | 30 | ui.separator(); | |
| 12 | 31 |
| @@ -53,6 +53,15 @@ pub fn draw_help_overlay(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 53 | 53 | ui.label("Cmd+Click"); | |
| 54 | 54 | ui.label("Toggle select"); | |
| 55 | 55 | ui.end_row(); | |
| 56 | + | ui.label("I"); | |
| 57 | + | ui.label("Toggle instrument panel"); | |
| 58 | + | ui.end_row(); | |
| 59 | + | ui.label("S"); | |
| 60 | + | ui.label("Toggle sidebar"); | |
| 61 | + | ui.end_row(); | |
| 62 | + | ui.label("D"); | |
| 63 | + | ui.label("Toggle detail panel"); | |
| 64 | + | ui.end_row(); | |
| 56 | 65 | ui.label("F1"); | |
| 57 | 66 | ui.label("Toggle this help"); | |
| 58 | 67 | ui.end_row(); |
| @@ -74,6 +74,17 @@ pub fn draw_toolbar(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 74 | 74 | state.undo(); | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | // Panel toggles | |
| 78 | + | let sidebar_label = if state.sidebar_visible { "\u{25E8}" } else { "\u{25E7}" }; | |
| 79 | + | if ui.button(sidebar_label).on_hover_text("Toggle sidebar (S)").clicked() { | |
| 80 | + | state.sidebar_visible = !state.sidebar_visible; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | let detail_label = if state.detail_visible { "\u{25E9}" } else { "\u{25EA}" }; | |
| 84 | + | if ui.button(detail_label).on_hover_text("Toggle detail panel (D)").clicked() { | |
| 85 | + | state.detail_visible = !state.detail_visible; | |
| 86 | + | } | |
| 87 | + | ||
| 77 | 88 | // Filter panel toggle with active indicator | |
| 78 | 89 | let filter_active = state.search_filter.is_active() && !state.filter_panel_open; | |
| 79 | 90 | let filter_label = if state.filter_panel_open { |
| @@ -71,6 +71,10 @@ pub enum CoreError { | |||
| 71 | 71 | #[error("serialization error: {0}")] | |
| 72 | 72 | Serialization(String), | |
| 73 | 73 | ||
| 74 | + | /// VFS node name contains invalid characters or is a reserved name. | |
| 75 | + | #[error("invalid node name: {0}")] | |
| 76 | + | InvalidNodeName(String), | |
| 77 | + | ||
| 74 | 78 | /// Internal logic error (e.g. invalid arguments to an internal function). | |
| 75 | 79 | #[error("internal error: {0}")] | |
| 76 | 80 | Internal(String), | |
| @@ -171,6 +175,7 @@ mod tests { | |||
| 171 | 175 | CoreError::RenameInvalid("unclosed brace".into()), | |
| 172 | 176 | CoreError::HashInvalid("bad hash".into()), | |
| 173 | 177 | CoreError::Analysis(AnalysisError::NoAudioData), | |
| 178 | + | CoreError::InvalidNodeName("contains /".into()), | |
| 174 | 179 | CoreError::Export("test export error".into()), | |
| 175 | 180 | CoreError::Serialization("test serialization error".into()), | |
| 176 | 181 | CoreError::Internal("test internal error".into()), |
| @@ -138,6 +138,30 @@ pub fn delete_vfs(db: &Database, id: VfsId) -> Result<()> { | |||
| 138 | 138 | ||
| 139 | 139 | // --- Nodes --- | |
| 140 | 140 | ||
| 141 | + | /// Validate a VFS node name. Rejects empty names, path separators, reserved | |
| 142 | + | /// names (`.` and `..`), and null bytes. | |
| 143 | + | fn validate_node_name(name: &str) -> Result<()> { | |
| 144 | + | if name.is_empty() { | |
| 145 | + | return Err(CoreError::InvalidNodeName("name must not be empty".to_string())); | |
| 146 | + | } | |
| 147 | + | if name == "." || name == ".." { | |
| 148 | + | return Err(CoreError::InvalidNodeName(format!( | |
| 149 | + | "reserved name: {name}" | |
| 150 | + | ))); | |
| 151 | + | } | |
| 152 | + | if name.contains('/') || name.contains('\\') { | |
| 153 | + | return Err(CoreError::InvalidNodeName( | |
| 154 | + | "name must not contain path separators (/ or \\)".to_string(), | |
| 155 | + | )); | |
| 156 | + | } | |
| 157 | + | if name.contains('\0') { | |
| 158 | + | return Err(CoreError::InvalidNodeName( | |
| 159 | + | "name must not contain null bytes".to_string(), | |
| 160 | + | )); | |
| 161 | + | } | |
| 162 | + | Ok(()) | |
| 163 | + | } | |
| 164 | + | ||
| 141 | 165 | /// Check for name conflicts at root level (parent_id IS NULL) since SQLite | |
| 142 | 166 | /// UNIQUE treats each NULL as distinct. | |
| 143 | 167 | fn check_root_name_conflict( | |
| @@ -166,6 +190,7 @@ pub fn create_directory( | |||
| 166 | 190 | parent_id: Option<NodeId>, | |
| 167 | 191 | name: &str, | |
| 168 | 192 | ) -> Result<NodeId> { | |
| 193 | + | validate_node_name(name)?; | |
| 169 | 194 | check_root_name_conflict(db, vfs_id, parent_id, name)?; | |
| 170 | 195 | let now = unix_now(); | |
| 171 | 196 | db.conn().execute( | |
| @@ -184,6 +209,7 @@ pub fn create_sample_link( | |||
| 184 | 209 | name: &str, | |
| 185 | 210 | sample_hash: &str, | |
| 186 | 211 | ) -> Result<NodeId> { | |
| 212 | + | validate_node_name(name)?; | |
| 187 | 213 | check_root_name_conflict(db, vfs_id, parent_id, name)?; | |
| 188 | 214 | let now = unix_now(); | |
| 189 | 215 | db.conn().execute( | |
| @@ -249,6 +275,7 @@ pub fn get_node(db: &Database, id: NodeId) -> Result<VfsNode> { | |||
| 249 | 275 | ||
| 250 | 276 | /// Rename a VFS node. Returns `NodeNotFound` if the ID doesn't exist. | |
| 251 | 277 | pub fn rename_node(db: &Database, id: NodeId, new_name: &str) -> Result<()> { | |
| 278 | + | validate_node_name(new_name)?; | |
| 252 | 279 | let changed = db.conn().execute( | |
| 253 | 280 | "UPDATE vfs_nodes SET name = ?1 WHERE id = ?2", | |
| 254 | 281 | rusqlite::params![new_name, id], | |
| @@ -870,4 +897,77 @@ mod tests { | |||
| 870 | 897 | let result = move_node(&db, a, Some(a)); | |
| 871 | 898 | assert!(result.is_err()); | |
| 872 | 899 | } | |
| 900 | + | ||
| 901 | + | // --- Node name validation tests --- | |
| 902 | + | ||
| 903 | + | #[test] | |
| 904 | + | fn validate_node_name_accepts_valid_names() { | |
| 905 | + | assert!(validate_node_name("kick.wav").is_ok()); | |
| 906 | + | assert!(validate_node_name("My Folder").is_ok()); | |
| 907 | + | assert!(validate_node_name("drums-2024").is_ok()); | |
| 908 | + | assert!(validate_node_name("a").is_ok()); | |
| 909 | + | assert!(validate_node_name("...").is_ok()); // three dots is fine | |
| 910 | + | } | |
| 911 | + | ||
| 912 | + | #[test] | |
| 913 | + | fn validate_node_name_rejects_empty() { | |
| 914 | + | let result = validate_node_name(""); | |
| 915 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | #[test] | |
| 919 | + | fn validate_node_name_rejects_dot() { | |
| 920 | + | let result = validate_node_name("."); | |
| 921 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | #[test] | |
| 925 | + | fn validate_node_name_rejects_dotdot() { | |
| 926 | + | let result = validate_node_name(".."); | |
| 927 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 928 | + | } | |
| 929 | + | ||
| 930 | + | #[test] | |
| 931 | + | fn validate_node_name_rejects_forward_slash() { | |
| 932 | + | let result = validate_node_name("foo/bar"); | |
| 933 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | #[test] | |
| 937 | + | fn validate_node_name_rejects_backslash() { | |
| 938 | + | let result = validate_node_name("foo\\bar"); | |
| 939 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | #[test] | |
| 943 | + | fn validate_node_name_rejects_null_byte() { | |
| 944 | + | let result = validate_node_name("foo\0bar"); | |
| 945 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 946 | + | } | |
| 947 | + | ||
| 948 | + | #[test] | |
| 949 | + | fn create_directory_rejects_invalid_name() { | |
| 950 | + | let db = setup(); | |
| 951 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 952 | + | let result = create_directory(&db, vfs_id, None, ".."); | |
| 953 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | #[test] | |
| 957 | + | fn create_sample_link_rejects_invalid_name() { | |
| 958 | + | let db = setup(); | |
| 959 | + | insert_fake_sample(&db, "hash1"); | |
| 960 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 961 | + | let result = create_sample_link(&db, vfs_id, None, "foo/bar.wav", "hash1"); | |
| 962 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | #[test] | |
| 966 | + | fn rename_node_rejects_invalid_name() { | |
| 967 | + | let db = setup(); | |
| 968 | + | let vfs_id = create_vfs(&db, "Lib").unwrap(); | |
| 969 | + | let dir = create_directory(&db, vfs_id, None, "Valid").unwrap(); | |
| 970 | + | let result = rename_node(&db, dir, ""); | |
| 971 | + | assert!(matches!(result, Err(CoreError::InvalidNodeName(_)))); | |
| 972 | + | } | |
| 873 | 973 | } |
| @@ -65,6 +65,7 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> { | |||
| 65 | 65 | ||
| 66 | 66 | let (tx, rx) = tokio::sync::oneshot::channel(); | |
| 67 | 67 | let expected_state = state.clone(); | |
| 68 | + | let thread_expected_state = expected_state.clone(); | |
| 68 | 69 | ||
| 69 | 70 | // Spawn blocking listener thread | |
| 70 | 71 | std::thread::spawn(move || { | |
| @@ -132,6 +133,9 @@ pub fn start_auth(client: &SyncKitClient) -> Result<AuthSession> { | |||
| 132 | 133 | let _ = stream.write_all(response.as_bytes()); | |
| 133 | 134 | ||
| 134 | 135 | if let (Some(code), Some(state)) = (code, cb_state) { | |
| 136 | + | if state != thread_expected_state { | |
| 137 | + | tracing::warn!("OAuth callback state mismatch"); | |
| 138 | + | } | |
| 135 | 139 | let _ = tx.send(CallbackResult { code, state }); | |
| 136 | 140 | } | |
| 137 | 141 | } |