Compare commits
28 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec1627a81 | ||
|
|
b00183419e | ||
|
|
ed9ceff1d2 | ||
|
|
e18491bcd3 | ||
|
|
fd0cfc7fb6 | ||
|
|
6541ce5755 | ||
|
|
4456801526 | ||
|
|
db7aaf1385 | ||
|
|
54b2616fa4 | ||
|
|
6a90e9f770 | ||
|
|
b79c78a5ba | ||
|
|
48a8882970 | ||
|
|
98276702a2 | ||
|
|
857d55a7cd | ||
|
|
b78dfad384 | ||
|
|
9ac51bab9a | ||
|
|
1fee994863 | ||
|
|
764bbf0e4c | ||
|
|
ff05ba0430 | ||
|
|
347df47c62 | ||
|
|
7536169508 | ||
|
|
34de384738 | ||
|
|
954ee42f0b | ||
|
|
f67aafe442 | ||
|
|
b2122ac24c | ||
|
|
6f8810146e | ||
|
|
929ba9241e | ||
|
|
c5e84c8218 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.7
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
# Tolaria App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
|
||||
@@ -438,6 +438,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
|
||||
|
||||
|
||||
@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
@@ -659,7 +659,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|---------|-------------|
|
||||
| `get_vault_settings` | Read `.laputa/settings.json` |
|
||||
| `save_vault_settings` | Write vault settings |
|
||||
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore config |
|
||||
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@ sidebar label: Config
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
/// Content for `note.md` — restores the default Note type definition when missing.
|
||||
const NOTE_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
type: Type
|
||||
---
|
||||
|
||||
# Note
|
||||
|
||||
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
@@ -113,8 +124,8 @@ fn classify_guidance_file(
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
|
||||
let vault = Path::new(vault_path);
|
||||
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
|
||||
let vault = vault_path;
|
||||
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
|
||||
}
|
||||
|
||||
@@ -137,7 +148,7 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
|
||||
let (agents_path, claude_path) = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&agents_path);
|
||||
let claude_state = classify_claude_file(&claude_path);
|
||||
@@ -150,12 +161,12 @@ fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
|
||||
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (_, claude_path) = guidance_paths(vault_path);
|
||||
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
|
||||
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
@@ -203,34 +214,45 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
|
||||
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (agents_path, _) = guidance_paths(vault_path);
|
||||
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
pub fn get_ai_guidance_status(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<VaultAiGuidanceStatus, String> {
|
||||
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
get_ai_guidance_status(vault_path)
|
||||
}
|
||||
|
||||
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `config.md` type definition for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
|
||||
pub fn seed_config_files(vault_path: impl AsRef<str>) {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
ensure_root_type_definitions(vault_path);
|
||||
}
|
||||
|
||||
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let path = Path::new(vault_path).join("config.md");
|
||||
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
|
||||
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
|
||||
let path = vault_path.join(file_name);
|
||||
let _ = write_if_missing(&path, content);
|
||||
}
|
||||
|
||||
/// 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, "note.md", NOTE_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
|
||||
@@ -241,8 +263,8 @@ fn ensure_config_type_definition(vault_path: &str) {
|
||||
/// - Cleans up empty `config/` directory after migration.
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
@@ -259,22 +281,23 @@ pub fn migrate_agents_md(vault_path: &str) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
let _ = sync_ai_guidance_files(vault_path);
|
||||
let _ = sync_ai_guidance_files(vault);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
|
||||
/// Migrates legacy `config/agents.md` to root if present.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
sync_ai_guidance_files(vault)?;
|
||||
|
||||
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
@@ -417,15 +440,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
fn test_seed_config_files_creates_type_definitions() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
assert!(vault.join("note.md").exists());
|
||||
let config_content = fs::read_to_string(vault.join("config.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!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
@@ -543,10 +570,14 @@ mod tests {
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("config.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 note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("general-purpose document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -208,107 +208,161 @@ const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
|
||||
"Tolaria still understands legacy aliases such as `Is A`.",
|
||||
];
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
let is_outdated_managed_template = OUTDATED_AGENTS_MARKERS
|
||||
.iter()
|
||||
.all(|marker| content.contains(marker));
|
||||
let is_stale_title_stub = content.contains("Do not add `title:` frontmatter.");
|
||||
const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter.";
|
||||
const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views";
|
||||
const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"];
|
||||
|
||||
content.trim().is_empty()
|
||||
|| content == PRE_TYPE_AGENTS_MD
|
||||
|| content == LEGACY_AGENTS_MD
|
||||
|| content == STALE_AGENTS_MD
|
||||
|| is_stale_title_stub
|
||||
|| is_outdated_managed_template
|
||||
struct AgentsContent<'a>(&'a str);
|
||||
|
||||
impl<'a> AgentsContent<'a> {
|
||||
fn new(content: &'a str) -> Self {
|
||||
Self(content)
|
||||
}
|
||||
|
||||
fn contains(&self, marker: &str) -> bool {
|
||||
self.0.contains(marker)
|
||||
}
|
||||
|
||||
fn contains_all(&self, markers: &[&str]) -> bool {
|
||||
markers.iter().all(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn is_known_legacy_template(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
|| self.0 == PRE_TYPE_AGENTS_MD
|
||||
|| self.0 == LEGACY_AGENTS_MD
|
||||
|| self.0 == STALE_AGENTS_MD
|
||||
}
|
||||
|
||||
fn has_stale_title_stub(&self) -> bool {
|
||||
self.contains(STALE_TITLE_FRONTMATTER_MARKER)
|
||||
}
|
||||
|
||||
fn has_legacy_json_view_guidance(&self) -> bool {
|
||||
self.contains(LEGACY_VIEWS_SECTION_MARKER)
|
||||
&& LEGACY_VIEW_FILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn can_be_refreshed(&self) -> bool {
|
||||
self.is_known_legacy_template()
|
||||
|| self.has_stale_title_stub()
|
||||
|| self.has_legacy_json_view_guidance()
|
||||
|| self.contains_all(&OUTDATED_AGENTS_MARKERS)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
AgentsContent::new(content).can_be_refreshed()
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Tolaria vault mechanics only; no user-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
pub(super) const AGENTS_MD: &str = r##"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault: a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
# AGENTS.md — Tolaria Vault
|
||||
|
||||
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault, a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
Keep edits compatible with this starter vault's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
|
||||
## Core conventions
|
||||
|
||||
- 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. Do not add it to new notes unless you are maintaining an older 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.
|
||||
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
|
||||
- Any frontmatter field containing `[[wikilinks]]` is treated as a relationship. Common names include `Belongs to:`, `Related to:`, `Workspace:`, and custom relationship names.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
type: Note
|
||||
related_to: "[[tolaria]]"
|
||||
status: Active
|
||||
icon: target
|
||||
Workspace: "[[tolaria]]"
|
||||
Belongs to:
|
||||
- "[[25q2]]"
|
||||
Related to:
|
||||
- "[[person-luca-rossi]]"
|
||||
aliases:
|
||||
- Tolaria work
|
||||
url: https://example.com
|
||||
---
|
||||
|
||||
# Ship Tolaria
|
||||
# Example note
|
||||
|
||||
Body content in Markdown.
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
Type definitions are regular notes stored in `type/`. Use `type: Type` for new ones:
|
||||
In this starter vault, types are regular notes stored at the vault root and use `type: Type`.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: shapes
|
||||
color: blue
|
||||
sidebar label: Projects
|
||||
template: |
|
||||
## Outcome
|
||||
|
||||
## Next actions
|
||||
_icon: rocket
|
||||
_color: "#3b82f6"
|
||||
_order: 0
|
||||
_list_properties_display:
|
||||
- related_to
|
||||
_sort: "property:onboarding:asc"
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
|
||||
Useful type metadata in this vault includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. In this starter vault, common relationship keys include `related_to` and `belongs_to`, but custom relationship names are valid too.
|
||||
|
||||
Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them.
|
||||
|
||||
Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- `[[filename]]` or `[[Note Title]]` for normal links
|
||||
- `[[filename|display text]]` for custom display text
|
||||
- Works in frontmatter values and Markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved views live in `views/*.yml` and are written as YAML:
|
||||
Saved views live in `views/*.yml` and are written as YAML. Tolaria scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`.
|
||||
|
||||
A view definition looks like this:
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: kanban
|
||||
color: blue
|
||||
sort: modified:desc
|
||||
icon: null
|
||||
color: null
|
||||
sort: "property:onboarding:asc"
|
||||
filters:
|
||||
all:
|
||||
any:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
- field: related_to
|
||||
op: contains
|
||||
value: "[[tolaria]]"
|
||||
```
|
||||
|
||||
View rules that matter when creating or editing files:
|
||||
- `name` is required. `icon`, `color`, and `sort` are optional.
|
||||
- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:<Property Name>`, for example `property:onboarding:asc`.
|
||||
- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group.
|
||||
- Each filter condition uses `field`, `op`, and usually `value`.
|
||||
- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.
|
||||
- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`.
|
||||
- `any_of` and `none_of` expect `value` to be a YAML list.
|
||||
- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed.
|
||||
- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`.
|
||||
- Do not create JSON view files or `.view.json` filenames.
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
@@ -316,16 +370,17 @@ Use kebab-case: `my-note-title.md`. One note per file.
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents in `type/`.
|
||||
- Create and edit type documents at the vault root.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
|
||||
- Do not move existing type files out of the vault root unless the user explicitly asks.
|
||||
- Do not treat files in `attachments/` as notes, types, or view definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not overwrite user-authored config or installation-specific app files unless the user explicitly asks.
|
||||
- Do not rewrite installation-specific app configuration unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
@@ -413,18 +468,23 @@ Do not modify app configuration files — those are local to each installation.
|
||||
|
||||
/// Clone the public starter vault into the requested path.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
create_getting_started_vault_from_repo(target_path, &getting_started_repo_url())
|
||||
let vault_path = create_getting_started_vault_from_repo(
|
||||
Path::new(target_path),
|
||||
&getting_started_repo_url(),
|
||||
)?;
|
||||
Ok(vault_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn create_getting_started_vault_from_repo(
|
||||
target_path: &str,
|
||||
target_path: &Path,
|
||||
repo_url: &str,
|
||||
) -> Result<String, String> {
|
||||
if target_path.trim().is_empty() {
|
||||
) -> Result<PathBuf, String> {
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
if target_path_str.trim().is_empty() {
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
crate::git::clone_repo(repo_url, target_path)?;
|
||||
crate::git::clone_repo(repo_url, &target_path_str)?;
|
||||
let vault_path = canonical_vault_path(target_path)?;
|
||||
refresh_cloned_vault_config_files(&vault_path)?;
|
||||
Ok(vault_path)
|
||||
@@ -436,16 +496,23 @@ fn getting_started_repo_url() -> String {
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
fn canonical_vault_path(target_path: &str) -> Result<String, String> {
|
||||
let path = Path::new(target_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve vault path '{}': {}", target_path, e))?;
|
||||
Ok(canonical.to_string_lossy().to_string())
|
||||
fn canonical_vault_path(target_path: &Path) -> Result<PathBuf, String> {
|
||||
target_path.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"Failed to resolve vault path '{}': {}",
|
||||
target_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
|
||||
let agents_path = Path::new(vault_path).join("AGENTS.md");
|
||||
fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> {
|
||||
path.to_str()
|
||||
.ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display()))
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
let refresh_agents = if !agents_path.exists() {
|
||||
true
|
||||
} else {
|
||||
@@ -459,18 +526,21 @@ fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
|
||||
}
|
||||
|
||||
crate::vault::repair_config_files(vault_path)?;
|
||||
crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?;
|
||||
|
||||
if !vault_has_pending_changes(vault_path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_commit_identity(vault_path)?;
|
||||
crate::git::git_commit(vault_path, "Initialize Tolaria config files")?;
|
||||
crate::git::git_commit(
|
||||
path_to_utf8(vault_path, "Vault path")?,
|
||||
"Initialize Tolaria config files",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
|
||||
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(vault_path)
|
||||
@@ -487,7 +557,7 @@ fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_commit_identity(vault_path: &str) -> Result<(), String> {
|
||||
fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
@@ -575,8 +645,7 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, Some(agents_content));
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
@@ -605,13 +674,11 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
let result = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let result =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, dest.canonicalize().unwrap().to_string_lossy());
|
||||
assert_eq!(result, dest.canonicalize().unwrap());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
assert!(dest.join("views").join("active-projects.yml").exists());
|
||||
assert!(dest.join(".git").exists());
|
||||
@@ -631,11 +698,8 @@ mod tests {
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
|
||||
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("already exists and is not empty"));
|
||||
}
|
||||
@@ -646,11 +710,9 @@ mod tests {
|
||||
let missing_repo = dir.path().join("missing");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
missing_repo.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
@@ -663,8 +725,7 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
@@ -689,16 +750,35 @@ mod tests {
|
||||
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() {
|
||||
let stale = r#"# AGENTS.md — Tolaria Vault
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{"title":"Active Notes"}
|
||||
```
|
||||
"#;
|
||||
assert!(agents_content_can_be_refreshed(stale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_template_matches_current_tolaria_vault_conventions() {
|
||||
assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n"));
|
||||
assert!(AGENTS_MD.contains("# AGENTS.md — Tolaria Vault"));
|
||||
assert!(AGENTS_MD.contains("type/"));
|
||||
assert!(AGENTS_MD.contains("views/"));
|
||||
assert!(AGENTS_MD.contains("sidebar label"));
|
||||
assert!(AGENTS_MD.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
|
||||
assert!(AGENTS_MD.contains("type definitions currently live at the vault root"));
|
||||
assert!(AGENTS_MD.contains("attachments/"));
|
||||
assert!(AGENTS_MD.contains("views/*.yml"));
|
||||
assert!(AGENTS_MD.contains("option:direction"));
|
||||
assert!(AGENTS_MD.contains("property:<Property Name>"));
|
||||
assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`."));
|
||||
assert!(AGENTS_MD.contains("Belongs to:"));
|
||||
assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames."));
|
||||
assert!(!AGENTS_MD.contains("Laputa"));
|
||||
assert!(!AGENTS_MD.contains("Is A"));
|
||||
assert!(!AGENTS_MD.contains("is_a"));
|
||||
|
||||
125
src/App.test.tsx
125
src/App.test.tsx
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
@@ -76,8 +76,14 @@ const mockAllContent: Record<string, string> = {
|
||||
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
|
||||
}
|
||||
|
||||
const mockVaultList = {
|
||||
vaults: [{ label: 'Test Vault', path: '/vault' }],
|
||||
active_vault: '/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
|
||||
load_vault_list: mockVaultList,
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
@@ -94,9 +100,42 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_vault_settings: { theme: null },
|
||||
}
|
||||
|
||||
function resetMockCommandResults() {
|
||||
Object.assign(mockCommandResults, {
|
||||
load_vault_list: mockVaultList,
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
},
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: '/Users/mock/Documents/Getting Started',
|
||||
list_themes: [],
|
||||
get_vault_settings: { theme: null },
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
const result = mockCommandResults[cmd]
|
||||
return typeof result === 'function'
|
||||
? (result as (input?: unknown) => unknown)(args)
|
||||
: result ?? null
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
|
||||
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
@@ -156,6 +195,7 @@ const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dis
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockCommandResults()
|
||||
localStorage.clear()
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
})
|
||||
@@ -204,6 +244,68 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('telemetry-accept'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['telemetry-accept', 'Allow anonymous reporting'],
|
||||
['telemetry-decline', 'No thanks'],
|
||||
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
|
||||
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [],
|
||||
active_vault: rememberedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(buttonTestId))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
})
|
||||
|
||||
it('renders sidebar with correct default selection (All Notes)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
@@ -214,6 +316,11 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
|
||||
active_vault: '/Users/mock/Documents/Getting Started',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
@@ -223,13 +330,19 @@ describe('App', () => {
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
|
||||
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
|
||||
const vaultPaths = [
|
||||
'/Users/mock/Documents/Getting Started',
|
||||
'/Users/mock/demo-vault-v2',
|
||||
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
|
||||
]
|
||||
for (const path of vaultPaths) {
|
||||
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
120
src/App.tsx
120
src/App.tsx
@@ -35,9 +35,10 @@ import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
@@ -206,22 +207,40 @@ function App() {
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const { handleVaultCloned } = vaultSwitcher
|
||||
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
|
||||
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
|
||||
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
|
||||
if (!vaultPath) return
|
||||
|
||||
if (allVaults.some((vault) => vault.path === vaultPath)) {
|
||||
switchVault(vaultPath)
|
||||
return
|
||||
}
|
||||
|
||||
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
|
||||
handleVaultCloned(vaultPath, label)
|
||||
}, [allVaults, handleVaultCloned, switchVault])
|
||||
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
|
||||
rememberOnboardingVaultChoice(vaultPath)
|
||||
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
|
||||
}, [handleVaultCloned])
|
||||
}, [rememberOnboardingVaultChoice])
|
||||
const cloneGettingStartedVault = useGettingStartedClone({
|
||||
onError: (message) => setToastMessage(message),
|
||||
onSuccess: handleGettingStartedVaultReady,
|
||||
})
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
|
||||
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
|
||||
handleGettingStartedVaultReady(vaultPath)
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
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])
|
||||
|
||||
// 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)
|
||||
@@ -336,12 +355,20 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
removeEntry: vault.removeEntry,
|
||||
entries: vault.entries,
|
||||
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
|
||||
flushBeforeNoteSwitch: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
flushBeforePathRename: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
reloadVault: vault.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
@@ -634,6 +661,7 @@ function App() {
|
||||
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
|
||||
})
|
||||
const recordAutoGitActivity = autoGit.recordActivity
|
||||
const openCommitDialog = commitFlow.openCommitDialog
|
||||
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
|
||||
const handleAppContentChange = appSave.handleContentChange
|
||||
const handleAppSave = appSave.handleSave
|
||||
@@ -644,9 +672,13 @@ function App() {
|
||||
recordAutoGitActivity()
|
||||
}, [modifiedFilesSignature, recordAutoGitActivity])
|
||||
|
||||
const handleQuickCommitPush = useCallback(() => {
|
||||
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
|
||||
}, [runAutomaticCheckpoint])
|
||||
const handleCommitPush = useCallback(() => {
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: settings.autogit_enabled === true,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
||||
|
||||
const handleTrackedContentChange = useCallback((path: string, content: string) => {
|
||||
recordAutoGitActivity()
|
||||
@@ -899,6 +931,17 @@ function App() {
|
||||
) ?? null
|
||||
}, [notes.activeTabPath, vault.modifiedFiles])
|
||||
|
||||
const activeCommandEntry = useMemo(() => {
|
||||
if (!notes.activeTabPath) return null
|
||||
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
|
||||
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
|
||||
?? null
|
||||
}, [notes.activeTabPath, notes.tabs, vault.entries])
|
||||
|
||||
const canToggleRichEditor = !!activeCommandEntry
|
||||
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
|
||||
&& !activeDeletedFile
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
@@ -916,13 +959,13 @@ function App() {
|
||||
onOpenFeedback: openFeedback,
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onCommitPush: handleCommitPush,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
|
||||
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
@@ -987,16 +1030,45 @@ function App() {
|
||||
return { type: null, query: '' }
|
||||
}, [effectiveSelection])
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
|
||||
}
|
||||
const shouldResumeFreshStartOnboarding = useMemo(() => {
|
||||
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
|
||||
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
|
||||
|
||||
return remembersOnlyDefaultVault
|
||||
&& vaultSwitcher.allVaults.length === 1
|
||||
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
|
||||
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
|
||||
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
|
||||
|
||||
// Show loading spinner while checking vault (skip for note windows)
|
||||
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows).
|
||||
// After the user answers, the next render can continue into onboarding.
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
||||
}}
|
||||
onDecline={() => {
|
||||
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
}
|
||||
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
|
||||
return (
|
||||
<>
|
||||
@@ -1026,21 +1098,6 @@ function App() {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows)
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
||||
}}
|
||||
onDecline={() => {
|
||||
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
@@ -1117,12 +1174,13 @@ function App() {
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
/>
|
||||
</div>
|
||||
</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={handleQuickCommitPush} 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} 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} />
|
||||
|
||||
@@ -658,7 +658,7 @@ describe('wikilink autocomplete', () => {
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
})
|
||||
|
||||
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
|
||||
it('shows Note chips and icons for explicit Note entries while keeping untyped entries neutral', async () => {
|
||||
const mixedEntries: VaultEntry[] = [
|
||||
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
|
||||
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
|
||||
@@ -675,20 +675,24 @@ describe('wikilink autocomplete', () => {
|
||||
/>
|
||||
)
|
||||
const items = await capturedGetItems!('Test')
|
||||
// Typed entries should have noteType and color
|
||||
// Typed entries should have noteType, color, and a left-side icon
|
||||
const project = items.find((i: { title: string }) => i.title === 'Test Project')
|
||||
expect(project).toBeDefined()
|
||||
expect(project!.noteType).toBe('Project')
|
||||
expect(project!.typeColor).toBeTruthy()
|
||||
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
|
||||
expect(project!.TypeIcon).toBeTruthy()
|
||||
|
||||
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
|
||||
expect(explicitNote).toBeDefined()
|
||||
expect(explicitNote!.noteType).toBe('Note')
|
||||
expect(explicitNote!.typeColor).toBeTruthy()
|
||||
expect(explicitNote!.TypeIcon).toBeTruthy()
|
||||
|
||||
// Untyped entries should remain neutral
|
||||
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
|
||||
expect(plainNote).toBeDefined()
|
||||
expect(plainNote!.noteType).toBeUndefined()
|
||||
expect(plainNote!.typeColor).toBeUndefined()
|
||||
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
|
||||
expect(explicitNote).toBeDefined()
|
||||
expect(explicitNote!.noteType).toBeUndefined()
|
||||
expect(explicitNote!.typeColor).toBeUndefined()
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveRawModeContent,
|
||||
} from './editorRawModeSync'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -86,6 +87,8 @@ interface EditorProps {
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -167,6 +170,7 @@ function useEditorSetup({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
const {
|
||||
rawMode,
|
||||
@@ -222,39 +226,147 @@ function useEditorSetup({
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
function useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
}: {
|
||||
activeTab: Tab | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawMode: boolean
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}) {
|
||||
const flushPendingRawContent = useCallback((path: string) => {
|
||||
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const latestContent = rawLatestContentRef.current
|
||||
if (latestContent === null || latestContent === activeTab.content) return
|
||||
|
||||
onContentChange?.(path, latestContent)
|
||||
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushPendingRawContentRef) return
|
||||
|
||||
flushPendingRawContentRef.current = flushPendingRawContent
|
||||
return () => {
|
||||
if (flushPendingRawContentRef.current === flushPendingRawContent) {
|
||||
flushPendingRawContentRef.current = null
|
||||
}
|
||||
}
|
||||
}, [flushPendingRawContent, flushPendingRawContentRef])
|
||||
}
|
||||
|
||||
function EditorLayout({
|
||||
tabs,
|
||||
activeTab,
|
||||
isLoadingNewTab,
|
||||
entries,
|
||||
editor,
|
||||
diffMode,
|
||||
diffContent,
|
||||
diffLoading,
|
||||
handleToggleDiffExclusive,
|
||||
rawMode,
|
||||
handleToggleRawExclusive,
|
||||
onContentChange,
|
||||
onSave,
|
||||
activeStatus,
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
handleEditorChange,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onUnarchiveNote,
|
||||
vaultPath,
|
||||
rawModeContent,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
inspectorContent,
|
||||
gitHistory,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
handleViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
onCreateAndOpenNote,
|
||||
onInitializeProperties,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: Tab | null
|
||||
isLoadingNewTab: boolean
|
||||
entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean
|
||||
diffContent: string | null
|
||||
diffLoading: boolean
|
||||
handleToggleDiffExclusive: () => void | Promise<void>
|
||||
rawMode: boolean
|
||||
handleToggleRawExclusive: () => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
activeStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
handleEditorChange: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
gitHistory: GitCommit[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
handleViewCommitDiff: (commitHash: string) => Promise<void>
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
@@ -328,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingRawContentRef,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -262,6 +262,23 @@ describe('NoteList filter pills', () => {
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only explicit Note entries for the Notes type filter', () => {
|
||||
const noteEntries = [
|
||||
makeEntry({ title: 'Note Type', isA: 'Type', path: '/types/note.md', filename: 'note.md' }),
|
||||
makeEntry({ title: 'Explicit Note', isA: 'Note', path: '/explicit-note.md', filename: 'explicit-note.md' }),
|
||||
makeEntry({ title: 'Untyped Note', isA: null, path: '/untyped-note.md', filename: 'untyped-note.md' }),
|
||||
makeEntry({ title: 'Archived Explicit Note', isA: 'Note', archived: true, path: '/archived-note.md', filename: 'archived-note.md' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries: noteEntries, selection: { kind: 'sectionGroup', type: 'Note' } })
|
||||
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Untyped Note')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived Explicit Note')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows the archived empty state when a section has no archived notes', () => {
|
||||
renderNoteList({
|
||||
entries: projectEntries.filter((entry) => !entry.archived),
|
||||
|
||||
@@ -780,6 +780,14 @@ describe('Sidebar', () => {
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/binary-note.pdf', filename: 'binary-note.pdf', title: 'Binary Note',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {}, fileKind: 'binary',
|
||||
},
|
||||
]
|
||||
|
||||
it('shows Notes section when Note entries exist', () => {
|
||||
@@ -787,13 +795,40 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts both explicit and untyped notes in Notes section chip', () => {
|
||||
it('counts only explicit Note entries in the Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('ignores non-markdown Note entries in the Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
expect(notesHeader.textContent).not.toContain('2')
|
||||
})
|
||||
|
||||
it('keeps the Notes section count aligned when an entry changes to or from Note', () => {
|
||||
const { rerender } = render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
let notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
|
||||
const withoutExplicitNote = noteEntries.map((entry) =>
|
||||
entry.path === '/vault/explicit-note.md' ? { ...entry, isA: null } : entry,
|
||||
)
|
||||
rerender(<Sidebar entries={withoutExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
|
||||
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toBe('Notes')
|
||||
|
||||
const withNewExplicitNote = noteEntries.map((entry) =>
|
||||
entry.path === '/vault/untyped-note.md' ? { ...entry, isA: 'Note' } : entry,
|
||||
)
|
||||
rerender(<Sidebar entries={withNewExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
|
||||
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
it('does not show Notes section for untyped entries without explicit Note entries', () => {
|
||||
const untypedOnly: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
|
||||
@@ -805,7 +840,7 @@ describe('Sidebar', () => {
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -874,6 +909,88 @@ describe('Sidebar', () => {
|
||||
expect(topNav.children[0].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('excludes attachments-folder markdown from top-nav note totals', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/note/real-note.md',
|
||||
filename: 'real-note.md',
|
||||
title: 'Real Note',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 310, snippet: '', wordCount: 120,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/reference.md',
|
||||
filename: 'reference.md',
|
||||
title: 'Attachment Markdown',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 220, snippet: '', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/nested/archive.md',
|
||||
filename: 'archive.md',
|
||||
title: 'Attachment Archive',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: true,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 180, snippet: '', wordCount: 25,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/archive/real-archive.md',
|
||||
filename: 'real-archive.md',
|
||||
title: 'Real Archive',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: true,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 280, snippet: '', wordCount: 90,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/image.png',
|
||||
filename: 'image.png',
|
||||
title: 'image.png',
|
||||
isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 1024, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
fileKind: 'binary',
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.children[1].textContent).toContain('All Notes1')
|
||||
expect(topNav.children[2].textContent).toContain('Archive1')
|
||||
})
|
||||
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
|
||||
@@ -179,6 +179,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
|
||||
@@ -27,4 +27,9 @@ describe('TelemetryConsentDialog', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
|
||||
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
|
||||
onClick={onDecline}
|
||||
data-testid="telemetry-decline"
|
||||
autoFocus
|
||||
>
|
||||
No thanks
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,11 @@ describe('WelcomeScreen', () => {
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows default vault path in template option description', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
|
||||
|
||||
@@ -160,6 +160,7 @@ interface OptionButtonProps {
|
||||
disabled: boolean
|
||||
loading?: boolean
|
||||
testId: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
@@ -173,6 +174,7 @@ function OptionButton({
|
||||
disabled,
|
||||
loading,
|
||||
testId,
|
||||
autoFocus = false,
|
||||
}: OptionButtonProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
return (
|
||||
@@ -188,6 +190,7 @@ function OptionButton({
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
data-testid={testId}
|
||||
autoFocus={autoFocus}
|
||||
>
|
||||
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
|
||||
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
|
||||
@@ -278,6 +281,7 @@ export function WelcomeScreen({
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
|
||||
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return DOMRect.fromRect({ x: left, y: top, width, height })
|
||||
}
|
||||
|
||||
function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 368, y: 104 },
|
||||
rect(300, 130, 140, 90),
|
||||
rect(322, 78, 96, 24),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates when the pointer is already over the toolbar', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = document.createElement('button')
|
||||
toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: toolbarButton,
|
||||
point: { x: 350, y: 90 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 368, y: 104 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated pointer movement alone', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 520, y: 220 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
|
||||
|
||||
const HOVER_BRIDGE_PADDING_X = 8
|
||||
const HOVER_BRIDGE_PADDING_Y = 8
|
||||
|
||||
function isVisibleRect(rect: RectLike) {
|
||||
return rect.right > rect.left && rect.bottom > rect.top
|
||||
}
|
||||
|
||||
function getSelectedFileBlockBridgeElement(
|
||||
container: HTMLElement,
|
||||
blockId: string,
|
||||
) {
|
||||
const selectedBlock = container.querySelector<HTMLElement>(
|
||||
`.bn-block[data-id="${blockId}"]`,
|
||||
)
|
||||
|
||||
if (!selectedBlock) return null
|
||||
|
||||
return (
|
||||
selectedBlock.querySelector<HTMLElement>(
|
||||
'[data-file-block] .bn-visual-media-wrapper',
|
||||
) ??
|
||||
selectedBlock.querySelector<HTMLElement>(
|
||||
'[data-file-block] .bn-file-name-with-icon',
|
||||
) ??
|
||||
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
|
||||
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
|
||||
)
|
||||
}
|
||||
|
||||
export function isWithinFormattingToolbarHoverBridge(
|
||||
point: { x: number; y: number },
|
||||
fileBlockRect: RectLike,
|
||||
toolbarRect: RectLike,
|
||||
) {
|
||||
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
|
||||
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
|
||||
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
|
||||
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
|
||||
|
||||
return (
|
||||
point.x >= left &&
|
||||
point.x <= right &&
|
||||
point.y >= top &&
|
||||
point.y <= bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget,
|
||||
point,
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockId,
|
||||
}: {
|
||||
eventTarget: EventTarget | null
|
||||
point: { x: number; y: number }
|
||||
container: HTMLElement | null
|
||||
doc: Document
|
||||
selectedFileBlockId: string | null
|
||||
}) {
|
||||
if (!container || !selectedFileBlockId) return false
|
||||
|
||||
if (
|
||||
eventTarget instanceof Element &&
|
||||
eventTarget.closest('.bn-formatting-toolbar')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFileBlock = getSelectedFileBlockBridgeElement(
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
)
|
||||
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
|
||||
|
||||
if (!selectedFileBlock || !toolbar) return false
|
||||
|
||||
return isWithinFormattingToolbarHoverBridge(
|
||||
point,
|
||||
selectedFileBlock.getBoundingClientRect(),
|
||||
toolbar.getBoundingClientRect(),
|
||||
)
|
||||
}
|
||||
|
||||
function useLastSelectedFormattingToolbarFileBlockId(
|
||||
selectedFileBlockId: string | null,
|
||||
isOpen: boolean,
|
||||
) {
|
||||
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFileBlockId) {
|
||||
lastSelectedFileBlockIdRef.current = selectedFileBlockId
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
lastSelectedFileBlockIdRef.current = null
|
||||
}
|
||||
}, [isOpen, selectedFileBlockId])
|
||||
|
||||
return lastSelectedFileBlockIdRef
|
||||
}
|
||||
|
||||
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
|
||||
const doc = container?.ownerDocument
|
||||
const view = doc?.defaultView
|
||||
|
||||
if (!container || !doc || !view) return null
|
||||
|
||||
return { container, doc, view }
|
||||
}
|
||||
|
||||
function createFormattingToolbarHoverGuardHandler({
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockIdRef,
|
||||
}: {
|
||||
container: HTMLElement
|
||||
doc: Document
|
||||
selectedFileBlockIdRef: RefObject<string | null>
|
||||
}) {
|
||||
return (event: MouseEvent) => {
|
||||
if (
|
||||
!shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: event.target,
|
||||
point: { x: event.clientX, y: event.clientY },
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockId: selectedFileBlockIdRef.current,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
export function useBlockNoteFormattingToolbarHoverGuard({
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}: {
|
||||
container: HTMLElement | null
|
||||
selectedFileBlockId: string | null
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const environment = getFormattingToolbarHoverGuardEnvironment(container)
|
||||
if (!environment) return
|
||||
|
||||
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
|
||||
container: environment.container,
|
||||
doc: environment.doc,
|
||||
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
|
||||
})
|
||||
|
||||
environment.view.addEventListener('mousemove', handleMouseMove, true)
|
||||
return () => {
|
||||
environment.view.removeEventListener('mousemove', handleMouseMove, true)
|
||||
}
|
||||
}, [container, isOpen, lastSelectedFileBlockIdRef])
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
ViewFile,
|
||||
} from '../../types'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
|
||||
import { NoteItem } from '../NoteItem'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
@@ -76,7 +76,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) {
|
||||
return useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type)
|
||||
if (selection.kind === 'folder') return countAllByFilter(entries)
|
||||
if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries)
|
||||
if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries)
|
||||
return { open: 0, archived: 0 }
|
||||
}, [entries, selection])
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { SidebarGroupHeader } from './SidebarGroupHeader'
|
||||
import { SidebarViewItem } from './SidebarViewItem'
|
||||
import { countByFilter } from '../../utils/noteListHelpers'
|
||||
|
||||
export { SidebarTopNav } from './SidebarTopNav'
|
||||
export { FavoritesSection } from './FavoritesSection'
|
||||
@@ -94,9 +95,7 @@ function SortableSection({
|
||||
sectionProps: SidebarSectionProps
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const itemCount = sectionProps.entries.filter((entry) =>
|
||||
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
|
||||
).length
|
||||
const itemCount = countByFilter(sectionProps.entries, group.type).open
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
|
||||
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
|
||||
|
||||
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
@@ -58,13 +59,8 @@ export function useSidebarCollapsed() {
|
||||
|
||||
export function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0
|
||||
let archived = 0
|
||||
for (const entry of entries) {
|
||||
if (entry.archived) archived++
|
||||
else active++
|
||||
}
|
||||
return { activeCount: active, archivedCount: archived }
|
||||
const counts = countAllNotesByFilter(entries)
|
||||
return { activeCount: counts.open, archivedCount: counts.archived }
|
||||
}, [entries])
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,18 @@ import type {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core'
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
|
||||
import { useCallback, useMemo, useState, type FC, type ReactElement } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
type ReactElement,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import {
|
||||
Button as MantineButton,
|
||||
CheckIcon as MantineCheckIcon,
|
||||
@@ -43,9 +54,82 @@ import {
|
||||
filterTolariaFormattingToolbarItems,
|
||||
getTolariaBlockTypeSelectItems,
|
||||
} from './tolariaEditorFormattingConfig'
|
||||
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
|
||||
|
||||
const FORMATTER_CLOSE_GRACE_MS = 160
|
||||
|
||||
function isFocusStillWithinToolbar(
|
||||
currentTarget: EventTarget & Element,
|
||||
nextTarget: EventTarget | null,
|
||||
) {
|
||||
return nextTarget instanceof Node && currentTarget.contains(nextTarget)
|
||||
}
|
||||
|
||||
function clearToolbarCloseGrace(
|
||||
timeoutRef: MutableRefObject<number | null>,
|
||||
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
setCloseGraceActive(false)
|
||||
}
|
||||
|
||||
function startToolbarCloseGrace(
|
||||
timeoutRef: MutableRefObject<number | null>,
|
||||
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
setCloseGraceActive(true)
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null
|
||||
setCloseGraceActive(false)
|
||||
}, FORMATTER_CLOSE_GRACE_MS)
|
||||
}
|
||||
|
||||
function useFormattingToolbarCloseGrace({
|
||||
show,
|
||||
toolbarHasFocus,
|
||||
toolbarHovered,
|
||||
}: {
|
||||
show: boolean
|
||||
toolbarHasFocus: boolean
|
||||
toolbarHovered: boolean
|
||||
}) {
|
||||
const [closeGraceActive, setCloseGraceActive] = useState(false)
|
||||
const closeGraceTimeoutRef = useRef<number | null>(null)
|
||||
const previousShowRef = useRef(show)
|
||||
|
||||
const clearCloseGrace = useCallback(() => {
|
||||
clearToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const toolbarInteractionActive = show || toolbarHasFocus || toolbarHovered
|
||||
|
||||
if (toolbarInteractionActive) {
|
||||
clearCloseGrace()
|
||||
} else if (previousShowRef.current) {
|
||||
startToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
|
||||
}
|
||||
|
||||
previousShowRef.current = show
|
||||
}, [clearCloseGrace, show, toolbarHasFocus, toolbarHovered])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeGraceTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeGraceTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { closeGraceActive, clearCloseGrace }
|
||||
}
|
||||
|
||||
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
|
||||
bold: {
|
||||
label: 'Bold',
|
||||
@@ -83,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
|
||||
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
|
||||
>['block']
|
||||
|
||||
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
|
||||
'audio',
|
||||
'file',
|
||||
'image',
|
||||
'video',
|
||||
])
|
||||
|
||||
type TolariaBlockTypeSelectOption = ReturnType<
|
||||
typeof getTolariaBlockTypeSelectItems
|
||||
>[number] & {
|
||||
@@ -180,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
|
||||
}))
|
||||
}
|
||||
|
||||
function getFormattingToolbarBridgeBlockId(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
) {
|
||||
const selectedBlock =
|
||||
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
|
||||
|
||||
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
|
||||
? selectedBlock.id
|
||||
: null
|
||||
}
|
||||
|
||||
function updateSelectedBlocksToType(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
selectedBlocks: TolariaSelectedBlock[],
|
||||
@@ -360,7 +462,27 @@ export function TolariaFormattingToolbarController(props: {
|
||||
editor,
|
||||
})
|
||||
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
|
||||
const isOpen = show || toolbarHasFocus
|
||||
const [toolbarHovered, setToolbarHovered] = useState(false)
|
||||
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
|
||||
show,
|
||||
toolbarHasFocus,
|
||||
toolbarHovered,
|
||||
})
|
||||
|
||||
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
|
||||
const currentBridgeBlockId = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
|
||||
})
|
||||
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
container:
|
||||
editor.domElement?.closest('.editor__blocknote-container') ??
|
||||
editor.domElement ??
|
||||
null,
|
||||
selectedFileBlockId: currentBridgeBlockId,
|
||||
isOpen,
|
||||
})
|
||||
|
||||
const position = useEditorState({
|
||||
editor,
|
||||
@@ -398,6 +520,8 @@ export function TolariaFormattingToolbarController(props: {
|
||||
formattingToolbar.store.setState(open)
|
||||
if (!open) {
|
||||
setToolbarHasFocus(false)
|
||||
setToolbarHovered(false)
|
||||
clearCloseGrace()
|
||||
}
|
||||
if (reason === 'escape-key') {
|
||||
editor.focus()
|
||||
@@ -413,7 +537,14 @@ export function TolariaFormattingToolbarController(props: {
|
||||
...props.floatingUIOptions?.elementProps,
|
||||
},
|
||||
}),
|
||||
[editor, formattingToolbar.store, isOpen, placement, props.floatingUIOptions],
|
||||
[
|
||||
clearCloseGrace,
|
||||
editor,
|
||||
formattingToolbar.store,
|
||||
isOpen,
|
||||
placement,
|
||||
props.floatingUIOptions,
|
||||
],
|
||||
)
|
||||
|
||||
const Component = props.formattingToolbar || TolariaFormattingToolbar
|
||||
@@ -422,12 +553,21 @@ export function TolariaFormattingToolbarController(props: {
|
||||
<PositionPopover position={position} {...floatingUIOptions}>
|
||||
{isOpen && (
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
setToolbarHovered(true)
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
setToolbarHovered(false)
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
setToolbarHasFocus(true)
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
132
src/components/useFilenameAutolinkGuard.ts
Normal file
132
src/components/useFilenameAutolinkGuard.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { shouldStripAutoLinkedLocalFileMark } from '../utils/editorLinkAutolink'
|
||||
|
||||
const FILENAME_AUTOLINK_GUARD_META = 'tolaria-filename-autolink-guard'
|
||||
|
||||
type TiptapLinkMark = {
|
||||
attrs?: {
|
||||
href?: string
|
||||
}
|
||||
type: unknown
|
||||
}
|
||||
|
||||
type TiptapTextNode = {
|
||||
isText?: boolean
|
||||
marks?: TiptapLinkMark[]
|
||||
nodeSize: number
|
||||
text?: string | null
|
||||
}
|
||||
|
||||
function hasTextContent(node: TiptapTextNode) {
|
||||
return Boolean(node.isText && node.text && node.marks?.length)
|
||||
}
|
||||
|
||||
function isAccidentalFilenameLinkMark(
|
||||
mark: TiptapLinkMark,
|
||||
linkMarkType: unknown,
|
||||
text: string,
|
||||
) {
|
||||
if (mark.type !== linkMarkType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof mark.attrs?.href !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: mark.attrs.href },
|
||||
text: { raw: text },
|
||||
})
|
||||
}
|
||||
|
||||
function hasEditorEventApi(
|
||||
tiptap: ReturnType<typeof useCreateBlockNote>['_tiptapEditor'] | undefined,
|
||||
) {
|
||||
return Boolean(tiptap && typeof tiptap.on === 'function' && typeof tiptap.off === 'function')
|
||||
}
|
||||
|
||||
function collectAccidentalFilenameLinkRanges(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const linkMarkType = editor._tiptapEditor?.schema?.marks?.link
|
||||
if (!linkMarkType) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: Array<{ from: number; to: number }> = []
|
||||
editor._tiptapEditor.state.doc.descendants((node: TiptapTextNode, pos: number) => {
|
||||
if (!hasTextContent(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasAccidentalFilenameLink = node.marks?.some((mark) => (
|
||||
isAccidentalFilenameLinkMark(mark, linkMarkType, node.text ?? '')
|
||||
)) ?? false
|
||||
|
||||
if (hasAccidentalFilenameLink) {
|
||||
ranges.push({ from: pos, to: pos + node.nodeSize })
|
||||
}
|
||||
})
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function shouldSkipGuardRun(
|
||||
applyingGuard: boolean,
|
||||
transaction: { docChanged?: boolean; getMeta: (key: string) => unknown },
|
||||
) {
|
||||
return (
|
||||
applyingGuard
|
||||
|| !transaction.docChanged
|
||||
|| transaction.getMeta(FILENAME_AUTOLINK_GUARD_META)
|
||||
)
|
||||
}
|
||||
|
||||
function removeLinkRanges(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
ranges: Array<{ from: number; to: number }>,
|
||||
) {
|
||||
const tr = editor._tiptapEditor.state.tr
|
||||
for (const range of ranges) {
|
||||
tr.removeMark(range.from, range.to, editor._tiptapEditor.schema.marks.link)
|
||||
}
|
||||
|
||||
return tr
|
||||
}
|
||||
|
||||
export function useFilenameAutolinkGuard(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
useEffect(() => {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!hasEditorEventApi(tiptap)) {
|
||||
return
|
||||
}
|
||||
|
||||
let applyingGuard = false
|
||||
const stripAccidentalFilenameAutolinks = ({ transaction }: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => {
|
||||
if (shouldSkipGuardRun(applyingGuard, transaction)) {
|
||||
return
|
||||
}
|
||||
|
||||
const ranges = collectAccidentalFilenameLinkRanges(editor)
|
||||
if (ranges.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const tr = removeLinkRanges(editor, ranges)
|
||||
if (!tr.docChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
tr.setMeta(FILENAME_AUTOLINK_GUARD_META, true)
|
||||
applyingGuard = true
|
||||
tiptap.view.dispatch(tr)
|
||||
applyingGuard = false
|
||||
}
|
||||
|
||||
tiptap.on('update', stripAccidentalFilenameAutolinks)
|
||||
|
||||
return () => {
|
||||
tiptap.off('update', stripAccidentalFilenameAutolinks)
|
||||
}
|
||||
}, [editor])
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
|
||||
@@ -461,6 +461,57 @@ describe('useAppSave', () => {
|
||||
expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending')
|
||||
})
|
||||
|
||||
it('does not run markdown title-sync renames for non-markdown text files', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const viewPath = '/vault/views/active-projects.yml'
|
||||
const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n'
|
||||
const viewEntry = {
|
||||
...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'),
|
||||
fileKind: 'text' as const,
|
||||
}
|
||||
|
||||
const { result } = renderSave({
|
||||
tabs: [{ entry: viewEntry, content: viewContent }],
|
||||
activeTabPath: viewPath,
|
||||
unsavedPaths: new Set([viewPath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
|
||||
path: viewPath,
|
||||
content: viewContent,
|
||||
})
|
||||
expect(deps.handleRenameNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const notePath = '/vault/note-b.md'
|
||||
const noteContent = '# Breadcrumb Sync Target\n\nBody'
|
||||
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
|
||||
|
||||
const { result } = renderSave({
|
||||
tabs: [{ entry, content: noteContent }],
|
||||
activeTabPath: notePath,
|
||||
unsavedPaths: new Set([notePath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
|
||||
path: notePath,
|
||||
content: noteContent,
|
||||
})
|
||||
expect(deps.handleRenameNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
|
||||
const initialContent = '# Fresh Title\n\nInitial body'
|
||||
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { flushEditorContent } from '../utils/autoSave'
|
||||
import { extractH1TitleFromContent } from '../utils/noteTitle'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
@@ -77,20 +76,6 @@ function findUnsavedFallback({
|
||||
return { path: activeTab.entry.path, content: activeTab.content }
|
||||
}
|
||||
|
||||
function activeTabNeedsRename({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
}: {
|
||||
tabs: TabState[]
|
||||
activeTabPath: string | null
|
||||
}): { path: string; title: string } | null {
|
||||
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
|
||||
if (!activeTab) return null
|
||||
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
|
||||
? { path: activeTab.entry.path, title: activeTab.entry.title }
|
||||
: null
|
||||
}
|
||||
|
||||
function isUntitledRenameCandidate(path: string): boolean {
|
||||
const filename = path.split('/').pop() ?? ''
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
@@ -546,16 +531,6 @@ function useRenameHandlers({
|
||||
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
|
||||
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
|
||||
}) {
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
const currentPath = await preparePathForManualRename({
|
||||
path,
|
||||
resolveCurrentPath,
|
||||
savePendingForPath,
|
||||
cancelPendingUntitledRename,
|
||||
})
|
||||
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
|
||||
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
|
||||
|
||||
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
|
||||
const currentPath = await preparePathForManualRename({
|
||||
path,
|
||||
@@ -578,12 +553,11 @@ function useRenameHandlers({
|
||||
.catch((err) => console.error('Title rename failed:', err))
|
||||
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
|
||||
|
||||
return { handleRenameTab, handleFilenameRename, handleTitleSync }
|
||||
return { handleFilenameRename, handleTitleSync }
|
||||
}
|
||||
|
||||
function useHandleSaveAction({
|
||||
handleSaveRaw,
|
||||
handleRenameTab,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
unsavedPaths,
|
||||
@@ -591,7 +565,6 @@ function useHandleSaveAction({
|
||||
resolveCurrentPath,
|
||||
}: {
|
||||
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
|
||||
handleRenameTab: (path: string, newTitle: string) => Promise<void>
|
||||
tabs: TabState[]
|
||||
activeTabPath: string | null
|
||||
unsavedPaths: Set<string>
|
||||
@@ -605,10 +578,8 @@ function useHandleSaveAction({
|
||||
activeTabPath: resolvedActiveTabPath,
|
||||
unsavedPaths,
|
||||
}))
|
||||
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
|
||||
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
|
||||
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
|
||||
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
|
||||
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
|
||||
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
|
||||
}
|
||||
|
||||
function useEditorPersistence({
|
||||
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
|
||||
setToastMessage,
|
||||
flushPendingUntitledRename,
|
||||
})
|
||||
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
|
||||
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
|
||||
resolveCurrentPath,
|
||||
savePendingForPath,
|
||||
cancelPendingUntitledRename,
|
||||
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
|
||||
})
|
||||
const handleSave = useHandleSaveAction({
|
||||
handleSaveRaw,
|
||||
handleRenameTab,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
unsavedPaths,
|
||||
|
||||
@@ -214,6 +214,12 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
|
||||
const config = makeConfig({ onToggleRawEditor: undefined })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
||||
const config = makeConfig({ showInbox: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
|
||||
const { startTransitionMock } = vi.hoisted(() => ({
|
||||
startTransitionMock: vi.fn((callback: () => void) => callback()),
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
return {
|
||||
...actual,
|
||||
startTransition: startTransitionMock,
|
||||
}
|
||||
})
|
||||
|
||||
const mockHandleContentChange = vi.fn()
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockSavePendingForPath = vi.fn()
|
||||
@@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => {
|
||||
setTabs = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
onAfterSave = vi.fn()
|
||||
startTransitionMock.mockClear()
|
||||
mockHandleContentChange.mockClear()
|
||||
mockHandleSave.mockClear()
|
||||
mockSavePendingForPath.mockClear()
|
||||
@@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Project',
|
||||
status: 'Active',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Essay',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Note',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith(path, expected)
|
||||
})
|
||||
|
||||
it('defers H1 title sync updates in a transition so typing stays responsive', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody')
|
||||
})
|
||||
|
||||
expect(startTransitionMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/old-title.md', {
|
||||
title: 'Renamed Note',
|
||||
hasH1: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('spreads all properties from useEditorSave onto the return value', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { startTransition, useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
@@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const prevTitleKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
const links = extractOutgoingLinks(content)
|
||||
@@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: {
|
||||
}
|
||||
const frontmatterPatch = contentToEntryPatch(content)
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const fmPatch = {
|
||||
...frontmatterPatch,
|
||||
...deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
}),
|
||||
}
|
||||
const titlePatch = deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
})
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
}
|
||||
const titleKey = JSON.stringify(titlePatch)
|
||||
if (titleKey !== prevTitleKeyRef.current) {
|
||||
prevTitleKeyRef.current = titleKey
|
||||
startTransition(() => {
|
||||
updateEntry(path, titlePatch)
|
||||
})
|
||||
}
|
||||
}, [rawOnChange, updateEntry])
|
||||
return { ...editor, handleContentChange }
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
@@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
|
||||
expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,23 +35,36 @@ export function extractEditorBody(rawFileContent: string): string {
|
||||
return rawBody.trimStart()
|
||||
}
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
type HeadingTextInline = { type?: string; text?: string }
|
||||
|
||||
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
|
||||
const first = blocks?.[0] as {
|
||||
type?: string
|
||||
props?: { level?: number }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
content?: HeadingTextInline[]
|
||||
} | undefined
|
||||
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
|
||||
? first.content
|
||||
: null
|
||||
|
||||
if (!first) return null
|
||||
if (first.type !== 'heading') return null
|
||||
if (first.props?.level !== 1) return null
|
||||
if (!Array.isArray(first.content)) return null
|
||||
return first.content
|
||||
}
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
const content = extractH1Content(blocks)
|
||||
if (!content) return null
|
||||
|
||||
const text = content
|
||||
.filter(item => item.type === 'text')
|
||||
.map(item => item.text || '')
|
||||
.join('')
|
||||
return text.trim() || null
|
||||
let text = ''
|
||||
for (const item of content) {
|
||||
if (item.type === 'text') {
|
||||
text += item.text || ''
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = text.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
/** Replace the title: line in YAML frontmatter with a new title value. */
|
||||
@@ -274,9 +287,11 @@ function normalizeTabBody(content: string): string {
|
||||
}
|
||||
|
||||
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
|
||||
return currentBody === nextBody
|
||||
|| currentBody.startsWith(nextBody)
|
||||
|| nextBody.startsWith(currentBody)
|
||||
const current = currentBody.trimEnd()
|
||||
const next = nextBody.trimEnd()
|
||||
return current === next
|
||||
|| current.startsWith(next)
|
||||
|| next.startsWith(current)
|
||||
}
|
||||
|
||||
function isUntitledRenameTransition(
|
||||
@@ -377,21 +392,52 @@ function cachePreviousTabOnPathChange(options: {
|
||||
cacheEditorState(cache, prevPath, editor.document)
|
||||
}
|
||||
|
||||
function rememberPendingTabArrival(
|
||||
function shouldWaitForActiveTab(
|
||||
pathChanged: boolean,
|
||||
activeTabPath: string | null,
|
||||
activeTab: Tab | undefined,
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>,
|
||||
) {
|
||||
if (!activeTabPath) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return pathChanged && !!activeTabPath && !activeTab
|
||||
}
|
||||
|
||||
function syncActivePathTransition(options: {
|
||||
prevPath: string | null
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
}) {
|
||||
const {
|
||||
prevPath,
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
} = options
|
||||
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
|
||||
|
||||
if (!preserveUntitledRenameState({
|
||||
prevPath,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
})) {
|
||||
prevActivePathRef.current = activeTabPath
|
||||
return false
|
||||
}
|
||||
if (activeTab) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return true
|
||||
}
|
||||
pendingTabArrivalPathRef.current = activeTabPath
|
||||
return false
|
||||
|
||||
prevActivePathRef.current = activeTabPath
|
||||
return true
|
||||
}
|
||||
|
||||
function handleStableActivePath(options: {
|
||||
@@ -399,7 +445,6 @@ function handleStableActivePath(options: {
|
||||
rawModeJustEnded: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
pendingTabArrival: boolean
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
@@ -410,7 +455,6 @@ function handleStableActivePath(options: {
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
@@ -423,7 +467,6 @@ function handleStableActivePath(options: {
|
||||
rawSwapPendingRef.current = true
|
||||
return false
|
||||
}
|
||||
if (pendingTabArrival) return false
|
||||
if (rawSwapPendingRef.current) return true
|
||||
|
||||
cacheStableActivePath({
|
||||
@@ -638,7 +681,81 @@ function scheduleTabSwap(options: {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
|
||||
function useTabSwapEffect(options: {
|
||||
function runTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
rawMode?: boolean
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
if (syncActivePathTransition({
|
||||
prevPath,
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath || !activeTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath: activeTabPath,
|
||||
activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
}
|
||||
|
||||
function useTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
@@ -647,7 +764,6 @@ function useTabSwapEffect(options: {
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
@@ -661,64 +777,22 @@ function useTabSwapEffect(options: {
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const pendingTabArrival = activeTabPath !== null
|
||||
&& pendingTabArrivalPathRef.current === activeTabPath
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
if (preserveUntitledRenameState({
|
||||
prevPath,
|
||||
runTabSwapEffect({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
editorMountedRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
|
||||
return
|
||||
}
|
||||
const targetPath = activeTabPath
|
||||
const readyActiveTab = activeTab
|
||||
if (!targetPath || !readyActiveTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath,
|
||||
activeTab: readyActiveTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
pendingSwapRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
@@ -727,7 +801,6 @@ function useTabSwapEffect(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevActivePathRef,
|
||||
prevRawModeRef,
|
||||
rawMode,
|
||||
@@ -771,7 +844,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const pendingTabArrivalPathRef = useRef<string | null>(null)
|
||||
const prevRawModeRef = useRef(!!rawMode)
|
||||
const rawSwapPendingRef = useRef(false)
|
||||
const suppressChangeRef = useRef(false)
|
||||
@@ -795,7 +867,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
flushBeforeNoteSwitch?: (path: string) => Promise<void>
|
||||
flushBeforePathRename?: (path: string) => Promise<void>
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
|
||||
config.onFrontmatterPersisted?.()
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
|
||||
) {
|
||||
return flushBeforeNoteSwitch
|
||||
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
|
||||
: undefined
|
||||
}
|
||||
|
||||
function useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
}: {
|
||||
config: NoteActionsConfig
|
||||
renameTabsRef: TitleRenameDeps['tabsRef']
|
||||
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleSwitchTab: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateTabContent: (path: string, newContent: string) => void
|
||||
runFrontmatterOp: (
|
||||
op: 'update' | 'delete',
|
||||
path: string,
|
||||
key: string,
|
||||
value?: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<string | undefined>
|
||||
}) {
|
||||
const handleUpdateFrontmatter = useCallback(async (
|
||||
path: string,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: renameTabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
|
||||
|
||||
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
return {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
handleAddProperty,
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { entries, setToastMessage, updateEntry } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
|
||||
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
const frontmatterActions = useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef: rename.tabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
|
||||
handleCreateType: creation.handleCreateType,
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: rename.tabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
|
||||
handleAddProperty: frontmatterActions.handleAddProperty,
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ReloadTabsAfterRenameRequest {
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
if (!filename.toLowerCase().endsWith('.md')) return false
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,22 @@ async function expectStatus(
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCancelledPickerLeavesWelcome(
|
||||
action: (onboarding: ReturnType<typeof useOnboarding>) => Promise<void>,
|
||||
) {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await action(result.current)
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
}
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -94,9 +110,15 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
})
|
||||
|
||||
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
|
||||
it('shows vault-missing when a previously configured active vault is missing', async () => {
|
||||
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands()
|
||||
mockCommands({
|
||||
load_vault_list: {
|
||||
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
|
||||
active_vault: '/vault/deleted',
|
||||
hidden_defaults: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
@@ -153,17 +175,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the template folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
|
||||
})
|
||||
|
||||
@@ -233,17 +247,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateNewVault()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('opens an existing folder and transitions to ready', async () => {
|
||||
@@ -262,17 +268,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the open-folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleOpenFolder()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
|
||||
import { cacheNoteContent } from './useTabManagement'
|
||||
|
||||
export async function persistContent(path: string, content: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
|
||||
export function useSaveNote(updateContent: (path: string, content: string) => void) {
|
||||
const saveNote = useCallback(async (path: string, content: string) => {
|
||||
await persistContent(path, content)
|
||||
cacheNoteContent(path, content)
|
||||
if (!isTauri()) {
|
||||
updateMockContent(path, content)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
|
||||
})
|
||||
}
|
||||
|
||||
async function prefetchResolvedContent(path: string, content: string) {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue(content)
|
||||
prefetchNoteContent(path)
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
return mockInvoke
|
||||
}
|
||||
|
||||
function expectSingleActiveTab(result: HookState, path: string) {
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe(path)
|
||||
expect(result.current.activeTabPath).toBe(path)
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useTabManagement (single-note model)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearPrefetchCache()
|
||||
})
|
||||
|
||||
it('starts with no note and null active path', () => {
|
||||
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
describe('content prefetch cache', () => {
|
||||
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
|
||||
|
||||
prefetchNoteContent('/vault/note/pre.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
|
||||
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('clearPrefetchCache prevents stale content from being served', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
|
||||
|
||||
prefetchNoteContent('/vault/note/stale.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
|
||||
|
||||
clearPrefetchCache()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
|
||||
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
|
||||
|
||||
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Persisted content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('waits for beforeNavigate before switching away from the current note', async () => {
|
||||
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
|
||||
const deferred = createDeferred<void>()
|
||||
beforeNavigate.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let replaceDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { replaceDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(replaceDone).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(replaceDone).toBe(true))
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
|
||||
const first = createDeferred<void>()
|
||||
const second = createDeferred<void>()
|
||||
const beforeNavigate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let switchToBDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { switchToBDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
let switchToCDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
.then(() => { switchToCDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
})
|
||||
|
||||
it('keeps the current note active when beforeNavigate fails', async () => {
|
||||
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist note before navigation:',
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
|
||||
prefetchCache.set(path, promise)
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
prefetchCache.set(path, Promise.resolve(content))
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
export function clearPrefetchCache(): void {
|
||||
prefetchCache.clear()
|
||||
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
|
||||
|
||||
export type { Tab }
|
||||
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
function syncActiveTabPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTabManagement() {
|
||||
export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
// Single-note model: tabs has 0 or 1 elements.
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
|
||||
@@ -123,18 +131,38 @@ export function useTabManagement() {
|
||||
|
||||
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
||||
const navSeqRef = useRef(0)
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
|
||||
const executeNavigationWithBoundary = useCallback(async (
|
||||
targetPath: string,
|
||||
navigate: () => void | Promise<void>,
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && currentPath !== targetPath) {
|
||||
try {
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist note before navigation:', err)
|
||||
return
|
||||
}
|
||||
if (beforeNavigateSeqRef.current !== seq) return
|
||||
}
|
||||
await navigate()
|
||||
}, [beforeNavigate])
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
||||
@@ -142,20 +170,22 @@ export function useTabManagement() {
|
||||
|
||||
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
|
||||
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
}, [])
|
||||
void executeNavigationWithBoundary(entry.path, () => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
})
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
|
||||
@@ -148,6 +148,16 @@ describe('useVaultSwitcher', () => {
|
||||
expect(onSwitch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist the implicit default vault as an active selection', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
expect(mockVaultListStore.active_vault).toBeNull()
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
@@ -413,6 +423,20 @@ describe('useVaultSwitcher', () => {
|
||||
|
||||
expect(result.current.vaultPath).toBe(persistedPath)
|
||||
})
|
||||
|
||||
it('treats a remembered default vault as unselected when it is the only vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [],
|
||||
active_vault: expectedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
|
||||
@@ -31,8 +31,10 @@ interface PersistedVaultState {
|
||||
extraVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
loaded: boolean
|
||||
selectedVaultPath: string | null
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
@@ -48,10 +50,12 @@ interface PersistedVaultStore {
|
||||
extraVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
loaded: boolean
|
||||
selectedVaultPath: string | null
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setLoaded: Dispatch<SetStateAction<boolean>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
@@ -77,10 +81,13 @@ interface RemainingVaultOptions {
|
||||
}
|
||||
|
||||
interface RemoveVaultStateOptions extends RemainingVaultOptions {
|
||||
selectedVaultPath: string | null
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface RemoveVaultActionOptions {
|
||||
@@ -91,10 +98,17 @@ interface RemoveVaultActionOptions {
|
||||
onToastRef: MutableRefObject<(msg: string) => void>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
selectedVaultPath: string | null
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
interface VaultPathInput {
|
||||
path: string
|
||||
}
|
||||
|
||||
function labelFromPath({ path }: VaultPathInput): string {
|
||||
return path.split('/').pop() || 'Local Vault'
|
||||
}
|
||||
|
||||
@@ -127,22 +141,37 @@ async function loadInitialVaultState() {
|
||||
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
}
|
||||
|
||||
function buildDefaultVaults(defaultPath: string): VaultOption[] {
|
||||
function buildDefaultVaults({ defaultPath }: { defaultPath: string }): VaultOption[] {
|
||||
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
|
||||
}
|
||||
|
||||
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
|
||||
function buildVisibleDefaultVaults({
|
||||
defaultVaults,
|
||||
hiddenDefaults,
|
||||
}: {
|
||||
defaultVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
}): VaultOption[] {
|
||||
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
|
||||
}
|
||||
|
||||
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
|
||||
function buildAllVaults({
|
||||
visibleDefaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
visibleDefaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): VaultOption[] {
|
||||
return [...visibleDefaults, ...extraVaults]
|
||||
}
|
||||
|
||||
function applyResolvedDefaultPath(
|
||||
resolvedDefaultPath: string,
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>,
|
||||
) {
|
||||
function applyResolvedDefaultPath({
|
||||
resolvedDefaultPath,
|
||||
setDefaultPath,
|
||||
}: {
|
||||
resolvedDefaultPath: string
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
if (!resolvedDefaultPath) {
|
||||
return
|
||||
}
|
||||
@@ -151,14 +180,35 @@ function applyResolvedDefaultPath(
|
||||
syncDefaultVaultExport(resolvedDefaultPath)
|
||||
}
|
||||
|
||||
function applyInitialVaultTarget(
|
||||
function normalizeInitialSelectedVaultPath(
|
||||
activeVault: string | null,
|
||||
resolvedDefaultPath: string,
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
) {
|
||||
vaults: VaultOption[],
|
||||
): string | null {
|
||||
if (!activeVault) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isRememberedDefaultOnlySelection = activeVault === resolvedDefaultPath && vaults.length === 0
|
||||
return isRememberedDefaultOnlySelection ? null : activeVault
|
||||
}
|
||||
|
||||
function applyInitialVaultTarget({
|
||||
activeVault,
|
||||
resolvedDefaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
resolvedDefaultPath: string
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
}) {
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
setSelectedVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
return
|
||||
}
|
||||
@@ -174,15 +224,15 @@ function useVaultCollections(
|
||||
extraVaults: VaultOption[],
|
||||
): VaultCollections {
|
||||
const defaultVaults = useMemo(
|
||||
() => buildDefaultVaults(defaultPath),
|
||||
() => buildDefaultVaults({ defaultPath }),
|
||||
[defaultPath],
|
||||
)
|
||||
const visibleDefaults = useMemo(
|
||||
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
|
||||
() => buildVisibleDefaultVaults({ defaultVaults, hiddenDefaults }),
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => buildAllVaults(visibleDefaults, extraVaults),
|
||||
() => buildAllVaults({ visibleDefaults, extraVaults }),
|
||||
[extraVaults, visibleDefaults],
|
||||
)
|
||||
const isGettingStartedHidden = useMemo(
|
||||
@@ -202,6 +252,7 @@ function useLoadPersistedVaultState(
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setLoaded,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
} = store
|
||||
|
||||
@@ -214,8 +265,14 @@ function useLoadPersistedVaultState(
|
||||
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
|
||||
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
|
||||
applyResolvedDefaultPath({ resolvedDefaultPath, setDefaultPath })
|
||||
applyInitialVaultTarget({
|
||||
activeVault: normalizeInitialSelectedVaultPath(activeVault, resolvedDefaultPath, vaults),
|
||||
resolvedDefaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
})
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
.finally(() => {
|
||||
@@ -225,23 +282,24 @@ function useLoadPersistedVaultState(
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
|
||||
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
|
||||
}
|
||||
|
||||
function usePersistedVaultStorage(store: PersistedVaultStore) {
|
||||
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
|
||||
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
|
||||
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
|
||||
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
|
||||
console.warn('Failed to persist vault list:', err),
|
||||
)
|
||||
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
|
||||
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
|
||||
}
|
||||
|
||||
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
|
||||
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
@@ -252,10 +310,12 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
setDefaultPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setLoaded,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}
|
||||
@@ -268,8 +328,10 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}
|
||||
@@ -304,23 +366,34 @@ async function ensureGettingStartedVaultReady(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function addVaultToList(
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
|
||||
path: string,
|
||||
label: string,
|
||||
) {
|
||||
function addVaultToList({
|
||||
setExtraVaults,
|
||||
path,
|
||||
label,
|
||||
}: {
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
path: string
|
||||
label: string
|
||||
}) {
|
||||
setExtraVaults(previousVaults => {
|
||||
const exists = previousVaults.some(vault => vault.path === path)
|
||||
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
|
||||
})
|
||||
}
|
||||
|
||||
function switchVaultPath(
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
path: string,
|
||||
) {
|
||||
function switchVaultPath({
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
path,
|
||||
}: {
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
path: string
|
||||
}) {
|
||||
trackEvent('vault_switched')
|
||||
setSelectedVaultPath(path)
|
||||
setVaultPath(path)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
@@ -349,7 +422,10 @@ function removeVaultFromState({
|
||||
removedPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}: RemoveVaultStateOptions) {
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
|
||||
@@ -357,43 +433,52 @@ function removeVaultFromState({
|
||||
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
|
||||
}
|
||||
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== removedPath) {
|
||||
return currentPath
|
||||
if (vaultPath !== removedPath) {
|
||||
if (selectedVaultPath === removedPath) {
|
||||
setSelectedVaultPath(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const remainingVaults = listRemainingVaults({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
isDefault,
|
||||
removedPath,
|
||||
})
|
||||
if (remainingVaults.length === 0) {
|
||||
return currentPath
|
||||
}
|
||||
|
||||
onSwitchRef.current()
|
||||
return remainingVaults[0].path
|
||||
const remainingVaults = listRemainingVaults({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
isDefault,
|
||||
removedPath,
|
||||
})
|
||||
if (remainingVaults.length === 0) {
|
||||
setSelectedVaultPath(null)
|
||||
return
|
||||
}
|
||||
|
||||
const nextPath = remainingVaults[0].path
|
||||
setSelectedVaultPath(nextPath)
|
||||
setVaultPath(nextPath)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
|
||||
function getRemovedVaultLabel(
|
||||
path: string,
|
||||
defaultVaults: VaultOption[],
|
||||
extraVaults: VaultOption[],
|
||||
): string {
|
||||
function getRemovedVaultLabel({
|
||||
path,
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
path: string
|
||||
defaultVaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): string {
|
||||
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
|
||||
return removedVault?.label ?? labelFromPath(path)
|
||||
return removedVault?.label ?? labelFromPath({ path })
|
||||
}
|
||||
|
||||
function useSwitchVaultAction(
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>,
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
) {
|
||||
return useCallback((path: string) => {
|
||||
switchVaultPath(setVaultPath, onSwitchRef, path)
|
||||
}, [onSwitchRef, setVaultPath])
|
||||
switchVaultPath({ setSelectedVaultPath, setVaultPath, onSwitchRef, path })
|
||||
}, [onSwitchRef, setSelectedVaultPath, setVaultPath])
|
||||
}
|
||||
|
||||
function useVaultClonedAction(
|
||||
@@ -414,7 +499,7 @@ function useOpenLocalFolderAction(
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
|
||||
const label = labelFromPath(path)
|
||||
const label = labelFromPath({ path })
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
}, [addAndSwitch, onToastRef])
|
||||
@@ -428,7 +513,10 @@ function useRemoveVaultAction({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}: RemoveVaultActionOptions) {
|
||||
return useCallback((path: string) => {
|
||||
const isDefault = defaultVaults.some(vault => vault.path === path)
|
||||
@@ -442,9 +530,12 @@ function useRemoveVaultAction({
|
||||
removedPath: path,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
})
|
||||
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
|
||||
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
|
||||
}, [
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
@@ -453,7 +544,10 @@ function useRemoveVaultAction({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -479,13 +573,16 @@ function useVaultActions({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
selectedVaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}: VaultActionOptions) {
|
||||
const addVault = useCallback((path: string, label: string) => {
|
||||
addVaultToList(setExtraVaults, path, label)
|
||||
addVaultToList({ setExtraVaults, path, label })
|
||||
}, [setExtraVaults])
|
||||
|
||||
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
|
||||
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
|
||||
const addAndSwitch = useCallback((path: string, label: string) => {
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
@@ -502,7 +599,10 @@ function useVaultActions({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}),
|
||||
restoreGettingStarted: useRestoreGettingStartedAction({
|
||||
defaultPath,
|
||||
@@ -543,7 +643,14 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
|
||||
|
||||
const persistedState = usePersistedVaultState(onSwitchRef)
|
||||
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
|
||||
const {
|
||||
defaultPath,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
} = persistedState
|
||||
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
|
||||
defaultPath,
|
||||
hiddenDefaults,
|
||||
@@ -559,7 +666,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
})
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
removeVault, restoreGettingStarted, isGettingStartedHidden,
|
||||
allVaults,
|
||||
defaultPath,
|
||||
handleOpenLocalFolder,
|
||||
handleVaultCloned,
|
||||
isGettingStartedHidden,
|
||||
loaded,
|
||||
removeVault,
|
||||
restoreGettingStarted,
|
||||
selectedVaultPath,
|
||||
switchVault,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,18 @@ if (typeof window !== 'undefined') {
|
||||
window.__mockHandlers = mockHandlers
|
||||
}
|
||||
|
||||
function resolveMockHandler(command: string) {
|
||||
if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) {
|
||||
return window.__mockHandlers[command]
|
||||
}
|
||||
return mockHandlers[command]
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const vaultResult = await tryVaultApi<T>(cmd, args)
|
||||
if (vaultResult !== undefined) return vaultResult
|
||||
|
||||
const handler = mockHandlers[cmd]
|
||||
const handler = resolveMockHandler(cmd)
|
||||
if (handler) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
return handler(args) as T
|
||||
|
||||
@@ -103,11 +103,17 @@ let mockSettings: Settings = {
|
||||
default_ai_agent: 'claude_code',
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
|
||||
const DEFAULT_MOCK_VAULT = {
|
||||
label: 'demo-vault-v2',
|
||||
path: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
vaults: [DEFAULT_MOCK_VAULT],
|
||||
active_vault: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockVaultAiGuidanceStatus = {
|
||||
|
||||
61
src/mock-tauri/vault-api.test.ts
Normal file
61
src/mock-tauri/vault-api.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('tryVaultApi', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('retries vault API discovery after an unavailable response', async () => {
|
||||
let vaultApiOnline = false
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
|
||||
}
|
||||
if (url === '/api/vault/list?path=%2Ffixture') {
|
||||
return jsonResponse([{ title: 'Alpha Project' }])
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
|
||||
|
||||
vaultApiOnline = true
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unwraps note content responses from the vault API', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') {
|
||||
return jsonResponse({ content: '# Alpha Project' })
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -6,16 +6,22 @@
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
|
||||
async function checkVaultApi(): Promise<boolean> {
|
||||
if (vaultApiAvailable !== null) return vaultApiAvailable
|
||||
async function detectVaultApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
|
||||
vaultApiAvailable = res.ok
|
||||
return res.ok
|
||||
} catch {
|
||||
vaultApiAvailable = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVaultApi(): Promise<boolean> {
|
||||
if (vaultApiAvailable === true) return true
|
||||
|
||||
const available = await detectVaultApiAvailability()
|
||||
vaultApiAvailable = available
|
||||
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
|
||||
return vaultApiAvailable
|
||||
return available
|
||||
}
|
||||
|
||||
interface VaultApiRequest {
|
||||
@@ -65,25 +71,38 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
|
||||
},
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const available = await checkVaultApi()
|
||||
if (!available) return undefined
|
||||
|
||||
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>) {
|
||||
if (!args) return null
|
||||
const requestBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!requestBuilder || !args) return undefined
|
||||
return requestBuilder?.(args) ?? null
|
||||
}
|
||||
|
||||
const request = requestBuilder(args)
|
||||
function buildFetchOptions(request: VaultApiRequest): RequestInit {
|
||||
if (!request.body) {
|
||||
return { method: request.method || 'GET' }
|
||||
}
|
||||
|
||||
return {
|
||||
method: request.method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request.body),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVaultApiResponse(request: VaultApiRequest) {
|
||||
const res = await fetch(request.url, buildFetchOptions(request))
|
||||
if (!res.ok) return undefined
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const request = buildVaultApiRequest(cmd, args)
|
||||
if (!request) return undefined
|
||||
if (!await checkVaultApi()) return undefined
|
||||
|
||||
try {
|
||||
const fetchOpts: RequestInit = { method: request.method || 'GET' }
|
||||
if (request.body) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOpts.body = JSON.stringify(request.body)
|
||||
}
|
||||
const res = await fetch(request.url, fetchOpts)
|
||||
if (!res.ok) return undefined
|
||||
const data = await res.json()
|
||||
const data = await fetchVaultApiResponse(request)
|
||||
if (data === undefined) return undefined
|
||||
return (cmd === 'get_note_content' ? data.content : data) as T
|
||||
} catch (err) {
|
||||
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)
|
||||
|
||||
32
src/utils/commitEntryAction.test.ts
Normal file
32
src/utils/commitEntryAction.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { triggerCommitEntryAction } from './commitEntryAction'
|
||||
|
||||
describe('triggerCommitEntryAction', () => {
|
||||
it('runs the automatic checkpoint when AutoGit is enabled', () => {
|
||||
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
|
||||
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: true,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
|
||||
expect(runAutomaticCheckpoint).toHaveBeenCalledWith({ savePendingBeforeCommit: true })
|
||||
expect(openCommitDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the manual commit dialog when AutoGit is disabled', () => {
|
||||
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
|
||||
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: false,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
|
||||
expect(openCommitDialog).toHaveBeenCalledTimes(1)
|
||||
expect(runAutomaticCheckpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
21
src/utils/commitEntryAction.ts
Normal file
21
src/utils/commitEntryAction.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type RunAutomaticCheckpoint = (options?: { savePendingBeforeCommit?: boolean }) => Promise<boolean>
|
||||
type OpenCommitDialog = () => Promise<void>
|
||||
|
||||
interface CommitEntryActionConfig {
|
||||
autoGitEnabled: boolean
|
||||
openCommitDialog: OpenCommitDialog
|
||||
runAutomaticCheckpoint: RunAutomaticCheckpoint
|
||||
}
|
||||
|
||||
export function triggerCommitEntryAction({
|
||||
autoGitEnabled,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
}: CommitEntryActionConfig): void {
|
||||
if (autoGitEnabled) {
|
||||
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
|
||||
return
|
||||
}
|
||||
|
||||
void openCommitDialog()
|
||||
}
|
||||
62
src/utils/editorLinkAutolink.test.ts
Normal file
62
src/utils/editorLinkAutolink.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
looksLikeLocalFileReference,
|
||||
shouldStripAutoLinkedLocalFileMark,
|
||||
shouldAutoLinkTolariaHref,
|
||||
} from './editorLinkAutolink'
|
||||
|
||||
describe('looksLikeLocalFileReference', () => {
|
||||
it('treats bare filenames with file extensions as local file references', () => {
|
||||
expect(looksLikeLocalFileReference({ raw: 'AGENTS.md' })).toBe(true)
|
||||
expect(looksLikeLocalFileReference({ raw: 'README.txt' })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats local path-like filenames as local file references', () => {
|
||||
expect(looksLikeLocalFileReference({ raw: 'docs/README.md' })).toBe(true)
|
||||
expect(looksLikeLocalFileReference({ raw: './docs/README.md' })).toBe(true)
|
||||
expect(looksLikeLocalFileReference({ raw: '/vault/README.md' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not classify domain-based urls as local file references', () => {
|
||||
expect(looksLikeLocalFileReference({ raw: 'https://example.com/README.md' })).toBe(false)
|
||||
expect(looksLikeLocalFileReference({ raw: 'example.com/README.md' })).toBe(false)
|
||||
expect(looksLikeLocalFileReference({ raw: 'www.example.com/README.md' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoLinkTolariaHref', () => {
|
||||
it('rejects plain filename-like text', () => {
|
||||
expect(shouldAutoLinkTolariaHref({ raw: 'AGENTS.md' })).toBe(false)
|
||||
expect(shouldAutoLinkTolariaHref({ raw: 'docs/README.md' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps normal url-like values eligible for autolinking', () => {
|
||||
expect(shouldAutoLinkTolariaHref({ raw: 'https://example.com/docs' })).toBe(true)
|
||||
expect(shouldAutoLinkTolariaHref({ raw: 'example.com' })).toBe(true)
|
||||
expect(shouldAutoLinkTolariaHref({ raw: 'example.com/README.md' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldStripAutoLinkedLocalFileMark', () => {
|
||||
it('strips accidental link marks that mirror local file text', () => {
|
||||
expect(shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: 'https://AGENTS.md' },
|
||||
text: { raw: 'AGENTS.md' },
|
||||
})).toBe(true)
|
||||
expect(shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: 'https://docs/README.md' },
|
||||
text: { raw: 'docs/README.md' },
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps intentional external links', () => {
|
||||
expect(shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: 'https://example.com/docs' },
|
||||
text: { raw: 'Tolaria Docs' },
|
||||
})).toBe(false)
|
||||
expect(shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: 'https://example.com/agents' },
|
||||
text: { raw: 'AGENTS.md' },
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
143
src/utils/editorLinkAutolink.ts
Normal file
143
src/utils/editorLinkAutolink.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
const FILE_LIKE_EXTENSION_PATTERN =
|
||||
/\.(?:md|markdown|txt|ya?ml|json|toml|csv|tsv|pdf|png|jpe?g|gif|svg|webp|avif|mp3|wav|ogg|mp4|mov|zip|tar|gz|tsx?|jsx?|cjs|mjs|rs|py|sh|css|html?)$/i
|
||||
|
||||
const EXPLICIT_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i
|
||||
const MAYBE_PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i
|
||||
const LOCAL_PATH_PREFIX_PATTERN = /^(?:\.{1,2}\/|~\/|\/)/
|
||||
const IPV4_HOST_PATTERN = /^\d{1,3}(\.\d{1,3}){3}$/
|
||||
const WINDOWS_PATH_SEPARATOR = '\\'
|
||||
const WWW_PREFIX = 'www.'
|
||||
|
||||
export type LinkValue = {
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type LinkMarkCandidate = {
|
||||
href: LinkValue
|
||||
text: LinkValue
|
||||
}
|
||||
|
||||
function withRaw(raw: string): LinkValue {
|
||||
return { raw }
|
||||
}
|
||||
|
||||
function stripUrlDecorators(value: LinkValue) {
|
||||
return withRaw(value.raw.split(/[?#]/, 1)[0] ?? value.raw)
|
||||
}
|
||||
|
||||
function stripProtocol(value: LinkValue) {
|
||||
return withRaw(value.raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''))
|
||||
}
|
||||
|
||||
function isExplicitProtocolUrl(value: LinkValue) {
|
||||
return EXPLICIT_PROTOCOL_PATTERN.test(value.raw)
|
||||
}
|
||||
|
||||
function isMaybeProtocolUrl(value: LinkValue) {
|
||||
return MAYBE_PROTOCOL_PATTERN.test(value.raw)
|
||||
}
|
||||
|
||||
function hasWindowsPathSeparator(value: LinkValue) {
|
||||
return value.raw.includes(WINDOWS_PATH_SEPARATOR)
|
||||
}
|
||||
|
||||
function hasPathSeparator(value: LinkValue) {
|
||||
return value.raw.includes('/')
|
||||
}
|
||||
|
||||
function startsWithWebHostPrefix(value: LinkValue) {
|
||||
return value.raw.startsWith(WWW_PREFIX)
|
||||
}
|
||||
|
||||
function firstPathSegment(value: LinkValue) {
|
||||
return value.raw.split('/', 1)[0] ?? value.raw
|
||||
}
|
||||
|
||||
function isDomainLikePath(value: LinkValue) {
|
||||
return firstPathSegment(value).includes('.')
|
||||
}
|
||||
|
||||
function hostnameFromUrlLikeValue(value: LinkValue) {
|
||||
const withoutUserinfo = value.raw.includes('@') ? value.raw.split('@').pop() ?? value.raw : value.raw
|
||||
return withoutUserinfo.split(/[/?#:]/, 1)[0] ?? withoutUserinfo
|
||||
}
|
||||
|
||||
function isExplicitWebUrl(value: LinkValue) {
|
||||
return isExplicitProtocolUrl(value) || startsWithWebHostPrefix(value)
|
||||
}
|
||||
|
||||
function isSingleSegmentValue(value: LinkValue) {
|
||||
return !hasPathSeparator(value)
|
||||
}
|
||||
|
||||
function isLocalPathPrefix(value: LinkValue) {
|
||||
return LOCAL_PATH_PREFIX_PATTERN.test(value.raw)
|
||||
}
|
||||
|
||||
function isPathLikeFileReference(value: LinkValue) {
|
||||
return hasPathSeparator(value) && !isDomainLikePath(value)
|
||||
}
|
||||
|
||||
function hasBareProtocolPrefix(value: LinkValue) {
|
||||
return isMaybeProtocolUrl(value) && !value.raw.includes('@')
|
||||
}
|
||||
|
||||
function hostnameHasTld(hostname: string) {
|
||||
return hostname.includes('.')
|
||||
}
|
||||
|
||||
function normalizeInput(value: LinkValue) {
|
||||
return stripUrlDecorators(withRaw(value.raw.trim()))
|
||||
}
|
||||
|
||||
export function looksLikeLocalFileReference(value: LinkValue) {
|
||||
const normalized = normalizeInput(value)
|
||||
if (!normalized.raw) return false
|
||||
|
||||
if (isLocalPathPrefix(normalized) || hasWindowsPathSeparator(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!FILE_LIKE_EXTENSION_PATTERN.test(normalized.raw)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isExplicitWebUrl(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isSingleSegmentValue(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isPathLikeFileReference(normalized)
|
||||
}
|
||||
|
||||
export function shouldAutoLinkTolariaHref(url: LinkValue) {
|
||||
if (looksLikeLocalFileReference(url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isExplicitProtocolUrl(url) || hasBareProtocolPrefix(url)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hostname = hostnameFromUrlLikeValue(url)
|
||||
if (IPV4_HOST_PATTERN.test(hostname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return hostnameHasTld(hostname)
|
||||
}
|
||||
|
||||
export function shouldStripAutoLinkedLocalFileMark(mark: LinkMarkCandidate) {
|
||||
const normalizedText = normalizeInput(mark.text)
|
||||
if (!looksLikeLocalFileReference(normalizedText)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalizedHref = stripUrlDecorators(
|
||||
stripProtocol(normalizeInput(mark.href)),
|
||||
)
|
||||
return normalizedHref.raw === normalizedText.raw
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { countAllByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
|
||||
|
||||
describe('filterEntries', () => {
|
||||
@@ -62,6 +62,17 @@ describe('filterEntries', () => {
|
||||
const result = filterEntries(entries, allSelection, 'archived')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Archived'])
|
||||
})
|
||||
|
||||
it('excludes attachments-folder markdown from the All Notes view', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/note/real-note.md', title: 'Real Note', isA: 'Note' }),
|
||||
makeEntry({ path: '/vault/attachments/reference.md', title: 'Attachment Markdown', isA: 'Note' }),
|
||||
makeEntry({ path: '/vault/attachments/nested/diagram.md', title: 'Nested Attachment Markdown', isA: 'Note' }),
|
||||
]
|
||||
|
||||
const result = filterEntries(entries, allSelection, 'open')
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Real Note'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countByFilter', () => {
|
||||
@@ -102,3 +113,17 @@ describe('countAllByFilter', () => {
|
||||
expect(countAllByFilter(entries)).toEqual({ open: 1, archived: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('countAllNotesByFilter', () => {
|
||||
it('excludes attachments-folder files from All Notes totals', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/note/real-note.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/vault/attachments/reference.md', isA: 'Note' }),
|
||||
makeEntry({ path: '/vault/attachments/archive.md', isA: 'Note', archived: true }),
|
||||
makeEntry({ path: '/vault/attachments/image.png', fileKind: 'binary' }),
|
||||
makeEntry({ path: '/vault/archive/real-archive.md', isA: 'Note', archived: true }),
|
||||
]
|
||||
|
||||
expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -310,6 +310,7 @@ export function buildRelationshipGroups(
|
||||
|
||||
const isActive = (e: VaultEntry) => !e.archived
|
||||
const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
|
||||
const ATTACHMENTS_FOLDER = 'attachments'
|
||||
|
||||
function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): VaultEntry[] {
|
||||
if (subFilter === 'archived') return entries.filter((e) => e.archived)
|
||||
@@ -321,26 +322,46 @@ function isInFolder(entryPath: string, folderRelPath: string): boolean {
|
||||
return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/')
|
||||
}
|
||||
|
||||
export function isAllNotesEntry(entry: VaultEntry): boolean {
|
||||
return isMarkdown(entry) && !isInFolder(entry.path, ATTACHMENTS_FOLDER)
|
||||
}
|
||||
|
||||
function filterViewEntries(entries: VaultEntry[], filename: string, views?: ViewFile[]): VaultEntry[] {
|
||||
const view = views?.find((candidate) => candidate.filename === filename)
|
||||
if (!view) return []
|
||||
return evaluateView(view.definition, entries.filter(isMarkdown))
|
||||
}
|
||||
|
||||
function filterFolderEntries(entries: VaultEntry[], folderPath: string, subFilter?: NoteListFilter): VaultEntry[] {
|
||||
// Folder view shows ALL files (text + binary), not just markdown
|
||||
const folderEntries = entries.filter((entry) => isInFolder(entry.path, folderPath))
|
||||
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
|
||||
}
|
||||
|
||||
function filterSectionGroupEntries(entries: VaultEntry[], type: string, subFilter?: NoteListFilter): VaultEntry[] {
|
||||
const typeEntries = entries.filter((entry) => isMarkdown(entry) && entry.isA === type)
|
||||
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
|
||||
}
|
||||
|
||||
function filterTopLevelEntries(
|
||||
entries: VaultEntry[],
|
||||
selection: Extract<SidebarSelection, { kind: 'filter' }>,
|
||||
subFilter?: NoteListFilter,
|
||||
): VaultEntry[] {
|
||||
const filterableEntries = selection.filter === 'all'
|
||||
? entries.filter(isAllNotesEntry)
|
||||
: entries.filter(isMarkdown)
|
||||
if (selection.filter === 'all' && subFilter) return applySubFilter(filterableEntries, subFilter)
|
||||
return filterByFilterType(filterableEntries, selection.filter)
|
||||
}
|
||||
|
||||
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter, views?: ViewFile[]): VaultEntry[] {
|
||||
if (selection.kind === 'entity') return []
|
||||
if (selection.kind === 'view') {
|
||||
const view = views?.find((v) => v.filename === selection.filename)
|
||||
if (!view) return []
|
||||
return evaluateView(view.definition, entries.filter(isMarkdown))
|
||||
}
|
||||
if (selection.kind === 'folder') {
|
||||
// Folder view shows ALL files (text + binary), not just markdown
|
||||
const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path))
|
||||
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
|
||||
}
|
||||
if (selection.kind === 'sectionGroup') {
|
||||
const typeEntries = entries.filter((e) => isMarkdown(e) && e.isA === selection.type)
|
||||
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
|
||||
}
|
||||
// Non-folder views: only markdown files
|
||||
const mdEntries = entries.filter(isMarkdown)
|
||||
if (selection.filter === 'all' && subFilter) return applySubFilter(mdEntries, subFilter)
|
||||
return filterByFilterType(mdEntries, selection.filter)
|
||||
if (selection.kind === 'view') return filterViewEntries(entries, selection.filename, views)
|
||||
if (selection.kind === 'folder') return filterFolderEntries(entries, selection.path, subFilter)
|
||||
if (selection.kind === 'sectionGroup') return filterSectionGroupEntries(entries, selection.type, subFilter)
|
||||
if (selection.kind === 'filter') return filterTopLevelEntries(entries, selection, subFilter)
|
||||
return []
|
||||
}
|
||||
|
||||
function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] {
|
||||
@@ -366,17 +387,25 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
|
||||
return { open, archived }
|
||||
}
|
||||
|
||||
/** Count notes per sub-filter across all entries (no type filter). */
|
||||
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
function countEntriesByArchiveStatus(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
let open = 0, archived = 0
|
||||
for (const e of entries) {
|
||||
if (!isMarkdown(e)) continue
|
||||
if (e.archived) archived++
|
||||
for (const entry of entries) {
|
||||
if (entry.archived) archived++
|
||||
else open++
|
||||
}
|
||||
return { open, archived }
|
||||
}
|
||||
|
||||
/** Count notes per sub-filter across all entries (no type filter). */
|
||||
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
return countEntriesByArchiveStatus(entries.filter(isMarkdown))
|
||||
}
|
||||
|
||||
/** Count All Notes-eligible documents per sub-filter, excluding files under attachments/. */
|
||||
export function countAllNotesByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
|
||||
return countEntriesByArchiveStatus(entries.filter(isAllNotesEntry))
|
||||
}
|
||||
|
||||
// --- Inbox ---
|
||||
|
||||
/** Check if entry belongs in the Inbox (markdown only, not organized, not archived, not a Type). */
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('buildRawEditorBaseItems', () => {
|
||||
title: 'Project Alpha',
|
||||
aliases: ['project-alpha', 'Alpha'],
|
||||
group: 'Project',
|
||||
entryType: 'Project',
|
||||
entryTitle: 'Project Alpha',
|
||||
path: 'projects/project-alpha.md',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface RawEditorBaseItem {
|
||||
title: string
|
||||
aliases: string[]
|
||||
group: string
|
||||
entryType?: string | null
|
||||
entryTitle: string
|
||||
path: string
|
||||
}
|
||||
@@ -59,6 +60,7 @@ export function buildRawEditorBaseItems(entries: VaultEntry[]): RawEditorBaseIte
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
})))
|
||||
|
||||
@@ -31,13 +31,10 @@ const isMarkdown = (e: VaultEntry) => e.fileKind === 'markdown' || !e.fileKind
|
||||
const isActive = (e: VaultEntry) => !e.archived
|
||||
const isSupportedSectionType = (type: string) => !isLegacyJournalingType(type)
|
||||
|
||||
function resolveEntrySectionType(entry: VaultEntry): string {
|
||||
return entry.isA || 'Note'
|
||||
}
|
||||
|
||||
function shouldCollectActiveType(entry: VaultEntry): boolean {
|
||||
if (!isActive(entry) || !isMarkdown(entry)) return false
|
||||
return isSupportedSectionType(resolveEntrySectionType(entry))
|
||||
if (!entry.isA) return false
|
||||
return isSupportedSectionType(entry.isA)
|
||||
}
|
||||
|
||||
function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
|
||||
@@ -45,12 +42,12 @@ function shouldIncludeTypeDefinition(name: string, entry: VaultEntry): boolean {
|
||||
return isSupportedSectionType(name)
|
||||
}
|
||||
|
||||
/** Collect unique isA values from active (non-archived) markdown entries. Untyped entries count as 'Note'. */
|
||||
/** Collect unique explicit isA values from active (non-archived) markdown entries. */
|
||||
export function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (!shouldCollectActiveType(e)) continue
|
||||
types.add(resolveEntrySectionType(e))
|
||||
types.add(e.isA!)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ describe('enrichSuggestionItems', () => {
|
||||
Project: makeEntry({ isA: 'Type', title: 'Project', color: 'blue', icon: 'wrench' }),
|
||||
}
|
||||
|
||||
function makeItem(title: string, group: string, path: string) {
|
||||
return { title, aliases: [] as string[], group, entryTitle: title, path, onItemClick: vi.fn() }
|
||||
function makeItem(title: string, group: string, path: string, entryType?: string | null) {
|
||||
return { title, aliases: [] as string[], group, entryType, entryTitle: title, path, onItemClick: vi.fn() }
|
||||
}
|
||||
|
||||
it('filters items by query', () => {
|
||||
@@ -88,7 +88,7 @@ describe('enrichSuggestionItems', () => {
|
||||
})
|
||||
|
||||
it('adds type metadata for non-Note groups', () => {
|
||||
const items = [makeItem('My Project', 'Project', '/p.md')]
|
||||
const items = [makeItem('My Project', 'Project', '/p.md', 'Project')]
|
||||
const result = enrichSuggestionItems(items, '', typeEntryMap)
|
||||
expect(result[0].noteType).toBe('Project')
|
||||
expect(result[0].typeColor).toBeDefined()
|
||||
@@ -96,7 +96,15 @@ describe('enrichSuggestionItems', () => {
|
||||
expect(result[0].TypeIcon).toBeDefined()
|
||||
})
|
||||
|
||||
it('omits type metadata for Note group', () => {
|
||||
it('preserves Note type metadata for explicit Note entries', () => {
|
||||
const items = [makeItem('Explicit Note', 'Note', '/n.md', 'Note')]
|
||||
const result = enrichSuggestionItems(items, '', {})
|
||||
expect(result[0].noteType).toBe('Note')
|
||||
expect(result[0].typeColor).toBeDefined()
|
||||
expect(result[0].TypeIcon).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps untyped Note-group entries neutral', () => {
|
||||
const items = [makeItem('Plain Note', 'Note', '/n.md')]
|
||||
const result = enrichSuggestionItems(items, '', {})
|
||||
expect(result[0].noteType).toBeUndefined()
|
||||
|
||||
@@ -13,6 +13,7 @@ interface BaseSuggestionItem {
|
||||
title: string
|
||||
aliases: string[]
|
||||
group: string
|
||||
entryType?: string | null
|
||||
entryTitle: string
|
||||
path: string
|
||||
}
|
||||
@@ -48,15 +49,15 @@ export function enrichSuggestionItems(
|
||||
)
|
||||
const sliced = filtered.slice(0, MAX_RESULTS)
|
||||
const final = disambiguateTitles(deduplicateByPath(sliced))
|
||||
return final.map(({ group, ...rest }) => {
|
||||
const noteType = group !== 'Note' ? group : undefined
|
||||
const te = typeEntryMap[group]
|
||||
return final.map(({ entryType, ...rest }) => {
|
||||
const noteType = entryType ?? undefined
|
||||
const te = noteType ? typeEntryMap[noteType] : undefined
|
||||
return {
|
||||
...rest,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(group, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(group, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(group, te?.icon) : undefined,
|
||||
typeColor: noteType ? getTypeColor(noteType, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(noteType, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(noteType, te?.icon) : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVa
|
||||
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
|
||||
}
|
||||
|
||||
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
|
||||
export function saveVaultList(vaults: VaultOption[], activeVault: string | null, hiddenDefaults: string[] = []): Promise<void> {
|
||||
const list: PersistedVaultList = {
|
||||
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
|
||||
active_vault: activeVault,
|
||||
|
||||
@@ -2,20 +2,40 @@ import { expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { installFixtureVaultDesktopBridgeInBrowser } from './fixtureVaultDesktopBridge'
|
||||
|
||||
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
|
||||
const FIXTURE_VAULT_READY_TIMEOUT = 30_000
|
||||
const FIXTURE_VAULT_REMOVE_RETRIES = 10
|
||||
const FIXTURE_VAULT_REMOVE_RETRY_DELAY_MS = 100
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
type FixtureCommandArgs = Record<string, unknown> | undefined
|
||||
|
||||
function copyDirSync(src: string, dest: string): void {
|
||||
interface FixtureVaultPageArgs {
|
||||
page: Page
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface FixturePageArgs {
|
||||
page: Page
|
||||
}
|
||||
|
||||
interface CopyDirArgs {
|
||||
src: string
|
||||
dest: string
|
||||
}
|
||||
|
||||
interface RemoveFixtureVaultArgs {
|
||||
tempVaultDir: string
|
||||
}
|
||||
|
||||
function copyDirSync({ src, dest }: CopyDirArgs): void {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const sourcePath = path.join(src, item.name)
|
||||
const destinationPath = path.join(dest, item.name)
|
||||
if (item.isDirectory()) {
|
||||
copyDirSync(sourcePath, destinationPath)
|
||||
copyDirSync({ src: sourcePath, dest: destinationPath })
|
||||
continue
|
||||
}
|
||||
fs.copyFileSync(sourcePath, destinationPath)
|
||||
@@ -24,12 +44,11 @@ function copyDirSync(src: string, dest: string): void {
|
||||
|
||||
export function createFixtureVaultCopy(): string {
|
||||
const tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
|
||||
copyDirSync(FIXTURE_VAULT, tempVaultDir)
|
||||
copyDirSync({ src: FIXTURE_VAULT, dest: tempVaultDir })
|
||||
return tempVaultDir
|
||||
}
|
||||
|
||||
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
|
||||
if (!tempVaultDir) return
|
||||
function removeFixtureVaultDirectory({ tempVaultDir }: RemoveFixtureVaultArgs): void {
|
||||
fs.rmSync(tempVaultDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -38,15 +57,21 @@ export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined):
|
||||
})
|
||||
}
|
||||
|
||||
export async function openFixtureVault(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
): Promise<void> {
|
||||
export function removeFixtureVaultCopy(tempVaultDir: string | null | undefined): void {
|
||||
if (!tempVaultDir) return
|
||||
removeFixtureVaultDirectory({ tempVaultDir })
|
||||
}
|
||||
|
||||
async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPageArgs): Promise<void> {
|
||||
await page.addInitScript(({ dismissedKey, resolvedVaultPath }: { dismissedKey: string; resolvedVaultPath: string }) => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem(dismissedKey, '1')
|
||||
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' }
|
||||
const FRONTMATTER_OPEN = '---\n'
|
||||
const FRONTMATTER_CLOSE = '\n---\n'
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestUrl = typeof input === 'string'
|
||||
? input
|
||||
@@ -64,85 +89,21 @@ export async function openFixtureVault(
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
|
||||
const applyFixtureVaultOverrides = (
|
||||
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
|
||||
) => {
|
||||
if (!handlers) return handlers
|
||||
handlers.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
|
||||
active_vault: resolvedVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
handlers.check_vault_exists = () => true
|
||||
handlers.get_last_vault_path = () => resolvedVaultPath
|
||||
handlers.get_default_vault_path = () => resolvedVaultPath
|
||||
handlers.save_vault_list = () => null
|
||||
return handlers
|
||||
const readJson = async (url: string, init?: RequestInit) => {
|
||||
const response = await nativeFetch(url, init)
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = await response.json() as { error?: string }
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// Preserve the HTTP status fallback when the body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
let ref = applyFixtureVaultOverrides(
|
||||
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
|
||||
) ?? null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = applyFixtureVaultOverrides(
|
||||
value as Record<string, ((args?: unknown) => unknown)> | undefined,
|
||||
) ?? null
|
||||
},
|
||||
get() {
|
||||
return applyFixtureVaultOverrides(ref) ?? ref
|
||||
},
|
||||
})
|
||||
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => Boolean(window.__mockHandlers))
|
||||
await page.evaluate((resolvedVaultPath: string) => {
|
||||
const handlers = window.__mockHandlers
|
||||
if (!handlers) {
|
||||
throw new Error('Mock handlers unavailable for fixture vault override')
|
||||
}
|
||||
|
||||
handlers.load_vault_list = () => ({
|
||||
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
|
||||
active_vault: resolvedVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
handlers.check_vault_exists = () => true
|
||||
handlers.get_last_vault_path = () => resolvedVaultPath
|
||||
handlers.get_default_vault_path = () => resolvedVaultPath
|
||||
handlers.save_vault_list = () => null
|
||||
}, vaultPath)
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
|
||||
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
|
||||
timeout: FIXTURE_VAULT_READY_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser harness for desktop command-routing tests.
|
||||
*
|
||||
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
|
||||
* renderer shortcut dispatch and desktop menu-command dispatch without a native
|
||||
* shell. It is deterministic, but it is not a substitute for real native QA.
|
||||
*/
|
||||
export async function openFixtureVaultDesktopHarness(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
): Promise<void> {
|
||||
await openFixtureVault(page, vaultPath)
|
||||
await page.evaluate((resolvedVaultPath: string) => {
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' }
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
|
||||
const FRONTMATTER_OPEN = '---\n'
|
||||
const FRONTMATTER_CLOSE = '\n---\n'
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const splitFrontmatter = (content: string) => {
|
||||
if (!content.startsWith(FRONTMATTER_OPEN)) {
|
||||
return { frontmatter: null as string | null, body: content }
|
||||
@@ -206,16 +167,12 @@ export async function openFixtureVaultDesktopHarness(
|
||||
return `${FRONTMATTER_OPEN}${nextEntryLines.join('\n')}${FRONTMATTER_CLOSE}${body}`
|
||||
}
|
||||
|
||||
const entries = splitFrontmatterEntries(frontmatter).filter((entry) => entry.key !== '')
|
||||
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
|
||||
let replaced = false
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!keyPattern.test(entry.key)) return entry
|
||||
replaced = true
|
||||
return { key, lines: nextEntryLines }
|
||||
})
|
||||
const nextEntries = splitFrontmatterEntries(frontmatter)
|
||||
.filter((entry) => entry.key !== '')
|
||||
.map((entry) => (entry.key === key ? { key, lines: nextEntryLines } : entry))
|
||||
|
||||
if (!replaced) {
|
||||
const hasEntry = nextEntries.some((entry) => entry.key === key)
|
||||
if (!hasEntry) {
|
||||
nextEntries.push({ key, lines: nextEntryLines })
|
||||
}
|
||||
|
||||
@@ -226,9 +183,8 @@ export async function openFixtureVaultDesktopHarness(
|
||||
const { frontmatter, body } = splitFrontmatter(content)
|
||||
if (frontmatter === null) return content
|
||||
|
||||
const keyPattern = new RegExp(`^${escapeRegExp(key)}$`)
|
||||
const nextEntries = splitFrontmatterEntries(frontmatter)
|
||||
.filter((entry) => entry.key !== '' && !keyPattern.test(entry.key))
|
||||
.filter((entry) => entry.key !== '' && entry.key !== key)
|
||||
|
||||
if (nextEntries.length === 0) {
|
||||
return body
|
||||
@@ -237,32 +193,19 @@ export async function openFixtureVaultDesktopHarness(
|
||||
return `${FRONTMATTER_OPEN}${nextEntries.flatMap((entry) => entry.lines).join('\n')}${FRONTMATTER_CLOSE}${body}`
|
||||
}
|
||||
|
||||
const persistFrontmatterChange = async (path: string, transform: (content: string) => string) => {
|
||||
const current = await readJson(`/api/vault/content?path=${encodeURIComponent(path)}`) as { content: string }
|
||||
const persistFrontmatterChange = async (notePath: string, transform: (content: string) => string) => {
|
||||
const current = await readJson(
|
||||
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
|
||||
) as { content: string }
|
||||
const updatedContent = transform(current.content)
|
||||
await readJson('/api/vault/save', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ path, content: updatedContent }),
|
||||
body: JSON.stringify({ path: notePath, content: updatedContent }),
|
||||
})
|
||||
return updatedContent
|
||||
}
|
||||
|
||||
const readJson = async (url: string, init?: RequestInit) => {
|
||||
const response = await nativeFetch(url, init)
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = await response.json() as { error?: string }
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// Keep the HTTP status fallback when the body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const activeVaultList = {
|
||||
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
|
||||
active_vault: resolvedVaultPath,
|
||||
@@ -283,16 +226,16 @@ export async function openFixtureVaultDesktopHarness(
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const commandHandlers: Record<string, (commandArgs?: Record<string, unknown>) => Promise<unknown> | unknown> = {
|
||||
trigger_menu_command: (commandArgs) => {
|
||||
const commandId = String(commandArgs?.id ?? '')
|
||||
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
|
||||
if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
|
||||
bridge(commandId)
|
||||
return null
|
||||
},
|
||||
const readCommandValue = (commandArgs: FixtureCommandArgs, key: string, fallback?: unknown) =>
|
||||
commandArgs?.[key] ?? fallback
|
||||
|
||||
const readCommandString = (commandArgs: FixtureCommandArgs, key: string, fallback = '') =>
|
||||
String(readCommandValue(commandArgs, key, fallback))
|
||||
|
||||
const buildFixtureStateHandlers = () => ({
|
||||
load_vault_list: () => activeVaultList,
|
||||
check_vault_exists: () => true,
|
||||
check_vault_exists: (commandArgs?: FixtureCommandArgs) =>
|
||||
readCommandString(commandArgs, 'path') === resolvedVaultPath,
|
||||
is_git_repo: () => true,
|
||||
get_last_vault_path: () => resolvedVaultPath,
|
||||
get_default_vault_path: () => resolvedVaultPath,
|
||||
@@ -309,97 +252,164 @@ export async function openFixtureVaultDesktopHarness(
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}),
|
||||
list_vault: (commandArgs) => readVaultList(commandArgs),
|
||||
reload_vault: (commandArgs) => readVaultList(commandArgs, true),
|
||||
})
|
||||
|
||||
const buildFixtureReadHandlers = () => ({
|
||||
list_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs),
|
||||
reload_vault: (commandArgs?: FixtureCommandArgs) => readVaultList(commandArgs, true),
|
||||
list_vault_folders: () => [],
|
||||
list_views: () => [],
|
||||
get_modified_files: () => [],
|
||||
detect_renames: () => [],
|
||||
reload_vault_entry: (commandArgs) =>
|
||||
readJson(`/api/vault/entry?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`),
|
||||
get_note_content: async (commandArgs) => {
|
||||
reload_vault_entry: (commandArgs?: FixtureCommandArgs) =>
|
||||
readJson(`/api/vault/entry?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`),
|
||||
get_note_content: async (commandArgs?: FixtureCommandArgs) => {
|
||||
const data = await readJson(
|
||||
`/api/vault/content?path=${encodeURIComponent(String(commandArgs?.path ?? ''))}`,
|
||||
`/api/vault/content?path=${encodeURIComponent(readCommandString(commandArgs, 'path'))}`,
|
||||
) as { content: string }
|
||||
return data.content
|
||||
},
|
||||
get_all_content: (commandArgs) =>
|
||||
get_all_content: (commandArgs?: FixtureCommandArgs) =>
|
||||
readJson(
|
||||
`/api/vault/all-content?path=${encodeURIComponent(String(commandArgs?.path ?? resolvedVaultPath))}`,
|
||||
`/api/vault/all-content?path=${encodeURIComponent(readCommandString(commandArgs, 'path', resolvedVaultPath))}`,
|
||||
),
|
||||
save_note_content: (commandArgs) =>
|
||||
readJson('/api/vault/save', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ path: commandArgs?.path, content: commandArgs?.content }),
|
||||
}),
|
||||
update_frontmatter: (commandArgs) =>
|
||||
persistFrontmatterChange(
|
||||
String(commandArgs?.path ?? ''),
|
||||
(content) => replaceFrontmatterEntry(content, String(commandArgs?.key ?? ''), commandArgs?.value),
|
||||
),
|
||||
delete_frontmatter_property: (commandArgs) =>
|
||||
persistFrontmatterChange(
|
||||
String(commandArgs?.path ?? ''),
|
||||
(content) => removeFrontmatterEntry(content, String(commandArgs?.key ?? '')),
|
||||
),
|
||||
rename_note: (commandArgs) =>
|
||||
renameNoteRequest({
|
||||
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
|
||||
old_path: commandArgs?.oldPath,
|
||||
new_title: commandArgs?.newTitle,
|
||||
old_title: commandArgs?.oldTitle ?? null,
|
||||
}),
|
||||
rename_note_filename: (commandArgs) =>
|
||||
readJson('/api/vault/rename-filename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
|
||||
old_path: commandArgs?.oldPath,
|
||||
new_filename_stem: commandArgs?.newFilenameStem,
|
||||
}),
|
||||
}),
|
||||
search_vault: (commandArgs) => {
|
||||
const resolvedPath = String(commandArgs?.path ?? commandArgs?.vaultPath ?? resolvedVaultPath)
|
||||
const query = encodeURIComponent(String(commandArgs?.query ?? ''))
|
||||
const mode = encodeURIComponent(String(commandArgs?.mode ?? 'all'))
|
||||
search_vault: (commandArgs?: FixtureCommandArgs) => {
|
||||
const resolvedPath = readCommandString(
|
||||
commandArgs,
|
||||
'path',
|
||||
readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
|
||||
)
|
||||
const query = encodeURIComponent(readCommandString(commandArgs, 'query'))
|
||||
const mode = encodeURIComponent(readCommandString(commandArgs, 'mode', 'all'))
|
||||
return readJson(
|
||||
`/api/vault/search?vault_path=${encodeURIComponent(resolvedPath)}&query=${query}&mode=${mode}`,
|
||||
)
|
||||
},
|
||||
auto_rename_untitled: async (commandArgs) => {
|
||||
const notePath = String(commandArgs?.notePath ?? '')
|
||||
})
|
||||
|
||||
const buildFixtureWriteHandlers = () => ({
|
||||
save_note_content: (commandArgs?: FixtureCommandArgs) =>
|
||||
readJson('/api/vault/save', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
path: readCommandValue(commandArgs, 'path'),
|
||||
content: readCommandValue(commandArgs, 'content'),
|
||||
}),
|
||||
}),
|
||||
update_frontmatter: (commandArgs?: FixtureCommandArgs) =>
|
||||
persistFrontmatterChange(
|
||||
readCommandString(commandArgs, 'path'),
|
||||
(content) => replaceFrontmatterEntry(
|
||||
content,
|
||||
readCommandString(commandArgs, 'key'),
|
||||
readCommandValue(commandArgs, 'value'),
|
||||
),
|
||||
),
|
||||
delete_frontmatter_property: (commandArgs?: FixtureCommandArgs) =>
|
||||
persistFrontmatterChange(
|
||||
readCommandString(commandArgs, 'path'),
|
||||
(content) => removeFrontmatterEntry(content, readCommandString(commandArgs, 'key')),
|
||||
),
|
||||
rename_note: (commandArgs?: FixtureCommandArgs) =>
|
||||
renameNoteRequest({
|
||||
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
|
||||
old_path: readCommandValue(commandArgs, 'oldPath'),
|
||||
new_title: readCommandValue(commandArgs, 'newTitle'),
|
||||
old_title: readCommandValue(commandArgs, 'oldTitle', null),
|
||||
}),
|
||||
rename_note_filename: (commandArgs?: FixtureCommandArgs) =>
|
||||
readJson('/api/vault/rename-filename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
|
||||
old_path: readCommandValue(commandArgs, 'oldPath'),
|
||||
new_filename_stem: readCommandValue(commandArgs, 'newFilenameStem'),
|
||||
}),
|
||||
}),
|
||||
auto_rename_untitled: async (commandArgs?: FixtureCommandArgs) => {
|
||||
const notePath = readCommandString(commandArgs, 'notePath')
|
||||
const contentData = await readJson(
|
||||
`/api/vault/content?path=${encodeURIComponent(notePath)}`,
|
||||
) as { content: string }
|
||||
const match = contentData.content.match(/^#\s+(.+)$/m)
|
||||
if (!match) return null
|
||||
return renameNoteRequest({
|
||||
vault_path: commandArgs?.vaultPath ?? resolvedVaultPath,
|
||||
vault_path: readCommandValue(commandArgs, 'vaultPath', resolvedVaultPath),
|
||||
old_path: notePath,
|
||||
new_title: match[1].trim(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const applyFixtureVaultOverrides = (
|
||||
handlers: Record<string, ((args?: unknown) => unknown)> | null | undefined,
|
||||
) => {
|
||||
if (!handlers) return handlers
|
||||
Object.assign(
|
||||
handlers,
|
||||
buildFixtureStateHandlers(),
|
||||
buildFixtureReadHandlers(),
|
||||
buildFixtureWriteHandlers(),
|
||||
)
|
||||
return handlers
|
||||
}
|
||||
|
||||
const invoke = async (command: string, args?: Record<string, unknown>) => {
|
||||
const handler = commandHandlers[command] ?? window.__mockHandlers?.[command]
|
||||
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
|
||||
return handler(args)
|
||||
}
|
||||
let ref = applyFixtureVaultOverrides(
|
||||
(window.__mockHandlers as Record<string, ((args?: unknown) => unknown)> | undefined),
|
||||
) ?? null
|
||||
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
set(value) {
|
||||
ref = applyFixtureVaultOverrides(
|
||||
value as Record<string, ((args?: unknown) => unknown)> | undefined,
|
||||
) ?? null
|
||||
},
|
||||
get() {
|
||||
return applyFixtureVaultOverrides(ref) ?? ref
|
||||
},
|
||||
})
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
})
|
||||
}, vaultPath)
|
||||
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, resolvedVaultPath: vaultPath })
|
||||
}
|
||||
|
||||
async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise<void> {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault))
|
||||
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
|
||||
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
|
||||
timeout: FIXTURE_VAULT_READY_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openFixtureVault(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
): Promise<void> {
|
||||
await installFixtureVaultInitScript({ page, vaultPath })
|
||||
await waitForFixtureVaultReady({ page })
|
||||
}
|
||||
|
||||
async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise<void> {
|
||||
await page.evaluate(installFixtureVaultDesktopBridgeInBrowser)
|
||||
|
||||
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser harness for desktop command-routing tests.
|
||||
*
|
||||
* This stubs the Tauri invoke bridge inside Playwright so tests can exercise
|
||||
* renderer shortcut dispatch and desktop menu-command dispatch without a native
|
||||
* shell. It is deterministic, but it is not a substitute for real native QA.
|
||||
*/
|
||||
export async function openFixtureVaultDesktopHarness(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
): Promise<void> {
|
||||
await openFixtureVault(page, vaultPath)
|
||||
await installFixtureVaultDesktopBridge({ page })
|
||||
}
|
||||
|
||||
export const openFixtureVaultTauri = openFixtureVaultDesktopHarness
|
||||
|
||||
27
tests/helpers/fixtureVaultDesktopBridge.ts
Normal file
27
tests/helpers/fixtureVaultDesktopBridge.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function installFixtureVaultDesktopBridgeInBrowser(): void {
|
||||
const dispatchBrowserMenuCommand =
|
||||
window.__laputaTest?.dispatchBrowserMenuCommand
|
||||
?? (() => { throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand') })
|
||||
|
||||
const specialHandlers: Record<string, (args?: Record<string, unknown>) => unknown> = {
|
||||
trigger_menu_command: (args) => {
|
||||
dispatchBrowserMenuCommand(String(args?.id ?? ''))
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const invoke = (command: string, args?: Record<string, unknown>) => {
|
||||
const handler = specialHandlers[command] ?? window.__mockHandlers?.[command]
|
||||
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
|
||||
return handler(args)
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
})
|
||||
}
|
||||
@@ -57,7 +57,7 @@ async function setRawEditorContent(page: Page, content: string) {
|
||||
}, content)
|
||||
}
|
||||
|
||||
test('breadcrumb sync button and inline rename keep the file path aligned with H1 title changes', async ({ page }) => {
|
||||
test('@smoke Cmd+S preserves the existing filename until the explicit breadcrumb sync action', async ({ page }) => {
|
||||
const syncedPath = path.join(tempVaultDir, 'note', 'breadcrumb-sync-target.md')
|
||||
const manuallyRenamedPath = path.join(tempVaultDir, 'note', 'manual-breadcrumb-name.md')
|
||||
const originalPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
|
||||
81
tests/smoke/fresh-start-telemetry-onboarding.spec.ts
Normal file
81
tests/smoke/fresh-start-telemetry-onboarding.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const REMEMBERED_DEFAULT_VAULT_PATH = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
|
||||
|
||||
async function mockFreshStart(
|
||||
page: Page,
|
||||
options: {
|
||||
activeVault: string | null
|
||||
checkExistingPath: string
|
||||
rememberWelcomeDismissal?: boolean
|
||||
},
|
||||
) {
|
||||
await page.addInitScript((config) => {
|
||||
localStorage.clear()
|
||||
if (config.rememberWelcomeDismissal) {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
}
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
|
||||
const originalGetSettings = ref.get_settings as (() => Record<string, unknown>) | undefined
|
||||
ref.get_settings = () => ({
|
||||
...(originalGetSettings ? originalGetSettings() : {}),
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
})
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [],
|
||||
active_vault: config.activeVault,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => config.checkExistingPath
|
||||
ref.check_vault_exists = (args: { path?: string }) => args?.path === config.checkExistingPath
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
}, options)
|
||||
}
|
||||
|
||||
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
|
||||
await mockFreshStart(page, {
|
||||
activeVault: null,
|
||||
checkExistingPath: '/Users/mock/Documents/Getting Started',
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
|
||||
await page.getByTestId('telemetry-accept').click()
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).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, {
|
||||
activeVault: REMEMBERED_DEFAULT_VAULT_PATH,
|
||||
checkExistingPath: REMEMBERED_DEFAULT_VAULT_PATH,
|
||||
rememberWelcomeDismissal: true,
|
||||
})
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByText('Help improve Tolaria')).toBeVisible()
|
||||
await page.getByTestId(`telemetry-${action}`).click()
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -65,6 +65,13 @@ function quickOpenSelectedTitle(page: Page) {
|
||||
return page.getByTestId('quick-open-palette').locator('[class*="bg-accent"] span.truncate').first()
|
||||
}
|
||||
|
||||
async function focusHeadingEnd(page: Page, title: string) {
|
||||
const heading = page.getByRole('heading', { name: title, level: 1 })
|
||||
await expect(heading).toBeVisible({ timeout: 5_000 })
|
||||
await heading.click()
|
||||
await page.keyboard.press('End')
|
||||
}
|
||||
|
||||
test('creating an untitled draft hides the legacy title section in the editor', async ({ page }) => {
|
||||
await page.locator('button[title="Create new note"]').click()
|
||||
|
||||
@@ -139,3 +146,29 @@ test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete
|
||||
const suggestionMenu = page.locator('.wikilink-menu')
|
||||
await expect(suggestionMenu).toContainText(updatedTitle, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('@smoke rapid H1 typing stays stable while editing an existing note', async ({ page }) => {
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
const firstTitle = 'Alpha Project Fast Typing Check'
|
||||
const finalTitle = 'Alpha Project Fast Typing Flow'
|
||||
|
||||
await openNote(page, 'Alpha Project')
|
||||
await focusHeadingEnd(page, 'Alpha Project')
|
||||
await page.keyboard.type(' Fast Typing Check')
|
||||
|
||||
await expect(page.getByRole('heading', { name: firstTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
for (let i = 0; i < ' Check'.length; i += 1) {
|
||||
await page.keyboard.press('Backspace')
|
||||
}
|
||||
await page.keyboard.type(' Flow')
|
||||
await page.keyboard.press('Meta+s')
|
||||
|
||||
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
await expect(noteList.getByText(finalTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
|
||||
await expect(noteList.getByText('Alpha Project', { exact: true })).toHaveCount(0)
|
||||
|
||||
await openNote(page, 'Spring 2026')
|
||||
await openNote(page, finalTitle)
|
||||
await expect(page.getByRole('heading', { name: finalTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
142
tests/smoke/image-toolbar-hover.spec.ts
Normal file
142
tests/smoke/image-toolbar-hover.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVault,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
const IMAGE_DATA_URL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yK9sAAAAASUVORK5CYII='
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openBlockNoteMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function getRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return ''
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) return el.textContent ?? ''
|
||||
|
||||
return view.state.doc.toString() as string
|
||||
})
|
||||
}
|
||||
|
||||
async function setRawEditorContent(page: Page, content: string) {
|
||||
await page.evaluate((nextContent) => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) return
|
||||
|
||||
const fullDocumentRange = { from: 0, to: view.state.doc.length }
|
||||
view.dispatch({
|
||||
changes: { ...fullDocumentRange, insert: nextContent },
|
||||
})
|
||||
}, content)
|
||||
}
|
||||
|
||||
async function seedImageBlock(page: Page) {
|
||||
await openNote(page, 'Note B')
|
||||
await openRawMode(page)
|
||||
|
||||
const rawContent = await getRawEditorContent(page)
|
||||
const imageMarkdown = `\n\n\n`
|
||||
await setRawEditorContent(page, `${rawContent}${imageMarkdown}`)
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
await openBlockNoteMode(page)
|
||||
|
||||
const image = page.locator('.bn-editor img.bn-visual-media').last()
|
||||
await expect(image).toBeVisible({ timeout: 5_000 })
|
||||
return image
|
||||
}
|
||||
|
||||
async function moveMouseInSteps(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
{ steps, stepDelayMs }: { steps: number; stepDelayMs: number },
|
||||
) {
|
||||
await page.mouse.move(from.x, from.y)
|
||||
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const progress = step / steps
|
||||
await page.mouse.move(
|
||||
from.x + (to.x - from.x) * progress,
|
||||
from.y + (to.y - from.y) * progress,
|
||||
)
|
||||
await page.waitForTimeout(stepDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(90_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('image toolbar stays usable while the pointer crosses onto its controls', async ({ page }) => {
|
||||
const image = await seedImageBlock(page)
|
||||
|
||||
const toolbar = page.locator('.bn-formatting-toolbar')
|
||||
const replaceButton = page.getByRole('button', { name: /Replace image/i })
|
||||
|
||||
const imageBox = await image.boundingBox()
|
||||
expect(imageBox).not.toBeNull()
|
||||
|
||||
await page.mouse.move(
|
||||
imageBox!.x + imageBox!.width / 2,
|
||||
imageBox!.y + imageBox!.height / 2,
|
||||
)
|
||||
|
||||
await expect(toolbar).toBeVisible({ timeout: 5_000 })
|
||||
await expect(replaceButton).toBeVisible()
|
||||
|
||||
const replaceButtonBox = await replaceButton.boundingBox()
|
||||
expect(replaceButtonBox).not.toBeNull()
|
||||
|
||||
await moveMouseInSteps(
|
||||
page,
|
||||
{
|
||||
x: imageBox!.x + imageBox!.width / 2,
|
||||
y: imageBox!.y + imageBox!.height / 2,
|
||||
},
|
||||
{
|
||||
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
|
||||
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
|
||||
},
|
||||
{ steps: 12, stepDelayMs: 35 },
|
||||
)
|
||||
|
||||
await expect(toolbar).toBeVisible()
|
||||
await expect(replaceButton).toBeVisible()
|
||||
|
||||
await replaceButton.click()
|
||||
await expect(page.locator('.bn-panel-popover')).toBeVisible()
|
||||
})
|
||||
168
tests/smoke/manual-commit-modal-autogit-off.spec.ts
Normal file
168
tests/smoke/manual-commit-modal-autogit-off.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
import { seedAutoGitSavedChange } from './testBridge'
|
||||
|
||||
type MockHandler = (args?: Record<string, unknown>) => unknown
|
||||
|
||||
function installCommitFlowMocks() {
|
||||
type BrowserWindow = Window & typeof globalThis & {
|
||||
__gitCommitMessages?: string[]
|
||||
__gitPushCalls?: number
|
||||
__mockHandlers?: Record<string, MockHandler>
|
||||
}
|
||||
|
||||
const browserWindow = window as BrowserWindow
|
||||
const dirtyPaths = new Set<string>()
|
||||
let ahead = 0
|
||||
|
||||
const createModifiedFiles = () => [...dirtyPaths].map((path) => ({
|
||||
path,
|
||||
relativePath: path.split('/').pop() ?? path,
|
||||
status: 'modified',
|
||||
}))
|
||||
|
||||
const isPatched = (handlers?: Record<string, MockHandler> | null) =>
|
||||
!handlers || (handlers as Record<string, unknown>).__manualCommitPatched === true
|
||||
|
||||
const patchSaveNoteContent = (handlers: Record<string, MockHandler>) => {
|
||||
const originalSaveNoteContent = handlers.save_note_content
|
||||
handlers.save_note_content = (args?: Record<string, unknown>) => {
|
||||
const path = typeof args?.path === 'string' ? args.path : null
|
||||
if (path) dirtyPaths.add(path)
|
||||
return originalSaveNoteContent?.(args)
|
||||
}
|
||||
}
|
||||
|
||||
const patchGitHandlers = (handlers: Record<string, MockHandler>) => {
|
||||
handlers.get_modified_files = () => createModifiedFiles()
|
||||
handlers.git_commit = (args?: Record<string, unknown>) => {
|
||||
const message = typeof args?.message === 'string' ? args.message : ''
|
||||
browserWindow.__gitCommitMessages?.push(message)
|
||||
dirtyPaths.clear()
|
||||
ahead = 1
|
||||
return `[main abc1234] ${message}`
|
||||
}
|
||||
|
||||
handlers.git_push = () => {
|
||||
browserWindow.__gitPushCalls = (browserWindow.__gitPushCalls ?? 0) + 1
|
||||
ahead = 0
|
||||
return { status: 'ok', message: 'Pushed to remote' }
|
||||
}
|
||||
|
||||
handlers.git_remote_status = () => ({
|
||||
branch: 'main',
|
||||
ahead,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
}
|
||||
|
||||
const markPatched = (handlers: Record<string, MockHandler>) => {
|
||||
Object.defineProperty(handlers, '__manualCommitPatched', {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: true,
|
||||
})
|
||||
}
|
||||
|
||||
const patchHandlers = (handlers?: Record<string, MockHandler> | null) => {
|
||||
if (isPatched(handlers)) {
|
||||
return handlers ?? null
|
||||
}
|
||||
|
||||
patchSaveNoteContent(handlers)
|
||||
patchGitHandlers(handlers)
|
||||
markPatched(handlers)
|
||||
return handlers
|
||||
}
|
||||
|
||||
browserWindow.__gitCommitMessages = []
|
||||
browserWindow.__gitPushCalls = 0
|
||||
|
||||
let ref = patchHandlers(browserWindow.__mockHandlers) ?? null
|
||||
Object.defineProperty(browserWindow, '__mockHandlers', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return patchHandlers(ref) ?? ref
|
||||
},
|
||||
set(value) {
|
||||
ref = patchHandlers(value as Record<string, MockHandler> | undefined) ?? null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function openFirstNote(page: Page) {
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function setAutoGitEnabled(page: Page, enabled: boolean) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Open Settings')
|
||||
|
||||
const settingsPanel = page.getByTestId('settings-panel')
|
||||
await expect(settingsPanel).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const toggle = page.getByRole('switch', { name: 'AutoGit' })
|
||||
const isEnabled = (await toggle.getAttribute('aria-checked')) === 'true'
|
||||
if (isEnabled !== enabled) {
|
||||
await toggle.click()
|
||||
}
|
||||
|
||||
await page.getByTestId('settings-save').click()
|
||||
await expect(settingsPanel).not.toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function triggerCommitButton(page: Page) {
|
||||
const commitButton = page.getByTestId('status-commit-push')
|
||||
await commitButton.focus()
|
||||
await expect(commitButton).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function expectCommitMessage(page: Page, index: number, expectedMessage: string) {
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(
|
||||
({ targetIndex }) => (window as Window & { __gitCommitMessages?: string[] }).__gitCommitMessages?.[targetIndex] ?? '',
|
||||
{ targetIndex: index },
|
||||
),
|
||||
).toBe(expectedMessage)
|
||||
}
|
||||
|
||||
async function expectPushCount(page: Page, expectedCount: number) {
|
||||
await expect.poll(async () =>
|
||||
page.evaluate(() => (window as Window & { __gitPushCalls?: number }).__gitPushCalls ?? 0),
|
||||
).toBe(expectedCount)
|
||||
}
|
||||
|
||||
test('@smoke commit entry opens the manual modal when AutoGit is off and switches back immediately when enabled', async ({ page }) => {
|
||||
await page.addInitScript(installCommitFlowMocks)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await openFirstNote(page)
|
||||
await setAutoGitEnabled(page, false)
|
||||
await seedAutoGitSavedChange(page)
|
||||
await triggerCommitButton(page)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Commit & Push' })).toBeVisible()
|
||||
const messageInput = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await expect(messageInput).toBeFocused()
|
||||
await page.keyboard.press('Meta+A')
|
||||
await page.keyboard.type('Manual commit from keyboard')
|
||||
await page.keyboard.press('Meta+Enter')
|
||||
|
||||
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 })
|
||||
await expectCommitMessage(page, 0, 'Manual commit from keyboard')
|
||||
await expectPushCount(page, 1)
|
||||
|
||||
await setAutoGitEnabled(page, true)
|
||||
await seedAutoGitSavedChange(page)
|
||||
await triggerCommitButton(page)
|
||||
|
||||
await expect(messageInput).not.toBeVisible()
|
||||
await expectCommitMessage(page, 1, 'Updated 1 note')
|
||||
await expectPushCount(page, 2)
|
||||
})
|
||||
108
tests/smoke/plain-filename-autolink.spec.ts
Normal file
108
tests/smoke/plain-filename-autolink.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVault,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function openBlockNoteMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function getRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return ''
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) return el.textContent ?? ''
|
||||
|
||||
return view.state.doc.toString() as string
|
||||
})
|
||||
}
|
||||
|
||||
async function setRawEditorContent(page: Page, content: string) {
|
||||
await page.evaluate((nextContent) => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) return
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: nextContent,
|
||||
},
|
||||
})
|
||||
}, content)
|
||||
}
|
||||
|
||||
async function appendPlainFilenameParagraph(page: Page, value: string) {
|
||||
const lastBlock = page.locator('.bn-block-content').last()
|
||||
await expect(lastBlock).toBeVisible({ timeout: 5_000 })
|
||||
await lastBlock.click()
|
||||
await page.keyboard.press('End')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.keyboard.type(`${value} `)
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(90_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('plain filename-like text typed in BlockNote stays plain through raw-mode round trips', async ({ page }) => {
|
||||
await openNote(page, 'Note B')
|
||||
await appendPlainFilenameParagraph(page, 'AGENTS.md')
|
||||
await appendPlainFilenameParagraph(page, 'docs/README.md')
|
||||
|
||||
await openRawMode(page)
|
||||
|
||||
const raw = await getRawEditorContent(page)
|
||||
expect(raw).toContain('AGENTS.md')
|
||||
expect(raw).toContain('docs/README.md')
|
||||
expect(raw).not.toMatch(/\[AGENTS\.md\]\(/)
|
||||
expect(raw).not.toMatch(/\[docs\/README\.md\]\(/)
|
||||
})
|
||||
|
||||
test('explicit markdown links still render and round-trip intentionally', async ({ page }) => {
|
||||
await openNote(page, 'Note B')
|
||||
await openRawMode(page)
|
||||
|
||||
const raw = await getRawEditorContent(page)
|
||||
const intentionalLink = '[Tolaria Docs](https://example.com/docs)'
|
||||
await setRawEditorContent(page, `${raw}\n\n${intentionalLink}\n`)
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
await openBlockNoteMode(page)
|
||||
await expect(page.locator('.bn-editor a[href="https://example.com/docs"]').last()).toContainText('Tolaria Docs')
|
||||
|
||||
await openRawMode(page)
|
||||
await expect.poll(() => getRawEditorContent(page)).toContain(intentionalLink)
|
||||
})
|
||||
184
tests/smoke/raw-yml-view-save.spec.ts
Normal file
184
tests/smoke/raw-yml-view-save.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { triggerShortcutCommand } from './testBridge'
|
||||
|
||||
const VIEW_RELATIVE_PATH = path.join('views', 'active-projects.yml')
|
||||
const DUPLICATE_RELATIVE_PATH = path.join('views', 'active-projects.md')
|
||||
const VIEW_TITLE = 'Active Projects'
|
||||
const VIEW_CONTENT = `name: Active Projects
|
||||
icon: rocket
|
||||
color: blue
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
`
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function seedViewFile(vaultPath: string): void {
|
||||
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
|
||||
fs.mkdirSync(path.dirname(viewPath), { recursive: true })
|
||||
fs.writeFileSync(viewPath, VIEW_CONTENT)
|
||||
}
|
||||
|
||||
function buildViewEntry(vaultPath: string) {
|
||||
const viewPath = path.join(vaultPath, VIEW_RELATIVE_PATH)
|
||||
return {
|
||||
path: viewPath,
|
||||
filename: path.basename(viewPath),
|
||||
title: VIEW_TITLE,
|
||||
isA: null,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: Date.now(),
|
||||
createdAt: null,
|
||||
fileSize: Buffer.byteLength(VIEW_CONTENT),
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: false,
|
||||
fileKind: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
async function injectViewEntryIntoVaultList(page: Page, vaultPath: string): Promise<void> {
|
||||
const viewEntry = buildViewEntry(vaultPath)
|
||||
await page.route('**/api/vault/list*', async (route) => {
|
||||
const response = await route.fetch()
|
||||
const entries = await response.json()
|
||||
if (!Array.isArray(entries)) {
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
|
||||
const hasViewEntry = entries.some((entry) => entry?.path === viewEntry.path)
|
||||
const nextEntries = hasViewEntry ? entries : [...entries, viewEntry]
|
||||
await route.fulfill({ response, json: nextEntries })
|
||||
})
|
||||
}
|
||||
|
||||
async function openQuickOpenEntry(page: Page, title: string): Promise<void> {
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileQuickOpen)
|
||||
const input = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(input).toBeVisible({ timeout: 5_000 })
|
||||
await input.fill(title)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function appendRawEditorLine(page: Page, line: string): Promise<void> {
|
||||
await page.evaluate((nextLine) => {
|
||||
const host = document.querySelector('.cm-content')
|
||||
if (!host) {
|
||||
throw new Error('CodeMirror content element is missing')
|
||||
}
|
||||
|
||||
type CodeMirrorHost = Element & {
|
||||
cmTile?: {
|
||||
view?: {
|
||||
state: { doc: { toString(): string; length: number } }
|
||||
dispatch(transaction: { changes: { from: number; to: number; insert: string } }): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const view = (host as CodeMirrorHost).cmTile?.view
|
||||
if (!view) {
|
||||
throw new Error('CodeMirror view is missing')
|
||||
}
|
||||
|
||||
const doc = view.state.doc.toString()
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: doc.length,
|
||||
to: doc.length,
|
||||
insert: `\n${nextLine}\n`,
|
||||
},
|
||||
})
|
||||
}, line)
|
||||
}
|
||||
|
||||
async function readRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('.cm-content')
|
||||
if (!host) return ''
|
||||
|
||||
type CodeMirrorHost = Element & {
|
||||
cmTile?: {
|
||||
view?: {
|
||||
state: { doc: { toString(): string } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (host as CodeMirrorHost).cmTile?.view?.state.doc.toString() ?? host.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('raw .yml view save regression', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
seedViewFile(tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('saving a raw-edited .yml view preserves the .yml file and blocks the raw toggle shortcut @smoke', async ({ page }) => {
|
||||
const viewPath = path.join(tempVaultDir, VIEW_RELATIVE_PATH)
|
||||
const duplicatePath = path.join(tempVaultDir, DUPLICATE_RELATIVE_PATH)
|
||||
const marker = `# raw-yml-save-${Date.now()}-${os.userInfo().username}`
|
||||
|
||||
await injectViewEntryIntoVaultList(page, tempVaultDir)
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await openQuickOpenEntry(page, VIEW_TITLE)
|
||||
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor')).toHaveCount(0)
|
||||
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.editToggleRawEditor)
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor')).toHaveCount(0)
|
||||
|
||||
await appendRawEditorLine(page, marker)
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.fileSave)
|
||||
|
||||
await expect.poll(() => fs.readFileSync(viewPath, 'utf-8')).toContain(marker)
|
||||
await expect.poll(() => fs.existsSync(duplicatePath)).toBe(false)
|
||||
|
||||
await openQuickOpenEntry(page, 'Alpha Project')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await openQuickOpenEntry(page, VIEW_TITLE)
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(async () => readRawEditorContent(page)).toContain(marker)
|
||||
})
|
||||
})
|
||||
83
tests/smoke/save-before-note-switch.spec.ts
Normal file
83
tests/smoke/save-before-note-switch.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.getByText(title, { exact: true }).click()
|
||||
}
|
||||
|
||||
async function openRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function getRawEditorContent(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) return ''
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (view) return view.state.doc.toString() as string
|
||||
return el.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
async function setRawEditorContent(page: Page, content: string) {
|
||||
await page.evaluate((nextContent) => {
|
||||
const el = document.querySelector('.cm-content')
|
||||
if (!el) {
|
||||
throw new Error('CodeMirror content element is missing')
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (el as any).cmTile?.view
|
||||
if (!view) {
|
||||
throw new Error('CodeMirror view is missing')
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: nextContent },
|
||||
})
|
||||
}, content)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('@smoke switching notes persists unsaved raw edits without waiting for the debounce window', async ({ page }) => {
|
||||
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
const appendedText = `Flushed before note switch ${Date.now()}`
|
||||
|
||||
await openNote(page, 'Note B')
|
||||
await openRawMode(page)
|
||||
|
||||
const rawContent = await getRawEditorContent(page)
|
||||
await setRawEditorContent(page, `${rawContent}\n\n${appendedText}`)
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText('alpha-project', { timeout: 5_000 })
|
||||
await expect.poll(async () => getRawEditorContent(page)).toContain('# Alpha Project')
|
||||
await expect.poll(
|
||||
() => fs.readFileSync(noteBPath, 'utf8'),
|
||||
{ timeout: 450, intervals: [50, 100, 100, 100, 100] },
|
||||
).toContain(appendedText)
|
||||
})
|
||||
@@ -56,6 +56,7 @@ test.describe('Wikilink insertion and navigation', () => {
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user