Compare commits
26 Commits
v0.2026031
...
v0.2026032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8954a6f7 | ||
|
|
99f5716508 | ||
|
|
b8f29c9530 | ||
|
|
33ae00a558 | ||
|
|
ac02de88e6 | ||
|
|
fb8208cfa0 | ||
|
|
66e29b70b8 | ||
|
|
d1b358f76a | ||
|
|
c2ce67c300 | ||
|
|
07522e984c | ||
|
|
36f43c1ae0 | ||
|
|
1b478d0fc1 | ||
|
|
8646be6b8d | ||
|
|
fd9b4fe5e7 | ||
|
|
d52365882c | ||
|
|
135fe62d21 | ||
|
|
99aee0f67b | ||
|
|
a369b3d93e | ||
|
|
4e88cf71b1 | ||
|
|
35bbe221b8 | ||
|
|
05dca72ef3 | ||
|
|
57a66e4788 | ||
|
|
7b0b31455b | ||
|
|
7c16ebd065 | ||
|
|
5df4a7a3ad | ||
|
|
ca41008850 |
@@ -342,6 +342,10 @@ interface PulseCommit {
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
@@ -350,6 +354,9 @@ interface PulseCommit {
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
|
||||
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
|
||||
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
|
||||
|
||||
## BlockNote Customization
|
||||
|
||||
@@ -532,7 +539,7 @@ Managed by `useIndexing` hook:
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
Per-vault settings stored in `ui.config.md` at vault root:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
|
||||
@@ -164,6 +164,24 @@ flowchart TD
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
|
||||
**Triggers:**
|
||||
- `Cmd+Shift+Click` on any note in the note list or sidebar
|
||||
- `Cmd+K` → "Open in New Window" (command palette, requires active note)
|
||||
- `Cmd+Shift+O` keyboard shortcut
|
||||
- Note → "Open in New Window" menu bar item
|
||||
|
||||
**Architecture:**
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
## AI System
|
||||
|
||||
Laputa has two AI interfaces with distinct architectures:
|
||||
@@ -448,7 +466,7 @@ Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
|
||||
Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note):
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across tab switches and sessions)
|
||||
@@ -547,17 +565,34 @@ flowchart LR
|
||||
flowchart TD
|
||||
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
|
||||
PULL --> PC{Result?}
|
||||
PC -->|Conflicts| CM["ConflictResolverModal"]
|
||||
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
AS --> PUSH["invoke('git_push')"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
|
||||
GC --> GP["invoke('git_push')"]
|
||||
GP --> RM["Reload modified files"]
|
||||
GP --> PR{Push result?}
|
||||
PR -->|ok| RM["Reload modified files"]
|
||||
PR -->|rejected| DIV["syncStatus = pull_required"]
|
||||
DIV -->|User clicks badge| PAP["pullAndPush()"]
|
||||
PAP --> PULL2["invoke('git_pull')"]
|
||||
PULL2 --> GP2["invoke('git_push')"]
|
||||
GP2 --> RM
|
||||
|
||||
CMD["Cmd+K → Pull\nor Menu → Pull"] --> PULL
|
||||
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
|
||||
```
|
||||
|
||||
#### Sync States
|
||||
|
||||
| State | Indicator | Color | Trigger |
|
||||
|-------|-----------|-------|---------|
|
||||
| `idle` | Synced / Synced Xm ago | green | Successful sync |
|
||||
| `syncing` | Syncing... | blue | Pull/push in progress |
|
||||
| `pull_required` | Pull required | orange | Push rejected (divergence) |
|
||||
| `conflict` | Conflict | orange | Merge conflicts detected |
|
||||
| `error` | Sync failed | grey | Network/auth error |
|
||||
|
||||
## Vault Module Structure
|
||||
|
||||
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
@@ -631,6 +666,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `git_commit` | Stage all + commit |
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
| `git_remote_status` | Get branch name + ahead/behind counts |
|
||||
| `git_resolve_conflict` | Resolve a merge conflict |
|
||||
| `git_commit_conflict_resolution` | Commit conflict resolution |
|
||||
| `get_file_history` | Last N commits for a file |
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"note-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
|
||||
@@ -6,7 +6,8 @@ use crate::claude_cli::{
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{
|
||||
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
|
||||
GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile,
|
||||
PulseCommit,
|
||||
};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
@@ -154,10 +155,14 @@ pub fn get_default_vault_path() -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
vault::invalidate_cache(std::path::Path::new(&path));
|
||||
vault::scan_vault_cached(std::path::Path::new(&path))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -292,9 +297,11 @@ pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_pull(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -326,9 +333,19 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_push(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_remote_status(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
@@ -571,6 +588,8 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
// Migrate legacy theme/ directory to root, then repair themes
|
||||
theme::migrate_theme_dir_to_root(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(&vault_path);
|
||||
// Repair config files (AGENTS.md at root, config.md type def)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
// Ensure .gitignore with sensible defaults exists
|
||||
@@ -787,7 +806,9 @@ mod tests {
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
|
||||
|
||||
// reload_vault must return the updated trashed state
|
||||
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
let vault_str = vault.to_str().unwrap();
|
||||
vault::invalidate_cache(std::path::Path::new(vault_str));
|
||||
let fresh = vault::scan_vault_cached(std::path::Path::new(vault_str)).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
|
||||
@@ -15,7 +15,10 @@ pub use conflict::{
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
|
||||
pub use remote::{
|
||||
git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult,
|
||||
GitRemoteStatus,
|
||||
};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -110,6 +110,75 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitRemoteStatus {
|
||||
pub branch: String,
|
||||
pub ahead: u32,
|
||||
pub behind: u32,
|
||||
#[serde(rename = "hasRemote")]
|
||||
pub has_remote: bool,
|
||||
}
|
||||
|
||||
/// Get the current branch name, and how many commits ahead/behind the upstream.
|
||||
pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
let branch = current_branch(vault)?;
|
||||
return Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = Command::new("git")
|
||||
.args(["fetch", "--quiet"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rev-list: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// No upstream set — report 0/0
|
||||
return Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
has_remote: true,
|
||||
});
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = stdout.trim().split('\t').collect();
|
||||
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
|
||||
Ok(GitRemoteStatus {
|
||||
branch,
|
||||
ahead,
|
||||
behind,
|
||||
has_remote: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to get branch: {}", e))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
@@ -391,6 +460,90 @@ hint: have locally."#;
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let status = git_remote_status(vp).unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_up_to_date() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert!(status.has_remote);
|
||||
assert_eq!(status.ahead, 0);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_ahead() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// Make a new commit without pushing
|
||||
fs::write(clone_a.path().join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(vp_a, "update").unwrap();
|
||||
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.behind, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_behind() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(clone_b.path().join("note.md"), "# B update\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// A is now behind by 1
|
||||
let status = git_remote_status(vp_a).unwrap();
|
||||
assert_eq!(status.behind, 1);
|
||||
assert_eq!(status.ahead, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_remote_status_serialization() {
|
||||
let status = GitRemoteStatus {
|
||||
branch: "main".to_string(),
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
has_remote: true,
|
||||
};
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
assert!(json.contains("\"hasRemote\""));
|
||||
let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.branch, "main");
|
||||
assert_eq!(parsed.ahead, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
|
||||
@@ -44,6 +44,8 @@ fn run_startup_tasks() {
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
// Migrate legacy config/ui.config.md → root ui.config.md
|
||||
vault_config::migrate_ui_config_to_root(vp_str);
|
||||
log_startup_result(
|
||||
"Migrated hidden_sections to visible property",
|
||||
vault_config::migrate_hidden_sections_to_visible(vp_str),
|
||||
@@ -130,6 +132,7 @@ pub fn run() {
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::git_remote_status,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
|
||||
@@ -41,6 +41,7 @@ const GO_INBOX: &str = "go-inbox";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
const VAULT_REMOVE: &str = "vault-remove";
|
||||
@@ -48,6 +49,7 @@ const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
|
||||
const VAULT_NEW_THEME: &str = "vault-new-theme";
|
||||
const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes";
|
||||
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
|
||||
const VAULT_PULL: &str = "vault-pull";
|
||||
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
@@ -87,12 +89,14 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
VAULT_NEW_THEME,
|
||||
VAULT_RESTORE_DEFAULT_THEMES,
|
||||
VAULT_COMMIT_PUSH,
|
||||
VAULT_PULL,
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
@@ -110,6 +114,7 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_TOGGLE_BACKLINKS,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on having uncommitted changes.
|
||||
@@ -302,6 +307,10 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let open_new_window = MenuItemBuilder::new("Open in New Window")
|
||||
.id(NOTE_OPEN_IN_NEW_WINDOW)
|
||||
.accelerator("CmdOrCtrl+Shift+O")
|
||||
.build(app)?;
|
||||
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
|
||||
.id(EDIT_TOGGLE_RAW_EDITOR)
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -319,6 +328,8 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.separator()
|
||||
.item(&open_new_window)
|
||||
.separator()
|
||||
.item(&toggle_raw_editor)
|
||||
.item(&toggle_ai_chat)
|
||||
.item(&toggle_backlinks)
|
||||
@@ -344,6 +355,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let commit_push = MenuItemBuilder::new("Commit & Push")
|
||||
.id(VAULT_COMMIT_PUSH)
|
||||
.build(app)?;
|
||||
let pull = MenuItemBuilder::new("Pull from Remote")
|
||||
.id(VAULT_PULL)
|
||||
.build(app)?;
|
||||
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
|
||||
.id(VAULT_RESOLVE_CONFLICTS)
|
||||
.enabled(false)
|
||||
@@ -373,6 +387,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&restore_default_themes)
|
||||
.separator()
|
||||
.item(&commit_push)
|
||||
.item(&pull)
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
@@ -488,12 +503,14 @@ mod tests {
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
VAULT_NEW_THEME,
|
||||
VAULT_RESTORE_DEFAULT_THEMES,
|
||||
VAULT_COMMIT_PUSH,
|
||||
VAULT_PULL,
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
|
||||
@@ -486,6 +486,79 @@ References:
|
||||
);
|
||||
}
|
||||
|
||||
// --- large relationship array (regression: No Code note with 32 Notes) ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_large_notes_relationship_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Topic
|
||||
Referred by Data:
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
Belongs to:
|
||||
- "[[engineering|Engineering]]"
|
||||
aliases:
|
||||
- No Code
|
||||
Notes:
|
||||
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
|
||||
- "[[airdev-build-hub|Airdev Build Hub]]"
|
||||
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
|
||||
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
|
||||
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
|
||||
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
|
||||
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
|
||||
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]"
|
||||
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
|
||||
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
|
||||
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
|
||||
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
|
||||
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
|
||||
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
|
||||
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
|
||||
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
|
||||
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
|
||||
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
|
||||
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
|
||||
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
|
||||
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
|
||||
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
|
||||
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
|
||||
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
|
||||
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
|
||||
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
|
||||
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
|
||||
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
|
||||
title: No Code
|
||||
---
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
.get("Notes")
|
||||
.expect("Notes relationship should exist");
|
||||
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
|
||||
|
||||
let referred = entry
|
||||
.relationships
|
||||
.get("Referred by Data")
|
||||
.expect("Referred by Data should exist");
|
||||
assert_eq!(referred.len(), 2);
|
||||
|
||||
let belongs = entry
|
||||
.relationships
|
||||
.get("Belongs to")
|
||||
.expect("Belongs to should exist");
|
||||
assert_eq!(belongs.len(), 1);
|
||||
}
|
||||
|
||||
// --- type from frontmatter only (no folder inference) ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Vault-wide UI configuration stored in `config/ui.config.md`.
|
||||
/// Vault-wide UI configuration stored in `ui.config.md` at vault root.
|
||||
///
|
||||
/// This file is a regular vault note with YAML frontmatter, visible in the
|
||||
/// sidebar under the "Config" section and editable like any note.
|
||||
@@ -21,14 +21,13 @@ pub struct VaultConfig {
|
||||
pub property_display_modes: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
const CONFIG_DIR: &str = "config";
|
||||
const CONFIG_FILENAME: &str = "ui.config.md";
|
||||
|
||||
fn config_path(vault_path: &str) -> std::path::PathBuf {
|
||||
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
|
||||
Path::new(vault_path).join(CONFIG_FILENAME)
|
||||
}
|
||||
|
||||
/// Read the vault-wide UI config from `config/ui.config.md`.
|
||||
/// Read the vault-wide UI config from `ui.config.md` at vault root.
|
||||
/// Returns default values if the file doesn't exist.
|
||||
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
|
||||
let path = config_path(vault_path);
|
||||
@@ -69,19 +68,42 @@ fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
|
||||
.map_err(|e| format!("Failed to parse config: {e}"))
|
||||
}
|
||||
|
||||
/// Save the vault-wide UI config to `config/ui.config.md`.
|
||||
/// Creates the directory and file if they don't exist.
|
||||
/// Save the vault-wide UI config to `ui.config.md` at vault root.
|
||||
/// Creates the file if it doesn't exist.
|
||||
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
|
||||
let path = config_path(vault_path);
|
||||
let dir = Path::new(vault_path).join(CONFIG_DIR);
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
|
||||
}
|
||||
|
||||
let content = serialize_config(&config);
|
||||
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/ui.config.md` → root `ui.config.md`.
|
||||
/// If the root file already exists, the legacy file is simply removed.
|
||||
/// Cleans up empty `config/` directory after migration.
|
||||
pub fn migrate_ui_config_to_root(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let legacy = vault.join("config").join(CONFIG_FILENAME);
|
||||
let root = vault.join(CONFIG_FILENAME);
|
||||
|
||||
if legacy.exists() {
|
||||
if !root.exists() {
|
||||
// Move legacy content to root
|
||||
if let Ok(content) = std::fs::read_to_string(&legacy) {
|
||||
let _ = std::fs::write(&root, content);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
}
|
||||
|
||||
// Clean up empty config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
if config_dir.is_dir() {
|
||||
let is_empty = std::fs::read_dir(&config_dir).map_or(true, |mut d| d.next().is_none());
|
||||
if is_empty {
|
||||
let _ = std::fs::remove_dir(&config_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
|
||||
fn serialize_config(config: &VaultConfig) -> String {
|
||||
let mut lines = vec!["---".to_string(), "type: config".to_string()];
|
||||
@@ -142,7 +164,7 @@ fn yaml_safe_value(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
|
||||
/// Migrate `hidden_sections` from `ui.config.md` to `visible: false`
|
||||
/// on Type notes. Returns the number of Type notes updated.
|
||||
///
|
||||
/// For each type name in `hidden_sections`:
|
||||
@@ -354,11 +376,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -375,7 +395,7 @@ property_display_modes:
|
||||
assert!(recipe.contains("visible: false"));
|
||||
|
||||
// Config should no longer have hidden_sections
|
||||
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
|
||||
let config_content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(!config_content.contains("hidden_sections"));
|
||||
}
|
||||
|
||||
@@ -384,11 +404,9 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create config with hidden_sections
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
// Create config with hidden_sections at vault root
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -416,10 +434,8 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -440,19 +456,15 @@ property_display_modes:
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Type note already has visible: false
|
||||
let type_dir = dir.path().join("type");
|
||||
std::fs::create_dir_all(&type_dir).unwrap();
|
||||
// Type note already has visible: false at vault root
|
||||
std::fs::write(
|
||||
type_dir.join("note.md"),
|
||||
dir.path().join("note.md"),
|
||||
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -460,7 +472,7 @@ property_display_modes:
|
||||
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
|
||||
let content = std::fs::read_to_string(dir.path().join("note.md")).unwrap();
|
||||
// Should have exactly one visible: false, not two
|
||||
assert_eq!(content.matches("visible:").count(), 1);
|
||||
}
|
||||
@@ -478,4 +490,90 @@ property_display_modes:
|
||||
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
|
||||
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_moves_legacy_to_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.5\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root file should exist with migrated content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 1.5"));
|
||||
// Legacy file and empty config dir should be gone
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
assert!(!config_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_preserves_existing_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Root already has content
|
||||
std::fs::write(
|
||||
dir.path().join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 2.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Legacy also exists
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(
|
||||
config_dir.join("ui.config.md"),
|
||||
"---\ntype: config\nzoom: 1.0\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// Root should keep its original content
|
||||
let content = std::fs::read_to_string(dir.path().join("ui.config.md")).unwrap();
|
||||
assert!(content.contains("zoom: 2.0"));
|
||||
// Legacy file should be removed
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_ui_config_keeps_nonempty_config_dir() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let config_dir = dir.path().join("config");
|
||||
std::fs::create_dir_all(&config_dir).unwrap();
|
||||
std::fs::write(config_dir.join("ui.config.md"), "---\ntype: config\n---\n").unwrap();
|
||||
std::fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
migrate_ui_config_to_root(vault_path);
|
||||
|
||||
// config/ should still exist because it has other files
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("other.md").exists());
|
||||
assert!(!config_dir.join("ui.config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_config_writes_to_vault_root() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let config = VaultConfig {
|
||||
zoom: Some(1.0),
|
||||
..Default::default()
|
||||
};
|
||||
save_vault_config(vault_path, config).unwrap();
|
||||
|
||||
// File should be at vault root, not in config/
|
||||
assert!(dir.path().join("ui.config.md").exists());
|
||||
assert!(!dir.path().join("config").exists());
|
||||
}
|
||||
}
|
||||
|
||||
52
src/App.tsx
52
src/App.tsx
@@ -53,6 +53,7 @@ import type { SidebarSelection, VaultEntry, InboxPeriod } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { flushEditorContent } from './utils/autoSave'
|
||||
import './App.css'
|
||||
|
||||
@@ -156,6 +157,35 @@ function App() {
|
||||
dialogs.closeConflictResolver()
|
||||
}, [autoSync, dialogs])
|
||||
|
||||
/** Resolve a single file conflict from the in-editor banner. */
|
||||
const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => {
|
||||
try {
|
||||
const relativePath = filePath.replace(resolvedPath + '/', '')
|
||||
const call = isTauri() ? invoke : mockInvoke
|
||||
await call('git_resolve_conflict', { vaultPath: resolvedPath, file: relativePath, strategy })
|
||||
// Reload the note content to show the resolved version
|
||||
const content = await (isTauri() ? invoke<string> : mockInvoke<string>)('get_note_content', { path: filePath })
|
||||
// Check remaining conflicts
|
||||
const remaining = await (isTauri() ? invoke<string[]> : mockInvoke<string[]>)('get_conflict_files', { vaultPath: resolvedPath })
|
||||
if (remaining.length === 0) {
|
||||
// All resolved — auto-commit the merge and push
|
||||
await (isTauri() ? invoke : mockInvoke)('git_commit_conflict_resolution', { vaultPath: resolvedPath })
|
||||
vault.reloadVault()
|
||||
autoSync.triggerSync()
|
||||
setToastMessage('All conflicts resolved — merge committed')
|
||||
} else {
|
||||
void content // content reload happens via vault reload
|
||||
vault.reloadVault()
|
||||
setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`)
|
||||
}
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to resolve conflict: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, autoSync, setToastMessage])
|
||||
|
||||
const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline])
|
||||
const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline])
|
||||
|
||||
// Ref bridges handleContentChange (created after notes) into useNoteActions.
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
@@ -256,6 +286,17 @@ function App() {
|
||||
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
||||
}, [notes.activeTabPath, handleRemoveNoteIcon])
|
||||
|
||||
/** Open the active note in a new window (command palette / keyboard shortcut). */
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
|
||||
}, [notes.tabs, notes.activeTabPath, resolvedPath])
|
||||
|
||||
/** Open a specific note entry in a new window (Cmd+Shift+Click). */
|
||||
const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => {
|
||||
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
|
||||
}, [resolvedPath])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
@@ -350,7 +391,7 @@ function App() {
|
||||
}
|
||||
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -458,6 +499,7 @@ function App() {
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: handleOpenConflictResolver,
|
||||
onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
@@ -510,6 +552,7 @@ function App() {
|
||||
})(),
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -557,7 +600,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -614,6 +657,9 @@ function App() {
|
||||
onVaultChanged={handleAgentVaultChanged}
|
||||
onSetNoteIcon={handleSetNoteIcon}
|
||||
onRemoveNoteIcon={handleRemoveNoteIcon}
|
||||
isConflicted={!!notes.activeTabPath && autoSync.conflictFiles.some(f => notes.activeTabPath?.endsWith(f))}
|
||||
onKeepMine={handleKeepMine}
|
||||
onKeepTheirs={handleKeepTheirs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -629,7 +675,7 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
169
src/NoteWindow.tsx
Normal file
169
src/NoteWindow.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Editor } from './components/Editor'
|
||||
import { Toast } from './components/Toast'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { getNoteWindowParams } from './utils/windowMode'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import type { VaultEntry } from './types'
|
||||
import './App.css'
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal app shell for secondary "note windows" opened via "Open in New Window".
|
||||
* Shows only the editor — no sidebar, no note list.
|
||||
*/
|
||||
export default function NoteWindow() {
|
||||
const params = getNoteWindowParams()
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const activeTabPath = tabs[0]?.entry.path ?? null
|
||||
|
||||
const layout = useLayoutPanels()
|
||||
|
||||
// Load vault entries + note content on mount
|
||||
useEffect(() => {
|
||||
if (!params) return
|
||||
const { vaultPath, notePath } = params
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
const vaultEntries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
if (cancelled) return
|
||||
setEntries(vaultEntries)
|
||||
const entry = vaultEntries.find(e => e.path === notePath)
|
||||
if (!entry) return
|
||||
const content = await tauriCall<string>('get_note_content', { path: notePath })
|
||||
if (cancelled) return
|
||||
setTabs([{ entry, content }])
|
||||
}
|
||||
|
||||
load().catch(err => console.error('NoteWindow load failed:', err))
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params
|
||||
|
||||
// Apply theme
|
||||
const vaultPath = params?.vaultPath ?? ''
|
||||
useThemeManager(vaultPath, entries)
|
||||
|
||||
// Update window title when note title changes
|
||||
useEffect(() => {
|
||||
const title = tabs[0]?.entry.title
|
||||
if (!title) return
|
||||
if (!isTauri()) { document.title = title; return }
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().setTitle(title)
|
||||
}).catch(() => {})
|
||||
}, [tabs])
|
||||
|
||||
// Auto-save
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries(prev => prev.map(e => e.path === path ? { ...e, ...patch } : e))
|
||||
setTabs(prev => prev.map(t => t.entry.path === path ? { ...t, entry: { ...t.entry, ...patch } } : t))
|
||||
}, [])
|
||||
|
||||
const onAfterSave = useCallback(() => {}, [])
|
||||
const onNotePersisted = useCallback(() => {}, [])
|
||||
|
||||
const { handleSave, handleContentChange } = useEditorSaveWithLinks({
|
||||
updateEntry,
|
||||
setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave,
|
||||
onNotePersisted,
|
||||
})
|
||||
|
||||
// Wikilink navigation — in a note window, open wikilinks in the same window
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
const targetLower = target.toLowerCase()
|
||||
const entry = entries.find(e =>
|
||||
e.title.toLowerCase() === targetLower ||
|
||||
e.aliases.some(a => a.toLowerCase() === targetLower)
|
||||
)
|
||||
if (!entry) return
|
||||
tauriCall<string>('get_note_content', { path: entry.path }).then(content => {
|
||||
setTabs([{ entry, content }])
|
||||
}).catch(() => {})
|
||||
}, [entries])
|
||||
|
||||
// Stub for close tab — in a note window, close the window
|
||||
const handleCloseTab = useCallback(() => {
|
||||
if (!isTauri()) return
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().close()
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Keyboard: Cmd+S to save, Cmd+W to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleCloseTab()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave, handleCloseTab])
|
||||
|
||||
const activeTab = tabs[0] ?? null
|
||||
const gitHistory = useMemo(() => [], [])
|
||||
|
||||
if (!params) {
|
||||
return <div className="app-shell"><p>Invalid note window parameters</p></div>
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
||||
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
<div className="app__editor">
|
||||
<Editor
|
||||
tabs={tabs}
|
||||
activeTabPath={activeTabPath}
|
||||
entries={entries}
|
||||
onSwitchTab={() => {}}
|
||||
onCloseTab={handleCloseTab}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={() => layout.setInspectorCollapsed(c => !c)}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab.entry}
|
||||
inspectorContent={activeTab.content}
|
||||
gitHistory={gitHistory}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
leftPanelsCollapsed={true}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
src/components/ConflictNoteBanner.test.tsx
Normal file
29
src/components/ConflictNoteBanner.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConflictNoteBanner } from './ConflictNoteBanner'
|
||||
|
||||
describe('ConflictNoteBanner', () => {
|
||||
it('renders conflict message', () => {
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
|
||||
expect(screen.getByText('This note has a merge conflict')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onKeepMine when clicking Keep mine button', () => {
|
||||
const onKeepMine = vi.fn()
|
||||
render(<ConflictNoteBanner onKeepMine={onKeepMine} onKeepTheirs={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('conflict-keep-mine-btn'))
|
||||
expect(onKeepMine).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onKeepTheirs when clicking Keep theirs button', () => {
|
||||
const onKeepTheirs = vi.fn()
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={onKeepTheirs} />)
|
||||
fireEvent.click(screen.getByTestId('conflict-keep-theirs-btn'))
|
||||
expect(onKeepTheirs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has the correct test id', () => {
|
||||
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
|
||||
expect(screen.getByTestId('conflict-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
68
src/components/ConflictNoteBanner.tsx
Normal file
68
src/components/ConflictNoteBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConflictNoteBannerProps {
|
||||
onKeepMine: () => void
|
||||
onKeepTheirs: () => void
|
||||
}
|
||||
|
||||
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="conflict-note-banner"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 16px',
|
||||
background: 'var(--muted)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 12,
|
||||
color: 'var(--accent-orange)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
<span>This note has a merge conflict</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
data-testid="conflict-keep-mine-btn"
|
||||
onClick={onKeepMine}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep my local version"
|
||||
>
|
||||
Keep mine
|
||||
</button>
|
||||
<button
|
||||
data-testid="conflict-keep-theirs-btn"
|
||||
onClick={onKeepTheirs}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 8px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep the remote version"
|
||||
>
|
||||
Keep theirs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -664,6 +664,39 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('property row 50/50 layout', () => {
|
||||
it('uses CSS grid with two equal columns on editable rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ url: 'https://example.com/very/long/path/that/should/be/truncated' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
const editableRows = screen.getAllByTestId('editable-property')
|
||||
editableRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses CSS grid with two equal columns on read-only rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
/>
|
||||
)
|
||||
const readOnlyRows = screen.getAllByTestId('readonly-property')
|
||||
readOnlyRows.forEach(row => {
|
||||
expect(row.className).toContain('grid')
|
||||
expect(row.className).toContain('grid-cols-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL property rendering', () => {
|
||||
it('renders URL values with link styling instead of plain EditableValue', () => {
|
||||
render(
|
||||
|
||||
@@ -47,15 +47,15 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop flex min-w-0 items-center gap-2 rounded px-1.5 py-0.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="font-mono-overline flex w-1/2 shrink-0 min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{propKey}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<div className="w-1/2 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,9 +64,9 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline w-1/2 shrink-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<span className="w-1/2 min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function DynamicPropertiesPanel({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
.editor__blocknote-container .bn-editor {
|
||||
width: 100%;
|
||||
padding: 20px 40px;
|
||||
max-width: 760px;
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -282,11 +282,13 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--editor-title-size, 28px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-size: var(--headings-h1-font-size);
|
||||
font-weight: var(--headings-h1-font-weight);
|
||||
line-height: var(--headings-h1-line-height);
|
||||
letter-spacing: var(--headings-h1-letter-spacing);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('Editor', () => {
|
||||
const trashedTab = { entry: trashedEntry, content: mockContent }
|
||||
|
||||
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
|
||||
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
|
||||
}
|
||||
|
||||
it('shows banner and read-only editor when note is trashed', () => {
|
||||
@@ -336,9 +336,10 @@ describe('Editor', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
|
||||
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
@@ -346,13 +347,14 @@ describe('Editor', () => {
|
||||
|
||||
it('removes trash banner immediately when entry is restored (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
|
||||
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
|
||||
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
|
||||
const restoredTab = { entry: restoredEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
entries={[trashedEntry]}
|
||||
tabs={[{ entry: trashedEntry, content: mockContent }]}
|
||||
activeTabPath={trashedEntry.path}
|
||||
/>
|
||||
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
|
||||
const archivedEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
|
||||
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
|
||||
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
|
||||
const unarchivedEntry = { ...archivedEntry, archived: false }
|
||||
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -77,6 +77,12 @@ interface EditorProps {
|
||||
onSetNoteIcon?: (path: string, emoji: string) => void
|
||||
/** Called when user removes an emoji icon from a note. */
|
||||
onRemoveNoteIcon?: (path: string) => void
|
||||
/** Whether the active note has a merge conflict. */
|
||||
isConflicted?: boolean
|
||||
/** Resolve conflict by keeping the local version. */
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -224,6 +230,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme, onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
|
||||
const {
|
||||
@@ -291,6 +298,9 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onTitleChange={onTitleSync}
|
||||
onSetNoteIcon={onSetNoteIcon}
|
||||
onRemoveNoteIcon={onRemoveNoteIcon}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TitleField } from './TitleField'
|
||||
import { NoteIcon } from './NoteIcon'
|
||||
import { TrashedNoteBanner } from './TrashedNoteBanner'
|
||||
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
|
||||
import { ConflictNoteBanner } from './ConflictNoteBanner'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
@@ -54,6 +55,12 @@ interface EditorContentProps {
|
||||
onSetNoteIcon?: (path: string, emoji: string) => void
|
||||
/** Called when user removes an emoji icon. */
|
||||
onRemoveNoteIcon?: (path: string) => void
|
||||
/** Whether the active note has a merge conflict. */
|
||||
isConflicted?: boolean
|
||||
/** Resolve conflict by keeping the local version. */
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -151,9 +158,14 @@ export function EditorContent({
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
onDeleteNote, rawLatestContentRef, onTitleChange,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
// so the banner appears regardless of navigation context.
|
||||
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
|
||||
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
|
||||
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
|
||||
const showEditor = !diffMode && !rawMode
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
@@ -180,9 +192,15 @@ export function EditorContent({
|
||||
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
|
||||
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
|
||||
)}
|
||||
{activeTab && isConflicted && (
|
||||
<ConflictNoteBanner
|
||||
onKeepMine={() => onKeepMine?.(activeTab.entry.path)}
|
||||
onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)}
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
|
||||
@@ -154,6 +154,29 @@ const mockEntries: VaultEntry[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeIndexedEntry = (i: number, overrides?: Partial<VaultEntry>): VaultEntry =>
|
||||
makeEntry({
|
||||
path: `/vault/note/note-${i}.md`,
|
||||
filename: `note-${i}.md`,
|
||||
title: `Note ${i}`,
|
||||
isA: 'Note',
|
||||
modifiedAt: 1700000000 - i * 60,
|
||||
fileSize: 500,
|
||||
snippet: `Content of note ${i}`,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
@@ -348,35 +371,6 @@ describe('NoteList click behavior', () => {
|
||||
})
|
||||
|
||||
describe('getSortComparator', () => {
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test',
|
||||
isA: null,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('sorts by modified date descending', () => {
|
||||
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
|
||||
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
|
||||
@@ -462,35 +456,6 @@ describe('NoteList sort controls', () => {
|
||||
try { localStorage.removeItem('laputa-sort-preferences') } catch { /* noop */ }
|
||||
})
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test',
|
||||
isA: null,
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: null,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('shows sort button in note list header for flat view', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -505,11 +470,21 @@ describe('NoteList sort controls', () => {
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
const renderListAndOpenSort = (entries: VaultEntry[] = mockEntries) => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
}
|
||||
|
||||
const zamEntries = [
|
||||
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
renderListAndOpenSort()
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
|
||||
@@ -518,20 +493,12 @@ describe('NoteList sort controls', () => {
|
||||
})
|
||||
|
||||
it('changes sort order when an option is selected', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: by modified (Zebra first)
|
||||
renderListAndOpenSort(zamEntries)
|
||||
// Default sort: by modified (Zebra first) — menu is already open
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
||||
|
||||
// Switch to title sort
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
|
||||
// Now should be alphabetical
|
||||
@@ -540,21 +507,14 @@ describe('NoteList sort controls', () => {
|
||||
})
|
||||
|
||||
it('closes sort menu after selecting an option', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
renderListAndOpenSort()
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('sort-option-title'))
|
||||
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows direction arrows in sort dropdown menu', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
// Each option should have asc and desc direction buttons
|
||||
renderListAndOpenSort()
|
||||
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
|
||||
@@ -562,20 +522,12 @@ describe('NoteList sort controls', () => {
|
||||
})
|
||||
|
||||
it('reverses sort order when clicking direction arrow', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
renderListAndOpenSort(zamEntries)
|
||||
// Default sort: modified descending (Zebra first at 3000)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
|
||||
|
||||
// Click the asc arrow for modified to reverse
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
|
||||
|
||||
// Now ascending: Alpha (1000) first
|
||||
@@ -897,37 +849,8 @@ describe('NoteList — trash view', () => {
|
||||
// --- Virtual list performance tests ---
|
||||
|
||||
describe('NoteList — virtual list with large datasets', () => {
|
||||
const makeEntry = (i: number, overrides?: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: `/vault/note/note-${i}.md`,
|
||||
filename: `note-${i}.md`,
|
||||
title: `Note ${i}`,
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000 - i * 60,
|
||||
createdAt: null,
|
||||
fileSize: 500,
|
||||
snippet: `Content of note ${i}`,
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeIndexedEntry(i))
|
||||
const { container } = render(
|
||||
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
@@ -936,7 +859,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
})
|
||||
|
||||
it('renders items from a large dataset via Virtuoso', () => {
|
||||
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
|
||||
const largeDataset = Array.from({ length: 500 }, (_, i) => makeIndexedEntry(i))
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
@@ -946,9 +869,9 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('search filters large dataset correctly', () => {
|
||||
const entries = [
|
||||
makeEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, i) => makeEntry(i + 1, { title: `Filler Note ${i + 1}` })),
|
||||
makeEntry(999, { title: 'Beta Strategy' }),
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, i) => makeIndexedEntry(i + 1, { title: `Filler Note ${i + 1}` })),
|
||||
makeIndexedEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -962,9 +885,9 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('sorting works with large dataset', () => {
|
||||
const entries = [
|
||||
makeEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
|
||||
makeEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
|
||||
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
|
||||
makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
|
||||
makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
|
||||
...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -976,8 +899,8 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('section group filter works with large mixed-type dataset', () => {
|
||||
const entries = [
|
||||
...Array.from({ length: 100 }, (_, i) => makeEntry(i, { isA: 'Project', title: `Project ${i}` })),
|
||||
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
|
||||
...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i, { isA: 'Project', title: `Project ${i}` })),
|
||||
...Array.from({ length: 200 }, (_, i) => makeIndexedEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -987,7 +910,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
})
|
||||
|
||||
it('selection highlighting works in virtualized list', () => {
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i))
|
||||
const selected = entries[5]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -997,7 +920,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('click handler works on virtualized items', () => {
|
||||
noopReplace.mockClear()
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i))
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
@@ -1197,68 +1120,34 @@ describe('NoteList — multi-select', () => {
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
const selectTwoNotes = (extraProps: Record<string, unknown> = {}) => {
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} {...extraProps} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
it.each([
|
||||
{ label: 'bulk archive via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
|
||||
{ label: 'bulk trash via button', prop: 'onBulkTrash', trigger: () => fireEvent.click(screen.getByTestId('bulk-trash-btn')) },
|
||||
{ label: 'Cmd+E archives', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
|
||||
{ label: 'Cmd+Backspace trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
|
||||
{ label: 'Cmd+Delete trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
|
||||
])('$label selected notes and clears selection', ({ prop, trigger }) => {
|
||||
const handler = vi.fn()
|
||||
selectTwoNotes({ [prop]: handler })
|
||||
trigger()
|
||||
expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
selectTwoNotes()
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+E archives selected notes when multiselect is active', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'e', metaKey: true })
|
||||
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'Backspace', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
|
||||
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
@@ -1360,17 +1249,6 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
})
|
||||
|
||||
describe('countByFilter', () => {
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('counts open, archived, and trashed notes per type', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Project' }),
|
||||
@@ -1397,17 +1275,6 @@ describe('countByFilter', () => {
|
||||
})
|
||||
|
||||
describe('NoteList — filter pills', () => {
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const projectEntries = [
|
||||
makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }),
|
||||
makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }),
|
||||
@@ -1484,17 +1351,6 @@ describe('NoteList — filter pills', () => {
|
||||
})
|
||||
|
||||
describe('NoteList — filterEntries with subFilter', () => {
|
||||
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
|
||||
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
|
||||
|
||||
@@ -40,9 +40,10 @@ interface NoteListProps {
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
@@ -77,8 +78,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect })
|
||||
}, [onReplaceActiveTab, onSelectNote, multiSelect])
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect])
|
||||
|
||||
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
||||
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
|
||||
|
||||
@@ -418,6 +418,36 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
|
||||
})
|
||||
|
||||
describe('Changes and Pulse in secondary bottom area', () => {
|
||||
it('renders Changes outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const changesEl = screen.getByText('Changes')
|
||||
// Changes should be inside the secondary bottom area, not the top nav
|
||||
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders Pulse outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
|
||||
const pulseEl = screen.getByText('Pulse')
|
||||
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Changes or Pulse inside the top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.textContent).not.toContain('Changes')
|
||||
expect(topNav.textContent).not.toContain('Pulse')
|
||||
})
|
||||
|
||||
it('shows Changes badge count in secondary area', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
|
||||
const secondaryArea = screen.getByTestId('sidebar-secondary')
|
||||
expect(secondaryArea.textContent).toContain('7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic custom type sections', () => {
|
||||
const entriesWithCustomTypes: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -1008,4 +1038,24 @@ describe('Sidebar', () => {
|
||||
const mondaySections = screen.getAllByText(/Monday Ideas/i)
|
||||
expect(mondaySections).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders Inbox as the first item in the top nav', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
const items = topNav.children
|
||||
expect(items[0].textContent).toContain('Inbox')
|
||||
expect(items[1].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('displays inbox count badge', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with inbox filter when clicking Inbox', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
|
||||
fireEvent.click(screen.getByText('Inbox'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -305,15 +305,11 @@ export const Sidebar = memo(function Sidebar({
|
||||
<SidebarTitleBar onCollapse={onCollapse} />
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
@@ -337,6 +333,13 @@ export const Sidebar = memo(function Sidebar({
|
||||
</DndContext>
|
||||
</nav>
|
||||
|
||||
{/* Secondary area: Changes + Pulse */}
|
||||
<div className="shrink-0 border-t border-border" data-testid="sidebar-secondary" style={{ padding: '4px 6px' }}>
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} compact />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} compact />
|
||||
</div>
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
@@ -25,7 +26,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip }: {
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
icon: ComponentType<IconProps>
|
||||
label: string
|
||||
count?: number
|
||||
@@ -36,25 +37,30 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
const iconSize = compact ? 14 : 16
|
||||
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
|
||||
const padding = compact ? '4px 16px' : '6px 16px'
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={iconSize} />
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
style={{ padding, borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
<Icon size={iconSize} />
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -143,7 +149,7 @@ function SectionChildList({ items, selection, sectionColor, sectionLightColor, o
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} isActive={active}
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
@@ -232,8 +238,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; isActive: boolean
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
@@ -243,7 +249,7 @@ function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, on
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,6 +428,40 @@ describe('StatusBar', () => {
|
||||
expect(onReindexVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Pull required label when syncStatus is pull_required', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />
|
||||
)
|
||||
expect(screen.getByText('Pull required')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onPullAndPush when clicking Pull required badge', () => {
|
||||
const onPullAndPush = vi.fn()
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" onPullAndPush={onPullAndPush} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(onPullAndPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows git status popup when clicking idle sync badge', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
syncStatus="idle"
|
||||
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides indexed time badge when no lastIndexedTime', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu } from 'lucide-react'
|
||||
import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
@@ -27,7 +27,9 @@ interface StatusBarProps {
|
||||
lastSyncTime?: number | null
|
||||
conflictCount?: number
|
||||
lastCommitInfo?: LastCommitInfo | null
|
||||
remoteStatus?: GitRemoteStatus | null
|
||||
onTriggerSync?: () => void
|
||||
onPullAndPush?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
@@ -159,10 +161,10 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
|
||||
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
|
||||
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle, pull_required: ArrowDown }
|
||||
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)', pull_required: 'var(--accent-orange)' }
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
@@ -201,21 +203,114 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
|
||||
function syncBadgeTitle(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'Click to resolve conflicts'
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'pull_required') return 'Click to pull from remote and push'
|
||||
return 'Click to sync now'
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; remoteStatus?: GitRemoteStatus | null; onTriggerSync?: () => void; onPullAndPush?: () => void; onOpenConflictResolver?: () => void }) {
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
|
||||
const isSyncing = status === 'syncing'
|
||||
const isConflict = status === 'conflict'
|
||||
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
|
||||
const isPullRequired = status === 'pull_required'
|
||||
|
||||
const handleClick = () => {
|
||||
if (isConflict) { onOpenConflictResolver?.(); return }
|
||||
if (isPullRequired) { onPullAndPush?.(); return }
|
||||
setShowPopup(v => !v)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPopup) return
|
||||
const handleOutside = (e: MouseEvent) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target as Node)) setShowPopup(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutside)
|
||||
return () => document.removeEventListener('mousedown', handleOutside)
|
||||
}, [showPopup])
|
||||
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
<div ref={popupRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncBadgeTitle(status)}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
{showPopup && (
|
||||
<GitStatusPopup
|
||||
status={status}
|
||||
remoteStatus={remoteStatus ?? null}
|
||||
onPull={onTriggerSync}
|
||||
onClose={() => setShowPopup(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitStatusPopup({ status, remoteStatus, onPull, onClose }: { status: SyncStatus; remoteStatus: GitRemoteStatus | null; onPull?: () => void; onClose: () => void }) {
|
||||
const branch = remoteStatus?.branch || '—'
|
||||
const ahead = remoteStatus?.ahead ?? 0
|
||||
const behind = remoteStatus?.behind ?? 0
|
||||
const hasRemote = remoteStatus?.hasRemote ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="git-status-popup"
|
||||
style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
|
||||
background: 'var(--sidebar)', border: '1px solid var(--border)',
|
||||
borderRadius: 6, padding: 8, minWidth: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
zIndex: 1000, fontSize: 12, color: 'var(--foreground)',
|
||||
}}
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
|
||||
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
|
||||
<span style={{ fontWeight: 500 }}>{branch}</span>
|
||||
</div>
|
||||
|
||||
{hasRemote && (
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
|
||||
{ahead > 0 && <span title={`${ahead} commit${ahead > 1 ? 's' : ''} ahead of remote`}>↑ {ahead} ahead</span>}
|
||||
{behind > 0 && <span title={`${behind} commit${behind > 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}>↓ {behind} behind</span>}
|
||||
{ahead === 0 && behind === 0 && <span>In sync with remote</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasRemote && (
|
||||
<div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
|
||||
Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status}
|
||||
</div>
|
||||
|
||||
{hasRemote && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
|
||||
<button
|
||||
onClick={() => { onPull?.(); onClose() }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
|
||||
background: 'transparent', border: '1px solid var(--border)', borderRadius: 4,
|
||||
fontSize: 11, color: 'var(--foreground)', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="git-status-pull-btn"
|
||||
>
|
||||
<ArrowDown size={11} />Pull
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -356,7 +451,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -378,7 +473,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
|
||||
@@ -22,17 +22,19 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,17 +53,28 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
|
||||
: availableTypes
|
||||
|
||||
const typeColor = isA ? getTypeColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<div className="min-w-0">
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
|
||||
style={{
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
@@ -71,7 +84,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="filter-pills">
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
|
||||
@@ -16,14 +16,14 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
return (
|
||||
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="inbox-filter-pills">
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
|
||||
@@ -56,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries)
|
||||
// Look up the fresh entry from the entries array to pick up relationship
|
||||
// updates that happened after the selection was captured.
|
||||
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
|
||||
const groups = buildRelationshipGroups(freshEntry, entries)
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
|
||||
77
src/components/note-list/noteListUtils.test.ts
Normal file
77
src/components/note-list/noteListUtils.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { routeNoteClick, type ClickActions } from './noteListUtils'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
function makeEntry(path = '/test.md'): VaultEntry {
|
||||
return {
|
||||
path, filename: 'test.md', title: 'Test', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeActions(): ClickActions {
|
||||
return {
|
||||
onReplace: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
multiSelect: {
|
||||
selectRange: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setAnchor: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeMouseEvent(overrides: Partial<React.MouseEvent> = {}): React.MouseEvent {
|
||||
return { metaKey: false, ctrlKey: false, shiftKey: false, ...overrides } as React.MouseEvent
|
||||
}
|
||||
|
||||
describe('routeNoteClick', () => {
|
||||
it('plain click replaces active tab', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent(), actions)
|
||||
expect(actions.onReplace).toHaveBeenCalledWith(entry)
|
||||
expect(actions.multiSelect.clear).toHaveBeenCalled()
|
||||
expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path)
|
||||
})
|
||||
|
||||
it('Cmd+click opens as new tab', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions)
|
||||
expect(actions.onSelect).toHaveBeenCalledWith(entry)
|
||||
expect(actions.multiSelect.clear).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Shift+click selects range', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ shiftKey: true }), actions)
|
||||
expect(actions.multiSelect.selectRange).toHaveBeenCalledWith(entry.path)
|
||||
})
|
||||
|
||||
it('Cmd+Shift+click opens in new window', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
|
||||
expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry)
|
||||
expect(actions.onReplace).not.toHaveBeenCalled()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+click is a no-op when handler is undefined', () => {
|
||||
const entry = makeEntry()
|
||||
const actions = makeActions()
|
||||
actions.onOpenInNewWindow = undefined
|
||||
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
|
||||
expect(actions.onReplace).not.toHaveBeenCalled()
|
||||
expect(actions.onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,13 @@ export function countExpiredTrash(entries: VaultEntry[]): number {
|
||||
export interface ClickActions {
|
||||
onReplace: (entry: VaultEntry) => void
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
|
||||
}
|
||||
|
||||
export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
|
||||
if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey) { actions.onOpenInNewWindow?.(entry) }
|
||||
else if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
|
||||
else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) }
|
||||
else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) }
|
||||
}
|
||||
|
||||
@@ -13,12 +13,38 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
function isWikilink(s: string): boolean {
|
||||
return s.startsWith('[[') && s.includes(']]')
|
||||
}
|
||||
|
||||
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
|
||||
function extractWikilinks(value: FrontmatterValue): string[] {
|
||||
if (typeof value === 'string') return isWikilink(value) ? [value] : []
|
||||
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship patch: a partial update to merge into `entry.relationships`.
|
||||
* Keys map to their new ref arrays. A `null` value means "remove this key".
|
||||
*/
|
||||
export type RelationshipPatch = Record<string, string[] | null>
|
||||
|
||||
export interface EntryPatchResult {
|
||||
patch: Partial<VaultEntry>
|
||||
relationshipPatch: RelationshipPatch | null
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
export function frontmatterToEntryPatch(
|
||||
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
|
||||
): Partial<VaultEntry> {
|
||||
): EntryPatchResult {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {}
|
||||
if (op === 'delete') {
|
||||
const relPatch: RelationshipPatch = { [key]: null }
|
||||
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
|
||||
}
|
||||
const str = value != null ? String(value) : null
|
||||
const arr = Array.isArray(value) ? value.map(String) : []
|
||||
const updates: Record<string, Partial<VaultEntry>> = {
|
||||
@@ -32,7 +58,11 @@ export function frontmatterToEntryPatch(
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
const relationshipPatch: RelationshipPatch | null =
|
||||
wikilinks.length > 0 ? { [key]: wikilinks } : null
|
||||
return { patch: updates[k] ?? {}, relationshipPatch }
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
@@ -65,18 +95,42 @@ export interface FrontmatterOpOptions {
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
/** Apply a relationship patch by merging into the existing relationships map. */
|
||||
export function applyRelationshipPatch(
|
||||
existing: Record<string, string[]>, relPatch: RelationshipPatch,
|
||||
): Record<string, string[]> {
|
||||
const merged = { ...existing }
|
||||
for (const [k, v] of Object.entries(relPatch)) {
|
||||
if (v === null) delete merged[k]
|
||||
else merged[k] = v
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Run a frontmatter update/delete and apply the result to state.
|
||||
* Returns the new file content on success, or undefined on failure. */
|
||||
export async function runFrontmatterAndApply(
|
||||
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
|
||||
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
|
||||
callbacks: {
|
||||
updateTab: (p: string, c: string) => void
|
||||
updateEntry: (p: string, patch: Partial<VaultEntry>) => void
|
||||
toast: (m: string | null) => void
|
||||
getEntry?: (p: string) => VaultEntry | undefined
|
||||
},
|
||||
options?: FrontmatterOpOptions,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const newContent = await executeFrontmatterOp(op, path, key, value)
|
||||
callbacks.updateTab(path, newContent)
|
||||
const patch = frontmatterToEntryPatch(op, key, value)
|
||||
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
|
||||
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
|
||||
const fullPatch = { ...patch }
|
||||
if (relationshipPatch && callbacks.getEntry) {
|
||||
const current = callbacks.getEntry(path)
|
||||
if (current) {
|
||||
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
|
||||
}
|
||||
}
|
||||
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
|
||||
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
|
||||
return newContent
|
||||
} catch (err) {
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AppCommandsConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -77,6 +78,7 @@ interface AppCommandsConfig {
|
||||
activeNoteHasIcon?: boolean
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -124,6 +126,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -157,6 +160,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onRestoreDefaultThemes: config.onRestoreDefaultThemes,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onPull: config.onPull,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
@@ -165,6 +169,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -185,6 +190,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onPull: config.onPull,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
@@ -229,6 +235,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
selection: config.selection,
|
||||
noteListFilter: config.noteListFilter,
|
||||
onSetNoteListFilter: config.onSetNoteListFilter,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -217,4 +217,12 @@ describe('useAppKeyboard', () => {
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+O triggers open in new window', () => {
|
||||
const actions = makeActions()
|
||||
const onOpenInNewWindow = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onOpenInNewWindow }))
|
||||
fireKey('o', { metaKey: true, shiftKey: true })
|
||||
expect(onOpenInNewWindow).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ interface KeyboardActions {
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -107,11 +108,17 @@ export function useAppKeyboard({
|
||||
onReopenClosedTab?.()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+O: open active note in new window
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) {
|
||||
e.preventDefault()
|
||||
onOpenInNewWindow?.()
|
||||
return
|
||||
}
|
||||
if (!handleViewModeKey(e, onSetViewMode)) {
|
||||
handleCmdKey(e, cmdKeyMap)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow])
|
||||
}
|
||||
|
||||
@@ -107,20 +107,32 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pulls on window focus', async () => {
|
||||
it('pulls on window focus after cooldown expires', async () => {
|
||||
const now = vi.spyOn(Date, 'now')
|
||||
let clock = 1000
|
||||
now.mockImplementation(() => clock)
|
||||
|
||||
renderSync()
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
// Focus within cooldown — should NOT trigger pull
|
||||
mockInvokeFn.mockClear()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
clock += 5_000 // only 5s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull')
|
||||
expect(pullCalls).toHaveLength(0)
|
||||
|
||||
// Focus after cooldown — should trigger pull
|
||||
clock += 30_000 // 30s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('triggerSync allows manual pull', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
@@ -23,11 +23,16 @@ export interface AutoSyncState {
|
||||
lastSyncTime: number | null
|
||||
conflictFiles: string[]
|
||||
lastCommitInfo: LastCommitInfo | null
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
triggerSync: () => void
|
||||
/** Pull from remote, then push if there are local commits ahead. */
|
||||
pullAndPush: () => void
|
||||
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
|
||||
pausePull: () => void
|
||||
/** Resume auto-pull after pausing. */
|
||||
resumePull: () => void
|
||||
/** Notify that a push was rejected so the status updates to pull_required. */
|
||||
handlePushRejected: () => void
|
||||
}
|
||||
|
||||
export function useAutoSync({
|
||||
@@ -42,11 +47,22 @@ export function useAutoSync({
|
||||
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
|
||||
const [conflictFiles, setConflictFiles] = useState<string[]>([])
|
||||
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
|
||||
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
|
||||
const syncingRef = useRef(false)
|
||||
const pauseRef = useRef(false)
|
||||
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
|
||||
const refreshRemoteStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
|
||||
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
@@ -63,6 +79,12 @@ export function useAutoSync({
|
||||
return false
|
||||
}, [vaultPath])
|
||||
|
||||
const refreshCommitInfo = useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath])
|
||||
|
||||
const performPull = useCallback(async () => {
|
||||
if (syncingRef.current || pauseRef.current) return
|
||||
syncingRef.current = true
|
||||
@@ -71,9 +93,7 @@ export function useAutoSync({
|
||||
try {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
refreshCommitInfo()
|
||||
|
||||
if (result.status === 'updated') {
|
||||
setSyncStatus('idle')
|
||||
@@ -96,24 +116,95 @@ export function useAutoSync({
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
// Refresh remote status after pull
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts])
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
|
||||
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
|
||||
const pullAndPush = useCallback(async () => {
|
||||
if (syncingRef.current) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
|
||||
try {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
|
||||
if (pullResult.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(pullResult.conflictFiles)
|
||||
callbacksRef.current.onConflict(pullResult.conflictFiles)
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'error') {
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (!hasConflicts) {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'updated') {
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
|
||||
// Now push
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
if (pushResult.status === 'ok') {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onToast('Pulled and pushed successfully')
|
||||
} else if (pushResult.status === 'rejected') {
|
||||
// Still diverged — shouldn't happen after pull but handle gracefully
|
||||
setSyncStatus('pull_required')
|
||||
callbacksRef.current.onToast('Push still rejected after pull — try again')
|
||||
} else {
|
||||
setSyncStatus('error')
|
||||
callbacksRef.current.onToast(pushResult.message)
|
||||
}
|
||||
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
|
||||
const handlePushRejected = useCallback(() => {
|
||||
setSyncStatus('pull_required')
|
||||
}, [])
|
||||
|
||||
// Check for pre-existing conflicts on mount, then pull
|
||||
useEffect(() => {
|
||||
checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) performPull()
|
||||
})
|
||||
}, [checkExistingConflicts, performPull])
|
||||
refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
// Pull on window focus (app foreground)
|
||||
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => { performPull() }
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
@@ -128,5 +219,5 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull }
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface CommandRegistryConfig {
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -43,6 +44,7 @@ interface CommandRegistryConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
@@ -200,7 +202,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
@@ -217,6 +219,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onSetNoteIcon,
|
||||
onRemoveNoteIcon,
|
||||
activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
} = config
|
||||
|
||||
@@ -274,9 +277,16 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: hasActiveNote,
|
||||
execute: () => onOpenInNewWindow?.(),
|
||||
},
|
||||
|
||||
// Git
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
@@ -312,17 +322,17 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ describe('useCommitFlow', () => {
|
||||
let loadModifiedFiles: vi.Mock
|
||||
let commitAndPush: vi.Mock
|
||||
let setToastMessage: vi.Mock
|
||||
let onPushRejected: vi.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
savePending = vi.fn().mockResolvedValue(undefined)
|
||||
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
|
||||
commitAndPush = vi.fn().mockResolvedValue('Committed and pushed')
|
||||
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
|
||||
setToastMessage = vi.fn()
|
||||
onPushRejected = vi.fn()
|
||||
})
|
||||
|
||||
function renderCommitFlow() {
|
||||
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }))
|
||||
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
|
||||
}
|
||||
|
||||
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
|
||||
@@ -46,6 +48,18 @@ describe('useCommitFlow', () => {
|
||||
expect(result.current.showCommitDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
|
||||
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommitPush('test message')
|
||||
})
|
||||
|
||||
expect(onPushRejected).toHaveBeenCalledTimes(1)
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected'))
|
||||
})
|
||||
|
||||
it('handleCommitPush shows error toast on failure', async () => {
|
||||
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
loadModifiedFiles: () => Promise<void>
|
||||
commitAndPush: (message: string) => Promise<string>
|
||||
commitAndPush: (message: string) => Promise<GitPushResult>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onPushRejected?: () => void
|
||||
}
|
||||
|
||||
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
|
||||
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }: CommitFlowConfig) {
|
||||
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
|
||||
const openCommitDialog = useCallback(async () => {
|
||||
@@ -22,13 +24,20 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
|
||||
try {
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
setToastMessage(result)
|
||||
if (result.status === 'ok') {
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
onPushRejected?.()
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
loadModifiedFiles()
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${err}`)
|
||||
}
|
||||
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage])
|
||||
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
|
||||
|
||||
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
||||
|
||||
|
||||
@@ -31,12 +31,14 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onCreateTheme: vi.fn(),
|
||||
onRestoreDefaultThemes: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
onOpenInNewWindow: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -284,6 +286,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onCommitPush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-pull triggers pull', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-pull', h)
|
||||
expect(h.onPull).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-resolve-conflicts triggers resolve conflicts', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-resolve-conflicts', h)
|
||||
@@ -321,6 +329,13 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Note: open in new window
|
||||
it('note-open-in-new-window triggers open in new window', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-open-in-new-window', h)
|
||||
expect(h.onOpenInNewWindow).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -32,10 +32,12 @@ export interface MenuEventHandlers {
|
||||
onCreateTheme?: () => void
|
||||
onRestoreDefaultThemes?: () => void
|
||||
onCommitPush?: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -83,9 +85,10 @@ type OptionalHandler =
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
| 'onOpenInNewWindow'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -101,6 +104,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-new-theme': 'onCreateTheme',
|
||||
'vault-restore-default-themes': 'onRestoreDefaultThemes',
|
||||
'vault-commit-push': 'onCommitPush',
|
||||
'vault-pull': 'onPull',
|
||||
'vault-resolve-conflicts': 'onResolveConflicts',
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
@@ -109,6 +113,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
'file-reopen-closed-tab': 'onReopenClosedTab',
|
||||
'note-open-in-new-window': 'onOpenInNewWindow',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { frontmatterToEntryPatch } from './frontmatterOps'
|
||||
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
|
||||
import { useNoteActions } from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -345,25 +345,45 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('maps aliases update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
|
||||
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
|
||||
})
|
||||
|
||||
it('maps belongs_to update with array value', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
|
||||
// Also produces a relationship patch for the wikilink
|
||||
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
|
||||
})
|
||||
|
||||
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' })
|
||||
expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
|
||||
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
|
||||
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
|
||||
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
|
||||
})
|
||||
|
||||
it('returns empty object for unknown keys', () => {
|
||||
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
|
||||
it('returns empty patch for unknown non-wikilink keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
|
||||
expect(result.patch).toEqual({})
|
||||
// Non-wikilink value → no relationship change
|
||||
expect(result.relationshipPatch).toBeNull()
|
||||
})
|
||||
|
||||
it('produces relationship patch for wikilink values on unknown keys', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
|
||||
})
|
||||
|
||||
it('produces relationship patch for single wikilink string', () => {
|
||||
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -377,12 +397,46 @@ describe('frontmatterToEntryPatch', () => {
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
|
||||
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
|
||||
},
|
||||
)
|
||||
|
||||
it('returns empty object for unknown key on delete', () => {
|
||||
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
|
||||
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
||||
expect(result.patch).toEqual({})
|
||||
expect(result.relationshipPatch).toEqual({ unknown_key: null })
|
||||
})
|
||||
|
||||
it('delete of known key also produces relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'status')
|
||||
expect(result.patch).toEqual({ status: null })
|
||||
expect(result.relationshipPatch).toEqual({ status: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRelationshipPatch', () => {
|
||||
it('adds new relationship key', () => {
|
||||
const existing = { 'Belongs to': ['[[eng]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
|
||||
})
|
||||
|
||||
it('overwrites existing relationship key', () => {
|
||||
const existing = { Notes: ['[[old]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
|
||||
})
|
||||
|
||||
it('removes relationship key when value is null', () => {
|
||||
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
|
||||
const result = applyRelationshipPatch(existing, { Notes: null })
|
||||
expect(result).toEqual({ Owner: ['[[alice]]'] })
|
||||
})
|
||||
|
||||
it('does not mutate the original map', () => {
|
||||
const existing = { Notes: ['[[a]]'] }
|
||||
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
|
||||
expect(existing).toEqual({ Notes: ['[[a]]'] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const runFrontmatterOp = useCallback(
|
||||
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) =>
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage],
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -579,4 +579,32 @@ background: "#FF0000"
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything())
|
||||
})
|
||||
|
||||
it('skips theme reload on focus within 30s cooldown', async () => {
|
||||
const now = vi.spyOn(Date, 'now')
|
||||
let clock = 1000
|
||||
now.mockImplementation(() => clock)
|
||||
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
mockInvokeFn.mockClear()
|
||||
|
||||
// Focus within cooldown — should NOT reload settings
|
||||
clock += 5_000
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
const settingsCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'get_vault_settings')
|
||||
expect(settingsCalls).toHaveLength(0)
|
||||
|
||||
// Focus after cooldown — should reload
|
||||
clock += 30_000
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
now.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,15 +79,31 @@ function clearColorScheme(): void {
|
||||
delete root.dataset.themeMode
|
||||
}
|
||||
|
||||
const THEME_STYLE_ID = 'laputa-theme-vars'
|
||||
|
||||
function getOrCreateThemeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
|
||||
if (!el) {
|
||||
el = document.createElement('style')
|
||||
el.id = THEME_STYLE_ID
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function applyVarsToDom(vars: Record<string, string>): void {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
updateColorScheme(vars)
|
||||
// Force WebKit to recalculate ::before/::after pseudo-element styles
|
||||
// when CSS custom properties change (WKWebView doesn't auto-invalidate).
|
||||
void root.offsetHeight
|
||||
// WKWebView doesn't invalidate ::before/::after pseudo-element styles when
|
||||
// CSS custom properties change via inline styles alone — `void offsetHeight`
|
||||
// triggers layout reflow but not style recalculation on pseudo-elements.
|
||||
// Replacing a <style> element's content forces a full style tree invalidation
|
||||
// that covers pseudo-elements using var() references (e.g. bullet size/color).
|
||||
const css = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';')
|
||||
getOrCreateThemeStyle().textContent = `:root{${css}}`
|
||||
}
|
||||
|
||||
function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
@@ -95,6 +111,7 @@ function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
for (const key of Object.keys(vars)) {
|
||||
root.style.removeProperty(key)
|
||||
}
|
||||
getOrCreateThemeStyle().textContent = ''
|
||||
clearColorScheme()
|
||||
}
|
||||
|
||||
@@ -146,9 +163,17 @@ function useThemeSetting(vaultPath: string | null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const lastLoadRef = useRef(0)
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', load)
|
||||
return () => window.removeEventListener('focus', load)
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastLoadRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastLoadRef.current = now
|
||||
load()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [load])
|
||||
|
||||
return { activeThemeId, setActiveThemeId, reload: load }
|
||||
@@ -205,11 +230,36 @@ function useThemeApplier(
|
||||
return { clearDom, isDark }
|
||||
}
|
||||
|
||||
/** Deactivate the theme and persist `null` to vault settings. */
|
||||
function deactivateTheme(
|
||||
vaultPath: string | null,
|
||||
clearTheme: () => void,
|
||||
setActiveThemeId: (id: string | null) => void,
|
||||
) {
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}
|
||||
|
||||
/** True when the active theme should be cleared (stale, trashed, or archived). */
|
||||
function shouldDeactivate(
|
||||
activeThemeId: string | null,
|
||||
themes: ThemeFile[],
|
||||
entries: VaultEntry[],
|
||||
userSetId: string | null,
|
||||
): boolean {
|
||||
if (!activeThemeId) return false
|
||||
// Stale ID from old theme system — skip IDs just set by user action
|
||||
if (themes.length > 0 && activeThemeId !== userSetId && !themes.some(t => t.id === activeThemeId)) return true
|
||||
// Trashed or archived
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
return !!entry && isEntryRemoved(entry)
|
||||
}
|
||||
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
): ThemeManager {
|
||||
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
|
||||
useEffect(() => {
|
||||
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
|
||||
}, [vaultPath])
|
||||
@@ -217,14 +267,10 @@ export function useThemeManager(
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
|
||||
|
||||
// Clear cached content when theme changes — useThemeApplier will fetch from disk
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
|
||||
|
||||
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
|
||||
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
|
||||
// cleanup doesn't clear a newly-created theme that isn't in entries yet.
|
||||
const userSetIdRef = useRef<string | null>(null)
|
||||
|
||||
const themes = useMemo(
|
||||
@@ -239,30 +285,12 @@ export function useThemeManager(
|
||||
[themes, activeThemeId],
|
||||
)
|
||||
|
||||
// If active theme ID doesn't match any known theme (e.g. stale ID from old
|
||||
// JSON-based theme system), clear it so the app doesn't try to load a
|
||||
// non-existent path. Skip IDs just set by switchTheme/createTheme — the
|
||||
// entry may not have appeared in `entries` yet.
|
||||
// Deactivate stale, trashed, or archived theme
|
||||
useEffect(() => {
|
||||
if (!activeThemeId || themes.length === 0) return
|
||||
if (activeThemeId === userSetIdRef.current) return
|
||||
const isKnown = themes.some(t => t.id === activeThemeId)
|
||||
if (!isKnown) {
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
if (shouldDeactivate(activeThemeId, themes, entries, userSetIdRef.current)) {
|
||||
deactivateTheme(vaultPath, clearTheme, setActiveThemeId)
|
||||
}
|
||||
}, [activeThemeId, themes, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
|
||||
useEffect(() => {
|
||||
if (!activeThemeId) return
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
if (!entry || !isEntryRemoved(entry)) return
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
|
||||
}, [activeThemeId, themes, entries, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
const switchTheme = useCallback(async (themeId: string) => {
|
||||
if (!vaultPath) return
|
||||
@@ -294,9 +322,7 @@ export function useThemeManager(
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
const newContent = await tauriCall<string>('update_frontmatter', {
|
||||
path: activeThemeId,
|
||||
key,
|
||||
value,
|
||||
path: activeThemeId, key, value,
|
||||
})
|
||||
setCachedThemeContent(newContent)
|
||||
} catch (err) { console.error('Failed to update theme property:', err) }
|
||||
|
||||
@@ -362,15 +362,15 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
let response = ''
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toBe('Committed and pushed')
|
||||
expect(response.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns actionable message when push is rejected', async () => {
|
||||
it('returns rejected status when push is rejected', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
@@ -383,16 +383,16 @@ describe('useVaultLoader', () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('Pull first')
|
||||
expect(response).not.toBe('Committed and pushed')
|
||||
expect(response.status).toBe('rejected')
|
||||
expect(response.message).toContain('Pull first')
|
||||
})
|
||||
|
||||
it('returns network error message on network failure', async () => {
|
||||
it('returns network error status on network failure', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
@@ -405,12 +405,13 @@ describe('useVaultLoader', () => {
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
let response: { status: string; message: string } = { status: '', message: '' }
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('network error')
|
||||
expect(response.status).toBe('network_error')
|
||||
expect(response.message).toContain('network error')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,15 +15,13 @@ async function loadVaultData(vaultPath: string) {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { message })
|
||||
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
return mockInvoke<GitPushResult>('git_push', {})
|
||||
}
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
return invoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
|
||||
function useNewNoteTracker() {
|
||||
@@ -156,7 +154,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
const getNoteStatus = useCallback((path: string): NoteStatus =>
|
||||
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
|
||||
|
||||
const commitAndPush = useCallback((message: string): Promise<string> =>
|
||||
const commitAndPush = useCallback((message: string): Promise<GitPushResult> =>
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
const reloadVault = useCallback(
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import NoteWindow from './NoteWindow.tsx'
|
||||
import { isNoteWindow } from './utils/windowMode'
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
@@ -12,10 +14,12 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
|
||||
}
|
||||
|
||||
const RootComponent = isNoteWindow() ? NoteWindow : App
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
<RootComponent />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Each handler simulates a Tauri backend command.
|
||||
*/
|
||||
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { MOCK_ENTRIES } from './mock-entries'
|
||||
|
||||
@@ -180,6 +180,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
|
||||
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
|
||||
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
git_remote_status: (): GitRemoteStatus => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }),
|
||||
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
|
||||
const limit = args.limit ?? 30
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -83,7 +83,14 @@ export interface GitPushResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' | 'pull_required'
|
||||
|
||||
export interface GitRemoteStatus {
|
||||
branch: string
|
||||
ahead: number
|
||||
behind: number
|
||||
hasRemote: boolean
|
||||
}
|
||||
|
||||
export interface DeviceFlowStart {
|
||||
device_code: string
|
||||
@@ -148,7 +155,7 @@ export interface VaultSettings {
|
||||
theme: string | null
|
||||
}
|
||||
|
||||
/** Vault-wide UI configuration stored in config/ui.config.md. */
|
||||
/** Vault-wide UI configuration stored in ui.config.md at vault root. */
|
||||
export interface VaultConfig {
|
||||
zoom: number | null
|
||||
view_mode: string | null
|
||||
|
||||
@@ -324,6 +324,84 @@ describe('buildRelationshipGroups', () => {
|
||||
const groups = buildRelationshipGroups(entity, [entity, linker])
|
||||
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
|
||||
})
|
||||
|
||||
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
|
||||
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
|
||||
const noteRefs = [
|
||||
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
|
||||
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
|
||||
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
|
||||
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
|
||||
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
|
||||
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
|
||||
'directus', 'framer-design',
|
||||
]
|
||||
const noteEntries = noteRefs.map((slug, i) => makeEntry({
|
||||
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
|
||||
modifiedAt: 1700000000 - i * 100,
|
||||
}))
|
||||
const engineering = makeEntry({
|
||||
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
|
||||
modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
isA: 'Topic',
|
||||
relationships: {
|
||||
'Belongs to': ['[[engineering|Engineering]]'],
|
||||
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
|
||||
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
|
||||
},
|
||||
})
|
||||
const allEntries = [entity, engineering, ...noteEntries]
|
||||
const groups = buildRelationshipGroups(entity, allEntries)
|
||||
|
||||
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
|
||||
expect(belongsGroup).toBeDefined()
|
||||
expect(belongsGroup!.entries).toHaveLength(1)
|
||||
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(32)
|
||||
|
||||
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
|
||||
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
|
||||
expect(referredGroup).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves refs by title when filename differs from wikilink target', () => {
|
||||
// Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev"
|
||||
const airdev = makeEntry({
|
||||
path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev',
|
||||
})
|
||||
const budibase = makeEntry({
|
||||
path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase',
|
||||
aliases: ['Budi'],
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, airdev, budibase])
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(2)
|
||||
expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase'])
|
||||
})
|
||||
|
||||
it('resolves Children via title match when belongsTo target differs from filename', () => {
|
||||
// Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md
|
||||
const child = makeEntry({
|
||||
path: '/vault/tool.md', filename: 'tool.md', title: 'Tool',
|
||||
belongsTo: ['[[No Code]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child])
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator — custom properties', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { VaultEntry, SidebarSelection, InboxPeriod } from '../types'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
|
||||
export type NoteListFilter = 'open' | 'archived' | 'trashed'
|
||||
|
||||
@@ -61,25 +62,12 @@ export function formatSearchSubtitle(entry: VaultEntry): string {
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const fileStem = entry.filename.replace(/\.md$/, '')
|
||||
return refs.some((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return inner === stem || inner.split('/').pop() === fileStem
|
||||
})
|
||||
return refs.some((ref) => resolveEntry([entry], wikilinkTarget(ref)) !== undefined)
|
||||
}
|
||||
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
return refs
|
||||
.map((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return entries.find((e) => {
|
||||
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (stem === inner) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
return fileStem === inner.split('/').pop()
|
||||
})
|
||||
})
|
||||
.map((ref) => resolveEntry(entries, wikilinkTarget(ref)))
|
||||
.filter((e): e is VaultEntry => e !== undefined)
|
||||
}
|
||||
|
||||
|
||||
23
src/utils/openNoteWindow.ts
Normal file
23
src/utils/openNoteWindow.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
/**
|
||||
* Opens a note in a new Tauri window with a minimal editor-only layout.
|
||||
* In browser mode (non-Tauri), this is a no-op.
|
||||
*/
|
||||
export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise<void> {
|
||||
if (!isTauri()) return
|
||||
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const label = `note-${Date.now()}`
|
||||
const url = `index.html?window=note&path=${encodeURIComponent(notePath)}&vault=${encodeURIComponent(vaultPath)}&title=${encodeURIComponent(noteTitle)}`
|
||||
|
||||
new WebviewWindow(label, {
|
||||
url,
|
||||
title: noteTitle,
|
||||
width: 800,
|
||||
height: 700,
|
||||
resizable: true,
|
||||
titleBarStyle: 'overlay',
|
||||
hiddenTitle: true,
|
||||
})
|
||||
}
|
||||
73
src/utils/windowMode.test.ts
Normal file
73
src/utils/windowMode.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { isNoteWindow, getNoteWindowParams } from './windowMode'
|
||||
|
||||
describe('windowMode', () => {
|
||||
let originalSearch: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalSearch = window.location.search
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, search: originalSearch },
|
||||
})
|
||||
})
|
||||
|
||||
function setSearch(search: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, search },
|
||||
})
|
||||
}
|
||||
|
||||
describe('isNoteWindow', () => {
|
||||
it('returns false when no query params', () => {
|
||||
setSearch('')
|
||||
expect(isNoteWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when window=note', () => {
|
||||
setSearch('?window=note&path=/test.md&vault=/vault')
|
||||
expect(isNoteWindow()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for other window values', () => {
|
||||
setSearch('?window=main')
|
||||
expect(isNoteWindow()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteWindowParams', () => {
|
||||
it('returns null when not a note window', () => {
|
||||
setSearch('')
|
||||
expect(getNoteWindowParams()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when path is missing', () => {
|
||||
setSearch('?window=note&vault=/vault')
|
||||
expect(getNoteWindowParams()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when vault is missing', () => {
|
||||
setSearch('?window=note&path=/test.md')
|
||||
expect(getNoteWindowParams()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns params when all are present', () => {
|
||||
setSearch('?window=note&path=%2Fvault%2Ftest.md&vault=%2Fvault&title=My%20Note')
|
||||
expect(getNoteWindowParams()).toEqual({
|
||||
notePath: '/vault/test.md',
|
||||
vaultPath: '/vault',
|
||||
noteTitle: 'My Note',
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults title to Untitled', () => {
|
||||
setSearch('?window=note&path=/test.md&vault=/vault')
|
||||
const params = getNoteWindowParams()
|
||||
expect(params?.noteTitle).toBe('Untitled')
|
||||
})
|
||||
})
|
||||
})
|
||||
24
src/utils/windowMode.ts
Normal file
24
src/utils/windowMode.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Detects whether the current window is a secondary "note window" (opened via
|
||||
* "Open in New Window") by inspecting URL query parameters.
|
||||
*/
|
||||
|
||||
export interface NoteWindowParams {
|
||||
notePath: string
|
||||
vaultPath: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
export function isNoteWindow(): boolean {
|
||||
return new URLSearchParams(window.location.search).get('window') === 'note'
|
||||
}
|
||||
|
||||
export function getNoteWindowParams(): NoteWindowParams | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('window') !== 'note') return null
|
||||
const notePath = params.get('path')
|
||||
const vaultPath = params.get('vault')
|
||||
const noteTitle = params.get('title') ?? 'Untitled'
|
||||
if (!notePath || !vaultPath) return null
|
||||
return { notePath, vaultPath, noteTitle }
|
||||
}
|
||||
50
tests/smoke/focus-no-freeze.spec.ts
Normal file
50
tests/smoke/focus-no-freeze.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Focus event does not freeze the UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('window focus event completes without blocking UI for >500ms', async ({ page }) => {
|
||||
// Verify the app loaded
|
||||
await expect(page.locator('[data-testid="sidebar"]').or(page.locator('nav')).first()).toBeVisible()
|
||||
|
||||
// Measure how long the UI is blocked after a focus event.
|
||||
// We dispatch focus, then immediately schedule a rAF callback.
|
||||
// If the main thread is blocked (sync IPC), the rAF callback is delayed.
|
||||
const blockMs = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const t0 = performance.now()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
requestAnimationFrame(() => {
|
||||
resolve(performance.now() - t0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The focus handler must not block the main thread for more than 500ms.
|
||||
// Before the fix, git_pull ran synchronously and blocked for 2-3 seconds.
|
||||
expect(blockMs).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test('rapid focus events (5x) do not accumulate freezes', async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="sidebar"]').or(page.locator('nav')).first()).toBeVisible()
|
||||
|
||||
const totalMs = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const t0 = performance.now()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
resolve(performance.now() - t0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 5 rapid focus events should still complete in under 500ms total
|
||||
// thanks to the cooldown preventing redundant work.
|
||||
expect(totalMs).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
90
tests/smoke/git-divergence-conflicts.spec.ts
Normal file
90
tests/smoke/git-divergence-conflicts.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Git divergence, conflicts, and manual pull', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('push rejection sets pull_required status in bottom bar', async ({ page }) => {
|
||||
// Override git_push mock to return rejected
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_push = () => ({
|
||||
status: 'rejected',
|
||||
message: 'Push rejected: remote has new commits. Pull first, then push.',
|
||||
})
|
||||
})
|
||||
|
||||
// Commit to trigger push
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
await page.getByRole('button', { name: 'Commit & Push' }).click()
|
||||
|
||||
// Verify toast shows rejection message
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('push rejected', { timeout: 5000 })
|
||||
|
||||
// Verify "Pull required" label appears in the status bar
|
||||
const syncBadge = page.getByTestId('status-sync')
|
||||
await expect(syncBadge).toContainText('Pull required', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Pull from Remote command exists in command palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
|
||||
const input = page.locator('input[placeholder="Type a command..."]')
|
||||
await input.fill('Pull')
|
||||
|
||||
// Verify the Pull from Remote command appears as the selected item
|
||||
const match = page.locator('[data-selected="true"]').first()
|
||||
await expect(match).toContainText('Pull from Remote', { timeout: 3000 })
|
||||
})
|
||||
|
||||
test('git status popup shows branch and sync info when clicking sync badge', async ({ page }) => {
|
||||
// Override git_remote_status to return data with ahead/behind
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_remote_status = () => ({
|
||||
branch: 'main',
|
||||
ahead: 3,
|
||||
behind: 1,
|
||||
hasRemote: true,
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger a sync so the remote status gets fetched
|
||||
const syncBadge = page.getByTestId('status-sync')
|
||||
await syncBadge.click()
|
||||
|
||||
// The popup should appear
|
||||
const popup = page.getByTestId('git-status-popup')
|
||||
await expect(popup).toBeVisible({ timeout: 3000 })
|
||||
await expect(popup).toContainText('main')
|
||||
})
|
||||
|
||||
test('conflict badge shows count when conflicts exist', async ({ page }) => {
|
||||
// Override git_pull to return conflicts
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_pull = () => ({
|
||||
status: 'conflict',
|
||||
message: 'Merge conflict in 2 file(s)',
|
||||
updatedFiles: [],
|
||||
conflictFiles: ['note-a.md', 'note-b.md'],
|
||||
})
|
||||
window.__mockHandlers!.get_conflict_files = () => ['note-a.md', 'note-b.md']
|
||||
})
|
||||
|
||||
// Trigger a sync
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Pull from Remote')
|
||||
|
||||
// Verify conflict count badge appears
|
||||
const conflictBadge = page.getByTestId('status-conflict-count')
|
||||
await expect(conflictBadge).toBeVisible({ timeout: 5000 })
|
||||
await expect(conflictBadge).toContainText('2 conflicts')
|
||||
})
|
||||
})
|
||||
103
tests/smoke/note-list-incomplete-relationships.spec.ts
Normal file
103
tests/smoke/note-list-incomplete-relationships.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note list shows complete relationships when opening from sidebar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('entity view shows relationship groups with correct counts after sidebar click', async ({ page }) => {
|
||||
// Navigate to Responsibilities type to find entity notes in the sidebar
|
||||
// First, click on a sidebar type that contains entity notes
|
||||
// We'll use the sidebar search to filter to "Sponsorships"
|
||||
const searchToggle = page.locator('[data-testid="search-toggle"]')
|
||||
if (await searchToggle.isVisible()) {
|
||||
await searchToggle.click()
|
||||
}
|
||||
|
||||
// Type in sidebar search to find Sponsorships
|
||||
const sidebarSearch = page.locator('[data-testid="note-list-search"]')
|
||||
if (await sidebarSearch.isVisible()) {
|
||||
await sidebarSearch.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Click the Sponsorships note in the note list to trigger entity view
|
||||
const sponsorshipsItem = page.locator('[data-entry-path*="sponsorships"]').first()
|
||||
if (await sponsorshipsItem.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await sponsorshipsItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
// Fallback: open via quick open — then click in sidebar
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// The inspector panel (right side) should show relationship labels with font-mono-overline
|
||||
// This verifies the note loaded and relationships were parsed correctly
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify the relationships panel shows the correct number of items
|
||||
// Has Measures should have 2 entries (measure-sponsorship-mrr, measure-close-rate)
|
||||
const measuresSection = measuresLabel.locator('xpath=ancestor::div[1]')
|
||||
const measureItems = measuresSection.locator('a, button').filter({ hasText: /measure|Measure/ })
|
||||
// At least 1 resolved relationship entry
|
||||
const hasItems = await measureItems.count()
|
||||
expect(hasItems).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('relationship labels persist after navigating away and back', async ({ page }) => {
|
||||
// Open Sponsorships via quick open
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Verify Has Measures relationship appears in inspector
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Navigate to different note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput2 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput2).toBeVisible()
|
||||
await searchInput2.fill('Start Laputa App')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Navigate back to Sponsorships
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput3 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput3).toBeVisible()
|
||||
await searchInput3.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Relationships should still be visible — not stale or incomplete
|
||||
const measuresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,6 @@ test.describe('Note list preview snippet', () => {
|
||||
if (text && text.length > 10) {
|
||||
expect(text).not.toMatch(/\*\*[^*]+\*\*/)
|
||||
expect(text).not.toContain('```')
|
||||
expect(text).not.toMatch(/\[\[.*\]\]/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
41
tests/smoke/open-in-new-window.spec.ts
Normal file
41
tests/smoke/open-in-new-window.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand } from './helpers'
|
||||
|
||||
test.describe('Open in New Window', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('"Open in New Window" command appears in palette when note is active', async ({ page }) => {
|
||||
// Open a note first (click first item in note list)
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
|
||||
// Wait for editor to load
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Open in New Window')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('"Open in New Window" shows shortcut hint', async ({ page }) => {
|
||||
// Open a note
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search for the command
|
||||
await openCommandPalette(page)
|
||||
const input = page.locator('input[placeholder="Type a command..."]')
|
||||
await input.fill('Open in New Window')
|
||||
|
||||
// The shortcut hint should be visible
|
||||
const commandRow = page.locator('text=Open in New Window').first()
|
||||
await expect(commandRow).toBeVisible({ timeout: 2_000 })
|
||||
})
|
||||
})
|
||||
@@ -54,4 +54,29 @@ test.describe('TitleField alignment and separator', () => {
|
||||
// Both should have the same left edge (within 2px tolerance)
|
||||
expect(Math.abs(titleBox!.x - editorBox!.x)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('title field uses H1 CSS variables for font styling', async ({ page }) => {
|
||||
// Verify the title-field__input element references the H1 heading CSS vars
|
||||
const usesH1Vars = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets)
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
for (const rule of Array.from(sheet.cssRules)) {
|
||||
if (rule instanceof CSSStyleRule && rule.selectorText === '.title-field__input') {
|
||||
const fontSize = rule.style.getPropertyValue('font-size')
|
||||
const fontWeight = rule.style.getPropertyValue('font-weight')
|
||||
return {
|
||||
fontSize: fontSize.includes('--headings-h1-font-size'),
|
||||
fontWeight: fontWeight.includes('--headings-h1-font-weight'),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheet */ }
|
||||
}
|
||||
return { fontSize: false, fontWeight: false }
|
||||
})
|
||||
|
||||
expect(usesH1Vars.fontSize).toBe(true)
|
||||
expect(usesH1Vars.fontWeight).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,7 +75,8 @@ test.describe('Trash/archive state consistency across UI', () => {
|
||||
|
||||
test('Changes list updates after trashing a note', async ({ page }) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
const changesRow = sidebar.locator('div', { hasText: /^Changes/ }).first()
|
||||
const secondaryArea = sidebar.locator('[data-testid="sidebar-secondary"]')
|
||||
const changesRow = secondaryArea.locator('div', { hasText: /^Changes/ }).first()
|
||||
await changesRow.waitFor({ timeout: 5000 })
|
||||
|
||||
const badge = changesRow.locator('span').last()
|
||||
|
||||
@@ -10,8 +10,9 @@ test.describe('Trash/archive notes appear in Changes', () => {
|
||||
test('trashing a note increments the Changes badge', async ({ page }) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for Changes nav item (mock starts with 3 modified files)
|
||||
const changesRow = sidebar.locator('div', { hasText: /^Changes/ }).first()
|
||||
// Wait for Changes nav item in the secondary bottom area (mock starts with 3 modified files)
|
||||
const secondaryArea = sidebar.locator('[data-testid="sidebar-secondary"]')
|
||||
const changesRow = secondaryArea.locator('div', { hasText: /^Changes/ }).first()
|
||||
await changesRow.waitFor({ timeout: 5000 })
|
||||
|
||||
// Read the initial badge count from the Changes row
|
||||
|
||||
Reference in New Issue
Block a user