Compare commits
6 Commits
v0.2026032
...
v0.2026032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6fa1f48cb | ||
|
|
8f8954a6f7 | ||
|
|
99f5716508 | ||
|
|
b8f29c9530 | ||
|
|
33ae00a558 | ||
|
|
ac02de88e6 |
@@ -539,7 +539,7 @@ Managed by `useIndexing` hook:
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
Per-vault settings stored in `ui.config.md` at vault root:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
|
||||
@@ -466,7 +466,7 @@ Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
|
||||
Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note):
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across tab switches and sessions)
|
||||
|
||||
@@ -588,6 +588,8 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
// Migrate legacy theme/ directory to root, then repair themes
|
||||
theme::migrate_theme_dir_to_root(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(&vault_path);
|
||||
// Repair config files (AGENTS.md at root, config.md type def)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
// Ensure .gitignore with sensible defaults exists
|
||||
|
||||
@@ -44,6 +44,8 @@ fn run_startup_tasks() {
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(vp_str);
|
||||
log_startup_result(
|
||||
"Migrated hidden_sections to visible property",
|
||||
vault_config::migrate_hidden_sections_to_visible(vp_str),
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Vault-wide UI configuration stored in `config/ui.config.md`.
|
||||
/// Vault-wide UI configuration stored in `ui.config.md` at vault root.
|
||||
///
|
||||
/// This file is a regular vault note with YAML frontmatter, visible in the
|
||||
/// sidebar under the "Config" section and editable like any note.
|
||||
@@ -21,14 +21,13 @@ pub struct VaultConfig {
|
||||
pub property_display_modes: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
const CONFIG_DIR: &str = "config";
|
||||
const CONFIG_FILENAME: &str = "ui.config.md";
|
||||
|
||||
fn config_path(vault_path: &str) -> std::path::PathBuf {
|
||||
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
|
||||
Path::new(vault_path).join(CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
/// Read the vault-wide UI config from `config/ui.config.md`.
|
||||
/// Read the vault-wide UI config from `ui.config.md` at vault root.
|
||||
/// Returns default values if the file doesn't exist.
|
||||
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
|
||||
let path = config_path(vault_path);
|
||||
@@ -69,19 +68,42 @@ fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
|
||||
.map_err(|e| format!("Failed to parse config: {e}"))
|
||||
}
|
||||
|
||||
/// Save the vault-wide UI config to `config/ui.config.md`.
|
||||
/// Creates the directory and file if they don't exist.
|
||||
/// Save the vault-wide UI config to `ui.config.md` at vault root.
|
||||
/// Creates the file if it doesn't exist.
|
||||
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
|
||||
let path = config_path(vault_path);
|
||||
let dir = Path::new(vault_path).join(CONFIG_DIR);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
}
|
||||
|
||||
let content = serialize_config(&config);
|
||||
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/ui.config.md` → root `ui.config.md`.
|
||||
/// If the root file already exists, the legacy file is simply removed.
|
||||
/// Cleans up empty `config/` directory after migration.
|
||||
pub fn migrate_ui_config_to_root(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let legacy = vault.join("config").join(CONFIG_FILENAME);
|
||||
let root = vault.join(CONFIG_FILENAME);
|
||||
|
||||
if legacy.exists() {
|
||||
if !root.exists() {
|
||||
// Move legacy content to root
|
||||
if let Ok(content) = std::fs::read_to_string(&legacy) {
|
||||
let _ = std::fs::write(&root, content);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
}
|
||||
|
||||
// Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = std::fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&config_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
|
||||
fn serialize_config(config: &VaultConfig) -> String {
|
||||
let mut lines = vec!["---".to_string(), "type: config".to_string()];
|
||||
@@ -142,7 +164,7 @@ fn yaml_safe_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
|
||||
/// Migrate `hidden_sections` from `ui.config.md` to `visible: false`
|
||||
/// on Type notes. Returns the number of Type notes updated.
|
||||
///
|
||||
/// For each type name in `hidden_sections`:
|
||||
@@ -354,11 +376,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -375,7 +395,7 @@ property_display_modes:
|
||||
assert!(recipe.contains("visible: false"));
|
||||
|
||||
// Config should no longer have hidden_sections
|
||||
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
|
||||
let config_content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(!config_content.contains("hidden_sections"));
|
||||
}
|
||||
|
||||
@@ -384,11 +404,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -416,10 +434,8 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -440,19 +456,15 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Type note already has visible: false
|
||||
let type_dir = dir.path().join("type");
|
||||
std::fs::create_dir_all(&type_dir).unwrap();
|
||||
// Type note already has visible: false at vault root
|
||||
std::fs::write(
|
||||
type_dir.join("note.md"),
|
||||
dir.path().join("note.md"),
|
||||
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -460,7 +472,7 @@ property_display_modes:
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
|
||||
let content = std::fs::read_to_string(dir.path().join("note.md")).unwrap();
|
||||
// Should have exactly one visible: false, not two
|
||||
assert_eq!(content.matches("visible:").count(), 1);
|
||||
}
|
||||
@@ -478,4 +490,90 @@ property_display_modes:
|
||||
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
|
||||
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_moves_legacy_to_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.5\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root file should exist with migrated content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 1.5"));
|
||||
// Legacy file and empty config dir should be gone
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
assert!(!config_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_preserves_existing_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Root already has content
|
||||
std::fs::write(
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 2.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Legacy also exists
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root should keep its original content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 2.0"));
|
||||
// Legacy file should be removed
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_keeps_nonempty_config_dir() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("ui.config.md"), "---\ntype: config\n---\n").unwrap();
|
||||
std::fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// config/ should still exist because it has other files
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("other.md").exists());
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_config_writes_to_vault_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let config = VaultConfig {
|
||||
zoom: Some(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
save_vault_config(vault_path, config).unwrap();
|
||||
|
||||
// File should be at vault root, not in config/
|
||||
assert!(dir.path().join("ui.config.md").exists());
|
||||
assert!(!dir.path().join("config").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('Editor', () => {
|
||||
const trashedTab = { entry: trashedEntry, content: mockContent }
|
||||
|
||||
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
|
||||
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
}
|
||||
|
||||
it('shows banner and read-only editor when note is trashed', () => {
|
||||
@@ -336,9 +336,10 @@ describe('Editor', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
|
||||
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
@@ -346,13 +347,14 @@ describe('Editor', () => {
|
||||
|
||||
it('removes trash banner immediately when entry is restored (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
|
||||
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
|
||||
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
|
||||
const restoredTab = { entry: restoredEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
entries={[trashedEntry]}
|
||||
tabs={[{ entry: trashedEntry, content: mockContent }]}
|
||||
activeTabPath={trashedEntry.path}
|
||||
/>
|
||||
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
|
||||
const archivedEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
|
||||
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
|
||||
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
|
||||
const unarchivedEntry = { ...archivedEntry, archived: false }
|
||||
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -161,7 +161,11 @@ export function EditorContent({
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
// so the banner appears regardless of navigation context.
|
||||
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
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
@@ -188,7 +192,7 @@ export function EditorContent({
|
||||
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
|
||||
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
|
||||
)}
|
||||
{activeTab && isConflicted && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { getSortComparator, filterEntries, countByFilter } from '../utils/noteListHelpers'
|
||||
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
@@ -1293,11 +1293,44 @@ describe('NoteList — filter pills', () => {
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show filter pills in All Notes view', () => {
|
||||
it('shows filter pills in All Notes view', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('filter-pills')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct All Notes count badges across all types', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived, 1 trashed
|
||||
const openPill = screen.getByTestId('filter-pill-open')
|
||||
const archivedPill = screen.getByTestId('filter-pill-archived')
|
||||
const trashedPill = screen.getByTestId('filter-pill-trashed')
|
||||
expect(openPill).toHaveTextContent('3')
|
||||
expect(archivedPill).toHaveTextContent('1')
|
||||
expect(trashedPill).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows archived notes in All Notes when filter is archived', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Archived Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trashed notes in All Notes when filter is trashed', () => {
|
||||
render(
|
||||
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct count badges for each filter', () => {
|
||||
@@ -1377,4 +1410,33 @@ describe('NoteList — filterEntries with subFilter', () => {
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
|
||||
expect(result.map(e => e.title)).toEqual(['Active'])
|
||||
})
|
||||
|
||||
it('filters all notes by open sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'open')
|
||||
expect(result.map(e => e.title)).toEqual(['Active', 'Other'])
|
||||
})
|
||||
|
||||
it('filters all notes by archived sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived')
|
||||
expect(result.map(e => e.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('filters all notes by trashed sub-filter', () => {
|
||||
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'trashed')
|
||||
expect(result.map(e => e.title)).toEqual(['Trashed'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAllByFilter', () => {
|
||||
it('counts all entries by filter status', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
|
||||
makeEntry({ path: '/4.md', isA: 'Note', trashed: true }),
|
||||
makeEntry({ path: '/5.md', isA: 'Person', archived: true, trashed: true }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -48,11 +48,13 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const subFilter = isSectionGroup ? noteListFilter : undefined
|
||||
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
|
||||
const showFilterPills = isSectionGroup || isAllNotesView
|
||||
const subFilter = showFilterPills ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, selection],
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, isAllNotesView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
@@ -75,7 +77,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
|
||||
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
@@ -100,7 +102,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
|
||||
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
|
||||
@@ -1038,4 +1038,24 @@ describe('Sidebar', () => {
|
||||
const mondaySections = screen.getAllByText(/Monday Ideas/i)
|
||||
expect(mondaySections).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders Inbox as the first item in the top nav', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
const items = topNav.children
|
||||
expect(items[0].textContent).toContain('Inbox')
|
||||
expect(items[1].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('displays inbox count badge', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with inbox filter when clicking Inbox', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
|
||||
fireEvent.click(screen.getByText('Inbox'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -306,10 +306,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
@@ -148,7 +149,7 @@ function SectionChildList({ items, selection, sectionColor, sectionLightColor, o
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} isActive={active}
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
@@ -237,8 +238,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; isActive: boolean
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
@@ -248,7 +249,7 @@ function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, on
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,17 +22,19 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -55,23 +57,24 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-auto shrink-0 gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
|
||||
style={{
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<div className="min-w-0">
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
|
||||
style={{
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
@@ -81,7 +84,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="filter-pills">
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
|
||||
@@ -16,14 +16,14 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="inbox-filter-pills">
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
|
||||
@@ -155,7 +155,7 @@ export interface VaultSettings {
|
||||
theme: string | null
|
||||
}
|
||||
|
||||
/** Vault-wide UI configuration stored in config/ui.config.md. */
|
||||
/** Vault-wide UI configuration stored in ui.config.md at vault root. */
|
||||
export interface VaultConfig {
|
||||
zoom: number | null
|
||||
view_mode: string | null
|
||||
|
||||
@@ -315,6 +315,7 @@ function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFil
|
||||
const typeEntries = entries.filter((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)
|
||||
}
|
||||
|
||||
@@ -342,6 +343,17 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
/** Count notes per sub-filter across all entries (no type filter). */
|
||||
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
let open = 0, archived = 0, trashed = 0
|
||||
for (const e of entries) {
|
||||
if (e.trashed) trashed++
|
||||
else if (e.archived) archived++
|
||||
else open++
|
||||
}
|
||||
return { open, archived, trashed }
|
||||
}
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */
|
||||
|
||||
Reference in New Issue
Block a user