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 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user