Compare commits
4 Commits
v0.2026032
...
v0.2026032
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07522e984c | ||
|
|
36f43c1ae0 | ||
|
|
1b478d0fc1 | ||
|
|
8646be6b8d |
@@ -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"
|
||||
|
||||
@@ -47,7 +47,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop flex min-w-0 items-center gap-2 rounded px-1.5 py-0.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<div className="group/prop flex min-w-0 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="font-mono-overline flex w-1/2 shrink-0 min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{propKey}</span>
|
||||
{onDelete && (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,15 +79,31 @@ function clearColorScheme(): void {
|
||||
delete root.dataset.themeMode
|
||||
}
|
||||
|
||||
const THEME_STYLE_ID = 'laputa-theme-vars'
|
||||
|
||||
function getOrCreateThemeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
|
||||
if (!el) {
|
||||
el = document.createElement('style')
|
||||
el.id = THEME_STYLE_ID
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
function applyVarsToDom(vars: Record<string, string>): void {
|
||||
const root = document.documentElement
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
updateColorScheme(vars)
|
||||
// Force WebKit to recalculate ::before/::after pseudo-element styles
|
||||
// when CSS custom properties change (WKWebView doesn't auto-invalidate).
|
||||
void root.offsetHeight
|
||||
// WKWebView doesn't invalidate ::before/::after pseudo-element styles when
|
||||
// CSS custom properties change via inline styles alone — `void offsetHeight`
|
||||
// triggers layout reflow but not style recalculation on pseudo-elements.
|
||||
// Replacing a <style> element's content forces a full style tree invalidation
|
||||
// that covers pseudo-elements using var() references (e.g. bullet size/color).
|
||||
const css = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';')
|
||||
getOrCreateThemeStyle().textContent = `:root{${css}}`
|
||||
}
|
||||
|
||||
function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
@@ -95,6 +111,7 @@ function clearVarsFromDom(vars: Record<string, string>): void {
|
||||
for (const key of Object.keys(vars)) {
|
||||
root.style.removeProperty(key)
|
||||
}
|
||||
getOrCreateThemeStyle().textContent = ''
|
||||
clearColorScheme()
|
||||
}
|
||||
|
||||
@@ -146,9 +163,17 @@ function useThemeSetting(vaultPath: string | null) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const lastLoadRef = useRef(0)
|
||||
useEffect(() => {
|
||||
window.addEventListener('focus', load)
|
||||
return () => window.removeEventListener('focus', load)
|
||||
const FOCUS_COOLDOWN_MS = 30_000
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastLoadRef.current < FOCUS_COOLDOWN_MS) return
|
||||
lastLoadRef.current = now
|
||||
load()
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [load])
|
||||
|
||||
return { activeThemeId, setActiveThemeId, reload: load }
|
||||
@@ -205,11 +230,36 @@ function useThemeApplier(
|
||||
return { clearDom, isDark }
|
||||
}
|
||||
|
||||
/** Deactivate the theme and persist `null` to vault settings. */
|
||||
function deactivateTheme(
|
||||
vaultPath: string | null,
|
||||
clearTheme: () => void,
|
||||
setActiveThemeId: (id: string | null) => void,
|
||||
) {
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}
|
||||
|
||||
/** True when the active theme should be cleared (stale, trashed, or archived). */
|
||||
function shouldDeactivate(
|
||||
activeThemeId: string | null,
|
||||
themes: ThemeFile[],
|
||||
entries: VaultEntry[],
|
||||
userSetId: string | null,
|
||||
): boolean {
|
||||
if (!activeThemeId) return false
|
||||
// Stale ID from old theme system — skip IDs just set by user action
|
||||
if (themes.length > 0 && activeThemeId !== userSetId && !themes.some(t => t.id === activeThemeId)) return true
|
||||
// Trashed or archived
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
return !!entry && isEntryRemoved(entry)
|
||||
}
|
||||
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
): ThemeManager {
|
||||
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
|
||||
useEffect(() => {
|
||||
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
|
||||
}, [vaultPath])
|
||||
@@ -217,14 +267,10 @@ export function useThemeManager(
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
|
||||
|
||||
// Clear cached content when theme changes — useThemeApplier will fetch from disk
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
|
||||
|
||||
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
|
||||
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
|
||||
// cleanup doesn't clear a newly-created theme that isn't in entries yet.
|
||||
const userSetIdRef = useRef<string | null>(null)
|
||||
|
||||
const themes = useMemo(
|
||||
@@ -239,30 +285,12 @@ export function useThemeManager(
|
||||
[themes, activeThemeId],
|
||||
)
|
||||
|
||||
// If active theme ID doesn't match any known theme (e.g. stale ID from old
|
||||
// JSON-based theme system), clear it so the app doesn't try to load a
|
||||
// non-existent path. Skip IDs just set by switchTheme/createTheme — the
|
||||
// entry may not have appeared in `entries` yet.
|
||||
// Deactivate stale, trashed, or archived theme
|
||||
useEffect(() => {
|
||||
if (!activeThemeId || themes.length === 0) return
|
||||
if (activeThemeId === userSetIdRef.current) return
|
||||
const isKnown = themes.some(t => t.id === activeThemeId)
|
||||
if (!isKnown) {
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
if (shouldDeactivate(activeThemeId, themes, entries, userSetIdRef.current)) {
|
||||
deactivateTheme(vaultPath, clearTheme, setActiveThemeId)
|
||||
}
|
||||
}, [activeThemeId, themes, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
|
||||
useEffect(() => {
|
||||
if (!activeThemeId) return
|
||||
const entry = entries.find(e => e.path === activeThemeId)
|
||||
if (!entry || !isEntryRemoved(entry)) return
|
||||
clearTheme()
|
||||
setActiveThemeId(null)
|
||||
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
|
||||
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
|
||||
}, [activeThemeId, themes, entries, clearTheme, vaultPath, setActiveThemeId])
|
||||
|
||||
const switchTheme = useCallback(async (themeId: string) => {
|
||||
if (!vaultPath) return
|
||||
@@ -294,9 +322,7 @@ export function useThemeManager(
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
const newContent = await tauriCall<string>('update_frontmatter', {
|
||||
path: activeThemeId,
|
||||
key,
|
||||
value,
|
||||
path: activeThemeId, key, value,
|
||||
})
|
||||
setCachedThemeContent(newContent)
|
||||
} catch (err) { console.error('Failed to update theme property:', err) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user