Compare commits

...

12 Commits

Author SHA1 Message Date
lucaronin
67a9930ea1 feat: add empty vault creation flow 2026-04-19 01:06:40 +02:00
lucaronin
aed4d05f1d docs: ratchet codescene thresholds 2026-04-19 00:25:35 +02:00
lucaronin
cac7f12c6d fix: stop duplicate native quick prompt paste 2026-04-19 00:18:42 +02:00
lucaronin
72b5b52140 feat: add view note-list controls 2026-04-18 20:31:25 +02:00
lucaronin
7deeb49751 feat: add number property type 2026-04-18 19:40:00 +02:00
lucaronin
8c0a2e7ec8 fix: share onboarding drag shell 2026-04-18 18:50:30 +02:00
lucaronin
c4e66dc74f fix: make onboarding window draggable 2026-04-18 18:09:37 +02:00
lucaronin
c0bb722db5 fix: reuse note content cache for rapid note switches 2026-04-18 17:30:49 +02:00
lucaronin
bc11c869aa fix: stabilize ai mode paste editing 2026-04-18 16:52:36 +02:00
lucaronin
502ecf0572 test: stabilize wikilink smoke coverage 2026-04-18 16:18:43 +02:00
lucaronin
8bbbba98b7 fix: correct vault path type guard 2026-04-18 16:02:19 +02:00
lucaronin
86adf860b5 fix: restore bottom bar vault switching 2026-04-18 15:59:34 +02:00
60 changed files with 2659 additions and 589 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.7
HOTSPOT_THRESHOLD=9.88
AVERAGE_THRESHOLD=9.73

View File

@@ -496,7 +496,8 @@ The app uses a single light theme — the vault-based theming system was removed
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.

View File

@@ -177,7 +177,7 @@ flowchart TD
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).

View File

@@ -14,7 +14,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -121,6 +121,7 @@ fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Re
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
@@ -129,6 +130,28 @@ fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Resu
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
@@ -441,10 +464,28 @@ mod tests {
assert!(result.is_ok());
assert!(vault_path.join(".git").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("CLAUDE.md").exists());
assert!(vault_path.join("config.md").exists());
assert!(vault_path.join("note.md").exists());
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
}
#[test]
fn test_create_empty_vault_rejects_nonempty_target() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("existing-folder");
std::fs::create_dir_all(&vault_path).unwrap();
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
let err = result.expect_err("expected non-empty folder to be rejected");
assert_eq!(err, "Choose an empty folder to create a new vault");
assert!(vault_path.join("keep.txt").exists());
assert!(!vault_path.join(".git").exists());
assert!(!vault_path.join("AGENTS.md").exists());
}
}

View File

@@ -17,6 +17,12 @@ pub struct ViewDefinition {
pub color: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
default,
rename = "listPropertiesDisplay",
skip_serializing_if = "Vec::is_empty"
)]
pub list_properties_display: Vec<String>,
pub filters: FilterGroup,
}
@@ -195,6 +201,37 @@ pub fn migrate_views(vault_path: &Path) {
}
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
fn is_view_definition_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
}
fn read_view_file(path: &Path) -> Option<ViewFile> {
if !is_view_definition_file(path) {
return None;
}
let filename = path.file_name()?.to_string_lossy().to_string();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
log::warn!("Failed to read view file {}: {}", filename, error);
return None;
}
};
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => definition,
Err(error) => {
log::warn!("Failed to parse view {}: {}", filename, error);
return None;
}
};
Some(ViewFile {
filename,
definition,
})
}
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
migrate_views(vault_path);
let views_dir = vault_path.join("views");
@@ -212,20 +249,8 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
continue;
}
let filename = entry.file_name().to_string_lossy().to_string();
match fs::read_to_string(&path) {
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => views.push(ViewFile {
filename,
definition,
}),
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
},
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
if let Some(view) = read_view_file(&entry.path()) {
views.push(view);
}
}
@@ -656,6 +681,22 @@ mod tests {
entry
}
fn make_project_view(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn test_parse_simple_view() {
let yaml = r#"
@@ -670,6 +711,7 @@ filters:
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "Active Projects");
assert_eq!(def.icon.as_deref(), Some("rocket"));
assert!(def.list_properties_display.is_empty());
match &def.filters {
FilterGroup::All(nodes) => {
assert_eq!(nodes.len(), 1);
@@ -945,18 +987,10 @@ filters:
fn test_save_and_read_view() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Test View".to_string(),
icon: Some("star".to_string()),
color: None,
sort: Some("modified:desc".to_string()),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};
let mut def = make_project_view("Test View");
def.icon = Some("star".to_string());
def.sort = Some("modified:desc".to_string());
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
save_view(dir.path(), "test.yml", &def).unwrap();
@@ -964,6 +998,10 @@ filters:
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Test View");
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
assert_eq!(
views[0].definition.list_properties_display,
vec!["Priority".to_string(), "Owner".to_string()]
);
delete_view(dir.path(), "test.yml").unwrap();
let views = scan_views(dir.path());
@@ -974,18 +1012,8 @@ filters:
fn test_save_and_read_view_with_emoji_icon() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Monday".to_string(),
icon: Some("🗂️".to_string()),
color: None,
sort: None,
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
};
let mut def = make_project_view("Monday");
def.icon = Some("🗂️".to_string());
save_view(dir.path(), "monday.yml", &def).unwrap();

View File

@@ -358,6 +358,30 @@ describe('App', () => {
expect(appShell).toBeInTheDocument()
})
it('switches vaults from the bottom bar after onboarding is ready', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Test Vault', path: '/work' },
{ label: 'Work Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Test Vault')
})
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByTestId('vault-menu-item-Work Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -69,7 +69,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
@@ -105,6 +105,16 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
function shouldPreferOnboardingVaultPath(
onboardingState: { status: string; vaultPath?: string },
vaults: Array<{ path: string }>,
): onboardingState is { status: 'ready'; vaultPath: string } {
return onboardingState.status === 'ready'
&& typeof onboardingState.vaultPath === 'string'
&& onboardingState.vaultPath.length > 0
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
async function resolveNoteWindowEntry(
noteWindowParams: NoteWindowParams,
entries: VaultEntry[],
@@ -234,16 +244,26 @@ function App() {
})
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
useEffect(() => {
if (onboarding.state.status !== 'ready') return
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
const onboardingVaultPath = onboarding.userReadyVaultPath
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
// The active vault path can temporarily come from onboarding before the
// persisted vault switcher catches up to the newly cloned starter vault.
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
rememberOnboardingVaultChoice(onboardingVaultPath)
}
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// Onboarding can briefly own the vault path for a newly created/opened vault
// before the persisted switcher catches up, but once the path is already in
// the switcher list we should trust the explicit switcher state.
const resolvedPath = noteWindowParams?.vaultPath ?? (
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
useEffect(() => {
@@ -541,6 +561,11 @@ function App() {
}, [setInspectorCollapsed])
const handleCustomizeNoteListColumns = useCallback(() => {
if (effectiveSelection.kind === 'view') {
openNoteListPropertiesPicker('view')
return
}
if (effectiveSelection.kind !== 'filter') return
if (effectiveSelection.filter === 'all') {
openNoteListPropertiesPicker('all')
@@ -780,21 +805,35 @@ function App() {
)
}, [notes, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
if (!existing) return
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', {
vaultPath: resolvedPath,
filename,
definition: { ...existing.definition, ...patch },
})
await vault.reloadViews()
}, [resolvedPath, vault])
const handleEditView = useCallback((filename: string) => {
const view = vault.views.find((v) => v.filename === filename)
if (view) dialogs.openEditView(filename, view.definition)
@@ -942,6 +981,17 @@ function App() {
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename)
return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns'
}
return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -975,6 +1025,7 @@ function App() {
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
@@ -1006,8 +1057,12 @@ function App() {
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')),
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -1115,7 +1170,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1180,7 +1235,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1244,7 +1299,7 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onRetryCreateVault={onboarding.retryCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
@@ -27,6 +28,17 @@ function dateToISO(day: Date): string {
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
function isValidNumberValue(value: string): boolean {
const trimmed = value.trim()
if (trimmed === '') return false
return Number.isFinite(Number(trimmed))
}
function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean {
if (!key.trim()) return false
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
@@ -91,21 +103,42 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
)
}
function AddNumberInput({ value, onChange, onKeyDown }: {
value: string
onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void
}) {
return (
<Input
className={`${ADD_INPUT_CLASS} font-mono tabular-nums`}
type="text"
inputMode="decimal"
placeholder="0"
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
data-testid="add-property-number-input"
/>
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
@@ -119,6 +152,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode })
const handleModeChange = (mode: PropertyDisplayMode) => {
setDisplayMode(mode)
@@ -127,13 +161,13 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode)
else if (e.key === 'Escape') onCancel()
}
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
<Input
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
@@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!newKey.trim()} title="Add property"
disabled={!canSubmit} title="Add property"
data-testid="add-property-confirm"
>
<Check className="size-3.5" />

View File

@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
const openExternalUrl = vi.fn()
const dragRegionMouseDown = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('AiAgentsOnboardingPrompt', () => {
beforeEach(() => {
@@ -65,4 +69,22 @@ describe('AiAgentsOnboardingPrompt', () => {
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -7,6 +7,7 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
@@ -82,11 +83,12 @@ export function AiAgentsOnboardingPrompt({
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="ai-agents-onboarding-screen"
<OnboardingShell
className="bg-sidebar px-6 py-10"
contentClassName="w-full max-w-2xl"
testId="ai-agents-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<Card className="border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
@@ -141,6 +143,6 @@ export function AiAgentsOnboardingPrompt({
</div>
</CardFooter>
</Card>
</div>
</OnboardingShell>
)
}

View File

@@ -627,6 +627,14 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('smart property display — number', () => {
it('renders numeric properties with the number display affordance', () => {
renderEditablePanel({ estimate: -3.25 })
expect(screen.getByTestId('number-display')).toBeInTheDocument()
expect(screen.getByText('-3.25')).toBeInTheDocument()
})
})
describe('smart property display — status auto-detection', () => {
it('renders status badge for property named Status', () => {
renderEditablePanel({ Status: 'Active' })
@@ -741,6 +749,7 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-number')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
@@ -798,6 +807,13 @@ describe('DynamicPropertiesPanel', () => {
})
describe('type-aware add property form', () => {
it('shows number input when number type selected', () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
expect(screen.getByTestId('add-property-number-input')).toBeInTheDocument()
})
it('shows boolean toggle when boolean type selected', () => {
openAddPropertyForm()
// Switch type to boolean
@@ -829,6 +845,16 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('published', true)
})
it('stores trimmed decimal values as numbers when adding number properties', () => {
const { keyInput } = openAddPropertyForm()
fireEvent.change(keyInput, { target: { value: 'estimate' } })
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
fireEvent.change(screen.getByTestId('add-property-number-input'), { target: { value: ' -12.5 ' } })
fireEvent.click(screen.getByTestId('add-property-confirm'))
expect(onAddProperty).toHaveBeenCalledWith('estimate', -12.5)
})
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })

View File

@@ -2,6 +2,12 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { GitRequiredModal } from './GitRequiredModal'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('GitRequiredModal', () => {
it('renders title and explanation', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
@@ -48,4 +54,14 @@ describe('GitRequiredModal', () => {
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
})
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
const shell = screen.getByTestId('git-required-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { GitBranch } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface GitRequiredModalProps {
onCreateRepo: () => Promise<void>
@@ -22,8 +23,12 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
}
return (
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentClassName="w-full max-w-sm"
testId="git-required-shell"
>
<div className="flex flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<GitBranch size={36} className="text-muted-foreground" />
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
@@ -52,6 +57,6 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
</button>
</div>
</div>
</div>
</OnboardingShell>
)
}

View File

@@ -1,5 +1,7 @@
import {
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type { VaultEntry } from '../types'
@@ -14,6 +16,12 @@ import {
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
} from './inlineWikilinkText'
import { serializeInlineNode } from './inlineWikilinkDom'
import {
buildPendingPasteState,
type PendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
import {
InlineWikilinkEditorField,
InlineWikilinkPaletteLayout,
@@ -48,7 +56,6 @@ function collapseSelectionRange(nextSelectionIndex: number) {
end: nextSelectionIndex,
}
}
function submitInlineValue({
onSubmit,
submitOnEmpty,
@@ -66,48 +73,6 @@ function submitInlineValue({
onSubmit(normalizedValue, references)
}
function renderInlineEditorField({
value,
placeholder,
disabled,
inputRef,
dataTestId,
editorClassName,
onInput,
onKeyDown,
onSelectionChange,
segments,
typeEntryMap,
}: {
value: string
placeholder?: string
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
}) {
return (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={inputRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={onInput}
onKeyDown={onKeyDown}
onSelectionChange={onSelectionChange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
}
function renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
@@ -157,12 +122,14 @@ export function InlineWikilinkInput({
paletteEmptyState,
paletteFooter,
}: InlineWikilinkInputProps) {
const [, forceRender] = useState(0)
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
editorRef,
selectionRange,
selectionIndex,
setSelectionRange,
@@ -175,6 +142,7 @@ export function InlineWikilinkInput({
onChange,
inputRef,
})
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -209,6 +177,36 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
if (!pastedText) return
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
pendingPasteRef.current = buildPendingPasteState(value, selectionRange, pastedText)
event.preventDefault()
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleInput = () => {
const editor = editorRef.current
const pendingPaste = pendingPasteRef.current
if (editor && pendingPaste) {
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
pendingPasteRef.current = null
if (shouldRecoverPendingPaste(nextValue, pendingPaste)) {
onChange(pendingPaste.expectedValue)
forceRender((current) => current + 1)
setSelectionRange({ ...pendingPaste.expectedSelection })
return
}
}
commitValueFromEditor()
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
@@ -223,19 +221,22 @@ export function InlineWikilinkInput({
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
const editor = renderInlineEditorField({
value,
placeholder,
disabled,
inputRef: setCombinedRef,
dataTestId,
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,
})
const editor = (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,

View File

@@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({
editorClassName,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
segments,
typeEntryMap,
@@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}

View File

@@ -1,13 +1,17 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
import {
allSelection,
buildNoteListProps,
makeEntry,
makeTypeDefinition,
mockEntries,
renderNoteList,
} from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
function makeBookTypeEntries(
displayProps: string[] = [],
@@ -28,6 +32,58 @@ function makeBookTypeEntries(
const noop = () => undefined
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'active-books.yml',
definition: {
name: 'Active Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewNoteList({
entries,
view = makeViewDefinition(),
}: {
entries: Parameters<typeof renderNoteList>[0]['entries']
view?: ViewFile
}) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function searchNoteList(query: string) {
const searchInput = screen.queryByPlaceholderText('Search notes...')
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
@@ -354,6 +410,44 @@ describe('NoteList rendering', () => {
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
})
it('opens the view column picker from the global event and applies the saved columns', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
})
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('shows an empty-state picker for views with no matching properties', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(),
view: makeViewDefinition({
filename: 'empty-view.yml',
definition: {
name: 'Empty View',
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
},
}),
})
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByText('No properties match this search.')).toBeInTheDocument()
})
it('shows status in the type column picker when at least one note has it set', () => {
renderNoteList({
entries: makeBookTypeEntries([], { status: 'Active' }),

View File

@@ -1,8 +1,11 @@
import { useState } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { NoteList } from './NoteList'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import { getSortComparator } from '../utils/noteListHelpers'
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import { buildNoteListProps, makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
describe('getSortComparator', () => {
it('sorts by modified date descending', () => {
@@ -121,6 +124,52 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
function makeView(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'rated-books.yml',
definition: {
name: 'Rated Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewSort(entries: typeof zamEntries, view = makeView()) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function openListSortMenu(entries = mockEntries) {
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
@@ -294,4 +343,38 @@ describe('NoteList sort controls', () => {
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['A', 'C', 'B'])
})
it('loads view sort properties from the current view results only', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', properties: { Priority: 'High' } }),
makeEntry({ path: '/project-a.md', title: 'Project A', isA: 'Project', properties: { Owner: 'Luca' } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
expect(screen.queryByTestId('sort-option-property:Owner')).not.toBeInTheDocument()
})
it('supports keyboard selection for view sorting and persists the chosen property', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', modifiedAt: 1000, properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', modifiedAt: 3000, properties: { Rating: 1 } }),
makeEntry({ path: '/book-c.md', title: 'Book C', isA: 'Book', modifiedAt: 2000, properties: { Rating: 5 } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.keyDown(screen.getByTestId('sort-menu-__list__'), { key: 'End' })
expect(screen.getByTestId('sort-option-property:Rating')).toHaveFocus()
fireEvent.keyDown(screen.getByTestId('sort-option-property:Rating'), { key: 'Enter' })
const titles = screen.getAllByText(/^Book [ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['Book B', 'Book A', 'Book C'])
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,36 @@
import type { CSSProperties, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
interface OnboardingShellProps {
children: ReactNode
className?: string
contentClassName?: string
contentStyle?: CSSProperties
style?: CSSProperties
testId?: string
}
export function OnboardingShell({
children,
className,
contentClassName,
contentStyle,
style,
testId,
}: OnboardingShellProps) {
const { onMouseDown } = useDragRegion()
return (
<div
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
style={style}
data-testid={testId}
onMouseDown={onMouseDown}
>
<div className={contentClassName} style={contentStyle} data-no-drag>
{children}
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
@@ -154,6 +155,77 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
)
}
function NumberValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: ScalarEditProps) {
const [editValue, setEditValue] = useState(value)
const restoreValue = useCallback(() => {
setEditValue(value)
}, [value])
const commitValue = useCallback(() => {
const trimmed = editValue.trim()
if (trimmed === '') {
onSave('')
return
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed)) {
onSave(trimmed)
return
}
restoreValue()
onCancel()
}, [editValue, onCancel, onSave, restoreValue])
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitValue()
return
}
if (event.key === 'Escape') {
restoreValue()
onCancel()
}
}, [commitValue, onCancel, restoreValue])
if (isEditing) {
return (
<Input
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
type="text"
inputMode="decimal"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitValue}
autoFocus
data-testid="number-input"
/>
)
}
return (
<button
type="button"
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="number-display"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</button>
)
}
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
@@ -352,48 +424,38 @@ function createScalarEditProps({
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
type ScalarRendererProps = SmartCellProps & {
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(v) => onSave(propKey, v)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
)
case 'boolean': {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
}
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
),
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
),
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(nextValue) => onSave(propKey, nextValue)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
),
number: ({ editProps }) => <NumberValue {...editProps} />,
boolean: ({ propKey, value, onUpdate }) => {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
},
url: ({ editProps }) => <UrlValue {...editProps} />,
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
}
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
}
function ScalarValueCell(props: SmartCellProps) {

View File

@@ -1,8 +1,237 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
interface SortItem {
value: SortOption
label: string
}
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
function buildSortItems(customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
}))
return [...builtInItems, ...customItems]
}
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
const activeElement = document.activeElement as HTMLElement | null
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
const currentIndex = sortItems.findIndex((item) => item.value === current)
return currentIndex >= 0 ? currentIndex : 0
}
function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>, index: number) {
sortButtonRefs.current[index]?.focus()
}
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
const lastIndex = itemCount - 1
switch (key) {
case 'Escape':
return { type: 'close' }
case 'ArrowDown':
return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) }
case 'ArrowUp':
return { type: 'focus', index: Math.max(0, focusIndex - 1) }
case 'Home':
return { type: 'focus', index: 0 }
case 'End':
return { type: 'focus', index: lastIndex }
default:
return null
}
}
function selectOnKeyboard(
event: React.KeyboardEvent<HTMLButtonElement>,
value: SortOption,
direction: SortDirection,
onSelect: (opt: SortOption, dir: SortDirection) => void,
) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelect(value, direction)
}
function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) {
return cn(
'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40',
)
}
function useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
}: {
groupLabel: string
current: SortOption
sortItems: SortItem[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const sortButtonRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (containerRef.current?.contains(event.target as Node)) return
setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
useEffect(() => {
if (!open) return
focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems))
}, [current, groupLabel, open, sortItems])
const closeMenu = useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => {
onChange(groupLabel, option, nextDirection)
closeMenu()
}, [closeMenu, groupLabel, onChange])
const handleMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const action = resolveSortMenuAction(
event.key,
resolveFocusedIndex(groupLabel, current, sortItems),
sortItems.length,
)
if (!action) return
event.preventDefault()
if (action.type === 'close') {
closeMenu()
return
}
focusSortItem(sortButtonRefs, action.index)
}, [closeMenu, current, groupLabel, sortItems])
return {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
}
}
function SortDropdownTrigger({
triggerRef,
open,
current,
groupLabel,
direction,
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
open: boolean
current: SortOption
groupLabel: string
direction: SortDirection
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
return (
<button
ref={triggerRef}
type="button"
className={cn('flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', open && 'bg-accent text-foreground')}
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
title={`Sort by ${getSortOptionLabel(current)}`}
aria-haspopup="menu"
aria-expanded={open}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
)
}
function SortDropdownMenu({
open,
groupLabel,
current,
direction,
sortItems,
sortButtonRefs,
onKeyDown,
onSelect,
}: {
open: boolean
groupLabel: string
current: SortOption
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
if (!open) return null
const hasCustom = sortItems.length > SORT_OPTIONS.length
const builtInOptionCount = SORT_OPTIONS.length
return (
<div
role="menu"
aria-label={`Sort ${groupLabel}`}
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
onKeyDown={onKeyDown}
data-testid={`sort-menu-${groupLabel}`}
>
{sortItems.map((item, index) => (
<SortRow
key={item.value}
index={index}
groupLabel={groupLabel}
value={item.value}
label={item.label}
current={current}
direction={direction}
buttonRef={(node) => {
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
onSelect={onSelect}
/>
))}
</div>
)
}
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
groupLabel: string
current: SortOption
@@ -10,99 +239,142 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
customProperties?: string[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption, dir: SortDirection) => {
onChange(groupLabel, opt, dir)
setOpen(false)
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const hasCustom = customProperties && customProperties.length > 0
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
const {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
} = useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
})
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${getSortOptionLabel(current)}`}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
))}
{hasCustom && (
<>
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
{customProperties.map((key) => {
const value: SortOption = `property:${key}`
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
})}
</>
)}
</div>
)}
<div ref={containerRef} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<SortDropdownTrigger
triggerRef={triggerRef}
open={open}
current={current}
groupLabel={groupLabel}
direction={direction}
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
open={open}
groupLabel={groupLabel}
current={current}
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
</div>
)
}
function SortRow({ value, label, current, direction, onSelect }: {
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
index: number
groupLabel: string
value: SortOption
label: string
current: SortOption
direction: SortDirection
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
const defaultDirection = isActive ? direction : getDefaultDirection(value)
const itemData = {
'data-sort-group-label': groupLabel,
'data-sort-item-index': String(index),
}
return (
<div
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${value}`}
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
{label}
</span>
<span className="flex items-center gap-0.5 ml-1 shrink-0">
<>
{showSeparator && <div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />}
<div
className={cn('flex items-center justify-between gap-1 rounded px-1 text-[12px] text-popover-foreground hover:bg-accent', isActive && 'bg-accent font-medium')}
style={{ minHeight: 28 }}
>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
data-testid={`sort-dir-asc-${value}`}
title="Ascending"
ref={buttonRef}
type="button"
role="menuitemradio"
aria-checked={isActive}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-1 text-left text-inherit hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation()
onSelect(value, defaultDirection)
}}
onKeyDown={(event) => selectOnKeyboard(event, value, defaultDirection, onSelect)}
data-testid={`sort-option-${value}`}
{...itemData}
>
<ArrowUp size={12} />
<span className="truncate">{label}</span>
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
data-testid={`sort-dir-desc-${value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
<span className="flex items-center gap-0.5">
<SortDirectionButton
value={value}
direction="asc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
/>
<SortDirectionButton
value={value}
direction="desc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
/>
</span>
</div>
</>
)
}
function SortDirectionButton({
value,
direction,
activeDirection,
isActive,
onSelect,
icon,
itemData,
}: {
value: SortOption
direction: SortDirection
activeDirection: SortDirection
isActive: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
}) {
return (
<button
type="button"
className={getDirectionButtonClass(isActive, activeDirection, direction)}
onClick={(event) => {
event.stopPropagation()
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
title={direction === 'asc' ? 'Ascending' : 'Descending'}
{...itemData}
>
{icon}
</button>
)
}

View File

@@ -154,6 +154,24 @@ describe('StatusBar', () => {
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
it('shows "Create empty vault" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
})
it('calls onCreateEmptyVault when clicking "Create empty vault"', () => {
const onCreateEmptyVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={onCreateEmptyVault} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Create empty vault'))
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('shows add-vault options in vault menu', () => {
render(
<StatusBar
@@ -161,11 +179,13 @@ describe('StatusBar', () => {
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCreateEmptyVault={vi.fn()}
onOpenLocalFolder={vi.fn()}
onCloneVault={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
})

View File

@@ -21,6 +21,7 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -60,6 +61,7 @@ export function StatusBar({
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -121,6 +123,7 @@ export function StatusBar({
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('TelemetryConsentDialog', () => {
it('renders the consent dialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
@@ -32,4 +38,14 @@ describe('TelemetryConsentDialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
})
it('uses the surrounding surface as a drag region and excludes the dialog card', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
const shell = screen.getByTestId('telemetry-consent-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface TelemetryConsentDialogProps {
onAccept: () => void
@@ -7,14 +8,21 @@ interface TelemetryConsentDialogProps {
export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) {
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
<OnboardingShell
className="fixed inset-0 z-50"
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
style={{ background: 'rgba(0,0,0,0.4)' }}
contentStyle={{
width: 'min(440px, 100%)',
padding: 32,
display: 'flex',
flexDirection: 'column',
gap: 20,
alignItems: 'center',
}}
testId="telemetry-consent-shell"
>
<div
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 440, padding: 32, display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}
>
<>
<ShieldCheck size={40} weight="duotone" style={{ color: 'var(--primary)' }} />
<div style={{ textAlign: 'center' }}>
@@ -63,7 +71,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>
You can change this anytime in Settings.
</p>
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -1,13 +1,19 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
const defaultProps = {
mode: 'welcome' as const,
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onRetryCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onCreateEmptyVault: vi.fn(),
onOpenFolder: vi.fn(),
isOffline: false,
creatingAction: null as 'template' | 'empty' | null,
@@ -16,6 +22,10 @@ const defaultProps = {
}
describe('WelcomeScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('welcome mode', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
@@ -25,7 +35,7 @@ describe('WelcomeScreen', () => {
it('shows all three option buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
@@ -46,11 +56,11 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
})
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
it('calls onCreateEmptyVault when create empty button is clicked', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.click(screen.getByTestId('welcome-create-new'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {
@@ -146,5 +156,15 @@ describe('WelcomeScreen', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<WelcomeScreen {...defaultProps} />)
const screenContainer = screen.getByTestId('welcome-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})
})

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -8,7 +9,7 @@ interface WelcomeScreenProps {
defaultVaultPath: string
onCreateVault: () => void
onRetryCreateVault: () => void
onCreateNewVault: () => void
onCreateEmptyVault: () => void
onOpenFolder: () => void
isOffline: boolean
creatingAction: 'template' | 'empty' | null
@@ -25,17 +26,8 @@ interface WelcomeScreenPresentation {
title: string
}
const CONTAINER_STYLE: React.CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sidebar)',
}
const CARD_STYLE: React.CSSProperties = {
width: 520,
width: 'min(520px, 100%)',
background: 'var(--background)',
borderRadius: 12,
border: '1px solid var(--border)',
@@ -238,7 +230,7 @@ export function WelcomeScreen({
defaultVaultPath,
onCreateVault,
onRetryCreateVault,
onCreateNewVault,
onCreateEmptyVault,
onOpenFolder,
isOffline,
creatingAction,
@@ -249,8 +241,12 @@ export function WelcomeScreen({
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
return (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
<div style={CARD_STYLE}>
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentStyle={CARD_STYLE}
testId="welcome-screen"
>
<>
<div
style={{
...ICON_WRAP_STYLE,
@@ -273,11 +269,11 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
label="Create a new vault"
description="Start fresh in a folder you choose"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
loadingDescription="Preparing an empty vault in the selected folder"
onClick={onCreateNewVault}
loadingDescription="Preparing Tolaria defaults in the selected folder"
onClick={onCreateEmptyVault}
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
@@ -331,7 +327,7 @@ export function WelcomeScreen({
)}
</div>
)}
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
buildPendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
describe('inlineWikilinkPasteRecovery', () => {
it('recognizes the expected post-paste value as recoverable', () => {
const pendingPaste = buildPendingPasteState(' ', { start: 1, end: 1 }, 'hello world')
expect(pendingPaste).toEqual({
duplicatedValue: ' hello worldhello world',
expectedValue: ' hello world',
expectedSelection: { start: 12, end: 12 },
})
expect(shouldRecoverPendingPaste(' hello world', pendingPaste)).toBe(true)
})
it('recognizes the duplicate native replay as recoverable when pasting into the middle of text', () => {
const pendingPaste = buildPendingPasteState('prefix suffix', { start: 7, end: 7 }, 'hello ')
expect(pendingPaste.duplicatedValue).toBe('prefix hello hello suffix')
expect(shouldRecoverPendingPaste('prefix hello hello suffix', pendingPaste)).toBe(true)
expect(shouldRecoverPendingPaste('prefix hello suffix!', pendingPaste)).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import type { InlineSelectionRange } from './inlineWikilinkDom'
import { replaceInlineSelection } from './inlineWikilinkEdits'
export interface PendingPasteState {
duplicatedValue: string
expectedValue: string
expectedSelection: InlineSelectionRange
}
export function buildPendingPasteState(
value: string,
selectionRange: InlineSelectionRange,
pastedText: string,
): PendingPasteState {
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
return {
duplicatedValue: replaceInlineSelection(nextState.value, nextState.selection, pastedText).value,
expectedValue: nextState.value,
expectedSelection: nextState.selection,
}
}
export function shouldRecoverPendingPaste(
nextValue: string,
pendingPaste: PendingPasteState,
): boolean {
return (
nextValue === pendingPaste.expectedValue ||
nextValue === pendingPaste.duplicatedValue
)
}

View File

@@ -372,8 +372,6 @@ export function ListPropertiesPopover({
handleToggle,
} = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave })
if (availableProperties.length === 0) return null
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewDefinition, ViewFile } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
@@ -156,29 +156,99 @@ export function useNoteListSearch() {
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
function findSelectedViewFile(selection: SidebarSelection, views?: ViewFile[]): ViewFile | null {
if (selection.kind !== 'view') return null
return views?.find((candidate) => candidate.filename === selection.filename) ?? null
}
function findSelectedTypeDocument(entries: VaultEntry[], selection: SidebarSelection): VaultEntry | null {
if (selection.kind !== 'sectionGroup') return null
return entries.find((entry) => entry.isA === 'Type' && entry.title === selection.type) ?? null
}
function resolveListSortConfig(
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
sortPrefs: Record<string, SortConfig>,
): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
if (selectedView?.definition.sort) {
const parsed = parseSortConfig(selectedView.definition.sort)
if (parsed) return parsed
}
return selectedView ? DEFAULT_LIST_CONFIG : (sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG)
}
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
onUpdateTypeSort?: (path: string, key: string, value: string) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function createSortPersistence(
onUpdateTypeSort?: SortPersistence['onUpdateTypeSort'],
updateEntry?: SortPersistence['updateEntry'],
onUpdateViewDefinition?: SortPersistence['onUpdateViewDefinition'],
): SortPersistence | null {
if (!onUpdateViewDefinition && !(onUpdateTypeSort && updateEntry)) return null
return { onUpdateTypeSort, updateEntry, onUpdateViewDefinition }
}
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
persistence.onUpdateTypeSort?.(path, 'sort', serialized)
persistence.updateEntry?.(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) {
if (groupLabel !== '__list__' || !typeDocument || !persistence) return null
return { path: typeDocument.path, persistence }
function persistSortToView(
filename: string,
config: SortConfig,
onUpdateViewDefinition: NonNullable<SortPersistence['onUpdateViewDefinition']>,
) {
onUpdateViewDefinition(filename, { sort: serializeSortConfig(config) })
}
type SortPersistenceTarget =
| { kind: 'type'; path: string }
| { kind: 'view'; filename: string }
function canPersistTypeSort(
persistence: SortPersistence,
): persistence is SortPersistence & Required<Pick<SortPersistence, 'onUpdateTypeSort' | 'updateEntry'>> {
return Boolean(persistence.onUpdateTypeSort && persistence.updateEntry)
}
function resolveSortPersistenceTarget(
groupLabel: string,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
): SortPersistenceTarget | null {
if (groupLabel !== '__list__' || !persistence) return null
if (typeDocument && canPersistTypeSort(persistence)) {
return { kind: 'type', path: typeDocument.path }
}
if (selectedView && persistence.onUpdateViewDefinition) {
return { kind: 'view', filename: selectedView.filename }
}
return null
}
function persistListSort(target: SortPersistenceTarget, config: SortConfig, persistence: SortPersistence) {
if (target.kind === 'type') {
persistSortToType(target.path, config, persistence)
return
}
if (persistence.onUpdateViewDefinition) {
persistSortToView(target.filename, config, persistence.onUpdateViewDefinition)
}
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
@@ -193,6 +263,24 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function persistOrSaveGroupSort(
groupLabel: string,
option: SortOption,
direction: SortDirection,
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
) {
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
if (!persistenceTarget || !persistence) {
saveGroupSort(groupLabel, option, direction, setSortPrefs)
return
}
persistListSort(persistenceTarget, { option, direction }, persistence)
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
@@ -205,22 +293,35 @@ export interface UseNoteListSortParams {
modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
views?: ViewFile[]
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({
entries,
selection,
modifiedPathSet,
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
}: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => findSelectedTypeDocument(entries, selection), [entries, selection])
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const listConfig = resolveListSortConfig(typeDocument, selectedView, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
() => createSortPersistence(onUpdateTypeSort, updateEntry, onUpdateViewDefinition),
[onUpdateTypeSort, onUpdateViewDefinition, updateEntry],
)
const migrationDoneRef = useRef<Set<string>>(new Set())
@@ -230,10 +331,16 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence)
if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs)
persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence)
}, [typeDocument, persistence])
persistOrSaveGroupSort(
groupLabel,
option,
direction,
setSortPrefs,
typeDocument,
selectedView,
persistence,
)
}, [typeDocument, selectedView, persistence])
const filteredEntries = useFilteredEntries({
entries,
@@ -242,6 +349,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
})
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
@@ -393,6 +501,96 @@ function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string
return ordered
}
interface ScopedPropertyPickerState {
availableProperties: string[]
defaultDisplay: string[]
}
function useAllNotesPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
isAllNotesView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
),
}
}
function useInboxPropertyPickerState(
entries: VaultEntry[],
inboxPeriod: InboxPeriod,
isInboxView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
),
}
}
interface ViewPropertyPickerState extends ScopedPropertyPickerState {
selectedView: ViewFile | null
hasCustomProperties: boolean
}
function useViewPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
views: ViewFile[] | undefined,
typeEntryMap: Record<string, VaultEntry>,
): ViewPropertyPickerState {
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const viewEntries = useMemo(
() => selectedView ? filterEntries(entries, selection, undefined, views) : [],
[entries, selection, selectedView, views],
)
return {
selectedView,
availableProperties: useMemo(
() => collectAvailableProperties(viewEntries),
[viewEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(viewEntries, typeEntryMap),
[viewEntries, typeEntryMap],
),
hasCustomProperties: Boolean(selectedView?.definition.listPropertiesDisplay?.length),
}
}
export interface NoteListPropertyPicker {
scope: NoteListPropertiesScope
availableProperties: string[]
@@ -457,6 +655,61 @@ function buildTypePropertyPicker({
}
}
interface BuildViewPropertyPickerParams {
selectedView: ViewFile | null
availableProperties: string[]
defaultDisplay: string[]
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function buildViewPropertyPicker({
selectedView,
availableProperties,
defaultDisplay,
onUpdateViewDefinition,
}: BuildViewPropertyPickerParams): NoteListPropertyPicker | null {
if (!selectedView || !onUpdateViewDefinition) return null
const currentDisplay = (selectedView.definition.listPropertiesDisplay?.length ?? 0) > 0
? selectedView.definition.listPropertiesDisplay ?? []
: defaultDisplay
return {
scope: 'view',
availableProperties,
currentDisplay,
onSave: (value: string[] | null) => onUpdateViewDefinition(selectedView.filename, { listPropertiesDisplay: value ?? [] }),
triggerTitle: `Customize ${selectedView.definition.name} columns`,
}
}
function resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView,
hasCustomViewProperties,
}: {
isAllNotesView: boolean
hasCustomAllNotesProperties: boolean
allNotesNoteListProperties?: string[] | null
isInboxView: boolean
hasCustomInboxProperties: boolean
inboxNoteListProperties?: string[] | null
selectedView: ViewFile | null
hasCustomViewProperties: boolean
}) {
if (selectedView && hasCustomViewProperties) {
return selectedView.definition.listPropertiesDisplay ?? null
}
if (isAllNotesView && hasCustomAllNotesProperties) return allNotesNoteListProperties ?? null
if (isInboxView && hasCustomInboxProperties) return inboxNoteListProperties ?? null
return null
}
interface UseListPropertyPickerParams {
entries: VaultEntry[]
selection: SidebarSelection
@@ -467,7 +720,9 @@ interface UseListPropertyPickerParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
views?: ViewFile[]
}
export function useListPropertyPicker({
@@ -480,68 +735,55 @@ export function useListPropertyPicker({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
}: UseListPropertyPickerParams) {
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isSectionGroup = selection.kind === 'sectionGroup'
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
const allNotesAvailableProperties = useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
)
const allNotesDefaultDisplay = useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
)
const allNotesState = useAllNotesPropertyPickerState(entries, selection, isAllNotesView, typeEntryMap)
const inboxState = useInboxPropertyPickerState(entries, inboxPeriod, isInboxView, typeEntryMap)
const viewState = useViewPropertyPickerState(entries, selection, views, typeEntryMap)
const typeAvailableProperties = useMemo(
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
[entries, typeDocument],
)
const inboxAvailableProperties = useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
)
const inboxDefaultDisplay = useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
)
const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0)
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties
? allNotesNoteListProperties
: (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null)
const displayPropsOverride = resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView: viewState.selectedView,
hasCustomViewProperties: viewState.hasCustomProperties,
})
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
return buildFilterPropertyPicker({
return buildViewPropertyPicker({
selectedView: viewState.selectedView,
availableProperties: viewState.availableProperties,
defaultDisplay: viewState.defaultDisplay,
onUpdateViewDefinition,
}) ?? buildFilterPropertyPicker({
scope: 'all',
isActive: isAllNotesView,
availableProperties: allNotesAvailableProperties,
availableProperties: allNotesState.availableProperties,
hasCustomProperties: hasCustomAllNotesProperties,
noteListProperties: allNotesNoteListProperties,
defaultDisplay: allNotesDefaultDisplay,
defaultDisplay: allNotesState.defaultDisplay,
onSave: onUpdateAllNotesNoteListProperties,
triggerTitle: 'Customize All Notes columns',
}) ?? buildFilterPropertyPicker({
scope: 'inbox',
isActive: isInboxView,
availableProperties: inboxAvailableProperties,
availableProperties: inboxState.availableProperties,
hasCustomProperties: hasCustomInboxProperties,
noteListProperties: inboxNoteListProperties,
defaultDisplay: inboxDefaultDisplay,
defaultDisplay: inboxState.defaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}) ?? buildTypePropertyPicker({
@@ -551,15 +793,19 @@ export function useListPropertyPicker({
typeAvailableProperties,
})
}, [
allNotesAvailableProperties,
allNotesDefaultDisplay,
allNotesState.availableProperties,
allNotesState.defaultDisplay,
allNotesNoteListProperties,
hasCustomAllNotesProperties,
isAllNotesView,
onUpdateAllNotesNoteListProperties,
hasCustomInboxProperties,
inboxAvailableProperties,
inboxDefaultDisplay,
isAllNotesView,
inboxState.availableProperties,
inboxState.defaultDisplay,
viewState.availableProperties,
viewState.defaultDisplay,
viewState.selectedView,
onUpdateViewDefinition,
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
isInboxView,
isSectionGroup,

View File

@@ -1,4 +1,4 @@
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
export interface OpenListPropertiesEventDetail {
scope: NoteListPropertiesScope

View File

@@ -5,6 +5,7 @@ import type {
ModifiedFile,
NoteStatus,
InboxPeriod,
ViewDefinition,
ViewFile,
} from '../../types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
@@ -94,6 +95,7 @@ interface UseNoteListContentParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
views?: ViewFile[]
@@ -113,6 +115,7 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,
@@ -129,7 +132,9 @@ function useNoteListContent({
modifiedSuffixes,
subFilter,
inboxPeriod: effectiveInboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
})
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
@@ -144,7 +149,9 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
})
const {
isEntityView,
@@ -372,6 +379,7 @@ export interface NoteListProps {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
@@ -466,6 +474,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
views,
visibleNotesRef,
}: NoteListProps) {
@@ -486,6 +495,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,

View File

@@ -35,6 +35,7 @@ interface StatusBarPrimarySectionProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -77,6 +78,7 @@ export function StatusBarPrimarySection({
vaults,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -111,6 +113,7 @@ export function StatusBarPrimarySection({
vaultPath={vaultPath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
@@ -11,6 +11,7 @@ interface VaultMenuProps {
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
@@ -42,12 +43,24 @@ interface VaultAction {
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onCreateEmptyVault) {
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
})
}
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
@@ -88,67 +101,49 @@ function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailab
return <span style={{ width: 12 }} />
}
function vaultItemStyle(isActive: boolean, unavailable: boolean): CSSProperties {
return {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: unavailable ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
fontSize: 12,
}
}
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const canHover = !isActive && !unavailable
return (
<div
role="button"
onClick={unavailable ? undefined : onSelect}
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Button
type="button"
variant="ghost"
size="xs"
disabled={unavailable}
onClick={onSelect}
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className="w-full justify-between rounded-sm px-2 py-1 text-xs font-normal"
style={{
height: 'auto',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
</Button>
{canRemove && onRemove && (
<span
role="button"
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
style={{
display: 'flex',
alignItems: 'center',
padding: 2,
borderRadius: 3,
cursor: 'pointer',
opacity: 0.5,
}}
title="Remove from list"
data-testid={`vault-menu-remove-${vault.label}`}
onMouseEnter={(event) => {
event.currentTarget.style.opacity = '1'
event.currentTarget.style.background = 'var(--hover)'
}}
onMouseLeave={(event) => {
event.currentTarget.style.opacity = '0.5'
event.currentTarget.style.background = 'transparent'
}}
className="rounded-sm"
style={{ opacity: 0.5 }}
>
<X size={10} />
</span>
</Button>
)}
</div>
)
@@ -156,27 +151,18 @@ function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: Vault
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
return (
<div
role="button"
<Button
type="button"
variant="ghost"
size="xs"
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
borderRadius: 4,
cursor: 'pointer',
background: 'transparent',
color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)',
fontSize: 12,
}}
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
data-testid={testId}
>
{icon}
{label}
</div>
</Button>
)
}
@@ -185,6 +171,7 @@ export function VaultMenu({
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
@@ -198,11 +185,12 @@ export function VaultMenu({
const actions = useMemo<VaultAction[]>(() => {
return buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

View File

@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
}, [selectionRange, value])
return {
editorRef,
selectionRange,
selectionIndex: selectionRange.end,
setSelectionRange,

View File

@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
enabled: true,
})
})
it('adds a create-empty-vault command when the handler is available', () => {
const onOpenSettings = vi.fn()
const onCreateEmptyVault = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
const command = commands.find((item) => item.id === 'create-empty-vault')
expect(command).toMatchObject({
label: 'Create Empty Vault…',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
})

View File

@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCheckForUpdates?: () => void
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
return [
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}),

View File

@@ -51,6 +51,7 @@ interface AppCommandsConfig {
canGoBack?: boolean
canGoForward?: boolean
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
@@ -82,6 +83,7 @@ interface AppCommandsConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
}
@@ -184,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleRawEditor: config.onToggleRawEditor,
onToggleAIChat: config.onToggleAIChat,
onOpenVault: config.onOpenVault,
onCreateEmptyVault: config.onCreateEmptyVault,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
@@ -224,6 +227,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleOrganized: config.onToggleOrganized,
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
noteListColumnsLabel: config.noteListColumnsLabel,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}

View File

@@ -45,6 +45,7 @@ interface CommandRegistryConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
onQuickOpen: () => void
@@ -54,6 +55,7 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -92,7 +94,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
@@ -119,9 +121,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const isArchived = activeEntry?.archived ?? false
const isFavorite = activeEntry?.favorite ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
const noteListColumnsLabel = selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
const noteListColumnsLabel = config.noteListColumnsLabel ?? (
selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
)
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
@@ -143,7 +147,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildAiAgentCommands({
@@ -162,7 +166,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDragRegion } from './useDragRegion'
const startDragging = vi.fn().mockResolvedValue(undefined)
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ startDragging }),
}))
function DragRegionHarness() {
const { onMouseDown } = useDragRegion()
return (
<div data-testid="drag-surface" onMouseDown={onMouseDown}>
<div data-testid="no-drag-card" data-no-drag>
<button type="button">Action</button>
</div>
</div>
)
}
describe('useDragRegion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts dragging from the background surface', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 })
expect(startDragging).toHaveBeenCalledOnce()
})
it('does not start dragging from no-drag containers', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('does not start dragging from interactive descendants', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('ignores non-primary mouse buttons', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 })
expect(startDragging).not.toHaveBeenCalled()
})
})

View File

@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
await result.current.handleCreateEmptyVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
it('does nothing when the empty-vault picker is cancelled', async () => {
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateNewVault()
await onboarding.handleCreateEmptyVault()
})
})

View File

@@ -64,6 +64,7 @@ export function useOnboarding(
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
const [error, setError] = useState<string | null>(null)
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
@@ -110,6 +111,7 @@ export function useOnboarding(
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
onTemplateVaultReady?.(vaultPath)
} catch (err) {
setError(formatGettingStartedCloneError(err))
@@ -129,7 +131,7 @@ export function useOnboarding(
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateNewVault = useCallback(async () => {
const handleCreateEmptyVault = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Choose where to create your vault')
@@ -138,6 +140,7 @@ export function useOnboarding(
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markDismissed()
setState({ status: 'ready', vaultPath })
setUserReadyVaultPath(vaultPath)
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
@@ -152,6 +155,7 @@ export function useOnboarding(
if (!path) return
markDismissed()
setState({ status: 'ready', vaultPath: path })
setUserReadyVaultPath(path)
} catch (err) {
setError(`Failed to open folder: ${err}`)
}
@@ -170,8 +174,9 @@ export function useOnboarding(
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
handleCreateVault,
retryCreateVault,
handleCreateNewVault,
handleCreateEmptyVault,
handleOpenFolder,
handleDismiss,
userReadyVaultPath,
}
}

View File

@@ -4,6 +4,7 @@ import type { FrontmatterValue } from '../components/Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import {
type PropertyDisplayMode,
getEffectiveDisplayMode,
loadDisplayModeOverrides,
saveDisplayModeOverride,
removeDisplayModeOverride,
@@ -22,6 +23,14 @@ function coerceValue(raw: string): FrontmatterValue {
return raw
}
function coerceNumberValue(raw: string): FrontmatterValue {
const trimmed = raw.trim()
if (trimmed === '') return ''
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : raw
}
function parseNewValue(rawValue: string): FrontmatterValue {
if (!rawValue.includes(',')) return rawValue.trim() || ''
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
@@ -125,6 +134,7 @@ function isHiddenPropertyKey(key: string): boolean {
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'number') return coerceNumberValue(rawValue)
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
@@ -137,6 +147,37 @@ function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
else saveDisplayModeOverride(key, mode)
}
function saveScalarProperty({
key,
newValue,
frontmatter,
displayOverrides,
onUpdateProperty,
onDeleteProperty,
}: {
key: string
newValue: string
frontmatter: ParsedFrontmatter
displayOverrides: Record<string, PropertyDisplayMode>
onUpdateProperty: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
}) {
const currentValue = frontmatter[key] ?? null
const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides)
if (displayMode !== 'number') {
onUpdateProperty(key, coerceValue(newValue))
return
}
const trimmed = newValue.trim()
if (trimmed === '') {
onDeleteProperty?.(key)
return
}
onUpdateProperty(key, coerceNumberValue(newValue))
}
export interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
@@ -159,8 +200,9 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
}, [onUpdateProperty])
if (!onUpdateProperty) return
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return

View File

@@ -265,6 +265,48 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.tabs[0].content).toBe('# Persisted content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
it('reuses cached content when reopening a recently loaded note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# A content')
.mockResolvedValueOnce('# B content')
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/a.md', title: 'A' })
await selectNote(result, { path: '/vault/b.md', title: 'B' })
await selectNote(result, { path: '/vault/a.md', title: 'A again' })
expect(result.current.tabs[0].entry.path).toBe('/vault/a.md')
expect(result.current.tabs[0].content).toBe('# A content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
})
it('deduplicates a late prefetch after note opening already started', async () => {
const { mockInvoke } = await import('../mock-tauri')
let resolveContent!: (value: string) => void
vi.mocked(mockInvoke).mockImplementationOnce(
() => new Promise<string>((resolve) => { resolveContent = resolve }),
)
const { result } = renderHook(() => useTabManagement())
await act(async () => {
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/rapid.md', title: 'Rapid' }))
prefetchNoteContent('/vault/note/rapid.md')
await Promise.resolve()
})
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
await act(async () => {
resolveContent('# Rapid content')
await Promise.resolve()
})
expect(result.current.tabs[0].content).toBe('# Rapid content')
})
})
describe('rapid switching safety', () => {

View File

@@ -8,30 +8,57 @@ interface Tab {
content: string
}
type NotePath = VaultEntry['path']
// --- Content prefetch cache ---
// Stores in-flight or resolved note content promises, keyed by path.
// Stores in-flight or recently loaded note content promises, keyed by path.
// Cleared on vault reload to prevent stale content after external edits.
// Latency profile: eliminates 50-200ms IPC round-trip for hover/keyboard-prefetched notes.
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
const prefetchCache = new Map<string, Promise<string>>()
const NOTE_CONTENT_CACHE_LIMIT = 48
interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
}
function trimPrefetchCache(): void {
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
const oldestPath = prefetchCache.keys().next().value
if (!oldestPath) return
prefetchCache.delete(oldestPath)
}
}
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, promise)
trimPrefetchCache()
return promise
}
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
).catch((err) => {
prefetchCache.delete(path)
throw err
})
return rememberNoteContent({ path, promise })
}
/** Prefetch a note's content into the in-memory cache.
* Safe to call multiple times — deduplicates concurrent requests for the same path.
* Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */
export function prefetchNoteContent(path: string): void {
if (prefetchCache.has(path)) return
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
).catch((err) => {
// Remove failed prefetch so a retry can occur
prefetchCache.delete(path)
throw err
})
prefetchCache.set(path, promise)
requestNoteContent({ path })
}
export function cacheNoteContent(path: string, content: string): void {
prefetchCache.set(path, Promise.resolve(content))
rememberNoteContent({ path, promise: Promise.resolve(content) })
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
@@ -40,15 +67,7 @@ export function clearPrefetchCache(): void {
}
async function loadNoteContent(path: string): Promise<string> {
// Check prefetch cache first — eliminates IPC round-trip for prefetched notes
const cached = prefetchCache.get(path)
if (cached) {
prefetchCache.delete(path)
return cached
}
return isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
return prefetchCache.get(path) ?? requestNoteContent({ path })
}
export type { Tab }

View File

@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
type MockInvokeOverrides = {
checkVaultExists?: boolean
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
}
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
// Re-set default implementation after resetAllMocks
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(true)
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
}
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
}
return Promise.resolve(null)
})
}
const renderLoadedVaultSwitcher = async () => {
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(hook.result.current.loaded).toBe(true)
})
return hook
}
const setWorkVaultWithHiddenGettingStarted = () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
}
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
setMockInvokeBehavior()
})
it('starts with default vaults', async () => {
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
it('creates an empty vault and switches to it', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')
setMockInvokeBehavior({
createEmptyVault: ({ targetPath }) => targetPath,
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
})
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
setMockInvokeBehavior({
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
})
describe('removeVault', () => {
it('removes an extra vault from the list', async () => {
mockVaultListStore = {
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
describe('restoreGettingStarted', () => {
it('un-hides the Getting Started vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
})
it('switches to the Getting Started vault after restoring', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
})
it('attempts to create vault on disk if it does not exist', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: ({ targetPath }) => targetPath,
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
})
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') {
return Promise.reject('git clone failed: fatal: unable to access')
}
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()

View File

@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
return `Could not prepare Getting Started vault: ${message}`
}
function formatCreateEmptyVaultError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
if (message.includes('Choose an empty folder')) {
return message
}
return `Could not create empty vault: ${message}`
}
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
const exists = await tauriCall<boolean>('check_vault_exists', { path })
if (!exists) {
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
}, [addAndSwitch, onToastRef])
}
function useCreateEmptyVaultAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
try {
const targetPath = await pickFolder('Choose where to create your vault')
if (!targetPath) return
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
const label = labelFromPath({ path: vaultPath })
addAndSwitch(vaultPath, label)
onToastRef.current(`Vault "${label}" created and opened`)
} catch (err) {
onToastRef.current(formatCreateEmptyVaultError(err))
}
}, [addAndSwitch, onToastRef])
}
function useRemoveVaultAction({
defaultVaults,
extraVaults,
@@ -589,6 +623,7 @@ function useVaultActions({
}, [addVault, switchVault])
return {
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
removeVault: useRemoveVaultAction({
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
hiddenDefaults,
extraVaults,
)
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
...persistedState,
allVaults,
defaultVaults,
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
return {
allVaults,
defaultPath,
handleCreateEmptyVault,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,

View File

@@ -372,7 +372,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault',
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',

View File

@@ -201,6 +201,7 @@ export interface ViewDefinition {
icon: string | null
color: string | null
sort: string | null
listPropertiesDisplay?: string[]
filters: FilterGroup
}

View File

@@ -29,6 +29,11 @@ describe('detectPropertyType', () => {
expect(detectPropertyType('published', false)).toBe('boolean')
})
it('detects number from value type', () => {
expect(detectPropertyType('estimate', 3)).toBe('number')
expect(detectPropertyType('ratio', -1.5)).toBe('number')
})
it('detects status from key name', () => {
expect(detectPropertyType('status', 'Active')).toBe('status')
expect(detectPropertyType('Status', 'Draft')).toBe('status')

View File

@@ -2,10 +2,10 @@ import type { FrontmatterValue } from '../components/Inspector'
import { getAppStorageItem } from '../constants/appStorage'
import { isValidCssColor, isColorKeyName } from './colorUtils'
import { updateVaultConfigField } from './vaultConfigStore'
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
import { canonicalSystemMetadataKey } from './systemMetadata'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
@@ -33,17 +33,35 @@ function isDateString(value: string): boolean {
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
}
function isStatusKey(key: string): boolean {
return keyMatchesPatterns(key, STATUS_KEY_PATTERNS)
}
function isDateKey(key: string): boolean {
return keyMatchesPatterns(key, DATE_KEY_PATTERNS)
}
function isStatusString(key: string, value: string): boolean {
if (isStatusKey(key)) return true
if (isDateKey(key)) return false
return STATUS_VALUES.has(value.toLowerCase())
}
function isColorString(key: string, value: string): boolean {
return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key))
}
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isStatusString(key, strValue)) return 'status'
if (isDateString(strValue)) return 'date'
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
if (isColorString(key, strValue)) return 'color'
return 'text'
}
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
if (value === null || value === undefined) return 'text'
if (typeof value === 'number') return 'number'
if (typeof value === 'boolean') return 'boolean'
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
@@ -95,22 +113,25 @@ export function getEffectiveDisplayMode(
return overrides[key] ?? detectPropertyType(key, value)
}
export function formatDateValue(value: string): string {
function resolveDateFromValue(value: string): Date | null {
const isoMatch = value.match(ISO_DATE_RE)
if (isoMatch) {
const d = new Date(isoMatch[0])
if (!isNaN(d.getTime())) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
const date = new Date(isoMatch[0])
return Number.isNaN(date.getTime()) ? null : date
}
const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/)
if (parts) {
const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
if (!isNaN(d.getTime())) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
}
return value
if (!parts) return null
const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
return Number.isNaN(date.getTime()) ? null : date
}
export function formatDateValue(value: string): string {
const date = resolveDateFromValue(value)
return date
? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
: value
}
export function toISODate(value: string): string {
@@ -123,11 +144,12 @@ export function toISODate(value: string): string {
}
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
text: Type, number: Hash, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
export const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Number' },
{ value: 'date', label: 'Date' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'status', label: 'Status' },

View File

@@ -1,10 +1,12 @@
import { test, expect, type Page } from '@playwright/test'
import { openCommandPalette } from './helpers'
import {
expectEditorSelectionRange,
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
writeClipboardText,
} from './inlineWikilinkEditorHelpers'
async function readCaretGapAfterChip(page: Page) {
@@ -72,4 +74,57 @@ test.describe('Command palette AI mode regression', () => {
await expect(aiInput).toBeVisible()
await expectNoPageErrors(pageErrors)
})
test('keeps pasted text, caret movement, and selection replacement stable in AI mode', async ({ page }) => {
const pageErrors = trackPageErrors(page)
const aiInputTarget = { dataTestId: 'command-palette-ai-input' }
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
const aiInput = page.getByTestId('command-palette-ai-input')
await expect(aiInput).toBeVisible()
await expect(aiInput).toBeFocused()
await writeClipboardText(page, { text: 'hello world' })
await page.keyboard.press('Meta+V')
await expectNormalizedEditorText(aiInput, 'hello world')
await expectEditorSelectionRange(page, {
expectedRange: { start: 12, end: 12 },
target: aiInputTarget,
})
for (let i = 0; i < 5; i += 1) {
await page.keyboard.press('ArrowLeft')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 7, end: 7 },
target: aiInputTarget,
})
await page.keyboard.press('Shift+ArrowRight')
await page.keyboard.press('Shift+ArrowRight')
await expectEditorSelectionRange(page, {
expectedRange: { start: 7, end: 9 },
target: aiInputTarget,
})
await page.keyboard.type('XY')
await expectNormalizedEditorText(aiInput, 'hello XYrld')
await expectEditorSelectionRange(page, {
expectedRange: { start: 9, end: 9 },
target: aiInputTarget,
})
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press('ArrowRight')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 12, end: 12 },
target: aiInputTarget,
})
await page.keyboard.press('Backspace')
await expectNormalizedEditorText(aiInput, 'hello XYrl')
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -0,0 +1,195 @@
import { test, expect, type Page } from '@playwright/test'
import { findCommand, openCommandPalette, sendShortcut } from './helpers'
interface MockEntry {
path: string
filename: string
title: string
isA: string
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
archived: boolean
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
template: null
sort: null
}
interface VaultSeed {
label: string
path: string
noteTitle?: string
}
function untitledNoteRow(page: Page) {
return page.getByText(/^Untitled Note(?: \d+)?$/i).first()
}
async function installEmptyVaultMocks(
page: Page,
config: {
createdVaultPath: string
initialVaults: VaultSeed[]
activeVault: string | null
},
) {
await page.addInitScript((mockConfig) => {
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
function buildEntry(vaultPath: string, title: string): MockEntry {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
return {
path: `${vaultPath}/${slug}.md`,
filename: `${slug}.md`,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: `${title} snippet`,
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}
}
let savedVaults = mockConfig.initialVaults.map(({ label, path }) => ({ label, path }))
let activeVault = mockConfig.activeVault
let hiddenDefaults: string[] = []
const entriesByVault = Object.fromEntries(
mockConfig.initialVaults.map((vault) => [
vault.path,
vault.noteTitle ? [buildEntry(vault.path, vault.noteTitle)] : [],
]),
) satisfies Record<string, MockEntry[]>
const allContent = Object.fromEntries(
Object.values(entriesByVault)
.flat()
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
)
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => mockConfig.createdVaultPath,
})
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [...savedVaults],
active_vault: activeVault,
hidden_defaults: [...hiddenDefaults],
})
ref.save_vault_list = (args: {
list: {
vaults: Array<{ label: string; path: string }>
active_vault: string | null
hidden_defaults?: string[]
}
}) => {
savedVaults = [...args.list.vaults]
activeVault = args.list.active_vault
hiddenDefaults = [...(args.list.hidden_defaults ?? [])]
return null
}
ref.get_default_vault_path = () => gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) =>
savedVaults.some((vault) => vault.path === args.path)
|| args.path === mockConfig.createdVaultPath
ref.create_empty_vault = (args: { targetPath?: string | null }) => {
if (args.targetPath !== mockConfig.createdVaultPath) {
throw new Error(`Unexpected empty vault target: ${args.targetPath}`)
}
entriesByVault[mockConfig.createdVaultPath] ??= []
return mockConfig.createdVaultPath
}
ref.list_vault = (args: { path?: string }) => entriesByVault[args.path ?? activeVault ?? ''] ?? []
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => allContent
ref.get_note_content = (args: { path?: string }) => allContent[args.path ?? ''] ?? ''
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
}, config)
}
test('keyboard onboarding can create an empty vault and the first note', async ({ page }) => {
await installEmptyVaultMocks(page, {
createdVaultPath: '/Users/mock/Documents/Fresh Vault',
initialVaults: [],
activeVault: null,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await sendShortcut(page, 'n', ['Control'])
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
})
test('command palette and bottom bar expose empty-vault creation from the active app shell', async ({ page }) => {
await installEmptyVaultMocks(page, {
createdVaultPath: '/Users/mock/Documents/Client Vault',
initialVaults: [{ label: 'Work Vault', path: '/Users/mock/Work', noteTitle: 'Work Home' }],
activeVault: '/Users/mock/Work',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work Vault')
await openCommandPalette(page)
expect(await findCommand(page, 'Create Empty Vault')).toBe(true)
await page.keyboard.press('Escape')
const trigger = page.getByTestId('status-vault-trigger')
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
const createEmptyItem = page.getByTestId('vault-menu-create-empty')
await createEmptyItem.focus()
await expect(createEmptyItem).toBeFocused()
await page.keyboard.press('Enter')
await expect(trigger).toContainText('Client Vault')
await sendShortcut(page, 'n', ['Control'])
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
})

View File

@@ -62,6 +62,42 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
})
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('telemetry-decline')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('telemetry-accept')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
let dialogHandled = false
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('Open vault folder')
await dialog.dismiss()
dialogHandled = true
})
await page.keyboard.press('Enter')
await expect.poll(() => dialogHandled).toBe(true)
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {

View File

@@ -1,9 +1,11 @@
import { test, expect } from '@playwright/test'
import {
expectEditorSelectionRange,
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
writeClipboardText,
} from './inlineWikilinkEditorHelpers'
test.describe('Inline wikilink editor regression', () => {
@@ -38,4 +40,53 @@ test.describe('Inline wikilink editor regression', () => {
await expect(editor).toBeVisible()
await expectNoPageErrors(pageErrors)
})
test('keeps pasted text, caret movement, and selection replacement stable in the AI panel chat input', async ({ page }) => {
const pageErrors = trackPageErrors(page)
const agentInputTarget = { dataTestId: 'agent-input' }
const editor = page.getByTestId('agent-input')
await expect(editor).toBeFocused()
await writeClipboardText(page, { text: 'hello world' })
await page.keyboard.press('Meta+V')
await expectNormalizedEditorText(editor, 'hello world')
await expectEditorSelectionRange(page, {
expectedRange: { start: 11, end: 11 },
target: agentInputTarget,
})
for (let i = 0; i < 5; i += 1) {
await page.keyboard.press('ArrowLeft')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 6, end: 6 },
target: agentInputTarget,
})
await page.keyboard.press('Shift+ArrowRight')
await page.keyboard.press('Shift+ArrowRight')
await expectEditorSelectionRange(page, {
expectedRange: { start: 6, end: 8 },
target: agentInputTarget,
})
await page.keyboard.type('XY')
await expectNormalizedEditorText(editor, 'hello XYrld')
await expectEditorSelectionRange(page, {
expectedRange: { start: 8, end: 8 },
target: agentInputTarget,
})
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press('ArrowRight')
}
await expectEditorSelectionRange(page, {
expectedRange: { start: 11, end: 11 },
target: agentInputTarget,
})
await page.keyboard.press('Backspace')
await expectNormalizedEditorText(editor, 'hello XYrl')
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -5,6 +5,24 @@ interface SelectEditorTextRangeArgs {
startOffset: number
}
interface ClipboardWriteArgs {
text: string
}
interface EditorSelectionRange {
start: number
end: number
}
interface EditorTarget {
dataTestId: string
}
interface ExpectEditorSelectionRangeArgs {
expectedRange: EditorSelectionRange
target: EditorTarget
}
export function trackPageErrors(page: Page): string[] {
const pageErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(error.message))
@@ -34,6 +52,28 @@ export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
.toEqual([])
}
export async function writeClipboardText(
page: Page,
{ text }: ClipboardWriteArgs,
): Promise<void> {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.evaluate(async ({ text: nextText }) => {
await navigator.clipboard.writeText(nextText)
}, { text })
}
export async function expectEditorSelectionRange(
page: Page,
{ expectedRange, target }: ExpectEditorSelectionRangeArgs,
): Promise<void> {
await expect
.poll(
async () => page.evaluate(readEditorSelectionRangeInBrowser, target),
{ timeout: 2_000 },
)
.toEqual(expectedRange)
}
function normalizeEditorText(value: string | null): string {
return value?.replace(/\s+/g, ' ').trim() ?? ''
}
@@ -55,3 +95,28 @@ function selectEditorTextRangeInBrowser({
selection.removeAllRanges()
selection.addRange(range)
}
function readEditorSelectionRangeInBrowser({
dataTestId,
}: EditorTarget): EditorSelectionRange | null {
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
const selection = window.getSelection()
if (!editor || !selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) return null
const startRange = document.createRange()
startRange.selectNodeContents(editor)
startRange.setEnd(range.startContainer, range.startOffset)
const endRange = document.createRange()
endRange.selectNodeContents(editor)
endRange.setEnd(range.endContainer, range.endOffset)
const normalizeSelectionText = (value: string) => value.replace(/\u200B/g, '')
return {
start: normalizeSelectionText(startRange.toString()).length,
end: normalizeSelectionText(endRange.toString()).length,
}
}

View File

@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function alphaProjectPath(vaultPath: string): string {
return path.join(vaultPath, 'project', 'alpha-project.md')
}
test.describe('Number property editing', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('properties panel saves number values across raw-mode switches and note reloads @smoke', async ({ page }) => {
const notePath = alphaProjectPath(tempVaultDir)
const noteList = page.getByTestId('note-list-container')
await noteList.getByText('Alpha Project', { exact: true }).click()
await page.keyboard.press('Control+Shift+i')
await expect(page.getByTestId('add-property-row')).toBeVisible()
await page.getByTestId('add-property-row').focus()
await page.keyboard.press('Enter')
await expect(page.getByTestId('add-property-form')).toBeVisible()
await page.keyboard.type('Estimate')
await page.getByTestId('add-property-type-trigger').click()
await page.getByRole('option', { name: 'Number', exact: true }).click()
const numberInput = page.getByTestId('add-property-number-input')
await expect(numberInput).toBeVisible()
await numberInput.focus()
await page.keyboard.type(' -12.5 ')
await page.keyboard.press('Enter')
await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toContain('Estimate: -12.5')
await expect(page.getByTestId('number-display')).toContainText('-12.5')
await page.keyboard.press('Control+Backslash')
const rawEditor = page.locator('.cm-content')
await expect(rawEditor).toBeVisible()
await expect(rawEditor).toContainText('Estimate: -12.5')
await page.keyboard.press('Control+Backslash')
await page.reload({ waitUntil: 'networkidle' })
await noteList.getByText('Alpha Project', { exact: true }).click()
await page.keyboard.press('Control+Shift+i')
await expect(page.getByTestId('number-display')).toContainText('-12.5')
})
})

View File

@@ -0,0 +1,167 @@
import { test, expect, type Page } from '@playwright/test'
interface MockEntry {
path: string
filename: string
title: string
isA: string
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
archived: boolean
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
template: null
sort: null
}
async function installVaultSwitcherMocks(page: Page) {
await page.addInitScript(() => {
const workVaultPath = '/Users/mock/Work'
const personalVaultPath = '/Users/mock/Personal'
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
const entriesByVault = {
[gettingStartedPath]: [({
path: `${gettingStartedPath}/getting-started.md`,
filename: 'getting-started.md',
title: 'Getting Started Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Getting Started snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
[workVaultPath]: [({
path: `${workVaultPath}/work-home.md`,
filename: 'work-home.md',
title: 'Work Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Work Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
[personalVaultPath]: [({
path: `${personalVaultPath}/personal-home.md`,
filename: 'personal-home.md',
title: 'Personal Home',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Personal Home snippet',
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
})],
} satisfies Record<string, MockEntry[]>
const allContent = Object.fromEntries(
Object.values(entriesByVault)
.flat()
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
)
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [
{ label: 'Work Vault', path: workVaultPath },
{ label: 'Personal Vault', path: personalVaultPath },
],
active_vault: workVaultPath,
hidden_defaults: [],
})
ref.get_default_vault_path = () => gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) => typeof args?.path === 'string' && args.path.length > 0
ref.list_vault = (args: { path?: string }) => entriesByVault[args?.path ?? ''] ?? []
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => allContent
ref.get_note_content = (args: { path?: string }) => allContent[args?.path ?? ''] ?? ''
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
})
}
test('bottom bar vault switching works with keyboard and mouse @smoke', async ({ page }) => {
await installVaultSwitcherMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const trigger = page.getByTestId('status-vault-trigger')
const noteList = page.getByTestId('note-list-container')
await expect(trigger).toContainText('Work Vault')
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
await page.keyboard.press('Tab')
await expect(page.getByTestId('vault-menu-item-Getting Started')).toBeFocused()
await page.getByTestId('vault-menu-item-Personal Vault').focus()
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeFocused()
await page.keyboard.press('Enter')
await expect(trigger).toContainText('Personal Vault')
await expect(noteList.getByText('Personal Home', { exact: true })).toBeVisible()
await expect(noteList.getByText('Work Home', { exact: true })).toHaveCount(0)
await trigger.click()
await page.getByTestId('vault-menu-item-Work Vault').click()
await expect(trigger).toContainText('Work Vault')
await expect(noteList.getByText('Work Home', { exact: true })).toBeVisible()
await expect(noteList.getByText('Personal Home', { exact: true })).toHaveCount(0)
})

View File

@@ -1,84 +1,62 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'
const SOURCE_NOTE_TITLE = 'Grow Newsletter'
const INSERTED_WIKILINK_QUERY = '[[Mana'
const INSERTED_WIKILINK_TITLE = 'Manage Sponsorships'
async function insertWikilink(page: Page) {
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
const firstParagraph = editor.locator('p').first()
await expect(
firstParagraph,
).toContainText('Build a sustainable audience through high-quality weekly essays', { timeout: 5000 })
await firstParagraph.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type(INSERTED_WIKILINK_QUERY)
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
const matchingWikilinks = editor.locator('.wikilink').filter({ hasText: INSERTED_WIKILINK_TITLE })
const existingCount = await matchingWikilinks.count()
await suggestionMenu.getByText(INSERTED_WIKILINK_TITLE, { exact: true }).click()
await page.waitForTimeout(500)
await expect(matchingWikilinks).toHaveCount(existingCount + 1)
return matchingWikilinks.nth(existingCount)
}
test.describe('Wikilink insertion and navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(500)
// Select first note to open in editor
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
const noteItem = page.locator('.app__note-list .cursor-pointer').filter({ hasText: SOURCE_NOTE_TITLE }).first()
await noteItem.click()
await page.waitForTimeout(1000)
})
test('[[ autocomplete inserts wikilink that is not broken', async ({ page }) => {
// Focus editor and move to a new line
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
const wikilink = await insertWikilink(page)
// Type [[ then a query (>= 2 chars for MIN_QUERY_LENGTH)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
// The wikilink suggestion menu should appear
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
// Select the first suggestion
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// A wikilink should have been inserted
const wikilinks = page.locator('.wikilink')
const count = await wikilinks.count()
expect(count).toBeGreaterThanOrEqual(1)
// The wikilink should NOT be broken
const lastWikilink = wikilinks.last()
const isBroken = await lastWikilink.evaluate(
const isBroken = await wikilink.evaluate(
el => el.classList.contains('wikilink--broken'),
)
expect(isBroken).toBe(false)
// The wikilink should have a data-target attribute
const target = await lastWikilink.getAttribute('data-target')
const target = await wikilink.getAttribute('data-target')
expect(target).toBeTruthy()
})
test('@smoke Cmd+clicking an inserted wikilink navigates to the note', async ({ page }) => {
// Insert a wikilink via autocomplete
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 5000 })
await editor.click()
await page.keyboard.press('End')
await page.keyboard.press('Enter')
await page.waitForTimeout(200)
await page.keyboard.type('[[Ma')
await page.waitForTimeout(800)
const suggestionMenu = page.locator('.wikilink-menu')
await expect(suggestionMenu).toBeVisible({ timeout: 5000 })
await page.keyboard.press('Enter')
await page.waitForTimeout(500)
// Get the wikilink that was just inserted
const wikilink = page.locator('.wikilink').last()
const wikilink = await insertWikilink(page)
await expect(wikilink).toBeVisible()
const targetTitle = await wikilink.textContent()
// Cmd+click the wikilink to navigate
await wikilink.click({ modifiers: ['Meta'] })
await page.waitForTimeout(1000)
const expected = targetTitle?.substring(0, 4) ?? ''
await expect.poll(async () => {
const heading = page.locator('.bn-editor h1').first()
if (await heading.count()) return (await heading.textContent()) ?? ''
return ''
}, { timeout: 5000 }).toContain(expected)
await expect(page.locator('.bn-editor h1').first()).toHaveText(INSERTED_WIKILINK_TITLE, { timeout: 5000 })
})
})