Compare commits

...

3 Commits

Author SHA1 Message Date
Test
98effa9637 fix: resolve TypeScript errors in Sidebar and NoteList
Remove unused entries/selection/onSelect props from TypesSection caller.
Add kind narrowing guard for selection.type access in NoteList.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:38:18 +02:00
Test
e966f3a14b feat: remove AI Provider Keys section from Settings
The OpenAI and Google AI key fields were unused — no feature reads them.
Removes the UI section, KeyField component, and all related state/storage
code across frontend (types, hooks, mock handlers, tests) and Rust backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:34:30 +02:00
Test
4ac2d994c1 feat: reactive vault state — editor changes propagate immediately to all UI
Add title and sidebar_label mappings to contentToEntryPatch so editing
these frontmatter fields in the raw editor immediately updates the note
list, breadcrumb bar, sidebar sections, and inspector.

Also trigger reloadViews() when .yml view files are saved, so sidebar
view names update without a full vault reload.

ADR-0043 documents the reactive update model.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:06:52 +02:00
16 changed files with 366 additions and 405 deletions

View File

@@ -0,0 +1,63 @@
---
type: ADR
id: "0043"
title: "Reactive vault state: editor changes propagate immediately to all UI"
status: active
date: 2026-04-05
---
## Context
When a user edits frontmatter in the raw editor (or BlockNote preserves it), changes to metadata fields like `title`, `type`, `_favorite`, `_archived`, and `sidebar_label` must be reflected immediately across all UI components — sidebar sections, note list, breadcrumb bar, inspector, and tabs.
Previously, after `save_note_content`, only derived fields (`outgoingLinks`, `snippet`, `wordCount`) were updated in `vault.entries`. Frontmatter-derived fields were stale until a full vault reload.
## Decision
**All frontmatter changes are parsed in real-time and applied to `vault.entries` via `updateEntry()` during content editing, not after save.**
### How it works
1. **On every content change** (keystroke in raw editor, or BlockNote onChange), `useEditorSaveWithLinks.handleContentChange` is called.
2. It invokes `contentToEntryPatch(content)` which parses frontmatter and maps known keys to `VaultEntry` fields.
3. If the parsed patch differs from the previous one, `updateEntry(path, patch)` merges it into `vault.entries`.
4. All UI components derive from `vault.entries` via React reactivity — they re-render automatically.
### Mapped fields
`contentToEntryPatch` maps these frontmatter keys to `VaultEntry` fields:
| Frontmatter key | VaultEntry field | Notes |
|---|---|---|
| `title` | `title` | |
| `type` / `is_a` | `isA` | |
| `status` | `status` | |
| `_favorite` | `favorite` | |
| `_favorite_index` | `favoriteIndex` | |
| `_archived` / `archived` | `archived` | |
| `_trashed` / `trashed` | `trashed` | |
| `_organized` | `organized` | |
| `color` | `color` | Type entries |
| `icon` | `icon` | Type entries |
| `order` | `order` | Type entries |
| `sidebar_label` | `sidebarLabel` | Type entries |
| `visible` | `visible` | Type entries |
| `template` | `template` | Type entries |
| `sort` | `sort` | Type entries |
| `view` | `view` | Type entries |
| `aliases` | `aliases` | |
| `belongs_to` | `belongsTo` | |
| `related_to` | `relatedTo` | |
### Inspector operations use a separate, more direct path
When the user edits frontmatter via the Inspector panel, `runFrontmatterAndApply` calls the Tauri command and immediately applies the result via `updateEntry()`. This path was already reactive before this ADR.
### View files (.yml)
View files are not markdown notes — they have no frontmatter delimiters. When a `.yml` file is saved, `onNotePersisted` triggers `reloadViews()` to refresh the sidebar view list.
## Consequences
- Any new frontmatter key that should affect the UI must be added to `frontmatterToEntryPatch` and its delete counterpart.
- Components must read note metadata from `vault.entries` (via props), never from local state that could diverge.
- The `reload_vault_entry` Tauri command exists for full re-parsing from disk but is not needed in the normal editing flow — `contentToEntryPatch` handles it client-side.

View File

@@ -4,8 +4,6 @@ use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
pub openai_key: Option<String>,
pub google_key: Option<String>,
pub github_token: Option<String>,
pub github_username: Option<String>,
pub auto_pull_interval_minutes: Option<u32>,
@@ -39,14 +37,6 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
// Trim whitespace and convert empty strings to None
let cleaned = Settings {
openai_key: settings
.openai_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
google_key: settings
.google_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
github_token: settings
.github_token
.map(|k| k.trim().to_string())
@@ -127,8 +117,6 @@ mod tests {
#[test]
fn test_default_settings_all_none() {
let s = Settings::default();
assert!(s.openai_key.is_none());
assert!(s.google_key.is_none());
assert!(s.github_token.is_none());
assert!(s.github_username.is_none());
assert!(s.auto_pull_interval_minutes.is_none());
@@ -141,8 +129,6 @@ mod tests {
#[test]
fn test_settings_json_roundtrip() {
let settings = Settings {
openai_key: None,
google_key: Some("AIza-test".to_string()),
github_token: Some("gho_xyz789".to_string()),
github_username: Some("lucaong".to_string()),
telemetry_consent: Some(true),
@@ -153,7 +139,6 @@ mod tests {
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.google_key, settings.google_key);
assert_eq!(parsed.github_token, settings.github_token);
assert_eq!(parsed.github_username, settings.github_username);
assert_eq!(parsed.telemetry_consent, Some(true));
@@ -167,20 +152,17 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nonexistent.json");
let result = get_settings_at(&path).unwrap();
assert!(result.openai_key.is_none());
assert!(result.github_token.is_none());
}
#[test]
fn test_save_and_load_preserves_values() {
let loaded = save_and_reload(Settings {
openai_key: Some("sk-openai".to_string()),
google_key: None,
github_token: Some("gho_token123".to_string()),
github_username: Some("lucaong".to_string()),
auto_pull_interval_minutes: Some(10),
..Default::default()
});
assert_eq!(loaded.openai_key.as_deref(), Some("sk-openai"));
assert_eq!(loaded.github_token.as_deref(), Some("gho_token123"));
assert_eq!(loaded.github_username.as_deref(), Some("lucaong"));
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
@@ -200,11 +182,9 @@ mod tests {
#[test]
fn test_save_filters_empty_and_whitespace_only() {
let loaded = save_and_reload(Settings {
openai_key: Some(" ".to_string()),
github_username: Some("".to_string()),
..Default::default()
});
assert!(loaded.openai_key.is_none());
assert!(loaded.github_username.is_none());
}
@@ -216,15 +196,15 @@ mod tests {
save_settings_at(
&path,
Settings {
openai_key: Some("key".to_string()),
github_token: Some("gho_test".to_string()),
..Default::default()
},
)
.unwrap();
assert!(path.exists());
assert_eq!(
get_settings_at(&path).unwrap().openai_key.as_deref(),
Some("key")
get_settings_at(&path).unwrap().github_token.as_deref(),
Some("gho_test")
);
}
@@ -258,9 +238,9 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("settings.json");
// Simulate old settings.json without telemetry fields
fs::write(&path, r#"{"openai_key":"sk-test"}"#).unwrap();
fs::write(&path, r#"{"github_token":"gho_test"}"#).unwrap();
let loaded = get_settings_at(&path).unwrap();
assert_eq!(loaded.openai_key.as_deref(), Some("sk-test"));
assert_eq!(loaded.github_token.as_deref(), Some("gho_test"));
assert!(loaded.telemetry_consent.is_none());
assert!(loaded.crash_reporting_enabled.is_none());
assert!(loaded.analytics_enabled.is_none());

View File

@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
get_file_history: [],
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
get_settings: { github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,

View File

@@ -250,7 +250,7 @@ function App() {
const appSave = useAppSave({
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
loadModifiedFiles: vault.loadModifiedFiles,
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
handleRenameNote: notes.handleRenameNote,

View File

@@ -22,6 +22,98 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
function useViewFlags(selection: SidebarSelection) {
const isSectionGroup = selection.kind === 'sectionGroup'
const isFolderView = selection.kind === 'folder'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const showFilterPills = isSectionGroup || isFolderView || isAllNotesView
return { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills }
}
function useBulkActions(
multiSelect: ReturnType<typeof useMultiSelect>,
onBulkArchive: NoteListProps['onBulkArchive'],
onBulkTrash: NoteListProps['onBulkTrash'],
onBulkRestore: NoteListProps['onBulkRestore'],
onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'],
isTrashView: boolean,
isArchivedView: boolean,
) {
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
return { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete }
}
function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise<void>; modifiedFiles?: ModifiedFile[] }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (!isChangesView || !onDiscardFile) return
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
useEffect(() => {
if (!ctxMenu) return
const handler = (e: MouseEvent) => {
if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const contextMenuNode = ctxMenu ? (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
>
Discard changes
</button>
</div>
) : null
const dialogNode = (
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
return { handleNoteContextMenu, contextMenuNode, dialogNode }
}
interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
@@ -52,15 +144,11 @@ interface NoteListProps {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, views }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const isSectionGroup = selection.kind === 'sectionGroup'
const isFolderView = selection.kind === 'folder'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const showFilterPills = isSectionGroup || isFolderView || isAllNotesView
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
const subFilter = showFilterPills ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
() => isSectionGroup && selection.kind === 'sectionGroup' ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
)
@@ -70,7 +158,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],
@@ -85,46 +172,10 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect])
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
const handleBulkUnarchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : isArchivedView ? handleBulkUnarchive : handleBulkArchive
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
const { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } = useBulkActions(multiSelect, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, isTrashView, isArchivedView)
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
// ── Changes view: context menu + discard confirmation ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (!isChangesView || !onDiscardFile) return
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
// Close context menu on outside click
useEffect(() => {
if (!ctxMenu) return
const handler = (e: MouseEvent) => {
if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
@@ -153,35 +204,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
)}
{/* Changes view: context menu */}
{ctxMenu && (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
>
Discard changes
</button>
</div>
)}
{/* Discard confirmation dialog */}
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{contextMenuNode}
{dialogNode}
</div>
)
}

View File

@@ -18,9 +18,6 @@ vi.mock('../utils/url', () => ({
}))
const emptySettings: Settings = {
openai_key: null,
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
@@ -31,19 +28,6 @@ const emptySettings: Settings = {
release_channel: null,
}
const populatedSettings: Settings = {
openai_key: 'sk-openai-test456',
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
@@ -64,42 +48,26 @@ describe('SettingsPanel', () => {
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('AI Provider Keys')).toBeInTheDocument()
expect(screen.getByText(/stored locally/)).toBeInTheDocument()
expect(screen.getByText('GitHub')).toBeInTheDocument()
})
it('shows two key fields with labels', () => {
it('does not show AI Provider Keys section', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Google AI')).toBeInTheDocument()
expect(screen.queryByText('AI Provider Keys')).not.toBeInTheDocument()
expect(screen.queryByText('OpenAI')).not.toBeInTheDocument()
expect(screen.queryByText('Google AI')).not.toBeInTheDocument()
})
it('populates fields from settings', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement
expect(openaiInput.value).toBe('sk-openai-test456')
expect(googleInput.value).toBe('')
})
it('calls onSave with trimmed keys on save', () => {
it('calls onSave with settings on save', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai')
fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } })
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
openai_key: 'sk-openai-test',
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
@@ -107,26 +75,6 @@ describe('SettingsPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('converts empty/whitespace keys to null', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
// Clear the openai key field
const openaiInput = screen.getByTestId('settings-key-openai')
fireEvent.change(openaiInput, { target: { value: ' ' } })
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
openai_key: null,
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
}))
})
it('calls onClose when Cancel is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
@@ -155,14 +103,9 @@ describe('SettingsPanel', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai')
fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } })
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Enter', metaKey: true })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
openai_key: 'sk-openai-test',
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
@@ -177,16 +120,6 @@ describe('SettingsPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('clears a key field when X button is clicked', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const clearBtn = screen.getByTestId('clear-openai')
fireEvent.click(clearBtn)
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
expect(openaiInput.value).toBe('')
})
it('shows keyboard shortcut hint in footer', () => {
render(
@@ -195,25 +128,6 @@ describe('SettingsPanel', () => {
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
})
it('resets fields when reopened with different settings', () => {
const { rerender } = render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
// Verify initial state
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
expect(openaiInput.value).toBe('sk-openai-test456')
// Close and reopen with different settings
rerender(
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' }
rerender(
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} />
)
const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
expect(updatedInput.value).toBe('new-key')
})
describe('GitHub OAuth section', () => {
it('shows Login with GitHub button when not connected', () => {

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
import { X, GithubLogo, SignOut } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { Settings } from '../types'
import { trackEvent } from '../lib/telemetry'
@@ -12,61 +12,6 @@ interface SettingsPanelProps {
}
interface KeyFieldProps {
label: string
placeholder: string
value: string
onChange: (value: string) => void
onClear: () => void
}
function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProps) {
const [revealed, setRevealed] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>{label}</label>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<input
ref={inputRef}
type={revealed ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full border border-border bg-transparent text-foreground rounded"
style={{ fontSize: 13, padding: '8px 60px 8px 10px', outline: 'none', fontFamily: 'inherit' }}
autoComplete="off"
data-testid={`settings-key-${label.toLowerCase().replace(/\s+/g, '-')}`}
/>
<div style={{ position: 'absolute', right: 8, display: 'flex', gap: 4, alignItems: 'center' }}>
{value && (
<>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => setRevealed(r => !r)}
title={revealed ? 'Hide key' : 'Reveal key'}
type="button"
>
{revealed ? <EyeSlash size={14} /> : <Eye size={14} />}
</button>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => { onClear(); setRevealed(false) }}
title="Clear key"
type="button"
data-testid={`clear-${label.toLowerCase().replace(/\s+/g, '-')}`}
>
<X size={14} />
</button>
</>
)}
</div>
</div>
</div>
)
}
// --- GitHub OAuth Section ---
interface GitHubSectionProps {
@@ -120,8 +65,6 @@ export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanel
}
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
@@ -140,8 +83,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
}, [])
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
openai_key: openaiKey.trim() || null,
google_key: googleKey.trim() || null,
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
auto_pull_interval_minutes: pullInterval,
@@ -150,7 +91,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
analytics_enabled: analytics,
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
}), [githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
const handleSave = () => {
const prevAnalytics = settings.analytics_enabled ?? false
@@ -199,8 +140,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
>
<SettingsHeader onClose={onClose} />
<SettingsBody
openaiKey={openaiKey} setOpenaiKey={setOpenaiKey}
googleKey={googleKey} setGoogleKey={setGoogleKey}
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
@@ -233,8 +172,6 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
}
interface SettingsBodyProps {
openaiKey: string; setOpenaiKey: (v: string) => void
googleKey: string; setGoogleKey: (v: string) => void
githubToken: string | null; githubUsername: string | null
onGitHubConnected: (token: string, username: string) => void
onGitHubDisconnect: () => void
@@ -247,18 +184,6 @@ interface SettingsBodyProps {
function SettingsBody(props: SettingsBodyProps) {
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>AI Provider Keys</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
API keys are stored locally on your device. Never sent to our servers.
</div>
</div>
<KeyField label="OpenAI" placeholder="sk-..." value={props.openaiKey} onChange={props.setOpenaiKey} onClear={() => props.setOpenaiKey('')} />
<KeyField label="Google AI" placeholder="AIza..." value={props.googleKey} onChange={props.setGoogleKey} onClear={() => props.setGoogleKey('')} />
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>GitHub</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>

View File

@@ -144,6 +144,169 @@ function applyCustomization(
// --- Sub-components ---
function SidebarGroupHeader({ label, collapsed, onToggle, count, children }: {
label: string
collapsed: boolean
onToggle: () => void
count?: number
children?: React.ReactNode
}) {
return (
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={onToggle}
>
<div className="flex items-center gap-1">
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>{label}</span>
</div>
{children ?? (count != null && (
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}>
{count}
</span>
))}
</button>
)
}
function ViewItem({ view, isActive, onSelect, onEditView, onDeleteView }: {
view: ViewFile
isActive: boolean
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
}) {
return (
<div className="group relative">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
label={view.definition.name}
isActive={isActive}
onClick={onSelect}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onEditView(view.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDeleteView(view.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
)
}
function ViewsSection({ views, selection, onSelect, collapsed, onToggle, onCreateView, onEditView, onDeleteView }: {
views: ViewFile[]
selection: SidebarSelection
onSelect: (sel: SidebarSelection) => void
collapsed: boolean
onToggle: () => void
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
}) {
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label="VIEWS" collapsed={collapsed} onToggle={onToggle}>
{onCreateView && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onCreateView() }}
/>
)}
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((v) => (
<ViewItem
key={v.filename}
view={v}
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onSelect={() => onSelect({ kind: 'view', filename: v.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
/>
))}
</div>
)}
</div>
)
}
function TypesSection({ visibleSections, allSectionGroups, sectionIds, sensors, handleDragEnd, sectionProps, collapsed, onToggle, showCustomize, setShowCustomize, isSectionVisible, toggleVisibility, onCreateNewType, customizeRef }: {
visibleSections: SectionGroup[]
allSectionGroups: SectionGroup[]
sectionIds: string[]
sensors: ReturnType<typeof useSensors>
handleDragEnd: (event: DragEndEvent) => void
sectionProps: {
entries: VaultEntry[]; selection: SidebarSelection; onSelect: (sel: SidebarSelection) => void
onContextMenu: (e: React.MouseEvent, type: string) => void
renamingType: string | null; renameInitialValue: string; onRenameSubmit: (v: string) => void; onRenameCancel: () => void
}
collapsed: boolean
onToggle: () => void
showCustomize: boolean
setShowCustomize: React.Dispatch<React.SetStateAction<boolean>>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
onCreateNewType?: () => void
customizeRef: React.RefObject<HTMLDivElement | null>
}) {
return (
<div className="border-b border-border">
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<SidebarGroupHeader label="TYPES" collapsed={collapsed} onToggle={onToggle}>
<div className="flex items-center gap-1.5">
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
data-testid="create-type-btn"
onClick={(e) => { e.stopPropagation(); onCreateNewType() }}
/>
)}
</div>
</SidebarGroupHeader>
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
</div>
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((g) => (
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
)}
</div>
)
}
function SortableSection({ group, sectionProps }: {
group: SectionGroup
sectionProps: Omit<SectionContentProps, 'group' | 'itemCount' | 'isRenaming' | 'renameInitialValue'>
@@ -225,19 +388,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
return (
<div style={{ padding: '0 6px' }}>
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={onToggle}
>
<div className="flex items-center gap-1">
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FAVORITES</span>
</div>
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}>
{favorites.length}
</span>
</button>
<SidebarGroupHeader label="FAVORITES" collapsed={collapsed} onToggle={onToggle} count={favorites.length} />
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
@@ -432,107 +583,11 @@ export const Sidebar = memo(function Sidebar({
{/* Views */}
{hasViews && (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={() => toggleGroup('views')}
>
<div className="flex items-center gap-1">
{groupCollapsed.views ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>VIEWS</span>
</div>
{onCreateView && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onCreateView() }}
/>
)}
</button>
{!groupCollapsed.views && (
<div style={{ paddingBottom: 4 }}>
{views.map((v) => (
<div key={v.filename} className="group relative">
<NavItem
icon={Funnel}
emoji={v.definition.icon}
label={v.definition.name}
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
<ViewsSection views={views} selection={selection} onSelect={onSelect} collapsed={groupCollapsed.views} onToggle={() => toggleGroup('views')} onCreateView={onCreateView} onEditView={onEditView} onDeleteView={onDeleteView} />
)}
{/* Sections header + entries */}
<div className="border-b border-border">
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '8px 14px 8px 16px' }}
onClick={() => toggleGroup('sections')}
>
<div className="flex items-center gap-1">
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
</div>
<div className="flex items-center gap-1.5">
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
data-testid="create-type-btn"
onClick={(e) => { e.stopPropagation(); onCreateNewType() }}
/>
)}
</div>
</button>
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
</div>
{/* Sortable section groups */}
{!groupCollapsed.sections && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((g) => (
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
)}
</div>
{/* Types */}
<TypesSection visibleSections={visibleSections} allSectionGroups={allSectionGroups} sectionIds={sectionIds} sensors={sensors} handleDragEnd={handleDragEnd} sectionProps={sectionProps} collapsed={groupCollapsed.sections} onToggle={() => toggleGroup('sections')} showCustomize={showCustomize} setShowCustomize={setShowCustomize} isSectionVisible={isSectionVisible} toggleVisibility={toggleVisibility} onCreateNewType={onCreateNewType} customizeRef={customizeRef} />
{/* Folder tree */}
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} collapsed={groupCollapsed.folders} onToggle={() => toggleGroup('folders')} />

View File

@@ -7,8 +7,9 @@ import { updateMockContent, trackMockChange } from '../mock-tauri'
import { parseFrontmatter } from '../utils/frontmatter'
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
title: { title: '' },
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
icon: { icon: null },
icon: { icon: null }, sidebar_label: { sidebarLabel: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
_archived: { archived: false }, archived: { archived: false },
_trashed: { trashed: false }, trashed: { trashed: false },
@@ -54,8 +55,9 @@ export function frontmatterToEntryPatch(
const str = value != null ? String(value) : null
const arr = Array.isArray(value) ? value.map(String) : []
const updates: Record<string, Partial<VaultEntry>> = {
title: { title: str ?? '' },
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
icon: { icon: str },
icon: { icon: str }, sidebar_label: { sidebarLabel: str },
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
_trashed: { trashed: Boolean(value) }, trashed: { trashed: Boolean(value) },

View File

@@ -30,6 +30,7 @@ interface AppSaveDeps {
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
setToastMessage: (msg: string | null) => void
loadModifiedFiles: () => void
reloadViews?: () => Promise<void>
clearUnsaved: (path: string) => void
unsavedPaths: Set<string>
tabs: TabState[]
@@ -41,7 +42,7 @@ interface AppSaveDeps {
export function useAppSave({
updateEntry, setTabs, setToastMessage,
loadModifiedFiles, clearUnsaved, unsavedPaths,
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
tabs, activeTabPath,
handleRenameNote, replaceEntry, resolvedPath,
}: AppSaveDeps) {
@@ -53,7 +54,8 @@ export function useAppSave({
const onNotePersisted = useCallback((path: string) => {
clearUnsaved(path)
}, [clearUnsaved])
if (path.endsWith('.yml')) reloadViews?.()
}, [clearUnsaved, reloadViews])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry, setTabs, setToastMessage, onAfterSave, onNotePersisted,

View File

@@ -331,11 +331,13 @@ describe('resolveNewType', () => {
describe('frontmatterToEntryPatch', () => {
it.each([
['title', 'My Note', { title: 'My Note' }],
['type', 'Project', { isA: 'Project' }],
['is_a', 'Project', { isA: 'Project' }],
['status', 'Done', { status: 'Done' }],
['color', 'red', { color: 'red' }],
['icon', 'star', { icon: 'star' }],
['sidebar_label', 'Projects', { sidebarLabel: 'Projects' }],
['archived', true, { archived: true }],
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
@@ -474,6 +476,16 @@ describe('contentToEntryPatch', () => {
expect(contentToEntryPatch(content)).toEqual({ isA: 'Essay' })
})
it('extracts title from frontmatter', () => {
const content = '---\ntitle: My Title\ntype: Note\n---\nBody'
expect(contentToEntryPatch(content)).toEqual({ title: 'My Title', isA: 'Note' })
})
it('extracts sidebar_label from frontmatter', () => {
const content = '---\ntype: Type\nsidebar_label: Projects\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Type', sidebarLabel: 'Projects' })
})
it('ignores unknown frontmatter keys', () => {
const content = '---\ntype: Note\ncustom: value\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })

View File

@@ -4,9 +4,6 @@ import type { Settings } from '../types'
import { useSettings } from './useSettings'
const defaultSettings: Settings = {
openai_key: null,
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
@@ -18,9 +15,7 @@ const defaultSettings: Settings = {
}
const savedSettings: Settings = {
openai_key: null,
google_key: 'AIza-test',
github_token: null,
github_token: 'gho_saved_token',
github_username: null,
auto_pull_interval_minutes: null,
telemetry_consent: null,
@@ -70,7 +65,7 @@ describe('useSettings', () => {
expect(result.current.loaded).toBe(true)
})
expect(result.current.settings.google_key).toBe('AIza-test')
expect(result.current.settings.github_token).toBe('gho_saved_token')
expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {})
})
@@ -82,8 +77,6 @@ describe('useSettings', () => {
})
const newSettings: Settings = {
openai_key: 'sk-openai-new',
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,

View File

@@ -8,8 +8,6 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
}
const EMPTY_SETTINGS: Settings = {
openai_key: null,
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,

View File

@@ -18,7 +18,6 @@ vi.mock('../lib/telemetry', () => ({
}))
const baseSettings: Settings = {
openai_key: null, google_key: null,
github_token: null, github_username: null, auto_pull_interval_minutes: null,
telemetry_consent: null, crash_reporting_enabled: null,
analytics_enabled: null, anonymous_id: null, release_channel: null,

View File

@@ -75,8 +75,6 @@ let mockHasChanges = true
const mockSavedSinceCommit = new Set<string>()
let mockSettings: Settings = {
openai_key: null,
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
@@ -201,8 +199,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
save_settings: (args: { settings: Settings }) => {
const s = args.settings
mockSettings = {
openai_key: trimOrNull(s.openai_key),
google_key: trimOrNull(s.google_key),
github_token: trimOrNull(s.github_token),
github_username: trimOrNull(s.github_username),
auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5,

View File

@@ -74,8 +74,6 @@ export interface ModifiedFile {
}
export interface Settings {
openai_key: string | null
google_key: string | null
github_token: string | null
github_username: string | null
auto_pull_interval_minutes: number | null