Compare commits

...

6 Commits

Author SHA1 Message Date
Test
c5a10b43f9 fix: tighten settings panel organization toggle typing 2026-04-10 13:56:38 +02:00
Test
1378e13b15 feat: add explicit note organization setting 2026-04-10 13:52:39 +02:00
Test
0b799b4a1e fix: ellipsize long property values 2026-04-10 13:25:12 +02:00
Test
08b8995123 fix: replace breadcrumb shadow with border 2026-04-10 13:14:10 +02:00
Test
c0110d123b feat: add created dates to note list rows 2026-04-10 13:02:25 +02:00
Test
2271cb70e1 feat: add feedback modal flow 2026-04-10 12:45:37 +02:00
33 changed files with 695 additions and 90 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
@@ -63,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
@@ -74,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() {
@@ -91,6 +97,9 @@ function App() {
const visibleNotesRef = useRef<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const dialogs = useDialogs()
const [showFeedback, setShowFeedback] = useState(false)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
// onSwitch closure captures `notes` declared below — safe because it's only
// called on user interaction, never during render (refs inside the hook
@@ -124,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)
@@ -501,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,
@@ -509,6 +534,7 @@ function App() {
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
onOpenSettings: dialogs.openSettings,
onOpenFeedback: openFeedback,
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
@@ -521,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,
@@ -549,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,
})
@@ -561,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')) {
@@ -622,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} />
</>
@@ -630,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} />
@@ -667,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}
@@ -692,7 +719,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -711,7 +738,8 @@ 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}
githubToken={settings.github_token}

View File

@@ -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)', () => {
@@ -132,13 +137,22 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
it('separator visibility is controlled by data-title-hidden while using the shared border chrome', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).toHaveClass('border-b', 'border-transparent')
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
it('uses the active separator state when raw mode forces the title into the breadcrumb', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — action buttons always right-aligned', () => {

View File

@@ -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"
@@ -204,11 +206,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
className="breadcrumb-bar flex shrink-0 items-center"
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">

View File

@@ -700,6 +700,22 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
})
it('gives long URL values a truncating value-cell layout inside the properties panel', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ url: 'https://example.com/very/long/path/that/should/truncate' }}
onUpdateProperty={onUpdateProperty}
/>
)
const link = screen.getByTestId('url-link')
const value = screen.getByText('https://example.com/very/long/path/that/should/truncate')
expect(link).toHaveClass('flex-1', 'overflow-hidden')
expect(value).toHaveClass('truncate')
})
it('renders bare domain values as URL links', () => {
render(
<DynamicPropertiesPanel

View File

@@ -35,6 +35,13 @@ describe('EditableValue', () => {
expect(onStartEdit).toHaveBeenCalled()
})
it('uses a full-width truncating layout in view mode', () => {
render(<EditableValue value="A very long property value" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
const value = screen.getByText('A very long property value')
expect(value.parentElement).toHaveClass('w-full', 'overflow-hidden')
expect(value).toHaveClass('truncate')
})
it('shows input in editing mode', () => {
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
const input = screen.getByDisplayValue('Active')
@@ -168,6 +175,11 @@ describe('TagPillList', () => {
fireEvent.blur(input)
expect(onSave).not.toHaveBeenCalled()
})
it('truncates long pill labels independently', () => {
render(<TagPillList items={['A very long property list value']} onSave={onSave} label="Tags" />)
expect(screen.getByText('A very long property list value')).toHaveClass('truncate')
})
})
describe('isUrlValue', () => {
@@ -238,6 +250,14 @@ describe('UrlValue', () => {
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com')
})
it('uses a flexible truncating layout for long URL values', () => {
render(<UrlValue value="https://example.com/very/long/path/that/needs/truncation" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
const link = screen.getByTestId('url-link')
const value = screen.getByText('https://example.com/very/long/path/that/needs/truncation')
expect(link).toHaveClass('flex-1', 'overflow-hidden')
expect(value).toHaveClass('truncate')
})
it('does not open malformed URL', () => {
render(<UrlValue value="://broken" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))

View File

@@ -64,14 +64,14 @@ export function UrlValue({
}
return (
<span className="group/url flex min-w-0 max-w-full items-center gap-1">
<span className="group/url flex w-full min-w-0 items-center gap-1">
<span
className="inline-flex h-6 min-w-0 cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"
>
{value || '\u2014'}
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</span>
<button
className="shrink-0 border-none bg-transparent p-0 text-[12px] leading-none text-muted-foreground opacity-0 transition-all hover:text-foreground group-hover/url:opacity-100"
@@ -125,11 +125,11 @@ export function EditableValue({
return (
<span
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
{value || '\u2014'}
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</span>
)
}
@@ -212,7 +212,7 @@ export function TagPillList({
) : (
<span
key={idx}
className="group/pill relative inline-flex h-6 cursor-pointer items-center rounded-md transition-colors"
className="group/pill relative inline-flex h-6 max-w-full min-w-0 cursor-pointer items-center overflow-hidden rounded-md transition-colors"
style={{
...getTagStyle(item),
backgroundColor: getTagStyle(item).bg,
@@ -223,7 +223,7 @@ export function TagPillList({
onClick={() => handleStartEdit(idx)}
title="Click to edit"
>
{item}
<span className="min-w-0 truncate pr-4">{item}</span>
<button
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}

View File

@@ -22,14 +22,13 @@
opacity: 0.55;
}
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
/* Breadcrumb bar: title + border toggled via data attribute (no React re-render) */
.breadcrumb-bar {
transition: box-shadow 0.2s ease;
box-shadow: none;
transition: border-color 0.2s ease;
}
.breadcrumb-bar[data-title-hidden] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
border-bottom-color: var(--border);
}
.breadcrumb-bar__title {

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FeedbackDialog } from './FeedbackDialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
openExternalUrl: ReturnType<typeof vi.fn>
}
describe('FeedbackDialog', () => {
it('renders the instructional copy when open', () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
expect(screen.getByText('Share feedback')).toBeInTheDocument()
expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument()
expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument()
})
it('focuses the primary CTA when opened', async () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
const cta = screen.getByRole('button', { name: 'Go to Issues' })
await waitFor(() => expect(cta).toHaveFocus())
})
it('opens GitHub Issues without closing the modal', async () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
})
it('closes when pressing Escape', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking Close', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,59 @@
import { Megaphone } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
import { openExternalUrl } from '../utils/url'
interface FeedbackDialogProps {
open: boolean
onClose: () => void
}
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
const handleOpenIssues = () => {
void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL)
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone size={18} weight="duotone" />
Share feedback
</DialogTitle>
<DialogDescription>
The best way to share product feedback is through a GitHub Issue.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Before opening a new issue, please check whether a similar one already exists.
If it does, add an upvote or comment there instead of opening a duplicate.
</p>
<p>
When you do open a new issue, include the steps to reproduce, what you expected,
and what actually happened so it is easier to triage.
</p>
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={onClose}>
Close
</Button>
<Button type="button" autoFocus onClick={handleOpenIssues}>
Go to Issues
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -12,6 +12,8 @@ const { openExternalUrl } = await import('../utils/url') as typeof import('../ut
openExternalUrl: ReturnType<typeof vi.fn>
}
const NOW_SECONDS = 1_744_286_400
describe('NoteItem', () => {
beforeEach(() => {
openExternalUrl.mockClear()
@@ -87,6 +89,60 @@ describe('NoteItem', () => {
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
})
it('adds more breathing room between note sections', () => {
const entry = makeEntry({
title: 'Spaced note',
snippet: 'Body preview',
createdAt: NOW_SECONDS - 86400 * 3,
modifiedAt: NOW_SECONDS - 86400,
properties: { Status: 'Active' },
})
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
displayPropsOverride={['Status']}
onClickNote={vi.fn()}
/>,
)
expect(screen.getByTestId('note-content-stack').className).toContain('space-y-2')
})
it('shows created date on the right side of the date row when available', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
const entry = makeEntry({
title: 'Dated note',
createdAt: NOW_SECONDS - 86400 * 5,
modifiedAt: NOW_SECONDS - 86400 * 2,
})
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
const dateRow = screen.getByTestId('note-date-row')
expect(dateRow.className).toContain('justify-between')
expect(dateRow).toHaveTextContent('2d ago')
expect(dateRow).toHaveTextContent('Created 5d ago')
})
it('leaves the right side empty when no creation date exists', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(NOW_SECONDS * 1000))
const entry = makeEntry({
title: 'Modified note',
createdAt: null,
modifiedAt: NOW_SECONDS - 3600,
})
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
expect(screen.getByTestId('note-date-row')).toHaveTextContent('1h ago')
expect(screen.queryByText(/Created /)).not.toBeInTheDocument()
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
const linkedProject = makeEntry({
path: '/vault/project/build-app.md',

View File

@@ -163,35 +163,72 @@ function StandardNoteContent({
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
<div className="space-y-2 pr-5" data-testid="note-content-stack">
<NoteTitleRow
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
isBinary={isBinary}
isSelected={isSelected}
noteStatus={noteStatus}
/>
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
{!isBinary && entry.snippet && (
<div
className="text-[12px] leading-[1.5] text-muted-foreground"
data-testid="note-snippet"
style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}
>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
/>
)}
{!isBinary && <NoteDateRow entry={entry} />}
</div>
</>
)
}
function NoteTitleRow({
entry,
isBinary,
isSelected,
noteStatus,
}: {
entry: VaultEntry
isBinary: boolean
isSelected: boolean
noteStatus: NoteStatus
}) {
return (
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
)
}
function NoteDateRow({ entry }: { entry: VaultEntry }) {
const modifiedLabel = relativeDate(getDisplayDate(entry))
const createdLabel = entry.createdAt ? `Created ${relativeDate(entry.createdAt)}` : null
if (!modifiedLabel && !createdLabel) return null
return (
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<span>{modifiedLabel}</span>
{createdLabel && <span className="shrink-0">{createdLabel}</span>}
</div>
)
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'

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

View File

@@ -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,48 @@ 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}
/>
)
}
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled'> & {
explicitOrganizationEnabled: boolean
}
function SettingsPanelInner({
settings,
onSave,
explicitOrganizationEnabled,
onSaveExplicitOrganization,
onClose,
}: SettingsPanelInnerProps) {
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 +132,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 +153,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 +178,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 +213,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 +288,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 &amp; Telemetry</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
@@ -264,6 +308,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}>

View File

@@ -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[] = [
{

View File

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

View File

@@ -51,6 +51,19 @@ describe('StatusBar', () => {
expect(screen.queryByText('main')).not.toBeInTheDocument()
})
it('shows Feedback button when callback is provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={vi.fn()} />)
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.getByText('Feedback')).toBeInTheDocument()
})
it('calls onOpenFeedback when Feedback is clicked', () => {
const onOpenFeedback = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={onOpenFeedback} />)
fireEvent.click(screen.getByTestId('status-feedback'))
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('shows clickable commit hash that opens URL via openExternalUrl', () => {
render(
<StatusBar

View File

@@ -34,6 +34,7 @@ interface StatusBarProps {
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
onOpenFeedback?: () => void
buildNumber?: string
onCheckForUpdates?: () => void
onRemoveVault?: (path: string) => void
@@ -67,6 +68,7 @@ export function StatusBar({
onOpenConflictResolver,
zoomLevel = 100,
onZoomReset,
onOpenFeedback,
buildNumber,
onCheckForUpdates,
onRemoveVault,
@@ -131,6 +133,7 @@ export function StatusBar({
noteCount={noteCount}
zoomLevel={zoomLevel}
onZoomReset={onZoomReset}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>
</footer>

View File

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

View File

@@ -1,7 +1,9 @@
import { Bell, FileText, Package, Settings } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
import { Button } from '@/components/ui/button'
import {
ClaudeCodeBadge,
CommitBadge,
@@ -49,6 +51,7 @@ interface StatusBarSecondarySectionProps {
noteCount: number
zoomLevel: number
onZoomReset?: () => void
onOpenFeedback?: () => void
onOpenSettings?: () => void
}
@@ -127,6 +130,7 @@ export function StatusBarSecondarySection({
noteCount,
zoomLevel,
onZoomReset,
onOpenFeedback,
onOpenSettings,
}: StatusBarSecondarySectionProps) {
return (
@@ -148,6 +152,20 @@ export function StatusBarSecondarySection({
{zoomLevel}%
</span>
)}
{onOpenFeedback && (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
title="Share feedback"
data-testid="status-feedback"
>
<Megaphone size={14} />
Feedback
</Button>
)}
<span style={DISABLED_STYLE} title="Coming soon">
<Bell size={14} />
</span>

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

View File

@@ -0,0 +1 @@
export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues'

View File

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

View File

@@ -5,6 +5,7 @@ interface SettingsCommandsConfig {
vaultCount?: number
isGettingStartedHidden?: boolean
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
@@ -17,12 +18,13 @@ interface SettingsCommandsConfig {
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
return [
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },

View File

@@ -23,6 +23,7 @@ interface AppCommandsConfig {
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onOpenFeedback?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
@@ -39,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
@@ -88,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' })
@@ -171,6 +174,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCreateNoteOfType: config.onCreateNoteOfType,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onOpenFeedback: config.onOpenFeedback,
onDeleteNote: config.onDeleteNote,
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
@@ -189,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,

View File

@@ -200,6 +200,25 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
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 })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'give-feedback')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
cmd!.execute()
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
})
describe('pluralizeType', () => {

View File

@@ -39,6 +39,7 @@ interface CommandRegistryConfig {
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
@@ -60,6 +61,7 @@ interface CommandRegistryConfig {
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onOpenDailyNote: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
@@ -76,12 +78,13 @@ interface CommandRegistryConfig {
export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
@@ -107,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,
@@ -124,19 +127,20 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
], [
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,

View File

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

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

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