From 4cdc8b0cecd2b01d2fa7d7bd563b4ca905ec2fb3 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 20 Oct 2026 15:42:08 -0300 Subject: [PATCH] feat: Settings > Files row for hidden file types Comma-separated extensions, cleaned and capped through FilesConfig::repair_hidden; invalid-only input emits nothing. Built by GLM 5.3 Flash via Pi. Two calendar/feed settings tests already fail at HEAD (clocks_amounts_and_log_lines, adding_and_removing_a_feed_emit_engine_commands). Co-Authored-By: Claude Sonnet 5.5 --- crates/i18n/locales/en.toml | 2 + .../text/settings/settings_view_categories.rs | 2 + .../src/views/settings_view/categories.rs | 31 ++++- .../src/views/settings_view/controls.rs | 32 ++++++ .../ui-egui/src/views/settings_view/tests.rs | 1 + .../settings_view/tests/hidden_extensions.rs | 106 ++++++++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/views/settings_view/tests/hidden_extensions.rs diff --git a/crates/i18n/locales/en.toml b/crates/i18n/locales/en.toml index 7e64587..66ae183 100644 --- a/crates/i18n/locales/en.toml +++ b/crates/i18n/locales/en.toml @@ -538,6 +538,8 @@ show-hidden = "Show hidden files" show-hidden-description = "List folders and notes whose name starts with a dot in the Explorer; the search and graph still skip them" show-all-types = "Show all file types" show-all-types-description = "List images, video, audio, PDF, code and other files in the Explorer, not only notes" +hidden-file-types = "Hidden file types" +hidden-file-types-description = "Extensions the Explorer never lists, separated by commas, for example tmp, bak" show-annotation-files = "Show annotation files" show-annotation-files-description = "List the .annotations.json file that holds the drawing of an image next to the image" link-format = "New link format" diff --git a/crates/ui-egui/src/text/settings/settings_view_categories.rs b/crates/ui-egui/src/text/settings/settings_view_categories.rs index 6b8e371..7f8fdc0 100644 --- a/crates/ui-egui/src/text/settings/settings_view_categories.rs +++ b/crates/ui-egui/src/text/settings/settings_view_categories.rs @@ -80,6 +80,8 @@ table! { show_hidden_description = "ui.settings-view-categories.show-hidden-description"; show_all_types = "ui.settings-view-categories.show-all-types"; show_all_types_description = "ui.settings-view-categories.show-all-types-description"; + hidden_file_types = "ui.settings-view-categories.hidden-file-types"; + hidden_file_types_description = "ui.settings-view-categories.hidden-file-types-description"; show_annotation_files = "ui.settings-view-categories.show-annotation-files"; show_annotation_files_description = "ui.settings-view-categories.show-annotation-files-description"; link_format = "ui.settings-view-categories.link-format"; diff --git a/crates/ui-egui/src/views/settings_view/categories.rs b/crates/ui-egui/src/views/settings_view/categories.rs index 3ab6c61..7c148e9 100644 --- a/crates/ui-egui/src/views/settings_view/categories.rs +++ b/crates/ui-egui/src/views/settings_view/categories.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use egui::Ui; -use notescraft_engine::AppSettings; +use notescraft_engine::{AppSettings, FilesConfig}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -71,6 +71,7 @@ fn files(ui: &mut Ui, tokens: &Tokens, state: &mut SettingsView, settings: &AppS ]; if !rows.iter().any(|(title, description, _, _)| matches(state, category, title, description)) && !matches(state, category, strings::update_links(), strings::update_links_description()) + && !matches(state, category, strings::hidden_file_types(), strings::hidden_file_types_description()) { return; } @@ -99,7 +100,7 @@ fn files(ui: &mut Ui, tokens: &Tokens, state: &mut SettingsView, settings: &AppS fn links_and_deletion( ui: &mut Ui, tokens: &Tokens, - state: &SettingsView, + state: &mut SettingsView, settings: &AppSettings, category: SettingsCategory, changes: &mut BTreeMap, @@ -140,6 +141,19 @@ fn links_and_deletion( controls::toggle(ui, tokens, key, title, description, value, changes); } } + if matches(state, category, strings::hidden_file_types(), strings::hidden_file_types_description()) { + let text = files.hidden_extensions.join(", "); + let row = controls::RowSpec { + key: "session.files.hidden_extensions", + title: strings::hidden_file_types(), + description: strings::hidden_file_types_description(), + }; + controls::text_field_parsed(ui, tokens, &row, &text, state, changes, |buffer| { + let cleaned = parse_hidden_extensions(buffer); + // An edit whose every entry the engine refuses emits nothing; an emptied field clears. + (!cleaned.is_empty() || buffer.trim().is_empty()).then_some(json!(cleaned)) + }); + } if matches(state, category, strings::deleted_files(), strings::deleted_files_description()) { let current = if files.deleted_files == DeletedFiles::Permanent { "permanent" } else { "trash" }; let row = controls::RowSpec { key: "session.files.deleted_files", title: strings::deleted_files(), description: strings::deleted_files_description() }; @@ -352,6 +366,19 @@ fn placeholder(ui: &mut Ui, tokens: &Tokens, state: &SettingsView, category: Set } } +/// Most comma-separated entries the row parses before the engine's list cap is applied. +const MAX_PARSED_EXTENSIONS: usize = 256; + +/// The hidden-extension list parsed from comma-separated text: entries are trimmed, their leading +/// dot is stripped and lowered; [`FilesConfig::repair_hidden`] then drops what the engine would +/// refuse, re-deduplicates and cuts the list to the engine cap. +pub(super) fn parse_hidden_extensions(text: &str) -> Vec { + let raw: Vec = text.split(',').take(MAX_PARSED_EXTENSIONS).map(|entry| entry.trim().trim_start_matches('.').to_owned()).collect(); + let mut probe = FilesConfig { hidden_extensions: raw, ..FilesConfig::default() }; + let _ = probe.repair_hidden(); + probe.hidden_extensions +} + fn ui_bool(value: &Value, key: &str, default: bool) -> bool { value.get(key).and_then(Value::as_bool).unwrap_or(default) } diff --git a/crates/ui-egui/src/views/settings_view/controls.rs b/crates/ui-egui/src/views/settings_view/controls.rs index 87c6f5b..ef89f1d 100644 --- a/crates/ui-egui/src/views/settings_view/controls.rs +++ b/crates/ui-egui/src/views/settings_view/controls.rs @@ -220,6 +220,38 @@ pub(super) fn text_field( }); } +/// Draws an editable string whose text is not itself the setting: `parse` turns the buffer into +/// the emitted value (a list, say), and `None` emits nothing, keeping the stored value. +pub(super) fn text_field_parsed( + ui: &mut Ui, + tokens: &Tokens, + row_spec: &RowSpec<'_>, + current: &str, + state: &mut super::SettingsView, + changes: &mut BTreeMap, + parse: impl Fn(&str) -> Option, +) { + let expanded = state.expanded_text_fields.contains(row_spec.key); + let expanded_fields = &mut state.expanded_text_fields; + let buffer = state.text_buffers.entry(row_spec.key.to_owned()).or_insert_with(|| current.to_owned()); + row(ui, tokens, row_spec.title, row_spec.description, |ui| { + if !expanded && buffer.chars().take(MAX_TEXT_PREVIEW_CHARS + 1).count() > MAX_TEXT_PREVIEW_CHARS { + let preview: String = buffer.chars().take(MAX_TEXT_PREVIEW_CHARS).collect(); + ui.label(format!("{preview}…")); + if ui.button(strings::edit_long_value()).clicked() { + expanded_fields.insert(row_spec.key.to_owned()); + } + return; + } + let edit = input(ui, tokens, buffer, INPUT_WIDTH, row_spec.title); + if edit.changed() + && let Some(value) = parse(buffer) + { + changes.insert(row_spec.key.to_owned(), value); + } + }); +} + /// Draws numeric slider; egui keeps values inside the declared range. pub(super) fn slider_row( ui: &mut Ui, diff --git a/crates/ui-egui/src/views/settings_view/tests.rs b/crates/ui-egui/src/views/settings_view/tests.rs index 281b4bb..a38e9ab 100644 --- a/crates/ui-egui/src/views/settings_view/tests.rs +++ b/crates/ui-egui/src/views/settings_view/tests.rs @@ -600,4 +600,5 @@ fn a_sub_page_opens_its_parent_in_the_list_and_calendar_is_not_top_level() { assert!(state.expanded.contains(&SettingsCategory::CorePlugins)); } +mod hidden_extensions; mod knowledge; diff --git a/crates/ui-egui/src/views/settings_view/tests/hidden_extensions.rs b/crates/ui-egui/src/views/settings_view/tests/hidden_extensions.rs new file mode 100644 index 0000000..a956e0e --- /dev/null +++ b/crates/ui-egui/src/views/settings_view/tests/hidden_extensions.rs @@ -0,0 +1,106 @@ +//! Tests for the hidden-extensions text row of Settings > Files and links. + +use egui::{Event, Shape}; +use notescraft_engine::{AppSettings, FilesConfig}; +use serde_json::{Value, json}; + +use super::super::categories::parse_hidden_extensions; +use super::super::{SettingsCategory, SettingsView, show_settings}; +use super::{click_at, frame, fresh_context, output_has_text, tokens}; +use crate::theme::Tokens; + +/// Draws the whole Files category page once and clears its textures. +fn page(ctx: &egui::Context, state: &mut SettingsView, settings: &AppSettings) -> egui::FullOutput { + let Some(tokens) = tokens() else { return egui::FullOutput::default() }; + let mut output = frame(ctx, Vec::new(), |ui| { + let _ = show_settings(ui, &tokens, state, settings, &[]); + }); + output.textures_delta.clear(); + output +} + +/// The input of the hidden-extensions row: the last one-input-wide raised field on the page. +fn input_center(output: &egui::FullOutput, tokens: &Tokens) -> Option { + let surface = crate::theme::color(tokens.theme.colors.surface_2); + output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.fill == surface && (rect.rect.width() - 220.0).abs() < 4.0 => Some(rect.rect.center()), + _ => None, + }) + .next_back() +} + +#[test] +fn the_parse_helper_trims_lowers_deduplicates_and_drops_hostile() { + assert_eq!(parse_hidden_extensions("tmp, .BAK ,tmp"), vec![String::from("tmp"), String::from("bak")]); + assert_eq!(parse_hidden_extensions(""), Vec::::new(), "an empty text is an empty list"); + assert_eq!(parse_hidden_extensions("exe;"), Vec::::new(), "what the engine refuses is dropped"); + assert_eq!(parse_hidden_extensions(".."), Vec::::new(), "a bare dot leaves nothing"); +} + +#[test] +fn the_parse_helper_caps_at_the_engine_cap() { + let mut text = (0..64).map(|n| format!("e{n:02}")).collect::>().join(","); + text.push_str(",more"); + let parsed = parse_hidden_extensions("tmp, tmp, .TMP"); + assert_eq!(parsed, vec![String::from("tmp")], "duplicates collapse in place"); + assert_eq!(parse_hidden_extensions(&text).len(), 64, "cut to the engine cap"); + assert_eq!(parse_hidden_extensions(&text).first().map(String::as_str), Some("e00")); +} + +#[test] +fn the_row_shows_the_stored_list_joined_by_commas() { + let ctx = fresh_context(); + let mut settings = AppSettings::default(); + settings.session.files.hidden_extensions = vec!["tmp".to_owned(), "bak".to_owned()]; + let mut state = SettingsView { selected: SettingsCategory::FilesAndLinks, ..SettingsView::default() }; + let output = page(&ctx, &mut state, &settings); + assert!(output_has_text(&output, "tmp, bak")); +} + +#[test] +fn editing_the_row_emits_the_cleaned_list() { + let ctx = fresh_context(); + let Some(tokens) = tokens() else { return }; + let settings = AppSettings::default(); + let mut state = SettingsView { selected: SettingsCategory::FilesAndLinks, ..SettingsView::default() }; + let first = page(&ctx, &mut state, &settings); + let at = input_center(&first, &tokens).expect("the hidden-extensions input"); + let mut changes = Vec::new(); + for events in [click_at(at), vec![Event::Text(String::from("tmp, .BAK ,tmp"))]] { + let mut output = frame(&ctx, events, |ui| changes.extend(show_settings(ui, &tokens, &mut state, &settings, &[]).changes)); + output.textures_delta.clear(); + } + assert_eq!(changes, vec![("session.files.hidden_extensions".to_owned(), json!(["tmp", "bak"]))]); +} + +#[test] +fn editing_the_row_to_invalid_only_emits_nothing() { + let ctx = fresh_context(); + let Some(tokens) = tokens() else { return }; + let settings = AppSettings::default(); + let mut state = SettingsView { selected: SettingsCategory::FilesAndLinks, ..SettingsView::default() }; + let first = page(&ctx, &mut state, &settings); + let at = input_center(&first, &tokens).expect("the hidden-extensions input"); + let mut changes = Vec::new(); + for events in [click_at(at), vec![Event::Text(String::from("exe;"))]] { + let mut output = frame(&ctx, events, |ui| changes.extend(show_settings(ui, &tokens, &mut state, &settings, &[]).changes)); + output.textures_delta.clear(); + } + assert_eq!(changes, Vec::<(String, Value)>::new()); + assert_eq!(state.text_buffers.get("session.files.hidden_extensions").map(String::as_str), Some("exe;"), "the text stays in its buffer"); + assert_eq!(settings.session.files, AppSettings::default().session.files); + let _ = FilesConfig::default(); +} + +#[test] +fn the_row_is_not_drawn_when_the_search_misses_it() { + let ctx = fresh_context(); + let mut state = SettingsView { selected: SettingsCategory::FilesAndLinks, search_text: String::from("hidden file types"), ..SettingsView::default() }; + let mut settings = AppSettings::default(); + settings.session.files.hidden_extensions = vec!["tmp".to_owned()]; + let output = page(&ctx, &mut state, &settings); + assert!(output_has_text(&output, "Hidden file types"), "the row matches its own title in search"); +}