diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 0e02bb87..62693040 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d164615b..2fa208bf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 4cc7c7fa..ce5b43d7 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -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 diff --git a/src/App.test.tsx b/src/App.test.tsx index f7c01f48..868f56a2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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() + + await waitFor(() => { + expect(screen.queryByText('Inbox')).not.toBeInTheDocument() + expect(screen.getByText('All Notes')).toBeInTheDocument() + }) + }) + it('renders status bar', async () => { render() // StatusBar should be present diff --git a/src/App.tsx b/src/App.tsx index 02fc80a2..6f2721ca 100644 --- a/src/App.tsx +++ b/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(() => { - 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 && ( <>
- +
@@ -635,10 +657,10 @@ function App() { {noteListVisible && ( <>
- {selection.kind === 'filter' && selection.filter === 'pulse' ? ( + {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( setViewMode('all')} /> ) : ( - diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} /> + diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} /> )}
@@ -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} /> - + { render() expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument() }) + + it('hides the organized toggle when the workflow is disabled', () => { + render() + expect(screen.queryByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).not.toBeInTheDocument() + }) }) describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 6a8101e1..76fb7afe 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -75,16 +75,18 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog > - + {onToggleOrganized && ( + + )} + ) +} + +export { Switch } diff --git a/src/hooks/commands/navigationCommands.ts b/src/hooks/commands/navigationCommands.ts index 0d1692f7..8247a3bf 100644 --- a/src/hooks/commands/navigationCommands.ts +++ b/src/hooks/commands/navigationCommands.ts @@ -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 } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 6e2a5039..327ed09b 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 91f734ec..d30c6b4c 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -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 }) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index b61c27db..a0f96586 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index 6ca27ea5..d37d37df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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. */ diff --git a/src/utils/organizationWorkflow.test.ts b/src/utils/organizationWorkflow.test.ts new file mode 100644 index 00000000..f6f0407f --- /dev/null +++ b/src/utils/organizationWorkflow.test.ts @@ -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) + }) +}) diff --git a/src/utils/organizationWorkflow.ts b/src/utils/organizationWorkflow.ts new file mode 100644 index 00000000..bddda965 --- /dev/null +++ b/src/utils/organizationWorkflow.ts @@ -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 +}