feat: hide gitignored vault content
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}"))?
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
300
src-tauri/src/vault/ignored.rs
Normal file
300
src-tauri/src/vault/ignored.rs
Normal 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"]);
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
7
src/lib/gitignoredVisibility.ts
Normal file
7
src/lib/gitignoredVisibility.ts
Normal 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
|
||||
}
|
||||
36
src/lib/gitignoredVisibilityEvents.ts
Normal file
36
src/lib/gitignoredVisibilityEvents.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
126
tests/smoke/gitignored-visibility.spec.ts
Normal file
126
tests/smoke/gitignored-visibility.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user