From 284af17483c3cc762ccb7e8086e23da53acf6d93 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 17:38:09 +0200 Subject: [PATCH] feat: show all files in folder view, open text files in raw editor, gray out binary Vault scanner now includes all files (not just .md). Each VaultEntry has a fileKind field ("markdown", "text", or "binary") that controls how the frontend handles it: - Folder view shows all file kinds; other views (All Notes, type sections) continue to show only markdown files - Text files (.yml, .json, .txt, .py, etc.) open in the raw CodeMirror editor - Binary files (.png, .jpg, .pdf, etc.) appear grayed out and are not clickable - Non-markdown files use filename as title, skip frontmatter parsing Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/vault/cache.rs | 13 +++-- src-tauri/src/vault/entry.rs | 8 +++ src-tauri/src/vault/mod.rs | 96 +++++++++++++++++++++++++++---- src-tauri/src/vault/mod_tests.rs | 9 ++- src/components/EditorContent.tsx | 9 ++- src/components/NoteItem.tsx | 53 +++++++++++------ src/components/NoteList.test.tsx | 41 +++++++++++++ src/hooks/useTabManagement.ts | 6 +- src/types.ts | 3 + src/utils/noteListHelpers.test.ts | 35 +++++++++++ src/utils/noteListHelpers.ts | 15 +++-- 11 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/vault/cache.rs b/src-tauri/src/vault/cache.rs index adea4b2b..71fed738 100644 --- a/src-tauri/src/vault/cache.rs +++ b/src-tauri/src/vault/cache.rs @@ -6,12 +6,12 @@ use std::path::{Path, PathBuf}; use crate::git::{get_all_file_dates, GitDates}; use std::collections::HashMap; -use super::{parse_md_file, scan_vault, VaultEntry}; +use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry}; // --- Vault Cache --- /// Bump this when VaultEntry fields change to force a full rescan. -const CACHE_VERSION: u32 = 10; +const CACHE_VERSION: u32 = 11; #[derive(Debug, Serialize, Deserialize)] struct VaultCache { @@ -175,7 +175,8 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String { .to_string() } -/// Parse .md files from a list of relative paths, skipping any that don't exist. +/// Parse files from a list of relative paths, skipping any that don't exist. +/// Dispatches to the appropriate parser based on file extension. fn parse_files_at( vault: &Path, rel_paths: &[String], @@ -189,7 +190,11 @@ fn parse_files_at( let dates = git_dates .get(rel.as_str()) .map(|d| (d.modified_at, d.created_at)); - parse_md_file(&abs, dates).ok() + if is_md_file(&abs) { + parse_md_file(&abs, dates).ok() + } else { + parse_non_md_file(&abs, dates).ok() + } } else { None } diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index 43aa7c53..fdbbff5c 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -75,4 +75,12 @@ pub struct VaultEntry { /// Only includes strings, numbers, and booleans — arrays/objects are excluded. #[serde(default)] pub properties: HashMap, + /// File kind: "markdown", "text", or "binary". + /// Determines how the frontend renders and opens the file. + #[serde(rename = "fileKind", default = "default_file_kind")] + pub file_kind: String, +} + +fn default_file_kind() -> String { + "markdown".to_string() } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 3b948afc..ad5fad3e 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -120,6 +120,33 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result) -> Result { + let filename = path + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let (fs_modified, fs_created, file_size) = read_file_metadata(path)?; + let (modified_at, created_at) = match git_dates { + Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)), + None => (fs_modified, fs_created), + }; + let file_kind = classify_file_kind(path).to_string(); + + Ok(VaultEntry { + path: path.to_string_lossy().to_string(), + filename: filename.clone(), + title: filename, + file_kind, + modified_at, + created_at, + file_size, + ..VaultEntry::default() }) } @@ -129,7 +156,11 @@ pub fn reload_entry(path: &Path) -> Result { if !path.exists() { return Err(format!("File does not exist: {}", path.display())); } - parse_md_file(path, None) + if is_md_file(path) { + parse_md_file(path, None) + } else { + parse_non_md_file(path, None) + } } /// Directories that are never shown in the folder tree or scanned for notes. @@ -139,10 +170,45 @@ fn is_hidden_dir(name: &str) -> bool { name.starts_with('.') || HIDDEN_DIRS.contains(&name) } -fn is_md_file(path: &Path) -> bool { +pub(crate) fn is_md_file(path: &Path) -> bool { path.is_file() && path.extension().is_some_and(|ext| ext == "md") } +/// Extensions recognized as editable text files (opened in raw editor). +const TEXT_EXTENSIONS: &[&str] = &[ + "yml", "yaml", "json", "txt", "toml", "csv", "xml", "html", "htm", "css", "scss", "less", + "ts", "tsx", "js", "jsx", "py", "rs", "sh", "bash", "zsh", "fish", "rb", "go", "java", + "kt", "c", "cpp", "h", "hpp", "swift", "lua", "sql", "graphql", "env", "ini", "cfg", + "conf", "properties", "makefile", "dockerfile", "gitignore", "editorconfig", "mdx", + "svelte", "vue", "astro", "tf", "hcl", "nix", "zig", "hs", "ml", "ex", "exs", "erl", + "clj", "lisp", "el", "vim", "r", "jl", "ps1", "bat", "cmd", +]; + +/// Classify a file extension into "markdown", "text", or "binary". +pub(crate) fn classify_file_kind(path: &Path) -> &'static str { + let ext = match path.extension() { + Some(e) => e.to_string_lossy().to_lowercase(), + None => { + // Files without extension: check if name itself is a known text file + let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + return if ["makefile", "dockerfile", "rakefile", "gemfile", "procfile", "brewfile", + ".gitignore", ".gitattributes", ".editorconfig", ".env"] + .contains(&name.as_str()) { + "text" + } else { + "binary" + }; + } + }; + if ext == "md" || ext == "markdown" { + "markdown" + } else if TEXT_EXTENSIONS.contains(&ext.as_str()) { + "text" + } else { + "binary" + } +} + use crate::git::GitDates; use std::collections::HashMap; @@ -159,22 +225,27 @@ fn lookup_git_dates( git_dates.get(&rel).map(|d| (d.modified_at, d.created_at)) } -fn try_parse_md( +fn try_parse_file( path: &Path, vault_path: &Path, git_dates: &HashMap, entries: &mut Vec, ) { let dates = lookup_git_dates(path, vault_path, git_dates); - match parse_md_file(path, dates) { + let result = if is_md_file(path) { + parse_md_file(path, dates) + } else { + parse_non_md_file(path, dates) + }; + match result { Ok(vault_entry) => entries.push(vault_entry), Err(e) => log::warn!("Skipping file: {}", e), } } -/// Scan all .md files in the vault, including subdirectories. +/// Scan all files in the vault, including subdirectories. /// Hidden directories (starting with `.`) are excluded. -fn scan_all_md_files( +fn scan_all_files( vault_path: &Path, git_dates: &HashMap, entries: &mut Vec, @@ -194,13 +265,18 @@ fn scan_all_md_files( true }); for entry in walker.filter_map(|e| e.ok()) { - if is_md_file(entry.path()) { - try_parse_md(entry.path(), vault_path, git_dates, entries); + if entry.path().is_file() { + // Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store + let fname = entry.file_name().to_string_lossy(); + if fname.starts_with('.') { + continue; + } + try_parse_file(entry.path(), vault_path, git_dates, entries); } } } -/// Scan a directory recursively for .md files and return VaultEntry for each. +/// Scan a directory recursively for all files and return VaultEntry for each. /// Pass an empty map for `git_dates` to use filesystem dates only. pub fn scan_vault( vault_path: &Path, @@ -220,7 +296,7 @@ pub fn scan_vault( } let mut entries = Vec::new(); - scan_all_md_files(vault_path, git_dates, &mut entries); + scan_all_files(vault_path, git_dates, &mut entries); entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at)); Ok(entries) diff --git a/src-tauri/src/vault/mod_tests.rs b/src-tauri/src/vault/mod_tests.rs index 312453ef..79744561 100644 --- a/src-tauri/src/vault/mod_tests.rs +++ b/src-tauri/src/vault/mod_tests.rs @@ -133,15 +133,20 @@ fn test_scan_vault_root_and_protected_folders() { "---\ntype: Type\n---\n# Project\n", ); create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n"); - create_test_file(dir.path(), "not-markdown.txt", "This should be ignored"); + create_test_file(dir.path(), "not-markdown.txt", "This should be included as text"); let entries = scan_vault(dir.path(), &HashMap::new()).unwrap(); - assert_eq!(entries.len(), 3); + assert_eq!(entries.len(), 4); let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect(); assert!(filenames.contains(&"root.md")); assert!(filenames.contains(&"project.md")); assert!(filenames.contains(&"notes.md")); + assert!(filenames.contains(&"not-markdown.txt")); + + let txt_entry = entries.iter().find(|e| e.filename == "not-markdown.txt").unwrap(); + assert_eq!(txt_entry.file_kind, "text"); + assert_eq!(txt_entry.title, "not-markdown.txt"); } #[test] diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 0c39ae54..02edf24b 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -170,7 +170,10 @@ export function EditorContent({ const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false - const showEditor = !diffMode && !rawMode + // Non-markdown text files always use the raw editor (no BlockNote) + const isNonMarkdownText = activeTab?.entry.fileKind === 'text' + const effectiveRawMode = rawMode || isNonMarkdownText + const showEditor = !diffMode && !effectiveRawMode const entryIcon = activeTab?.entry.icon ?? null const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null @@ -206,7 +209,7 @@ export function EditorContent({ )} {activeTab && isTrashed && ( @@ -225,7 +228,7 @@ export function EditorContent({ /> )} {diffMode && } - + {showEditor && activeTab && (
diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index 0f0bac04..a9232fd7 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank, Tag, FileText, StackSimple, + File, FileDashed, } from '@phosphor-icons/react' import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { resolveIcon } from '../utils/iconRegistry' @@ -91,6 +92,12 @@ function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: return base } +function getFileKindIcon(fileKind: string | undefined): ComponentType> { + if (fileKind === 'text') return File + if (fileKind === 'binary') return FileDashed + return FileText +} + export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: { entry: VaultEntry isSelected: boolean @@ -101,47 +108,55 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void onPrefetch?: (path: string) => void }) { + const isBinary = entry.fileKind === 'binary' + const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown' const te = typeEntryMap[entry.isA ?? ''] - const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color) + const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color) const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color) - const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon]) + const TypeIcon = useMemo(() => { + if (isNonMarkdown) return getFileKindIcon(entry.fileKind) + return getTypeIcon(entry.isA, te?.icon) + }, [entry.isA, te?.icon, entry.fileKind, isNonMarkdown]) + + const handleClick = isBinary + ? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation() } + : (e: React.MouseEvent) => onClickNote(entry, e) return (
onClickNote(entry, e)} - onMouseEnter={onPrefetch ? () => onPrefetch(entry.path) : undefined} - data-testid={isMultiSelected ? 'multi-selected-item' : undefined} + style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)} + onClick={handleClick} + onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined} + data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined} data-highlighted={isHighlighted || undefined} + title={isBinary ? 'Cannot open this file type' : undefined} > {/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
-
- {noteStatus !== 'clean' && } +
+ {noteStatus !== 'clean' && !isBinary && } {entry.icon && isEmoji(entry.icon) && {entry.icon}} {entry.title} - + {!isBinary && }
- {entry.path.includes('/') && ( -
{entry.path}
- )}
- {entry.snippet && ( + {entry.snippet && !isBinary && (
{entry.snippet}
)} - {entry.trashed && entry.trashedAt + {!isBinary && (entry.trashed && entry.trashedAt ? :
{relativeDate(getDisplayDate(entry))}
- } + )}
) } diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 1f5df90c..2ae38983 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { NoteList } from './NoteList' +import { NoteItem } from './NoteItem' import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers' import type { NoteListFilter } from '../utils/noteListHelpers' import type { VaultEntry, SidebarSelection } from '../types' @@ -1453,4 +1454,44 @@ describe('countAllByFilter', () => { const counts = countAllByFilter(entries) expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 }) }) + + it('excludes non-markdown files from counts', () => { + const entries = [ + makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }), + makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }), + makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }), + ] + const counts = countAllByFilter(entries) + expect(counts).toEqual({ open: 1, archived: 0, trashed: 0 }) + }) +}) + +describe('NoteItem — binary file rendering', () => { + it('renders binary files as non-clickable with muted style', () => { + const binaryEntry = makeEntry({ + path: '/vault/photo.png', filename: 'photo.png', title: 'photo.png', fileKind: 'binary', + }) + const onClick = vi.fn() + render() + + const item = screen.getByTestId('binary-file-item') + expect(item).toBeTruthy() + expect(item.className).toContain('opacity-50') + expect(item).toHaveAttribute('title', 'Cannot open this file type') + + fireEvent.click(item) + expect(onClick).not.toHaveBeenCalled() + }) + + it('renders text files as clickable', () => { + const textEntry = makeEntry({ + path: '/vault/config.yml', filename: 'config.yml', title: 'config.yml', fileKind: 'text', + }) + const onClick = vi.fn() + render() + + const item = screen.getByText('config.yml').closest('div')! + fireEvent.click(item) + expect(onClick).toHaveBeenCalled() + }) }) diff --git a/src/hooks/useTabManagement.ts b/src/hooks/useTabManagement.ts index 8cd348af..83f9c7ee 100644 --- a/src/hooks/useTabManagement.ts +++ b/src/hooks/useTabManagement.ts @@ -73,13 +73,15 @@ export function useTabManagement() { /** Open a note — replaces the current note (single-note model). */ const handleSelectNote = useCallback(async (entry: VaultEntry) => { + // Binary files cannot be opened + if (entry.fileKind === 'binary') return // Already viewing this note — no-op if (tabsRef.current.some(t => t.entry.path === entry.path)) { setActiveTabPath(entry.path) return } const seq = ++navSeqRef.current - await syncNoteTitle(entry.path) + if (!entry.fileKind || entry.fileKind === 'markdown') await syncNoteTitle(entry.path) try { const content = await loadNoteContent(entry.path) if (navSeqRef.current === seq) { @@ -104,6 +106,8 @@ export function useTabManagement() { }, []) const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => { + // Binary files cannot be opened + if (entry.fileKind === 'binary') return // In single-note model, replace is the same as select if (tabsRef.current.some(t => t.entry.path === entry.path)) { setActiveTabPath(entry.path) diff --git a/src/types.ts b/src/types.ts index 7bf9b2c1..f9f37997 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,9 @@ export interface VaultEntry { outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ properties: Record + /** File kind: "markdown", "text", or "binary". Determines editor behavior. + * Defaults to "markdown" when absent (for backwards compatibility). */ + fileKind?: 'markdown' | 'text' | 'binary' } export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved' diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index c884af72..0d2168b5 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -739,3 +739,38 @@ describe('filterEntries — folder selection', () => { expect(result.find(e => e.title === 'Archived')).toBeUndefined() }) }) + +describe('filterEntries — fileKind filtering', () => { + const entries = [ + makeEntry({ path: '/vault/note.md', title: 'Note', fileKind: 'markdown' }), + makeEntry({ path: '/vault/config.yml', title: 'config.yml', fileKind: 'text' }), + makeEntry({ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary' }), + makeEntry({ path: '/vault/projects/readme.md', title: 'README', fileKind: 'markdown' }), + makeEntry({ path: '/vault/projects/data.json', title: 'data.json', fileKind: 'text' }), + makeEntry({ path: '/vault/projects/image.jpg', title: 'image.jpg', fileKind: 'binary' }), + ] + + it('all-notes filter only shows markdown files', () => { + const result = filterEntries(entries, { kind: 'filter', filter: 'all' }) + expect(result.map(e => e.title)).toEqual(['Note', 'README']) + }) + + it('folder view shows all file kinds including binary', () => { + const result = filterEntries(entries, { kind: 'folder', path: 'projects' }) + expect(result.map(e => e.title)).toEqual(['README', 'data.json', 'image.jpg']) + }) + + it('sectionGroup filter only shows markdown files', () => { + const typed = entries.map(e => ({ ...e, isA: 'Note' })) + const result = filterEntries(typed, { kind: 'sectionGroup', type: 'Note' }) + expect(result.map(e => e.title)).toEqual(['Note', 'README']) + }) + + it('entries without fileKind are treated as markdown', () => { + const legacy = [ + makeEntry({ path: '/vault/old.md', title: 'Old' }), + ] + const result = filterEntries(legacy, { kind: 'filter', filter: 'all' }) + expect(result.map(e => e.title)).toEqual(['Old']) + }) +}) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index d7e0025e..8db70535 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -307,6 +307,7 @@ export function buildRelationshipGroups( } const isActive = (e: VaultEntry) => !e.archived && !e.trashed +const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] { if (subFilter === 'archived') return entries.filter((e) => e.archived && !e.trashed) @@ -324,18 +325,21 @@ function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFil if (selection.kind === 'view') { const view = views?.find((v) => v.filename === selection.filename) if (!view) return [] - return evaluateView(view.definition, entries) + return evaluateView(view.definition, entries.filter(isMarkdown)) } if (selection.kind === 'folder') { + // Folder view shows ALL files (text + binary), not just markdown const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path)) return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive) } if (selection.kind === 'sectionGroup') { - const typeEntries = entries.filter((e) => e.isA === selection.type) + const typeEntries = entries.filter((e) => isMarkdown(e) && e.isA === selection.type) return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive) } - if (selection.filter === 'all' && subFilter) return applySubFilter(entries, subFilter) - return filterByFilterType(entries, selection.filter) + // Non-folder views: only markdown files + const mdEntries = entries.filter(isMarkdown) + if (selection.filter === 'all' && subFilter) return applySubFilter(mdEntries, subFilter) + return filterByFilterType(mdEntries, selection.filter) } function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] { @@ -355,7 +359,7 @@ export function filterEntries(entries: VaultEntry[], selection: SidebarSelection export function countByFilter(entries: VaultEntry[], type: string): Record { let open = 0, archived = 0, trashed = 0 for (const e of entries) { - if (e.isA !== type) continue + if (!isMarkdown(e) || e.isA !== type) continue if (e.trashed) trashed++ else if (e.archived) archived++ else open++ @@ -367,6 +371,7 @@ export function countByFilter(entries: VaultEntry[], type: string): Record { let open = 0, archived = 0, trashed = 0 for (const e of entries) { + if (!isMarkdown(e)) continue if (e.trashed) trashed++ else if (e.archived) archived++ else open++