Compare commits
9 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b5b52140 | ||
|
|
7deeb49751 | ||
|
|
8c0a2e7ec8 | ||
|
|
c4e66dc74f | ||
|
|
c0bb722db5 | ||
|
|
bc11c869aa | ||
|
|
502ecf0572 | ||
|
|
8bbbba98b7 | ||
|
|
86adf860b5 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
82
src/App.tsx
82
src/App.tsx
@@ -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,
|
||||
@@ -1006,8 +1056,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 +1169,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} />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ function renderInlineEditorField({
|
||||
editorClassName,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
@@ -87,6 +88,7 @@ function renderInlineEditorField({
|
||||
editorClassName?: string
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: ReturnType<typeof buildInlineWikilinkSegments>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
@@ -101,6 +103,7 @@ function renderInlineEditorField({
|
||||
editorClassName={editorClassName}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
segments={segments}
|
||||
typeEntryMap={typeEntryMap}
|
||||
@@ -209,6 +212,17 @@ 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
|
||||
|
||||
event.preventDefault()
|
||||
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
@@ -232,6 +246,7 @@ export function InlineWikilinkInput({
|
||||
editorClassName,
|
||||
onInput: commitValueFromEditor,
|
||||
onKeyDown: handleKeyDown,
|
||||
onPaste: handlePaste,
|
||||
onSelectionChange: syncSelectionRange,
|
||||
segments,
|
||||
typeEntryMap,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
36
src/components/OnboardingShell.tsx
Normal file
36
src/components/OnboardingShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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',
|
||||
@@ -16,6 +22,10 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe('WelcomeScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('welcome mode', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -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)',
|
||||
@@ -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,
|
||||
@@ -331,7 +327,7 @@ export function WelcomeScreen({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</OnboardingShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
|
||||
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
|
||||
|
||||
export interface OpenListPropertiesEventDetail {
|
||||
scope: NoteListPropertiesScope
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -88,67 +88,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 +138,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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ interface AppCommandsConfig {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onCustomizeNoteListColumns?: () => void
|
||||
canCustomizeNoteListColumns?: boolean
|
||||
noteListColumnsLabel?: string
|
||||
onRestoreDeletedNote?: () => void
|
||||
canRestoreDeletedNote?: boolean
|
||||
}
|
||||
@@ -224,6 +225,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,
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface CommandRegistryConfig {
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onCustomizeNoteListColumns?: () => void
|
||||
canCustomizeNoteListColumns?: boolean
|
||||
noteListColumnsLabel?: string
|
||||
onRestoreDeletedNote?: () => void
|
||||
canRestoreDeletedNote?: boolean
|
||||
onQuickOpen: () => void
|
||||
@@ -119,9 +120,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])
|
||||
|
||||
|
||||
59
src/hooks/useDragRegion.test.tsx
Normal file
59
src/hooks/useDragRegion.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -173,5 +177,6 @@ export function useOnboarding(
|
||||
handleCreateNewVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
userReadyVaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -201,6 +201,7 @@ export interface ViewDefinition {
|
||||
icon: string | null
|
||||
color: string | null
|
||||
sort: string | null
|
||||
listPropertiesDisplay?: string[]
|
||||
filters: FilterGroup
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
58
tests/smoke/number-property.spec.ts
Normal file
58
tests/smoke/number-property.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal file
167
tests/smoke/vault-switcher-bottom-bar.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user