Compare commits
17 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9f3ec21f | ||
|
|
0d91e29e82 | ||
|
|
77d59e3ee0 | ||
|
|
f37de38d21 | ||
|
|
51ce27f781 | ||
|
|
4a48833dbc | ||
|
|
d21eef8aa6 | ||
|
|
13f503691e | ||
|
|
17a327173b | ||
|
|
6eb4963952 | ||
|
|
b51b464605 | ||
|
|
a3b5b2244e | ||
|
|
38f83b55e0 | ||
|
|
a5652b3f4d | ||
|
|
56faffdcc8 | ||
|
|
c01f340851 | ||
|
|
a0fc97e5cf |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.72
|
||||
AVERAGE_THRESHOLD=9.34
|
||||
HOTSPOT_THRESHOLD=9.56
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -826,9 +826,13 @@ flowchart LR
|
||||
- **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`
|
||||
|
||||
### Feature Flags (Local V1)
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
Feature flags use a local-only system with no external dependencies:
|
||||
Feature flags are backed by PostHog and evaluated per release channel:
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the flag targets `release_channel = stable`
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlag } from './hooks/useFeatureFlag'
|
||||
@@ -838,18 +842,14 @@ const enabled = useFeatureFlag('example_flag') // boolean
|
||||
|
||||
**Resolution order:**
|
||||
1. `localStorage` override: key `ff_<name>` with value `"true"` or `"false"`
|
||||
2. Compile-time default in `FLAG_DEFAULTS` map
|
||||
2. `isFeatureEnabled(flag)` in `telemetry.ts` → checks release channel, then PostHog, then hardcoded defaults
|
||||
|
||||
**How to add a new flag:**
|
||||
1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts`
|
||||
2. Set its default in the `FLAG_DEFAULTS` record
|
||||
2. Create the flag on PostHog dashboard with rollout rules per channel
|
||||
3. Use `useFeatureFlag('your_flag')` in components
|
||||
|
||||
**Design decisions:**
|
||||
- No remote fetching, no PostHog dependency — zero privacy concerns
|
||||
- `localStorage` overrides allow dev/QA testing without rebuilding
|
||||
- Type-safe flag names via TypeScript union type
|
||||
- API surface is compatible with future migration to remote flags
|
||||
Release channel is selectable in Settings (alpha / beta / stable) and passed to PostHog as a person property via `identify()`. See ADR-0042.
|
||||
|
||||
## Platform Support — iOS / iPadOS (Prototype)
|
||||
|
||||
|
||||
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0042"
|
||||
title: "PostHog-based release channels and feature flags"
|
||||
status: active
|
||||
date: 2026-04-03
|
||||
supersedes: "0017"
|
||||
---
|
||||
## Context
|
||||
|
||||
ADR-0017 introduced canary/stable update channels with localStorage-based feature flags. This worked for local development but lacked remote flag management — promoting a feature from beta to stable required a code change and rebuild.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace localStorage feature flags with PostHog-based feature flags, evaluated per release channel (alpha/beta/stable). The release channel is a user-selectable setting; PostHog flag rules determine which features are visible for each channel.**
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup needed, works offline)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the PostHog flag targets `release_channel = stable`
|
||||
- Promotion = flipping a PostHog flag on the dashboard. Zero code changes, zero rebuilds.
|
||||
- `isFeatureEnabled(flagKey)` in `telemetry.ts` is the single evaluation point.
|
||||
- localStorage overrides (`ff_<name>`) still work for dev/QA testing (checked first).
|
||||
- Offline: PostHog caches flags in localStorage; alpha always works; first-launch-no-network falls back to hardcoded defaults.
|
||||
|
||||
## Options considered
|
||||
|
||||
* **Option A**: Keep localStorage-only flags (ADR-0017) — no server dependency, but no remote management.
|
||||
* **Option B** (chosen): PostHog feature flags — we already use PostHog for analytics, so no new dependency. Remote flag management, per-channel targeting, gradual rollouts via PostHog dashboard.
|
||||
* **Option C**: Dedicated feature flag service (LaunchDarkly, Unleash) — more powerful but adds a new vendor dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
* `release_channel` added to Settings (persisted via Tauri backend, not vault).
|
||||
* `useTelemetry` passes `release_channel` as a PostHog person property on identify.
|
||||
* `isFeatureEnabled()` checks channel → PostHog → hardcoded defaults.
|
||||
* `useFeatureFlag` hook updated to delegate to `isFeatureEnabled` (after localStorage override check).
|
||||
* ADR-0017 is superseded — the canary update channel remains, but feature gating moves from localStorage to PostHog.
|
||||
@@ -14,6 +14,7 @@ pub struct Settings {
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub update_channel: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
@@ -67,6 +68,10 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.update_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&cleaned)
|
||||
|
||||
@@ -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();
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@@ -336,15 +336,23 @@ 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`)
|
||||
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
|
||||
@@ -545,7 +553,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} />
|
||||
</>
|
||||
@@ -624,7 +632,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} 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}
|
||||
|
||||
45
src/components/CreateViewDialog.test.tsx
Normal file
45
src/components/CreateViewDialog.test.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen } 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')
|
||||
})
|
||||
})
|
||||
@@ -13,9 +13,11 @@ interface CreateViewDialogProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
/** 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, editingView }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
@@ -23,16 +25,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()
|
||||
@@ -60,11 +69,11 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
<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="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5 relative">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
@@ -90,7 +99,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
group={filters}
|
||||
@@ -101,7 +110,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
</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>
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useId } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
@@ -15,6 +16,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,49 +34,97 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
isDateOp: boolean
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
if (isDateOp) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{suggestions.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,37 +136,36 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -159,26 +208,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">
|
||||
|
||||
@@ -8,6 +8,8 @@ interface FolderTreeProps {
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onCreateFolder?: (name: string) => void
|
||||
collapsed?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
@@ -72,8 +74,9 @@ function FolderItem({
|
||||
)
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) {
|
||||
const [sectionCollapsed, setSectionCollapsed] = useState(false)
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder, collapsed: externalCollapsed, onToggle }: FolderTreeProps) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const sectionCollapsed = externalCollapsed ?? internalCollapsed
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
@@ -99,12 +102,12 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
{/* Header */}
|
||||
<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' }}
|
||||
onClick={() => setSectionCollapsed((v) => !v)}
|
||||
onClick={() => onToggle ? onToggle() : setInternalCollapsed((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
@@ -114,7 +117,7 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }}
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); if (sectionCollapsed && onToggle) onToggle(); else if (sectionCollapsed) setInternalCollapsed(false) }}
|
||||
data-testid="create-folder-btn"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -126,6 +126,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
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)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -150,7 +151,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
analytics_enabled: analytics,
|
||||
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
|
||||
update_channel: updateChannel === 'stable' ? null : updateChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
@@ -205,6 +207,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
updateChannel={updateChannel} setUpdateChannel={setUpdateChannel}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
/>
|
||||
@@ -240,6 +243,7 @@ interface SettingsBodyProps {
|
||||
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
|
||||
}
|
||||
@@ -323,6 +327,24 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
|
||||
<select
|
||||
value={props.releaseChannel}
|
||||
onChange={(e) => props.setReleaseChannel(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-release-channel"
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<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)' }} />
|
||||
|
||||
<div>
|
||||
|
||||
@@ -958,4 +958,55 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('SECTIONS 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 projectsSection = screen.getByText('Projects')
|
||||
// Walk up from SECTIONS 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -71,6 +72,32 @@ function useSidebarSections(entries: VaultEntry[]) {
|
||||
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'laputa:sidebar-collapsed'
|
||||
|
||||
type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
|
||||
function loadCollapsedState(): Record<SidebarGroupKey, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return { favorites: false, views: false, sections: false, folders: false }
|
||||
}
|
||||
|
||||
function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsed] = useState<Record<SidebarGroupKey, boolean>>(loadCollapsedState)
|
||||
|
||||
const toggle = useCallback((key: SidebarGroupKey) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = { ...prev, [key]: !prev[key] }
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { collapsed, toggle }
|
||||
}
|
||||
|
||||
function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0, archived = 0, trashed = 0
|
||||
@@ -163,14 +190,15 @@ function SortableFavoriteItem({ entry, isActive, onSelect }: {
|
||||
)
|
||||
}
|
||||
|
||||
function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder }: {
|
||||
function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder, collapsed, onToggle }: {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onReorder?: (orderedPaths: string[]) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((e) => e.favorite && !e.archived && !e.trashed)
|
||||
@@ -196,11 +224,11 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<div style={{ padding: '4px 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' }}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
@@ -304,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)
|
||||
@@ -377,6 +405,11 @@ export const Sidebar = memo(function Sidebar({
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
|
||||
|
||||
const hasFavorites = entries.some((e) => e.favorite && !e.archived && !e.trashed)
|
||||
const hasViews = views.length > 0 || !!onCreateView
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<SidebarTitleBar onCollapse={onCollapse} />
|
||||
@@ -390,64 +423,108 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
|
||||
{/* Views */}
|
||||
{(views.length > 0 || onCreateView) && (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Views</span>
|
||||
{onCreateView && (
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={onCreateView} aria-label="Create view" title="Create view">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
{hasFavorites && (
|
||||
<div className="border-b border-border">
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} collapsed={groupCollapsed.favorites} onToggle={() => toggleGroup('favorites')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Sections</span>
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
|
||||
<SlidersHorizontal size={14} />
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 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' }}
|
||||
onClick={() => toggleGroup('views')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.views ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>VIEWS</span>
|
||||
</div>
|
||||
{onCreateView && (
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateView() }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{!groupCollapsed.views && (
|
||||
<div style={{ paddingBottom: 4 }}>
|
||||
{views.map((v) => (
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={v.definition.icon}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
|
||||
title="Edit view"
|
||||
>
|
||||
<PencilSimple size={12} />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
)}
|
||||
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 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' }}
|
||||
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>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
title="Customize sections"
|
||||
aria-label="Customize sections"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
|
||||
>
|
||||
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} />
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} collapsed={groupCollapsed.folders} onToggle={() => toggleGroup('folders')} />
|
||||
</nav>
|
||||
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -36,8 +36,12 @@ describe('TitleField', () => {
|
||||
expect(input).toHaveValue('Keep This')
|
||||
})
|
||||
|
||||
it('shows filename indicator when slug differs from current filename', () => {
|
||||
it('shows filename indicator when slug differs and title is focused', () => {
|
||||
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
|
||||
// Not shown when unfocused
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
// Shown when focused
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
|
||||
})
|
||||
|
||||
@@ -148,4 +152,17 @@ describe('TitleField', () => {
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves vault-relative path when paths differ by symlink prefix', () => {
|
||||
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
|
||||
it('handles vaultPath with trailing slash', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// Auto-resize textarea to fit content
|
||||
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
@@ -75,7 +75,22 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}, [])
|
||||
|
||||
useEffect(() => { autoResize() }, [value, autoResize])
|
||||
useEffect(() => {
|
||||
autoResize()
|
||||
// Schedule a second measurement after the browser has painted, to catch
|
||||
// cases where scrollHeight is stale on the first read (e.g. tab switch).
|
||||
const id = requestAnimationFrame(autoResize)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [value, autoResize])
|
||||
|
||||
// Re-measure when container width changes (text may re-wrap)
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(autoResize)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
@@ -104,15 +119,25 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = notePath && vaultPath
|
||||
? notePath.replace(vaultPath + '/', '').replace(/\.md$/, '')
|
||||
: null
|
||||
const relativePath = (() => {
|
||||
if (!notePath || !vaultPath) return null
|
||||
const vp = vaultPath.replace(/\/+$/, '')
|
||||
const np = notePath.replace(/\.md$/, '')
|
||||
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
|
||||
// Fallback: match by vault directory name for symlink-resolved paths
|
||||
const vaultName = vp.split('/').pop()
|
||||
if (!vaultName) return null
|
||||
const segments = np.split('/')
|
||||
const idx = segments.lastIndexOf(vaultName)
|
||||
if (idx >= 0) return segments.slice(idx + 1).join('/')
|
||||
return null
|
||||
})()
|
||||
const isSubdirectory = relativePath != null && relativePath.includes('/')
|
||||
|
||||
// Show path only when title is focused and note is in a subdirectory
|
||||
const showRelativePath = isFocused && isSubdirectory
|
||||
// Show filename hint when slug differs, but suppress when path is already visible
|
||||
const showFilename = !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
// Show filename hint when slug differs, but only when focused (not always)
|
||||
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Local feature flag hook (V1 — no remote fetching).
|
||||
* Feature flag hook backed by PostHog + release channels.
|
||||
*
|
||||
* Flags are resolved in order:
|
||||
* 1. localStorage override (`ff_<name>`) — for dev/QA testing
|
||||
* 2. Compile-time defaults in FLAG_DEFAULTS
|
||||
*
|
||||
* To add a new flag: add its name to the FeatureFlagName union and
|
||||
* set its default in FLAG_DEFAULTS.
|
||||
* 2. PostHog feature flags (evaluated by release channel)
|
||||
* 3. Alpha channel always returns true (sees all features)
|
||||
*/
|
||||
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
import { isFeatureEnabled } from '../lib/telemetry'
|
||||
|
||||
const FLAG_DEFAULTS: Record<FeatureFlagName, boolean> = {
|
||||
example_flag: false,
|
||||
}
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
|
||||
export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
try {
|
||||
@@ -22,5 +18,5 @@ export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
return FLAG_DEFAULTS[flag] ?? false
|
||||
return isFeatureEnabled(flag)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const defaultSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
const savedSettings: Settings = {
|
||||
@@ -28,6 +29,7 @@ const savedSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
let mockSettingsStore: Settings = { ...defaultSettings }
|
||||
|
||||
@@ -18,6 +18,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
export function useSettings() {
|
||||
|
||||
@@ -13,13 +13,15 @@ vi.mock('../lib/telemetry', () => ({
|
||||
teardownSentry: () => mockTeardownSentry(),
|
||||
initPostHog: (...args: unknown[]) => mockInitPostHog(...args),
|
||||
teardownPostHog: () => mockTeardownPostHog(),
|
||||
updatePostHogIdentify: vi.fn(),
|
||||
setReleaseChannel: vi.fn(),
|
||||
}))
|
||||
|
||||
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,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null, release_channel: null,
|
||||
}
|
||||
|
||||
describe('useTelemetry', () => {
|
||||
@@ -50,7 +52,7 @@ describe('useTelemetry', () => {
|
||||
renderHook(() =>
|
||||
useTelemetry({ ...baseSettings, analytics_enabled: true, anonymous_id: 'test-uuid' }, true)
|
||||
)
|
||||
expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid')
|
||||
expect(mockInitPostHog).toHaveBeenCalledWith('test-uuid', 'stable')
|
||||
})
|
||||
|
||||
it('tears down Sentry when crash reporting is disabled after being enabled', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { initSentry, teardownSentry, initPostHog, teardownPostHog } from '../lib/telemetry'
|
||||
import { initSentry, teardownSentry, initPostHog, teardownPostHog, updatePostHogIdentify, setReleaseChannel } from '../lib/telemetry'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
function tauriCall(command: string): Promise<void> {
|
||||
@@ -29,13 +29,18 @@ export function useTelemetry(settings: Settings, loaded: boolean): void {
|
||||
tauriCall('reinit_telemetry').catch(() => {})
|
||||
}
|
||||
|
||||
const channel = settings.release_channel ?? 'stable'
|
||||
setReleaseChannel(channel)
|
||||
|
||||
if (analyticsEnabled && id && !prevAnalytics.current) {
|
||||
initPostHog(id)
|
||||
initPostHog(id, channel)
|
||||
} else if (!analyticsEnabled && prevAnalytics.current) {
|
||||
teardownPostHog()
|
||||
} else if (analyticsEnabled && id) {
|
||||
updatePostHogIdentify(channel)
|
||||
}
|
||||
|
||||
prevCrash.current = crashEnabled
|
||||
prevAnalytics.current = analyticsEnabled
|
||||
}, [loaded, settings.crash_reporting_enabled, settings.analytics_enabled, settings.anonymous_id])
|
||||
}, [loaded, settings.crash_reporting_enabled, settings.analytics_enabled, settings.anonymous_id, settings.release_channel])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent } from './telemetry'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent, isFeatureEnabled, setReleaseChannel } from './telemetry'
|
||||
|
||||
describe('telemetry scrubPaths', () => {
|
||||
it('redacts macOS absolute paths', () => {
|
||||
@@ -43,3 +43,21 @@ describe('trackEvent', () => {
|
||||
expect(() => trackEvent('note_created', { has_type: 1, creation_path: 'cmd_n' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFeatureEnabled', () => {
|
||||
it('returns true for alpha channel regardless of flag state', () => {
|
||||
setReleaseChannel('alpha')
|
||||
expect(isFeatureEnabled('any_flag')).toBe(true)
|
||||
expect(isFeatureEnabled('nonexistent_flag')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for stable channel when PostHog is not initialized', () => {
|
||||
setReleaseChannel('stable')
|
||||
expect(isFeatureEnabled('some_flag')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for beta channel when PostHog is not initialized', () => {
|
||||
setReleaseChannel('beta')
|
||||
expect(isFeatureEnabled('some_flag')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export function teardownSentry(): void {
|
||||
sentryInitialized = false
|
||||
}
|
||||
|
||||
export async function initPostHog(anonymousId: string): Promise<void> {
|
||||
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
|
||||
if (posthogInstance || !POSTHOG_KEY) return
|
||||
const posthog = (await import('posthog-js')).default
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
@@ -50,7 +50,7 @@ export async function initPostHog(anonymousId: string): Promise<void> {
|
||||
persistence: 'memory',
|
||||
disable_session_recording: true,
|
||||
})
|
||||
posthog.identify(anonymousId)
|
||||
posthog.identify(anonymousId, releaseChannel ? { release_channel: releaseChannel } : undefined)
|
||||
posthogInstance = posthog
|
||||
}
|
||||
|
||||
@@ -61,6 +61,24 @@ export function teardownPostHog(): void {
|
||||
posthogInstance = null
|
||||
}
|
||||
|
||||
export function updatePostHogIdentify(releaseChannel: string): void {
|
||||
posthogInstance?.identify(undefined, { release_channel: releaseChannel })
|
||||
}
|
||||
|
||||
/** Hardcoded defaults for first launch with no network (PostHog cache empty). */
|
||||
const FEATURE_DEFAULTS: Record<string, boolean> = {}
|
||||
|
||||
let currentReleaseChannel: string = 'stable'
|
||||
|
||||
export function setReleaseChannel(channel: string): void {
|
||||
currentReleaseChannel = channel
|
||||
}
|
||||
|
||||
export function isFeatureEnabled(flagKey: string): boolean {
|
||||
if (currentReleaseChannel === 'alpha') return true
|
||||
return posthogInstance?.isFeatureEnabled(flagKey) ?? FEATURE_DEFAULTS[flagKey] ?? false
|
||||
}
|
||||
|
||||
export function trackEvent(name: string, properties?: Record<string, string | number>): void {
|
||||
posthogInstance?.capture(name, properties)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ let mockSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
@@ -210,6 +211,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
anonymous_id: s.anonymous_id,
|
||||
update_channel: s.update_channel,
|
||||
release_channel: s.release_channel,
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface Settings {
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
update_channel: string | null
|
||||
release_channel: string | null
|
||||
}
|
||||
|
||||
export interface GitPullResult {
|
||||
|
||||
@@ -136,4 +136,58 @@ 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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user