Compare commits
13 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feef938174 | ||
|
|
67a9930ea1 | ||
|
|
aed4d05f1d | ||
|
|
cac7f12c6d | ||
|
|
72b5b52140 | ||
|
|
7deeb49751 | ||
|
|
8c0a2e7ec8 | ||
|
|
c4e66dc74f | ||
|
|
c0bb722db5 | ||
|
|
bc11c869aa | ||
|
|
502ecf0572 | ||
|
|
8bbbba98b7 | ||
|
|
86adf860b5 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.7
|
||||
HOTSPOT_THRESHOLD=9.88
|
||||
AVERAGE_THRESHOLD=9.73
|
||||
|
||||
@@ -496,7 +496,8 @@ The app uses a single light theme — the vault-based theming system was removed
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ flowchart TD
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -121,6 +121,7 @@ fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Re
|
||||
}
|
||||
|
||||
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
|
||||
ensure_directory_is_missing_or_empty(vault_dir)?;
|
||||
std::fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
@@ -129,6 +130,28 @@ fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
|
||||
if !vault_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(vault_dir)
|
||||
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err("Choose a folder path for the new vault".to_string());
|
||||
}
|
||||
|
||||
let has_entries = std::fs::read_dir(vault_dir)
|
||||
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
|
||||
.next()
|
||||
.is_some();
|
||||
if has_entries {
|
||||
return Err("Choose an empty folder to create a new vault".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
|
||||
vault_dir
|
||||
.canonicalize()
|
||||
@@ -291,6 +314,7 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -299,6 +323,35 @@ mod tests {
|
||||
(dir, note)
|
||||
}
|
||||
|
||||
fn assert_paths_exist(root: &Path, paths: &[&str]) {
|
||||
for path in paths {
|
||||
assert!(root.join(path).exists(), "{path} should exist");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_paths_absent(root: &Path, paths: &[&str]) {
|
||||
for path in paths {
|
||||
assert!(!root.join(path).exists(), "{path} should be absent");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_seeded_guidance_content(vault_path: &Path) {
|
||||
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
|
||||
let claude = std::fs::read_to_string(vault_path.join("CLAUDE.md")).unwrap();
|
||||
|
||||
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(agents.contains("views/*.yml"));
|
||||
assert!(claude.starts_with("@AGENTS.md"));
|
||||
assert!(claude.contains("# CLAUDE.md"));
|
||||
}
|
||||
|
||||
fn assert_seeded_type_scaffolding(vault_path: &Path) {
|
||||
let type_definition = std::fs::read_to_string(vault_path.join("type.md")).unwrap();
|
||||
|
||||
assert!(type_definition.contains("visible: false"));
|
||||
assert!(type_definition.contains("# Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_archive_notes() {
|
||||
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
|
||||
@@ -427,24 +480,35 @@ mod tests {
|
||||
|
||||
let result = repair_vault(vault_path.to_str().unwrap().to_string());
|
||||
assert!(result.is_ok());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("config.md").exists());
|
||||
assert!(vault_path.join(".gitignore").exists());
|
||||
assert_paths_exist(vault_path, &["AGENTS.md", "CLAUDE.md", "type.md", "note.md", ".gitignore"]);
|
||||
assert_paths_absent(vault_path, &["config.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_empty_vault_seeds_agents_and_config() {
|
||||
fn test_create_empty_vault_seeds_agents_and_type_scaffolding() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("fresh-vault");
|
||||
|
||||
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
|
||||
assert!(result.is_ok());
|
||||
assert!(vault_path.join(".git").exists());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("config.md").exists());
|
||||
assert_paths_exist(&vault_path, &[".git", "AGENTS.md", "CLAUDE.md", "type.md", "note.md"]);
|
||||
assert_paths_absent(&vault_path, &["config.md"]);
|
||||
assert_seeded_guidance_content(&vault_path);
|
||||
assert_seeded_type_scaffolding(&vault_path);
|
||||
}
|
||||
|
||||
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
|
||||
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(agents.contains("views/*.yml"));
|
||||
#[test]
|
||||
fn test_create_empty_vault_rejects_nonempty_target() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("existing-folder");
|
||||
std::fs::create_dir_all(&vault_path).unwrap();
|
||||
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
|
||||
|
||||
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
|
||||
let err = result.expect_err("expected non-empty folder to be rejected");
|
||||
|
||||
assert_eq!(err, "Choose an empty folder to create a new vault");
|
||||
assert_paths_exist(&vault_path, &["keep.txt"]);
|
||||
assert_paths_absent(&vault_path, &[".git", "AGENTS.md"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ fn run_startup_tasks() {
|
||||
);
|
||||
// Migrate legacy config/agents.md → root AGENTS.md (one-time, idempotent)
|
||||
vault::migrate_agents_md(vp_str);
|
||||
// Seed AGENTS.md and config.md at vault root if missing
|
||||
// Seed AGENTS.md and starter type definitions at vault root if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Tolaria MCP server in Claude Code and Cursor configs
|
||||
|
||||
@@ -4,19 +4,25 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use super::getting_started::{agents_content_can_be_refreshed, AGENTS_MD};
|
||||
|
||||
/// Content for `config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
/// Content for `type.md` — describes the generic Type metamodel for the vault.
|
||||
const TYPE_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
type: Type
|
||||
icon: gear-six
|
||||
color: gray
|
||||
order: 90
|
||||
sidebar label: Config
|
||||
order: 0
|
||||
visible: false
|
||||
---
|
||||
|
||||
# Config
|
||||
# Type
|
||||
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
A Type defines shared metadata and defaults for a category of notes in this vault.
|
||||
|
||||
## Common properties
|
||||
- **Icon**: Sidebar icon for this type
|
||||
- **Color**: Accent color for notes of this type
|
||||
- **Order**: Sidebar ordering
|
||||
- **Sidebar label**: Override the default plural label
|
||||
- **Template**: Default body for new notes of this type
|
||||
- **View**: Preferred note-list view for this type
|
||||
";
|
||||
|
||||
/// Content for `note.md` — restores the default Note type definition when missing.
|
||||
@@ -30,8 +36,15 @@ type: Type
|
||||
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
|
||||
";
|
||||
|
||||
const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
@@ -80,7 +93,9 @@ fn root_agents_can_be_replaced(path: &Path) -> bool {
|
||||
|
||||
fn matches_claude_shim(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
|
||||
trimmed == "@AGENTS.md"
|
||||
|| trimmed == LEGACY_CLAUDE_MD_SHIM.trim()
|
||||
|| trimmed == CLAUDE_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
@@ -251,7 +266,7 @@ fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str
|
||||
|
||||
/// Ensure the default root type definitions exist for opened/repaired vaults.
|
||||
fn ensure_root_type_definitions(vault_path: &Path) {
|
||||
ensure_root_type_definition(vault_path, "config.md", CONFIG_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
@@ -296,7 +311,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault)?;
|
||||
|
||||
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
@@ -445,12 +460,13 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(config_content.contains("type: Type"));
|
||||
assert!(config_content.contains("icon: gear-six"));
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
assert!(!vault.join("config").exists());
|
||||
@@ -569,12 +585,15 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = read_root_agents(&vault);
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("general-purpose document"));
|
||||
|
||||
@@ -276,7 +276,7 @@ Keep edits compatible with this starter vault's current conventions. Prefer smal
|
||||
- One Markdown note per file.
|
||||
- The first H1 in the body is the preferred display title. Legacy `title:` frontmatter is still read as a fallback when a note has no H1, but do not add it to new notes.
|
||||
- Store note type in the `type:` frontmatter field.
|
||||
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `config.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
|
||||
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `type.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
|
||||
- Saved views live in `views/*.yml`.
|
||||
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
|
||||
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
@@ -649,7 +649,8 @@ mod tests {
|
||||
|
||||
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
assert!(dest.join("config.md").exists());
|
||||
assert!(dest.join("type.md").exists());
|
||||
assert!(dest.join("note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -686,7 +687,8 @@ mod tests {
|
||||
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
|
||||
AGENTS_MD
|
||||
);
|
||||
assert!(dest.join("config.md").exists());
|
||||
assert!(dest.join("type.md").exists());
|
||||
assert!(dest.join("note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,12 @@ pub struct ViewDefinition {
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "listPropertiesDisplay",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
pub filters: FilterGroup,
|
||||
}
|
||||
|
||||
@@ -195,6 +201,37 @@ pub fn migrate_views(vault_path: &Path) {
|
||||
}
|
||||
|
||||
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
|
||||
fn is_view_definition_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
|
||||
}
|
||||
|
||||
fn read_view_file(path: &Path) -> Option<ViewFile> {
|
||||
if !is_view_definition_file(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
log::warn!("Failed to read view file {}: {}", filename, error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
|
||||
Ok(definition) => definition,
|
||||
Err(error) => {
|
||||
log::warn!("Failed to parse view {}: {}", filename, error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(ViewFile {
|
||||
filename,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
migrate_views(vault_path);
|
||||
let views_dir = vault_path.join("views");
|
||||
@@ -212,20 +249,8 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
|
||||
continue;
|
||||
}
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
|
||||
Ok(definition) => views.push(ViewFile {
|
||||
filename,
|
||||
definition,
|
||||
}),
|
||||
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
|
||||
},
|
||||
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
|
||||
if let Some(view) = read_view_file(&entry.path()) {
|
||||
views.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,6 +681,22 @@ mod tests {
|
||||
entry
|
||||
}
|
||||
|
||||
fn make_project_view(name: &str) -> ViewDefinition {
|
||||
ViewDefinition {
|
||||
name: name.to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
sort: None,
|
||||
list_properties_display: Vec::new(),
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
regex: false,
|
||||
})]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_view() {
|
||||
let yaml = r#"
|
||||
@@ -670,6 +711,7 @@ filters:
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(def.name, "Active Projects");
|
||||
assert_eq!(def.icon.as_deref(), Some("rocket"));
|
||||
assert!(def.list_properties_display.is_empty());
|
||||
match &def.filters {
|
||||
FilterGroup::All(nodes) => {
|
||||
assert_eq!(nodes.len(), 1);
|
||||
@@ -945,18 +987,10 @@ filters:
|
||||
fn test_save_and_read_view() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Test View".to_string(),
|
||||
icon: Some("star".to_string()),
|
||||
color: None,
|
||||
sort: Some("modified:desc".to_string()),
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
regex: false,
|
||||
})]),
|
||||
};
|
||||
let mut def = make_project_view("Test View");
|
||||
def.icon = Some("star".to_string());
|
||||
def.sort = Some("modified:desc".to_string());
|
||||
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
|
||||
|
||||
save_view(dir.path(), "test.yml", &def).unwrap();
|
||||
|
||||
@@ -964,6 +998,10 @@ filters:
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Test View");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
|
||||
assert_eq!(
|
||||
views[0].definition.list_properties_display,
|
||||
vec!["Priority".to_string(), "Owner".to_string()]
|
||||
);
|
||||
|
||||
delete_view(dir.path(), "test.yml").unwrap();
|
||||
let views = scan_views(dir.path());
|
||||
@@ -974,18 +1012,8 @@ filters:
|
||||
fn test_save_and_read_view_with_emoji_icon() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Monday".to_string(),
|
||||
icon: Some("🗂️".to_string()),
|
||||
color: None,
|
||||
sort: None,
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
regex: false,
|
||||
})]),
|
||||
};
|
||||
let mut def = make_project_view("Monday");
|
||||
def.icon = Some("🗂️".to_string());
|
||||
|
||||
save_view(dir.path(), "monday.yml", &def).unwrap();
|
||||
|
||||
|
||||
@@ -358,6 +358,30 @@ describe('App', () => {
|
||||
expect(appShell).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches vaults from the bottom bar after onboarding is ready', async () => {
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [
|
||||
{ label: 'Test Vault', path: '/work' },
|
||||
{ label: 'Work Vault', path: '/vault-2' },
|
||||
],
|
||||
active_vault: '/work',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
|
||||
87
src/App.tsx
87
src/App.tsx
@@ -69,7 +69,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
|
||||
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
@@ -105,6 +105,16 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
function shouldPreferOnboardingVaultPath(
|
||||
onboardingState: { status: string; vaultPath?: string },
|
||||
vaults: Array<{ path: string }>,
|
||||
): onboardingState is { status: 'ready'; vaultPath: string } {
|
||||
return onboardingState.status === 'ready'
|
||||
&& typeof onboardingState.vaultPath === 'string'
|
||||
&& onboardingState.vaultPath.length > 0
|
||||
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
|
||||
}
|
||||
|
||||
async function resolveNoteWindowEntry(
|
||||
noteWindowParams: NoteWindowParams,
|
||||
entries: VaultEntry[],
|
||||
@@ -234,16 +244,26 @@ function App() {
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (onboarding.state.status !== 'ready') return
|
||||
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
|
||||
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
|
||||
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
||||
const onboardingVaultPath = onboarding.userReadyVaultPath
|
||||
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
|
||||
|
||||
// The active vault path can temporarily come from onboarding before the
|
||||
// persisted vault switcher catches up to the newly cloned starter vault.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
|
||||
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
|
||||
rememberOnboardingVaultChoice(onboardingVaultPath)
|
||||
}
|
||||
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
||||
|
||||
// Onboarding can briefly own the vault path for a newly created/opened vault
|
||||
// before the persisted switcher catches up, but once the path is already in
|
||||
// the switcher list we should trust the explicit switcher state.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (
|
||||
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
|
||||
? onboarding.state.vaultPath
|
||||
: vaultSwitcher.vaultPath
|
||||
)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
useEffect(() => {
|
||||
@@ -541,6 +561,11 @@ function App() {
|
||||
}, [setInspectorCollapsed])
|
||||
|
||||
const handleCustomizeNoteListColumns = useCallback(() => {
|
||||
if (effectiveSelection.kind === 'view') {
|
||||
openNoteListPropertiesPicker('view')
|
||||
return
|
||||
}
|
||||
|
||||
if (effectiveSelection.kind !== 'filter') return
|
||||
if (effectiveSelection.filter === 'all') {
|
||||
openNoteListPropertiesPicker('all')
|
||||
@@ -780,21 +805,35 @@ function App() {
|
||||
)
|
||||
}, [notes, setToastMessage, vault.entries])
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
const filename = editing
|
||||
? editing.filename
|
||||
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
|
||||
trackEvent(editing ? 'view_updated' : 'view_created')
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
|
||||
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
||||
|
||||
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
|
||||
const existing = vault.views.find((view) => view.filename === filename)
|
||||
if (!existing) return
|
||||
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', {
|
||||
vaultPath: resolvedPath,
|
||||
filename,
|
||||
definition: { ...existing.definition, ...patch },
|
||||
})
|
||||
await vault.reloadViews()
|
||||
}, [resolvedPath, vault])
|
||||
|
||||
const handleEditView = useCallback((filename: string) => {
|
||||
const view = vault.views.find((v) => v.filename === filename)
|
||||
if (view) dialogs.openEditView(filename, view.definition)
|
||||
@@ -942,6 +981,17 @@ function App() {
|
||||
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
|
||||
&& !activeDeletedFile
|
||||
|
||||
const noteListColumnsLabel = useMemo(() => {
|
||||
if (effectiveSelection.kind === 'view') {
|
||||
const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename)
|
||||
return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns'
|
||||
}
|
||||
|
||||
return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all'
|
||||
? 'Customize All Notes columns'
|
||||
: 'Customize Inbox columns'
|
||||
}, [effectiveSelection, vault.views])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
@@ -975,6 +1025,7 @@ function App() {
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
canGoBack: canGoBack, canGoForward: canGoForward,
|
||||
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
||||
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
|
||||
onCreateType: dialogs.openCreateType,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
@@ -1006,8 +1057,12 @@ function App() {
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
|
||||
canCustomizeNoteListColumns: effectiveSelection.kind === 'filter'
|
||||
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')),
|
||||
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|
||||
|| (
|
||||
effectiveSelection.kind === 'filter'
|
||||
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
|
||||
),
|
||||
noteListColumnsLabel,
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
@@ -1115,7 +1170,7 @@ function App() {
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -1180,7 +1235,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
@@ -1244,7 +1299,7 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
|
||||
defaultVaultPath={state.defaultPath}
|
||||
onCreateVault={onboarding.handleCreateVault}
|
||||
onRetryCreateVault={onboarding.retryCreateVault}
|
||||
onCreateNewVault={onboarding.handleCreateNewVault}
|
||||
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
isOffline={isOffline}
|
||||
creatingAction={onboarding.creatingAction}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CalendarIcon, Check, X } from 'lucide-react'
|
||||
@@ -27,6 +28,17 @@ function dateToISO(day: Date): string {
|
||||
|
||||
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
|
||||
function isValidNumberValue(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') return false
|
||||
return Number.isFinite(Number(trimmed))
|
||||
}
|
||||
|
||||
function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean {
|
||||
if (!key.trim()) return false
|
||||
return displayMode !== 'number' || isValidNumberValue(value)
|
||||
}
|
||||
|
||||
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const boolVal = value.toLowerCase() === 'true'
|
||||
return (
|
||||
@@ -91,21 +103,42 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
|
||||
)
|
||||
}
|
||||
|
||||
function AddNumberInput({ value, onChange, onKeyDown }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
className={`${ADD_INPUT_CLASS} font-mono tabular-nums`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
data-testid="add-property-number-input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
|
||||
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
|
||||
}) {
|
||||
switch (displayMode) {
|
||||
case 'number':
|
||||
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
|
||||
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
|
||||
case 'date': return <AddDateInput value={value} onChange={onChange} />
|
||||
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
|
||||
case 'tags': return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
|
||||
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
default: return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
@@ -119,6 +152,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
|
||||
const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode })
|
||||
|
||||
const handleModeChange = (mode: PropertyDisplayMode) => {
|
||||
setDisplayMode(mode)
|
||||
@@ -127,13 +161,13 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
|
||||
if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode)
|
||||
else if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
|
||||
<input
|
||||
<Input
|
||||
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Property name" value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
|
||||
@@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
|
||||
<Button
|
||||
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
|
||||
disabled={!newKey.trim()} title="Add property"
|
||||
disabled={!canSubmit} title="Add property"
|
||||
data-testid="add-property-confirm"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
|
||||
@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
|
||||
|
||||
const openExternalUrl = vi.fn()
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
|
||||
}))
|
||||
vi.mock('../hooks/useDragRegion', () => ({
|
||||
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
|
||||
}))
|
||||
|
||||
describe('AiAgentsOnboardingPrompt', () => {
|
||||
beforeEach(() => {
|
||||
@@ -65,4 +69,22 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
|
||||
})
|
||||
|
||||
it('uses the surrounding surface as a drag region and excludes the card', () => {
|
||||
render(
|
||||
<AiAgentsOnboardingPrompt
|
||||
statuses={{
|
||||
claude_code: { status: 'installed', version: '1.0.20' },
|
||||
codex: { status: 'missing', version: null },
|
||||
}}
|
||||
onContinue={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
|
||||
fireEvent.mouseDown(screenContainer)
|
||||
|
||||
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
|
||||
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AiAgentsStatus,
|
||||
} from '../lib/aiAgents'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
import { Button } from './ui/button'
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
|
||||
|
||||
@@ -82,11 +83,12 @@ export function AiAgentsOnboardingPrompt({
|
||||
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
|
||||
data-testid="ai-agents-onboarding-screen"
|
||||
<OnboardingShell
|
||||
className="bg-sidebar px-6 py-10"
|
||||
contentClassName="w-full max-w-2xl"
|
||||
testId="ai-agents-onboarding-screen"
|
||||
>
|
||||
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
|
||||
<Card className="border-border bg-background shadow-sm">
|
||||
<CardHeader className="items-center gap-5 text-center">
|
||||
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
|
||||
{copy.icon}
|
||||
@@ -141,6 +143,6 @@ export function AiAgentsOnboardingPrompt({
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</OnboardingShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -627,6 +627,14 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — number', () => {
|
||||
it('renders numeric properties with the number display affordance', () => {
|
||||
renderEditablePanel({ estimate: -3.25 })
|
||||
expect(screen.getByTestId('number-display')).toBeInTheDocument()
|
||||
expect(screen.getByText('-3.25')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — status auto-detection', () => {
|
||||
it('renders status badge for property named Status', () => {
|
||||
renderEditablePanel({ Status: 'Active' })
|
||||
@@ -741,6 +749,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-number')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
|
||||
@@ -798,6 +807,13 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
describe('type-aware add property form', () => {
|
||||
it('shows number input when number type selected', () => {
|
||||
openAddPropertyForm()
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
|
||||
expect(screen.getByTestId('add-property-number-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows boolean toggle when boolean type selected', () => {
|
||||
openAddPropertyForm()
|
||||
// Switch type to boolean
|
||||
@@ -829,6 +845,16 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('published', true)
|
||||
})
|
||||
|
||||
it('stores trimmed decimal values as numbers when adding number properties', () => {
|
||||
const { keyInput } = openAddPropertyForm()
|
||||
fireEvent.change(keyInput, { target: { value: 'estimate' } })
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
|
||||
fireEvent.change(screen.getByTestId('add-property-number-input'), { target: { value: ' -12.5 ' } })
|
||||
fireEvent.click(screen.getByTestId('add-property-confirm'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('estimate', -12.5)
|
||||
})
|
||||
|
||||
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
|
||||
openAddPropertyForm()
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
|
||||
@@ -2,6 +2,12 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GitRequiredModal } from './GitRequiredModal'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useDragRegion', () => ({
|
||||
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
|
||||
}))
|
||||
|
||||
describe('GitRequiredModal', () => {
|
||||
it('renders title and explanation', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
@@ -48,4 +54,14 @@ describe('GitRequiredModal', () => {
|
||||
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the surrounding surface as a drag region and excludes the card', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
|
||||
const shell = screen.getByTestId('git-required-shell')
|
||||
fireEvent.mouseDown(shell)
|
||||
|
||||
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
|
||||
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { GitBranch } from '@phosphor-icons/react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
|
||||
interface GitRequiredModalProps {
|
||||
onCreateRepo: () => Promise<void>
|
||||
@@ -22,8 +23,12 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
|
||||
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
|
||||
<OnboardingShell
|
||||
style={{ background: 'var(--sidebar)' }}
|
||||
contentClassName="w-full max-w-sm"
|
||||
testId="git-required-shell"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
|
||||
<GitBranch size={36} className="text-muted-foreground" />
|
||||
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
|
||||
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
|
||||
@@ -52,6 +57,6 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OnboardingShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -14,6 +16,12 @@ import {
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
} from './inlineWikilinkText'
|
||||
import { serializeInlineNode } from './inlineWikilinkDom'
|
||||
import {
|
||||
buildPendingPasteState,
|
||||
type PendingPasteState,
|
||||
shouldRecoverPendingPaste,
|
||||
} from './inlineWikilinkPasteRecovery'
|
||||
import {
|
||||
InlineWikilinkEditorField,
|
||||
InlineWikilinkPaletteLayout,
|
||||
@@ -48,7 +56,6 @@ function collapseSelectionRange(nextSelectionIndex: number) {
|
||||
end: nextSelectionIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function submitInlineValue({
|
||||
onSubmit,
|
||||
submitOnEmpty,
|
||||
@@ -66,48 +73,6 @@ function submitInlineValue({
|
||||
onSubmit(normalizedValue, references)
|
||||
}
|
||||
|
||||
function renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled: boolean
|
||||
inputRef: React.Ref<HTMLDivElement>
|
||||
dataTestId: string
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
return (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={inputRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelectionChange={onSelectionChange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderInlineSuggestionList({
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
@@ -157,12 +122,14 @@ export function InlineWikilinkInput({
|
||||
paletteEmptyState,
|
||||
paletteFooter,
|
||||
}: InlineWikilinkInputProps) {
|
||||
const [, forceRender] = useState(0)
|
||||
const segments = useMemo(
|
||||
() => buildInlineWikilinkSegments(value, entries),
|
||||
[entries, value],
|
||||
)
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
editorRef,
|
||||
selectionRange,
|
||||
selectionIndex,
|
||||
setSelectionRange,
|
||||
@@ -175,6 +142,7 @@ export function InlineWikilinkInput({
|
||||
onChange,
|
||||
inputRef,
|
||||
})
|
||||
const pendingPasteRef = useRef<PendingPasteState | null>(null)
|
||||
const activeQuery = useMemo(
|
||||
() => selectionRange.start === selectionRange.end
|
||||
? findActiveWikilinkQuery(value, selectionIndex)
|
||||
@@ -209,6 +177,36 @@ export function InlineWikilinkInput({
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
|
||||
if (!pastedText) return
|
||||
|
||||
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
|
||||
pendingPasteRef.current = buildPendingPasteState(value, selectionRange, pastedText)
|
||||
|
||||
event.preventDefault()
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const handleInput = () => {
|
||||
const editor = editorRef.current
|
||||
const pendingPaste = pendingPasteRef.current
|
||||
if (editor && pendingPaste) {
|
||||
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
|
||||
pendingPasteRef.current = null
|
||||
|
||||
if (shouldRecoverPendingPaste(nextValue, pendingPaste)) {
|
||||
onChange(pendingPaste.expectedValue)
|
||||
forceRender((current) => current + 1)
|
||||
setSelectionRange({ ...pendingPaste.expectedSelection })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
commitValueFromEditor()
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
@@ -223,19 +221,22 @@ export function InlineWikilinkInput({
|
||||
canSubmit: onSubmit !== undefined,
|
||||
onSubmit: submitValue,
|
||||
})
|
||||
const editor = renderInlineEditorField({
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
inputRef: setCombinedRef,
|
||||
dataTestId,
|
||||
editorClassName,
|
||||
onInput: commitValueFromEditor,
|
||||
onKeyDown: handleKeyDown,
|
||||
onSelectionChange: syncSelectionRange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
})
|
||||
const editor = (
|
||||
<InlineWikilinkEditorField
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
inputRef={setCombinedRef}
|
||||
dataTestId={dataTestId}
|
||||
editorClassName={editorClassName}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onSelectionChange={syncSelectionRange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
/>
|
||||
)
|
||||
const suggestionList = renderInlineSuggestionList({
|
||||
suggestions,
|
||||
selectedSuggestionIndex,
|
||||
|
||||
@@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
@@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
|
||||
)}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
onMouseUp={onSelectionChange}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
|
||||
import {
|
||||
allSelection,
|
||||
buildNoteListProps,
|
||||
makeEntry,
|
||||
makeTypeDefinition,
|
||||
mockEntries,
|
||||
renderNoteList,
|
||||
} from '../test-utils/noteListTestUtils'
|
||||
import type { ViewFile } from '../types'
|
||||
|
||||
function makeBookTypeEntries(
|
||||
displayProps: string[] = [],
|
||||
@@ -28,6 +32,58 @@ function makeBookTypeEntries(
|
||||
|
||||
const noop = () => undefined
|
||||
|
||||
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
|
||||
return {
|
||||
filename: 'active-books.yml',
|
||||
definition: {
|
||||
name: 'Active Books',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
|
||||
...overrides.definition,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderManagedViewNoteList({
|
||||
entries,
|
||||
view = makeViewDefinition(),
|
||||
}: {
|
||||
entries: Parameters<typeof renderNoteList>[0]['entries']
|
||||
view?: ViewFile
|
||||
}) {
|
||||
const built = buildNoteListProps({
|
||||
entries,
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
views: [view],
|
||||
})
|
||||
|
||||
function ManagedViewNoteList() {
|
||||
const [views, setViews] = useState([view])
|
||||
|
||||
return (
|
||||
<NoteList
|
||||
{...built.props}
|
||||
views={views}
|
||||
onUpdateViewDefinition={(filename, patch) => {
|
||||
setViews((currentViews) => currentViews.map((currentView) => (
|
||||
currentView.filename === filename
|
||||
? { ...currentView, definition: { ...currentView.definition, ...patch } }
|
||||
: currentView
|
||||
)))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...render(<ManagedViewNoteList />),
|
||||
...built,
|
||||
}
|
||||
}
|
||||
|
||||
function searchNoteList(query: string) {
|
||||
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
||||
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
||||
@@ -354,6 +410,44 @@ describe('NoteList rendering', () => {
|
||||
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
|
||||
})
|
||||
|
||||
it('opens the view column picker from the global event and applies the saved columns', () => {
|
||||
renderManagedViewNoteList({
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
})
|
||||
|
||||
expect(screen.getByText('High')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
openNoteListPropertiesPicker('view')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
|
||||
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty-state picker for views with no matching properties', () => {
|
||||
renderManagedViewNoteList({
|
||||
entries: makeBookTypeEntries(),
|
||||
view: makeViewDefinition({
|
||||
filename: 'empty-view.yml',
|
||||
definition: {
|
||||
name: 'Empty View',
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
openNoteListPropertiesPicker('view')
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
|
||||
expect(screen.getByText('No properties match this search.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows status in the type column picker when at least one note has it set', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries([], { status: 'Active' }),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NoteList } from './NoteList'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
|
||||
import { getSortComparator } from '../utils/noteListHelpers'
|
||||
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
import { buildNoteListProps, makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
|
||||
import type { ViewFile } from '../types'
|
||||
|
||||
describe('getSortComparator', () => {
|
||||
it('sorts by modified date descending', () => {
|
||||
@@ -121,6 +124,52 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
function makeView(overrides: Partial<ViewFile> = {}): ViewFile {
|
||||
return {
|
||||
filename: 'rated-books.yml',
|
||||
definition: {
|
||||
name: 'Rated Books',
|
||||
icon: null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
|
||||
...overrides.definition,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderManagedViewSort(entries: typeof zamEntries, view = makeView()) {
|
||||
const built = buildNoteListProps({
|
||||
entries,
|
||||
selection: { kind: 'view', filename: view.filename },
|
||||
views: [view],
|
||||
})
|
||||
|
||||
function ManagedViewNoteList() {
|
||||
const [views, setViews] = useState([view])
|
||||
|
||||
return (
|
||||
<NoteList
|
||||
{...built.props}
|
||||
views={views}
|
||||
onUpdateViewDefinition={(filename, patch) => {
|
||||
setViews((currentViews) => currentViews.map((currentView) => (
|
||||
currentView.filename === filename
|
||||
? { ...currentView, definition: { ...currentView.definition, ...patch } }
|
||||
: currentView
|
||||
)))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...render(<ManagedViewNoteList />),
|
||||
...built,
|
||||
}
|
||||
}
|
||||
|
||||
function openListSortMenu(entries = mockEntries) {
|
||||
renderNoteList({ entries })
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
@@ -294,4 +343,38 @@ describe('NoteList sort controls', () => {
|
||||
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['A', 'C', 'B'])
|
||||
})
|
||||
|
||||
it('loads view sort properties from the current view results only', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', properties: { Rating: 3 } }),
|
||||
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', properties: { Priority: 'High' } }),
|
||||
makeEntry({ path: '/project-a.md', title: 'Project A', isA: 'Project', properties: { Owner: 'Luca' } }),
|
||||
]
|
||||
|
||||
renderManagedViewSort(entries)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
|
||||
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('sort-option-property:Owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports keyboard selection for view sorting and persists the chosen property', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', modifiedAt: 1000, properties: { Rating: 3 } }),
|
||||
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', modifiedAt: 3000, properties: { Rating: 1 } }),
|
||||
makeEntry({ path: '/book-c.md', title: 'Book C', isA: 'Book', modifiedAt: 2000, properties: { Rating: 5 } }),
|
||||
]
|
||||
|
||||
renderManagedViewSort(entries)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.keyDown(screen.getByTestId('sort-menu-__list__'), { key: 'End' })
|
||||
expect(screen.getByTestId('sort-option-property:Rating')).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('sort-option-property:Rating'), { key: 'Enter' })
|
||||
|
||||
const titles = screen.getAllByText(/^Book [ABC]$/).map((element) => element.textContent)
|
||||
expect(titles).toEqual(['Book B', 'Book A', 'Book C'])
|
||||
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
36
src/components/OnboardingShell.tsx
Normal file
36
src/components/OnboardingShell.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
|
||||
interface OnboardingShellProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
contentStyle?: CSSProperties
|
||||
style?: CSSProperties
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export function OnboardingShell({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
contentStyle,
|
||||
style,
|
||||
testId,
|
||||
}: OnboardingShellProps) {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
|
||||
style={style}
|
||||
data-testid={testId}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
<div className={contentClassName} style={contentStyle} data-no-drag>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUpRight } from '@phosphor-icons/react'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { isValidCssColor } from '../utils/colorUtils'
|
||||
@@ -154,6 +155,77 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
|
||||
)
|
||||
}
|
||||
|
||||
function NumberValue({
|
||||
value,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
onStartEdit,
|
||||
}: ScalarEditProps) {
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
|
||||
const restoreValue = useCallback(() => {
|
||||
setEditValue(value)
|
||||
}, [value])
|
||||
|
||||
const commitValue = useCallback(() => {
|
||||
const trimmed = editValue.trim()
|
||||
if (trimmed === '') {
|
||||
onSave('')
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed)
|
||||
if (Number.isFinite(parsed)) {
|
||||
onSave(trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
restoreValue()
|
||||
onCancel()
|
||||
}, [editValue, onCancel, onSave, restoreValue])
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
commitValue()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
restoreValue()
|
||||
onCancel()
|
||||
}
|
||||
}, [commitValue, onCancel, restoreValue])
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={editValue}
|
||||
onChange={(event) => setEditValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitValue}
|
||||
autoFocus
|
||||
data-testid="number-input"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
data-testid="number-display"
|
||||
>
|
||||
<span className="min-w-0 truncate">{value || '\u2014'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
|
||||
value: string
|
||||
onSave: (newValue: string) => void
|
||||
@@ -352,48 +424,38 @@ function createScalarEditProps({
|
||||
}
|
||||
}
|
||||
|
||||
function renderScalarDisplayMode({
|
||||
propKey,
|
||||
value,
|
||||
isEditing,
|
||||
resolvedMode,
|
||||
vaultStatuses,
|
||||
vaultTags,
|
||||
onSave,
|
||||
onSaveList,
|
||||
onStartEdit,
|
||||
onUpdate,
|
||||
editProps,
|
||||
}: SmartCellProps & {
|
||||
resolvedMode: PropertyDisplayMode
|
||||
type ScalarRendererProps = SmartCellProps & {
|
||||
editProps: ScalarEditProps
|
||||
}) {
|
||||
switch (resolvedMode) {
|
||||
case 'status':
|
||||
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
case 'tags':
|
||||
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
case 'date':
|
||||
return (
|
||||
<DateValue
|
||||
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
|
||||
value={String(value ?? '')}
|
||||
onSave={(v) => onSave(propKey, v)}
|
||||
autoOpen={isEditing}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
)
|
||||
case 'boolean': {
|
||||
const boolVal = toBooleanValue(value)
|
||||
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
|
||||
}
|
||||
case 'url':
|
||||
return <UrlValue {...editProps} />
|
||||
case 'color':
|
||||
return <ColorEditableValue {...editProps} />
|
||||
default:
|
||||
return <EditableValue {...editProps} />
|
||||
}
|
||||
}
|
||||
|
||||
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
|
||||
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
|
||||
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
),
|
||||
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
|
||||
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
),
|
||||
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
|
||||
<DateValue
|
||||
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
|
||||
value={String(value ?? '')}
|
||||
onSave={(nextValue) => onSave(propKey, nextValue)}
|
||||
autoOpen={isEditing}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
),
|
||||
number: ({ editProps }) => <NumberValue {...editProps} />,
|
||||
boolean: ({ propKey, value, onUpdate }) => {
|
||||
const boolVal = toBooleanValue(value)
|
||||
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
|
||||
},
|
||||
url: ({ editProps }) => <UrlValue {...editProps} />,
|
||||
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
|
||||
}
|
||||
|
||||
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
|
||||
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
|
||||
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
|
||||
}
|
||||
|
||||
function ScalarValueCell(props: SmartCellProps) {
|
||||
|
||||
@@ -1,8 +1,237 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
|
||||
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
|
||||
|
||||
interface SortItem {
|
||||
value: SortOption
|
||||
label: string
|
||||
}
|
||||
|
||||
type SortMenuAction =
|
||||
| { type: 'close' }
|
||||
| { type: 'focus'; index: number }
|
||||
|
||||
function buildSortItems(customProperties?: string[]): SortItem[] {
|
||||
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
|
||||
const customItems = (customProperties ?? []).map((key) => ({
|
||||
value: `property:${key}` as SortOption,
|
||||
label: key,
|
||||
}))
|
||||
return [...builtInItems, ...customItems]
|
||||
}
|
||||
|
||||
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
|
||||
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
|
||||
|
||||
const currentIndex = sortItems.findIndex((item) => item.value === current)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>, index: number) {
|
||||
sortButtonRefs.current[index]?.focus()
|
||||
}
|
||||
|
||||
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
|
||||
const lastIndex = itemCount - 1
|
||||
|
||||
switch (key) {
|
||||
case 'Escape':
|
||||
return { type: 'close' }
|
||||
case 'ArrowDown':
|
||||
return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) }
|
||||
case 'ArrowUp':
|
||||
return { type: 'focus', index: Math.max(0, focusIndex - 1) }
|
||||
case 'Home':
|
||||
return { type: 'focus', index: 0 }
|
||||
case 'End':
|
||||
return { type: 'focus', index: lastIndex }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function selectOnKeyboard(
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
value: SortOption,
|
||||
direction: SortDirection,
|
||||
onSelect: (opt: SortOption, dir: SortDirection) => void,
|
||||
) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onSelect(value, direction)
|
||||
}
|
||||
|
||||
function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) {
|
||||
return cn(
|
||||
'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40',
|
||||
)
|
||||
}
|
||||
|
||||
function useSortDropdownState({
|
||||
groupLabel,
|
||||
current,
|
||||
sortItems,
|
||||
onChange,
|
||||
}: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
sortItems: SortItem[]
|
||||
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const sortButtonRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (containerRef.current?.contains(event.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems))
|
||||
}, [current, groupLabel, open, sortItems])
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => {
|
||||
onChange(groupLabel, option, nextDirection)
|
||||
closeMenu()
|
||||
}, [closeMenu, groupLabel, onChange])
|
||||
|
||||
const handleMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const action = resolveSortMenuAction(
|
||||
event.key,
|
||||
resolveFocusedIndex(groupLabel, current, sortItems),
|
||||
sortItems.length,
|
||||
)
|
||||
if (!action) return
|
||||
|
||||
event.preventDefault()
|
||||
if (action.type === 'close') {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
|
||||
focusSortItem(sortButtonRefs, action.index)
|
||||
}, [closeMenu, current, groupLabel, sortItems])
|
||||
|
||||
return {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
triggerRef,
|
||||
sortButtonRefs,
|
||||
handleSelect,
|
||||
handleMenuKeyDown,
|
||||
}
|
||||
}
|
||||
|
||||
function SortDropdownTrigger({
|
||||
triggerRef,
|
||||
open,
|
||||
current,
|
||||
groupLabel,
|
||||
direction,
|
||||
onToggle,
|
||||
}: {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>
|
||||
open: boolean
|
||||
current: SortOption
|
||||
groupLabel: string
|
||||
direction: SortDirection
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={cn('flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', open && 'bg-accent text-foreground')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}}
|
||||
title={`Sort by ${getSortOptionLabel(current)}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
|
||||
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SortDropdownMenu({
|
||||
open,
|
||||
groupLabel,
|
||||
current,
|
||||
direction,
|
||||
sortItems,
|
||||
sortButtonRefs,
|
||||
onKeyDown,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
direction: SortDirection
|
||||
sortItems: SortItem[]
|
||||
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onSelect: (option: SortOption, nextDirection: SortDirection) => void
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
const hasCustom = sortItems.length > SORT_OPTIONS.length
|
||||
const builtInOptionCount = SORT_OPTIONS.length
|
||||
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={`Sort ${groupLabel}`}
|
||||
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
|
||||
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
|
||||
onKeyDown={onKeyDown}
|
||||
data-testid={`sort-menu-${groupLabel}`}
|
||||
>
|
||||
{sortItems.map((item, index) => (
|
||||
<SortRow
|
||||
key={item.value}
|
||||
index={index}
|
||||
groupLabel={groupLabel}
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
current={current}
|
||||
direction={direction}
|
||||
buttonRef={(node) => {
|
||||
sortButtonRefs.current[index] = node
|
||||
}}
|
||||
showSeparator={hasCustom && index === builtInOptionCount}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
@@ -10,99 +239,142 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
|
||||
customProperties?: string[]
|
||||
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (opt: SortOption, dir: SortDirection) => {
|
||||
onChange(groupLabel, opt, dir)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
|
||||
const hasCustom = customProperties && customProperties.length > 0
|
||||
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
containerRef,
|
||||
triggerRef,
|
||||
sortButtonRefs,
|
||||
handleSelect,
|
||||
handleMenuKeyDown,
|
||||
} = useSortDropdownState({
|
||||
groupLabel,
|
||||
current,
|
||||
sortItems,
|
||||
onChange,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<button
|
||||
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
|
||||
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
||||
title={`Sort by ${getSortOptionLabel(current)}`}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
|
||||
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
|
||||
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
|
||||
data-testid={`sort-menu-${groupLabel}`}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
|
||||
))}
|
||||
{hasCustom && (
|
||||
<>
|
||||
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
|
||||
{customProperties.map((key) => {
|
||||
const value: SortOption = `property:${key}`
|
||||
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<SortDropdownTrigger
|
||||
triggerRef={triggerRef}
|
||||
open={open}
|
||||
current={current}
|
||||
groupLabel={groupLabel}
|
||||
direction={direction}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
/>
|
||||
<SortDropdownMenu
|
||||
open={open}
|
||||
groupLabel={groupLabel}
|
||||
current={current}
|
||||
direction={direction}
|
||||
sortItems={sortItems}
|
||||
sortButtonRefs={sortButtonRefs}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortRow({ value, label, current, direction, onSelect }: {
|
||||
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
|
||||
index: number
|
||||
groupLabel: string
|
||||
value: SortOption
|
||||
label: string
|
||||
current: SortOption
|
||||
direction: SortDirection
|
||||
buttonRef: (node: HTMLButtonElement | null) => void
|
||||
showSeparator: boolean
|
||||
onSelect: (opt: SortOption, dir: SortDirection) => void
|
||||
}) {
|
||||
const isActive = value === current
|
||||
const defaultDirection = isActive ? direction : getDefaultDirection(value)
|
||||
const itemData = {
|
||||
'data-sort-group-label': groupLabel,
|
||||
'data-sort-item-index': String(index),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
|
||||
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
|
||||
data-testid={`sort-option-${value}`}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 ml-1 shrink-0">
|
||||
<>
|
||||
{showSeparator && <div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />}
|
||||
<div
|
||||
className={cn('flex items-center justify-between gap-1 rounded px-1 text-[12px] text-popover-foreground hover:bg-accent', isActive && 'bg-accent font-medium')}
|
||||
style={{ minHeight: 28 }}
|
||||
>
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
|
||||
data-testid={`sort-dir-asc-${value}`}
|
||||
title="Ascending"
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={isActive}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-1 text-left text-inherit hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelect(value, defaultDirection)
|
||||
}}
|
||||
onKeyDown={(event) => selectOnKeyboard(event, value, defaultDirection, onSelect)}
|
||||
data-testid={`sort-option-${value}`}
|
||||
{...itemData}
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
|
||||
data-testid={`sort-dir-desc-${value}`}
|
||||
title="Descending"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<SortDirectionButton
|
||||
value={value}
|
||||
direction="asc"
|
||||
activeDirection={direction}
|
||||
isActive={isActive}
|
||||
onSelect={onSelect}
|
||||
icon={<ArrowUp size={12} />}
|
||||
itemData={itemData}
|
||||
/>
|
||||
<SortDirectionButton
|
||||
value={value}
|
||||
direction="desc"
|
||||
activeDirection={direction}
|
||||
isActive={isActive}
|
||||
onSelect={onSelect}
|
||||
icon={<ArrowDown size={12} />}
|
||||
itemData={itemData}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SortDirectionButton({
|
||||
value,
|
||||
direction,
|
||||
activeDirection,
|
||||
isActive,
|
||||
onSelect,
|
||||
icon,
|
||||
itemData,
|
||||
}: {
|
||||
value: SortOption
|
||||
direction: SortDirection
|
||||
activeDirection: SortDirection
|
||||
isActive: boolean
|
||||
onSelect: (opt: SortOption, dir: SortDirection) => void
|
||||
icon: React.ReactNode
|
||||
itemData: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={getDirectionButtonClass(isActive, activeDirection, direction)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSelect(value, direction)
|
||||
}}
|
||||
data-testid={`sort-dir-${direction}-${value}`}
|
||||
title={direction === 'asc' ? 'Ascending' : 'Descending'}
|
||||
{...itemData}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,24 @@ describe('StatusBar', () => {
|
||||
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows "Create empty vault" option in vault menu', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateEmptyVault when clicking "Create empty vault"', () => {
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={onCreateEmptyVault} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Create empty vault'))
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows add-vault options in vault menu', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
@@ -161,11 +179,13 @@ describe('StatusBar', () => {
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
onCreateEmptyVault={vi.fn()}
|
||||
onOpenLocalFolder={vi.fn()}
|
||||
onCloneVault={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ interface StatusBarProps {
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenSettings?: () => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCloneVault?: () => void
|
||||
onCloneGettingStarted?: () => void
|
||||
onClickPending?: () => void
|
||||
@@ -60,6 +61,7 @@ export function StatusBar({
|
||||
onSwitchVault,
|
||||
onOpenSettings,
|
||||
onOpenLocalFolder,
|
||||
onCreateEmptyVault,
|
||||
onCloneVault,
|
||||
onCloneGettingStarted,
|
||||
onClickPending,
|
||||
@@ -121,6 +123,7 @@ export function StatusBar({
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCreateEmptyVault={onCreateEmptyVault}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onClickPending={onClickPending}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useDragRegion', () => ({
|
||||
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
|
||||
}))
|
||||
|
||||
describe('TelemetryConsentDialog', () => {
|
||||
it('renders the consent dialog', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
@@ -32,4 +38,14 @@ describe('TelemetryConsentDialog', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('uses the surrounding surface as a drag region and excludes the dialog card', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
|
||||
const shell = screen.getByTestId('telemetry-consent-shell')
|
||||
fireEvent.mouseDown(shell)
|
||||
|
||||
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
|
||||
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ShieldCheck } from '@phosphor-icons/react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
|
||||
interface TelemetryConsentDialogProps {
|
||||
onAccept: () => void
|
||||
@@ -7,14 +8,21 @@ interface TelemetryConsentDialogProps {
|
||||
|
||||
export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
<OnboardingShell
|
||||
className="fixed inset-0 z-50"
|
||||
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
|
||||
style={{ background: 'rgba(0,0,0,0.4)' }}
|
||||
contentStyle={{
|
||||
width: 'min(440px, 100%)',
|
||||
padding: 32,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
testId="telemetry-consent-shell"
|
||||
>
|
||||
<div
|
||||
className="bg-background border border-border rounded-lg shadow-xl"
|
||||
style={{ width: 440, padding: 32, display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}
|
||||
>
|
||||
<>
|
||||
<ShieldCheck size={40} weight="duotone" style={{ color: 'var(--primary)' }} />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
@@ -63,7 +71,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
|
||||
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>
|
||||
You can change this anytime in Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</OnboardingShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useDragRegion', () => ({
|
||||
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
mode: 'welcome' as const,
|
||||
defaultVaultPath: '~/Documents/Laputa',
|
||||
onCreateVault: vi.fn(),
|
||||
onRetryCreateVault: vi.fn(),
|
||||
onCreateNewVault: vi.fn(),
|
||||
onCreateEmptyVault: vi.fn(),
|
||||
onOpenFolder: vi.fn(),
|
||||
isOffline: false,
|
||||
creatingAction: null as 'template' | 'empty' | null,
|
||||
@@ -16,6 +22,10 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe('WelcomeScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('welcome mode', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
@@ -25,7 +35,7 @@ describe('WelcomeScreen', () => {
|
||||
|
||||
it('shows all three option buttons', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
|
||||
})
|
||||
@@ -46,11 +56,11 @@ describe('WelcomeScreen', () => {
|
||||
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateNewVault when create new button is clicked', () => {
|
||||
const onCreateNewVault = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
|
||||
it('calls onCreateEmptyVault when create empty button is clicked', () => {
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
|
||||
fireEvent.click(screen.getByTestId('welcome-create-new'))
|
||||
expect(onCreateNewVault).toHaveBeenCalledOnce()
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onCreateVault when template button is clicked', () => {
|
||||
@@ -146,5 +156,15 @@ describe('WelcomeScreen', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the surrounding surface as a drag region and excludes the card', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
|
||||
const screenContainer = screen.getByTestId('welcome-screen')
|
||||
fireEvent.mouseDown(screenContainer)
|
||||
|
||||
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
|
||||
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
mode: 'welcome' | 'vault-missing'
|
||||
@@ -8,7 +9,7 @@ interface WelcomeScreenProps {
|
||||
defaultVaultPath: string
|
||||
onCreateVault: () => void
|
||||
onRetryCreateVault: () => void
|
||||
onCreateNewVault: () => void
|
||||
onCreateEmptyVault: () => void
|
||||
onOpenFolder: () => void
|
||||
isOffline: boolean
|
||||
creatingAction: 'template' | 'empty' | null
|
||||
@@ -25,17 +26,8 @@ interface WelcomeScreenPresentation {
|
||||
title: string
|
||||
}
|
||||
|
||||
const CONTAINER_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--sidebar)',
|
||||
}
|
||||
|
||||
const CARD_STYLE: React.CSSProperties = {
|
||||
width: 520,
|
||||
width: 'min(520px, 100%)',
|
||||
background: 'var(--background)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
@@ -238,7 +230,7 @@ export function WelcomeScreen({
|
||||
defaultVaultPath,
|
||||
onCreateVault,
|
||||
onRetryCreateVault,
|
||||
onCreateNewVault,
|
||||
onCreateEmptyVault,
|
||||
onOpenFolder,
|
||||
isOffline,
|
||||
creatingAction,
|
||||
@@ -249,8 +241,12 @@ export function WelcomeScreen({
|
||||
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
|
||||
|
||||
return (
|
||||
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
|
||||
<div style={CARD_STYLE}>
|
||||
<OnboardingShell
|
||||
style={{ background: 'var(--sidebar)' }}
|
||||
contentStyle={CARD_STYLE}
|
||||
testId="welcome-screen"
|
||||
>
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...ICON_WRAP_STYLE,
|
||||
@@ -273,11 +269,11 @@ export function WelcomeScreen({
|
||||
<OptionButton
|
||||
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
|
||||
iconBg="var(--accent-blue-light, #EBF4FF)"
|
||||
label="Create a new vault"
|
||||
description="Start fresh in a folder you choose"
|
||||
label="Create empty vault"
|
||||
description="Start fresh in an empty folder with Tolaria defaults"
|
||||
loadingLabel="Creating vault…"
|
||||
loadingDescription="Preparing an empty vault in the selected folder"
|
||||
onClick={onCreateNewVault}
|
||||
loadingDescription="Preparing Tolaria defaults in the selected folder"
|
||||
onClick={onCreateEmptyVault}
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
@@ -331,7 +327,7 @@ export function WelcomeScreen({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</OnboardingShell>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/components/inlineWikilinkPasteRecovery.test.ts
Normal file
26
src/components/inlineWikilinkPasteRecovery.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPendingPasteState,
|
||||
shouldRecoverPendingPaste,
|
||||
} from './inlineWikilinkPasteRecovery'
|
||||
|
||||
describe('inlineWikilinkPasteRecovery', () => {
|
||||
it('recognizes the expected post-paste value as recoverable', () => {
|
||||
const pendingPaste = buildPendingPasteState(' ', { start: 1, end: 1 }, 'hello world')
|
||||
|
||||
expect(pendingPaste).toEqual({
|
||||
duplicatedValue: ' hello worldhello world',
|
||||
expectedValue: ' hello world',
|
||||
expectedSelection: { start: 12, end: 12 },
|
||||
})
|
||||
expect(shouldRecoverPendingPaste(' hello world', pendingPaste)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes the duplicate native replay as recoverable when pasting into the middle of text', () => {
|
||||
const pendingPaste = buildPendingPasteState('prefix suffix', { start: 7, end: 7 }, 'hello ')
|
||||
|
||||
expect(pendingPaste.duplicatedValue).toBe('prefix hello hello suffix')
|
||||
expect(shouldRecoverPendingPaste('prefix hello hello suffix', pendingPaste)).toBe(true)
|
||||
expect(shouldRecoverPendingPaste('prefix hello suffix!', pendingPaste)).toBe(false)
|
||||
})
|
||||
})
|
||||
32
src/components/inlineWikilinkPasteRecovery.ts
Normal file
32
src/components/inlineWikilinkPasteRecovery.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { InlineSelectionRange } from './inlineWikilinkDom'
|
||||
import { replaceInlineSelection } from './inlineWikilinkEdits'
|
||||
|
||||
export interface PendingPasteState {
|
||||
duplicatedValue: string
|
||||
expectedValue: string
|
||||
expectedSelection: InlineSelectionRange
|
||||
}
|
||||
|
||||
export function buildPendingPasteState(
|
||||
value: string,
|
||||
selectionRange: InlineSelectionRange,
|
||||
pastedText: string,
|
||||
): PendingPasteState {
|
||||
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
|
||||
|
||||
return {
|
||||
duplicatedValue: replaceInlineSelection(nextState.value, nextState.selection, pastedText).value,
|
||||
expectedValue: nextState.value,
|
||||
expectedSelection: nextState.selection,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRecoverPendingPaste(
|
||||
nextValue: string,
|
||||
pendingPaste: PendingPasteState,
|
||||
): boolean {
|
||||
return (
|
||||
nextValue === pendingPaste.expectedValue ||
|
||||
nextValue === pendingPaste.duplicatedValue
|
||||
)
|
||||
}
|
||||
@@ -372,8 +372,6 @@ export function ListPropertiesPopover({
|
||||
handleToggle,
|
||||
} = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave })
|
||||
|
||||
if (availableProperties.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewDefinition, ViewFile } from '../../types'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
@@ -156,29 +156,99 @@ export function useNoteListSearch() {
|
||||
|
||||
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
|
||||
|
||||
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
|
||||
function findSelectedViewFile(selection: SidebarSelection, views?: ViewFile[]): ViewFile | null {
|
||||
if (selection.kind !== 'view') return null
|
||||
return views?.find((candidate) => candidate.filename === selection.filename) ?? null
|
||||
}
|
||||
|
||||
function findSelectedTypeDocument(entries: VaultEntry[], selection: SidebarSelection): VaultEntry | null {
|
||||
if (selection.kind !== 'sectionGroup') return null
|
||||
return entries.find((entry) => entry.isA === 'Type' && entry.title === selection.type) ?? null
|
||||
}
|
||||
|
||||
function resolveListSortConfig(
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
sortPrefs: Record<string, SortConfig>,
|
||||
): SortConfig {
|
||||
if (typeDocument?.sort) {
|
||||
const parsed = parseSortConfig(typeDocument.sort)
|
||||
if (parsed) return parsed
|
||||
}
|
||||
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
|
||||
|
||||
if (selectedView?.definition.sort) {
|
||||
const parsed = parseSortConfig(selectedView.definition.sort)
|
||||
if (parsed) return parsed
|
||||
}
|
||||
|
||||
return selectedView ? DEFAULT_LIST_CONFIG : (sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG)
|
||||
}
|
||||
|
||||
interface SortPersistence {
|
||||
onUpdateTypeSort: (path: string, key: string, value: string) => void
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
}
|
||||
|
||||
function createSortPersistence(
|
||||
onUpdateTypeSort?: SortPersistence['onUpdateTypeSort'],
|
||||
updateEntry?: SortPersistence['updateEntry'],
|
||||
onUpdateViewDefinition?: SortPersistence['onUpdateViewDefinition'],
|
||||
): SortPersistence | null {
|
||||
if (!onUpdateViewDefinition && !(onUpdateTypeSort && updateEntry)) return null
|
||||
return { onUpdateTypeSort, updateEntry, onUpdateViewDefinition }
|
||||
}
|
||||
|
||||
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
|
||||
const serialized = serializeSortConfig(config)
|
||||
persistence.onUpdateTypeSort(path, 'sort', serialized)
|
||||
persistence.updateEntry(path, { sort: serialized })
|
||||
persistence.onUpdateTypeSort?.(path, 'sort', serialized)
|
||||
persistence.updateEntry?.(path, { sort: serialized })
|
||||
clearListSortFromLocalStorage()
|
||||
}
|
||||
|
||||
function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) {
|
||||
if (groupLabel !== '__list__' || !typeDocument || !persistence) return null
|
||||
return { path: typeDocument.path, persistence }
|
||||
function persistSortToView(
|
||||
filename: string,
|
||||
config: SortConfig,
|
||||
onUpdateViewDefinition: NonNullable<SortPersistence['onUpdateViewDefinition']>,
|
||||
) {
|
||||
onUpdateViewDefinition(filename, { sort: serializeSortConfig(config) })
|
||||
}
|
||||
|
||||
type SortPersistenceTarget =
|
||||
| { kind: 'type'; path: string }
|
||||
| { kind: 'view'; filename: string }
|
||||
|
||||
function canPersistTypeSort(
|
||||
persistence: SortPersistence,
|
||||
): persistence is SortPersistence & Required<Pick<SortPersistence, 'onUpdateTypeSort' | 'updateEntry'>> {
|
||||
return Boolean(persistence.onUpdateTypeSort && persistence.updateEntry)
|
||||
}
|
||||
|
||||
function resolveSortPersistenceTarget(
|
||||
groupLabel: string,
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
persistence: SortPersistence | null,
|
||||
): SortPersistenceTarget | null {
|
||||
if (groupLabel !== '__list__' || !persistence) return null
|
||||
if (typeDocument && canPersistTypeSort(persistence)) {
|
||||
return { kind: 'type', path: typeDocument.path }
|
||||
}
|
||||
if (selectedView && persistence.onUpdateViewDefinition) {
|
||||
return { kind: 'view', filename: selectedView.filename }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function persistListSort(target: SortPersistenceTarget, config: SortConfig, persistence: SortPersistence) {
|
||||
if (target.kind === 'type') {
|
||||
persistSortToType(target.path, config, persistence)
|
||||
return
|
||||
}
|
||||
|
||||
if (persistence.onUpdateViewDefinition) {
|
||||
persistSortToView(target.filename, config, persistence.onUpdateViewDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
|
||||
@@ -193,6 +263,24 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
|
||||
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
|
||||
}
|
||||
|
||||
function persistOrSaveGroupSort(
|
||||
groupLabel: string,
|
||||
option: SortOption,
|
||||
direction: SortDirection,
|
||||
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
|
||||
typeDocument: VaultEntry | null,
|
||||
selectedView: ViewFile | null,
|
||||
persistence: SortPersistence | null,
|
||||
) {
|
||||
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
|
||||
if (!persistenceTarget || !persistence) {
|
||||
saveGroupSort(groupLabel, option, direction, setSortPrefs)
|
||||
return
|
||||
}
|
||||
|
||||
persistListSort(persistenceTarget, { option, direction }, persistence)
|
||||
}
|
||||
|
||||
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
|
||||
if (!configOption.startsWith('property:')) return configOption
|
||||
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
|
||||
@@ -205,22 +293,35 @@ export interface UseNoteListSortParams {
|
||||
modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
views?: ViewFile[]
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
export function useNoteListSort({
|
||||
entries,
|
||||
selection,
|
||||
modifiedPathSet,
|
||||
modifiedSuffixes,
|
||||
subFilter,
|
||||
inboxPeriod,
|
||||
views,
|
||||
onUpdateTypeSort,
|
||||
onUpdateViewDefinition,
|
||||
updateEntry,
|
||||
}: UseNoteListSortParams) {
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
|
||||
const typeDocument = useMemo(() => findSelectedTypeDocument(entries, selection), [entries, selection])
|
||||
const selectedView = useMemo(
|
||||
() => findSelectedViewFile(selection, views),
|
||||
[selection, views],
|
||||
)
|
||||
|
||||
const typeDocument = useMemo(() => {
|
||||
if (selection.kind !== 'sectionGroup') return null
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
|
||||
}, [selection, entries])
|
||||
|
||||
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
|
||||
const listConfig = resolveListSortConfig(typeDocument, selectedView, sortPrefs)
|
||||
const persistence = useMemo<SortPersistence | null>(
|
||||
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
|
||||
[onUpdateTypeSort, updateEntry],
|
||||
() => createSortPersistence(onUpdateTypeSort, updateEntry, onUpdateViewDefinition),
|
||||
[onUpdateTypeSort, onUpdateViewDefinition, updateEntry],
|
||||
)
|
||||
|
||||
const migrationDoneRef = useRef<Set<string>>(new Set())
|
||||
@@ -230,10 +331,16 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
|
||||
}, [typeDocument, sortPrefs, persistence])
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
|
||||
const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence)
|
||||
if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs)
|
||||
persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence)
|
||||
}, [typeDocument, persistence])
|
||||
persistOrSaveGroupSort(
|
||||
groupLabel,
|
||||
option,
|
||||
direction,
|
||||
setSortPrefs,
|
||||
typeDocument,
|
||||
selectedView,
|
||||
persistence,
|
||||
)
|
||||
}, [typeDocument, selectedView, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries({
|
||||
entries,
|
||||
@@ -242,6 +349,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
|
||||
modifiedSuffixes,
|
||||
subFilter,
|
||||
inboxPeriod,
|
||||
views,
|
||||
})
|
||||
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
|
||||
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
|
||||
@@ -393,6 +501,96 @@ function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string
|
||||
return ordered
|
||||
}
|
||||
|
||||
interface ScopedPropertyPickerState {
|
||||
availableProperties: string[]
|
||||
defaultDisplay: string[]
|
||||
}
|
||||
|
||||
function useAllNotesPropertyPickerState(
|
||||
entries: VaultEntry[],
|
||||
selection: SidebarSelection,
|
||||
isAllNotesView: boolean,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): ScopedPropertyPickerState {
|
||||
const allNotesEntries = useMemo(
|
||||
() => isAllNotesView
|
||||
? [
|
||||
...filterEntries(entries, selection, 'open'),
|
||||
...filterEntries(entries, selection, 'archived'),
|
||||
]
|
||||
: [],
|
||||
[entries, isAllNotesView, selection],
|
||||
)
|
||||
|
||||
return {
|
||||
availableProperties: useMemo(
|
||||
() => collectAvailableProperties(allNotesEntries),
|
||||
[allNotesEntries],
|
||||
),
|
||||
defaultDisplay: useMemo(
|
||||
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
|
||||
[allNotesEntries, typeEntryMap],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function useInboxPropertyPickerState(
|
||||
entries: VaultEntry[],
|
||||
inboxPeriod: InboxPeriod,
|
||||
isInboxView: boolean,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): ScopedPropertyPickerState {
|
||||
const inboxEntries = useMemo(
|
||||
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
|
||||
[entries, inboxPeriod, isInboxView],
|
||||
)
|
||||
|
||||
return {
|
||||
availableProperties: useMemo(
|
||||
() => collectAvailableProperties(inboxEntries),
|
||||
[inboxEntries],
|
||||
),
|
||||
defaultDisplay: useMemo(
|
||||
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
|
||||
[inboxEntries, typeEntryMap],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
interface ViewPropertyPickerState extends ScopedPropertyPickerState {
|
||||
selectedView: ViewFile | null
|
||||
hasCustomProperties: boolean
|
||||
}
|
||||
|
||||
function useViewPropertyPickerState(
|
||||
entries: VaultEntry[],
|
||||
selection: SidebarSelection,
|
||||
views: ViewFile[] | undefined,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): ViewPropertyPickerState {
|
||||
const selectedView = useMemo(
|
||||
() => findSelectedViewFile(selection, views),
|
||||
[selection, views],
|
||||
)
|
||||
const viewEntries = useMemo(
|
||||
() => selectedView ? filterEntries(entries, selection, undefined, views) : [],
|
||||
[entries, selection, selectedView, views],
|
||||
)
|
||||
|
||||
return {
|
||||
selectedView,
|
||||
availableProperties: useMemo(
|
||||
() => collectAvailableProperties(viewEntries),
|
||||
[viewEntries],
|
||||
),
|
||||
defaultDisplay: useMemo(
|
||||
() => deriveDefaultDisplay(viewEntries, typeEntryMap),
|
||||
[viewEntries, typeEntryMap],
|
||||
),
|
||||
hasCustomProperties: Boolean(selectedView?.definition.listPropertiesDisplay?.length),
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoteListPropertyPicker {
|
||||
scope: NoteListPropertiesScope
|
||||
availableProperties: string[]
|
||||
@@ -457,6 +655,61 @@ function buildTypePropertyPicker({
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildViewPropertyPickerParams {
|
||||
selectedView: ViewFile | null
|
||||
availableProperties: string[]
|
||||
defaultDisplay: string[]
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
}
|
||||
|
||||
function buildViewPropertyPicker({
|
||||
selectedView,
|
||||
availableProperties,
|
||||
defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
}: BuildViewPropertyPickerParams): NoteListPropertyPicker | null {
|
||||
if (!selectedView || !onUpdateViewDefinition) return null
|
||||
|
||||
const currentDisplay = (selectedView.definition.listPropertiesDisplay?.length ?? 0) > 0
|
||||
? selectedView.definition.listPropertiesDisplay ?? []
|
||||
: defaultDisplay
|
||||
|
||||
return {
|
||||
scope: 'view',
|
||||
availableProperties,
|
||||
currentDisplay,
|
||||
onSave: (value: string[] | null) => onUpdateViewDefinition(selectedView.filename, { listPropertiesDisplay: value ?? [] }),
|
||||
triggerTitle: `Customize ${selectedView.definition.name} columns`,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDisplayPropsOverride({
|
||||
isAllNotesView,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
isInboxView,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
selectedView,
|
||||
hasCustomViewProperties,
|
||||
}: {
|
||||
isAllNotesView: boolean
|
||||
hasCustomAllNotesProperties: boolean
|
||||
allNotesNoteListProperties?: string[] | null
|
||||
isInboxView: boolean
|
||||
hasCustomInboxProperties: boolean
|
||||
inboxNoteListProperties?: string[] | null
|
||||
selectedView: ViewFile | null
|
||||
hasCustomViewProperties: boolean
|
||||
}) {
|
||||
if (selectedView && hasCustomViewProperties) {
|
||||
return selectedView.definition.listPropertiesDisplay ?? null
|
||||
}
|
||||
if (isAllNotesView && hasCustomAllNotesProperties) return allNotesNoteListProperties ?? null
|
||||
if (isInboxView && hasCustomInboxProperties) return inboxNoteListProperties ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
interface UseListPropertyPickerParams {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
@@ -467,7 +720,9 @@ interface UseListPropertyPickerParams {
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
inboxNoteListProperties?: string[] | null
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
@@ -480,68 +735,55 @@ export function useListPropertyPicker({
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateViewDefinition,
|
||||
onUpdateTypeSort,
|
||||
views,
|
||||
}: UseListPropertyPickerParams) {
|
||||
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
|
||||
const allNotesEntries = useMemo(
|
||||
() => isAllNotesView
|
||||
? [
|
||||
...filterEntries(entries, selection, 'open'),
|
||||
...filterEntries(entries, selection, 'archived'),
|
||||
]
|
||||
: [],
|
||||
[entries, isAllNotesView, selection],
|
||||
)
|
||||
const inboxEntries = useMemo(
|
||||
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
|
||||
[entries, inboxPeriod, isInboxView],
|
||||
)
|
||||
const allNotesAvailableProperties = useMemo(
|
||||
() => collectAvailableProperties(allNotesEntries),
|
||||
[allNotesEntries],
|
||||
)
|
||||
const allNotesDefaultDisplay = useMemo(
|
||||
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
|
||||
[allNotesEntries, typeEntryMap],
|
||||
)
|
||||
const allNotesState = useAllNotesPropertyPickerState(entries, selection, isAllNotesView, typeEntryMap)
|
||||
const inboxState = useInboxPropertyPickerState(entries, inboxPeriod, isInboxView, typeEntryMap)
|
||||
const viewState = useViewPropertyPickerState(entries, selection, views, typeEntryMap)
|
||||
const typeAvailableProperties = useMemo(
|
||||
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
|
||||
[entries, typeDocument],
|
||||
)
|
||||
const inboxAvailableProperties = useMemo(
|
||||
() => collectAvailableProperties(inboxEntries),
|
||||
[inboxEntries],
|
||||
)
|
||||
const inboxDefaultDisplay = useMemo(
|
||||
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
|
||||
[inboxEntries, typeEntryMap],
|
||||
)
|
||||
const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0)
|
||||
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
|
||||
const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties
|
||||
? allNotesNoteListProperties
|
||||
: (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null)
|
||||
const displayPropsOverride = resolveDisplayPropsOverride({
|
||||
isAllNotesView,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
isInboxView,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
selectedView: viewState.selectedView,
|
||||
hasCustomViewProperties: viewState.hasCustomProperties,
|
||||
})
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return buildFilterPropertyPicker({
|
||||
return buildViewPropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
availableProperties: viewState.availableProperties,
|
||||
defaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'all',
|
||||
isActive: isAllNotesView,
|
||||
availableProperties: allNotesAvailableProperties,
|
||||
availableProperties: allNotesState.availableProperties,
|
||||
hasCustomProperties: hasCustomAllNotesProperties,
|
||||
noteListProperties: allNotesNoteListProperties,
|
||||
defaultDisplay: allNotesDefaultDisplay,
|
||||
defaultDisplay: allNotesState.defaultDisplay,
|
||||
onSave: onUpdateAllNotesNoteListProperties,
|
||||
triggerTitle: 'Customize All Notes columns',
|
||||
}) ?? buildFilterPropertyPicker({
|
||||
scope: 'inbox',
|
||||
isActive: isInboxView,
|
||||
availableProperties: inboxAvailableProperties,
|
||||
availableProperties: inboxState.availableProperties,
|
||||
hasCustomProperties: hasCustomInboxProperties,
|
||||
noteListProperties: inboxNoteListProperties,
|
||||
defaultDisplay: inboxDefaultDisplay,
|
||||
defaultDisplay: inboxState.defaultDisplay,
|
||||
onSave: onUpdateInboxNoteListProperties,
|
||||
triggerTitle: 'Customize Inbox columns',
|
||||
}) ?? buildTypePropertyPicker({
|
||||
@@ -551,15 +793,19 @@ export function useListPropertyPicker({
|
||||
typeAvailableProperties,
|
||||
})
|
||||
}, [
|
||||
allNotesAvailableProperties,
|
||||
allNotesDefaultDisplay,
|
||||
allNotesState.availableProperties,
|
||||
allNotesState.defaultDisplay,
|
||||
allNotesNoteListProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
isAllNotesView,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxAvailableProperties,
|
||||
inboxDefaultDisplay,
|
||||
isAllNotesView,
|
||||
inboxState.availableProperties,
|
||||
inboxState.defaultDisplay,
|
||||
viewState.availableProperties,
|
||||
viewState.defaultDisplay,
|
||||
viewState.selectedView,
|
||||
onUpdateViewDefinition,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
isInboxView,
|
||||
isSectionGroup,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
|
||||
|
||||
export interface OpenListPropertiesEventDetail {
|
||||
scope: NoteListPropertiesScope
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ModifiedFile,
|
||||
NoteStatus,
|
||||
InboxPeriod,
|
||||
ViewDefinition,
|
||||
ViewFile,
|
||||
} from '../../types'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
@@ -94,6 +95,7 @@ interface UseNoteListContentParams {
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
inboxNoteListProperties?: string[] | null
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
views?: ViewFile[]
|
||||
@@ -113,6 +115,7 @@ function useNoteListContent({
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateViewDefinition,
|
||||
onUpdateTypeSort,
|
||||
updateEntry,
|
||||
views,
|
||||
@@ -129,7 +132,9 @@ function useNoteListContent({
|
||||
modifiedSuffixes,
|
||||
subFilter,
|
||||
inboxPeriod: effectiveInboxPeriod,
|
||||
views,
|
||||
onUpdateTypeSort,
|
||||
onUpdateViewDefinition,
|
||||
updateEntry,
|
||||
})
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
@@ -144,7 +149,9 @@ function useNoteListContent({
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateViewDefinition,
|
||||
onUpdateTypeSort,
|
||||
views,
|
||||
})
|
||||
const {
|
||||
isEntityView,
|
||||
@@ -372,6 +379,7 @@ export interface NoteListProps {
|
||||
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
|
||||
inboxNoteListProperties?: string[] | null
|
||||
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
|
||||
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
|
||||
views?: ViewFile[]
|
||||
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
|
||||
}
|
||||
@@ -466,6 +474,7 @@ export function useNoteListModel({
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateViewDefinition,
|
||||
views,
|
||||
visibleNotesRef,
|
||||
}: NoteListProps) {
|
||||
@@ -486,6 +495,7 @@ export function useNoteListModel({
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateViewDefinition,
|
||||
onUpdateTypeSort,
|
||||
updateEntry,
|
||||
views,
|
||||
|
||||
@@ -35,6 +35,7 @@ interface StatusBarPrimarySectionProps {
|
||||
vaults: VaultOption[]
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCloneVault?: () => void
|
||||
onCloneGettingStarted?: () => void
|
||||
onClickPending?: () => void
|
||||
@@ -77,6 +78,7 @@ export function StatusBarPrimarySection({
|
||||
vaults,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onCreateEmptyVault,
|
||||
onCloneVault,
|
||||
onCloneGettingStarted,
|
||||
onClickPending,
|
||||
@@ -111,6 +113,7 @@ export function StatusBarPrimarySection({
|
||||
vaultPath={vaultPath}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCreateEmptyVault={onCreateEmptyVault}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onRemoveVault={onRemoveVault}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { VaultOption } from './types'
|
||||
@@ -11,6 +11,7 @@ interface VaultMenuProps {
|
||||
vaultPath: string
|
||||
onSwitchVault: (path: string) => void
|
||||
onOpenLocalFolder?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCloneVault?: () => void
|
||||
onCloneGettingStarted?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
@@ -42,12 +43,24 @@ interface VaultAction {
|
||||
}
|
||||
|
||||
function buildVaultActions({
|
||||
onCreateEmptyVault,
|
||||
onCloneGettingStarted,
|
||||
onCloneVault,
|
||||
onOpenLocalFolder,
|
||||
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
|
||||
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
|
||||
const items: VaultAction[] = []
|
||||
|
||||
if (onCreateEmptyVault) {
|
||||
items.push({
|
||||
key: 'create-empty',
|
||||
icon: <Plus size={12} />,
|
||||
label: 'Create empty vault',
|
||||
testId: 'vault-menu-create-empty',
|
||||
accent: true,
|
||||
onClick: onCreateEmptyVault,
|
||||
})
|
||||
}
|
||||
|
||||
if (onOpenLocalFolder) {
|
||||
items.push({
|
||||
key: 'open-local',
|
||||
@@ -88,67 +101,49 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
|
||||
return <span style={{ width: 12 }} />
|
||||
}
|
||||
|
||||
function vaultItemStyle(isActive: boolean, unavailable: boolean): CSSProperties {
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
|
||||
const unavailable = vault.available === false
|
||||
const canHover = !isActive && !unavailable
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
onMouseEnter={canHover ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={canHover ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={unavailable}
|
||||
onClick={onSelect}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
className="w-full justify-between rounded-sm px-2 py-1 text-xs font-normal"
|
||||
style={{
|
||||
height: 'auto',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</span>
|
||||
</Button>
|
||||
{canRemove && onRemove && (
|
||||
<span
|
||||
role="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
title="Remove from list"
|
||||
data-testid={`vault-menu-remove-${vault.label}`}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.opacity = '1'
|
||||
event.currentTarget.style.background = 'var(--hover)'
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.opacity = '0.5'
|
||||
event.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
className="rounded-sm"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -156,27 +151,18 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
|
||||
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
|
||||
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,6 +171,7 @@ export function VaultMenu({
|
||||
vaultPath,
|
||||
onSwitchVault,
|
||||
onOpenLocalFolder,
|
||||
onCreateEmptyVault,
|
||||
onCloneVault,
|
||||
onCloneGettingStarted,
|
||||
onRemoveVault,
|
||||
@@ -198,11 +185,12 @@ export function VaultMenu({
|
||||
|
||||
const actions = useMemo<VaultAction[]>(() => {
|
||||
return buildVaultActions({
|
||||
onCreateEmptyVault,
|
||||
onCloneGettingStarted,
|
||||
onCloneVault,
|
||||
onOpenLocalFolder,
|
||||
})
|
||||
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
|
||||
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
|
||||
|
||||
return (
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
|
||||
@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
|
||||
}, [selectionRange, value])
|
||||
|
||||
return {
|
||||
editorRef,
|
||||
selectionRange,
|
||||
selectionIndex: selectionRange.end,
|
||||
setSelectionRange,
|
||||
|
||||
@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a create-empty-vault command when the handler is available', () => {
|
||||
const onOpenSettings = vi.fn()
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
|
||||
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
|
||||
const command = commands.find((item) => item.id === 'create-empty-vault')
|
||||
|
||||
expect(command).toMatchObject({
|
||||
label: 'Create Empty Vault…',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
|
||||
command?.execute()
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenFeedback?: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
|
||||
vaultCount,
|
||||
isGettingStartedHidden,
|
||||
onOpenVault,
|
||||
onCreateEmptyVault,
|
||||
onRemoveActiveVault,
|
||||
onRestoreGettingStarted,
|
||||
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
|
||||
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
|
||||
return [
|
||||
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
|
||||
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
} = config
|
||||
|
||||
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
|
||||
vaultCount,
|
||||
isGettingStartedHidden,
|
||||
onOpenVault,
|
||||
onCreateEmptyVault,
|
||||
onRemoveActiveVault,
|
||||
onRestoreGettingStarted,
|
||||
}),
|
||||
|
||||
@@ -51,6 +51,7 @@ interface AppCommandsConfig {
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -82,6 +83,7 @@ interface AppCommandsConfig {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onCustomizeNoteListColumns?: () => void
|
||||
canCustomizeNoteListColumns?: boolean
|
||||
noteListColumnsLabel?: string
|
||||
onRestoreDeletedNote?: () => void
|
||||
canRestoreDeletedNote?: boolean
|
||||
}
|
||||
@@ -184,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onCreateEmptyVault: config.onCreateEmptyVault,
|
||||
activeNoteModified: config.activeNoteModified,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
@@ -224,6 +227,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
|
||||
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
|
||||
noteListColumnsLabel: config.noteListColumnsLabel,
|
||||
onRestoreDeletedNote: config.onRestoreDeletedNote,
|
||||
canRestoreDeletedNote: config.canRestoreDeletedNote,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface CommandRegistryConfig {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onCustomizeNoteListColumns?: () => void
|
||||
canCustomizeNoteListColumns?: boolean
|
||||
noteListColumnsLabel?: string
|
||||
onRestoreDeletedNote?: () => void
|
||||
canRestoreDeletedNote?: boolean
|
||||
onQuickOpen: () => void
|
||||
@@ -54,6 +55,7 @@ interface CommandRegistryConfig {
|
||||
onOpenSettings: () => void
|
||||
onOpenFeedback?: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateEmptyVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onDeleteNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
@@ -92,7 +94,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect,
|
||||
@@ -119,9 +121,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isFavorite = activeEntry?.favorite ?? false
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
const noteListColumnsLabel = selection?.kind === 'filter' && selection.filter === 'all'
|
||||
? 'Customize All Notes columns'
|
||||
: 'Customize Inbox columns'
|
||||
const noteListColumnsLabel = config.noteListColumnsLabel ?? (
|
||||
selection?.kind === 'filter' && selection.filter === 'all'
|
||||
? 'Customize All Notes columns'
|
||||
: 'Customize Inbox columns'
|
||||
)
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
@@ -143,7 +147,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
}),
|
||||
...buildSettingsCommands({
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
}),
|
||||
...buildAiAgentCommands({
|
||||
@@ -162,7 +166,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect,
|
||||
|
||||
59
src/hooks/useDragRegion.test.tsx
Normal file
59
src/hooks/useDragRegion.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDragRegion } from './useDragRegion'
|
||||
|
||||
const startDragging = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({ startDragging }),
|
||||
}))
|
||||
|
||||
function DragRegionHarness() {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
|
||||
return (
|
||||
<div data-testid="drag-surface" onMouseDown={onMouseDown}>
|
||||
<div data-testid="no-drag-card" data-no-drag>
|
||||
<button type="button">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useDragRegion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts dragging from the background surface', () => {
|
||||
render(<DragRegionHarness />)
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 })
|
||||
|
||||
expect(startDragging).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start dragging from no-drag containers', () => {
|
||||
render(<DragRegionHarness />)
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 })
|
||||
|
||||
expect(startDragging).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start dragging from interactive descendants', () => {
|
||||
render(<DragRegionHarness />)
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 })
|
||||
|
||||
expect(startDragging).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-primary mouse buttons', () => {
|
||||
render(<DragRegionHarness />)
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 })
|
||||
|
||||
expect(startDragging).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
|
||||
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
|
||||
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateNewVault()
|
||||
await onboarding.handleCreateEmptyVault()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useOnboarding(
|
||||
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
|
||||
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -110,6 +111,7 @@ export function useOnboarding(
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
setUserReadyVaultPath(vaultPath)
|
||||
onTemplateVaultReady?.(vaultPath)
|
||||
} catch (err) {
|
||||
setError(formatGettingStartedCloneError(err))
|
||||
@@ -129,7 +131,7 @@ export function useOnboarding(
|
||||
await createTemplateVault(lastTemplatePath)
|
||||
}, [createTemplateVault, lastTemplatePath])
|
||||
|
||||
const handleCreateNewVault = useCallback(async () => {
|
||||
const handleCreateEmptyVault = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const path = await pickFolder('Choose where to create your vault')
|
||||
@@ -138,6 +140,7 @@ export function useOnboarding(
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
setUserReadyVaultPath(vaultPath)
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
|
||||
} finally {
|
||||
@@ -152,6 +155,7 @@ export function useOnboarding(
|
||||
if (!path) return
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath: path })
|
||||
setUserReadyVaultPath(path)
|
||||
} catch (err) {
|
||||
setError(`Failed to open folder: ${err}`)
|
||||
}
|
||||
@@ -170,8 +174,9 @@ export function useOnboarding(
|
||||
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
|
||||
handleCreateVault,
|
||||
retryCreateVault,
|
||||
handleCreateNewVault,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
userReadyVaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
@@ -22,6 +23,14 @@ function coerceValue(raw: string): FrontmatterValue {
|
||||
return raw
|
||||
}
|
||||
|
||||
function coerceNumberValue(raw: string): FrontmatterValue {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return ''
|
||||
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : raw
|
||||
}
|
||||
|
||||
function parseNewValue(rawValue: string): FrontmatterValue {
|
||||
if (!rawValue.includes(',')) return rawValue.trim() || ''
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
@@ -125,6 +134,7 @@ function isHiddenPropertyKey(key: string): boolean {
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
|
||||
if (mode === 'number') return coerceNumberValue(rawValue)
|
||||
if (mode === 'tags') {
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
return items
|
||||
@@ -137,6 +147,37 @@ function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
|
||||
else saveDisplayModeOverride(key, mode)
|
||||
}
|
||||
|
||||
function saveScalarProperty({
|
||||
key,
|
||||
newValue,
|
||||
frontmatter,
|
||||
displayOverrides,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}: {
|
||||
key: string
|
||||
newValue: string
|
||||
frontmatter: ParsedFrontmatter
|
||||
displayOverrides: Record<string, PropertyDisplayMode>
|
||||
onUpdateProperty: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
}) {
|
||||
const currentValue = frontmatter[key] ?? null
|
||||
const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides)
|
||||
if (displayMode !== 'number') {
|
||||
onUpdateProperty(key, coerceValue(newValue))
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = newValue.trim()
|
||||
if (trimmed === '') {
|
||||
onDeleteProperty?.(key)
|
||||
return
|
||||
}
|
||||
|
||||
onUpdateProperty(key, coerceNumberValue(newValue))
|
||||
}
|
||||
|
||||
export interface PropertyPanelDeps {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
@@ -159,8 +200,9 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
|
||||
}, [onUpdateProperty])
|
||||
if (!onUpdateProperty) return
|
||||
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
|
||||
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
|
||||
|
||||
const handleSaveList = useCallback((key: string, newItems: string[]) => {
|
||||
if (!onUpdateProperty) return
|
||||
|
||||
@@ -265,6 +265,48 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expect(result.current.tabs[0].content).toBe('# Persisted content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses cached content when reopening a recently loaded note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# A content')
|
||||
.mockResolvedValueOnce('# B content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
await selectNote(result, { path: '/vault/b.md', title: 'B' })
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A again' })
|
||||
|
||||
expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
|
||||
expect(result.current.tabs[0].content).toBe('# A content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates a late prefetch after note opening already started', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
let resolveContent!: (value: string) => void
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(
|
||||
() => new Promise<string>((resolve) => { resolveContent = resolve }),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/rapid.md', title: 'Rapid' }))
|
||||
prefetchNoteContent('/vault/note/rapid.md')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveContent('# Rapid content')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Rapid content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
|
||||
@@ -8,30 +8,57 @@ interface Tab {
|
||||
content: string
|
||||
}
|
||||
|
||||
type NotePath = VaultEntry['path']
|
||||
|
||||
// --- Content prefetch cache ---
|
||||
// Stores in-flight or resolved note content promises, keyed by path.
|
||||
// Stores in-flight or recently loaded note content promises, keyed by path.
|
||||
// Cleared on vault reload to prevent stale content after external edits.
|
||||
// Latency profile: eliminates 50-200ms IPC round-trip for hover/keyboard-prefetched notes.
|
||||
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
|
||||
const prefetchCache = new Map<string, Promise<string>>()
|
||||
const NOTE_CONTENT_CACHE_LIMIT = 48
|
||||
|
||||
interface NoteContentCacheEntry {
|
||||
path: NotePath
|
||||
promise: Promise<string>
|
||||
}
|
||||
|
||||
function trimPrefetchCache(): void {
|
||||
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
|
||||
const oldestPath = prefetchCache.keys().next().value
|
||||
if (!oldestPath) return
|
||||
prefetchCache.delete(oldestPath)
|
||||
}
|
||||
}
|
||||
|
||||
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
|
||||
if (prefetchCache.has(path)) prefetchCache.delete(path)
|
||||
prefetchCache.set(path, promise)
|
||||
trimPrefetchCache()
|
||||
return promise
|
||||
}
|
||||
|
||||
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
|
||||
const promise = (isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
).catch((err) => {
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
|
||||
return rememberNoteContent({ path, promise })
|
||||
}
|
||||
|
||||
/** Prefetch a note's content into the in-memory cache.
|
||||
* Safe to call multiple times — deduplicates concurrent requests for the same path.
|
||||
* Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */
|
||||
export function prefetchNoteContent(path: string): void {
|
||||
if (prefetchCache.has(path)) return
|
||||
const promise = (isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
).catch((err) => {
|
||||
// Remove failed prefetch so a retry can occur
|
||||
prefetchCache.delete(path)
|
||||
throw err
|
||||
})
|
||||
prefetchCache.set(path, promise)
|
||||
requestNoteContent({ path })
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
prefetchCache.set(path, Promise.resolve(content))
|
||||
rememberNoteContent({ path, promise: Promise.resolve(content) })
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
@@ -40,15 +67,7 @@ export function clearPrefetchCache(): void {
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
// Check prefetch cache first — eliminates IPC round-trip for prefetched notes
|
||||
const cached = prefetchCache.get(path)
|
||||
if (cached) {
|
||||
prefetchCache.delete(path)
|
||||
return cached
|
||||
}
|
||||
return isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
return prefetchCache.get(path) ?? requestNoteContent({ path })
|
||||
}
|
||||
|
||||
export type { Tab }
|
||||
|
||||
@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
type MockInvokeOverrides = {
|
||||
checkVaultExists?: boolean
|
||||
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
|
||||
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
|
||||
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
|
||||
}
|
||||
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
|
||||
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
}
|
||||
|
||||
const renderLoadedVaultSwitcher = async () => {
|
||||
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.loaded).toBe(true)
|
||||
})
|
||||
return hook
|
||||
}
|
||||
|
||||
const setWorkVaultWithHiddenGettingStarted = () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
setMockInvokeBehavior()
|
||||
})
|
||||
|
||||
it('starts with default vaults', async () => {
|
||||
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
|
||||
it('creates an empty vault and switches to it', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
|
||||
})
|
||||
|
||||
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
|
||||
setMockInvokeBehavior({
|
||||
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateEmptyVault()
|
||||
})
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
|
||||
})
|
||||
|
||||
describe('removeVault', () => {
|
||||
it('removes an extra vault from the list', async () => {
|
||||
mockVaultListStore = {
|
||||
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
|
||||
|
||||
describe('restoreGettingStarted', () => {
|
||||
it('un-hides the Getting Started vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('switches to the Getting Started vault after restoring', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('attempts to create vault on disk if it does not exist', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: ({ targetPath }) => targetPath,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
|
||||
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [expectedDefaultVaultPath],
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
if (cmd === 'create_getting_started_vault') {
|
||||
return Promise.reject('git clone failed: fatal: unable to access')
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
setWorkVaultWithHiddenGettingStarted()
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: false,
|
||||
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.restoreGettingStarted()
|
||||
|
||||
@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
|
||||
return `Could not prepare Getting Started vault: ${message}`
|
||||
}
|
||||
|
||||
function formatCreateEmptyVaultError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (message.includes('Choose an empty folder')) {
|
||||
return message
|
||||
}
|
||||
|
||||
return `Could not create empty vault: ${message}`
|
||||
}
|
||||
|
||||
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path })
|
||||
if (!exists) {
|
||||
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useCreateEmptyVaultAction(
|
||||
addAndSwitch: (path: string, label: string) => void,
|
||||
onToastRef: MutableRefObject<(msg: string) => void>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const targetPath = await pickFolder('Choose where to create your vault')
|
||||
if (!targetPath) return
|
||||
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
|
||||
const label = labelFromPath({ path: vaultPath })
|
||||
addAndSwitch(vaultPath, label)
|
||||
onToastRef.current(`Vault "${label}" created and opened`)
|
||||
} catch (err) {
|
||||
onToastRef.current(formatCreateEmptyVaultError(err))
|
||||
}
|
||||
}, [addAndSwitch, onToastRef])
|
||||
}
|
||||
|
||||
function useRemoveVaultAction({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
@@ -589,6 +623,7 @@ function useVaultActions({
|
||||
}, [addVault, switchVault])
|
||||
|
||||
return {
|
||||
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
|
||||
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
|
||||
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
|
||||
removeVault: useRemoveVaultAction({
|
||||
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
hiddenDefaults,
|
||||
extraVaults,
|
||||
)
|
||||
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
|
||||
...persistedState,
|
||||
allVaults,
|
||||
defaultVaults,
|
||||
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return {
|
||||
allVaults,
|
||||
defaultPath,
|
||||
handleCreateEmptyVault,
|
||||
handleOpenLocalFolder,
|
||||
handleVaultCloned,
|
||||
isGettingStartedHidden,
|
||||
|
||||
@@ -372,7 +372,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
|
||||
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault',
|
||||
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface ViewDefinition {
|
||||
icon: string | null
|
||||
color: string | null
|
||||
sort: string | null
|
||||
listPropertiesDisplay?: string[]
|
||||
filters: FilterGroup
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('detectPropertyType', () => {
|
||||
expect(detectPropertyType('published', false)).toBe('boolean')
|
||||
})
|
||||
|
||||
it('detects number from value type', () => {
|
||||
expect(detectPropertyType('estimate', 3)).toBe('number')
|
||||
expect(detectPropertyType('ratio', -1.5)).toBe('number')
|
||||
})
|
||||
|
||||
it('detects status from key name', () => {
|
||||
expect(detectPropertyType('status', 'Active')).toBe('status')
|
||||
expect(detectPropertyType('Status', 'Draft')).toBe('status')
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { getAppStorageItem } from '../constants/appStorage'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
import { updateVaultConfigField } from './vaultConfigStore'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
|
||||
import { canonicalSystemMetadataKey } from './systemMetadata'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
||||
@@ -33,17 +33,35 @@ function isDateString(value: string): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
}
|
||||
|
||||
function isStatusKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, STATUS_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isDateKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, DATE_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isStatusString(key: string, value: string): boolean {
|
||||
if (isStatusKey(key)) return true
|
||||
if (isDateKey(key)) return false
|
||||
return STATUS_VALUES.has(value.toLowerCase())
|
||||
}
|
||||
|
||||
function isColorString(key: string, value: string): boolean {
|
||||
return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key))
|
||||
}
|
||||
|
||||
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (isStatusString(key, strValue)) return 'status'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
|
||||
if (isColorString(key, strValue)) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'number') return 'number'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
|
||||
@@ -95,22 +113,25 @@ export function getEffectiveDisplayMode(
|
||||
return overrides[key] ?? detectPropertyType(key, value)
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
function resolveDateFromValue(value: string): Date | null {
|
||||
const isoMatch = value.match(ISO_DATE_RE)
|
||||
if (isoMatch) {
|
||||
const d = new Date(isoMatch[0])
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
const date = new Date(isoMatch[0])
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/)
|
||||
if (parts) {
|
||||
const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
}
|
||||
return value
|
||||
if (!parts) return null
|
||||
|
||||
const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
const date = resolveDateFromValue(value)
|
||||
return date
|
||||
? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: value
|
||||
}
|
||||
|
||||
export function toISODate(value: string): string {
|
||||
@@ -123,11 +144,12 @@ export function toISODate(value: string): string {
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
text: Type, number: Hash, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { openCommandPalette } from './helpers'
|
||||
import {
|
||||
expectEditorSelectionRange,
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
writeClipboardText,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
async function readCaretGapAfterChip(page: Page) {
|
||||
@@ -72,4 +74,57 @@ test.describe('Command palette AI mode regression', () => {
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
|
||||
test('keeps pasted text, caret movement, and selection replacement stable in AI mode', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
const aiInputTarget = { dataTestId: 'command-palette-ai-input' }
|
||||
await openCommandPalette(page)
|
||||
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
|
||||
|
||||
const aiInput = page.getByTestId('command-palette-ai-input')
|
||||
await expect(aiInput).toBeVisible()
|
||||
await expect(aiInput).toBeFocused()
|
||||
|
||||
await writeClipboardText(page, { text: 'hello world' })
|
||||
await page.keyboard.press('Meta+V')
|
||||
await expectNormalizedEditorText(aiInput, 'hello world')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 12, end: 12 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 7, end: 7 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 7, end: 9 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.type('XY')
|
||||
await expectNormalizedEditorText(aiInput, 'hello XYrld')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 9, end: 9 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 12, end: 12 },
|
||||
target: aiInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Backspace')
|
||||
await expectNormalizedEditorText(aiInput, 'hello XYrl')
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
|
||||
224
tests/smoke/create-empty-vault-flows.spec.ts
Normal file
224
tests/smoke/create-empty-vault-flows.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { findCommand, openCommandPalette, sendShortcut } from './helpers'
|
||||
|
||||
interface MockEntry {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
aliases: string[]
|
||||
belongsTo: string[]
|
||||
relatedTo: string[]
|
||||
status: string | null
|
||||
archived: boolean
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
relationships: Record<string, string[]>
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, unknown>
|
||||
template: null
|
||||
sort: null
|
||||
}
|
||||
|
||||
interface VaultSeed {
|
||||
label: string
|
||||
path: string
|
||||
noteTitle?: string
|
||||
}
|
||||
|
||||
interface EntryOptions {
|
||||
fileName?: string
|
||||
isA?: string
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
function untitledNoteRow(page: Page) {
|
||||
return page.getByText(/^Untitled Note(?: \d+)?$/i).first()
|
||||
}
|
||||
|
||||
async function expectFreshVaultSeedEntries(page: Page) {
|
||||
await expect(page.getByText('AGENTS.md — Tolaria Vault', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('CLAUDE.md', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Config', { exact: true })).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function installEmptyVaultMocks(
|
||||
page: Page,
|
||||
config: {
|
||||
createdVaultPath: string
|
||||
initialVaults: VaultSeed[]
|
||||
activeVault: string | null
|
||||
},
|
||||
) {
|
||||
await page.addInitScript((mockConfig) => {
|
||||
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
|
||||
|
||||
function buildEntry(vaultPath: string, title: string, options: EntryOptions = {}): MockEntry {
|
||||
const defaultStem = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const filename = options.fileName ?? `${defaultStem}.md`
|
||||
return {
|
||||
path: `${vaultPath}/${filename}`,
|
||||
filename,
|
||||
title,
|
||||
isA: options.isA ?? 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: options.snippet ?? `${title} snippet`,
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}
|
||||
}
|
||||
|
||||
function buildFreshVaultSeedEntries(vaultPath: string): MockEntry[] {
|
||||
return [
|
||||
buildEntry(vaultPath, 'AGENTS.md — Tolaria Vault', { fileName: 'AGENTS.md' }),
|
||||
buildEntry(vaultPath, 'CLAUDE.md', { fileName: 'CLAUDE.md' }),
|
||||
buildEntry(vaultPath, 'Type', { fileName: 'type.md', isA: 'Type' }),
|
||||
buildEntry(vaultPath, 'Note', { fileName: 'note.md', isA: 'Type' }),
|
||||
]
|
||||
}
|
||||
|
||||
function syncEntryContent(entry: MockEntry) {
|
||||
allContent[entry.path] = `# ${entry.title}\n\n${entry.snippet}`
|
||||
}
|
||||
|
||||
let savedVaults = mockConfig.initialVaults.map(({ label, path }) => ({ label, path }))
|
||||
let activeVault = mockConfig.activeVault
|
||||
let hiddenDefaults: string[] = []
|
||||
|
||||
const entriesByVault = Object.fromEntries(
|
||||
mockConfig.initialVaults.map((vault) => [
|
||||
vault.path,
|
||||
vault.noteTitle ? [buildEntry(vault.path, vault.noteTitle)] : [],
|
||||
]),
|
||||
) satisfies Record<string, MockEntry[]>
|
||||
|
||||
const allContent = Object.fromEntries(
|
||||
Object.values(entriesByVault)
|
||||
.flat()
|
||||
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
|
||||
)
|
||||
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
Object.defineProperty(window, 'prompt', {
|
||||
configurable: true,
|
||||
value: () => mockConfig.createdVaultPath,
|
||||
})
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [...savedVaults],
|
||||
active_vault: activeVault,
|
||||
hidden_defaults: [...hiddenDefaults],
|
||||
})
|
||||
ref.save_vault_list = (args: {
|
||||
list: {
|
||||
vaults: Array<{ label: string; path: string }>
|
||||
active_vault: string | null
|
||||
hidden_defaults?: string[]
|
||||
}
|
||||
}) => {
|
||||
savedVaults = [...args.list.vaults]
|
||||
activeVault = args.list.active_vault
|
||||
hiddenDefaults = [...(args.list.hidden_defaults ?? [])]
|
||||
return null
|
||||
}
|
||||
ref.get_default_vault_path = () => gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) =>
|
||||
savedVaults.some((vault) => vault.path === args.path)
|
||||
|| args.path === mockConfig.createdVaultPath
|
||||
ref.create_empty_vault = (args: { targetPath?: string | null }) => {
|
||||
if (args.targetPath !== mockConfig.createdVaultPath) {
|
||||
throw new Error(`Unexpected empty vault target: ${args.targetPath}`)
|
||||
}
|
||||
entriesByVault[mockConfig.createdVaultPath] = buildFreshVaultSeedEntries(mockConfig.createdVaultPath)
|
||||
entriesByVault[mockConfig.createdVaultPath].forEach(syncEntryContent)
|
||||
return mockConfig.createdVaultPath
|
||||
}
|
||||
ref.list_vault = (args: { path?: string }) => entriesByVault[args.path ?? activeVault ?? ''] ?? []
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => allContent
|
||||
ref.get_note_content = (args: { path?: string }) => allContent[args.path ?? ''] ?? ''
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
}, config)
|
||||
}
|
||||
|
||||
test('keyboard onboarding can create an empty vault and the first note', async ({ page }) => {
|
||||
await installEmptyVaultMocks(page, {
|
||||
createdVaultPath: '/Users/mock/Documents/Fresh Vault',
|
||||
initialVaults: [],
|
||||
activeVault: null,
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
await expectFreshVaultSeedEntries(page)
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('command palette and bottom bar expose empty-vault creation from the active app shell', async ({ page }) => {
|
||||
await installEmptyVaultMocks(page, {
|
||||
createdVaultPath: '/Users/mock/Documents/Client Vault',
|
||||
initialVaults: [{ label: 'Work Vault', path: '/Users/mock/Work', noteTitle: 'Work Home' }],
|
||||
activeVault: '/Users/mock/Work',
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work Vault')
|
||||
|
||||
await openCommandPalette(page)
|
||||
expect(await findCommand(page, 'Create Empty Vault')).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const createEmptyItem = page.getByTestId('vault-menu-create-empty')
|
||||
await createEmptyItem.focus()
|
||||
await expect(createEmptyItem).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(trigger).toContainText('Client Vault')
|
||||
await expectFreshVaultSeedEntries(page)
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
@@ -62,6 +62,42 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
})
|
||||
|
||||
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
|
||||
await mockFreshStart(page, {
|
||||
activeVault: null,
|
||||
checkExistingPath: '/Users/mock/Documents/Getting Started',
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('telemetry-decline')).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('telemetry-accept')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
|
||||
|
||||
let dialogHandled = false
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain('Open vault folder')
|
||||
await dialog.dismiss()
|
||||
dialogHandled = true
|
||||
})
|
||||
await page.keyboard.press('Enter')
|
||||
await expect.poll(() => dialogHandled).toBe(true)
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
|
||||
})
|
||||
|
||||
for (const action of ['accept', 'decline'] as const) {
|
||||
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
|
||||
await mockFreshStart(page, {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
expectEditorSelectionRange,
|
||||
expectNoPageErrors,
|
||||
expectNormalizedEditorText,
|
||||
selectEditorTextRange,
|
||||
trackPageErrors,
|
||||
writeClipboardText,
|
||||
} from './inlineWikilinkEditorHelpers'
|
||||
|
||||
test.describe('Inline wikilink editor regression', () => {
|
||||
@@ -38,4 +40,53 @@ test.describe('Inline wikilink editor regression', () => {
|
||||
await expect(editor).toBeVisible()
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
|
||||
test('keeps pasted text, caret movement, and selection replacement stable in the AI panel chat input', async ({ page }) => {
|
||||
const pageErrors = trackPageErrors(page)
|
||||
const agentInputTarget = { dataTestId: 'agent-input' }
|
||||
const editor = page.getByTestId('agent-input')
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
await writeClipboardText(page, { text: 'hello world' })
|
||||
await page.keyboard.press('Meta+V')
|
||||
await expectNormalizedEditorText(editor, 'hello world')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 11, end: 11 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 6, end: 6 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await page.keyboard.press('Shift+ArrowRight')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 6, end: 8 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.type('XY')
|
||||
await expectNormalizedEditorText(editor, 'hello XYrld')
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 8, end: 8 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.press('ArrowRight')
|
||||
}
|
||||
await expectEditorSelectionRange(page, {
|
||||
expectedRange: { start: 11, end: 11 },
|
||||
target: agentInputTarget,
|
||||
})
|
||||
|
||||
await page.keyboard.press('Backspace')
|
||||
await expectNormalizedEditorText(editor, 'hello XYrl')
|
||||
await expectNoPageErrors(pageErrors)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,24 @@ interface SelectEditorTextRangeArgs {
|
||||
startOffset: number
|
||||
}
|
||||
|
||||
interface ClipboardWriteArgs {
|
||||
text: string
|
||||
}
|
||||
|
||||
interface EditorSelectionRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface EditorTarget {
|
||||
dataTestId: string
|
||||
}
|
||||
|
||||
interface ExpectEditorSelectionRangeArgs {
|
||||
expectedRange: EditorSelectionRange
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
export function trackPageErrors(page: Page): string[] {
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message))
|
||||
@@ -34,6 +52,28 @@ export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
|
||||
.toEqual([])
|
||||
}
|
||||
|
||||
export async function writeClipboardText(
|
||||
page: Page,
|
||||
{ text }: ClipboardWriteArgs,
|
||||
): Promise<void> {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await page.evaluate(async ({ text: nextText }) => {
|
||||
await navigator.clipboard.writeText(nextText)
|
||||
}, { text })
|
||||
}
|
||||
|
||||
export async function expectEditorSelectionRange(
|
||||
page: Page,
|
||||
{ expectedRange, target }: ExpectEditorSelectionRangeArgs,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.evaluate(readEditorSelectionRangeInBrowser, target),
|
||||
{ timeout: 2_000 },
|
||||
)
|
||||
.toEqual(expectedRange)
|
||||
}
|
||||
|
||||
function normalizeEditorText(value: string | null): string {
|
||||
return value?.replace(/\s+/g, ' ').trim() ?? ''
|
||||
}
|
||||
@@ -55,3 +95,28 @@ function selectEditorTextRangeInBrowser({
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
function readEditorSelectionRangeInBrowser({
|
||||
dataTestId,
|
||||
}: EditorTarget): EditorSelectionRange | null {
|
||||
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !selection || selection.rangeCount === 0) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) return null
|
||||
|
||||
const startRange = document.createRange()
|
||||
startRange.selectNodeContents(editor)
|
||||
startRange.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
const endRange = document.createRange()
|
||||
endRange.selectNodeContents(editor)
|
||||
endRange.setEnd(range.endContainer, range.endOffset)
|
||||
const normalizeSelectionText = (value: string) => value.replace(/\u200B/g, '')
|
||||
|
||||
return {
|
||||
start: normalizeSelectionText(startRange.toString()).length,
|
||||
end: normalizeSelectionText(endRange.toString()).length,
|
||||
}
|
||||
}
|
||||
|
||||
58
tests/smoke/number-property.spec.ts
Normal file
58
tests/smoke/number-property.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function alphaProjectPath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'project', 'alpha-project.md')
|
||||
}
|
||||
|
||||
test.describe('Number property editing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('properties panel saves number values across raw-mode switches and note reloads @smoke', async ({ page }) => {
|
||||
const notePath = alphaProjectPath(tempVaultDir)
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('add-property-row')).toBeVisible()
|
||||
|
||||
await page.getByTestId('add-property-row').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('add-property-form')).toBeVisible()
|
||||
|
||||
await page.keyboard.type('Estimate')
|
||||
await page.getByTestId('add-property-type-trigger').click()
|
||||
await page.getByRole('option', { name: 'Number', exact: true }).click()
|
||||
const numberInput = page.getByTestId('add-property-number-input')
|
||||
await expect(numberInput).toBeVisible()
|
||||
await numberInput.focus()
|
||||
await page.keyboard.type(' -12.5 ')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toContain('Estimate: -12.5')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
const rawEditor = page.locator('.cm-content')
|
||||
await expect(rawEditor).toBeVisible()
|
||||
await expect(rawEditor).toContainText('Estimate: -12.5')
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
})
|
||||
})
|
||||
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal file
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
interface MockEntry {
|
||||
path: string
|
||||
filename: string
|
||||
title: string
|
||||
isA: string
|
||||
aliases: string[]
|
||||
belongsTo: string[]
|
||||
relatedTo: string[]
|
||||
status: string | null
|
||||
archived: boolean
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
snippet: string
|
||||
wordCount: number
|
||||
relationships: Record<string, string[]>
|
||||
outgoingLinks: string[]
|
||||
properties: Record<string, unknown>
|
||||
template: null
|
||||
sort: null
|
||||
}
|
||||
|
||||
async function installVaultSwitcherMocks(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const workVaultPath = '/Users/mock/Work'
|
||||
const personalVaultPath = '/Users/mock/Personal'
|
||||
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
|
||||
|
||||
const entriesByVault = {
|
||||
[gettingStartedPath]: [({
|
||||
path: `${gettingStartedPath}/getting-started.md`,
|
||||
filename: 'getting-started.md',
|
||||
title: 'Getting Started Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Getting Started snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
[workVaultPath]: [({
|
||||
path: `${workVaultPath}/work-home.md`,
|
||||
filename: 'work-home.md',
|
||||
title: 'Work Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Work Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
[personalVaultPath]: [({
|
||||
path: `${personalVaultPath}/personal-home.md`,
|
||||
filename: 'personal-home.md',
|
||||
title: 'Personal Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Personal Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
})],
|
||||
} satisfies Record<string, MockEntry[]>
|
||||
|
||||
const allContent = Object.fromEntries(
|
||||
Object.values(entriesByVault)
|
||||
.flat()
|
||||
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
|
||||
)
|
||||
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [
|
||||
{ label: 'Work Vault', path: workVaultPath },
|
||||
{ label: 'Personal Vault', path: personalVaultPath },
|
||||
],
|
||||
active_vault: workVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) => typeof args?.path === 'string' && args.path.length > 0
|
||||
ref.list_vault = (args: { path?: string }) => entriesByVault[args?.path ?? ''] ?? []
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => allContent
|
||||
ref.get_note_content = (args: { path?: string }) => allContent[args?.path ?? ''] ?? ''
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('bottom bar vault switching works with keyboard and mouse @smoke', async ({ page }) => {
|
||||
await installVaultSwitcherMocks(page)
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
|
||||
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('vault-menu-item-Getting Started')).toBeFocused()
|
||||
await page.getByTestId('vault-menu-item-Personal Vault').focus()
|
||||
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(trigger).toContainText('Personal Vault')
|
||||
await expect(noteList.getByText('Personal Home', { exact: true })).toBeVisible()
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toHaveCount(0)
|
||||
|
||||
await trigger.click()
|
||||
await page.getByTestId('vault-menu-item-Work Vault').click()
|
||||
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
|
||||
await expect(noteList.getByText('Personal Home', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -1,84 +1,62 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const SOURCE_NOTE_TITLE = 'Grow Newsletter'
|
||||
const INSERTED_WIKILINK_QUERY = '[[Mana'
|
||||
const INSERTED_WIKILINK_TITLE = 'Manage Sponsorships'
|
||||
|
||||
async function insertWikilink(page: Page) {
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const firstParagraph = editor.locator('p').first()
|
||||
await expect(
|
||||
firstParagraph,
|
||||
).toContainText('Build a sustainable audience through high-quality weekly essays', { timeout: 5000 })
|
||||
await firstParagraph.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
await page.keyboard.type(INSERTED_WIKILINK_QUERY)
|
||||
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
const matchingWikilinks = editor.locator('.wikilink').filter({ hasText: INSERTED_WIKILINK_TITLE })
|
||||
const existingCount = await matchingWikilinks.count()
|
||||
await suggestionMenu.getByText(INSERTED_WIKILINK_TITLE, { exact: true }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await expect(matchingWikilinks).toHaveCount(existingCount + 1)
|
||||
return matchingWikilinks.nth(existingCount)
|
||||
}
|
||||
|
||||
test.describe('Wikilink insertion and navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select first note to open in editor
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').filter({ hasText: SOURCE_NOTE_TITLE }).first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
test('[[ autocomplete inserts wikilink that is not broken', async ({ page }) => {
|
||||
// Focus editor and move to a new line
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
await editor.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
const wikilink = await insertWikilink(page)
|
||||
|
||||
// Type [[ then a query (>= 2 chars for MIN_QUERY_LENGTH)
|
||||
await page.keyboard.type('[[Ma')
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// The wikilink suggestion menu should appear
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Select the first suggestion
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// A wikilink should have been inserted
|
||||
const wikilinks = page.locator('.wikilink')
|
||||
const count = await wikilinks.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// The wikilink should NOT be broken
|
||||
const lastWikilink = wikilinks.last()
|
||||
const isBroken = await lastWikilink.evaluate(
|
||||
const isBroken = await wikilink.evaluate(
|
||||
el => el.classList.contains('wikilink--broken'),
|
||||
)
|
||||
expect(isBroken).toBe(false)
|
||||
|
||||
// The wikilink should have a data-target attribute
|
||||
const target = await lastWikilink.getAttribute('data-target')
|
||||
const target = await wikilink.getAttribute('data-target')
|
||||
expect(target).toBeTruthy()
|
||||
})
|
||||
|
||||
test('@smoke Cmd+clicking an inserted wikilink navigates to the note', async ({ page }) => {
|
||||
// Insert a wikilink via autocomplete
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 5000 })
|
||||
await editor.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.type('[[Ma')
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Get the wikilink that was just inserted
|
||||
const wikilink = page.locator('.wikilink').last()
|
||||
const wikilink = await insertWikilink(page)
|
||||
await expect(wikilink).toBeVisible()
|
||||
const targetTitle = await wikilink.textContent()
|
||||
|
||||
// Cmd+click the wikilink to navigate
|
||||
await wikilink.click({ modifiers: ['Meta'] })
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const expected = targetTitle?.substring(0, 4) ?? ''
|
||||
await expect.poll(async () => {
|
||||
const heading = page.locator('.bn-editor h1').first()
|
||||
if (await heading.count()) return (await heading.textContent()) ?? ''
|
||||
return ''
|
||||
}, { timeout: 5000 }).toContain(expected)
|
||||
await expect(page.locator('.bn-editor h1').first()).toHaveText(INSERTED_WIKILINK_TITLE, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user