Compare commits
20 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b51b464605 | ||
|
|
a3b5b2244e | ||
|
|
38f83b55e0 | ||
|
|
a5652b3f4d | ||
|
|
56faffdcc8 | ||
|
|
c01f340851 | ||
|
|
a0fc97e5cf | ||
|
|
3172425a16 | ||
|
|
9b93a17cec | ||
|
|
aec4e9e992 | ||
|
|
1bca32a263 | ||
|
|
d5c3e1858e | ||
|
|
ab3de7eecd | ||
|
|
188cd7af8b | ||
|
|
248ec02dee | ||
|
|
9f2bd669fe | ||
|
|
b786b2a4cb | ||
|
|
83dad79692 | ||
|
|
e7ea808f2b | ||
|
|
96df0e6796 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.6
|
||||
AVERAGE_THRESHOLD=9.37
|
||||
HOTSPOT_THRESHOLD=9.56
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy to .env.local and fill in real values
|
||||
# These are never committed — .env.local is gitignored
|
||||
|
||||
# Sentry DSN (https://sentry.io → Project → Settings → Client Keys)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# PostHog (https://posthog.com → Project → Settings → Project API Key)
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -67,3 +67,7 @@ CODE-HEALTH-REPORT.md
|
||||
# Tauri signing keys (never commit private keys)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
# Local environment variables (never commit)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -798,7 +798,7 @@ sequenceDiagram
|
||||
|
||||
**Architecture:**
|
||||
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
|
||||
- **JS:** `@sentry/browser` + `posthog-js` initialized lazily by `useTelemetry` hook
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
@@ -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.
|
||||
@@ -42,7 +42,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/browser": "^10.45.0",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
|
||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -77,9 +77,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@sentry/browser':
|
||||
specifier: ^10.45.0
|
||||
version: 10.45.0
|
||||
'@sentry/react':
|
||||
specifier: ^10.47.0
|
||||
version: 10.47.0(react@19.2.4)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
@@ -1785,30 +1785,36 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sentry-internal/browser-utils@10.45.0':
|
||||
resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==}
|
||||
'@sentry-internal/browser-utils@10.47.0':
|
||||
resolution: {integrity: sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@10.45.0':
|
||||
resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==}
|
||||
'@sentry-internal/feedback@10.47.0':
|
||||
resolution: {integrity: sha512-pdvMmi4dQpX5S/vAAzrhHPIw3T3HjUgDNgUiCBrlp7N9/6zGO2gNPhUnNekP+CjgI/z0rvf49RLqlDenpNrMOg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@10.45.0':
|
||||
resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==}
|
||||
'@sentry-internal/replay-canvas@10.47.0':
|
||||
resolution: {integrity: sha512-A5OY8friSe6g8WAK4L8IeOPiEd9D3Ps40DzRH5j2f6SUja0t90mKMvHRcRf8zq0d4BkdB+JM7tjOkwxpuv8heA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@10.45.0':
|
||||
resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==}
|
||||
'@sentry-internal/replay@10.47.0':
|
||||
resolution: {integrity: sha512-ScdovxP7hJxgMt70+7hFvwT02GIaIUAxdEM/YPsayZBeCoAukPW8WiwztJfoKtsfPyKJ5A6f0H3PIxTPcA9Row==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@10.45.0':
|
||||
resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==}
|
||||
'@sentry/browser@10.47.0':
|
||||
resolution: {integrity: sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@10.45.0':
|
||||
resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==}
|
||||
'@sentry/core@10.47.0':
|
||||
resolution: {integrity: sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/react@10.47.0':
|
||||
resolution: {integrity: sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || 17.x || 18.x || 19.x
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
|
||||
|
||||
@@ -5941,33 +5947,39 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
optional: true
|
||||
|
||||
'@sentry-internal/browser-utils@10.45.0':
|
||||
'@sentry-internal/browser-utils@10.47.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/feedback@10.45.0':
|
||||
'@sentry-internal/feedback@10.47.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/replay-canvas@10.45.0':
|
||||
'@sentry-internal/replay-canvas@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/replay': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/replay@10.45.0':
|
||||
'@sentry-internal/replay@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/browser-utils': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry/browser@10.45.0':
|
||||
'@sentry/browser@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.45.0
|
||||
'@sentry-internal/feedback': 10.45.0
|
||||
'@sentry-internal/replay': 10.45.0
|
||||
'@sentry-internal/replay-canvas': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/browser-utils': 10.47.0
|
||||
'@sentry-internal/feedback': 10.47.0
|
||||
'@sentry-internal/replay': 10.47.0
|
||||
'@sentry-internal/replay-canvas': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry/core@10.45.0': {}
|
||||
'@sentry/core@10.47.0': {}
|
||||
|
||||
'@sentry/react@10.47.0(react@19.2.4)':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
react: 19.2.4
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
dependencies:
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -57,6 +57,7 @@ import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -118,6 +119,14 @@ function App() {
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
const vaultOpenedRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
|
||||
vaultOpenedRef.current = resolvedPath
|
||||
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
@@ -328,9 +337,10 @@ function App() {
|
||||
}, [notes])
|
||||
|
||||
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const 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')
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
@@ -349,13 +359,27 @@ function App() {
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customProps = new Set<string>()
|
||||
const customFields = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (e.properties) {
|
||||
for (const key of Object.keys(e.properties)) customProps.add(key)
|
||||
for (const key of Object.keys(e.properties)) customFields.add(key)
|
||||
}
|
||||
if (e.relationships) {
|
||||
for (const key of Object.keys(e.relationships)) customFields.add(key)
|
||||
}
|
||||
}
|
||||
return [...builtIn, ...Array.from(customProps).sort()]
|
||||
return [...builtIn, ...Array.from(customFields).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const valueSuggestionsForField = useCallback((field: string): string[] => {
|
||||
if (!vault.entries?.length) return []
|
||||
const values = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (field === 'type' && e.isA) values.add(e.isA)
|
||||
else if (field === 'status' && e.status) values.add(e.status)
|
||||
else if (e.properties?.[field] != null) values.add(String(e.properties[field]))
|
||||
}
|
||||
return Array.from(values).sort()
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
@@ -452,6 +476,7 @@ function App() {
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -599,7 +624,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} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
|
||||
@@ -146,6 +146,15 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
it('actions container has ml-auto so buttons are always right-aligned', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const actions = container.querySelector('.breadcrumb-bar__actions')
|
||||
expect(actions).toBeInTheDocument()
|
||||
expect(actions).toHaveClass('ml-auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
|
||||
@@ -61,7 +61,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
onToggleFavorite, onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterCondition, ViewDefinition } from '../types'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (definition: ViewDefinition) => void
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields }: CreateViewDialogProps) {
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([
|
||||
{ field: 'type', op: 'equals', value: '' },
|
||||
])
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [filters, setFilters] = useState<FilterGroup>({
|
||||
all: [{ field: 'type', op: 'equals', value: '' }],
|
||||
})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
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
|
||||
setConditions([{ 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])
|
||||
@@ -33,18 +38,26 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const validConditions = conditions.filter((c) => c.field)
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: validConditions },
|
||||
filters,
|
||||
}
|
||||
onCreate(definition)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSelectEmoji = useCallback((emoji: string) => {
|
||||
setIcon(emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleCloseEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
@@ -53,15 +66,19 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5">
|
||||
<div className="w-16 space-y-1.5 relative">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
<Input
|
||||
placeholder="📋"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
className="text-center"
|
||||
maxLength={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
title="Pick icon"
|
||||
>
|
||||
{icon || <span className="text-sm text-muted-foreground">📋</span>}
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
@@ -76,9 +93,10 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
conditions={conditions}
|
||||
onChange={setConditions}
|
||||
group={filters}
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -333,6 +333,10 @@
|
||||
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -1,7 +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 type { FilterCondition, FilterOp } from '../types'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -10,35 +10,91 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'not_contains', label: 'does not contain' },
|
||||
{ value: 'is_empty', label: 'is empty' },
|
||||
{ value: 'is_not_empty', label: 'is not empty' },
|
||||
{ value: 'before', label: 'before' },
|
||||
{ value: 'after', label: 'after' },
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
|
||||
interface FilterBuilderProps {
|
||||
conditions: FilterCondition[]
|
||||
onChange: (conditions: FilterCondition[]) => void
|
||||
availableFields: string[]
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'all' in node || 'any' in node
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
function getGroupChildren(group: FilterGroup): FilterNode[] {
|
||||
return 'all' in group ? group.all : group.any
|
||||
}
|
||||
|
||||
function getGroupMode(group: FilterGroup): 'all' | 'any' {
|
||||
return 'all' in group ? 'all' : 'any'
|
||||
}
|
||||
|
||||
function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGroup {
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
/** Combobox-style field input with datalist autocomplete. */
|
||||
function FieldInput({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
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"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="field"
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{fields.map((f) => <option key={f} value={f} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Combobox-style value input with autocomplete for known values. */
|
||||
function ValueInput({ value, suggestions, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
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"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{suggestions.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const suggestions = valueSuggestions(condition.field)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
<FieldInput
|
||||
value={condition.field}
|
||||
onChange={(e) => onUpdate({ ...condition, field: e.target.value })}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
@@ -47,11 +103,10 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
))}
|
||||
</select>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
<ValueInput
|
||||
value={String(condition.value ?? '')}
|
||||
onChange={(e) => onUpdate({ ...condition, value: e.target.value })}
|
||||
suggestions={suggestions}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
@@ -66,40 +121,121 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterBuilder({ conditions, onChange, availableFields }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: {
|
||||
group: FilterGroup
|
||||
fields: string[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
depth: number
|
||||
onChange: (g: FilterGroup) => void
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const mode = getGroupMode(group)
|
||||
const children = getGroupChildren(group)
|
||||
|
||||
const handleUpdate = (index: number, updated: FilterCondition) => {
|
||||
const next = [...conditions]
|
||||
next[index] = updated
|
||||
onChange(next)
|
||||
const toggleMode = () => {
|
||||
onChange(setGroupChildren(mode === 'all' ? 'any' : 'all', children))
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(conditions.filter((_, i) => i !== index))
|
||||
const updateChild = (index: number, node: FilterNode) => {
|
||||
const next = [...children]
|
||||
next[index] = node
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
onChange([...conditions, { field: fields[0], op: 'equals', value: '' }])
|
||||
const removeChild = (index: number) => {
|
||||
const next = children.filter((_, i) => i !== index)
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
onChange(setGroupChildren(mode, [...children, { field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }]))
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
const nested: FilterGroup = { all: [{ field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }] }
|
||||
onChange(setGroupChildren(mode, [...children, nested]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conditions.length > 1 && (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Match all of:</span>
|
||||
)}
|
||||
{conditions.map((c, i) => (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={c}
|
||||
fields={fields}
|
||||
onUpdate={(updated) => handleUpdate(i, updated)}
|
||||
onRemove={() => handleRemove(i)}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={handleAdd}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
<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
|
||||
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"
|
||||
onClick={toggleMode}
|
||||
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
|
||||
>
|
||||
{mode === 'all' ? 'AND' : 'OR'}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove group"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{children.map((child, i) =>
|
||||
isFilterGroup(child) ? (
|
||||
<FilterGroupView
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
depth={depth + 1}
|
||||
onChange={(g) => updateChild(i, g)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
) : (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addCondition}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addGroup}>
|
||||
<Plus size={12} className="mr-1" /> Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FilterBuilderProps {
|
||||
group: FilterGroup
|
||||
onChange: (group: FilterGroup) => void
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
}
|
||||
|
||||
const defaultSuggestions = () => [] as string[]
|
||||
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
return (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 0' }}>
|
||||
{/* 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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
@@ -136,6 +137,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
@@ -125,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)
|
||||
@@ -149,9 +151,14 @@ 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
|
||||
const newAnalytics = analytics
|
||||
if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in')
|
||||
if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out')
|
||||
onSave(buildSettings())
|
||||
onClose()
|
||||
}
|
||||
@@ -200,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}
|
||||
/>
|
||||
@@ -235,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
|
||||
}
|
||||
@@ -318,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>
|
||||
|
||||
@@ -71,6 +71,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 +189,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)
|
||||
@@ -200,7 +227,7 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
<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} />}
|
||||
@@ -377,6 +404,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 +422,95 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px 0' }}>
|
||||
<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}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={customizeRef} className="border-b border-border" style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<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 */}
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTypeColor } from '../utils/typeColors'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
export interface SectionGroup {
|
||||
@@ -94,11 +94,13 @@ export function SectionContent({
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
|
||||
return (
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
sectionLightColor={sectionLightColor}
|
||||
itemCount={itemCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
@@ -142,9 +144,9 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; itemCount: number; isActive: boolean
|
||||
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
@@ -152,14 +154,14 @@ function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, o
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: sectionLightColor } : {}) }}
|
||||
{...dragHandleProps}
|
||||
onClick={() => { if (!isRenaming) onSelect() }}
|
||||
onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
<Icon size={16} weight={isActive ? 'fill' : 'regular'} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
{isRenaming && onRenameSubmit && onRenameCancel ? (
|
||||
<InlineRenameInput
|
||||
key={`rename-${type}`}
|
||||
@@ -168,11 +170,14 @@ function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, o
|
||||
onCancel={onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
<span className="text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? sectionColor : undefined }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
{itemCount > 0 && (
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
<span
|
||||
className={cn("flex items-center justify-center", !isActive && "text-muted-foreground")}
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...(isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }) }}
|
||||
>
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { useCreateBlockNote, SuggestionMenuController } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
@@ -87,6 +88,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
])
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -117,18 +121,48 @@ describe('TitleField', () => {
|
||||
expect(input).toHaveValue('Untitled note')
|
||||
})
|
||||
|
||||
it('shows vault-relative path for notes in subdirectories', () => {
|
||||
it('shows vault-relative path (without .md) only when title is focused', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack.md')
|
||||
// Path hidden by default
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
// Focus title → path appears
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
// No bare filename shown when path is visible
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path for notes at vault root', () => {
|
||||
it('hides path on blur', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
|
||||
fireEvent.blur(input)
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path for notes at vault root even when focused', () => {
|
||||
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path when vaultPath is not provided', () => {
|
||||
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,13 +19,13 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const prevTitleRef = useRef(title)
|
||||
const [prevTitle, setPrevTitle] = useState(title)
|
||||
|
||||
// Reset local edit when the title prop changes (e.g. note switch).
|
||||
// This prevents a stale handleFocus closure from locking in the old note's title
|
||||
// when focus-editor fires before React re-renders with the new tab.
|
||||
if (prevTitleRef.current !== title) {
|
||||
prevTitleRef.current = title
|
||||
if (prevTitle !== title) {
|
||||
setPrevTitle(title)
|
||||
if (localValue !== null) setLocalValue(null)
|
||||
}
|
||||
|
||||
@@ -62,10 +62,36 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
* Displays the title as an editable field and shows the resulting filename below.
|
||||
*/
|
||||
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}, [])
|
||||
|
||||
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) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
@@ -91,23 +117,38 @@ export function TitleField({ title, filename, editable = true, notePath, vaultPa
|
||||
|
||||
const expectedSlug = slugify(value.trim() || title)
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
const showFilename = isEditing || currentStem !== expectedSlug
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = notePath && vaultPath
|
||||
? notePath.replace(vaultPath + '/', '')
|
||||
: null
|
||||
const showRelativePath = relativePath && relativePath.includes('/')
|
||||
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 only when focused (not always)
|
||||
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
<input
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="title-field__input"
|
||||
value={value}
|
||||
onChange={e => setEdit(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={commitTitle}
|
||||
rows={1}
|
||||
onChange={e => { setEdit(e.target.value); autoResize() }}
|
||||
onFocus={() => { setIsFocused(true); handleFocus() }}
|
||||
onBlur={() => { setIsFocused(false); commitTitle() }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!editable}
|
||||
placeholder="Untitled"
|
||||
|
||||
@@ -19,6 +19,8 @@ interface NoteCommandsConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
isFavorite?: boolean
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
@@ -28,7 +30,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
} = config
|
||||
|
||||
return [
|
||||
@@ -47,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: hasActiveNote && !!onToggleFavorite,
|
||||
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
|
||||
@@ -65,6 +65,7 @@ interface AppCommandsConfig {
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -112,6 +113,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleFavorite: config.onToggleFavorite,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
|
||||
@@ -79,6 +79,14 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+D triggers toggle favorite on active note', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleFavorite = vi.fn()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('d', { metaKey: true })
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface KeyboardActions {
|
||||
onQuickOpen: () => void
|
||||
@@ -20,6 +21,7 @@ interface KeyboardActions {
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
}
|
||||
@@ -65,7 +67,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow, activeTabPathRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onToggleFavorite, onOpenInNewWindow, activeTabPathRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -80,6 +82,7 @@ export function useAppKeyboard({
|
||||
j: onOpenDailyNote,
|
||||
s: onSave,
|
||||
',': onOpenSettings,
|
||||
d: withActiveTab((path) => onToggleFavorite?.(path)),
|
||||
e: withActiveTab(onArchiveNote),
|
||||
Backspace: withActiveTab(onTrashNote),
|
||||
Delete: withActiveTab(onTrashNote),
|
||||
@@ -102,6 +105,7 @@ export function useAppKeyboard({
|
||||
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
trackEvent('search_used')
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
@@ -219,5 +220,10 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
const triggerSync = useCallback(() => {
|
||||
trackEvent('sync_triggered')
|
||||
performPull()
|
||||
}, [performPull])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface CommandRegistryConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
@@ -85,7 +86,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
} = config
|
||||
|
||||
@@ -97,6 +98,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
const isFavorite = activeEntry?.favorite ?? false
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
@@ -107,7 +109,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed,
|
||||
onCreateNote, onCreateType, onOpenDailyNote, onSave,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
|
||||
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
}),
|
||||
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
|
||||
...buildViewCommands({
|
||||
@@ -136,6 +138,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
@@ -25,6 +26,7 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
if (result.status === 'ok') {
|
||||
trackEvent('commit_made')
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface ConfirmDeleteState {
|
||||
title: string
|
||||
@@ -50,7 +51,10 @@ export function useDeleteActions({
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
const ok = await deleteNoteFromDisk(path)
|
||||
if (ok) setToastMessage('Note permanently deleted')
|
||||
if (ok) {
|
||||
trackEvent('note_deleted')
|
||||
setToastMessage('Note permanently deleted')
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
@@ -32,6 +33,7 @@ export function useEntryActions({
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
const trashedAt = Date.now() / 1000
|
||||
updateEntry(path, { trashed: true, trashedAt })
|
||||
trackEvent('note_trashed')
|
||||
setToastMessage('Note moved to trash')
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
@@ -64,6 +66,7 @@ export function useEntryActions({
|
||||
await onBeforeAction?.(path)
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
updateEntry(path, { archived: true })
|
||||
trackEvent('note_archived')
|
||||
setToastMessage('Note archived')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, '_archived', true, { silent: true })
|
||||
@@ -129,6 +132,7 @@ export function useEntryActions({
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
if (!entry) return
|
||||
if (entry.favorite) {
|
||||
trackEvent('note_unfavorited')
|
||||
updateEntry(path, { favorite: false, favoriteIndex: null })
|
||||
try {
|
||||
await handleDeleteProperty(path, '_favorite', { silent: true })
|
||||
@@ -139,6 +143,7 @@ export function useEntryActions({
|
||||
setToastMessage('Failed to unfavorite — rolled back')
|
||||
}
|
||||
} else {
|
||||
trackEvent('note_favorited')
|
||||
const maxIndex = entries.filter((e) => e.favorite).reduce((max, e) => Math.max(max, e.favoriteIndex ?? 0), 0)
|
||||
const newIndex = maxIndex + 1
|
||||
updateEntry(path, { favorite: true, favoriteIndex: newIndex })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -468,6 +468,24 @@ describe('contentToEntryPatch', () => {
|
||||
const content = '---\ntype: Note\ncustom: value\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
|
||||
})
|
||||
|
||||
it('preserves _favorite_index as a number (not null)', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
expect(patch.favorite).toBe(true)
|
||||
expect(patch.favoriteIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves _favorite_index: 0 as number 0', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 0\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
expect(patch.favoriteIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves order as a number', () => {
|
||||
const content = '---\ntype: Type\norder: 3\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Type', order: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('todayDateString', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, addMockEntry } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
export interface NewEntryParams {
|
||||
path: string
|
||||
@@ -250,6 +251,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
@@ -257,6 +259,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' })
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
@@ -269,7 +272,10 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
persistNew(resolveNewType(typeName, config.vaultPath))
|
||||
trackEvent('type_created')
|
||||
}, [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
@@ -32,6 +33,7 @@ export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: Us
|
||||
const rawMode = rawEnabled && activeTabPath !== null
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
trackEvent('raw_mode_toggled')
|
||||
if (rawEnabled) {
|
||||
onBeforeRawEnd?.()
|
||||
setRawEnabled(false)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
@@ -91,6 +92,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
}, [])
|
||||
|
||||
const switchVault = useCallback((path: string) => {
|
||||
trackEvent('vault_switched')
|
||||
setVaultPath(path)
|
||||
onSwitchRef.current()
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { _scrubPathsForTest as scrubPaths } from './telemetry'
|
||||
import { _scrubPathsForTest as scrubPaths, trackEvent, isFeatureEnabled, setReleaseChannel } from './telemetry'
|
||||
|
||||
describe('telemetry scrubPaths', () => {
|
||||
it('redacts macOS absolute paths', () => {
|
||||
@@ -29,3 +29,35 @@ describe('telemetry scrubPaths', () => {
|
||||
expect(scrubPaths(input)).toBe('Failed copying <redacted-path> to <redacted-path>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('does not throw when PostHog is not initialized', () => {
|
||||
expect(() => trackEvent('test_event', { count: 1 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts event name with no properties', () => {
|
||||
expect(() => trackEvent('note_created')).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts event name with string and number properties', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN ?? ''
|
||||
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,33 @@ import { describe, it, expect } from 'vitest'
|
||||
import { parseFrontmatter, detectFrontmatterState } from './frontmatter'
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
describe('numeric values', () => {
|
||||
it('parses integer values as numbers', () => {
|
||||
const fm = parseFrontmatter('---\n_favorite_index: 2\n---\nBody')
|
||||
expect(fm['_favorite_index']).toBe(2)
|
||||
})
|
||||
|
||||
it('parses zero as number 0', () => {
|
||||
const fm = parseFrontmatter('---\n_favorite_index: 0\n---\nBody')
|
||||
expect(fm['_favorite_index']).toBe(0)
|
||||
})
|
||||
|
||||
it('parses float values as numbers', () => {
|
||||
const fm = parseFrontmatter('---\norder: 3.5\n---\nBody')
|
||||
expect(fm['order']).toBe(3.5)
|
||||
})
|
||||
|
||||
it('parses negative numbers', () => {
|
||||
const fm = parseFrontmatter('---\norder: -1\n---\nBody')
|
||||
expect(fm['order']).toBe(-1)
|
||||
})
|
||||
|
||||
it('does not parse quoted numbers as numbers', () => {
|
||||
const fm = parseFrontmatter('---\nversion: "42"\n---\nBody')
|
||||
expect(fm['version']).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('boolean-like Yes/No values', () => {
|
||||
it('parses Archived: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
|
||||
|
||||
@@ -26,6 +26,7 @@ function parseScalar(value: string): FrontmatterValue {
|
||||
const lower = clean.toLowerCase()
|
||||
if (lower === 'true' || lower === 'yes') return true
|
||||
if (lower === 'false' || lower === 'no') return false
|
||||
if (clean === value && /^-?\d+(\.\d+)?$/.test(clean)) return Number(clean)
|
||||
return clean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user