Compare commits
20 Commits
stable-v20
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee42fb856 | ||
|
|
eacf05a9f8 | ||
|
|
a199fad803 | ||
|
|
e956c806e5 | ||
|
|
7a6e3863ad | ||
|
|
e0e2dc4063 | ||
|
|
0020ade6d7 | ||
|
|
503e482c7d | ||
|
|
0d0e6af4c9 | ||
|
|
dbaf361878 | ||
|
|
d3741c82f5 | ||
|
|
b90d2f8612 | ||
|
|
9536efee0e | ||
|
|
c86a6ab9c5 | ||
|
|
1d7c027700 | ||
|
|
74e50c64bb | ||
|
|
8b97ef711f | ||
|
|
08855cee92 | ||
|
|
03042bb49c | ||
|
|
cee4bef179 |
57
.github/workflows/release-stable.yml
vendored
57
.github/workflows/release-stable.yml
vendored
@@ -127,6 +127,63 @@ jobs:
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -170,6 +170,63 @@ jobs:
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
|
||||
@@ -273,7 +273,7 @@ type SidebarSelection =
|
||||
- The selected `entry` is the neighborhood source note.
|
||||
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
|
||||
- Outgoing relationship groups render first using the note's `relationships` map.
|
||||
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
|
||||
- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups.
|
||||
- Empty groups stay visible with count `0`.
|
||||
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
|
||||
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
|
||||
@@ -395,6 +395,8 @@ interface PulseCommit {
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
|
||||
@@ -531,7 +531,11 @@ flowchart TD
|
||||
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
|
||||
PULL --> PC{Result?}
|
||||
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Fast-forward| RV["reload vault + folders/views"]
|
||||
RV --> TAB{"clean active tab?"}
|
||||
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
|
||||
TAB -->|No| DONE["idle"]
|
||||
RT --> DONE
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0070"
|
||||
title: "Starter vaults are local-first with explicit remote connection"
|
||||
status: active
|
||||
date: 2026-04-19
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
|
||||
|
||||
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
|
||||
|
||||
## Decision
|
||||
|
||||
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
|
||||
|
||||
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
|
||||
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
|
||||
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
|
||||
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
|
||||
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
|
||||
- The starter repo remains a distribution source only, not an ongoing sync destination.
|
||||
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.
|
||||
@@ -125,3 +125,4 @@ proposed → active → superseded
|
||||
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
|
||||
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
|
||||
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
|
||||
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
|
||||
|
||||
@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
|
||||
re.replace_all(input, "<redacted-path>").to_string()
|
||||
}
|
||||
|
||||
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
|
||||
let value = raw?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unwrapped = match (value.chars().next(), value.chars().last()) {
|
||||
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
|
||||
value[1..value.len() - 1].trim()
|
||||
}
|
||||
_ => value,
|
||||
};
|
||||
|
||||
if unwrapped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(unwrapped.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_http_like_value(value: &str) -> String {
|
||||
if value.contains("://") {
|
||||
value.to_string()
|
||||
} else {
|
||||
format!("https://{value}")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
|
||||
let normalized = normalize_embedded_env(raw)?;
|
||||
normalize_http_like_value(&normalized).parse().ok()
|
||||
}
|
||||
|
||||
/// Initialize Sentry if the user has opted in to crash reporting.
|
||||
/// Returns `true` if Sentry was initialized, `false` if skipped.
|
||||
pub fn init_sentry_from_settings() -> bool {
|
||||
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
|
||||
if dsn.is_empty() {
|
||||
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let anonymous_id = settings.anonymous_id.unwrap_or_default();
|
||||
let guard = sentry::init(sentry::ClientOptions {
|
||||
dsn: dsn.parse().ok(),
|
||||
dsn: Some(dsn),
|
||||
release: Some(env!("CARGO_PKG_VERSION").into()),
|
||||
send_default_pii: false,
|
||||
before_send: Some(std::sync::Arc::new(|mut event| {
|
||||
@@ -86,6 +118,43 @@ mod tests {
|
||||
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_embedded_env_trims_wrapping_quotes() {
|
||||
assert_eq!(
|
||||
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
|
||||
Some("value")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_embedded_env(Some(" 'value' ")).as_deref(),
|
||||
Some("value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_embedded_env_drops_blank_values() {
|
||||
assert_eq!(normalize_embedded_env(Some(" ")), None);
|
||||
assert_eq!(normalize_embedded_env(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
|
||||
let parsed =
|
||||
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
|
||||
assert!(parsed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
|
||||
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
|
||||
assert!(parsed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
|
||||
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
|
||||
assert!(parsed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_sentry_returns_false_without_dsn() {
|
||||
// Without SENTRY_DSN env var set at compile time, init should return false
|
||||
|
||||
@@ -570,9 +570,10 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
const workVaultPath = '/Users/mock/Documents/Work'
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
|
||||
active_vault: '/Users/mock/Documents/Getting Started',
|
||||
vaults: [{ label: 'Work Vault', path: workVaultPath }],
|
||||
active_vault: workVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
@@ -584,14 +585,7 @@ describe('App', () => {
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
const vaultPaths = [
|
||||
'/Users/mock/Documents/Getting Started',
|
||||
'/Users/mock/demo-vault-v2',
|
||||
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
|
||||
]
|
||||
for (const path of vaultPaths) {
|
||||
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
|
||||
}
|
||||
localStorage.setItem(`laputa:vault-config:${workVaultPath}`, disabledWorkflowConfig)
|
||||
|
||||
render(<App />)
|
||||
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -74,6 +74,7 @@ import type { NoteListItem } from './utils/ai-context'
|
||||
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
@@ -404,17 +405,6 @@ function App() {
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
|
||||
// Detect external file renames on window focus
|
||||
@@ -487,8 +477,41 @@ function App() {
|
||||
const {
|
||||
handleSelectNote,
|
||||
handleReplaceActiveTab,
|
||||
closeAllTabs,
|
||||
openTabWithContent,
|
||||
} = notes
|
||||
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
|
||||
await refreshPulledVaultState({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
reloadFolders: vault.reloadFolders,
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadViews: vault.reloadViews,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
}, [
|
||||
closeAllTabs,
|
||||
handleReplaceActiveTab,
|
||||
notes.activeTabPath,
|
||||
resolvedPath,
|
||||
vault.reloadFolders,
|
||||
vault.reloadVault,
|
||||
vault.reloadViews,
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const pulseCommitDiffRequestIdRef = useRef(0)
|
||||
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
|
||||
|
||||
@@ -592,8 +615,14 @@ function App() {
|
||||
}, [handleEnterNeighborhood, handleReplaceActiveTab])
|
||||
|
||||
const vaultBridge = useVaultBridge({
|
||||
entriesByPath, resolvedPath,
|
||||
entriesByPath,
|
||||
resolvedPath,
|
||||
reloadVault: vault.reloadVault,
|
||||
reloadFolders: vault.reloadFolders,
|
||||
reloadViews: vault.reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab: handleReplaceActiveTab,
|
||||
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
})
|
||||
@@ -858,7 +887,8 @@ function App() {
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
||||
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
notes.handleCreateType(name)
|
||||
|
||||
10
src/assets/tolaria-icon.svg
Normal file
10
src/assets/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
@@ -77,6 +77,12 @@ describe('AiPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a new AI chat when the header action is clicked', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
fireEvent.click(screen.getByTitle('New AI chat'))
|
||||
expect(mockClearConversation).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders empty state without context', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
|
||||
@@ -153,6 +159,20 @@ describe('AiPanel', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('focuses the panel shell when reopening with existing messages', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockMessages = [{
|
||||
userMessage: 'Remember this',
|
||||
actions: [],
|
||||
response: 'Still here.',
|
||||
id: 'msg-3',
|
||||
}]
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(document.activeElement).toBe(screen.getByTestId('ai-panel'))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed while panel has focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
|
||||
import { useAiPanelPromptQueue } from './useAiPanelPromptQueue'
|
||||
import { useAiPanelFocus } from './useAiPanelFocus'
|
||||
|
||||
@@ -32,29 +32,31 @@ interface AiPanelProps {
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
}
|
||||
|
||||
export function AiPanel({
|
||||
interface AiPanelViewProps {
|
||||
controller: AiPanelController
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
}
|
||||
|
||||
export function AiPanelView({
|
||||
controller,
|
||||
onClose,
|
||||
onOpenNote,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: AiPanelProps) {
|
||||
}: AiPanelViewProps) {
|
||||
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
|
||||
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
|
||||
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
@@ -64,24 +66,17 @@ export function AiPanel({
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
} = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
handleNewChat,
|
||||
} = controller
|
||||
|
||||
useAiPanelPromptQueue({ agent, input, isActive, setInput })
|
||||
useAiPanelFocus({ inputRef, panelRef, isActive, onClose })
|
||||
useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
hasMessages: agent.messages.length > 0,
|
||||
isActive,
|
||||
onClose,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -99,7 +94,13 @@ export function AiPanel({
|
||||
data-testid="ai-panel"
|
||||
data-ai-active={isActive || undefined}
|
||||
>
|
||||
<AiPanelHeader agentLabel={agentLabel} agentReady={defaultAiAgentReady} legacyCopy={useLegacyAiExperience} onClose={onClose} onClear={agent.clearConversation} />
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReady={defaultAiAgentReady}
|
||||
legacyCopy={useLegacyAiExperience}
|
||||
onClose={onClose}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
{activeEntry && (
|
||||
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
|
||||
)}
|
||||
@@ -128,3 +129,48 @@ export function AiPanel({
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
vaultPath,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
}: AiPanelProps) {
|
||||
const controller = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
|
||||
activeEntry,
|
||||
activeNoteContent,
|
||||
entries,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
|
||||
return (
|
||||
<AiPanelView
|
||||
controller={controller}
|
||||
onClose={onClose}
|
||||
onOpenNote={onOpenNote}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
activeEntry={activeEntry}
|
||||
entries={entries}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface AiPanelHeaderProps {
|
||||
agentReady: boolean
|
||||
legacyCopy: boolean
|
||||
onClose: () => void
|
||||
onClear: () => void
|
||||
onNewChat: () => void
|
||||
}
|
||||
|
||||
interface AiPanelContextBarProps {
|
||||
@@ -111,7 +111,7 @@ export function AiPanelHeader({
|
||||
agentReady,
|
||||
legacyCopy,
|
||||
onClose,
|
||||
onClear,
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -132,8 +132,9 @@ export function AiPanelHeader({
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onClear}
|
||||
title="New conversation"
|
||||
onClick={onNewChat}
|
||||
aria-label="New AI chat"
|
||||
title="New AI chat"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
@@ -528,6 +528,37 @@ describe('click empty editor space', () => {
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
it('restores the cursor to the H1 when clicking the title block', async () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
mockEditor.document = [
|
||||
{ id: 'title', type: 'heading', content: [{ type: 'text', text: 'Alpha Project', styles: {} }], props: { level: 1 }, children: [] },
|
||||
{ id: 'body', type: 'paragraph', content: [], props: {}, children: [] },
|
||||
]
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')!
|
||||
const editableDiv = document.createElement('div')
|
||||
editableDiv.setAttribute('contenteditable', 'true')
|
||||
const heading = document.createElement('h1')
|
||||
heading.textContent = 'Alpha Project'
|
||||
heading.setAttribute('data-content-type', 'heading')
|
||||
heading.setAttribute('data-level', '1')
|
||||
editableDiv.appendChild(heading)
|
||||
container.appendChild(editableDiv)
|
||||
|
||||
fireEvent.click(heading)
|
||||
await act(() => Promise.resolve())
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('title', 'end')
|
||||
expect(mockEditor.focus).toHaveBeenCalled()
|
||||
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('archived note behavior', () => {
|
||||
@@ -598,6 +629,12 @@ describe('wikilink autocomplete', () => {
|
||||
expect(mockFilterSuggestionItems).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes BlockNote trigger-prefixed wikilink queries before filtering', async () => {
|
||||
renderWithEntries()
|
||||
const items = await capturedGetItems!('[[Al')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('limits results to MAX_RESULTS (20)', async () => {
|
||||
// Create many entries that will all match
|
||||
const manyEntries = Array.from({ length: 50 }, (_, i) => ({
|
||||
@@ -633,7 +670,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test' } },
|
||||
' ',
|
||||
])
|
||||
], { updateSelection: true })
|
||||
})
|
||||
|
||||
it('deduplicates entries with the same path', async () => {
|
||||
@@ -790,7 +827,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini' } },
|
||||
' ',
|
||||
])
|
||||
], { updateSelection: true })
|
||||
})
|
||||
|
||||
it('shows Person type badge on results', async () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import { AiPanelView } from './AiPanel'
|
||||
import { useAiPanelController } from './useAiPanelController'
|
||||
import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
|
||||
interface EditorRightPanelProps {
|
||||
showAIChat?: boolean
|
||||
@@ -43,26 +46,45 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
const aiPanelController = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
activeEntry: inspectorEntry,
|
||||
activeNoteContent: inspectorContent,
|
||||
entries,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
})
|
||||
const { handleNewChat } = aiPanelController
|
||||
|
||||
useEffect(() => {
|
||||
const handleRequestedNewChat = () => {
|
||||
handleNewChat()
|
||||
}
|
||||
|
||||
window.addEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
|
||||
return () => window.removeEventListener(NEW_AI_CHAT_EVENT, handleRequestedNewChat)
|
||||
}, [handleNewChat])
|
||||
|
||||
if (showAIChat) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 flex flex-col min-h-0"
|
||||
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
|
||||
>
|
||||
<AiPanel
|
||||
<AiPanelView
|
||||
controller={aiPanelController}
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -451,8 +451,8 @@ Status: Active
|
||||
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides referenced-by section when no entries reference the current note', () => {
|
||||
@@ -559,7 +559,7 @@ Status: Active
|
||||
/>
|
||||
)
|
||||
// noteA shows in Referenced By (via Belongs to)
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
|
||||
@@ -265,17 +265,29 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
describe('relation editing', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const renderEditableRelationships = (
|
||||
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
|
||||
) => renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
...overrides,
|
||||
})
|
||||
const openInlineAdd = (value?: string) => {
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
if (value !== undefined) {
|
||||
fireEvent.change(input, { target: { value } })
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows remove buttons on relation refs when editing is enabled', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
expect(screen.getByTestId('remove-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -285,91 +297,59 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('calls onDeleteProperty when removing the last ref in a group', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
fireEvent.click(screen.getByTestId('remove-relation-ref'))
|
||||
expect(onDeleteProperty).toHaveBeenCalledWith('Belongs to')
|
||||
})
|
||||
|
||||
it('calls onUpdateProperty with remaining refs when removing one of many', () => {
|
||||
renderRelationshipsPanel({
|
||||
it.each([
|
||||
{
|
||||
name: 'calls onUpdateProperty with remaining refs when removing one of many',
|
||||
frontmatter: { 'Related to': ['[[project/my-project]]', '[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
const removeButtons = screen.getAllByTestId('remove-relation-ref')
|
||||
fireEvent.click(removeButtons[0]) // Remove first ref
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Related to', '[[topic/ai]]')
|
||||
})
|
||||
|
||||
it('calls onUpdateProperty with string when two refs become one', () => {
|
||||
renderRelationshipsPanel({
|
||||
expectedKey: 'Related to',
|
||||
expectedValue: '[[topic/ai]]',
|
||||
},
|
||||
{
|
||||
name: 'calls onUpdateProperty with string when two refs become one',
|
||||
frontmatter: { Has: ['[[project/my-project]]', '[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
expectedKey: 'Has',
|
||||
expectedValue: '[[topic/ai]]',
|
||||
},
|
||||
])('$name', ({ frontmatter, expectedKey, expectedValue }) => {
|
||||
renderEditableRelationships({ frontmatter })
|
||||
const removeButtons = screen.getAllByTestId('remove-relation-ref')
|
||||
fireEvent.click(removeButtons[0])
|
||||
// Should pass a single string, not an array of one
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Has', '[[topic/ai]]')
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith(expectedKey, expectedValue)
|
||||
})
|
||||
|
||||
it('shows inline add button for each relationship group when editing is enabled', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
renderEditableRelationships()
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens inline add input when add button clicked', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
renderEditableRelationships()
|
||||
openInlineAdd()
|
||||
expect(screen.getByTestId('add-relation-ref-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a note to an existing relationship via inline add', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd('AI')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[topic/ai]]'])
|
||||
})
|
||||
|
||||
it('does not add duplicate refs', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[topic/ai]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderEditableRelationships({ frontmatter: { 'Belongs to': ['[[topic/ai]]'] } })
|
||||
const input = openInlineAdd('AI')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes inline add on Escape', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
renderEditableRelationships()
|
||||
const input = openInlineAdd()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('add-relation-ref-input')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
@@ -380,6 +360,21 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
const renderInlineCreatePanel = (
|
||||
overrides: Partial<ComponentProps<typeof DynamicRelationshipsPanel>> = {},
|
||||
) => renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
...overrides,
|
||||
})
|
||||
const openInlineCreate = (value: string) => {
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
return input
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -387,43 +382,22 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('shows "Create & open" option when typed title does not match any note', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Brand New Note')
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when typed title matches an existing note', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('AI')
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Brand New Note')
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
@@ -433,15 +407,8 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
|
||||
it('does not add wikilink when note creation fails', async () => {
|
||||
onCreateAndOpenNote.mockResolvedValue(false)
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Failing Note' } })
|
||||
renderInlineCreatePanel()
|
||||
openInlineCreate('Failing Note')
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
|
||||
// Give async handler time to resolve
|
||||
@@ -453,29 +420,16 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
})
|
||||
|
||||
it('shows both existing matches and "Create & open" for partial matches', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onCreateAndOpenNote,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
// "My" partially matches "My Project" but is not an exact match
|
||||
fireEvent.change(input, { target: { value: 'My' } })
|
||||
renderInlineCreatePanel()
|
||||
// "My" partially matches "My Project" but is not an exact match.
|
||||
openInlineCreate('My')
|
||||
// Should show search results AND create option
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
|
||||
renderRelationshipsPanel({
|
||||
frontmatter: { 'Belongs to': ['[[project/my-project]]'] },
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
renderInlineCreatePanel({ onCreateAndOpenNote: undefined })
|
||||
openInlineCreate('Brand New Note')
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -597,8 +551,8 @@ describe('ReferencedByPanel', () => {
|
||||
expect(screen.getByText('Write Essays')).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/← Related to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking a referenced-by entry', () => {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('NoteList rendering', () => {
|
||||
it('shows referenced-by groups for topic entities', () => {
|
||||
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[4] } })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced By')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the search input from the header action', () => {
|
||||
@@ -318,7 +318,7 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -348,7 +348,7 @@ describe('NoteList rendering', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Referenced by/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -375,7 +375,7 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByText('Related to')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced By')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -128,6 +128,128 @@ function shouldIgnoreContainerClick(target: HTMLElement) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSuggestionQuery(query: string, triggerCharacter: string): string {
|
||||
return query.startsWith(triggerCharacter)
|
||||
? query.slice(triggerCharacter.length)
|
||||
: query
|
||||
}
|
||||
|
||||
function isSelectionInsideElement(element: HTMLElement): boolean {
|
||||
const selection = window.getSelection()
|
||||
const anchorNode = selection?.anchorNode ?? null
|
||||
const anchorElement = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
|
||||
return Boolean(anchorElement && element.contains(anchorElement))
|
||||
}
|
||||
|
||||
function queueTitleHeadingCursorRepair(
|
||||
target: HTMLElement,
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
): boolean {
|
||||
const titleHeading = target.closest<HTMLElement>(
|
||||
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
|
||||
)
|
||||
if (!titleHeading) return false
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (isSelectionInsideElement(titleHeading)) return
|
||||
|
||||
const firstBlock = editor.document[0]
|
||||
if (firstBlock?.type !== 'heading') return
|
||||
|
||||
editor.setTextCursorPosition(firstBlock.id, 'end')
|
||||
editor.focus()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function useEditorContainerClickHandler(options: {
|
||||
editable: boolean
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
}) {
|
||||
const { editable, editor } = options
|
||||
|
||||
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (queueTitleHeadingCursorRepair(target, editor)) return
|
||||
if (shouldIgnoreContainerClick(target)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
}
|
||||
|
||||
function buildBaseSuggestionItems(entries: VaultEntry[]) {
|
||||
return deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
})))
|
||||
}
|
||||
|
||||
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
return useCallback((target: string) => {
|
||||
editor.insertInlineContent([
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
], { updateSelection: true })
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
}
|
||||
|
||||
function useSuggestionMenuItems(options: {
|
||||
baseItems: ReturnType<typeof buildBaseSuggestionItems>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
insertWikilink: (target: string) => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const {
|
||||
baseItems,
|
||||
editor,
|
||||
insertWikilink,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
} = options
|
||||
|
||||
const buildItems = useCallback((query: string, triggerCharacter: '[[' | '@') => {
|
||||
const normalizedQuery = normalizeSuggestionQuery(query, triggerCharacter)
|
||||
const minLength = triggerCharacter === '[[' ? MIN_QUERY_LENGTH : PERSON_MENTION_MIN_QUERY
|
||||
if (normalizedQuery.length < minLength) return null
|
||||
|
||||
const candidates = triggerCharacter === '[['
|
||||
? preFilterWikilinks(baseItems, normalizedQuery)
|
||||
: filterPersonMentions(baseItems, normalizedQuery)
|
||||
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
|
||||
buildItems(query, '[[') ?? []
|
||||
), [buildItems])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
|
||||
buildItems(query, '@') ?? []
|
||||
), [buildItems])
|
||||
|
||||
const getSlashMenuItems = useCallback(async (query: string) => (
|
||||
getTolariaSlashMenuItems(editor, query)
|
||||
), [editor])
|
||||
|
||||
return {
|
||||
getWikilinkItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
@@ -150,22 +272,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
}) {
|
||||
const { cssVars } = useEditorTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
useBlockNoteSideMenuHoverGuard(containerRef)
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (shouldIgnoreContainerClick(target)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
|
||||
useEffect(() => {
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
@@ -173,44 +285,19 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
useSeedBlockNoteTableBridge(editor)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries]
|
||||
)
|
||||
|
||||
const insertWikilink = useCallback((target: string) => {
|
||||
editor.insertInlineContent([
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
])
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < PERSON_MENTION_MIN_QUERY) return []
|
||||
const candidates = filterPersonMentions(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getSlashMenuItems = useCallback(async (query: string) => (
|
||||
getTolariaSlashMenuItems(editor, query)
|
||||
), [editor])
|
||||
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
|
||||
const insertWikilink = useInsertWikilink(editor)
|
||||
const {
|
||||
getWikilinkItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
} = useSuggestionMenuItems({
|
||||
baseItems,
|
||||
editor,
|
||||
insertWikilink,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('StatusBar', () => {
|
||||
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes a hover-revealed remove action for non-active vaults', () => {
|
||||
it('exposes an in-row, hover-revealed remove action for non-active vaults', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
@@ -237,8 +237,16 @@ describe('StatusBar', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
|
||||
expect(screen.getByTestId('vault-menu-item-Work Vault').className).toContain('hover:bg-[var(--hover)]')
|
||||
expect(screen.getByTestId('vault-menu-remove-Work Vault').className).toContain('group-hover:opacity-100')
|
||||
const item = screen.getByTestId('vault-menu-item-Work Vault')
|
||||
const removeAction = screen.getByTestId('vault-menu-remove-Work Vault')
|
||||
|
||||
expect(item.className).toContain('hover:bg-[var(--hover)]')
|
||||
expect(item.className).toContain('pr-7')
|
||||
expect(removeAction.className).toContain('absolute')
|
||||
expect(removeAction.className).toContain('right-1')
|
||||
expect(removeAction.className).toContain('group-hover:opacity-100')
|
||||
expect(removeAction.className).toContain('group-focus-within:opacity-100')
|
||||
expect(removeAction.className).toContain('pointer-events-none')
|
||||
expect(screen.getByRole('button', { name: 'Remove Work Vault from list' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
@@ -30,19 +31,28 @@ describe('WelcomeScreen', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows all three option buttons', () => {
|
||||
it('renders the local Tolaria branding icon', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
|
||||
|
||||
const brandIcon = screen.getByAltText('Tolaria icon')
|
||||
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
|
||||
})
|
||||
|
||||
it('shows the onboarding actions in the guided-first order', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
|
||||
const optionButtons = screen.getAllByRole('button')
|
||||
expect(optionButtons[0]).toBe(screen.getByTestId('welcome-create-vault'))
|
||||
expect(optionButtons[1]).toBe(screen.getByTestId('welcome-create-new'))
|
||||
expect(optionButtons[2]).toBe(screen.getByTestId('welcome-open-folder'))
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows the simplified template option description', () => {
|
||||
@@ -101,13 +111,13 @@ describe('WelcomeScreen', () => {
|
||||
})
|
||||
|
||||
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
|
||||
const onOpenFolder = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
|
||||
const onCreateEmptyVault = vi.fn()
|
||||
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Tab' })
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
|
||||
expect(onOpenFolder).toHaveBeenCalledOnce()
|
||||
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables all buttons while creating', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
mode: 'welcome' | 'vault-missing'
|
||||
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const BRAND_ICON_STYLE: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const TITLE_STYLE: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
|
||||
): WelcomeScreenPresentation {
|
||||
if (mode === 'welcome') {
|
||||
return {
|
||||
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
|
||||
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>✦</span>,
|
||||
heroBackground: 'transparent',
|
||||
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
|
||||
openFolderLabel: 'Open existing vault',
|
||||
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
|
||||
subtitle: 'Markdown knowledge management for the age of AI',
|
||||
templateDescription: isOffline
|
||||
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
|
||||
: 'Download the getting started vault',
|
||||
@@ -303,18 +310,18 @@ function useWelcomeActionButtons({
|
||||
> & {
|
||||
busy: boolean
|
||||
}) {
|
||||
const primaryActionRef = useRef<HTMLButtonElement>(null)
|
||||
const openFolderActionRef = useRef<HTMLButtonElement>(null)
|
||||
const templateActionRef = useRef<HTMLButtonElement>(null)
|
||||
const createEmptyActionRef = useRef<HTMLButtonElement>(null)
|
||||
const openFolderActionRef = useRef<HTMLButtonElement>(null)
|
||||
const actionButtonRefs = useMemo(
|
||||
() => [primaryActionRef, openFolderActionRef, templateActionRef],
|
||||
() => [templateActionRef, createEmptyActionRef, openFolderActionRef],
|
||||
[],
|
||||
)
|
||||
const actions = useMemo<WelcomeAction[]>(
|
||||
() => ([
|
||||
{ disabled: isOffline, run: onCreateVault },
|
||||
{ disabled: false, run: onCreateEmptyVault },
|
||||
{ disabled: false, run: onOpenFolder },
|
||||
{ disabled: isOffline, run: onCreateVault },
|
||||
]),
|
||||
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
|
||||
)
|
||||
@@ -323,7 +330,7 @@ function useWelcomeActionButtons({
|
||||
if (busy) return
|
||||
|
||||
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
|
||||
// onboarding always starts on "Create empty vault".
|
||||
// onboarding always starts on the guided template flow.
|
||||
focusWelcomeAction(actionButtonRefs, 0)
|
||||
}, [actionButtonRefs, busy, mode])
|
||||
|
||||
@@ -355,9 +362,9 @@ function useWelcomeActionButtons({
|
||||
}, [actionButtonRefs, actions, busy])
|
||||
|
||||
return {
|
||||
primaryActionRef,
|
||||
openFolderActionRef,
|
||||
templateActionRef,
|
||||
createEmptyActionRef,
|
||||
openFolderActionRef,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +382,7 @@ export function WelcomeScreen({
|
||||
}: WelcomeScreenProps) {
|
||||
const busy = creatingAction !== null
|
||||
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
|
||||
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
|
||||
const { templateActionRef, createEmptyActionRef, openFolderActionRef } = useWelcomeActionButtons({
|
||||
mode,
|
||||
busy,
|
||||
isOffline,
|
||||
@@ -410,6 +417,21 @@ export function WelcomeScreen({
|
||||
<div style={DIVIDER_STYLE} />
|
||||
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<OptionButton
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={presentation.templateDescription}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault template"
|
||||
onClick={onCreateVault}
|
||||
disabled={busy || isOffline}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
autoFocus
|
||||
buttonRef={templateActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
|
||||
iconBg="var(--accent-blue-light, #EBF4FF)"
|
||||
@@ -421,8 +443,7 @@ export function WelcomeScreen({
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
autoFocus
|
||||
buttonRef={primaryActionRef}
|
||||
buttonRef={createEmptyActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
@@ -435,20 +456,6 @@ export function WelcomeScreen({
|
||||
testId="welcome-open-folder"
|
||||
buttonRef={openFolderActionRef}
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={presentation.templateDescription}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault template"
|
||||
onClick={onCreateVault}
|
||||
disabled={busy || isOffline}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
buttonRef={templateActionRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{creatingAction === 'template' && (
|
||||
|
||||
@@ -24,16 +24,16 @@ interface WikilinkSuggestionMenuProps {
|
||||
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
|
||||
return (
|
||||
<div className="wikilink-menu">
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={selectedIndex ?? 0}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
emptyMessage="No results"
|
||||
/>
|
||||
<NoteSearchList
|
||||
items={items}
|
||||
selectedIndex={selectedIndex ?? 0}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => {
|
||||
item.onItemClick()
|
||||
onItemClick?.(item)
|
||||
}}
|
||||
emptyMessage="No results"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { humanizePropertyKey } from '../../utils/propertyLabels'
|
||||
import { orderInverseRelationshipLabels, resolveInverseRelationshipLabel } from '../../utils/inverseRelationshipLabels'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { LinkButton } from './LinkButton'
|
||||
@@ -16,24 +16,37 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry[]>()
|
||||
const map = new Map<string, Map<string, VaultEntry>>()
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.viaKey)
|
||||
if (existing) existing.push(item.entry)
|
||||
else map.set(item.viaKey, [item.entry])
|
||||
const label = resolveInverseRelationshipLabel(item.viaKey, item.entry)
|
||||
const entriesByPath = map.get(label) ?? new Map<string, VaultEntry>()
|
||||
entriesByPath.set(item.entry.path, item.entry)
|
||||
map.set(label, entriesByPath)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
|
||||
return orderInverseRelationshipLabels(map.keys()).map((label) => [
|
||||
label,
|
||||
[...(map.get(label)?.values() ?? [])],
|
||||
] as const)
|
||||
}, [items])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="referenced-by-panel">
|
||||
<div className="mb-2 flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/80">
|
||||
Derived relationships
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/80">
|
||||
Read-only groups sourced from other notes.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
{grouped.map(([label, groupEntries]) => (
|
||||
<div key={label}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
← {humanizePropertyKey(viaKey)}
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{groupEntries.map((e) => {
|
||||
|
||||
@@ -112,7 +112,25 @@ describe('relationship display labels', () => {
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText(/← Belongs to/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dedupes canonical and legacy inverse keys into a single normalized inspector group', () => {
|
||||
const entry = makeEntry({ path: '/vault/project-alpha.md', filename: 'project-alpha.md', title: 'Project Alpha', isA: 'Project' })
|
||||
const items: ReferencedByItem[] = [
|
||||
{ entry, viaKey: 'belongs_to' },
|
||||
{ entry, viaKey: 'Belongs to' },
|
||||
{ entry: makeEntry({ path: '/vault/topic-alpha.md', filename: 'topic-alpha.md', title: 'Topic Alpha', isA: 'Note' }), viaKey: 'related_to' },
|
||||
]
|
||||
|
||||
render(<ReferencedByPanel items={items} typeEntryMap={{}} onNavigate={onNavigate} />)
|
||||
|
||||
expect(screen.getByText('Children')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referenced by')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Project Alpha')).toHaveLength(1)
|
||||
expect(screen.queryByText(/← Belongs to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/← belongs_to/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,9 +104,16 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
|
||||
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
|
||||
const unavailable = vault.available === false
|
||||
const removeLabel = `Remove ${vault.label} from list`
|
||||
const itemClassName = [
|
||||
'w-full justify-start rounded-sm px-2 py-1 text-xs font-normal',
|
||||
canRemove ? 'pr-7' : '',
|
||||
isActive
|
||||
? 'text-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-1 rounded-sm">
|
||||
<div className="group relative flex w-full items-center rounded-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -116,17 +123,14 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
className={isActive
|
||||
? 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
: 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
||||
}
|
||||
className={itemClassName}
|
||||
style={{
|
||||
height: 'auto',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
<span className="truncate">{vault.label}</span>
|
||||
</span>
|
||||
@@ -143,7 +147,7 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
|
||||
title={removeLabel}
|
||||
aria-label={removeLabel}
|
||||
data-testid={`vault-menu-remove-${vault.label}`}
|
||||
className="rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2 rounded-sm text-muted-foreground opacity-0 pointer-events-none transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:pointer-events-auto group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
>
|
||||
<X size={10} />
|
||||
</Button>
|
||||
|
||||
@@ -24,6 +24,18 @@ interface UseAiPanelControllerArgs {
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export interface AiPanelController {
|
||||
agent: ReturnType<typeof useCliAiAgent>
|
||||
input: string
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>
|
||||
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
|
||||
hasContext: boolean
|
||||
isActive: boolean
|
||||
handleSend: (text: string, references: NoteReference[]) => void
|
||||
handleNavigateWikilink: (target: string) => void
|
||||
handleNewChat: () => void
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
@@ -38,7 +50,7 @@ export function useAiPanelController({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: UseAiPanelControllerArgs) {
|
||||
}: UseAiPanelControllerArgs): AiPanelController {
|
||||
const [input, setInput] = useState('')
|
||||
const { linkedEntries, contextPrompt } = useAiPanelContextSnapshot({
|
||||
activeEntry,
|
||||
@@ -73,6 +85,11 @@ export function useAiPanelController({
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
agent.clearConversation()
|
||||
setInput('')
|
||||
}, [agent])
|
||||
|
||||
return {
|
||||
agent,
|
||||
input,
|
||||
@@ -82,5 +99,6 @@ export function useAiPanelController({
|
||||
isActive,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
handleNewChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,33 +3,53 @@ import { useCallback, useEffect } from 'react'
|
||||
interface UseAiPanelFocusArgs {
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
panelRef: React.RefObject<HTMLElement | null>
|
||||
hasMessages: boolean
|
||||
isActive: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function focusPreferredElement(
|
||||
panelRef: React.RefObject<HTMLElement | null>,
|
||||
inputRef: React.RefObject<HTMLDivElement | null>,
|
||||
shouldFocusPanel: boolean,
|
||||
) {
|
||||
if (shouldFocusPanel) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function shouldHandleEscape(
|
||||
event: KeyboardEvent,
|
||||
panelRef: React.RefObject<HTMLElement | null>,
|
||||
): boolean {
|
||||
return event.key === 'Escape' && !!panelRef.current?.contains(document.activeElement)
|
||||
}
|
||||
|
||||
export function useAiPanelFocus({
|
||||
inputRef,
|
||||
panelRef,
|
||||
hasMessages,
|
||||
isActive,
|
||||
onClose,
|
||||
}: UseAiPanelFocusArgs) {
|
||||
const shouldFocusPanel = hasMessages || isActive
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
const timer = setTimeout(() => {
|
||||
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [inputRef])
|
||||
}, [inputRef, panelRef, shouldFocusPanel])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
inputRef.current?.focus()
|
||||
}, [inputRef, isActive, panelRef])
|
||||
focusPreferredElement(panelRef, inputRef, shouldFocusPanel)
|
||||
}, [inputRef, panelRef, shouldFocusPanel])
|
||||
|
||||
const handleEscape = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (!panelRef.current?.contains(document.activeElement)) return
|
||||
if (!shouldHandleEscape(event, panelRef)) return
|
||||
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
|
||||
interface ViewCommandsConfig {
|
||||
hasActiveNote: boolean
|
||||
@@ -34,6 +35,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
|
||||
@@ -8,6 +8,13 @@ interface HeadingRange {
|
||||
to: number
|
||||
}
|
||||
|
||||
interface FocusableHeadingBlock {
|
||||
id: string
|
||||
type?: string
|
||||
props?: { level?: number } & Record<string, unknown>
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
@@ -21,6 +28,8 @@ export interface TiptapEditor {
|
||||
export interface FocusableEditor {
|
||||
focus: () => void
|
||||
_tiptapEditor?: TiptapEditor
|
||||
document?: FocusableHeadingBlock[]
|
||||
setTextCursorPosition?: (targetBlock: string, placement?: 'start' | 'end') => void
|
||||
}
|
||||
|
||||
function buildHeadingRange(pos: number, nodeSize: number): HeadingRange | null {
|
||||
@@ -42,7 +51,38 @@ function findFirstHeadingRange(tiptap: TiptapEditor): HeadingRange | null {
|
||||
return range
|
||||
}
|
||||
|
||||
function isTopLevelHeadingBlock(block: FocusableHeadingBlock): boolean {
|
||||
return block.type === 'heading' && (block.props?.level === undefined || block.props?.level === 1)
|
||||
}
|
||||
|
||||
function getHeadingBlockText(block: FocusableHeadingBlock | undefined): string {
|
||||
if (!Array.isArray(block?.content)) return ''
|
||||
|
||||
return block.content
|
||||
.filter((item): item is { type?: string; text?: string } => (
|
||||
typeof item === 'object' && item !== null
|
||||
))
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text ?? '')
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getFirstHeadingBlock(editor: FocusableEditor): FocusableHeadingBlock | undefined {
|
||||
return editor.document?.find(isTopLevelHeadingBlock)
|
||||
}
|
||||
|
||||
function trySelectEmptyFirstHeading(editor: FocusableEditor): boolean {
|
||||
const firstHeadingBlock = getFirstHeadingBlock(editor)
|
||||
if (!firstHeadingBlock || !editor.setTextCursorPosition) return false
|
||||
if (getHeadingBlockText(firstHeadingBlock)) return false
|
||||
|
||||
editor.setTextCursorPosition(firstHeadingBlock.id, 'start')
|
||||
return true
|
||||
}
|
||||
|
||||
function trySelectFirstHeading(editor: FocusableEditor): boolean {
|
||||
if (trySelectEmptyFirstHeading(editor)) return true
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return false
|
||||
|
||||
|
||||
@@ -75,12 +75,47 @@ describe('useAutoSync', () => {
|
||||
const { result } = renderSync()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).toHaveBeenCalled()
|
||||
expect(onVaultUpdated).toHaveBeenCalledWith(['note.md', 'project/plan.md'])
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
|
||||
expect(result.current.syncStatus).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for vault refresh before showing the updated toast', async () => {
|
||||
let releaseVaultRefresh: (() => void) | null = null
|
||||
const asyncVaultRefresh = vi.fn(() => new Promise<void>((resolve) => {
|
||||
releaseVaultRefresh = resolve
|
||||
}))
|
||||
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
return Promise.resolve(updated(['note.md']))
|
||||
})
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated: asyncVaultRefresh,
|
||||
onConflict,
|
||||
onToast,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(asyncVaultRefresh).toHaveBeenCalledWith(['note.md'])
|
||||
})
|
||||
expect(onToast).not.toHaveBeenCalledWith('Pulled 1 update(s) from remote')
|
||||
|
||||
await act(async () => {
|
||||
releaseVaultRefresh?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onToast).toHaveBeenCalledWith('Pulled 1 update(s) from remote')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onConflict and sets conflict status when pull has conflicts', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
|
||||
type MaybePromise = void | Promise<void>
|
||||
|
||||
type SyncCallbacks = Pick<UseAutoSyncOptions, 'onVaultUpdated' | 'onSyncUpdated' | 'onConflict' | 'onToast'>
|
||||
|
||||
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
@@ -13,8 +19,8 @@ function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
interface UseAutoSyncOptions {
|
||||
vaultPath: string
|
||||
intervalMinutes: number | null
|
||||
onVaultUpdated: () => void
|
||||
onSyncUpdated?: () => void
|
||||
onVaultUpdated: (updatedFiles: string[]) => MaybePromise
|
||||
onSyncUpdated?: () => MaybePromise
|
||||
onConflict: (files: string[]) => void
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
@@ -36,6 +42,214 @@ export interface AutoSyncState {
|
||||
handlePushRejected: () => void
|
||||
}
|
||||
|
||||
type SyncSetState<T> = Dispatch<SetStateAction<T>>
|
||||
|
||||
interface PullErrorResolution {
|
||||
checkExistingConflicts: () => Promise<boolean>
|
||||
notifyError?: string
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}
|
||||
|
||||
interface SyncTaskOptions {
|
||||
blockWhenPaused: boolean
|
||||
pauseRef: MutableRefObject<boolean>
|
||||
syncingRef: MutableRefObject<boolean>
|
||||
setLastSyncTime: SyncSetState<number | null>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
task: () => Promise<void>
|
||||
}
|
||||
|
||||
function clearConflictState(
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
): void {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
function setConflictState(
|
||||
files: string[],
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
): void {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(files)
|
||||
void callbacksRef.current.onConflict(files)
|
||||
}
|
||||
|
||||
function markPullTimestamp(
|
||||
setLastSyncTime: SyncSetState<number | null>,
|
||||
refreshCommitInfo: () => void,
|
||||
): void {
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
}
|
||||
|
||||
function useRemoteStatusRefresher(
|
||||
vaultPath: string,
|
||||
setRemoteStatus: SyncSetState<GitRemoteStatus | null>,
|
||||
) {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
|
||||
setRemoteStatus(status)
|
||||
return status
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [vaultPath, setRemoteStatus])
|
||||
}
|
||||
|
||||
function useConflictChecker(
|
||||
vaultPath: string,
|
||||
setSyncStatus: SyncSetState<SyncStatus>,
|
||||
setConflictFiles: SyncSetState<string[]>,
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>,
|
||||
) {
|
||||
return useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
if (!Array.isArray(files) || files.length === 0) return false
|
||||
setConflictState(files, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [vaultPath, setSyncStatus, setConflictFiles, callbacksRef])
|
||||
}
|
||||
|
||||
function useCommitInfoRefresher(
|
||||
vaultPath: string,
|
||||
setLastCommitInfo: SyncSetState<LastCommitInfo | null>,
|
||||
) {
|
||||
return useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath, setLastCommitInfo])
|
||||
}
|
||||
|
||||
async function handleUpdatedPull(options: {
|
||||
result: GitPullResult
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): Promise<void> {
|
||||
const {
|
||||
result,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
await callbacksRef.current.onVaultUpdated(result.updatedFiles)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
await callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
}
|
||||
|
||||
async function resolvePullError(options: PullErrorResolution): Promise<void> {
|
||||
const {
|
||||
checkExistingConflicts,
|
||||
notifyError,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (hasConflicts) return
|
||||
setSyncStatus('error')
|
||||
if (notifyError) await callbacksRef.current.onToast(notifyError)
|
||||
}
|
||||
|
||||
function handlePushResult(options: {
|
||||
pushResult: GitPushResult
|
||||
callbacksRef: MutableRefObject<SyncCallbacks>
|
||||
setConflictFiles: SyncSetState<string[]>
|
||||
setSyncStatus: SyncSetState<SyncStatus>
|
||||
}): void {
|
||||
const {
|
||||
pushResult,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
} = options
|
||||
if (pushResult.status === 'ok') {
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
void callbacksRef.current.onToast('Pulled and pushed successfully')
|
||||
return
|
||||
}
|
||||
if (pushResult.status === 'rejected') {
|
||||
setSyncStatus('pull_required')
|
||||
void callbacksRef.current.onToast('Push still rejected after pull — try again')
|
||||
return
|
||||
}
|
||||
setSyncStatus('error')
|
||||
void callbacksRef.current.onToast(pushResult.message)
|
||||
}
|
||||
|
||||
async function runSyncTask(options: SyncTaskOptions): Promise<void> {
|
||||
const {
|
||||
blockWhenPaused,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task,
|
||||
} = options
|
||||
if (syncingRef.current || (blockWhenPaused && pauseRef.current)) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
|
||||
try {
|
||||
await task()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function useAutoSyncLifecycle(options: {
|
||||
checkExistingConflicts: () => Promise<boolean>
|
||||
intervalMinutes: number | null
|
||||
performPull: () => Promise<void>
|
||||
refreshRemoteStatus: () => Promise<GitRemoteStatus | null>
|
||||
}) {
|
||||
const {
|
||||
checkExistingConflicts,
|
||||
intervalMinutes,
|
||||
performPull,
|
||||
refreshRemoteStatus,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
void checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) void performPull()
|
||||
})
|
||||
void refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
void performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
|
||||
useEffect(() => {
|
||||
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
|
||||
const id = setInterval(() => { void performPull() }, ms)
|
||||
return () => clearInterval(id)
|
||||
}, [performPull, intervalMinutes])
|
||||
}
|
||||
|
||||
export function useAutoSync({
|
||||
vaultPath,
|
||||
intervalMinutes,
|
||||
@@ -51,178 +265,111 @@ export function useAutoSync({
|
||||
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 {
|
||||
const files = await tauriCall<string[]>('get_conflict_files', { vaultPath })
|
||||
if (files.length > 0) {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(files)
|
||||
callbacksRef.current.onConflict(files)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// If the command doesn't exist (e.g. browser mock), ignore
|
||||
}
|
||||
return false
|
||||
}, [vaultPath])
|
||||
|
||||
const refreshCommitInfo = useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
}, [vaultPath])
|
||||
const callbacksRef = useRef<SyncCallbacks>({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
}, [onVaultUpdated, onSyncUpdated, onConflict, onToast])
|
||||
const refreshRemoteStatus = useRemoteStatusRefresher(vaultPath, setRemoteStatus)
|
||||
const checkExistingConflicts = useConflictChecker(vaultPath, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
const refreshCommitInfo = useCommitInfoRefresher(vaultPath, setLastCommitInfo)
|
||||
|
||||
const performPull = useCallback(async () => {
|
||||
if (syncingRef.current || pauseRef.current) return
|
||||
syncingRef.current = true
|
||||
setSyncStatus('syncing')
|
||||
await runSyncTask({
|
||||
blockWhenPaused: true,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
|
||||
try {
|
||||
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
setLastSyncTime(Date.now())
|
||||
refreshCommitInfo()
|
||||
|
||||
if (result.status === 'updated') {
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
} else if (result.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
setConflictFiles(result.conflictFiles)
|
||||
callbacksRef.current.onConflict(result.conflictFiles)
|
||||
} else if (result.status === 'error') {
|
||||
// Pull failed — check if there are pre-existing conflicts that caused it
|
||||
const hasConflicts = await checkExistingConflicts()
|
||||
if (!hasConflicts) {
|
||||
setSyncStatus('error')
|
||||
if (result.status === 'updated') {
|
||||
await handleUpdatedPull({
|
||||
result,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
})
|
||||
} else if (result.status === 'conflict') {
|
||||
setConflictState(result.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
} else if (result.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
})
|
||||
} else {
|
||||
clearConflictState(setSyncStatus, setConflictFiles)
|
||||
}
|
||||
} else {
|
||||
// up_to_date or no_remote
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
}
|
||||
|
||||
// Refresh remote status after pull
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
void refreshRemoteStatus()
|
||||
},
|
||||
})
|
||||
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, 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')
|
||||
await runSyncTask({
|
||||
blockWhenPaused: false,
|
||||
pauseRef,
|
||||
syncingRef,
|
||||
setLastSyncTime,
|
||||
setSyncStatus,
|
||||
task: async () => {
|
||||
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
|
||||
markPullTimestamp(setLastSyncTime, refreshCommitInfo)
|
||||
|
||||
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)
|
||||
if (pullResult.status === 'conflict') {
|
||||
setConflictState(pullResult.conflictFiles, setSyncStatus, setConflictFiles, callbacksRef)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pullResult.status === 'updated') {
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
if (pullResult.status === 'error') {
|
||||
await resolvePullError({
|
||||
checkExistingConflicts,
|
||||
notifyError: `Pull failed: ${pullResult.message}`,
|
||||
callbacksRef,
|
||||
setSyncStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if (pullResult.status === 'updated') {
|
||||
await callbacksRef.current.onVaultUpdated(pullResult.updatedFiles)
|
||||
await callbacksRef.current.onSyncUpdated?.()
|
||||
}
|
||||
|
||||
refreshRemoteStatus()
|
||||
} catch {
|
||||
setSyncStatus('error')
|
||||
setLastSyncTime(Date.now())
|
||||
} finally {
|
||||
syncingRef.current = false
|
||||
}
|
||||
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
|
||||
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
|
||||
handlePushResult({
|
||||
pushResult,
|
||||
callbacksRef,
|
||||
setConflictFiles,
|
||||
setSyncStatus,
|
||||
})
|
||||
|
||||
void refreshRemoteStatus()
|
||||
},
|
||||
})
|
||||
}, [vaultPath, refreshCommitInfo, checkExistingConflicts, refreshRemoteStatus])
|
||||
|
||||
const handlePushRejected = useCallback(() => {
|
||||
setSyncStatus('pull_required')
|
||||
}, [])
|
||||
|
||||
// Check for pre-existing conflicts on mount, then pull
|
||||
useEffect(() => {
|
||||
checkExistingConflicts().then(hasConflicts => {
|
||||
if (!hasConflicts) performPull()
|
||||
})
|
||||
refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
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])
|
||||
|
||||
// Periodic pull
|
||||
useEffect(() => {
|
||||
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
|
||||
const id = setInterval(performPull, ms)
|
||||
return () => clearInterval(id)
|
||||
}, [performPull, intervalMinutes])
|
||||
useAutoSyncLifecycle({
|
||||
checkExistingConflicts,
|
||||
intervalMinutes,
|
||||
performPull,
|
||||
refreshRemoteStatus,
|
||||
})
|
||||
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
const triggerSync = useCallback(() => {
|
||||
trackEvent('sync_triggered')
|
||||
performPull()
|
||||
void performPull()
|
||||
}, [performPull])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
|
||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -220,6 +221,24 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('includes a New AI chat command that opens and resets the panel session', () => {
|
||||
const config = makeConfig()
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'new-ai-chat')
|
||||
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('New AI chat')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
|
||||
cmd!.execute()
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: NEW_AI_CHAT_EVENT }))
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: OPEN_AI_CHAT_EVENT }))
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
||||
const config = makeConfig({ showInbox: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
import type { FocusableEditor } from './editorFocusUtils'
|
||||
|
||||
function makeTiptapMock(hasHeading: boolean | Array<number | null> = true, headingNodeSize = 15) {
|
||||
const headingSizes = Array.isArray(hasHeading)
|
||||
@@ -36,13 +37,20 @@ describe('useEditorFocus', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
function setup(
|
||||
isMounted: boolean,
|
||||
tiptap?: ReturnType<typeof makeTiptapMock>,
|
||||
editorOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
document.body.appendChild(editable)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
|
||||
const editor = {
|
||||
focus: vi.fn(() => editable.focus()),
|
||||
_tiptapEditor: tiptap,
|
||||
...editorOverrides,
|
||||
} as FocusableEditor & Record<string, unknown>
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return { editor, tiptap, editable }
|
||||
@@ -186,6 +194,25 @@ describe('useEditorFocus', () => {
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('places a text cursor inside an empty H1 instead of selecting through the next block', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const setTextCursorPosition = vi.fn()
|
||||
const { editor } = setup(true, tiptap, {
|
||||
document: [
|
||||
{ id: 'title', type: 'heading', props: { level: 1 }, content: [] },
|
||||
{ id: 'body', type: 'paragraph', props: {}, content: [] },
|
||||
],
|
||||
setTextCursorPosition,
|
||||
})
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(setTextCursorPosition).toHaveBeenCalledWith('title', 'start')
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects H1 text after timeout when editor not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus
|
||||
|
||||
@@ -10,6 +10,7 @@ function makeTab(path: string, title: string, body: string) {
|
||||
}
|
||||
|
||||
function makeMockEditor(currentMarkdown: string) {
|
||||
const markdownRef = { current: currentMarkdown }
|
||||
const docRef = {
|
||||
current: [
|
||||
{
|
||||
@@ -30,58 +31,95 @@ function makeMockEditor(currentMarkdown: string) {
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
replaceBlocks: vi.fn(),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => currentMarkdown),
|
||||
blocksToMarkdownLossy: vi.fn(() => markdownRef.current),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => []),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
setMarkdown: (markdown: string) => {
|
||||
markdownRef.current = markdown
|
||||
},
|
||||
}
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function setupMountedEditorMocks() {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
}
|
||||
|
||||
function renderRenameHarness(options?: { onContentChange?: ReturnType<typeof vi.fn> }) {
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = options?.onContentChange ?? vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const hook = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
return {
|
||||
editor,
|
||||
onContentChange,
|
||||
untitledTab,
|
||||
renamedTab,
|
||||
...hook,
|
||||
}
|
||||
}
|
||||
|
||||
async function settleRenameHarness(editor: ReturnType<typeof makeMockEditor>) {
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
}
|
||||
|
||||
async function expectRenameSessionContinues(options: { renamedTabArrivesLate: boolean }) {
|
||||
const {
|
||||
editor,
|
||||
onContentChange,
|
||||
renamedTab,
|
||||
result,
|
||||
rerender,
|
||||
untitledTab,
|
||||
} = renderRenameHarness()
|
||||
|
||||
await settleRenameHarness(editor)
|
||||
|
||||
if (options.renamedTabArrivesLate) {
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
}
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
it('keeps the live editor session when an untitled note auto-renames', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
setupMountedEditorMocks()
|
||||
await expectRenameSessionContinues({ renamedTabArrivesLate: false })
|
||||
})
|
||||
|
||||
it('still swaps when the next note does not match the live untitled body', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
@@ -107,13 +145,16 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
})
|
||||
|
||||
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
setupMountedEditorMocks()
|
||||
await expectRenameSessionContinues({ renamedTabArrivesLate: true })
|
||||
})
|
||||
|
||||
it('does not re-swap the active note when app state catches up with live typing', async () => {
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
@@ -122,29 +163,83 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
const syncedContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof syncedContent).toBe('string')
|
||||
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: syncedContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not re-swap while local wikilink insertion is ahead of the latest tab props', async () => {
|
||||
setupMountedEditorMocks()
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody')
|
||||
const onContentChange = vi.fn()
|
||||
const tab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [tab], activeTabPath: tab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[Mana')
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
const queryContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof queryContent).toBe('string')
|
||||
|
||||
editor.setMarkdown('# Fresh Title\n\nBody\n\n[[manage-sponsorships]] ')
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
const insertedContent = onContentChange.mock.calls.at(-1)?.[1]
|
||||
expect(typeof insertedContent).toBe('string')
|
||||
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: queryContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
rerender({
|
||||
tabs: [{ ...tab, content: insertedContent }],
|
||||
activeTabPath: tab.entry.path,
|
||||
})
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -361,6 +361,28 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-parses when the active tab content changes without a path change', async () => {
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const refreshedTabA = {
|
||||
...tabA,
|
||||
content: '---\ntitle: Note A\n---\n\n# Note A\n\nFresh after pull.',
|
||||
}
|
||||
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
})
|
||||
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
await rerenderWith({ tabs: [refreshedTabA], activeTabPath: 'a.md' })
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Fresh after pull.'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Tab {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
type EditorBlocks = any[]
|
||||
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
|
||||
type PendingLocalContent = { path: string; content: string }
|
||||
const TAB_STATE_CACHE_LIMIT = 24
|
||||
|
||||
interface TabSwapState {
|
||||
@@ -372,6 +373,8 @@ function useEditorChangeHandler(options: {
|
||||
onContentChangeRef: MutableRefObject<((path: string, content: string) => void) | undefined>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
editor,
|
||||
@@ -379,6 +382,8 @@ function useEditorChangeHandler(options: {
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
return useCallback(() => {
|
||||
@@ -393,8 +398,15 @@ function useEditorChangeHandler(options: {
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
onContentChangeRef.current?.(path, `${frontmatter}${bodyMarkdown}`)
|
||||
}, [editor, onContentChangeRef, prevActivePathRef, suppressChangeRef, tabsRef])
|
||||
const nextContent = `${frontmatter}${bodyMarkdown}`
|
||||
pendingLocalContentRef.current = { path, content: nextContent }
|
||||
cacheEditorState(tabCacheRef.current, path, {
|
||||
blocks,
|
||||
scrollTop: readEditorScrollTop(),
|
||||
sourceContent: nextContent,
|
||||
})
|
||||
onContentChangeRef.current?.(path, nextContent)
|
||||
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef])
|
||||
}
|
||||
|
||||
function consumeRawModeTransition(
|
||||
@@ -481,6 +493,113 @@ function syncActivePathTransition(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
function markRawModeReswapPending(options: {
|
||||
activeTabPath: string | null
|
||||
cache: Map<string, CachedTabState>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const { activeTabPath, cache, rawSwapPendingRef } = options
|
||||
if (!activeTabPath) return false
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
return true
|
||||
}
|
||||
|
||||
function currentEditorMatchesActiveTab(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
} = options
|
||||
|
||||
return Boolean(
|
||||
activeTabPath
|
||||
&& activeTab
|
||||
&& editorMountedRef.current
|
||||
&& typeof editor.blocksToMarkdownLossy === 'function'
|
||||
&& serializeEditorBody(editor) === normalizeTabBody({ content: activeTab.content }),
|
||||
)
|
||||
}
|
||||
|
||||
function cacheStableActiveTabAndClearPending(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
cacheStableActivePath({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
})
|
||||
pendingLocalContentRef.current = null
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldKeepPendingLocalContent(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const pendingLocalContent = pendingLocalContentRef.current
|
||||
if (!activeTabPath || !activeTab || pendingLocalContent?.path !== activeTabPath) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function consumePendingLocalContent(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const pendingLocalContent = pendingLocalContentRef.current
|
||||
if (!pendingLocalContent || pendingLocalContent.content !== activeTab?.content) return true
|
||||
return cacheStableActiveTabAndClearPending({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
|
||||
function handleStableActivePath(options: {
|
||||
pathChanged: boolean
|
||||
rawModeJustEnded: boolean
|
||||
@@ -490,6 +609,7 @@ function handleStableActivePath(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
pathChanged,
|
||||
@@ -500,14 +620,34 @@ function handleStableActivePath(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
if (pathChanged) return false
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
return false
|
||||
if (rawModeJustEnded) {
|
||||
return !markRawModeReswapPending({ activeTabPath, cache, rawSwapPendingRef })
|
||||
}
|
||||
if (currentEditorMatchesActiveTab({ activeTabPath, activeTab, editor, editorMountedRef })) {
|
||||
return cacheStableActiveTabAndClearPending({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
if (shouldKeepPendingLocalContent({ activeTabPath, activeTab, pendingLocalContentRef })) {
|
||||
return consumePendingLocalContent({
|
||||
cache,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
if (shouldRefreshStableActivePath({ activeTabPath, activeTab, cache })) return false
|
||||
if (rawSwapPendingRef.current) return true
|
||||
|
||||
cacheStableActivePath({
|
||||
@@ -520,6 +660,22 @@ function handleStableActivePath(options: {
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldRefreshStableActivePath(options: {
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
}): boolean {
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
} = options
|
||||
|
||||
if (!activeTabPath || !activeTab) return false
|
||||
const cachedState = cache.get(activeTabPath)
|
||||
return !cachedState || cachedState.sourceContent !== activeTab.content
|
||||
}
|
||||
|
||||
function cacheStableActivePath(options: {
|
||||
cache: Map<string, CachedTabState>
|
||||
activeTabPath: string | null
|
||||
@@ -779,6 +935,7 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
@@ -787,8 +944,13 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
if (state.pathChanged) {
|
||||
pendingLocalContentRef.current = null
|
||||
}
|
||||
|
||||
if (syncActivePathTransition({
|
||||
prevPath: state.prevPath,
|
||||
pathChanged: state.pathChanged,
|
||||
@@ -812,6 +974,7 @@ function shouldSkipScheduledTabSwap(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -827,6 +990,7 @@ function runTabSwapEffect(options: {
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
@@ -840,6 +1004,7 @@ function runTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
@@ -859,6 +1024,7 @@ function runTabSwapEffect(options: {
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
pendingLocalContentRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
@@ -889,6 +1055,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
@@ -902,6 +1069,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
@@ -917,6 +1085,7 @@ function useTabSwapEffect(options: {
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
}, [
|
||||
activeTabPath,
|
||||
@@ -930,6 +1099,7 @@ function useTabSwapEffect(options: {
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
tabs,
|
||||
pendingLocalContentRef,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -946,6 +1116,7 @@ function useTabSwapEffect(options: {
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode }: UseEditorTabSwapOptions) {
|
||||
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
|
||||
const pendingLocalContentRef = useRef<PendingLocalContent | null>(null)
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
@@ -960,6 +1131,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
onContentChangeRef,
|
||||
prevActivePathRef,
|
||||
suppressChangeRef,
|
||||
tabCacheRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
|
||||
useEditorMountState(editor, editorMountedRef, pendingSwapRef)
|
||||
@@ -975,6 +1148,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
pendingLocalContentRef,
|
||||
})
|
||||
|
||||
return { handleEditorChange, editorMountedRef }
|
||||
|
||||
105
src/hooks/useGitHistory.test.ts
Normal file
105
src/hooks/useGitHistory.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitCommit } from '../types'
|
||||
import { useGitHistory } from './useGitHistory'
|
||||
|
||||
const mockHistory: GitCommit[] = [
|
||||
{ hash: 'abc', shortHash: 'abc', author: 'luca', date: 1_700_000_000, message: 'Initial commit' },
|
||||
]
|
||||
|
||||
describe('useGitHistory', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits briefly before loading note history', async () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, true))
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(result.current).toEqual(mockHistory)
|
||||
})
|
||||
|
||||
it('skips loading when history is disabled', () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, false))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels stale pending loads when the active note changes quickly', async () => {
|
||||
const loadGitHistory = vi.fn((path: string) => Promise.resolve([
|
||||
{ ...mockHistory[0], hash: path, shortHash: path, message: path },
|
||||
]))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ path }) => useGitHistory(path, loadGitHistory, true),
|
||||
{ initialProps: { path: '/vault/a.md' as string | null } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
})
|
||||
|
||||
rerender({ path: '/vault/b.md' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).toHaveBeenCalledTimes(1)
|
||||
expect(loadGitHistory).toHaveBeenCalledWith('/vault/b.md')
|
||||
expect(result.current).toEqual([
|
||||
expect.objectContaining({ hash: '/vault/b.md', message: '/vault/b.md' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('clears previously loaded history when the inspector is hidden', async () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useGitHistory('/vault/a.md', loadGitHistory, enabled),
|
||||
{ initialProps: { enabled: true } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(mockHistory)
|
||||
|
||||
rerender({ enabled: false })
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,43 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { GitCommit } from '../types'
|
||||
|
||||
export function useGitHistory(activeTabPath: string | null, loadGitHistory: (path: string) => Promise<GitCommit[]>) {
|
||||
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
|
||||
const GIT_HISTORY_LOAD_DELAY_MS = 200
|
||||
|
||||
export function useGitHistory(
|
||||
activeTabPath: string | null,
|
||||
loadGitHistory: (path: string) => Promise<GitCommit[]>,
|
||||
enabled = true,
|
||||
) {
|
||||
const [loadedHistory, setLoadedHistory] = useState<{
|
||||
path: string | null
|
||||
commits: GitCommit[]
|
||||
}>({
|
||||
path: null,
|
||||
commits: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabPath) return
|
||||
loadGitHistory(activeTabPath).then(setGitHistory)
|
||||
}, [activeTabPath, loadGitHistory])
|
||||
if (!enabled || !activeTabPath) return
|
||||
|
||||
return activeTabPath ? gitHistory : []
|
||||
let cancelled = false
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadGitHistory(activeTabPath).then((history) => {
|
||||
if (cancelled) return
|
||||
setLoadedHistory({
|
||||
path: activeTabPath,
|
||||
commits: history,
|
||||
})
|
||||
})
|
||||
}, GIT_HISTORY_LOAD_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [activeTabPath, enabled, loadGitHistory])
|
||||
|
||||
return enabled && activeTabPath && loadedHistory.path === activeTabPath
|
||||
? loadedHistory.commits
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -152,15 +152,45 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('is a no-op when replacing with the same entry', async () => {
|
||||
it('treats /tmp and /private/tmp aliases as the same active note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
const beforeNavigate = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/private/tmp/vault/active.md', title: 'Active' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(
|
||||
makeEntry({ path: '/tmp/vault/active.md', title: 'Active' }),
|
||||
)
|
||||
})
|
||||
|
||||
expect(beforeNavigate).not.toHaveBeenCalled()
|
||||
expect(result.current.activeTabPath).toBe('/tmp/vault/active.md')
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
|
||||
})
|
||||
|
||||
it('reloads content when replacing with the same entry', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = { path: '/vault/a.md' }
|
||||
const entry = { path: '/vault/a.md', title: 'A' }
|
||||
await selectNote(result, entry)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry(entry))
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe('# Fresh after pull')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opens a note when no note is active', async () => {
|
||||
|
||||
@@ -93,7 +93,8 @@ function getCachedNoteContent(path: string): string | null {
|
||||
return prefetchCache.get(path)?.value ?? null
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
async function loadNoteContent(path: string, forceFresh = false): Promise<string> {
|
||||
if (forceFresh) return requestNoteContent({ path }).promise
|
||||
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
|
||||
}
|
||||
|
||||
@@ -112,6 +113,18 @@ function syncActiveTabPath(
|
||||
setActiveTabPath(path)
|
||||
}
|
||||
|
||||
function normalizeComparablePath(path: string): string {
|
||||
return path
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
|
||||
.replace(/\/+$/u, '')
|
||||
}
|
||||
|
||||
function pathsMatch(leftPath: string | null, rightPath: string | null): boolean {
|
||||
if (!leftPath || !rightPath) return false
|
||||
return normalizeComparablePath(leftPath) === normalizeComparablePath(rightPath)
|
||||
}
|
||||
|
||||
function setSingleTab(
|
||||
tabsRef: React.MutableRefObject<Tab[]>,
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>,
|
||||
@@ -126,7 +139,8 @@ function isAlreadyViewingPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
path: string,
|
||||
) {
|
||||
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
|
||||
return pathsMatch(activeTabPathRef.current, path)
|
||||
|| tabsRef.current.some((tab) => pathsMatch(tab.entry.path, path))
|
||||
}
|
||||
|
||||
function startEntryNavigation(options: {
|
||||
@@ -162,6 +176,7 @@ function shouldApplyLoadedEntry(options: {
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
cachedContent: string | null
|
||||
content: string
|
||||
forceReload: boolean
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
path: string
|
||||
}) {
|
||||
@@ -170,12 +185,14 @@ function shouldApplyLoadedEntry(options: {
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
path,
|
||||
} = options
|
||||
|
||||
if (navSeqRef.current !== seq) return false
|
||||
return cachedContent !== content || activeTabPathRef.current !== path
|
||||
if (forceReload) return true
|
||||
return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path)
|
||||
}
|
||||
|
||||
function handleEntryLoadFailure(options: {
|
||||
@@ -203,6 +220,7 @@ function handleEntryLoadFailure(options: {
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
forceReload?: boolean
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
@@ -211,6 +229,7 @@ async function navigateToEntry(options: {
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
forceReload = false,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
@@ -222,7 +241,7 @@ async function navigateToEntry(options: {
|
||||
failNoteOpenTrace(entry.path, 'binary-entry')
|
||||
return
|
||||
}
|
||||
if (isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
if (!forceReload && isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
finishNoteOpenTrace(entry.path)
|
||||
return
|
||||
@@ -239,13 +258,14 @@ async function navigateToEntry(options: {
|
||||
|
||||
try {
|
||||
markNoteOpenTrace(entry.path, 'contentLoadStart')
|
||||
const content = await loadNoteContent(entry.path)
|
||||
const content = await loadNoteContent(entry.path, forceReload)
|
||||
markNoteOpenTrace(entry.path, 'contentLoadEnd')
|
||||
if (!shouldApplyLoadedEntry({
|
||||
seq,
|
||||
navSeqRef,
|
||||
cachedContent,
|
||||
content,
|
||||
forceReload,
|
||||
activeTabPathRef,
|
||||
path: entry.path,
|
||||
})) return
|
||||
@@ -282,7 +302,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && currentPath !== targetPath) {
|
||||
if (beforeNavigate && currentPath && !pathsMatch(currentPath, targetPath)) {
|
||||
try {
|
||||
markNoteOpenTrace(targetPath, 'beforeNavigateStart')
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
@@ -299,7 +319,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
if (entry.path !== activeTabPathRef.current) {
|
||||
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
|
||||
beginNoteOpenTrace(entry.path, 'select-note')
|
||||
}
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
@@ -325,11 +345,12 @@ export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
if (entry.path !== activeTabPathRef.current) {
|
||||
if (!pathsMatch(entry.path, activeTabPathRef.current)) {
|
||||
beginNoteOpenTrace(entry.path, 'replace-active-tab')
|
||||
}
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
forceReload: true,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
|
||||
@@ -7,22 +7,54 @@ function makeEntry(path: string, title = 'Test'): VaultEntry {
|
||||
return { path, title, filename: path.split('/').pop()!, content: '', outgoingLinks: [], snippet: '', wordCount: 0, isA: 'Note', status: null, createdAt: null, modifiedAt: null, icon: null, tags: [] } as unknown as VaultEntry
|
||||
}
|
||||
|
||||
function expectVaultDerivedStateReloaded(options: {
|
||||
reloadVault: ReturnType<typeof vi.fn>
|
||||
reloadFolders: ReturnType<typeof vi.fn>
|
||||
reloadViews: ReturnType<typeof vi.fn>
|
||||
}) {
|
||||
const { reloadVault, reloadFolders, reloadViews } = options
|
||||
expect(reloadVault).toHaveBeenCalledOnce()
|
||||
expect(reloadFolders).toHaveBeenCalledOnce()
|
||||
expect(reloadViews).toHaveBeenCalledOnce()
|
||||
}
|
||||
|
||||
describe('useVaultBridge', () => {
|
||||
const onSelectNote = vi.fn()
|
||||
let reloadVault: ReturnType<typeof vi.fn>
|
||||
let reloadFolders: ReturnType<typeof vi.fn>
|
||||
let reloadViews: ReturnType<typeof vi.fn>
|
||||
let closeAllTabs: ReturnType<typeof vi.fn>
|
||||
let replaceActiveTab: ReturnType<typeof vi.fn>
|
||||
let hasUnsavedChanges: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reloadVault = vi.fn().mockResolvedValue([])
|
||||
reloadFolders = vi.fn()
|
||||
reloadViews = vi.fn()
|
||||
closeAllTabs = vi.fn()
|
||||
replaceActiveTab = vi.fn().mockResolvedValue(undefined)
|
||||
hasUnsavedChanges = vi.fn(() => false)
|
||||
})
|
||||
|
||||
function renderBridge(entries: VaultEntry[] = [], activeTabPath: string | null = null) {
|
||||
function renderBridge(
|
||||
entries: VaultEntry[] = [],
|
||||
activeTabPath: string | null = null,
|
||||
overrides: Partial<{
|
||||
hasUnsavedChanges: typeof hasUnsavedChanges
|
||||
}> = {},
|
||||
) {
|
||||
const entriesByPath = new Map(entries.map(e => [e.path, e]))
|
||||
return renderHook(() =>
|
||||
useVaultBridge({
|
||||
entriesByPath,
|
||||
resolvedPath: '/vault',
|
||||
reloadVault,
|
||||
reloadFolders,
|
||||
reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges: overrides.hasUnsavedChanges ?? hasUnsavedChanges,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}),
|
||||
@@ -87,27 +119,52 @@ describe('useVaultBridge', () => {
|
||||
expect(onSelectNote).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handleAgentFileModified reloads when active tab matches', () => {
|
||||
it('handleAgentFileModified refreshes the active tab with fresh disk content', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([], '/vault/active.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).toHaveBeenCalledOnce()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
|
||||
it('handleAgentFileModified does not reload for different tab', () => {
|
||||
it('handleAgentFileModified still refreshes vault-derived UI for other notes', async () => {
|
||||
const active = makeEntry('/vault/other.md', 'Other')
|
||||
reloadVault.mockResolvedValue([active])
|
||||
const { result } = renderBridge([], '/vault/other.md')
|
||||
|
||||
act(() => { result.current.handleAgentFileModified('active.md') })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).not.toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(active)
|
||||
})
|
||||
|
||||
it('handleAgentVaultChanged always reloads', () => {
|
||||
const { result } = renderBridge([])
|
||||
it('keeps unsaved active note content intact while reloading agent changes', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const hasUnsaved = vi.fn((path: string) => path === '/vault/active.md')
|
||||
const { result } = renderBridge([], '/vault/active.md', { hasUnsavedChanges: hasUnsaved })
|
||||
|
||||
act(() => { result.current.handleAgentVaultChanged() })
|
||||
await act(async () => { result.current.handleAgentFileModified('active.md') })
|
||||
|
||||
expect(reloadVault).toHaveBeenCalled()
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleAgentVaultChanged reloads vault-derived state and refreshes the active note when safe', async () => {
|
||||
const fresh = makeEntry('/vault/active.md', 'Fresh active')
|
||||
reloadVault.mockResolvedValue([fresh])
|
||||
const { result } = renderBridge([], '/vault/active.md')
|
||||
|
||||
await act(async () => { result.current.handleAgentVaultChanged() })
|
||||
|
||||
expectVaultDerivedStateReloaded({ reloadVault, reloadFolders, reloadViews })
|
||||
expect(closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(replaceActiveTab).toHaveBeenCalledWith(fresh)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { refreshPulledVaultState } from '../utils/pulledVaultRefresh'
|
||||
|
||||
interface VaultBridgeDeps {
|
||||
entriesByPath: Map<string, VaultEntry>
|
||||
resolvedPath: string
|
||||
reloadVault: () => Promise<unknown>
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
reloadFolders: () => Promise<unknown> | unknown
|
||||
reloadViews: () => Promise<unknown> | unknown
|
||||
closeAllTabs: () => void
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
activeTabPath: string | null
|
||||
}
|
||||
@@ -13,12 +19,21 @@ function findEntry(entriesByPath: Map<string, VaultEntry>, resolvedPath: string,
|
||||
return entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
function findInFresh(entries: unknown, resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return (entries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
function findInFresh(entries: VaultEntry[], resolvedPath: string, path: string): VaultEntry | undefined {
|
||||
return entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
}
|
||||
|
||||
export function useVaultBridge({
|
||||
entriesByPath, resolvedPath, reloadVault, onSelectNote, activeTabPath,
|
||||
entriesByPath,
|
||||
resolvedPath,
|
||||
reloadVault,
|
||||
reloadFolders,
|
||||
reloadViews,
|
||||
closeAllTabs,
|
||||
replaceActiveTab,
|
||||
hasUnsavedChanges,
|
||||
onSelectNote,
|
||||
activeTabPath,
|
||||
}: VaultBridgeDeps) {
|
||||
const reloadAndOpen = useCallback((path: string) => {
|
||||
reloadVault().then(fresh => {
|
||||
@@ -27,6 +42,29 @@ export function useVaultBridge({
|
||||
})
|
||||
}, [reloadVault, onSelectNote, resolvedPath])
|
||||
|
||||
const refreshAgentChanges = useCallback((updatedFiles: string[]) => (
|
||||
refreshPulledVaultState({
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
), [
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
resolvedPath,
|
||||
])
|
||||
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = findEntry(entriesByPath, resolvedPath, path)
|
||||
if (entry) onSelectNote(entry)
|
||||
@@ -40,11 +78,12 @@ export function useVaultBridge({
|
||||
}, [entriesByPath, resolvedPath, onSelectNote])
|
||||
|
||||
const handleAgentFileModified = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
if (activeTabPath === relativePath || activeTabPath === fullPath) reloadVault()
|
||||
}, [reloadVault, activeTabPath, resolvedPath])
|
||||
void refreshAgentChanges([relativePath])
|
||||
}, [refreshAgentChanges])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => { reloadVault() }, [reloadVault])
|
||||
const handleAgentVaultChanged = useCallback(() => {
|
||||
void refreshAgentChanges([])
|
||||
}, [refreshAgentChanges])
|
||||
|
||||
return {
|
||||
openNoteByPath,
|
||||
|
||||
@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
|
||||
expect(result.current.isGettingStartedHidden).toBe(false)
|
||||
})
|
||||
|
||||
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [
|
||||
{ label: 'Getting Started', path: expectedDefaultVaultPath },
|
||||
{ label: 'Work', path: '/work/vault' },
|
||||
],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: ({ path }) => path === '/work/vault',
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
|
||||
expect(result.current.vaultPath).toBe('/work/vault')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
|
||||
active_vault: expectedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({ checkVaultExists: false })
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([])
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
|
||||
@@ -137,6 +137,18 @@ function syncDefaultVaultExport(path: string) {
|
||||
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
|
||||
}
|
||||
|
||||
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
|
||||
return path === resolvedDefaultPath
|
||||
}
|
||||
|
||||
function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
|
||||
return vault.label === GETTING_STARTED_LABEL && vault.available === false
|
||||
}
|
||||
|
||||
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
|
||||
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
|
||||
}
|
||||
|
||||
async function checkVaultAvailability(path: string): Promise<boolean> {
|
||||
if (!path) {
|
||||
return false
|
||||
@@ -166,7 +178,79 @@ async function loadInitialVaultState() {
|
||||
console.warn('Failed to load vault list:', vaultListResult.reason)
|
||||
}
|
||||
|
||||
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
return sanitizeCanonicalGettingStartedState({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults,
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeCanonicalGettingStartedState({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
defaultAvailable: boolean
|
||||
hiddenDefaults: string[]
|
||||
resolvedDefaultPath: string
|
||||
vaults: VaultOption[]
|
||||
}) {
|
||||
if (!resolvedDefaultPath) {
|
||||
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
}
|
||||
|
||||
const filteredVaults = vaults.filter(
|
||||
(vault) => !shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath),
|
||||
)
|
||||
const removedStarterPaths = new Set(
|
||||
vaults
|
||||
.filter((vault) => shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath))
|
||||
.map((vault) => vault.path),
|
||||
)
|
||||
const sanitizedActiveVault = resolveSanitizedGettingStartedSelection({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
filteredVaults,
|
||||
removedStarterPaths,
|
||||
resolvedDefaultPath,
|
||||
})
|
||||
|
||||
return {
|
||||
activeVault: sanitizedActiveVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults: filteredVaults,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSanitizedGettingStartedSelection({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
filteredVaults,
|
||||
removedStarterPaths,
|
||||
resolvedDefaultPath,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
defaultAvailable: boolean
|
||||
filteredVaults: VaultOption[]
|
||||
removedStarterPaths: Set<string>
|
||||
resolvedDefaultPath: string
|
||||
}): string | null {
|
||||
if (!activeVault || !removedStarterPaths.has(activeVault)) {
|
||||
return activeVault
|
||||
}
|
||||
|
||||
if (isCanonicalGettingStartedPath(activeVault, resolvedDefaultPath) && defaultAvailable) {
|
||||
return activeVault
|
||||
}
|
||||
|
||||
return filteredVaults[0]?.path ?? null
|
||||
}
|
||||
|
||||
function buildDefaultVaults({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN ?? ''
|
||||
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
|
||||
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com'
|
||||
import { resolveFrontendTelemetryConfig } from './telemetryConfig'
|
||||
|
||||
/** Pattern that matches absolute file paths (macOS / Linux / Windows). */
|
||||
const PATH_PATTERN = /(?:\/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+/g
|
||||
@@ -11,24 +8,30 @@ function scrubPaths(input: string): string {
|
||||
return input.replace(PATH_PATTERN, '<redacted-path>')
|
||||
}
|
||||
|
||||
function scrubSentryEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
|
||||
if (event.message) event.message = scrubPaths(event.message)
|
||||
for (const ex of event.exception?.values ?? []) {
|
||||
if (ex.value) ex.value = scrubPaths(ex.value)
|
||||
}
|
||||
for (const breadcrumb of event.breadcrumbs ?? []) {
|
||||
if (breadcrumb.message) breadcrumb.message = scrubPaths(breadcrumb.message)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
let sentryInitialized = false
|
||||
let posthogInstance: typeof import('posthog-js').default | null = null
|
||||
|
||||
export function initSentry(anonymousId: string): void {
|
||||
if (sentryInitialized || !SENTRY_DSN) return
|
||||
if (sentryInitialized) return
|
||||
|
||||
const { sentryDsn } = resolveFrontendTelemetryConfig()
|
||||
if (!sentryDsn) return
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
dsn: sentryDsn,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
if (event.message) event.message = scrubPaths(event.message)
|
||||
for (const ex of event.exception?.values ?? []) {
|
||||
if (ex.value) ex.value = scrubPaths(ex.value)
|
||||
}
|
||||
for (const bc of event.breadcrumbs ?? []) {
|
||||
if (bc.message) bc.message = scrubPaths(bc.message)
|
||||
}
|
||||
return event
|
||||
},
|
||||
beforeSend: scrubSentryEvent,
|
||||
})
|
||||
Sentry.setUser({ id: anonymousId })
|
||||
sentryInitialized = true
|
||||
@@ -41,10 +44,14 @@ export function teardownSentry(): void {
|
||||
}
|
||||
|
||||
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
|
||||
if (posthogInstance || !POSTHOG_KEY) return
|
||||
if (posthogInstance) return
|
||||
|
||||
const { posthogKey, posthogHost } = resolveFrontendTelemetryConfig()
|
||||
if (!posthogKey || !posthogHost) return
|
||||
|
||||
const posthog = (await import('posthog-js')).default
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
posthog.init(posthogKey, {
|
||||
api_host: posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
persistence: 'memory',
|
||||
|
||||
71
src/lib/telemetryConfig.test.ts
Normal file
71
src/lib/telemetryConfig.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_defaultPostHogHostForTest as defaultPostHogHost,
|
||||
resolveFrontendTelemetryConfig,
|
||||
sanitizeTelemetryEnvValue,
|
||||
} from './telemetryConfig'
|
||||
|
||||
describe('sanitizeTelemetryEnvValue', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
|
||||
})
|
||||
|
||||
it('unwraps matching quotes after trimming', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' "value" ')).toBe('value')
|
||||
expect(sanitizeTelemetryEnvValue(" 'value' ")).toBe('value')
|
||||
})
|
||||
|
||||
it('returns an empty string for blank input', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' ')).toBe('')
|
||||
expect(sanitizeTelemetryEnvValue(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFrontendTelemetryConfig', () => {
|
||||
it('keeps valid telemetry values after sanitizing them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the default PostHog host when one is not configured', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
}).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('adds https to scheme-less DSNs and PostHog hosts', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'not a dsn',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
}).sentryDsn).toBe('')
|
||||
})
|
||||
|
||||
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'not a url',
|
||||
}).posthogHost).toBeNull()
|
||||
})
|
||||
})
|
||||
75
src/lib/telemetryConfig.ts
Normal file
75
src/lib/telemetryConfig.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
type TelemetryEnv = {
|
||||
VITE_SENTRY_DSN?: string
|
||||
VITE_POSTHOG_KEY?: string
|
||||
VITE_POSTHOG_HOST?: string
|
||||
}
|
||||
|
||||
export type FrontendTelemetryConfig = {
|
||||
sentryDsn: string
|
||||
posthogKey: string
|
||||
posthogHost: string | null
|
||||
}
|
||||
|
||||
function unwrapMatchingQuotes(value: string): string {
|
||||
if (value.length < 2) return value
|
||||
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if (first !== last) return value
|
||||
if (first !== '"' && first !== "'") return value
|
||||
|
||||
return value.slice(1, -1).trim()
|
||||
}
|
||||
|
||||
export function sanitizeTelemetryEnvValue(value: string | undefined): string {
|
||||
if (!value) return ''
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
return unwrapMatchingQuotes(trimmed)
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpLikeValue(value: string): string {
|
||||
if (!value) return ''
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
|
||||
return `https://${value}`
|
||||
}
|
||||
|
||||
function normalizeSentryDsn(value: string): string {
|
||||
const normalized = normalizeHttpLikeValue(value)
|
||||
return isHttpUrl(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizePostHogHost(value: string): string | null {
|
||||
if (!value) return DEFAULT_POSTHOG_HOST
|
||||
const normalized = normalizeHttpLikeValue(value)
|
||||
return isHttpUrl(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveFrontendTelemetryConfig(
|
||||
env: TelemetryEnv = import.meta.env as TelemetryEnv,
|
||||
): FrontendTelemetryConfig {
|
||||
const sentryDsn = normalizeSentryDsn(
|
||||
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
|
||||
)
|
||||
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
|
||||
const posthogHost = normalizePostHogHost(
|
||||
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
|
||||
)
|
||||
|
||||
return { sentryDsn, posthogKey, posthogHost }
|
||||
}
|
||||
|
||||
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }
|
||||
@@ -2,6 +2,7 @@ import type { NoteReference } from './ai-context'
|
||||
|
||||
export const OPEN_AI_CHAT_EVENT = 'tolaria:open-ai-chat'
|
||||
export const AI_PROMPT_QUEUED_EVENT = 'tolaria:ai-prompt-queued'
|
||||
export const NEW_AI_CHAT_EVENT = 'tolaria:new-ai-chat'
|
||||
|
||||
export interface QueuedAiPrompt {
|
||||
id: number
|
||||
@@ -32,3 +33,8 @@ export function takeQueuedAiPrompt(): QueuedAiPrompt | null {
|
||||
export function requestOpenAiChat() {
|
||||
window.dispatchEvent(new Event(OPEN_AI_CHAT_EVENT))
|
||||
}
|
||||
|
||||
export function requestNewAiChat() {
|
||||
window.dispatchEvent(new Event(NEW_AI_CHAT_EVENT))
|
||||
requestOpenAiChat()
|
||||
}
|
||||
|
||||
33
src/utils/inverseRelationshipLabels.ts
Normal file
33
src/utils/inverseRelationshipLabels.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
import { humanizePropertyKey } from './propertyLabels'
|
||||
|
||||
const PREFERRED_INVERSE_RELATIONSHIP_LABELS = ['Children', 'Events', 'Referenced by'] as const
|
||||
|
||||
export function resolveInverseRelationshipLabel(
|
||||
key: string,
|
||||
entry: Pick<VaultEntry, 'isA'>,
|
||||
): string {
|
||||
const normalizedKey = humanizePropertyKey(key).trim().toLowerCase()
|
||||
|
||||
if (normalizedKey === 'belongs to') {
|
||||
return entry.isA === 'Event'
|
||||
? 'Events'
|
||||
: 'Children'
|
||||
}
|
||||
|
||||
if (normalizedKey === 'related to') {
|
||||
return entry.isA === 'Event'
|
||||
? 'Events'
|
||||
: 'Referenced by'
|
||||
}
|
||||
|
||||
return `← ${humanizePropertyKey(key)}`
|
||||
}
|
||||
|
||||
export function orderInverseRelationshipLabels(labels: Iterable<string>): string[] {
|
||||
const customLabels = [...labels]
|
||||
.filter((label) => !PREFERRED_INVERSE_RELATIONSHIP_LABELS.includes(label as typeof PREFERRED_INVERSE_RELATIONSHIP_LABELS[number]))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
|
||||
return [...PREFERRED_INVERSE_RELATIONSHIP_LABELS, ...customLabels]
|
||||
}
|
||||
@@ -161,7 +161,43 @@ describe('buildRelationshipGroups', () => {
|
||||
const groups = buildRelationshipGroups(parent, [parent, shared])
|
||||
|
||||
expect(groups.find((group) => group.label === 'Related to')?.entries).toEqual([shared])
|
||||
expect(groups.find((group) => group.label === 'Referenced By')?.entries).toEqual([shared])
|
||||
expect(groups.find((group) => group.label === 'Referenced by')?.entries).toEqual([shared])
|
||||
})
|
||||
|
||||
it('normalizes canonical inverse relationship keys without duplicating raw inverse groups', () => {
|
||||
const parent = makeEntry({
|
||||
path: '/vault/parent.md',
|
||||
filename: 'parent.md',
|
||||
title: 'Parent',
|
||||
isA: 'Project',
|
||||
})
|
||||
const child = makeEntry({
|
||||
path: '/vault/child.md',
|
||||
filename: 'child.md',
|
||||
title: 'Child',
|
||||
isA: 'Note',
|
||||
belongsTo: ['[[parent]]'],
|
||||
relationships: {
|
||||
belongs_to: ['[[parent]]'],
|
||||
},
|
||||
})
|
||||
const related = makeEntry({
|
||||
path: '/vault/related.md',
|
||||
filename: 'related.md',
|
||||
title: 'Related',
|
||||
isA: 'Note',
|
||||
relatedTo: ['[[parent]]'],
|
||||
relationships: {
|
||||
related_to: ['[[parent]]'],
|
||||
},
|
||||
})
|
||||
|
||||
const groups = buildRelationshipGroups(parent, [parent, child, related])
|
||||
|
||||
expect(groups.find((group) => group.label === 'Children')?.entries).toEqual([child])
|
||||
expect(groups.find((group) => group.label === 'Referenced by')?.entries).toEqual([related])
|
||||
expect(groups.find((group) => group.label === '← belongs_to')).toBeUndefined()
|
||||
expect(groups.find((group) => group.label === '← related_to')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes all inverse relationship groups for non-core relationship keys', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { VaultEntry, SidebarSelection, InboxPeriod, ViewFile } from '../types'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
|
||||
import {
|
||||
orderInverseRelationshipLabels as sortInverseRelationshipLabels,
|
||||
resolveInverseRelationshipLabel,
|
||||
} from './inverseRelationshipLabels'
|
||||
import { evaluateView } from './viewFilters'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
|
||||
@@ -308,12 +312,6 @@ function appendInverseRelationshipEntries(
|
||||
inverseGroups.set(label, [entry])
|
||||
}
|
||||
|
||||
function resolveInverseRelationshipLabel(key: string, entry: VaultEntry): string {
|
||||
if (key === 'Belongs to') return entry.isA === 'Event' ? 'Events' : 'Children'
|
||||
if (key === 'Related to') return entry.isA === 'Event' ? 'Events' : 'Referenced By'
|
||||
return `← ${key}`
|
||||
}
|
||||
|
||||
function appendLegacyInverseRelationshipEntries(
|
||||
inverseGroups: Map<string, VaultEntry[]>,
|
||||
entity: VaultEntry,
|
||||
@@ -340,12 +338,7 @@ function appendDynamicInverseRelationshipEntries(
|
||||
}
|
||||
|
||||
function orderInverseRelationshipLabels(inverseGroups: Map<string, VaultEntry[]>): string[] {
|
||||
const preferredOrder = ['Children', 'Events', 'Referenced By']
|
||||
const customLabels = [...inverseGroups.keys()]
|
||||
.filter((label) => !preferredOrder.includes(label))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
|
||||
return [...preferredOrder, ...customLabels]
|
||||
return sortInverseRelationshipLabels(inverseGroups.keys())
|
||||
}
|
||||
|
||||
function collectInverseRelationshipGroups(
|
||||
@@ -381,7 +374,7 @@ export function buildRelationshipGroups(
|
||||
}
|
||||
|
||||
// Direct relationships first — all keys from entity.relationships take
|
||||
// priority so that reverse/computed groups (Children, Events, Referenced By)
|
||||
// priority so that reverse/computed groups (Children, Events, Referenced by)
|
||||
// only show *additional* entries not already covered by a direct property.
|
||||
Object.keys(rels)
|
||||
.filter((k) => k.toLowerCase() !== 'type')
|
||||
|
||||
91
src/utils/pulledVaultRefresh.test.ts
Normal file
91
src/utils/pulledVaultRefresh.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { refreshPulledVaultState } from './pulledVaultRefresh'
|
||||
|
||||
function makeEntry(path: string, title = 'Test note'): VaultEntry {
|
||||
return {
|
||||
path,
|
||||
title,
|
||||
filename: path.split('/').pop() ?? 'note.md',
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
outgoingLinks: [],
|
||||
} as VaultEntry
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<Parameters<typeof refreshPulledVaultState>[0]> = {}) {
|
||||
const activeEntry = makeEntry('/vault/active.md', 'Active')
|
||||
return {
|
||||
activeTabPath: activeEntry.path,
|
||||
closeAllTabs: vi.fn(),
|
||||
hasUnsavedChanges: vi.fn(() => false),
|
||||
reloadFolders: vi.fn(),
|
||||
reloadVault: vi.fn().mockResolvedValue([activeEntry]),
|
||||
reloadViews: vi.fn(),
|
||||
replaceActiveTab: vi.fn().mockResolvedValue(undefined),
|
||||
updatedFiles: ['active.md'],
|
||||
vaultPath: '/vault',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('refreshPulledVaultState', () => {
|
||||
it('reloads vault-derived data and refreshes the active note when pull updated it', async () => {
|
||||
const options = makeOptions()
|
||||
|
||||
const entries = await refreshPulledVaultState(options)
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(options.reloadVault).toHaveBeenCalledOnce()
|
||||
expect(options.reloadFolders).toHaveBeenCalledOnce()
|
||||
expect(options.reloadViews).toHaveBeenCalledOnce()
|
||||
expect(options.closeAllTabs).toHaveBeenCalledOnce()
|
||||
expect(options.replaceActiveTab).toHaveBeenCalledWith(entries[0])
|
||||
})
|
||||
|
||||
it('reloads the active tab after any successful pull with updates', async () => {
|
||||
const options = makeOptions({ updatedFiles: ['project/plan.md'] })
|
||||
|
||||
await refreshPulledVaultState(options)
|
||||
|
||||
expect(options.reloadVault).toHaveBeenCalledOnce()
|
||||
expect(options.closeAllTabs).not.toHaveBeenCalled()
|
||||
expect(options.replaceActiveTab).toHaveBeenCalledWith(expect.objectContaining({ path: '/vault/active.md' }))
|
||||
})
|
||||
|
||||
it('matches macOS /tmp and /private/tmp aliases when reloading the active tab entry', async () => {
|
||||
const activeEntry = makeEntry('/private/tmp/tolaria/active.md', 'Active')
|
||||
const options = makeOptions({
|
||||
activeTabPath: activeEntry.path,
|
||||
reloadVault: vi.fn().mockResolvedValue([activeEntry]),
|
||||
vaultPath: '/tmp/tolaria',
|
||||
})
|
||||
|
||||
await refreshPulledVaultState(options)
|
||||
|
||||
expect(options.closeAllTabs).toHaveBeenCalledOnce()
|
||||
expect(options.replaceActiveTab).toHaveBeenCalledWith(activeEntry)
|
||||
})
|
||||
|
||||
it('skips tab replacement when the active note has unsaved edits', async () => {
|
||||
const options = makeOptions({
|
||||
hasUnsavedChanges: vi.fn((path: string) => path === '/vault/active.md'),
|
||||
})
|
||||
|
||||
await refreshPulledVaultState(options)
|
||||
|
||||
expect(options.replaceActiveTab).not.toHaveBeenCalled()
|
||||
expect(options.closeAllTabs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the tab when the pulled note disappeared from the reloaded vault', async () => {
|
||||
const options = makeOptions({
|
||||
reloadVault: vi.fn().mockResolvedValue([makeEntry('/vault/other.md', 'Other')]),
|
||||
})
|
||||
|
||||
await refreshPulledVaultState(options)
|
||||
|
||||
expect(options.replaceActiveTab).not.toHaveBeenCalled()
|
||||
expect(options.closeAllTabs).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
68
src/utils/pulledVaultRefresh.ts
Normal file
68
src/utils/pulledVaultRefresh.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface PulledVaultRefreshOptions {
|
||||
activeTabPath: string | null
|
||||
closeAllTabs: () => void
|
||||
hasUnsavedChanges: (path: string) => boolean
|
||||
reloadFolders: () => Promise<unknown> | unknown
|
||||
reloadVault: () => Promise<VaultEntry[]>
|
||||
reloadViews: () => Promise<unknown> | unknown
|
||||
replaceActiveTab: (entry: VaultEntry) => Promise<void>
|
||||
updatedFiles: string[]
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
.replaceAll('\\', '/')
|
||||
.replace(/^\/private\/tmp(?=\/|$)/u, '/tmp')
|
||||
.replace(/\/+$/u, '')
|
||||
}
|
||||
|
||||
function resolveUpdatedFilePath(path: string, vaultPath: string): string {
|
||||
if (path.startsWith('/')) return normalizePath(path)
|
||||
return normalizePath(`${vaultPath}/${path}`)
|
||||
}
|
||||
|
||||
function didPullUpdateActiveNote(updatedFiles: string[], vaultPath: string, activeTabPath: string): boolean {
|
||||
const normalizedActivePath = normalizePath(activeTabPath)
|
||||
return updatedFiles.some((path) => resolveUpdatedFilePath(path, vaultPath) === normalizedActivePath)
|
||||
}
|
||||
|
||||
export async function refreshPulledVaultState(options: PulledVaultRefreshOptions): Promise<VaultEntry[]> {
|
||||
const {
|
||||
activeTabPath,
|
||||
closeAllTabs,
|
||||
hasUnsavedChanges,
|
||||
reloadFolders,
|
||||
reloadVault,
|
||||
reloadViews,
|
||||
replaceActiveTab,
|
||||
updatedFiles,
|
||||
vaultPath,
|
||||
} = options
|
||||
|
||||
const [entries] = await Promise.all([
|
||||
reloadVault(),
|
||||
Promise.resolve(reloadFolders()),
|
||||
Promise.resolve(reloadViews()),
|
||||
])
|
||||
|
||||
if (!activeTabPath || hasUnsavedChanges(activeTabPath)) return entries
|
||||
|
||||
const refreshedEntry = entries.find(entry => normalizePath(entry.path) === normalizePath(activeTabPath))
|
||||
if (!refreshedEntry) {
|
||||
closeAllTabs()
|
||||
return entries
|
||||
}
|
||||
|
||||
// Native BlockNote can keep rendering the previous document after a pull that
|
||||
// changes the active file in place. Dropping the tab first forces a full
|
||||
// reopen for that specific case without affecting unrelated pull updates.
|
||||
if (didPullUpdateActiveNote(updatedFiles, vaultPath, activeTabPath)) {
|
||||
closeAllTabs()
|
||||
}
|
||||
|
||||
await replaceActiveTab(refreshedEntry)
|
||||
return entries
|
||||
}
|
||||
@@ -14,14 +14,14 @@ test.describe('AI chat conversation history', () => {
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open AI Chat with Ctrl+I
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
// Open AI Chat with the current keyboard shortcut.
|
||||
await sendShortcut(page, 'L', ['Meta', 'Shift'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('first message has no conversation history marker', async ({ page }) => {
|
||||
test('first message renders a mocked AI response', async ({ page }) => {
|
||||
// Find the input and send a message
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
const input = page.getByTestId('agent-input')
|
||||
await input.fill('Hello')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
@@ -29,36 +29,35 @@ test.describe('AI chat conversation history', () => {
|
||||
const response = page.getByTestId('ai-message').last()
|
||||
await expect(response).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// First message should have [mock-no-history] since there's no prior conversation
|
||||
await expect(response).toContainText('[mock-no-history]')
|
||||
await expect(response).toContainText('[mock-claude code]')
|
||||
await expect(response).toContainText('You said: "Hello"')
|
||||
})
|
||||
|
||||
test('second message includes conversation history from first exchange', async ({ page }) => {
|
||||
test('second message appends to the current visible conversation', async ({ page }) => {
|
||||
// Send first message
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
const input = page.getByTestId('agent-input')
|
||||
await input.fill('What is 2+2?')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for first response to appear
|
||||
const firstResponse = page.getByTestId('ai-message').last()
|
||||
await expect(firstResponse).toBeVisible({ timeout: 5000 })
|
||||
await expect(firstResponse).toContainText('[mock-no-history]')
|
||||
await expect(firstResponse).toContainText('[mock-claude code]')
|
||||
|
||||
// Send second message
|
||||
await input.fill('What was my previous question?')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for second response — it should contain history marker
|
||||
await page.waitForTimeout(1000)
|
||||
const messages = page.getByTestId('ai-message')
|
||||
await expect(messages).toHaveCount(2)
|
||||
await expect(messages.first()).toContainText('What is 2+2?')
|
||||
const secondResponse = page.getByTestId('ai-message').last()
|
||||
await expect(secondResponse).toContainText('[mock-with-history', { timeout: 5000 })
|
||||
// turns=2 means 2 [user] lines: original + new question
|
||||
await expect(secondResponse).toContainText('turns=2')
|
||||
await expect(secondResponse).toContainText('What was my previous question?', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('history resets after clearing conversation', async ({ page }) => {
|
||||
// Send first message
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
const input = page.getByTestId('agent-input')
|
||||
await input.fill('Hello')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
@@ -67,7 +66,7 @@ test.describe('AI chat conversation history', () => {
|
||||
await expect(firstResponse).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Clear conversation (click the + button)
|
||||
await page.locator('button[title="New conversation"]').click()
|
||||
await page.locator('button[title="New AI chat"]').click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Messages should be cleared
|
||||
@@ -79,6 +78,31 @@ test.describe('AI chat conversation history', () => {
|
||||
|
||||
const freshResponse = page.getByTestId('ai-message').last()
|
||||
await expect(freshResponse).toBeVisible({ timeout: 5000 })
|
||||
await expect(freshResponse).toContainText('[mock-no-history]')
|
||||
await expect(freshResponse).toContainText('[mock-claude code]')
|
||||
await expect(freshResponse).toContainText('You said: "Fresh start"')
|
||||
})
|
||||
|
||||
test('closing and reopening restores the last chat until a new AI chat is started', async ({ page }) => {
|
||||
const input = page.getByTestId('agent-input')
|
||||
await input.fill('Keep this thread alive')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
const firstResponse = page.getByTestId('ai-message').last()
|
||||
await expect(firstResponse).toContainText('[mock-claude code]', { timeout: 5000 })
|
||||
|
||||
await page.getByTitle('Close AI panel').click()
|
||||
await expect(page.getByTestId('ai-panel')).toHaveCount(0)
|
||||
|
||||
await sendShortcut(page, 'L', ['Meta', 'Shift'])
|
||||
const panel = page.getByTestId('ai-panel')
|
||||
await expect(panel).toBeVisible({ timeout: 3_000 })
|
||||
const restoredMessage = page.getByTestId('ai-message').last()
|
||||
await expect(restoredMessage).toContainText('Keep this thread alive')
|
||||
await expect(restoredMessage).toContainText('[mock-claude code]')
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTitle('New AI chat')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('ai-message')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,8 +181,10 @@ test('keyboard onboarding can create an empty vault and the first note', async (
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
@@ -36,25 +36,59 @@ function untitledRow(page: Page, typeLabel: string) {
|
||||
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
|
||||
}
|
||||
|
||||
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
type EmptyHeadingState = {
|
||||
contentType: string | null
|
||||
editorFocused: boolean
|
||||
placeholder: string | null
|
||||
}
|
||||
|
||||
function capturePageErrors(page: Page): string[] {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
return errors
|
||||
}
|
||||
|
||||
async function readEmptyHeadingState(page: Page): Promise<EmptyHeadingState> {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
|
||||
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
|
||||
return {
|
||||
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
|
||||
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
|
||||
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
|
||||
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
|
||||
}
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toEqual({
|
||||
editorFocused: true,
|
||||
contentType: 'heading',
|
||||
placeholder: '"Title"',
|
||||
})
|
||||
}
|
||||
|
||||
function hasExpectedTitlePlaceholder(placeholder: string | null): boolean {
|
||||
return placeholder === '"Heading"' || placeholder === '"Title"'
|
||||
}
|
||||
|
||||
function isReadyEmptyTitleHeading(state: EmptyHeadingState): boolean {
|
||||
return state.editorFocused && state.contentType === 'heading' && hasExpectedTitlePlaceholder(state.placeholder)
|
||||
}
|
||||
|
||||
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
|
||||
await expect.poll(async () => isReadyEmptyTitleHeading(await readEmptyHeadingState(page)), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function expectUntitledNoteWithoutCrash(
|
||||
page: Page,
|
||||
typeLabel: string,
|
||||
createNote: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const errors = capturePageErrors(page)
|
||||
|
||||
await createNote()
|
||||
await expect(untitledRow(page, typeLabel)).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
}
|
||||
|
||||
test.describe('Create note crash fix', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
@@ -65,57 +99,36 @@ test.describe('Create note crash fix', () => {
|
||||
})
|
||||
|
||||
test('clicking + next to a type section creates a note without crashing @smoke', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
await openTestVault(page)
|
||||
await selectSection(page, 'Projects')
|
||||
await createNoteFromListHeader(page)
|
||||
await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
await expectUntitledNoteWithoutCrash(page, 'project', async () => {
|
||||
await createNoteFromListHeader(page)
|
||||
})
|
||||
})
|
||||
|
||||
test('Cmd+N creates a note without crashing @smoke', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
await openTestVault(page)
|
||||
await page.waitForTimeout(300)
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
await expectUntitledNoteWithoutCrash(page, 'note', async () => {
|
||||
await page.waitForTimeout(300)
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
})
|
||||
})
|
||||
|
||||
test('creating note for custom type does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
await openTestVault(page)
|
||||
await selectSection(page, 'Events')
|
||||
await createNoteFromListHeader(page)
|
||||
await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
await expectUntitledNoteWithoutCrash(page, 'event', async () => {
|
||||
await createNoteFromListHeader(page)
|
||||
})
|
||||
})
|
||||
|
||||
test('command palette creates typed notes without crashing when a type template is present @smoke', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
seedTypeEntry(tempVaultDir, 'Procedure', '## Checklist\n\n- first step\n- [[Alpha Project]]\n- unmatched [link')
|
||||
await openTestVault(page)
|
||||
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'new procedure')
|
||||
|
||||
await expect(untitledRow(page, 'procedure')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
expect(errors).toEqual([])
|
||||
await expectUntitledNoteWithoutCrash(page, 'procedure', async () => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'new procedure')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ test.describe('Fresh-install regression: AI panel renders and works', () => {
|
||||
|
||||
// Layer 1: Header with title and buttons
|
||||
await expect(panel.locator('text=AI Chat')).toBeVisible()
|
||||
await expect(panel.locator('button[title="New conversation"]')).toBeVisible()
|
||||
await expect(panel.locator('button[title="New AI chat"]')).toBeVisible()
|
||||
await expect(panel.locator('button[title="Close AI panel"]')).toBeVisible()
|
||||
|
||||
// Layer 2: Message area (empty state with robot icon suggestion)
|
||||
|
||||
@@ -59,7 +59,7 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
})
|
||||
|
||||
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
|
||||
@@ -76,6 +76,9 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
@@ -92,10 +95,10 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
|
||||
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
|
||||
})
|
||||
|
||||
for (const action of ['accept', 'decline'] as const) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { test, expect, type Page } from '@playwright/test'
|
||||
async function installOnboardingMocks(page: Page, offline: boolean) {
|
||||
await page.addInitScript((isOffline: boolean) => {
|
||||
localStorage.clear()
|
||||
let createdVaultPath: string | null = null
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
@@ -16,11 +17,12 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
|
||||
ref.check_vault_exists = () => false
|
||||
ref.check_vault_exists = (args: { path?: string }) => args.path === createdVaultPath
|
||||
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
|
||||
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
|
||||
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
|
||||
}
|
||||
createdVaultPath = args.targetPath
|
||||
return args.targetPath
|
||||
}
|
||||
},
|
||||
@@ -65,6 +67,6 @@ test('status bar keeps a Getting Started clone entry available after onboarding'
|
||||
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
|
||||
await page.getByTestId('claude-onboarding-continue').click()
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
|
||||
await page.getByTitle('Switch vault').click()
|
||||
await page.getByTestId('status-vault-trigger').click()
|
||||
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()
|
||||
})
|
||||
|
||||
76
tests/smoke/pull-refresh-open-note.spec.ts
Normal file
76
tests/smoke/pull-refresh-open-note.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVaultDesktopHarness,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
await noteList.getByText(title, { exact: true }).click()
|
||||
}
|
||||
|
||||
async function stubUpdatedPull(page: Page, updatedFile: string) {
|
||||
await page.evaluate((filePath) => {
|
||||
window.__mockHandlers!.git_pull = () => ({
|
||||
status: 'updated',
|
||||
message: 'Pulled 1 update from remote',
|
||||
updatedFiles: [filePath],
|
||||
conflictFiles: [],
|
||||
})
|
||||
}, updatedFile)
|
||||
}
|
||||
|
||||
async function pullFromRemote(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Pull from Remote')
|
||||
}
|
||||
|
||||
test.describe('Pull refreshes the open note immediately', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('successful pull refreshes the open editor and note list title immediately', async ({ page }) => {
|
||||
const originalTitle = 'Note B'
|
||||
const pulledTitle = `Pulled Note B ${Date.now()}`
|
||||
const pulledBody = `Pulled change ${Date.now()}`
|
||||
const notePath = path.join(tempVaultDir, 'note', 'note-b.md')
|
||||
|
||||
await openNote(page, originalTitle)
|
||||
await expect(page.locator('.bn-editor h1').first()).toHaveText(originalTitle, { timeout: 5_000 })
|
||||
|
||||
fs.writeFileSync(notePath, `---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
---
|
||||
|
||||
# ${pulledTitle}
|
||||
|
||||
${pulledBody}
|
||||
`, 'utf8')
|
||||
await stubUpdatedPull(page, notePath)
|
||||
|
||||
await pullFromRemote(page)
|
||||
|
||||
await expect(page.getByText('Pulled 1 update(s) from remote')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor h1').first()).toHaveText(pulledTitle, { timeout: 5_000 })
|
||||
await expect(page.locator('.bn-editor')).toContainText(pulledBody, { timeout: 5_000 })
|
||||
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
await expect(noteList.getByText(pulledTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
|
||||
await expect(noteList.getByText(originalTitle, { exact: true })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal file
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
interface StaleStarterPaths {
|
||||
gettingStartedPath: string
|
||||
personalVaultPath: string
|
||||
workVaultPath: string
|
||||
}
|
||||
|
||||
function buildPaths(): StaleStarterPaths {
|
||||
return {
|
||||
gettingStartedPath: '/Users/mock/Documents/Getting Started',
|
||||
personalVaultPath: '/Users/mock/Personal',
|
||||
workVaultPath: '/Users/mock/Work',
|
||||
}
|
||||
}
|
||||
|
||||
async function installStaleStarterMocks(page: Page) {
|
||||
const paths = buildPaths()
|
||||
|
||||
await page.addInitScript((data: StaleStarterPaths) => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [
|
||||
{ label: 'Getting Started', path: data.gettingStartedPath },
|
||||
{ label: 'Work Vault', path: data.workVaultPath },
|
||||
{ label: 'Personal Vault', path: data.personalVaultPath },
|
||||
],
|
||||
active_vault: data.workVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => data.gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) =>
|
||||
args?.path === data.workVaultPath || args?.path === data.personalVaultPath
|
||||
ref.list_vault = (args: { path?: string }) => {
|
||||
if (args?.path === data.workVaultPath) {
|
||||
return [{
|
||||
path: `${data.workVaultPath}/work-home.md`,
|
||||
filename: 'work-home.md',
|
||||
title: 'Work Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Work Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}]
|
||||
}
|
||||
|
||||
if (args?.path === data.personalVaultPath) {
|
||||
return [{
|
||||
path: `${data.personalVaultPath}/personal-home.md`,
|
||||
filename: 'personal-home.md',
|
||||
title: 'Personal Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Personal Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => ({
|
||||
[`${data.workVaultPath}/work-home.md`]: '# Work Home\n\nWork Home snippet',
|
||||
[`${data.personalVaultPath}/personal-home.md`]: '# Personal Home\n\nPersonal Home snippet',
|
||||
})
|
||||
ref.get_note_content = (args: { path?: string }) => {
|
||||
if (args?.path === `${data.workVaultPath}/work-home.md`) {
|
||||
return '# Work Home\n\nWork Home snippet'
|
||||
}
|
||||
|
||||
if (args?.path === `${data.personalVaultPath}/personal-home.md`) {
|
||||
return '# Personal Home\n\nPersonal Home snippet'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
}, paths)
|
||||
}
|
||||
|
||||
test('stale persisted Getting Started entries stay hidden when the starter path is missing @smoke', async ({ page }) => {
|
||||
await installStaleStarterMocks(page)
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('vault-menu-item-Getting Started')).toHaveCount(0)
|
||||
await expect(page.getByTestId('vault-menu-item-Work Vault')).toBeVisible()
|
||||
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeVisible()
|
||||
})
|
||||
@@ -201,6 +201,17 @@ test('missing Getting Started vault stays hidden while remove actions still work
|
||||
await expect(removeButton).toHaveCSS('opacity', '0')
|
||||
await personalVaultItem.hover()
|
||||
await expect(removeButton).toHaveCSS('opacity', '1')
|
||||
|
||||
const itemBounds = await personalVaultItem.boundingBox()
|
||||
const removeBounds = await removeButton.boundingBox()
|
||||
|
||||
expect(itemBounds).not.toBeNull()
|
||||
expect(removeBounds).not.toBeNull()
|
||||
expect(removeBounds!.x).toBeGreaterThanOrEqual(itemBounds!.x - 1)
|
||||
expect(removeBounds!.y).toBeGreaterThanOrEqual(itemBounds!.y - 1)
|
||||
expect(removeBounds!.x + removeBounds!.width).toBeLessThanOrEqual(itemBounds!.x + itemBounds!.width + 1)
|
||||
expect(removeBounds!.y + removeBounds!.height).toBeLessThanOrEqual(itemBounds!.y + itemBounds!.height + 1)
|
||||
|
||||
await removeButton.click()
|
||||
|
||||
await trigger.click()
|
||||
|
||||
@@ -32,6 +32,7 @@ async function insertWikilink(page: Page) {
|
||||
|
||||
test.describe('Wikilink insertion and navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user