Compare commits
5 Commits
v0.2026032
...
v0.2026032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e29b70b8 | ||
|
|
d1b358f76a | ||
|
|
c2ce67c300 | ||
|
|
07522e984c | ||
|
|
36f43c1ae0 |
@@ -155,10 +155,14 @@ pub fn get_default_vault_path() -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
vault::invalidate_cache(std::path::Path::new(&path));
|
||||
vault::scan_vault_cached(std::path::Path::new(&path))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -293,9 +297,11 @@ pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_pull(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -327,15 +333,19 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_push(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_remote_status(&vault_path)
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || git::git_remote_status(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
@@ -794,7 +804,9 @@ mod tests {
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
|
||||
|
||||
// reload_vault must return the updated trashed state
|
||||
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
let vault_str = vault.to_str().unwrap();
|
||||
vault::invalidate_cache(std::path::Path::new(vault_str));
|
||||
let fresh = vault::scan_vault_cached(std::path::Path::new(vault_str)).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
.editor__blocknote-container .bn-editor {
|
||||
width: 100%;
|
||||
padding: 20px 40px;
|
||||
max-width: 760px;
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -282,11 +282,13 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--editor-title-size, 28px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-size: var(--headings-h1-font-size);
|
||||
font-weight: var(--headings-h1-font-weight);
|
||||
line-height: var(--headings-h1-line-height);
|
||||
letter-spacing: var(--headings-h1-letter-spacing);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -107,20 +107,32 @@ describe('useAutoSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pulls on window focus', async () => {
|
||||
it('pulls on window focus after cooldown expires', async () => {
|
||||
const now = vi.spyOn(Date, 'now')
|
||||
let clock = 1000
|
||||
now.mockImplementation(() => clock)
|
||||
|
||||
renderSync()
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
// Focus within cooldown — should NOT trigger pull
|
||||
mockInvokeFn.mockClear()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
clock += 5_000 // only 5s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull')
|
||||
expect(pullCalls).toHaveLength(0)
|
||||
|
||||
// Focus after cooldown — should trigger pull
|
||||
clock += 30_000 // 30s later
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
|
||||
})
|
||||
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('triggerSync allows manual pull', async () => {
|
||||
|
||||
@@ -195,9 +195,16 @@ export function useAutoSync({
|
||||
refreshRemoteStatus()
|
||||
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
|
||||
|
||||
// Pull on window focus (app foreground)
|
||||
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
|
||||
const lastPullTimeRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => { performPull() }
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastPullTimeRef.current = now
|
||||
performPull()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [performPull])
|
||||
|
||||
@@ -579,4 +579,32 @@ background: "#FF0000"
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything())
|
||||
})
|
||||
|
||||
it('skips theme reload on focus within 30s cooldown', async () => {
|
||||
const now = vi.spyOn(Date, 'now')
|
||||
let clock = 1000
|
||||
now.mockImplementation(() => clock)
|
||||
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
mockInvokeFn.mockClear()
|
||||
|
||||
// Focus within cooldown — should NOT reload settings
|
||||
clock += 5_000
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
const settingsCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'get_vault_settings')
|
||||
expect(settingsCalls).toHaveLength(0)
|
||||
|
||||
// Focus after cooldown — should reload
|
||||
clock += 30_000
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_settings', { vaultPath: '/vault' })
|
||||
})
|
||||
|
||||
now.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,9 +163,17 @@ function useThemeSetting(vaultPath: string | null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const lastLoadRef = useRef(0)
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', load)
|
||||
return () => window.removeEventListener('focus', load)
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastLoadRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastLoadRef.current = now
|
||||
load()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [load])
|
||||
|
||||
return { activeThemeId, setActiveThemeId, reload: load }
|
||||
|
||||
@@ -368,6 +368,40 @@ describe('buildRelationshipGroups', () => {
|
||||
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
|
||||
expect(referredGroup).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves refs by title when filename differs from wikilink target', () => {
|
||||
// Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev"
|
||||
const airdev = makeEntry({
|
||||
path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev',
|
||||
})
|
||||
const budibase = makeEntry({
|
||||
path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase',
|
||||
aliases: ['Budi'],
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code',
|
||||
relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] },
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, airdev, budibase])
|
||||
const notesGroup = groups.find((g) => g.label === 'Notes')
|
||||
expect(notesGroup).toBeDefined()
|
||||
expect(notesGroup!.entries).toHaveLength(2)
|
||||
expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase'])
|
||||
})
|
||||
|
||||
it('resolves Children via title match when belongsTo target differs from filename', () => {
|
||||
// Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md
|
||||
const child = makeEntry({
|
||||
path: '/vault/tool.md', filename: 'tool.md', title: 'Tool',
|
||||
belongsTo: ['[[No Code]]'], modifiedAt: 1700000000,
|
||||
})
|
||||
const entity = makeEntry({
|
||||
path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code',
|
||||
relationships: {},
|
||||
})
|
||||
const groups = buildRelationshipGroups(entity, [entity, child])
|
||||
expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSortComparator — custom properties', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { VaultEntry, SidebarSelection, InboxPeriod } from '../types'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
|
||||
export type NoteListFilter = 'open' | 'archived' | 'trashed'
|
||||
|
||||
@@ -61,25 +62,12 @@ export function formatSearchSubtitle(entry: VaultEntry): string {
|
||||
}
|
||||
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const fileStem = entry.filename.replace(/\.md$/, '')
|
||||
return refs.some((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return inner === stem || inner.split('/').pop() === fileStem
|
||||
})
|
||||
return refs.some((ref) => resolveEntry([entry], wikilinkTarget(ref)) !== undefined)
|
||||
}
|
||||
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
return refs
|
||||
.map((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
|
||||
return entries.find((e) => {
|
||||
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (stem === inner) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
return fileStem === inner.split('/').pop()
|
||||
})
|
||||
})
|
||||
.map((ref) => resolveEntry(entries, wikilinkTarget(ref)))
|
||||
.filter((e): e is VaultEntry => e !== undefined)
|
||||
}
|
||||
|
||||
|
||||
50
tests/smoke/focus-no-freeze.spec.ts
Normal file
50
tests/smoke/focus-no-freeze.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Focus event does not freeze the UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('window focus event completes without blocking UI for >500ms', async ({ page }) => {
|
||||
// Verify the app loaded
|
||||
await expect(page.locator('[data-testid="sidebar"]').or(page.locator('nav')).first()).toBeVisible()
|
||||
|
||||
// Measure how long the UI is blocked after a focus event.
|
||||
// We dispatch focus, then immediately schedule a rAF callback.
|
||||
// If the main thread is blocked (sync IPC), the rAF callback is delayed.
|
||||
const blockMs = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const t0 = performance.now()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
requestAnimationFrame(() => {
|
||||
resolve(performance.now() - t0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The focus handler must not block the main thread for more than 500ms.
|
||||
// Before the fix, git_pull ran synchronously and blocked for 2-3 seconds.
|
||||
expect(blockMs).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test('rapid focus events (5x) do not accumulate freezes', async ({ page }) => {
|
||||
await expect(page.locator('[data-testid="sidebar"]').or(page.locator('nav')).first()).toBeVisible()
|
||||
|
||||
const totalMs = await page.evaluate(() => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const t0 = performance.now()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
resolve(performance.now() - t0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 5 rapid focus events should still complete in under 500ms total
|
||||
// thanks to the cooldown preventing redundant work.
|
||||
expect(totalMs).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,6 @@ test.describe('Note list preview snippet', () => {
|
||||
if (text && text.length > 10) {
|
||||
expect(text).not.toMatch(/\*\*[^*]+\*\*/)
|
||||
expect(text).not.toContain('```')
|
||||
expect(text).not.toMatch(/\[\[.*\]\]/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,4 +54,29 @@ test.describe('TitleField alignment and separator', () => {
|
||||
// Both should have the same left edge (within 2px tolerance)
|
||||
expect(Math.abs(titleBox!.x - editorBox!.x)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('title field uses H1 CSS variables for font styling', async ({ page }) => {
|
||||
// Verify the title-field__input element references the H1 heading CSS vars
|
||||
const usesH1Vars = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets)
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
for (const rule of Array.from(sheet.cssRules)) {
|
||||
if (rule instanceof CSSStyleRule && rule.selectorText === '.title-field__input') {
|
||||
const fontSize = rule.style.getPropertyValue('font-size')
|
||||
const fontWeight = rule.style.getPropertyValue('font-weight')
|
||||
return {
|
||||
fontSize: fontSize.includes('--headings-h1-font-size'),
|
||||
fontWeight: fontWeight.includes('--headings-h1-font-weight'),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheet */ }
|
||||
}
|
||||
return { fontSize: false, fontWeight: false }
|
||||
})
|
||||
|
||||
expect(usesH1Vars.fontSize).toBe(true)
|
||||
expect(usesH1Vars.fontWeight).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user