feat: Settings > Files row for hidden file types
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-20 15:42:08 -03:00
parent 79b61756d2
commit 4cdc8b0cec
6 changed files with 172 additions and 2 deletions
+2
View File
@@ -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"
@@ -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";
@@ -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<String, Value>,
@@ -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<String> {
let raw: Vec<String> = 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)
}
@@ -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<String, Value>,
parse: impl Fn(&str) -> Option<Value>,
) {
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,
@@ -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;
@@ -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<egui::Pos2> {
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::<String>::new(), "an empty text is an empty list");
assert_eq!(parse_hidden_extensions("exe;"), Vec::<String>::new(), "what the engine refuses is dropped");
assert_eq!(parse_hidden_extensions(".."), Vec::<String>::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::<Vec<_>>().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");
}