feat: hide gitignored vault content

This commit is contained in:
lucaronin
2026-04-28 08:55:40 +02:00
parent 2fe6c5b580
commit d2c15b8469
24 changed files with 1035 additions and 75 deletions

View File

@@ -165,6 +165,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
Ok(())
}
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
}
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
let folders = vault::scan_vault_folders(vault_path)?;
Ok(vault::filter_gitignored_folders(
vault_path,
folders,
crate::settings::hide_gitignored_files_enabled(),
))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
@@ -207,12 +225,12 @@ pub fn copy_image_to_vault(
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
}
#[cfg(test)]

View File

@@ -83,8 +83,14 @@ pub async fn reload_vault(
let path = expand_tilde(&path).into_owned();
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(Path::new(&path));
vault::scan_vault_cached(Path::new(&path))
let vault_path = Path::new(&path);
vault::invalidate_cache(vault_path);
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?

View File

@@ -1,5 +1,5 @@
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;
@@ -20,6 +20,14 @@ pub struct SearchResponse {
pub mode: String,
}
pub struct SearchOptions<'a> {
pub vault_path: &'a str,
pub query: &'a str,
pub mode: &'a str,
pub limit: usize,
pub hide_gitignored_files: bool,
}
fn extract_snippet(content: &str, query_lower: &str) -> String {
let content_lower = content.to_lowercase();
let pos = match content_lower.find(query_lower) {
@@ -63,26 +71,45 @@ pub fn search_vault(
_mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
search_vault_with_options(SearchOptions {
vault_path,
query,
mode: _mode,
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
})
}
fn is_markdown_search_candidate(path: &Path) -> bool {
if !path.extension().is_some_and(|ext| ext == "md") {
return false;
}
!path
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
}
fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec<PathBuf> {
let paths = WalkDir::new(vault_dir)
.into_iter()
.filter_map(|entry| entry.ok())
.map(|entry| entry.into_path())
.filter(|path| is_markdown_search_candidate(path))
.collect::<Vec<_>>();
crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files)
}
pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchResponse, String> {
let start = Instant::now();
let query_lower = query.to_lowercase();
let vault_dir = Path::new(vault_path);
let query_lower = options.query.to_lowercase();
let vault_dir = Path::new(options.vault_path);
let mut results: Vec<SearchResult> = Vec::new();
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "md") {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let content = match std::fs::read_to_string(path) {
for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
@@ -117,15 +144,15 @@ pub fn search_vault(
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
results.truncate(options.limit);
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResponse {
results,
elapsed_ms,
query: query.to_string(),
mode: "keyword".to_string(),
query: options.query.to_string(),
mode: options.mode.to_string(),
})
}
@@ -135,6 +162,14 @@ mod tests {
use std::fs;
use tempfile::Builder;
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
#[test]
fn test_extract_snippet_basic() {
let content = "line one\nline with keyword here\nline three";
@@ -188,4 +223,38 @@ mod tests {
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Updated Display Title");
}
#[test]
fn test_search_vault_hides_gitignored_notes_when_enabled() {
let dir = Builder::new()
.prefix("search-gitignored-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
init_git_repo(dir.path());
fs::create_dir_all(dir.path().join("ignored")).unwrap();
fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap();
fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap();
let hidden = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: true,
})
.unwrap();
let shown = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
})
.unwrap();
assert_eq!(hidden.results.len(), 1);
assert_eq!(hidden.results[0].title, "Visible");
assert_eq!(shown.results.len(), 2);
}
}

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
@@ -22,6 +23,7 @@ pub struct Settings {
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub hide_gitignored_files: Option<bool>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -63,6 +65,18 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
settings
.hide_gitignored_files
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
pub fn hide_gitignored_files_enabled() -> bool {
get_settings()
.map(|settings| should_hide_gitignored_files(&settings))
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
@@ -111,6 +125,7 @@ fn normalize_settings(settings: Settings) -> Settings {
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
hide_gitignored_files: settings.hide_gitignored_files,
}
}
@@ -253,6 +268,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
@@ -280,6 +296,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
..Default::default()
});
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
@@ -292,6 +309,20 @@ mod tests {
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.hide_gitignored_files, Some(false));
}
#[test]
fn test_gitignored_files_are_hidden_by_default() {
assert!(should_hide_gitignored_files(&Settings::default()));
assert!(should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(true),
..Default::default()
}));
assert!(!should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(false),
..Default::default()
}));
}
#[test]

View File

@@ -0,0 +1,300 @@
use super::{FolderNode, VaultEntry};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use walkdir::{DirEntry, WalkDir};
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(vault_path).ok()?;
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
(!normalized.is_empty()).then_some(normalized)
}
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
}
fn has_gitignore_file(vault_path: &Path) -> bool {
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
.filter_entry(should_descend_for_gitignore)
.filter_map(Result::ok)
.any(|entry| {
entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore"
})
}
fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<String> {
let mut child = crate::hidden_command("git")
.args(["check-ignore", "--no-index", "--stdin"])
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
for path in relative_paths {
writeln!(stdin, "{path}").ok()?;
}
}
let output = child.wait_with_output().ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
None
}
fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet<String> {
if relative_paths.is_empty() || !has_gitignore_file(vault_path) {
return HashSet::new();
}
let mut candidates = relative_paths
.iter()
.map(|path| normalize_relative_path(path))
.filter(|path| !path.is_empty())
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
run_git_check_ignore(vault_path, &candidates)
.unwrap_or_default()
.lines()
.map(normalize_relative_path)
.filter(|path| !path.is_empty())
.collect()
}
fn filter_gitignored_items<T>(
vault_path: &Path,
items: Vec<T>,
hide_enabled: bool,
relative_for: impl Fn(&T) -> Option<String>,
) -> Vec<T> {
if !hide_enabled || items.is_empty() {
return items;
}
let relative_paths = items.iter().filter_map(&relative_for).collect::<Vec<_>>();
let ignored = ignored_relative_paths(vault_path, &relative_paths);
if ignored.is_empty() {
return items;
}
items
.into_iter()
.filter(|item| {
relative_for(item)
.map(|relative| !ignored.contains(&relative))
.unwrap_or(true)
})
.collect()
}
pub fn filter_gitignored_paths(
vault_path: &Path,
paths: Vec<PathBuf>,
hide_enabled: bool,
) -> Vec<PathBuf> {
filter_gitignored_items(vault_path, paths, hide_enabled, |path| {
relative_path(vault_path, path)
})
}
pub fn filter_gitignored_entries(
vault_path: &Path,
entries: Vec<VaultEntry>,
hide_enabled: bool,
) -> Vec<VaultEntry> {
filter_gitignored_items(vault_path, entries, hide_enabled, |entry| {
relative_path(vault_path, Path::new(&entry.path))
})
}
fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec<String>) {
for node in nodes {
let relative = normalize_relative_path(&node.path);
if !relative.is_empty() {
queries.push(relative.clone());
queries.push(format!("{relative}/"));
}
collect_folder_queries(&node.children, queries);
}
}
fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet<String>) -> bool {
if ignored.contains(relative_path) {
return true;
}
let mut current = relative_path;
while let Some((parent, _)) = current.rsplit_once('/') {
if ignored.contains(parent) {
return true;
}
current = parent;
}
false
}
fn filter_folder_nodes(nodes: Vec<FolderNode>, ignored: &HashSet<String>) -> Vec<FolderNode> {
nodes
.into_iter()
.filter_map(|mut node| {
let relative = normalize_relative_path(&node.path);
if path_or_parent_is_ignored(&relative, ignored) {
return None;
}
node.children = filter_folder_nodes(node.children, ignored);
Some(node)
})
.collect()
}
pub fn filter_gitignored_folders(
vault_path: &Path,
folders: Vec<FolderNode>,
hide_enabled: bool,
) -> Vec<FolderNode> {
if !hide_enabled || folders.is_empty() {
return folders;
}
let mut queries = Vec::new();
collect_folder_queries(&folders, &mut queries);
let ignored = ignored_relative_paths(vault_path, &queries);
if ignored.is_empty() {
return folders;
}
filter_folder_nodes(folders, &ignored)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
fn entry(root: &Path, relative: &str) -> VaultEntry {
VaultEntry {
path: root.join(relative).to_string_lossy().to_string(),
filename: relative.rsplit('/').next().unwrap().to_string(),
title: relative.to_string(),
..VaultEntry::default()
}
}
fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec<String> {
entries
.iter()
.map(|entry| relative_path(root, Path::new(&entry.path)).unwrap())
.collect()
}
#[test]
fn filters_ignored_entries_with_git_style_negation() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n");
write_file(dir.path(), "visible.md", "# Visible\n");
write_file(dir.path(), "ignored/hidden.md", "# Hidden\n");
write_file(dir.path(), "ignored/keep.md", "# Keep\n");
let filtered = filter_gitignored_entries(
dir.path(),
vec![
entry(dir.path(), "visible.md"),
entry(dir.path(), "ignored/hidden.md"),
entry(dir.path(), "ignored/keep.md"),
],
true,
);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["visible.md", "ignored/keep.md"]
);
}
#[test]
fn keeps_ignored_entries_when_visibility_is_enabled() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/\n");
let entries = vec![entry(dir.path(), "ignored/hidden.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, false);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["ignored/hidden.md"]
);
}
#[test]
fn filters_ignored_folder_trees() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
fs::create_dir_all(dir.path().join("generated/nested")).unwrap();
fs::create_dir_all(dir.path().join("notes")).unwrap();
let folders = vec![
FolderNode {
name: "generated".to_string(),
path: "generated".to_string(),
children: vec![FolderNode {
name: "nested".to_string(),
path: "generated/nested".to_string(),
children: vec![],
}],
},
FolderNode {
name: "notes".to_string(),
path: "notes".to_string(),
children: vec![],
},
];
let filtered = filter_gitignored_folders(dir.path(), folders, true);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let entries = vec![entry(dir.path(), "notes/local.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, true);
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
}
}

View File

@@ -6,6 +6,7 @@ pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
mod ignored;
mod image;
mod migration;
mod parsing;
@@ -24,6 +25,7 @@ pub use entry::{FolderNode, VaultEntry};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{