diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 04e47ccd..7a867589 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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::` - 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8110df51..efa787e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | +| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility → `Vec` | | `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` | +| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec` | | `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 | diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index 18191def..08156dc1 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -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, 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, 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, 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, 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)] diff --git a/src-tauri/src/commands/vault/scan_cmds.rs b/src-tauri/src/commands/vault/scan_cmds.rs index 3d4859da..be4cf66a 100644 --- a/src-tauri/src/commands/vault/scan_cmds.rs +++ b/src-tauri/src/commands/vault/scan_cmds.rs @@ -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}"))? diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 19da1466..90f44a45 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -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 { + 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 { + 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::>(); + + crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files) +} + +pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result { 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 = 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); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6b38f478..d84266ba 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, pub initial_h1_auto_rename_enabled: Option, pub default_ai_agent: Option, + pub hide_gitignored_files: Option, } fn normalize_optional_string(value: Option) -> Option { @@ -63,6 +65,18 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option { } } +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 { 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] diff --git a/src-tauri/src/vault/ignored.rs b/src-tauri/src/vault/ignored.rs new file mode 100644 index 00000000..d84d87df --- /dev/null +++ b/src-tauri/src/vault/ignored.rs @@ -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 { + 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 { + 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 { + 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::>(); + 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( + vault_path: &Path, + items: Vec, + hide_enabled: bool, + relative_for: impl Fn(&T) -> Option, +) -> Vec { + if !hide_enabled || items.is_empty() { + return items; + } + + let relative_paths = items.iter().filter_map(&relative_for).collect::>(); + 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, + hide_enabled: bool, +) -> Vec { + filter_gitignored_items(vault_path, paths, hide_enabled, |path| { + relative_path(vault_path, path) + }) +} + +pub fn filter_gitignored_entries( + vault_path: &Path, + entries: Vec, + hide_enabled: bool, +) -> Vec { + 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) { + 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) -> 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, ignored: &HashSet) -> Vec { + 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, + hide_enabled: bool, +) -> Vec { + 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 { + 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"]); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index c59e94ad..f55ed31d 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -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::{ diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index b622f892..a7fbfb04 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -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( + + ) + + 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() }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 8eb54d46..e5613416 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -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({ /> + + + + ) { + return ( + <> + + + + + ) +} + function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> { return AI_AGENT_DEFINITIONS.map((definition) => { const status = aiAgentsStatus[definition.id] diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index 77c4687d..d1a320ce 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -59,6 +59,7 @@ const STATIC_LABEL_KEYS: Partial> = { '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', } diff --git a/src/hooks/commands/settingsCommands.test.ts b/src/hooks/commands/settingsCommands.test.ts index 3be55f8c..59ad255b 100644 --- a/src/hooks/commands/settingsCommands.test.ts +++ b/src/hooks/commands/settingsCommands.test.ts @@ -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) + }) }) diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index ce03ccb7..e09491a9 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -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): CommandAction[] { + onToggleGitignoredFilesVisibility, +}: Pick): 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, + }), ] } diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 41d8cd8c..277023e8 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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 + 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)) diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index da178768..d120897d 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -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()) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3f2ca2e6..a862bfcd 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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('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 } } diff --git a/src/hooks/useUnifiedSearch.ts b/src/hooks/useUnifiedSearch.ts index f96ba979..ab0cdb98 100644 --- a/src/hooks/useUnifiedSearch.ts +++ b/src/hooks/useUnifiedSearch.ts @@ -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([]) @@ -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 } } diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 9adb236b..4e894e28 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -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(command: string, tauriArgs: Record, mockArgs?: Record): Promise { @@ -168,6 +173,23 @@ interface ResolveNoteStatusOptions { unsavedPaths?: Set } +function resolveTransientNoteStatus({ + path, + pendingSavePaths, + unsavedPaths, +}: Pick): 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, '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([]) const [folders, setFolders] = useState([]) @@ -441,6 +485,7 @@ export function useVaultLoader(vaultPath: string) { setFolders, setViews, }) + useGitignoredVisibilityReloads(vaultReloads) useInitialVaultLoad({ vaultPath, diff --git a/src/lib/gitignoredVisibility.ts b/src/lib/gitignoredVisibility.ts new file mode 100644 index 00000000..0132bd68 --- /dev/null +++ b/src/lib/gitignoredVisibility.ts @@ -0,0 +1,7 @@ +import type { Settings } from '../types' + +export const DEFAULT_HIDE_GITIGNORED_FILES = true + +export function shouldHideGitignoredFiles(settings: Pick): boolean { + return settings.hide_gitignored_files ?? DEFAULT_HIDE_GITIGNORED_FILES +} diff --git a/src/lib/gitignoredVisibilityEvents.ts b/src/lib/gitignoredVisibilityEvents.ts new file mode 100644 index 00000000..706cc110 --- /dev/null +++ b/src/lib/gitignoredVisibilityEvents.ts @@ -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 +export type GitignoredVisibilityAppliedEvent = CustomEvent + +function dispatchBrowserEvent(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), + }) +} diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index b2266222..48fab025 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/types.ts b/src/types.ts index 5080240c..b1a72ae0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/tests/smoke/gitignored-visibility.spec.ts b/tests/smoke/gitignored-visibility.spec.ts new file mode 100644 index 00000000..a4676e70 --- /dev/null +++ b/tests/smoke/gitignored-visibility.spec.ts @@ -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 { + await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault)) + await page.evaluate(({ ignoredDir }) => { + type Handler = (args?: Record) => unknown + type SearchResponse = { results: Array<{ path: string }>; elapsed_ms: number } + type Settings = Record & { hide_gitignored_files?: boolean | null } + + const handlers = window.__mockHandlers as Record + 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) => { + settings = { ...settings, ...((args?.settings as Settings | undefined) ?? {}) } + return null + } + handlers.list_vault = async (args?: Record) => + filterEntries(await originalList(args) as Array<{ path: string }>) + handlers.reload_vault = async (args?: Record) => + filterEntries(await originalReload(args) as Array<{ path: string }>) + handlers.search_vault = async (args?: Record) => { + 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 { + 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 { + 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 { + 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 { + 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) +}) diff --git a/ui-design.pen b/ui-design.pen index c1afc114..2457aa28 100644 --- a/ui-design.pen +++ b/ui-design.pen @@ -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" } ] -} \ No newline at end of file +}