Compare commits
3 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e3c8630a4 | ||
|
|
d5b621e174 | ||
|
|
24c4a5a823 |
@@ -1,14 +1,3 @@
|
||||
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()
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 665 B After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 815 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 10 KiB |
@@ -116,9 +116,19 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_build_number() -> String {
|
||||
format!("b{}", env!("BUILD_NUMBER"))
|
||||
fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -540,14 +550,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_build_number_returns_prefixed_value() {
|
||||
let result = get_build_number();
|
||||
assert!(
|
||||
result.starts_with('b'),
|
||||
"expected 'b' prefix, got: {}",
|
||||
result
|
||||
);
|
||||
assert_ne!(result, "b0", "build number should not fall back to 0");
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
src/App.tsx
@@ -150,6 +150,23 @@ function App() {
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
useEffect(() => {
|
||||
notes.setTabs(prev => {
|
||||
let changed = false
|
||||
const next = prev.map(tab => {
|
||||
const fresh = vault.entries.find(e => e.path === tab.entry.path)
|
||||
if (fresh && fresh !== tab.entry) {
|
||||
changed = true
|
||||
return { ...tab, entry: fresh }
|
||||
}
|
||||
return tab
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
|
||||
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
// Push to navigation history whenever the active tab changes (user-initiated)
|
||||
@@ -450,7 +467,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} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
|
||||
<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} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
|
||||
<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} />
|
||||
|
||||
@@ -328,6 +328,64 @@ describe('Editor', () => {
|
||||
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
|
||||
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
|
||||
})
|
||||
|
||||
it('shows trash banner immediately when entry changes to trashed (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
|
||||
})
|
||||
|
||||
it('removes trash banner immediately when entry is restored (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
|
||||
|
||||
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('archived note behavior', () => {
|
||||
it('shows archive banner immediately when entry changes to archived (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
|
||||
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('removes archive banner immediately when entry is unarchived (reactive)', () => {
|
||||
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
|
||||
const archivedTab = { entry: archivedEntry, content: mockContent }
|
||||
const { rerender } = render(
|
||||
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
|
||||
|
||||
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
|
||||
rerender(
|
||||
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText('b?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCheckForUpdates when clicking build number', () => {
|
||||
const onCheckForUpdates = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={onCheckForUpdates} />)
|
||||
fireEvent.click(screen.getByTestId('status-build-number'))
|
||||
expect(onCheckForUpdates).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('build number has "Check for updates" title', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={vi.fn()} />)
|
||||
expect(screen.getByTitle('Check for updates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display branch name', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByText('main')).not.toBeInTheDocument()
|
||||
|
||||
@@ -30,6 +30,7 @@ interface StatusBarProps {
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
indexingProgress?: IndexingProgress
|
||||
onRemoveVault?: (path: string) => void
|
||||
}
|
||||
@@ -281,7 +282,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, indexingProgress, onRemoveVault }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRemoveVault }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -293,7 +294,15 @@ 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} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onCheckForUpdates}
|
||||
style={{ ...ICON_STYLE, cursor: onCheckForUpdates ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Check for updates"
|
||||
data-testid="status-build-number"
|
||||
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
|
||||
@@ -322,6 +322,49 @@ describe('useTabManagement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTabs entry sync', () => {
|
||||
it('updates tab entry via setTabs mapper (vault entry sync pattern)', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md', trashed: false })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].entry.trashed).toBe(false)
|
||||
|
||||
// Simulate the App.tsx sync effect: vault entry updated, sync into tab
|
||||
const freshEntry = { ...entry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
act(() => {
|
||||
result.current.setTabs(prev => prev.map(tab =>
|
||||
tab.entry.path === freshEntry.path ? { ...tab, entry: freshEntry } : tab
|
||||
))
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].entry.trashed).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves content when syncing entry', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
const entry = makeEntry({ path: '/vault/a.md', archived: false })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
|
||||
const originalContent = result.current.tabs[0].content
|
||||
|
||||
act(() => {
|
||||
result.current.setTabs(prev => prev.map(tab =>
|
||||
tab.entry.path === entry.path ? { ...tab, entry: { ...tab.entry, archived: true } } : tab
|
||||
))
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].entry.archived).toBe(true)
|
||||
expect(result.current.tabs[0].content).toBe(originalContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeAllTabs', () => {
|
||||
it('clears all tabs and active path', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||