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

@@ -323,20 +323,21 @@ type SidebarSelection =
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
3. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
@@ -634,7 +635,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
### Search
Keyword-based search scans all vault `.md` files using `walkdir`:
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
```typescript
interface SearchResult {
@@ -726,10 +727,11 @@ interface Settings {
theme_mode: 'light' | 'dark' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
hide_gitignored_files: boolean | null // null = default true
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
@@ -82,6 +83,7 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
#### External Change Detection
@@ -618,6 +620,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
@@ -632,7 +635,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
@@ -649,7 +652,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
@@ -661,7 +664,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
| `batch_archive_notes` | Archive multiple notes |
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
| `check_vault_exists` | Check if vault path exists |
@@ -788,7 +791,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |

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::{

View File

@@ -17,6 +17,8 @@ const emptySettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
function installPointerCapturePolyfill() {
@@ -102,6 +104,21 @@ describe('SettingsPanel', () => {
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
hide_gitignored_files: true,
}))
expect(onClose).toHaveBeenCalled()
})
it('saves Gitignored content visibility immediately for keyboard close', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-hide-gitignored-files'))
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
hide_gitignored_files: false,
}))
expect(onClose).toHaveBeenCalled()
})

View File

@@ -33,6 +33,7 @@ import {
type ThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
import { Checkbox, type CheckedState } from './ui/checkbox'
@@ -70,6 +71,7 @@ interface SettingsDraft {
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
initialH1AutoRename: boolean
hideGitignoredFiles: boolean
crashReporting: boolean
analytics: boolean
explicitOrganization: boolean
@@ -101,6 +103,8 @@ interface SettingsBodyProps {
systemLocale: AppLocale
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
hideGitignoredFiles: boolean
setHideGitignoredFiles: (value: boolean) => void
explicitOrganization: boolean
setExplicitOrganization: (value: boolean) => void
crashReporting: boolean
@@ -139,6 +143,7 @@ function createSettingsDraft(
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
hideGitignoredFiles: shouldHideGitignoredFiles(settings),
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
explicitOrganization: explicitOrganizationEnabled,
@@ -180,6 +185,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
hide_gitignored_files: draft.hideGitignoredFiles,
}
}
@@ -268,6 +274,11 @@ function SettingsPanelInner({
[],
)
const handleGitignoredVisibilityChange = useCallback((value: boolean) => {
updateDraft('hideGitignoredFiles', value)
onSave({ ...settings, hide_gitignored_files: value })
}, [onSave, settings, updateDraft])
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
onSave(buildSettingsFromDraft(settings, draft))
@@ -338,6 +349,8 @@ function SettingsPanelInner({
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
hideGitignoredFiles={draft.hideGitignoredFiles}
setHideGitignoredFiles={handleGitignoredVisibilityChange}
explicitOrganization={draft.explicitOrganization}
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
crashReporting={draft.crashReporting}
@@ -397,6 +410,8 @@ function SettingsBody({
setUiLanguage,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
setHideGitignoredFiles,
explicitOrganization,
setExplicitOrganization,
crashReporting,
@@ -455,6 +470,14 @@ function SettingsBody({
/>
</SettingsSection>
<SettingsSection>
<VaultContentSettingsSection
t={t}
hideGitignoredFiles={hideGitignoredFiles}
setHideGitignoredFiles={setHideGitignoredFiles}
/>
</SettingsSection>
<SettingsSection>
<AiAgentSettingsSection
t={t}
@@ -733,6 +756,29 @@ function TitleSettingsSection({
)
}
function VaultContentSettingsSection({
t,
hideGitignoredFiles,
setHideGitignoredFiles,
}: Pick<SettingsBodyProps, 't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles'>) {
return (
<>
<SectionHeading
title={t('settings.vaultContent.title')}
description={t('settings.vaultContent.description')}
/>
<SettingsSwitchRow
label={t('settings.vaultContent.hideGitignored')}
description={t('settings.vaultContent.hideGitignoredDescription')}
checked={hideGitignoredFiles}
onChange={setHideGitignoredFiles}
testId="settings-hide-gitignored-files"
/>
</>
)
}
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]

View File

@@ -59,6 +59,7 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
'restore-getting-started': 'command.settings.restoreGettingStarted',
'reload-vault': 'command.settings.reloadVault',
'repair-vault': 'command.settings.repairVault',
'toggle-gitignored-files-visibility': 'command.settings.toggleGitignoredFilesVisibility',
'open-ai-agents': 'command.ai.openAgents',
'restore-vault-ai-guidance': 'command.ai.restoreGuidance',
}

View File

@@ -1,22 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { formatShortcutDisplay } from '../appCommandCatalog'
import { TOGGLE_GITIGNORED_VISIBILITY_EVENT } from '../../lib/gitignoredVisibilityEvents'
import { buildSettingsCommands } from './settingsCommands'
function findCommand(id: string, commands = buildSettingsCommands({ onOpenSettings: vi.fn() })) {
return commands.find((item) => item.id === id)
}
function expectOpenSettingsCommand(id: string, label: string) {
const onOpenSettings = vi.fn()
const command = findCommand(id, buildSettingsCommands({ onOpenSettings }))
expect(command).toMatchObject({
label,
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
}
describe('buildSettingsCommands', () => {
it('adds a discoverable H1 auto-rename settings command', () => {
const onOpenSettings = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings })
const command = commands.find((item) => item.id === 'open-h1-auto-rename-setting')
expect(command).toMatchObject({
label: 'Open H1 Auto-Rename Setting',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
expectOpenSettingsCommand('open-h1-auto-rename-setting', 'Open H1 Auto-Rename Setting')
})
it('keeps the general settings command available', () => {
@@ -32,19 +39,7 @@ describe('buildSettingsCommands', () => {
})
it('adds a discoverable language settings command', () => {
const onOpenSettings = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings })
const command = commands.find((item) => item.id === 'open-language-settings')
expect(command).toMatchObject({
label: 'Open Language Settings',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onOpenSettings).toHaveBeenCalledTimes(1)
expectOpenSettingsCommand('open-language-settings', 'Open Language Settings')
})
it('adds language switch commands when a setter is available', () => {
@@ -101,4 +96,34 @@ describe('buildSettingsCommands', () => {
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
it('adds a command palette toggle for Gitignored file visibility', () => {
const onOpenSettings = vi.fn()
const onToggleGitignoredFilesVisibility = vi.fn()
const commands = buildSettingsCommands({
onOpenSettings,
onToggleGitignoredFilesVisibility,
})
const command = commands.find((item) => item.id === 'toggle-gitignored-files-visibility')
expect(command).toMatchObject({
label: 'Toggle Gitignored Files Visibility',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onToggleGitignoredFilesVisibility).toHaveBeenCalledTimes(1)
})
it('dispatches the Gitignored visibility event when no direct handler is provided', () => {
const listener = vi.fn()
window.addEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
findCommand('toggle-gitignored-files-visibility')?.execute()
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
})
})

View File

@@ -1,6 +1,7 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
import { requestGitignoredVisibilityToggle } from '../../lib/gitignoredVisibilityEvents'
import {
APP_LOCALES,
SYSTEM_UI_LANGUAGE,
@@ -25,6 +26,7 @@ interface SettingsCommandsConfig {
onInstallMcp?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onToggleGitignoredFilesVisibility?: () => void
locale?: AppLocale
systemLocale?: AppLocale
selectedUiLanguage?: UiLanguagePreference
@@ -140,7 +142,8 @@ function buildMaintenanceCommands({
onInstallMcp,
onReloadVault,
onRepairVault,
}: Pick<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'>): CommandAction[] {
onToggleGitignoredFilesVisibility,
}: Pick<SettingsCommandsConfig, 'mcpStatus' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault' | 'onToggleGitignoredFilesVisibility'>): CommandAction[] {
return [
{
id: 'install-mcp',
@@ -150,6 +153,14 @@ function buildMaintenanceCommands({
enabled: true,
execute: () => onInstallMcp?.(),
},
{
id: 'toggle-gitignored-files-visibility',
label: 'Toggle Gitignored Files Visibility',
group: 'Settings',
keywords: ['gitignore', 'ignored', 'files', 'folders', 'visibility', 'hide', 'show', 'generated', 'local'],
enabled: true,
execute: onToggleGitignoredFilesVisibility ?? requestGitignoredVisibilityToggle,
},
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
]
@@ -159,7 +170,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, onToggleGitignoredFilesVisibility,
locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage,
} = config
@@ -180,6 +191,12 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
onRemoveActiveVault,
onRestoreGettingStarted,
}),
...buildMaintenanceCommands({ mcpStatus, onInstallMcp, onReloadVault, onRepairVault }),
...buildMaintenanceCommands({
mcpStatus,
onInstallMcp,
onReloadVault,
onRepairVault,
onToggleGitignoredFilesVisibility,
}),
]
}

View File

@@ -1,7 +1,11 @@
import { useCallback } from 'react'
import { useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { useTabManagement } from './useTabManagement'
import {
GITIGNORED_VISIBILITY_APPLIED_EVENT,
type GitignoredVisibilityAppliedEvent,
} from '../lib/gitignoredVisibilityEvents'
import { resolveEntry } from '../utils/wikilink'
import { useNoteCreation } from './useNoteCreation'
import {
@@ -215,6 +219,33 @@ function buildTabManagementOptions(
return options
}
function useGitignoredVisibilityTabCleanup({
activeTabPathRef,
closeAllTabs,
setToastMessage,
}: {
activeTabPathRef: React.MutableRefObject<string | null>
closeAllTabs: () => void
setToastMessage: (msg: string | null) => void
}) {
useEffect(() => {
if (typeof window === 'undefined') return
const handleVisibilityApplied = (event: Event) => {
const { hide, visiblePaths } = (event as GitignoredVisibilityAppliedEvent).detail
const activePath = activeTabPathRef.current
if (!hide || !activePath || visiblePaths.includes(activePath)) return
closeAllTabs()
setToastMessage('Closed hidden Gitignored file')
}
window.addEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied)
return () => {
window.removeEventListener(GITIGNORED_VISIBILITY_APPLIED_EVENT, handleVisibilityApplied)
}
}, [activeTabPathRef, closeAllTabs, setToastMessage])
}
function useFrontmatterActionHandlers({
config,
renameTabsRef,
@@ -297,6 +328,11 @@ export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement(buildTabManagementOptions(config))
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
useGitignoredVisibilityTabCleanup({
activeTabPathRef,
closeAllTabs: tabMgmt.closeAllTabs,
setToastMessage,
})
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))

View File

@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import type { Settings } from '../types'
import {
GITIGNORED_VISIBILITY_CHANGED_EVENT,
TOGGLE_GITIGNORED_VISIBILITY_EVENT,
} from '../lib/gitignoredVisibilityEvents'
import { useSettings } from './useSettings'
const defaultSettings: Settings = {
@@ -18,6 +22,7 @@ const defaultSettings: Settings = {
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
const savedSettings: Settings = {
@@ -34,6 +39,7 @@ const savedSettings: Settings = {
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
let mockSettingsStore: Settings = { ...defaultSettings }
@@ -83,6 +89,7 @@ function changedSettings(): Settings {
theme_mode: null,
ui_language: 'zh-CN',
default_ai_agent: null,
hide_gitignored_files: false,
}
}
@@ -160,6 +167,37 @@ describe('useSettings', () => {
expect(result.current.settings).toEqual(newSettings)
})
it('preserves the Gitignored files visibility preference', async () => {
mockSettingsStore = {
...savedSettings,
hide_gitignored_files: false,
}
const settings = await renderLoadedSettings()
expect(settings.hide_gitignored_files).toBe(false)
})
it('toggles Gitignored file visibility from the command event', async () => {
const listener = vi.fn()
window.addEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, listener)
const { result } = renderHook(() => useSettings())
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
await act(async () => {
window.dispatchEvent(new CustomEvent(TOGGLE_GITIGNORED_VISIBILITY_EVENT))
})
await waitFor(() => {
expect(result.current.settings.hide_gitignored_files).toBe(false)
})
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, listener)
})
it('saves settings through native invoke when Tauri globals are not detectable', async () => {
const { result } = renderHook(() => useSettings())

View File

@@ -2,6 +2,11 @@ import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { normalizeStoredAiAgent } from '../lib/aiAgents'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import {
notifyGitignoredVisibilityChanged,
TOGGLE_GITIGNORED_VISIBILITY_EVENT,
} from '../lib/gitignoredVisibilityEvents'
import { serializeUiLanguagePreference } from '../lib/i18n'
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
import { normalizeThemeMode } from '../lib/themeMode'
@@ -39,6 +44,7 @@ const EMPTY_SETTINGS: Settings = {
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
function normalizeSettings(settings: Settings): Settings {
@@ -50,6 +56,7 @@ function normalizeSettings(settings: Settings): Settings {
theme_mode: normalizeThemeMode(settings.theme_mode),
ui_language: serializeUiLanguagePreference(settings.ui_language),
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
hide_gitignored_files: settings.hide_gitignored_files ?? null,
}
}
@@ -73,14 +80,35 @@ export function useSettings() {
}, [loadSettings])
const saveSettings = useCallback(async (newSettings: Settings) => {
const previousHideGitignored = shouldHideGitignoredFiles(settings)
const normalizedSettings = normalizeSettings(newSettings)
try {
await tauriCall<null>('save_settings', { settings: normalizedSettings })
setSettings(normalizedSettings)
const nextHideGitignored = shouldHideGitignoredFiles(normalizedSettings)
if (previousHideGitignored !== nextHideGitignored) {
notifyGitignoredVisibilityChanged(nextHideGitignored)
}
} catch (err) {
console.error('Failed to save settings:', err)
}
}, [])
}, [settings])
useEffect(() => {
if (typeof window === 'undefined') return
const handleToggleGitignoredVisibility = () => {
void saveSettings({
...settings,
hide_gitignored_files: !shouldHideGitignoredFiles(settings),
})
}
window.addEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, handleToggleGitignoredVisibility)
return () => {
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, handleToggleGitignoredVisibility)
}
}, [saveSettings, settings])
return { settings, loaded, saveSettings }
}

View File

@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import type { SearchResult } from '../types'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { GITIGNORED_VISIBILITY_CHANGED_EVENT } from '../lib/gitignoredVisibilityEvents'
interface SearchResultData {
title: string
@@ -42,6 +43,29 @@ function mapResults(raw: SearchResultData[]): SearchResult[] {
})
}
function useGitignoredVisibilitySearchRefresh({
active,
performSearch,
query,
}: {
active: boolean
performSearch: (query: string) => void
query: string
}) {
useEffect(() => {
if (typeof window === 'undefined') return
const handleGitignoredVisibilityChanged = () => {
if (active && query.trim()) performSearch(query)
}
window.addEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleGitignoredVisibilityChanged)
return () => {
window.removeEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleGitignoredVisibilityChanged)
}
}, [active, performSearch, query])
}
export function useUnifiedSearch(vaultPath: string, active: boolean) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
@@ -103,5 +127,11 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
}
}, [query, performSearch])
useGitignoredVisibilitySearchRefresh({
active,
performSearch: (nextQuery) => { void performSearch(nextQuery) },
query,
})
return { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs }
}

View File

@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
import {
GITIGNORED_VISIBILITY_CHANGED_EVENT,
notifyGitignoredVisibilityApplied,
type GitignoredVisibilityChangedEvent,
} from '../lib/gitignoredVisibilityEvents'
import { clearPrefetchCache } from './useTabManagement'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
@@ -168,6 +173,23 @@ interface ResolveNoteStatusOptions {
unsavedPaths?: Set<string>
}
function resolveTransientNoteStatus({
path,
pendingSavePaths,
unsavedPaths,
}: Pick<ResolveNoteStatusOptions, 'path' | 'pendingSavePaths' | 'unsavedPaths'>): NoteStatus | null {
if (unsavedPaths?.has(path)) return 'unsaved'
if (pendingSavePaths?.has(path)) return 'pendingSave'
return null
}
function resolveGitBackedNoteStatus(file: ModifiedFile | undefined): NoteStatus {
if (!file) return 'clean'
if (file.status === 'untracked' || file.status === 'added') return 'new'
if (file.status === 'modified' || file.status === 'deleted') return 'modified'
return 'clean'
}
export function resolveNoteStatus({
path,
newPaths,
@@ -175,14 +197,10 @@ export function resolveNoteStatus({
pendingSavePaths,
unsavedPaths,
}: ResolveNoteStatusOptions): NoteStatus {
if (unsavedPaths?.has(path)) return 'unsaved'
if (pendingSavePaths?.has(path)) return 'pendingSave'
const transientStatus = resolveTransientNoteStatus({ path, pendingSavePaths, unsavedPaths })
if (transientStatus) return transientStatus
if (newPaths.has(path)) return 'new'
const gitEntry = modifiedFiles.find((f) => f.path === path)
if (!gitEntry) return 'clean'
if (gitEntry.status === 'untracked' || gitEntry.status === 'added') return 'new'
if (gitEntry.status === 'modified' || gitEntry.status === 'deleted') return 'modified'
return 'clean'
return resolveGitBackedNoteStatus(modifiedFiles.find((file) => file.path === path))
}
function useInitialVaultLoad({
@@ -415,6 +433,32 @@ function useVaultReloads({
return { isReloading, reloadFolders, reloadVault, reloadViews, resetReloading }
}
function useGitignoredVisibilityReloads(
reloads: Pick<ReturnType<typeof useVaultReloads>, 'reloadFolders' | 'reloadVault' | 'reloadViews'>,
) {
const { reloadFolders, reloadVault, reloadViews } = reloads
useEffect(() => {
if (typeof window === 'undefined') return
const handleVisibilityChanged = (event: Event) => {
const { hide } = (event as GitignoredVisibilityChangedEvent).detail
void Promise.all([
reloadVault(),
reloadFolders(),
reloadViews(),
]).then(([entries]) => {
notifyGitignoredVisibilityApplied(hide, entries)
})
}
window.addEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleVisibilityChanged)
return () => {
window.removeEventListener(GITIGNORED_VISIBILITY_CHANGED_EVENT, handleVisibilityChanged)
}
}, [reloadFolders, reloadVault, reloadViews])
}
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [folders, setFolders] = useState<FolderNode[]>([])
@@ -441,6 +485,7 @@ export function useVaultLoader(vaultPath: string) {
setFolders,
setViews,
})
useGitignoredVisibilityReloads(vaultReloads)
useInitialVaultLoad({
vaultPath,

View File

@@ -0,0 +1,7 @@
import type { Settings } from '../types'
export const DEFAULT_HIDE_GITIGNORED_FILES = true
export function shouldHideGitignoredFiles(settings: Pick<Settings, 'hide_gitignored_files'>): boolean {
return settings.hide_gitignored_files ?? DEFAULT_HIDE_GITIGNORED_FILES
}

View File

@@ -0,0 +1,36 @@
import type { VaultEntry } from '../types'
export const TOGGLE_GITIGNORED_VISIBILITY_EVENT = 'tolaria:toggle-gitignored-visibility'
export const GITIGNORED_VISIBILITY_CHANGED_EVENT = 'tolaria:gitignored-visibility-changed'
export const GITIGNORED_VISIBILITY_APPLIED_EVENT = 'tolaria:gitignored-visibility-applied'
interface GitignoredVisibilityChangedDetail {
hide: boolean
}
interface GitignoredVisibilityAppliedDetail extends GitignoredVisibilityChangedDetail {
visiblePaths: string[]
}
export type GitignoredVisibilityChangedEvent = CustomEvent<GitignoredVisibilityChangedDetail>
export type GitignoredVisibilityAppliedEvent = CustomEvent<GitignoredVisibilityAppliedDetail>
function dispatchBrowserEvent<T>(name: string, detail: T): void {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(name, { detail }))
}
export function requestGitignoredVisibilityToggle(): void {
dispatchBrowserEvent(TOGGLE_GITIGNORED_VISIBILITY_EVENT, {})
}
export function notifyGitignoredVisibilityChanged(hide: boolean): void {
dispatchBrowserEvent(GITIGNORED_VISIBILITY_CHANGED_EVENT, { hide })
}
export function notifyGitignoredVisibilityApplied(hide: boolean, entries: VaultEntry[]): void {
dispatchBrowserEvent(GITIGNORED_VISIBILITY_APPLIED_EVENT, {
hide,
visiblePaths: entries.map((entry) => entry.path),
})
}

View File

@@ -12,6 +12,7 @@
"command.openLanguageSettings.keywords": "language locale i18n internationalization localization english italian french german russian spanish portuguese chinese japanese korean 中文",
"command.useSystemLanguage": "Use System Language",
"command.openH1Setting": "Open H1 Auto-Rename Setting",
"command.toggleGitignoredFilesVisibility": "Toggle Gitignored Files Visibility",
"command.contribute": "Contribute",
"command.checkUpdates": "Check for Updates",
"command.group.navigation": "Navigation",
@@ -79,6 +80,7 @@
"command.settings.setupExternalAi": "Set Up External AI Tools…",
"command.settings.reloadVault": "Reload Vault",
"command.settings.repairVault": "Repair Vault",
"command.settings.toggleGitignoredFilesVisibility": "Toggle Gitignored Files Visibility",
"command.ai.openAgents": "Open AI Agents",
"command.ai.restoreGuidance": "Restore Tolaria AI Guidance",
"command.ai.switchToAgent": "Switch AI Agent to {agent}",
@@ -113,6 +115,10 @@
"settings.titles.description": "Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title.",
"settings.titles.autoRename": "Auto-rename untitled notes from first H1",
"settings.titles.autoRenameDescription": "When enabled, Tolaria renames untitled-note files as soon as the first H1 becomes a real title. Turn this off to keep the filename unchanged until you rename it manually from the breadcrumb bar.",
"settings.vaultContent.title": "Vault Content",
"settings.vaultContent.description": "Control whether files matched by the vault .gitignore appear in Tolaria.",
"settings.vaultContent.hideGitignored": "Hide files and folders ignored by Git",
"settings.vaultContent.hideGitignoredDescription": "Keeps generated and local-only vault files out of notes, search, quick open, and folders.",
"settings.aiAgents.title": "AI Agents",
"settings.aiAgents.description": "Choose which CLI AI agent Tolaria uses in the AI panel and command palette.",
"settings.aiAgents.default": "Default AI agent",

View File

@@ -95,6 +95,7 @@ export interface Settings {
ui_language?: AppLocale | null
initial_h1_auto_rename_enabled?: boolean | null
default_ai_agent?: AiAgentId | null
hide_gitignored_files?: boolean | null
}
export interface GitPullResult {

View File

@@ -0,0 +1,126 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
const IGNORED_DIR = 'ignored-local'
const IGNORED_TITLE = 'Ignored Local Note'
const IGNORED_NEEDLE = 'gitignored-visibility-needle'
const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]'
const GLOBAL_SEARCH_INPUT = 'input[placeholder="Search in all notes..."]'
let tempVaultDir: string
function writeIgnoredFixture(vaultPath: string): void {
fs.writeFileSync(path.join(vaultPath, '.gitignore'), `${IGNORED_DIR}/\n`)
fs.mkdirSync(path.join(vaultPath, IGNORED_DIR), { recursive: true })
fs.writeFileSync(
path.join(vaultPath, IGNORED_DIR, 'ignored-local-note.md'),
`---\ntype: Note\n---\n# ${IGNORED_TITLE}\n\n${IGNORED_NEEDLE}\n`,
)
}
async function installGitignoredVisibilityHarness(page: Page): Promise<void> {
await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault))
await page.evaluate(({ ignoredDir }) => {
type Handler = (args?: Record<string, unknown>) => unknown
type SearchResponse = { results: Array<{ path: string }>; elapsed_ms: number }
type Settings = Record<string, unknown> & { hide_gitignored_files?: boolean | null }
const handlers = window.__mockHandlers as Record<string, Handler>
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
get: () => handlers,
set: (nextHandlers) => Object.assign(handlers, nextHandlers),
})
const originalGetSettings = handlers.get_settings?.bind(handlers)
const originalList = handlers.list_vault.bind(handlers)
const originalReload = handlers.reload_vault.bind(handlers)
const originalSearch = handlers.search_vault.bind(handlers)
let settings: Settings = {
...((originalGetSettings?.() as Settings | undefined) ?? {}),
hide_gitignored_files: null,
}
const ignoredPathFragment = `/${ignoredDir}/`
const isIgnoredPath = (candidate: string) =>
candidate.includes(ignoredPathFragment) || candidate.includes(`\\${ignoredDir}\\`)
const shouldShowPath = (candidate: string) =>
settings.hide_gitignored_files === false || !isIgnoredPath(candidate)
const filterEntries = (entries: Array<{ path: string }>) =>
entries.filter((entry) => shouldShowPath(entry.path))
handlers.get_settings = () => settings
handlers.save_settings = (args?: Record<string, unknown>) => {
settings = { ...settings, ...((args?.settings as Settings | undefined) ?? {}) }
return null
}
handlers.list_vault = async (args?: Record<string, unknown>) =>
filterEntries(await originalList(args) as Array<{ path: string }>)
handlers.reload_vault = async (args?: Record<string, unknown>) =>
filterEntries(await originalReload(args) as Array<{ path: string }>)
handlers.search_vault = async (args?: Record<string, unknown>) => {
const response = await originalSearch(args) as SearchResponse
return {
...response,
results: response.results.filter((result) => shouldShowPath(result.path)),
}
}
}, { ignoredDir: IGNORED_DIR })
}
async function reloadVaultFromCommandPalette(page: Page): Promise<void> {
await openCommandPalette(page)
await executeCommand(page, 'Reload Vault')
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
}
async function openQuickOpen(page: Page): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible()
}
async function expectQuickOpenResult(page: Page, visible: boolean): Promise<void> {
await openQuickOpen(page)
await page.locator(QUICK_OPEN_INPUT).fill(IGNORED_TITLE)
const result = page.getByTestId('quick-open-palette').getByText(IGNORED_TITLE, { exact: true })
await expect(result).toHaveCount(visible ? 1 : 0, { timeout: 5_000 })
await page.keyboard.press('Escape')
}
async function expectGlobalSearchResult(page: Page, visible: boolean): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'f', ['Control', 'Shift'])
await expect(page.locator(GLOBAL_SEARCH_INPUT)).toBeVisible()
await page.locator(GLOBAL_SEARCH_INPUT).fill(IGNORED_NEEDLE)
const result = page.getByText(IGNORED_TITLE, { exact: true })
await expect(result).toHaveCount(visible ? 1 : 0, { timeout: 5_000 })
await page.keyboard.press('Escape')
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
writeIgnoredFixture(tempVaultDir)
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await installGitignoredVisibilityHarness(page)
await reloadVaultFromCommandPalette(page)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('Gitignored vault content hides by default and reappears from the command palette', async ({ page }) => {
await expectQuickOpenResult(page, false)
await expectGlobalSearchResult(page, false)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Gitignored Files Visibility')
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
await expectQuickOpenResult(page, true)
await expectGlobalSearchResult(page, true)
})

View File

@@ -14028,6 +14028,76 @@
]
}
]
},
{
"type": "frame",
"id": "gitignored_visibility_settings",
"x": 1480,
"y": 7900,
"name": "Settings - Gitignored files visibility",
"theme": {
"Mode": "Light"
},
"width": 520,
"height": 188,
"fill": "$--background",
"padding": [
24,
24
],
"children": [
{
"type": "text",
"id": "gitignored_visibility_heading",
"name": "sectionHeading",
"fill": "$--muted-foreground",
"content": "VAULT CONTENT",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "700",
"letterSpacing": 0
},
{
"type": "text",
"id": "gitignored_visibility_description",
"name": "sectionDescription",
"fill": "$--muted-foreground",
"content": "Control whether files matched by the vault .gitignore appear in Tolaria.",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal",
"letterSpacing": 0
},
{
"type": "frame",
"id": "gitignored_visibility_row",
"name": "switchRow",
"width": 472,
"height": 54,
"children": [
{
"type": "text",
"id": "gitignored_visibility_label",
"fill": "$--foreground",
"content": "Hide files and folders ignored by Git",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500",
"letterSpacing": 0
},
{
"type": "text",
"id": "gitignored_visibility_helper",
"fill": "$--muted-foreground",
"content": "Keeps generated and local-only vault files out of notes, search, quick open, and folders.",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal",
"letterSpacing": 0
}
]
}
]
}
],
"themes": {
@@ -14916,4 +14986,4 @@
"url": "GT-Pressura-Regular-Trial.otf"
}
]
}
}