Compare commits

...

4 Commits

Author SHA1 Message Date
Luca Rossi
a5e4efcf86 fix: increase tab max-width and add ellipsis truncation (#166)
- Tab max-width increased from 180px to 360px
- Breadcrumb title now truncates with ellipsis at 40vw max-width

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 01:50:52 +01:00
Luca Rossi
e4bab72952 feat: clicking section group header toggles collapse/expand (#164)
- Clicking a collapsed section header now expands it (onToggle + onSelect)
- Clicking the active section header collapses it (onToggle only)
- Clicking an inactive, expanded section header selects it (onSelect only)
- Adds 33 tests covering toggle behavior in Sidebar

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 01:50:46 +01:00
Luca Rossi
f08b807a0c feat: show dynamic build number in status bar (bNNN format) (#163)
* feat: show dynamic build number in status bar (bNNN format)

Replace hardcoded v0.4.2 with a dynamic build number derived from
git rev-list --count HEAD at compile time. The count is embedded via
build.rs and exposed through a get_build_number Tauri command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add build-number-status-bar wireframes (status bar with dynamic b-number)

* fix: use runtime env var for BUILD_NUMBER (compile-time env! not available in CI)

* style: rustfmt format get_build_number

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 01:50:41 +01:00
Luca Rossi
b521736102 fix: preserve scroll position independently per editor tab (#162)
Cache scrollTop alongside blocks in the tab swap cache. On tab leave,
capture the scroll container position; on tab restore, apply it via
requestAnimationFrame after blocks are rendered.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 01:50:06 +01:00
15 changed files with 403 additions and 25 deletions

View File

@@ -0,0 +1,96 @@
{
"children": [
{
"type": "frame",
"id": "build_number_status_bar",
"name": "Build Number — Status Bar",
"x": 0,
"y": 0,
"width": 1200,
"height": 36,
"fill": "$--background",
"layout": "horizontal",
"gap": 12,
"padding": [
0,
16
],
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "text",
"id": "status_vault",
"content": "vault-name",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "status_sep1",
"content": "|",
"fill": "$--border",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "status_build",
"content": "b223",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "status_note",
"content": "← dynamic build number from package.json, replacing hardcoded v0.4.2",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontStyle": "italic"
}
]
},
{
"type": "frame",
"id": "build_number_fallback",
"name": "Build Number — Fallback (no build info)",
"x": 0,
"y": 60,
"width": 400,
"height": 36,
"fill": "$--background",
"layout": "horizontal",
"gap": 12,
"padding": [
0,
16
],
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "text",
"id": "fallback_build",
"content": "b?",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 12
},
{
"type": "text",
"id": "fallback_note",
"content": "← shows b? when build number unavailable",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontStyle": "italic"
}
]
}
]
}

View File

@@ -1,3 +1,14 @@
fn main() {
let count = std::process::Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "DEV".to_string());
println!("cargo:rustc-env=BUILD_NUMBER={}", count);
tauri_build::build()
}

View File

@@ -112,6 +112,14 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
git::git_commit(&vault_path, &message)
}
#[tauri::command]
fn get_build_number() -> String {
{
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
format!("b{}", n)
}
}
#[tauri::command]
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -435,6 +443,16 @@ mod tests {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
#[test]
fn get_build_number_returns_prefixed_value() {
let result = get_build_number();
assert!(
result.starts_with('b'),
"expected 'b' prefix, got: {}",
result
);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -498,6 +516,7 @@ pub fn run() {
get_file_diff,
get_file_diff_at_commit,
git_commit,
get_build_number,
get_last_commit_info,
git_pull,
git_push,

View File

@@ -29,6 +29,7 @@ import { useUpdater } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useZoom } from './hooks/useZoom'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { UpdateBanner } from './components/UpdateBanner'
@@ -260,6 +261,7 @@ function App() {
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
const buildNumber = useBuildNumber()
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -384,7 +386,7 @@ function App() {
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -156,12 +156,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
{/* Left: breadcrumb */}
<div className="flex items-center gap-1" style={{ fontSize: 12 }}>
<span className="text-muted-foreground">{entry.isA || 'Note'}</span>
<span className="text-muted-foreground" style={{ margin: '0 2px' }}>&rsaquo;</span>
<span className="font-medium text-foreground">{entry.title}</span>
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>&middot;</span>
<span className="text-muted-foreground">{wordCount.toLocaleString()} words</span>
<div className="flex items-center gap-1 min-w-0 whitespace-nowrap" style={{ fontSize: 12 }}>
<span className="shrink-0 text-muted-foreground">{entry.isA || 'Note'}</span>
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 2px' }}>&rsaquo;</span>
<span className="truncate font-medium text-foreground" style={{ maxWidth: '40vw' }}>{entry.title}</span>
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 4px' }}>&middot;</span>
<span className="shrink-0 text-muted-foreground">{wordCount.toLocaleString()} words</span>
{noteStatus === 'pendingSave' && (
<>
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>&middot;</span>

View File

@@ -296,6 +296,39 @@ describe('Sidebar', () => {
})
})
it('expands a collapsed section when clicking its header', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Sections start collapsed — items hidden
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
// Click the section header text (not the chevron)
fireEvent.click(screen.getByText('Projects'))
// Section should now be expanded
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
})
it('collapses an expanded+selected section when clicking its header again', () => {
const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' }
render(<Sidebar entries={mockEntries} selection={projectSelection} onSelect={() => {}} />)
// First click expands (starts collapsed) and selects
fireEvent.click(screen.getByText('Projects'))
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
// Second click: section is expanded + selected → should collapse
fireEvent.click(screen.getByText('Projects'))
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('selects but keeps expanded an unselected expanded section when clicking its header', () => {
const onSelect = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
// Expand via chevron first
fireEvent.click(screen.getByLabelText('Expand Projects'))
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
// Click the header — section is expanded but not selected → should select and stay expanded
fireEvent.click(screen.getByText('Projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' })
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
})
it('calls onSelect with sectionGroup for People', () => {
const onSelect = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)

View File

@@ -156,7 +156,11 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
onClick={onSelect} onContextMenu={onContextMenu}
onClick={() => {
if (isCollapsed) { onToggle(); onSelect() }
else if (isActive) { onToggle() }
else { onSelect() }
}} onContextMenu={onContextMenu}
>
<div className="flex items-center" style={{ gap: 4 }}>
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>

View File

@@ -25,9 +25,14 @@ describe('StatusBar', () => {
expect(screen.getByText('9,200 notes')).toBeInTheDocument()
})
it('displays version info', () => {
it('displays build number when provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b223" />)
expect(screen.getByText('b223')).toBeInTheDocument()
})
it('displays fallback build number when not provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.getByText('v0.4.2')).toBeInTheDocument()
expect(screen.getByText('b?')).toBeInTheDocument()
})
it('does not display branch name', () => {

View File

@@ -26,6 +26,7 @@ interface StatusBarProps {
onTriggerSync?: () => void
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
@@ -155,7 +156,7 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
// Force re-render every 30s to keep relative time label fresh
const [, setTick] = useState(0)
useEffect(() => {
@@ -171,7 +172,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><Package size={13} />v0.4.2</span>
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<span
role="button"

View File

@@ -219,7 +219,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
draggable={!isEditing}
{...dragProps}
className={cn(
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative",
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
)}
style={{

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useBuildNumber } from './useBuildNumber'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockResolvedValue('b223'),
}))
beforeEach(() => { vi.clearAllMocks() })
describe('useBuildNumber', () => {
it('returns build number from mock invoke', async () => {
const { result } = renderHook(() => useBuildNumber())
await waitFor(() => expect(result.current).toBe('b223'))
})
it('returns fallback on error', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
const { result } = renderHook(() => useBuildNumber())
await waitFor(() => expect(result.current).toBe('b?'))
})
})

View File

@@ -0,0 +1,19 @@
import { useState, useEffect } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(cmd: string): Promise<T> {
return isTauri() ? invoke<T>(cmd) : mockInvoke<T>(cmd)
}
export function useBuildNumber(): string | undefined {
const [buildNumber, setBuildNumber] = useState<string>()
useEffect(() => {
tauriCall<string>('get_build_number').then(setBuildNumber).catch(() => {
setBuildNumber('b?')
})
}, [])
return buildNumber
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
@@ -156,3 +157,150 @@ describe('replaceTitleInFrontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('useEditorTabSwap scroll position', () => {
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
function makeTab(path: string, title: string) {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
}
}
function makeMockEditor(docRef: { current: unknown[] }) {
const mountCallbacks: Array<() => void> = []
return {
document: docRef.current,
get prosemirrorView() { return {} },
onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} },
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
insertBlocks: vi.fn(),
blocksToMarkdownLossy: vi.fn(() => ''),
blocksToHTMLLossy: vi.fn(() => ''),
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
_tiptapEditor: { commands: { setContent: vi.fn() } },
// Make document getter dynamic
_docRef: docRef,
}
}
afterEach(() => { vi.restoreAllMocks() })
it('saves scroll position when switching tabs and restores it when switching back', async () => {
const scrollEl = { scrollTop: 0 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
// Override document to be dynamic
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
)
// Flush the microtask for initial content swap
await act(() => new Promise(r => setTimeout(r, 0)))
// Simulate scrolling in tab A
scrollEl.scrollTop = 350
// Switch to tab B
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// rAF should have been called to set scroll to 0 (new tab, no cached scroll)
expect(rAF).toHaveBeenCalled()
// Switch back to tab A
docRef.current = blocksB // simulate B's content in editor
scrollEl.scrollTop = 0 // B is at top
rerender({ tabs: [tabA, tabB], activeTabPath: 'a.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// The last rAF call should restore A's scroll position (350)
const lastRAFCall = rAF.mock.calls[rAF.mock.calls.length - 1]
expect(lastRAFCall).toBeDefined()
// Execute the callback to verify scrollTop is set
scrollEl.scrollTop = 0
;(lastRAFCall[0] as (n: number) => void)(0)
expect(scrollEl.scrollTop).toBe(350)
})
it('defaults to scroll top 0 for newly opened tabs', async () => {
const scrollEl = { scrollTop: 0 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md' } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
// For a fresh tab, scroll should go to 0
expect(rAF).toHaveBeenCalled()
expect(scrollEl.scrollTop).toBe(0)
})
it('cleans up scroll cache when a tab is closed', async () => {
const scrollEl = { scrollTop: 100 }
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs,
activeTabPath,
editor: mockEditor as never,
}),
{ initialProps: { tabs: [tabA, tabB], activeTabPath: 'a.md' } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
// Switch to B (caches A's scroll at 100)
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// Close tab A (only tab B remains)
rerender({ tabs: [tabB], activeTabPath: 'b.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
// Reopen tab A — should start at scroll 0, not the cached 100
const tabANew = makeTab('a.md', 'Note A')
scrollEl.scrollTop = 0
rerender({ tabs: [tabB, tabANew], activeTabPath: 'a.md' })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(scrollEl.scrollTop).toBe(0)
})
})

View File

@@ -61,9 +61,9 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
// Cache parsed blocks per tab path for instant switching
// Cache parsed blocks + scroll position per tab path for instant switching
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
const tabCacheRef = useRef<Map<string, any[]>>(new Map())
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
const prevActivePathRef = useRef<string | null>(null)
const editorMountedRef = useRef(false)
const pendingSwapRef = useRef<(() => void) | null>(null)
@@ -136,9 +136,13 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
// Save current editor state for the tab we're leaving
// Save current editor state + scroll position for the tab we're leaving
if (prevPath && pathChanged && editorMountedRef.current) {
cache.set(prevPath, editor.document)
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(prevPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
prevActivePathRef.current = activeTabPath
@@ -148,7 +152,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// the editor already shows the user's edits.
if (!pathChanged) {
if (activeTabPath && editorMountedRef.current) {
cache.set(activeTabPath, editor.document)
const scrollEl = document.querySelector('.editor__blocknote-container')
cache.set(activeTabPath, {
blocks: editor.document,
scrollTop: scrollEl?.scrollTop ?? 0,
})
}
return
}
@@ -159,7 +167,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
if (!tab) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
const applyBlocks = (blocks: any[]) => {
const applyBlocks = (blocks: any[], scrollTop = 0) => {
suppressChangeRef.current = true
try {
const current = editor.document
@@ -181,6 +189,11 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// finishes its internal state updates from the content swap
queueMicrotask(() => { suppressChangeRef.current = false })
}
// Restore scroll position after layout updates from the content swap
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = scrollTop
})
}
const targetPath = activeTabPath
@@ -190,7 +203,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
if (prevActivePathRef.current !== targetPath) return
if (cache.has(targetPath)) {
applyBlocks(cache.get(targetPath)!)
const cached = cache.get(targetPath)!
applyBlocks(cached.blocks, cached.scrollTop)
return
}
@@ -202,7 +216,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
// so the editor is immediately interactive.
if (!preprocessed.trim()) {
const emptyDoc = [{ type: 'paragraph', content: [] }]
cache.set(targetPath, emptyDoc)
cache.set(targetPath, { blocks: emptyDoc, scrollTop: 0 })
applyBlocks(emptyDoc)
return
}
@@ -215,7 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
cache.set(targetPath, h1Doc)
cache.set(targetPath, { blocks: h1Doc, scrollTop: 0 })
applyBlocks(h1Doc)
return
}
@@ -228,7 +242,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const withWikilinks = injectWikilinks(blocks)
// Only cache non-empty results to avoid poisoning the cache
if (withWikilinks.length > 0) {
cache.set(targetPath, withWikilinks)
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
applyBlocks(withWikilinks)
}

View File

@@ -162,6 +162,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockSavedSinceCommit.clear()
return `[main abc1234] ${args.message}\n ${count} files changed`
},
get_build_number: () => 'bDEV',
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',