Compare commits
12 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b135d208e | ||
|
|
076860632c | ||
|
|
520ec504fe | ||
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e |
@@ -7,8 +7,12 @@ Priority order: **To Rework** first, then **Open** (sorted by Todoist priority p
|
||||
## Steps
|
||||
|
||||
1. Fetch tasks from To Rework (`6g6QqvR9rRpvJWvv`), then Open (`6g3XjWR832hVHhCM`)
|
||||
2. Sort each section by priority (p1 highest)
|
||||
3. Take the first available task
|
||||
2. **Sort by priority — this is mandatory.** Todoist returns tasks in arbitrary order. You must sort them yourself:
|
||||
- Todoist priority field: `4` = p1 (urgent), `3` = p2, `2` = p3, `1` = p4
|
||||
- Sort descending by `priority` field (4 first, 1 last)
|
||||
- To Rework tasks always come before Open tasks regardless of priority
|
||||
- **Never pick a p3/p4 task if a p1/p2 task exists in the same section**
|
||||
3. Take the first task from the sorted list
|
||||
4. Move it to In Progress (`6g3XjWjfmJFcGgHM`) via Todoist API:
|
||||
|
||||
```bash
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -107,6 +107,27 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task
|
||||
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Text input | `Input` from shadcn/ui |
|
||||
| Dropdown/select | `Select` from shadcn/ui |
|
||||
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
|
||||
| Button | `Button` from shadcn/ui |
|
||||
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
|
||||
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
|
||||
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
|
||||
| Color picker | Reuse the color swatch picker used for type customization |
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
|
||||
@@ -551,15 +551,11 @@ Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
---
|
||||
|
||||
## Update Channels & Feature Flags
|
||||
|
||||
### Settings
|
||||
- **`update_channel`** — `"stable"` (default/null) or `"canary"`. Stored in `Settings` struct, configurable in Settings panel under "Updates" section.
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(channel?)`** — Checks for updates. For stable: uses Tauri updater plugin. For canary: fetches `latest-canary.json` and opens release page for download.
|
||||
- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages.
|
||||
- **`.github/workflows/release-canary.yml`** — Canary builds from `canary` branch. Produces `latest-canary.json` on GitHub Pages. Releases are marked as prerelease.
|
||||
|
||||
@@ -802,29 +802,13 @@ sequenceDiagram
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
### Update Channels (Stable / Canary)
|
||||
### Updates
|
||||
|
||||
Laputa supports two release channels:
|
||||
Laputa uses the Tauri updater plugin for automatic updates:
|
||||
|
||||
- **Stable** (default): builds from `main` branch, published as full GitHub Releases
|
||||
- **Canary**: builds from `canary` branch, published as pre-release GitHub Releases
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
main["main branch"] -->|push| stable["Stable build<br/>latest.json"]
|
||||
canary["canary branch"] -->|push| canaryBuild["Canary build<br/>latest-canary.json"]
|
||||
stable --> ghPages["GitHub Pages"]
|
||||
canaryBuild --> ghPages
|
||||
ghPages -->|"update_channel = stable"| stableUsers["Stable users<br/>(auto-update via plugin)"]
|
||||
ghPages -->|"update_channel = canary"| canaryUsers["Canary users<br/>(fetch + manual download)"]
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Both channels publish to GitHub Pages: `latest.json` (stable) and `latest-canary.json` (canary)
|
||||
- `update_channel` is stored in `Settings` (`settings.json`), configurable in Settings panel
|
||||
- **Stable**: uses the Tauri updater plugin with automatic download and install
|
||||
- **Canary**: `useUpdater` hook fetches `latest-canary.json` via HTTP, compares versions, and opens the GitHub release page for manual download
|
||||
- Canary versions use semver prerelease: `0.YYYYMMDD.N-canary`
|
||||
- Builds from `main` branch are published as GitHub Releases
|
||||
- `latest.json` is published to GitHub Pages for the updater plugin
|
||||
- `useUpdater()` hook checks for updates automatically and supports download + install
|
||||
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ pub struct Settings {
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub update_channel: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
@@ -64,10 +63,6 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.anonymous_id
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
update_channel: settings
|
||||
.update_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
@@ -141,7 +136,6 @@ mod tests {
|
||||
assert!(s.crash_reporting_enabled.is_none());
|
||||
assert!(s.analytics_enabled.is_none());
|
||||
assert!(s.anonymous_id.is_none());
|
||||
assert!(s.update_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -714,6 +714,30 @@ filters:
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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())),
|
||||
})]),
|
||||
};
|
||||
|
||||
save_view(dir.path(), "monday.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Monday");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_stem_matching() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null },
|
||||
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { VaultEntry } from './types'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
@@ -76,7 +77,7 @@ function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
const inboxPeriod: InboxPeriod = 'all'
|
||||
const handleSetSelection = useCallback((sel: SidebarSelection) => {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
@@ -345,6 +346,13 @@ function App() {
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent(editing ? 'view_updated' : 'view_created')
|
||||
await vault.reloadViews()
|
||||
// Update vault entries so the .yml file appears in FOLDERS immediately
|
||||
const filePath = resolvedPath + '/views/' + filename
|
||||
try {
|
||||
const entry = await target<VaultEntry>('reload_vault_entry', { path: filePath })
|
||||
if (editing) { vault.updateEntry(filePath, entry) } else { vault.addEntry(entry) }
|
||||
} catch { /* non-critical — will appear after next vault reload */ }
|
||||
vault.reloadFolders()
|
||||
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
||||
@@ -358,6 +366,9 @@ function App() {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
// Remove the .yml file from vault entries so it disappears from FOLDERS immediately
|
||||
vault.removeEntry(resolvedPath + '/views/' + filename)
|
||||
vault.reloadFolders()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
@@ -401,7 +412,7 @@ function App() {
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater(settings.update_channel)
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
if (updateStatus.state === 'downloading') {
|
||||
@@ -564,7 +575,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
|
||||
<NoteList entries={vault.entries} selection={selection} 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} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -632,7 +643,7 @@ function App() {
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} entries={vault.entries} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
@@ -42,4 +42,51 @@ describe('CreateViewDialog', () => {
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes selected emoji icon when creating a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'Test View' } })
|
||||
// Open emoji picker and select an emoji
|
||||
fireEvent.click(screen.getByTitle('Pick icon'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
// Submit the form
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
const definition = onCreate.mock.calls[0][0] as ViewDefinition
|
||||
expect(definition.icon).not.toBeNull()
|
||||
expect(typeof definition.icon).toBe('string')
|
||||
expect(definition.icon!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes null icon when no emoji is selected', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'No Icon View' } })
|
||||
fireEvent.submit(screen.getByText('Create').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
import type { FilterGroup, ViewDefinition, VaultEntry } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
@@ -13,11 +13,13 @@ interface CreateViewDialogProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
/** Vault entries for wikilink autocomplete in filter value fields. */
|
||||
entries?: VaultEntry[]
|
||||
/** When provided, the dialog operates in edit mode with pre-populated fields. */
|
||||
editingView?: ViewDefinition | null
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, editingView }: CreateViewDialogProps) {
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, entries, editingView }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
@@ -106,6 +108,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
entries={entries}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{searchResults.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
@@ -98,6 +99,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{emojis.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
|
||||
201
src/components/FilterBuilder.test.tsx
Normal file
201
src/components/FilterBuilder.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup, VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
|
||||
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
|
||||
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
|
||||
makeEntry({ path: '/vault/trashed.md', filename: 'trashed.md', title: 'Trashed Note', isA: null, trashed: true }),
|
||||
]
|
||||
|
||||
describe('FilterBuilder wikilink autocomplete', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderWithEntries(group?: FilterGroup) {
|
||||
const defaultGroup: FilterGroup = {
|
||||
all: [{ field: 'title', op: 'contains', value: '' }],
|
||||
}
|
||||
return render(
|
||||
<FilterBuilder
|
||||
group={group ?? defaultGroup}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entries}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('renders value input with wikilink support when entries are provided', () => {
|
||||
renderWithEntries()
|
||||
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for plain text input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: 'hello' }],
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dropdown when value starts with [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for short queries after [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[A' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts [[note-title]] when a note is selected', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.click(screen.getByText('Alpha Project'))
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates dropdown with arrow keys and selects with Enter', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
const selected = document.querySelector('.wikilink-menu__item--selected')
|
||||
expect(selected).toBeTruthy()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dropdown on Escape', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes trashed notes from autocomplete', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Trashed' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByText('Trashed Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alice' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows type badge for typed entries', () => {
|
||||
const personType = makeEntry({
|
||||
path: '/vault/person.md', filename: 'person.md', title: 'Person',
|
||||
isA: 'Type', color: 'yellow', icon: 'user',
|
||||
})
|
||||
const entriesWithType = [...entries, personType]
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '[[Luca' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entriesWithType}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Person')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dropdown on typing [[ in input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
// Simulate the user typing [[ — dropdown opens when value starts with [[
|
||||
fireEvent.change(input, { target: { value: '[[Al' } })
|
||||
// The internal open state is set by onChange, verified via focus re-trigger
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plain text without [[ still works as regular input', () => {
|
||||
renderWithEntries()
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.change(input, { target: { value: 'some text' } })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to plain input when no entries are provided', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByPlaceholderText('value')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import './WikilinkSuggestionMenu.css'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -80,10 +84,197 @@ function OperatorSelect({ value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
const MAX_WIKILINK_RESULTS = 10
|
||||
const MIN_WIKILINK_QUERY = 2
|
||||
|
||||
function entryMatchesQuery(e: VaultEntry, lowerQuery: string): boolean {
|
||||
return e.title.toLowerCase().includes(lowerQuery) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery))
|
||||
}
|
||||
|
||||
function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: e.title,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string) {
|
||||
if (query.length < MIN_WIKILINK_QUERY) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return entries
|
||||
.filter(e => !e.trashed && entryMatchesQuery(e, lowerQuery))
|
||||
.slice(0, MAX_WIKILINK_RESULTS)
|
||||
.map(e => toWikilinkMatch(e, typeEntryMap))
|
||||
}
|
||||
|
||||
type WikilinkMatch = ReturnType<typeof toWikilinkMatch>
|
||||
|
||||
function extractWikilinkQuery(value: string): string | null {
|
||||
return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null
|
||||
}
|
||||
|
||||
function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (refs.every(r => !r.current?.contains(target))) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [refs, onClose])
|
||||
}
|
||||
|
||||
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: {
|
||||
matches: WikilinkMatch[]
|
||||
selectedIndex: number
|
||||
onSelect: (title: string) => void
|
||||
onHover: (index: number) => void
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="wikilink-menu"
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
|
||||
data-testid="wikilink-dropdown"
|
||||
>
|
||||
{matches.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useWikilinkMatches(entries: VaultEntry[], value: string, open: boolean) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const wikilinkQuery = extractWikilinkQuery(value)
|
||||
return useMemo(
|
||||
() => (open && wikilinkQuery !== null) ? matchWikilinkEntries(entries, typeEntryMap, wikilinkQuery) : [],
|
||||
[entries, typeEntryMap, wikilinkQuery, open],
|
||||
)
|
||||
}
|
||||
|
||||
function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | null>, selectedIndex: number) {
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !menuRef.current) return
|
||||
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedIndex, menuRef])
|
||||
}
|
||||
|
||||
function useDropdownKeyboard(
|
||||
matches: WikilinkMatch[],
|
||||
open: boolean,
|
||||
onSelect: (title: string) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const resetIndex = useCallback(() => setSelectedIndex(-1), [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!open || matches.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % matches.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
onSelect(matches[selectedIndex].title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [open, matches, selectedIndex, onSelect, onClose])
|
||||
|
||||
return { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown }
|
||||
}
|
||||
|
||||
function WikilinkValueInput({ value, entries, onChange }: {
|
||||
value: string
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const matches = useWikilinkMatches(entries, value, open)
|
||||
|
||||
const handleSelect = useCallback((title: string) => {
|
||||
onChange(`[[${title}]]`)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const closeMenu = useCallback(() => setOpen(false), [])
|
||||
useOutsideClick([inputRef, menuRef], closeMenu)
|
||||
|
||||
const { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown } =
|
||||
useDropdownKeyboard(matches, open, handleSelect, closeMenu)
|
||||
|
||||
useScrollSelectedIntoView(menuRef, selectedIndex)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
setOpen(e.target.value.startsWith('[['))
|
||||
resetIndex()
|
||||
}, [onChange, resetIndex])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} className="flex-1 min-w-0">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 w-full text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="filter-value-input"
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<WikilinkDropdown
|
||||
matches={matches}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
isDateOp: boolean
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
if (isDateOp) {
|
||||
@@ -118,6 +309,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
@@ -128,9 +323,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
@@ -153,6 +349,7 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
isDateOp={isDateOp}
|
||||
entries={entries}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
@@ -170,9 +367,10 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
)
|
||||
}
|
||||
|
||||
function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: {
|
||||
function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onChange, onRemove }: {
|
||||
group: FilterGroup
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
depth: number
|
||||
onChange: (g: FilterGroup) => void
|
||||
@@ -241,6 +439,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
depth={depth + 1}
|
||||
onChange={(g) => updateChild(i, g)}
|
||||
@@ -251,6 +450,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
onRemove={() => removeChild(i)}
|
||||
@@ -276,16 +476,19 @@ export interface FilterBuilderProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
/** Vault entries for wikilink autocomplete in value fields. */
|
||||
entries?: VaultEntry[]
|
||||
}
|
||||
|
||||
const defaultSuggestions = () => [] as string[]
|
||||
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) {
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions, entries }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
return (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
entries={entries ?? []}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -9,7 +9,6 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { InboxFilterPills } from './note-list/InboxFilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
|
||||
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
@@ -44,7 +43,7 @@ interface NoteListProps {
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
@@ -59,11 +58,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
|
||||
[entries, isInboxView],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -117,7 +111,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} position="bottom" />}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
|
||||
@@ -28,7 +28,7 @@ const emptySettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
const populatedSettings: Settings = {
|
||||
@@ -41,7 +41,7 @@ const populatedSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
describe('SettingsPanel', () => {
|
||||
@@ -405,56 +405,6 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update channel section', () => {
|
||||
it('renders the update channel dropdown', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('settings-update-channel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to stable when update_channel is null', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement
|
||||
expect(select.value).toBe('stable')
|
||||
})
|
||||
|
||||
it('reflects canary setting', () => {
|
||||
const canarySettings: Settings = { ...emptySettings, update_channel: 'canary' }
|
||||
render(
|
||||
<SettingsPanel open={true} settings={canarySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement
|
||||
expect(select.value).toBe('canary')
|
||||
})
|
||||
|
||||
it('saves update_channel when changed to canary', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('settings-update-channel'), { target: { value: 'canary' } })
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update_channel: 'canary',
|
||||
}))
|
||||
})
|
||||
|
||||
it('saves null when channel is stable (default)', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update_channel: null,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Privacy & Telemetry section', () => {
|
||||
it('renders crash reporting and analytics toggles', () => {
|
||||
render(
|
||||
|
||||
@@ -125,7 +125,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [updateChannel, setUpdateChannel] = useState(settings.update_channel ?? 'stable')
|
||||
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
|
||||
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
|
||||
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
|
||||
@@ -150,9 +149,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
crash_reporting_enabled: crashReporting,
|
||||
analytics_enabled: analytics,
|
||||
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
|
||||
update_channel: updateChannel === 'stable' ? null : updateChannel,
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
@@ -206,7 +204,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
updateChannel={updateChannel} setUpdateChannel={setUpdateChannel}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
@@ -242,7 +239,6 @@ interface SettingsBodyProps {
|
||||
onGitHubConnected: (token: string, username: string) => void
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
updateChannel: string; setUpdateChannel: (v: string) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
crashReporting: boolean; setCrashReporting: (v: boolean) => void
|
||||
analytics: boolean; setAnalytics: (v: boolean) => void
|
||||
@@ -307,26 +303,12 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Updates</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Release Channel</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Canary builds include the latest features but may be less stable. Restart required after changing.
|
||||
Controls which features are visible. Alpha users see all features. Beta/Stable see features as they are promoted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Update channel</label>
|
||||
<select
|
||||
value={props.updateChannel}
|
||||
onChange={(e) => props.setUpdateChannel(e.target.value)}
|
||||
className="border border-border bg-transparent text-foreground rounded"
|
||||
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
|
||||
data-testid="settings-update-channel"
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<option value="canary">Canary (pre-release)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
|
||||
<select
|
||||
@@ -340,9 +322,6 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha (bleeding edge)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.4 }}>
|
||||
Alpha users see all features. Beta/Stable see features as they are promoted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
@@ -960,11 +960,11 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('SECTIONS header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
it('TYPES header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const sectionsHeader = screen.getByText('SECTIONS')
|
||||
const sectionsHeader = screen.getByText('TYPES')
|
||||
const projectsSection = screen.getByText('Projects')
|
||||
// Walk up from SECTIONS header to find the border-b container
|
||||
// Walk up from TYPES header to find the border-b container
|
||||
const borderContainer = sectionsHeader.closest('.border-b')
|
||||
expect(borderContainer).not.toBeNull()
|
||||
// The section entry should be inside the same border-b container
|
||||
|
||||
@@ -224,10 +224,10 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -431,10 +431,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('views')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -489,15 +489,15 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px' }}>
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>SECTIONS</span>
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
|
||||
@@ -11,7 +11,6 @@ interface InboxFilterPillsProps {
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'quarter', label: 'Quarter' },
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const defaultSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -28,7 +27,6 @@ const savedSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ describe('useSettings', () => {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const baseSettings: Settings = {
|
||||
openai_key: null, google_key: null,
|
||||
github_token: null, github_username: null, auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null, crash_reporting_enabled: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null, release_channel: null,
|
||||
analytics_enabled: null, anonymous_id: null, release_channel: null,
|
||||
}
|
||||
|
||||
describe('useTelemetry', () => {
|
||||
|
||||
@@ -25,11 +25,6 @@ vi.mock('@tauri-apps/plugin-process', () => ({
|
||||
relaunch: (...args: unknown[]) => mockRelaunch(...args),
|
||||
}))
|
||||
|
||||
const mockGetVersion = vi.fn().mockResolvedValue('0.20260101.1')
|
||||
vi.mock('@tauri-apps/api/app', () => ({
|
||||
getVersion: () => mockGetVersion(),
|
||||
}))
|
||||
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
describe('useUpdater', () => {
|
||||
@@ -205,42 +200,6 @@ describe('useUpdater', () => {
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('canary channel', () => {
|
||||
it('fetches latest-canary.json when channel is canary', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockGetVersion.mockResolvedValue('0.20260101.1')
|
||||
|
||||
const mockFetch = vi.mocked(globalThis.fetch)
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
version: '0.20260325.99-canary',
|
||||
notes: 'Canary build',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://github.com/refactoringhq/laputa-app/releases/download/v0.20260325.99-canary/laputa.app.tar.gz',
|
||||
signature: 'sig123',
|
||||
},
|
||||
},
|
||||
}), { status: 200 }))
|
||||
|
||||
const { result } = renderHook(() => useUpdater('canary'))
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('available')
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '0.20260325.99-canary',
|
||||
notes: 'Canary build',
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/latest-canary.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdates (manual)', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isTauri } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
const CANARY_ENDPOINT = 'https://refactoringhq.github.io/laputa-app/latest-canary.json'
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
@@ -21,45 +20,14 @@ export interface UpdateActions {
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
interface CanaryRelease {
|
||||
version: string
|
||||
notes: string
|
||||
platforms: Record<string, { url: string; signature: string }>
|
||||
}
|
||||
|
||||
async function checkCanaryUpdate(): Promise<{ version: string; notes: string; downloadUrl: string } | null> {
|
||||
const response = await fetch(CANARY_ENDPOINT)
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = await response.json() as CanaryRelease
|
||||
const { getVersion } = await import('@tauri-apps/api/app')
|
||||
const currentVersion = await getVersion()
|
||||
|
||||
if (data.version === currentVersion) return null
|
||||
|
||||
const platform = data.platforms['darwin-aarch64']
|
||||
const downloadUrl = platform?.url?.replace(/\.tar\.gz$/, '').replace(/\.app$/, '') ?? ''
|
||||
|
||||
return { version: data.version, notes: data.notes, downloadUrl }
|
||||
}
|
||||
|
||||
export function useUpdater(channel: string | null = null): { status: UpdateStatus; actions: UpdateActions } {
|
||||
export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
|
||||
const updateRef = useRef<unknown>(null)
|
||||
const canaryUrlRef = useRef<string | null>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
|
||||
try {
|
||||
if (channel === 'canary') {
|
||||
const canary = await checkCanaryUpdate()
|
||||
if (!canary) return 'up-to-date'
|
||||
canaryUrlRef.current = canary.downloadUrl
|
||||
setStatus({ state: 'available', version: canary.version, notes: canary.notes })
|
||||
return 'available'
|
||||
}
|
||||
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return 'up-to-date'
|
||||
@@ -75,7 +43,7 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
}
|
||||
}, [channel])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
@@ -84,12 +52,6 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu
|
||||
}, [checkForUpdates])
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
// Canary: open the GitHub release page for manual download
|
||||
if (canaryUrlRef.current) {
|
||||
openExternalUrl(canaryUrlRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const update = updateRef.current as {
|
||||
version: string
|
||||
downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise<void>
|
||||
|
||||
@@ -353,6 +353,28 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default vault path', () => {
|
||||
it('does not contain CI runner paths', () => {
|
||||
// Regression: production builds must never bake in the CI runner's absolute path
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/Users/runner/')
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/home/runner/')
|
||||
})
|
||||
|
||||
it('keeps persisted active vault when one exists', async () => {
|
||||
const persistedPath = '/Users/luca/MyVault'
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: persistedPath }],
|
||||
active_vault: persistedPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(persistedPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
it('is false by default', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
@@ -10,10 +10,14 @@ export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
export const GETTING_STARTED_LABEL = 'Getting Started'
|
||||
|
||||
declare const __DEMO_VAULT_PATH__: string
|
||||
declare const __DEMO_VAULT_PATH__: string | undefined
|
||||
|
||||
/** Build-time demo vault path (dev only). In production Tauri builds this is
|
||||
* undefined and the real path is resolved at runtime via get_default_vault_path. */
|
||||
const STATIC_DEFAULT_PATH = typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : ''
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: GETTING_STARTED_LABEL, path: typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: GETTING_STARTED_LABEL, path: STATIC_DEFAULT_PATH },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
@@ -32,14 +36,20 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
|
||||
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
|
||||
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
|
||||
const defaultVaults: VaultOption[] = useMemo(
|
||||
() => [{ label: GETTING_STARTED_LABEL, path: defaultPath }],
|
||||
[defaultPath],
|
||||
)
|
||||
|
||||
const visibleDefaults = useMemo(
|
||||
() => DEFAULT_VAULTS.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[hiddenDefaults],
|
||||
() => defaultVaults.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => [...visibleDefaults, ...extraVaults],
|
||||
@@ -47,8 +57,8 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
)
|
||||
|
||||
const isGettingStartedHidden = useMemo(
|
||||
() => hiddenDefaults.includes(DEFAULT_VAULTS[0].path),
|
||||
[hiddenDefaults],
|
||||
() => hiddenDefaults.includes(defaultPath),
|
||||
[hiddenDefaults, defaultPath],
|
||||
)
|
||||
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
@@ -60,13 +70,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
.then(async ({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
} else if (!STATIC_DEFAULT_PATH) {
|
||||
// Production build: resolve the Getting Started path at runtime
|
||||
try {
|
||||
const runtimePath = await tauriCall<string>('get_default_vault_path', {})
|
||||
if (!cancelled && runtimePath) {
|
||||
setDefaultPath(runtimePath)
|
||||
setVaultPath(runtimePath)
|
||||
// Keep the module-level export in sync for external consumers
|
||||
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path: runtimePath }
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, command may not exist
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
@@ -116,7 +139,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const removeVault = useCallback((path: string) => {
|
||||
const isDefault = DEFAULT_VAULTS.some(v => v.path === path)
|
||||
const isDefault = defaultVaults.some(v => v.path === path)
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
|
||||
} else {
|
||||
@@ -127,7 +150,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== path) return currentPath
|
||||
const remaining = [
|
||||
...DEFAULT_VAULTS.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...defaultVaults.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...extraVaults.filter(v => v.path !== path),
|
||||
]
|
||||
if (remaining.length > 0) {
|
||||
@@ -137,26 +160,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return currentPath
|
||||
})
|
||||
|
||||
const vault = [...DEFAULT_VAULTS, ...extraVaults].find(v => v.path === path)
|
||||
const vault = [...defaultVaults, ...extraVaults].find(v => v.path === path)
|
||||
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
|
||||
}, [extraVaults, hiddenDefaults])
|
||||
}, [defaultVaults, extraVaults, hiddenDefaults])
|
||||
|
||||
const restoreGettingStarted = useCallback(async () => {
|
||||
const defaultPath = DEFAULT_VAULTS[0].path
|
||||
const gsPath = defaultPath
|
||||
// Un-hide the Getting Started vault
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== defaultPath))
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== gsPath))
|
||||
// Try to create the vault if it doesn't exist on disk
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: defaultPath })
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: gsPath })
|
||||
if (!exists) {
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: defaultPath })
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: gsPath })
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, creation may fail — that's fine
|
||||
}
|
||||
switchVault(defaultPath)
|
||||
switchVault(gsPath)
|
||||
onToastRef.current('Getting Started vault restored')
|
||||
}, [switchVault])
|
||||
}, [defaultPath, switchVault])
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
|
||||
@@ -84,7 +84,6 @@ let mockSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -210,7 +209,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
crash_reporting_enabled: s.crash_reporting_enabled,
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
anonymous_id: s.anonymous_id,
|
||||
update_channel: s.update_channel,
|
||||
release_channel: s.release_channel,
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface Settings {
|
||||
crash_reporting_enabled: boolean | null
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
update_channel: string | null
|
||||
release_channel: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -190,4 +190,58 @@ describe('evaluateView', () => {
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Exact'])
|
||||
})
|
||||
|
||||
it('before operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
makeEntry({ title: 'NoDate', properties: {} }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Early'])
|
||||
})
|
||||
|
||||
it('after operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Late'])
|
||||
})
|
||||
|
||||
it('before/after works with ISO datetime strings', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before datetime', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-03-15T12:00:00' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Morning', properties: { Date: '2024-03-15T08:00:00' } }),
|
||||
makeEntry({ title: 'Evening', properties: { Date: '2024-03-15T18:00:00' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Morning'])
|
||||
})
|
||||
|
||||
it('before/after works with numeric Unix timestamps', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After ts', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-01-01' }] },
|
||||
}
|
||||
// Unix timestamp for 2024-06-15 in seconds
|
||||
const ts = Math.floor(new Date('2024-06-15').getTime() / 1000)
|
||||
const entries = [
|
||||
makeEntry({ title: 'Match', properties: { Date: ts } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Match'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,11 +104,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||
|
||||
// Date comparisons
|
||||
if (op === 'before' || op === 'after') {
|
||||
const ts = typeof resolved.scalar === 'number' ? resolved.scalar : null
|
||||
if (!ts) return false
|
||||
const target = Date.parse(condVal) / 1000
|
||||
let tsMs: number | null = null
|
||||
if (typeof resolved.scalar === 'number') {
|
||||
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
|
||||
} else if (typeof resolved.scalar === 'string') {
|
||||
const parsed = Date.parse(resolved.scalar)
|
||||
tsMs = isNaN(parsed) ? null : parsed
|
||||
}
|
||||
if (tsMs == null) return false
|
||||
const target = Date.parse(condVal)
|
||||
if (isNaN(target)) return false
|
||||
return op === 'before' ? ts < target : ts > target
|
||||
return op === 'before' ? tsMs < target : tsMs > target
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -9,32 +9,34 @@ async function openSettings(page: import('@playwright/test').Page) {
|
||||
return panel
|
||||
}
|
||||
|
||||
test.describe('Canary release channel + feature flags', () => {
|
||||
test.describe('Release channel settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Settings panel shows Update channel dropdown defaulting to Stable', async ({ page }) => {
|
||||
test('Settings panel shows Release channel dropdown defaulting to Stable', async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
// Check the Updates section exists
|
||||
await expect(page.getByText('Updates')).toBeVisible()
|
||||
await expect(page.getByText('Canary builds include')).toBeVisible()
|
||||
// Check the Release Channel section exists
|
||||
await expect(page.getByText('Release Channel')).toBeVisible()
|
||||
|
||||
// Check the dropdown defaults to stable
|
||||
const select = page.locator('[data-testid="settings-update-channel"]')
|
||||
const select = page.locator('[data-testid="settings-release-channel"]')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toHaveValue('stable')
|
||||
|
||||
// Update channel should NOT be present
|
||||
await expect(page.locator('[data-testid="settings-update-channel"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Update channel can be changed to canary and saved', async ({ page }) => {
|
||||
test('Release channel can be changed to alpha and saved', async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
// Change to canary
|
||||
const select = page.locator('[data-testid="settings-update-channel"]')
|
||||
await select.selectOption('canary')
|
||||
await expect(select).toHaveValue('canary')
|
||||
// Change to alpha
|
||||
const select = page.locator('[data-testid="settings-release-channel"]')
|
||||
await select.selectOption('alpha')
|
||||
await expect(select).toHaveValue('alpha')
|
||||
|
||||
// Save (closes the panel)
|
||||
await page.click('[data-testid="settings-save"]')
|
||||
@@ -42,7 +44,7 @@ test.describe('Canary release channel + feature flags', () => {
|
||||
|
||||
// Reopen settings and verify the value persisted
|
||||
await openSettings(page)
|
||||
const reopenedSelect = page.locator('[data-testid="settings-update-channel"]')
|
||||
await expect(reopenedSelect).toHaveValue('canary')
|
||||
const reopenedSelect = page.locator('[data-testid="settings-release-channel"]')
|
||||
await expect(reopenedSelect).toHaveValue('alpha')
|
||||
})
|
||||
})
|
||||
|
||||
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function openCreateViewDialog(page: Page) {
|
||||
// The VIEWS header has a small + icon. Find the header button containing "VIEWS" text
|
||||
// and the + SVG icon next to it
|
||||
const viewsHeader = page.locator('button:has(span:text("VIEWS"))')
|
||||
await viewsHeader.waitFor({ timeout: 5000 })
|
||||
// The Plus icon is rendered as an SVG inside the same container
|
||||
// Click the SVG child of the VIEWS button container (the + icon)
|
||||
const plusSvg = viewsHeader.locator('svg').last()
|
||||
await plusSvg.click({ force: true })
|
||||
await expect(page.locator('text=Create View')).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
test.describe('Filter wikilink autocomplete', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('typing [[ in filter value field shows wikilink autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Default field is 'type' which has valueSuggestions (shows Select, not text input).
|
||||
// Change to 'title' field which has no suggestions → renders WikilinkValueInput.
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
// The filter value input has data-testid="filter-value-input"
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await expect(valueInput).toBeVisible()
|
||||
|
||||
// Type [[ without enough chars - dropdown should not appear
|
||||
await valueInput.fill('[[')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
|
||||
// Type enough characters to trigger the dropdown
|
||||
await valueInput.fill('[[un')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Verify dropdown contains note suggestions
|
||||
const dropdownItems = page.locator('.wikilink-menu__item')
|
||||
const count = await dropdownItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
// Click a suggestion to select it
|
||||
const firstItem = dropdownItems.first()
|
||||
const itemText = await firstItem.locator('.wikilink-menu__title').textContent()
|
||||
await firstItem.click()
|
||||
|
||||
// Verify the value was set to [[note-title]]
|
||||
const inputValue = await valueInput.inputValue()
|
||||
expect(inputValue).toMatch(/^\[\[.+\]\]$/)
|
||||
expect(inputValue).toContain(itemText?.trim() ?? '')
|
||||
|
||||
// Verify dropdown closed after selection
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('plain text in filter value does not trigger autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Change field to 'title' (no suggestions → WikilinkValueInput)
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await valueInput.fill('some plain text')
|
||||
|
||||
// No dropdown should appear
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -414,9 +414,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// Inject the demo-vault-v2 path so browser code resolves it relative to the project root
|
||||
// Inject the demo-vault-v2 path in dev mode only — production Tauri builds must
|
||||
// resolve the default vault path at runtime via the backend to avoid baking
|
||||
// the CI runner's absolute path into the distributed bundle.
|
||||
define: {
|
||||
__DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')),
|
||||
...(process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG
|
||||
? {}
|
||||
: { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }),
|
||||
},
|
||||
|
||||
// Prevent vite from obscuring Rust errors
|
||||
|
||||
Reference in New Issue
Block a user