Compare commits

...

18 Commits

Author SHA1 Message Date
Test
2b135d208e fix: remove Update channel from Settings, keep only Release channel
Update channel (Stable/Canary) was redundant since builds are flat.
Removed the UI dropdown, canary update logic in useUpdater, and
update_channel field from Settings type (TS + Rust). Release channel
(Alpha/Beta/Stable) remains as the sole selector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:11:26 +02:00
Test
076860632c fix: rename SECTIONS to TYPES in sidebar header 2026-04-04 12:02:32 +02:00
Test
520ec504fe fix: enforce priority sort in laputa-next-task (p1 before p3/p4) 2026-04-04 12:00:02 +02:00
Test
cdb3eeea4c fix: update vault entries immediately after view create/delete
After saving a view, the .yml file wasn't appearing in the FOLDERS note
list until a full vault reload. Now we call reload_vault_entry to get a
fresh VaultEntry, add/update it in the entries state, and reload the
folder tree — making the file visible immediately in FOLDERS.

Same for delete: removeEntry + reloadFolders ensures the file disappears
from FOLDERS without requiring a vault reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:19:44 +02:00
Test
f68436c2e0 fix: remove Inbox period filter pills, show all notes by default 2026-04-04 11:03:50 +02:00
Test
7c03c50e25 fix: prevent CI runner vault path from leaking into distributed builds
The demo vault path was resolved at build time via Vite's `define` config,
baking the CI runner's absolute path (/Users/runner/work/...) into the
JavaScript bundle. Fresh installs showed "Vault not found" with that path.

Now __DEMO_VAULT_PATH__ is only injected in dev mode. Production Tauri builds
resolve the Getting Started vault path at runtime via get_default_vault_path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:53:29 +02:00
Test
24911b6bb7 fix: remove Quarter filter pill from Inbox, keep Week/Month/All 2026-04-04 10:48:46 +02:00
Test
51914db534 test: add Playwright smoke test for filter wikilink autocomplete
Verifies typing [[ in a view filter value field triggers the wikilink
autocomplete dropdown and that selecting a note inserts [[note-title]].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:12:21 +02:00
Test
e4ffeeccc0 feat: add wikilink autocomplete on [[ in view filter value field
Typing [[ in a filter value input now shows a note autocomplete dropdown,
matching the same visual style used in the editor and Properties panel.
Selecting a note inserts [[note-title]] as the filter value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:04:51 +02:00
Test
8ec33b99b5 docs: add mandatory UI components rules to CLAUDE.md — always use shadcn/ui, never raw HTML 2026-04-04 09:56:30 +02:00
Test
638678184e fix: add type="button" to EmojiPicker buttons to prevent form submission
Emoji buttons inside the EmojiPicker defaulted to type="submit", causing
premature form submission when selecting an emoji inside the Create/Edit
View dialog. The form submitted with the stale (empty) icon state before
the emoji selection could update it, resulting in views always saving
with icon: null regardless of the user's emoji choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:59:18 +02:00
Test
62af7a3d6e fix: parse date strings in view filter before/after operators
Date properties are stored as YAML strings (e.g. "2024-03-15"), not
Unix timestamps. Parse string values via Date.parse so before/after
operators work correctly. Also handles numeric Unix timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:07:11 +02:00
Test
ef9f3ec21f feat: rebuild FilterBuilder with shadcn/ui components
Replace native HTML elements with design system components:
- Operator <select> → shadcn Select dropdown
- Field <input>+<datalist> → shadcn Select with all available fields
- Value input → shadcn Select (when suggestions available) or Input
- Date input (type="date") for before/after operators
- Remove button → shadcn Button variant="ghost"
- AND/OR toggle → shadcn Button variant="outline" with rounded-full

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:41:16 +02:00
Test
0d91e29e82 style: apply rustfmt to views migration code
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:10:26 +02:00
Test
77d59e3ee0 feat: move view storage from .laputa/views/ to views/ in vault root
Views are vault content, not app config — they should be visible in
FOLDERS, git-tracked, and portable. Adds one-time migration that moves
existing .yml files from .laputa/views/ to views/ on first scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:05:57 +02:00
Test
f37de38d21 feat: add edit button (pencil) on hover for sidebar view items
Opens the Create View dialog in edit mode with pre-populated fields
(name, icon, filters). Saving updates the existing .yml file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:50:49 +02:00
Test
51ce27f781 fix: show view emoji icon in sidebar instead of default funnel
Added optional `emoji` prop to NavItem. When a view has an icon (emoji)
set in its definition, it renders that emoji instead of the default
Funnel icon. Views with no icon still fall back to the funnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:41:12 +02:00
Test
4a48833dbc fix: use substring match for contains/not_contains on relationship fields
Plain text values now use substring matching on wikilink stems so
"Monday" matches [[Monday Ideas]], [[Monday Recap]], etc. Wikilink
syntax values ([[...]]) still use exact stem matching. any_of/none_of
remain exact match only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:18:40 +02:00
35 changed files with 1166 additions and 352 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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]

View File

@@ -142,9 +142,58 @@ pub struct ViewFile {
pub definition: ViewDefinition,
}
/// Scan all `.yml` files from `vault_path/.laputa/views/` and return parsed views.
/// Migrate views from `.laputa/views/` to `views/` in the vault root (one-time).
pub fn migrate_views(vault_path: &Path) {
let old_dir = vault_path.join(".laputa").join("views");
if !old_dir.is_dir() {
return;
}
let entries = match fs::read_dir(&old_dir) {
Ok(e) => e,
Err(_) => return,
};
let yml_files: Vec<_> = entries
.flatten()
.filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("yml"))
.collect();
if yml_files.is_empty() {
return;
}
let new_dir = vault_path.join("views");
if fs::create_dir_all(&new_dir).is_err() {
log::warn!("Failed to create views/ directory for migration");
return;
}
for entry in yml_files {
let src = entry.path();
let dst = new_dir.join(entry.file_name());
if !dst.exists() {
if let Err(e) = fs::rename(&src, &dst) {
log::warn!("Failed to migrate view {:?}: {}", src, e);
} else {
log::info!("Migrated view {:?} → {:?}", src, dst);
}
}
}
// Clean up old directory if empty
if fs::read_dir(&old_dir)
.map(|mut d| d.next().is_none())
.unwrap_or(false)
{
let _ = fs::remove_dir(&old_dir);
}
}
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
let views_dir = vault_path.join(".laputa").join("views");
migrate_views(vault_path);
let views_dir = vault_path.join("views");
if !views_dir.is_dir() {
return Vec::new();
}
@@ -180,7 +229,7 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
views
}
/// Save a view definition as YAML to `vault_path/.laputa/views/{filename}`.
/// Save a view definition as YAML to `vault_path/views/{filename}`.
pub fn save_view(
vault_path: &Path,
filename: &str,
@@ -189,7 +238,7 @@ pub fn save_view(
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let views_dir = vault_path.join(".laputa").join("views");
let views_dir = vault_path.join("views");
fs::create_dir_all(&views_dir)
.map_err(|e| format!("Failed to create views directory: {}", e))?;
let yaml = serde_yaml::to_string(definition)
@@ -198,9 +247,9 @@ pub fn save_view(
.map_err(|e| format!("Failed to write view file: {}", e))
}
/// Delete a view file at `vault_path/.laputa/views/{filename}`.
/// Delete a view file at `vault_path/views/{filename}`.
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
let path = vault_path.join(".laputa").join("views").join(filename);
let path = vault_path.join("views").join(filename);
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
}
@@ -599,7 +648,7 @@ filters:
#[test]
fn test_scan_views_reads_yml_files() {
let dir = tempfile::TempDir::new().unwrap();
let views_dir = dir.path().join(".laputa").join("views");
let views_dir = dir.path().join("views");
fs::create_dir_all(&views_dir).unwrap();
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
@@ -617,6 +666,26 @@ filters:
assert_eq!(views[1].definition.name, "Beta");
}
#[test]
fn test_migrate_views_from_old_location() {
let dir = tempfile::TempDir::new().unwrap();
let old_dir = dir.path().join(".laputa").join("views");
fs::create_dir_all(&old_dir).unwrap();
let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
fs::write(old_dir.join("test.yml"), yaml).unwrap();
// scan_views should trigger migration and find the view
let views = scan_views(dir.path());
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Migrated");
// File should now be in new location
assert!(dir.path().join("views").join("test.yml").exists());
// Old file should be gone
assert!(!old_dir.join("test.yml").exists());
}
#[test]
fn test_save_and_read_view() {
let dir = tempfile::TempDir::new().unwrap();
@@ -645,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#"

View File

@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
get_file_history: [],
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, 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,

View File

@@ -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')
@@ -336,20 +337,38 @@ function App() {
setToastMessage(`Type "${name}" created`)
}, [notes])
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
trackEvent('view_created')
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
setToastMessage(`View "${definition.name}" created`)
// 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])
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleEditView = useCallback((filename: string) => {
const view = vault.views.find((v) => v.filename === filename)
if (view) dialogs.openEditView(filename, view.definition)
}, [vault.views, dialogs])
const handleDeleteView = useCallback(async (filename: string) => {
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' })
}
@@ -393,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') {
@@ -545,7 +564,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -556,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} />
@@ -624,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={handleCreateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} />
<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}

View File

@@ -0,0 +1,92 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
describe('CreateViewDialog', () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onCreate: vi.fn(),
availableFields: ['type', 'status', 'title'],
}
it('shows "Create View" title in create mode', () => {
render(<CreateViewDialog {...defaultProps} />)
expect(screen.getByText('Create View')).toBeInTheDocument()
expect(screen.getByText('Create')).toBeInTheDocument()
})
it('shows "Edit View" title when editingView is provided', () => {
const editingView: ViewDefinition = {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
}
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
expect(screen.getByText('Edit View')).toBeInTheDocument()
expect(screen.getByText('Save')).toBeInTheDocument()
})
it('pre-populates name field in edit mode', () => {
const editingView: ViewDefinition = {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
}
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
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 })
)
})
})

View File

@@ -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,9 +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 }: 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)
@@ -23,16 +27,23 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
all: [{ field: 'type', op: 'equals', value: '' }],
})
const inputRef = useRef<HTMLInputElement>(null)
const isEditing = !!editingView
useEffect(() => {
if (open) {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
if (editingView) {
setName(editingView.name) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setIcon(editingView.icon ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setFilters(editingView.filters) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
} else {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
}
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, availableFields])
}, [open, availableFields, editingView])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -62,7 +73,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create View</DialogTitle>
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex gap-2">
@@ -97,11 +108,12 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
onChange={setFilters}
availableFields={availableFields}
valueSuggestions={valueSuggestions}
entries={entries}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim()}>Create</Button>
<Button type="submit" disabled={!name.trim()}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -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)}

View 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')
})
})

View File

@@ -1,7 +1,12 @@
import { useId } from 'react'
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
import { Plus, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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' },
@@ -15,6 +20,7 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
]
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
function isFilterGroup(node: FilterNode): node is FilterGroup {
return 'all' in node || 'any' in node
@@ -32,98 +38,339 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
return mode === 'all' ? { all: children } : { any: children }
}
/** Combobox-style field input with datalist autocomplete. */
function FieldInput({ value, fields, onChange }: {
function FieldSelect({ value, fields, onChange }: {
value: string
fields: string[]
onChange: (v: string) => void
}) {
const id = useId()
const isCustom = value !== '' && !fields.includes(value)
return (
<>
<input
list={id}
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="field"
/>
<datalist id={id}>
{fields.map((f) => <option key={f} value={f} />)}
</datalist>
</>
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
>
<SelectValue placeholder="field" />
</SelectTrigger>
<SelectContent position="popper">
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
{fields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</SelectContent>
</Select>
)
}
/** Combobox-style value input with autocomplete for known values. */
function ValueInput({ value, suggestions, onChange }: {
function OperatorSelect({ value, onChange }: {
value: FilterOp
onChange: (v: FilterOp) => void
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as FilterOp)}>
<SelectTrigger
size="sm"
className="h-8 shrink-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
style={{ minWidth: 120 }}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{OPERATORS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)
}
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
suggestions: string[]
entries: VaultEntry[]
onChange: (v: string) => void
}) {
const id = useId()
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 (
<>
<input
list={id}
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
<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={(e) => onChange(e.target.value)}
onChange={handleChange}
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
onKeyDown={handleKeyDown}
data-testid="filter-value-input"
/>
<datalist id={id}>
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
</>
{open && matches.length > 0 && (
<WikilinkDropdown
matches={matches}
selectedIndex={selectedIndex}
onSelect={handleSelect}
onHover={setSelectedIndex}
menuRef={menuRef}
/>
)}
</div>
)
}
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
value: string
suggestions: string[]
isDateOp: boolean
entries: VaultEntry[]
onChange: (v: string) => void
}) {
if (isDateOp) {
return (
<Input
type="date"
className="h-8 flex-1 min-w-0 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
}
if (suggestions.length > 0) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-8 flex-1 min-w-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
>
<SelectValue placeholder="value" />
</SelectTrigger>
<SelectContent position="popper">
{value !== '' && !suggestions.includes(value) && (
<SelectItem value={value}>{value}</SelectItem>
)}
{suggestions.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (entries.length > 0) {
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
}
return (
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
}
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
condition: FilterCondition
fields: string[]
entries: VaultEntry[]
valueSuggestions: (field: string) => string[]
onUpdate: (c: FilterCondition) => void
onRemove: () => void
}) {
const suggestions = valueSuggestions(condition.field)
const isDateOp = DATE_OPS.has(condition.op)
return (
<div className="flex items-center gap-1.5">
<FieldInput
<FieldSelect
value={condition.field}
fields={fields}
onChange={(v) => onUpdate({ ...condition, field: v })}
/>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
<OperatorSelect
value={condition.op}
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
>
{OPERATORS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
onChange={(op) => onUpdate({ ...condition, op })}
/>
{!NO_VALUE_OPS.has(condition.op) && (
<ValueInput
value={String(condition.value ?? '')}
suggestions={suggestions}
isDateOp={isDateOp}
entries={entries}
onChange={(v) => onUpdate({ ...condition, value: v })}
/>
)}
<button
<Button
type="button"
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onRemove}
title="Remove filter"
>
<X size={14} />
</button>
</Button>
</div>
)
}
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
@@ -159,26 +406,30 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
return (
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
<div className="flex items-center gap-2 mb-2">
<button
<Button
type="button"
className="rounded-full border border-input bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground cursor-pointer hover:bg-accent transition-colors"
variant="outline"
size="sm"
className="h-6 rounded-full px-2.5 text-[11px] font-medium"
onClick={toggleMode}
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
>
{mode === 'all' ? 'AND' : 'OR'}
</button>
</Button>
<span className="text-[11px] text-muted-foreground">
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
</span>
{onRemove && (
<button
<Button
type="button"
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onRemove}
title="Remove group"
>
<X size={12} />
</button>
</Button>
)}
</div>
<div className="space-y-2">
@@ -188,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)}
@@ -198,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)}
@@ -223,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}

View File

@@ -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} />

View File

@@ -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(

View File

@@ -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)' }} />

View File

@@ -960,15 +960,53 @@ 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
expect(borderContainer!.contains(projectsSection)).toBe(true)
})
})
describe('view edit button', () => {
const mockViews = [
{
filename: 'active-projects.yml',
definition: {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
},
},
]
it('renders edit button for each view item when onEditView is provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={() => {}} onDeleteView={() => {}} />
)
expect(screen.getByTitle('Edit view')).toBeInTheDocument()
})
it('does not render edit button when onEditView is not provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onDeleteView={() => {}} />
)
expect(screen.queryByTitle('Edit view')).not.toBeInTheDocument()
})
it('calls onEditView with correct filename when clicked', () => {
const onEditView = vi.fn()
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={onEditView} onDeleteView={() => {}} />
)
fireEvent.click(screen.getByTitle('Edit view'))
expect(onEditView).toHaveBeenCalledWith('active-projects.yml')
})
})
})

View File

@@ -12,7 +12,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel,
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
} from '@phosphor-icons/react'
import { isEmoji } from '../utils/emoji'
import { arrayMove } from '@dnd-kit/sortable'
@@ -40,6 +40,7 @@ interface SidebarProps {
onReorderFavorites?: (orderedPaths: string[]) => void
views?: ViewFile[]
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
@@ -223,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">
@@ -331,7 +332,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
views = [], onCreateView, onDeleteView,
views = [], onCreateView, onEditView, onDeleteView,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -430,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">
@@ -454,19 +455,31 @@ export const Sidebar = memo(function Sidebar({
<div key={v.filename} className="group relative">
<NavItem
icon={Funnel}
emoji={v.definition.icon}
label={v.definition.name}
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
/>
{onDeleteView && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
))}
</div>
@@ -476,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"

View File

@@ -26,8 +26,9 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
// --- NavItem ---
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
icon: ComponentType<IconProps>
emoji?: string | null
label: string
count?: number
isActive?: boolean
@@ -46,11 +47,14 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
const padding = compact ? '4px 16px' : '6px 16px'
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
const iconEl = emoji
? <span style={{ fontSize: iconSize, lineHeight: 1, width: iconSize, textAlign: 'center' }}>{emoji}</span>
: <Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<Icon size={iconSize} />
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
</div>
)
@@ -61,7 +65,7 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
<Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
{count !== undefined && count > 0 && (
<span className={cn("flex items-center justify-center", resolvedBadgeClass)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...resolvedBadgeStyle }}>

View File

@@ -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' },
]

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from 'react'
import type { ViewDefinition } from '../types'
export function useDialogs() {
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
@@ -10,6 +11,7 @@ export function useDialogs() {
const [showSearch, setShowSearch] = useState(false)
const [showConflictResolver, setShowConflictResolver] = useState(false)
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false)
const [editingView, setEditingView] = useState<{ filename: string; definition: ViewDefinition } | null>(null)
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
@@ -26,8 +28,12 @@ export function useDialogs() {
const closeSearch = useCallback(() => setShowSearch(false), [])
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
const openCreateView = useCallback(() => setShowCreateViewDialog(true), [])
const closeCreateView = useCallback(() => setShowCreateViewDialog(false), [])
const openCreateView = useCallback(() => { setEditingView(null); setShowCreateViewDialog(true) }, [])
const closeCreateView = useCallback(() => { setShowCreateViewDialog(false); setEditingView(null) }, [])
const openEditView = useCallback((filename: string, definition: ViewDefinition) => {
setEditingView({ filename, definition })
setShowCreateViewDialog(true)
}, [])
return {
showCreateTypeDialog, openCreateType, closeCreateType,
@@ -38,6 +44,6 @@ export function useDialogs() {
showGitHubVault, openGitHubVault, closeGitHubVault,
showSearch, openSearch, closeSearch,
showConflictResolver, openConflictResolver, closeConflictResolver,
showCreateViewDialog, openCreateView, closeCreateView,
showCreateViewDialog, openCreateView, closeCreateView, editingView, openEditView,
}
}

View File

@@ -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 () => {

View File

@@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = {
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
update_channel: null,
release_channel: null,
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 }))

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -136,4 +136,112 @@ describe('evaluateView', () => {
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['A', 'B'])
})
it('contains on relationship uses substring match for plain text', () => {
const view: ViewDefinition = {
name: 'Monday', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: 'Monday' }] },
}
const entries = [
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Monday Recap]]'] } }),
makeEntry({ title: 'C', relationships: { 'belongs to': ['[[Tuesday Ideas]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['A', 'B'])
})
it('not_contains on relationship uses substring match for plain text', () => {
const view: ViewDefinition = {
name: 'Not Monday', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'not_contains', value: 'Monday' }] },
}
const entries = [
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Tuesday Ideas]]'] } }),
makeEntry({ title: 'C', relationships: { 'belongs to': [] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['B', 'C'])
})
it('contains on relationship uses exact match for wikilink syntax', () => {
const view: ViewDefinition = {
name: 'Exact', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[Monday Ideas]]' }] },
}
const entries = [
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Monday Recap]]'] } }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['A'])
})
it('any_of / none_of on relationship always use exact stem match', () => {
const view: ViewDefinition = {
name: 'Exact list', icon: null, color: null, sort: null,
filters: { all: [{ field: 'belongs to', op: 'any_of', value: ['[[Monday]]'] }] },
}
const entries = [
makeEntry({ title: 'Exact', relationships: { 'belongs to': ['[[Monday]]'] } }),
makeEntry({ title: 'Partial', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
]
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'])
})
})

View File

@@ -75,7 +75,10 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
if (resolved.array) {
const stem = wikilinkStem(condVal)
const arrayMatch = (arr: string[]) => arr.some((item) => wikilinkStem(item) === stem)
const isWikilink = condVal.trim().startsWith('[[')
const arrayMatch = (arr: string[]) => arr.some((item) =>
isWikilink ? wikilinkStem(item) === stem : wikilinkStem(item).includes(stem)
)
if (op === 'contains') return arrayMatch(resolved.array)
if (op === 'not_contains') return !arrayMatch(resolved.array)
if (op === 'any_of' && Array.isArray(value)) {
@@ -101,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

View File

@@ -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')
})
})

View 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()
})
})

View File

@@ -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