feat: add explicit note organization setting
This commit is contained in:
@@ -503,7 +503,7 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
@@ -420,6 +420,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
|
||||
@@ -245,8 +245,8 @@ laputa-app/
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection and the vault-level explicit organization toggle. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
|
||||
@@ -185,6 +185,27 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
|
||||
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders status bar', async () => {
|
||||
render(<App />)
|
||||
// StatusBar should be present
|
||||
|
||||
52
src/App.tsx
52
src/App.tsx
@@ -64,6 +64,11 @@ import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import {
|
||||
INBOX_SELECTION,
|
||||
isExplicitOrganizationEnabled,
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './utils/organizationWorkflow'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -75,7 +80,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
@@ -128,6 +133,22 @@ function App() {
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
||||
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSelection !== selection) {
|
||||
setSelection(effectiveSelection)
|
||||
setNoteListFilter('open')
|
||||
}
|
||||
}, [effectiveSelection, selection])
|
||||
|
||||
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
|
||||
updateConfig('inbox', {
|
||||
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
|
||||
explicitOrganization: enabled,
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
@@ -505,7 +526,7 @@ function App() {
|
||||
visibleNotesRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
selection: effectiveSelection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
@@ -526,6 +547,7 @@ function App() {
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
showInbox: explicitOrganizationEnabled,
|
||||
onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
@@ -554,9 +576,9 @@ function App() {
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: entryActions.handleToggleOrganized,
|
||||
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
||||
onCustomizeInboxColumns: handleCustomizeInboxColumns,
|
||||
canCustomizeInboxColumns: selection.kind === 'filter' && selection.filter === 'inbox',
|
||||
canCustomizeInboxColumns: explicitOrganizationEnabled && effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox',
|
||||
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
@@ -566,18 +588,18 @@ function App() {
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection, undefined, vault.views)
|
||||
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, vault.views, selection, inboxPeriod])
|
||||
}, [vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
|
||||
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
|
||||
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
|
||||
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
|
||||
return { type: null, query: '' }
|
||||
}, [selection])
|
||||
}, [effectiveSelection])
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
@@ -627,7 +649,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -635,10 +657,10 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -672,7 +694,7 @@ function App() {
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile ? undefined : entryActions.handleToggleOrganized}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
@@ -716,7 +738,7 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
|
||||
@@ -104,6 +104,11 @@ describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the organized toggle when the workflow is disabled', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.queryByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
|
||||
|
||||
@@ -75,16 +75,18 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleOrganized}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
>
|
||||
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{onToggleOrganized && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.organized ? "text-green-600" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleOrganized}
|
||||
title={entry.organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
>
|
||||
<CheckCircle size={16} weight={entry.organized ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
|
||||
@@ -75,6 +75,32 @@ describe('SettingsPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults the organization workflow switch to on', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByRole('switch', { name: 'Organize notes explicitly' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('saves the organization workflow preference when toggled off', () => {
|
||||
const onSaveExplicitOrganization = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
onSave={onSave}
|
||||
explicitOrganizationEnabled={true}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Organize notes explicitly' }))
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSaveExplicitOrganization).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
|
||||
@@ -3,11 +3,14 @@ import { X, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { Switch } from './ui/switch'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
settings: Settings
|
||||
onSave: (settings: Settings) => void
|
||||
explicitOrganizationEnabled?: boolean
|
||||
onSaveExplicitOrganization?: (enabled: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -59,18 +62,44 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
|
||||
|
||||
// --- Settings Panel ---
|
||||
|
||||
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
|
||||
if (!open) return null
|
||||
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
|
||||
function isSaveShortcut(event: React.KeyboardEvent): boolean {
|
||||
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
||||
}
|
||||
|
||||
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
settings,
|
||||
onSave,
|
||||
explicitOrganizationEnabled = true,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
}: SettingsPanelProps) {
|
||||
if (!open) return null
|
||||
return (
|
||||
<SettingsPanelInner
|
||||
settings={settings}
|
||||
onSave={onSave}
|
||||
explicitOrganizationEnabled={explicitOrganizationEnabled}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanelInner({
|
||||
settings,
|
||||
onSave,
|
||||
explicitOrganizationEnabled,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
}: Omit<SettingsPanelProps, 'open'>) {
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [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 [explicitOrganization, setExplicitOrganization] = useState(explicitOrganizationEnabled)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-focus first input when settings panel opens
|
||||
@@ -99,6 +128,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in')
|
||||
if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out')
|
||||
onSave(buildSettings())
|
||||
onSaveExplicitOrganization?.(explicitOrganization)
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -119,7 +149,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
if (isSaveShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
@@ -144,6 +174,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
explicitOrganization={explicitOrganization}
|
||||
setExplicitOrganization={setExplicitOrganization}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
/>
|
||||
@@ -177,6 +209,7 @@ interface SettingsBodyProps {
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
explicitOrganization: boolean; setExplicitOrganization: (v: boolean) => void
|
||||
crashReporting: boolean; setCrashReporting: (v: boolean) => void
|
||||
analytics: boolean; setAnalytics: (v: boolean) => void
|
||||
}
|
||||
@@ -251,6 +284,13 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<OrganizationWorkflowSection
|
||||
checked={props.explicitOrganization}
|
||||
onChange={props.setExplicitOrganization}
|
||||
/>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy & Telemetry</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
@@ -264,6 +304,39 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizationWorkflowSection({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Workflow</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Choose whether Laputa shows the Inbox workflow and the organized toggle.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="flex items-start justify-between gap-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-testid="settings-explicit-organization"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>Organize notes explicitly</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} aria-label="Organize notes explicitly" />
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TelemetryToggle({ label, description, checked, onChange, testId }: { label: string; description: string; checked: boolean; onChange: (v: boolean) => void; testId: string }) {
|
||||
return (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} data-testid={testId}>
|
||||
|
||||
@@ -867,6 +867,13 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
|
||||
it('hides Inbox when explicit organization is disabled', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} showInbox={false} inboxCount={3} />)
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.children[0].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ interface SidebarProps {
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
showInbox?: boolean
|
||||
inboxCount?: number
|
||||
onCollapse?: () => void
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
onDeleteView,
|
||||
folders = [],
|
||||
onCreateFolder,
|
||||
showInbox = true,
|
||||
inboxCount = 0,
|
||||
onCollapse,
|
||||
onCreateNewType,
|
||||
@@ -160,6 +162,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
<SidebarTopNav
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
showInbox={showInbox}
|
||||
inboxCount={inboxCount}
|
||||
activeCount={activeCount}
|
||||
archivedCount={archivedCount}
|
||||
|
||||
@@ -123,28 +123,32 @@ function ViewItem({
|
||||
export function SidebarTopNav({
|
||||
selection,
|
||||
onSelect,
|
||||
showInbox,
|
||||
inboxCount,
|
||||
activeCount,
|
||||
archivedCount,
|
||||
}: {
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
showInbox: boolean
|
||||
inboxCount: number
|
||||
activeCount: number
|
||||
archivedCount: number
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
|
||||
<NavItem
|
||||
icon={Tray}
|
||||
label="Inbox"
|
||||
count={inboxCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })}
|
||||
badgeClassName="text-muted-foreground"
|
||||
badgeStyle={{ background: 'var(--muted)' }}
|
||||
activeBadgeClassName="bg-primary text-primary-foreground"
|
||||
onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })}
|
||||
/>
|
||||
{showInbox && (
|
||||
<NavItem
|
||||
icon={Tray}
|
||||
label="Inbox"
|
||||
count={inboxCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })}
|
||||
badgeClassName="text-muted-foreground"
|
||||
badgeStyle={{ background: 'var(--muted)' }}
|
||||
activeBadgeClassName="bg-primary text-primary-foreground"
|
||||
onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })}
|
||||
/>
|
||||
)}
|
||||
<NavItem
|
||||
icon={FileText}
|
||||
label="All Notes"
|
||||
|
||||
46
src/components/ui/switch.tsx
Normal file
46
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SwitchProps extends Omit<React.ComponentProps<"button">, "onChange"> {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
function Switch({
|
||||
checked = false,
|
||||
className,
|
||||
onCheckedChange,
|
||||
onClick,
|
||||
type = "button",
|
||||
...props
|
||||
}: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
data-slot="switch"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
type={type}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) onCheckedChange?.(!checked)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block size-4 rounded-full bg-background transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -4,6 +4,7 @@ import type { SidebarSelection } from '../../types'
|
||||
interface NavigationCommandsConfig {
|
||||
onQuickOpen: () => void
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
showInbox?: boolean
|
||||
onOpenDailyNote: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
@@ -12,15 +13,25 @@ interface NavigationCommandsConfig {
|
||||
}
|
||||
|
||||
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
|
||||
const { onQuickOpen, onSelect, onGoBack, onGoForward, canGoBack, canGoForward } = config
|
||||
return [
|
||||
const { onQuickOpen, onSelect, showInbox = true, onGoBack, onGoForward, canGoBack, canGoForward } = config
|
||||
const commands: CommandAction[] = [
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
]
|
||||
if (showInbox) {
|
||||
commands.splice(5, 0, {
|
||||
id: 'go-inbox',
|
||||
label: 'Go to Inbox',
|
||||
group: 'Navigation',
|
||||
keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'],
|
||||
enabled: true,
|
||||
execute: () => onSelect({ kind: 'filter', filter: 'inbox' }),
|
||||
})
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface AppCommandsConfig {
|
||||
onZoomReset: () => void
|
||||
zoomLevel: number
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
showInbox?: boolean
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onGoBack?: () => void
|
||||
@@ -89,8 +90,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: SidebarFilter) => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
const safeFilter = !config.showInbox && filter === 'inbox' ? 'all' : filter
|
||||
onSelect({ kind: 'filter', filter: safeFilter })
|
||||
}, [config.showInbox, onSelect])
|
||||
|
||||
const viewChanges = useCallback(() => {
|
||||
onSelect({ kind: 'filter', filter: 'changes' })
|
||||
@@ -191,6 +193,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onZoomReset: config.onZoomReset,
|
||||
zoomLevel: config.zoomLevel,
|
||||
onSelect: config.onSelect,
|
||||
showInbox: config.showInbox,
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
|
||||
@@ -201,6 +201,12 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
||||
const config = makeConfig({ showInbox: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'go-inbox')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes Give Feedback in the Settings group when available', () => {
|
||||
const onOpenFeedback = vi.fn()
|
||||
const config = makeConfig({ onOpenFeedback })
|
||||
|
||||
@@ -61,6 +61,7 @@ interface CommandRegistryConfig {
|
||||
zoomLevel: number
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onOpenDailyNote: () => void
|
||||
showInbox?: boolean
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
canGoBack?: boolean
|
||||
@@ -83,6 +84,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote,
|
||||
showInbox,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
onCheckForUpdates, onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
@@ -108,7 +110,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
return useMemo(() => [
|
||||
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, onGoBack, onGoForward, canGoBack, canGoForward }),
|
||||
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, showInbox, onGoBack, onGoForward, canGoBack, canGoForward }),
|
||||
...buildNoteCommands({
|
||||
hasActiveNote, activeTabPath, isArchived,
|
||||
onCreateNote, onCreateType, onOpenDailyNote, onSave,
|
||||
@@ -138,6 +140,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote,
|
||||
showInbox,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
|
||||
@@ -155,6 +155,7 @@ export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
/** Vault-scoped UI configuration stored locally per vault path. */
|
||||
export interface InboxConfig {
|
||||
noteListProperties: string[] | null
|
||||
explicitOrganization?: boolean | null
|
||||
}
|
||||
|
||||
/** Vault-scoped UI configuration stored locally per vault path. */
|
||||
|
||||
36
src/utils/organizationWorkflow.test.ts
Normal file
36
src/utils/organizationWorkflow.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ALL_NOTES_SELECTION,
|
||||
INBOX_SELECTION,
|
||||
getDefaultSelectionForOrganization,
|
||||
isExplicitOrganizationEnabled,
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './organizationWorkflow'
|
||||
|
||||
describe('organizationWorkflow', () => {
|
||||
it('treats the setting as enabled by default', () => {
|
||||
expect(isExplicitOrganizationEnabled(undefined)).toBe(true)
|
||||
expect(isExplicitOrganizationEnabled(null)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats explicit false as disabled', () => {
|
||||
expect(isExplicitOrganizationEnabled(false)).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to Inbox when explicit organization is enabled', () => {
|
||||
expect(getDefaultSelectionForOrganization(true)).toEqual(INBOX_SELECTION)
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled', () => {
|
||||
expect(getDefaultSelectionForOrganization(false)).toEqual(ALL_NOTES_SELECTION)
|
||||
})
|
||||
|
||||
it('replaces Inbox selection with All Notes when the workflow is disabled', () => {
|
||||
expect(sanitizeSelectionForOrganization(INBOX_SELECTION, false)).toEqual(ALL_NOTES_SELECTION)
|
||||
})
|
||||
|
||||
it('leaves non-Inbox selections unchanged when the workflow is disabled', () => {
|
||||
const selection = { kind: 'filter', filter: 'archived' } as const
|
||||
expect(sanitizeSelectionForOrganization(selection, false)).toEqual(selection)
|
||||
})
|
||||
})
|
||||
22
src/utils/organizationWorkflow.ts
Normal file
22
src/utils/organizationWorkflow.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { SidebarSelection } from '../types'
|
||||
|
||||
export const INBOX_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
export const ALL_NOTES_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
export function isExplicitOrganizationEnabled(explicitOrganization?: boolean | null): boolean {
|
||||
return explicitOrganization !== false
|
||||
}
|
||||
|
||||
export function getDefaultSelectionForOrganization(explicitOrganization?: boolean | null): SidebarSelection {
|
||||
return isExplicitOrganizationEnabled(explicitOrganization) ? INBOX_SELECTION : ALL_NOTES_SELECTION
|
||||
}
|
||||
|
||||
export function sanitizeSelectionForOrganization(
|
||||
selection: SidebarSelection,
|
||||
explicitOrganization?: boolean | null,
|
||||
): SidebarSelection {
|
||||
if (!isExplicitOrganizationEnabled(explicitOrganization) && selection.kind === 'filter' && selection.filter === 'inbox') {
|
||||
return ALL_NOTES_SELECTION
|
||||
}
|
||||
return selection
|
||||
}
|
||||
Reference in New Issue
Block a user