Compare commits
17 Commits
v0.2026040
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a10b43f9 | ||
|
|
1378e13b15 | ||
|
|
0b799b4a1e | ||
|
|
08b8995123 | ||
|
|
c0110d123b | ||
|
|
2271cb70e1 | ||
|
|
42b1e18cc4 | ||
|
|
334d8bec66 | ||
|
|
b8983f16b2 | ||
|
|
2fabc2a1d7 | ||
|
|
2f32a1781a | ||
|
|
94ce91457d | ||
|
|
717d97f6f0 | ||
|
|
727a1b95d0 | ||
|
|
000d89df50 | ||
|
|
b9879b15c9 | ||
|
|
32a30b40b9 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
@@ -77,6 +78,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_CHANGES,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
@@ -96,6 +98,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_TOGGLE_ORGANIZED,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -273,9 +276,12 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_note_menu(app: &App) -> MenuResult {
|
||||
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
|
||||
.id(NOTE_TOGGLE_ORGANIZED)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let archive_note = MenuItemBuilder::new("Archive Note")
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
@@ -295,13 +301,14 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.accelerator("Cmd+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&toggle_organized)
|
||||
.item(&archive_note)
|
||||
.item(&delete_note)
|
||||
.item(&restore_deleted_note)
|
||||
|
||||
@@ -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
|
||||
|
||||
63
src/App.tsx
63
src/App.tsx
@@ -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)
|
||||
|
||||
@@ -187,7 +212,7 @@ function App() {
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterPersisted: vault.loadModifiedFiles })
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
@@ -381,7 +406,6 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: appSave.flushBeforeAction,
|
||||
})
|
||||
|
||||
@@ -502,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,
|
||||
@@ -510,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,
|
||||
@@ -522,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,
|
||||
@@ -550,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,
|
||||
})
|
||||
@@ -562,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')) {
|
||||
@@ -623,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} />
|
||||
</>
|
||||
@@ -631,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} />
|
||||
@@ -668,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}
|
||||
@@ -693,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} />
|
||||
@@ -712,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}
|
||||
|
||||
@@ -9,12 +9,12 @@ describe('ArchivedNoteBanner', () => {
|
||||
expect(screen.getByText('Archived')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders unarchive button with keyboard hint', () => {
|
||||
it('renders unarchive button without the stale archive shortcut hint', () => {
|
||||
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
|
||||
const btn = screen.getByTestId('unarchive-btn')
|
||||
expect(btn).toBeTruthy()
|
||||
expect(btn.textContent).toContain('Unarchive')
|
||||
expect(btn.title).toBe('Unarchive (Cmd+E)')
|
||||
expect(btn.title).toBe('Unarchive')
|
||||
})
|
||||
|
||||
it('calls onUnarchive when button is clicked', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive (Cmd+E)"
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
|
||||
@@ -74,31 +74,43 @@ describe('BreadcrumbBar — delete', () => {
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Archive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Archive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive (Cmd+E)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onArchive when archive button is clicked', () => {
|
||||
const onArchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
|
||||
fireEvent.click(screen.getByTitle('Archive (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Archive'))
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when unarchive button is clicked', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTitle('Unarchive (Cmd+E)'))
|
||||
fireEvent.click(screen.getByTitle('Unarchive'))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', () => {
|
||||
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)', () => {
|
||||
it('always renders title elements in the DOM', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
@@ -125,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', () => {
|
||||
|
||||
@@ -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)' : 'Mark as organized (remove from Inbox)'}
|
||||
>
|
||||
<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"
|
||||
@@ -137,7 +139,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onUnarchive}
|
||||
title="Unarchive (Cmd+E)"
|
||||
title="Unarchive"
|
||||
>
|
||||
<ArrowUUpLeft size={16} />
|
||||
</button>
|
||||
@@ -145,7 +147,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onArchive}
|
||||
title="Archive (Cmd+E)"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
53
src/components/FeedbackDialog.test.tsx
Normal file
53
src/components/FeedbackDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
59
src/components/FeedbackDialog.tsx
Normal file
59
src/components/FeedbackDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup } from '../types'
|
||||
|
||||
@@ -135,6 +135,23 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('keeps filter controls top-aligned when the date preview adds a second line', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-07T12:00:00Z'))
|
||||
|
||||
renderBuilder({
|
||||
all: [{ field: 'created', op: 'after', value: '10 days ago' }],
|
||||
})
|
||||
|
||||
fireEvent.focus(screen.getByTestId('date-value-input'))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 28, 2026')
|
||||
expect(screen.getByTestId('filter-row')).toHaveClass('items-start')
|
||||
})
|
||||
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -141,7 +141,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const regexEnabled = regexSupported && condition.regex === true
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-start gap-1.5" data-testid="filter-row">
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
|
||||
@@ -54,6 +54,62 @@ function stepHighlightedIndex(current: number, optionCount: number, direction: '
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function moveHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
direction,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
direction: 'next' | 'previous'
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
}) {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, direction))
|
||||
}
|
||||
|
||||
function selectHighlightedOption({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
selectOption,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
selectOption: (value: string) => void
|
||||
}) {
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
}
|
||||
|
||||
function closeOpenCombobox({
|
||||
event,
|
||||
open,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
@@ -75,32 +131,16 @@ function handleFilterFieldKeyDown({
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'next', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
moveHighlightedOption({ event, open, options, direction: 'previous', openCombobox, setHighlightedIndex })
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
selectHighlightedOption({ event, open, options, highlightedIndex, selectOption })
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
closeOpenCombobox({ event, open, closeCombobox })
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -178,21 +218,27 @@ function FilterFieldPopoverPanel({
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<div
|
||||
className="max-h-60 overflow-y-auto overscroll-contain"
|
||||
data-testid="filter-field-combobox-scroll-area"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -10,18 +10,20 @@ describe('NoteTitleIcon', () => {
|
||||
})
|
||||
|
||||
it('renders a Phosphor icon when the name is recognized', () => {
|
||||
render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="cooking pot" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('svg')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders an image when the icon is an http url', () => {
|
||||
render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" />)
|
||||
const { container } = render(<NoteTitleIcon icon="https://example.com/favicon.png" testId="note-title-icon" className="mr-1" />)
|
||||
|
||||
const icon = screen.getByTestId('note-title-icon')
|
||||
expect(icon.tagName.toLowerCase()).toBe('img')
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/favicon.png')
|
||||
expect(container.querySelector('span.inline-flex.mr-1')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for an unrecognized icon value', () => {
|
||||
|
||||
@@ -9,6 +9,21 @@ interface NoteTitleIconProps {
|
||||
testId?: string
|
||||
}
|
||||
|
||||
function IconWrapper({
|
||||
children,
|
||||
className,
|
||||
size,
|
||||
}: Pick<NoteTitleIconProps, 'className' | 'size'> & { children: React.ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center align-middle', className)}
|
||||
style={{ width: size, height: size, lineHeight: 1 }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteTitleIcon({ icon, size = 14, className, color, testId }: NoteTitleIconProps) {
|
||||
const resolved = resolveNoteIcon(icon)
|
||||
|
||||
@@ -16,39 +31,40 @@ export function NoteTitleIcon({ icon, size = 14, className, color, testId }: Not
|
||||
|
||||
if (resolved.kind === 'emoji') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', className)}
|
||||
style={{ fontSize: size, lineHeight: 1 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
{resolved.value}
|
||||
</span>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<span style={{ fontSize: size, lineHeight: 1 }} data-testid={testId}>
|
||||
{resolved.value}
|
||||
</span>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
if (resolved.kind === 'image') {
|
||||
return (
|
||||
<img
|
||||
src={resolved.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0 rounded-sm object-cover', className)}
|
||||
style={{ width: size, height: size }}
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<img
|
||||
src={resolved.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="block h-full w-full rounded-sm object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
}}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('shrink-0', className)}
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<IconWrapper className={className} size={size}>
|
||||
<resolved.Icon
|
||||
width={size}
|
||||
height={size}
|
||||
className="block"
|
||||
style={color ? { color } : undefined}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</IconWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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 & 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}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -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[] = [
|
||||
{
|
||||
@@ -910,6 +917,53 @@ describe('Sidebar', () => {
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
|
||||
it('matches the Types row styling and type color for favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const favoriteLabel = screen.getByText('My Favorite Note')
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
const favoriteIcon = favoriteRow?.querySelector('svg')
|
||||
|
||||
expect(favoriteRow?.className).toBe(typeRow?.className)
|
||||
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
|
||||
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
expect(favoriteIcon?.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
|
||||
const customType: VaultEntry = {
|
||||
path: '/vault/types/recipe.md', filename: 'recipe.md', title: 'Recipe',
|
||||
isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'flask', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
const recipeFavorite: VaultEntry = {
|
||||
path: '/vault/recipe/sourdough.md', filename: 'sourdough.md', title: 'Sourdough',
|
||||
isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, customType, recipeFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const recipeRow = screen.getByText('Sourdough').closest('.cursor-pointer')
|
||||
const recipeIcon = recipeRow?.querySelector('svg')
|
||||
|
||||
expect(recipeIcon?.getAttribute('style')).toContain('var(--muted-foreground)')
|
||||
expect(within(recipeRow as HTMLElement).getByText('Sourdough')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
@@ -1049,5 +1103,32 @@ describe('Sidebar', () => {
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
|
||||
it('adds hover and focus classes that hide the view count chip while showing the action buttons', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
views={mockViews}
|
||||
onEditView={() => {}}
|
||||
onDeleteView={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const label = screen.getByText('Active Projects')
|
||||
const viewItem = label.closest('.group.relative') as HTMLElement
|
||||
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const countChip = navItem.querySelector('span:last-child') as HTMLElement
|
||||
expect(countChip).toBeTruthy()
|
||||
expect(viewItem.className).toContain('[&>div>span:last-child]:transition-opacity')
|
||||
expect(viewItem.className).toContain('group-hover:[&>div>span:last-child]:opacity-0')
|
||||
expect(viewItem.className).toContain('group-focus-within:[&>div>span:last-child]:opacity-0')
|
||||
|
||||
const actionButton = within(viewItem).getByTitle('Edit view')
|
||||
const actionContainer = actionButton.parentElement as HTMLElement
|
||||
expect(actionContainer.className).toContain('group-hover:opacity-100')
|
||||
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -62,6 +62,26 @@ describe('deriveEditorContentState', () => {
|
||||
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the legacy title section when a frontmatter title drives the display title', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the title section when the document title still comes from the filename', () => {
|
||||
const state = deriveState({
|
||||
entry: baseEntry,
|
||||
content: '---\nstatus: Active\n---\nBody without a heading',
|
||||
})
|
||||
|
||||
expect(state.hasH1).toBe(false)
|
||||
expect(state.showTitleSection).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { NoteStatus, VaultEntry } from '../../types'
|
||||
import { extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
|
||||
import { countWords } from '../../utils/wikilinks'
|
||||
|
||||
export interface EditorContentTab {
|
||||
@@ -14,6 +14,19 @@ interface EditorContentStateInput {
|
||||
activeStatus: NoteStatus
|
||||
}
|
||||
|
||||
interface TitleSectionState {
|
||||
hasDisplayTitle: boolean
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
interface VisibilityState {
|
||||
effectiveRawMode: boolean
|
||||
isDeletedPreview: boolean
|
||||
isNonMarkdownText: boolean
|
||||
showEditor: boolean
|
||||
showTitleSection: boolean
|
||||
}
|
||||
|
||||
export interface EditorContentState {
|
||||
freshEntry: VaultEntry | undefined
|
||||
isArchived: boolean
|
||||
@@ -36,10 +49,53 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
|
||||
}
|
||||
|
||||
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
|
||||
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
|
||||
}
|
||||
|
||||
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
|
||||
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
|
||||
}
|
||||
|
||||
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
|
||||
return hasH1 || contentDefinesTitle(activeTab)
|
||||
}
|
||||
|
||||
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
return {
|
||||
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
|
||||
hasH1,
|
||||
}
|
||||
}
|
||||
|
||||
function deriveVisibilityState(input: {
|
||||
activeStatus: NoteStatus
|
||||
activeTab: EditorContentTab | null
|
||||
freshEntry: VaultEntry | undefined
|
||||
hasDisplayTitle: boolean
|
||||
rawMode: boolean
|
||||
}): VisibilityState {
|
||||
const {
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle,
|
||||
rawMode,
|
||||
} = input
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
|
||||
return {
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor: !effectiveRawMode,
|
||||
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
|
||||
if (!activeTab) return false
|
||||
if (!activeTab.entry.filename.startsWith('untitled-')) return false
|
||||
@@ -53,22 +109,20 @@ export function deriveEditorContentState({
|
||||
activeStatus,
|
||||
}: EditorContentStateInput): EditorContentState {
|
||||
const freshEntry = findFreshEntry(activeTab, entries)
|
||||
const isDeletedPreview = !!activeTab && !freshEntry
|
||||
const hasH1 = resolveHasH1(activeTab, freshEntry)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
const showEditor = !effectiveRawMode
|
||||
const showTitleSection = !isDeletedPreview && !hasH1 && !isUnsavedUntitledDraft(activeTab, activeStatus)
|
||||
const titleState = deriveTitleSectionState(activeTab, freshEntry)
|
||||
const visibilityState = deriveVisibilityState({
|
||||
activeStatus,
|
||||
activeTab,
|
||||
freshEntry,
|
||||
hasDisplayTitle: titleState.hasDisplayTitle,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
return {
|
||||
freshEntry,
|
||||
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
|
||||
hasH1,
|
||||
isDeletedPreview,
|
||||
isNonMarkdownText,
|
||||
effectiveRawMode,
|
||||
showEditor,
|
||||
showTitleSection,
|
||||
hasH1: titleState.hasH1,
|
||||
...visibilityState,
|
||||
path: activeTab?.entry.path ?? '',
|
||||
wordCount: activeTab ? countWords(activeTab.content) : 0,
|
||||
}
|
||||
|
||||
@@ -19,4 +19,41 @@ describe('FilterFieldCombobox', () => {
|
||||
expect(listbox).toBeInTheDocument()
|
||||
expect(root.contains(listbox)).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the option list inside a dedicated scroll container', () => {
|
||||
render(
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
|
||||
expect(screen.getByTestId('filter-field-combobox-scroll-area')).toHaveClass(
|
||||
'max-h-60',
|
||||
'overflow-y-auto',
|
||||
'overscroll-contain',
|
||||
)
|
||||
})
|
||||
|
||||
it('stops wheel events from bubbling out of the scroll container', () => {
|
||||
const onWheel = vi.fn()
|
||||
|
||||
render(
|
||||
<div onWheel={onWheel}>
|
||||
<FilterFieldCombobox
|
||||
value="status"
|
||||
fields={Array.from({ length: 20 }, (_, index) => `Field ${index + 1}`)}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.focus(screen.getByTestId('filter-field-combobox-input'))
|
||||
fireEvent.wheel(screen.getByTestId('filter-field-combobox-scroll-area'))
|
||||
|
||||
expect(onWheel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../SidebarParts'
|
||||
import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { NoteTitleIcon } from '../NoteTitleIcon'
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
@@ -86,7 +87,7 @@ function ViewItem({
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="group relative [&>div>span:last-child]:transition-opacity group-hover:[&>div>span:last-child]:opacity-0 group-focus-within:[&>div>span:last-child]:opacity-0">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={view.definition.icon}
|
||||
@@ -95,7 +96,7 @@ function ViewItem({
|
||||
isActive={isActive}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -122,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"
|
||||
@@ -334,16 +339,46 @@ export function TypesSection({
|
||||
)
|
||||
}
|
||||
|
||||
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
|
||||
Project: 'wrench',
|
||||
project: 'wrench',
|
||||
Experiment: 'flask',
|
||||
experiment: 'flask',
|
||||
Responsibility: 'target',
|
||||
responsibility: 'target',
|
||||
Procedure: 'arrows-clockwise',
|
||||
procedure: 'arrows-clockwise',
|
||||
Person: 'users',
|
||||
person: 'users',
|
||||
Event: 'calendar-blank',
|
||||
event: 'calendar-blank',
|
||||
Topic: 'tag',
|
||||
topic: 'tag',
|
||||
Type: 'stack-simple',
|
||||
type: 'stack-simple',
|
||||
}
|
||||
|
||||
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
|
||||
}
|
||||
|
||||
function SortableFavoriteItem({
|
||||
entry,
|
||||
isActive,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
const icon = getFavoriteIcon(entry, typeEntryMap)
|
||||
const typeColor = getTypeColor(entry.isA ?? null, typeEntry?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? null, typeEntry?.color)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -353,17 +388,36 @@ function SortableFavoriteItem({
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={14} />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
|
||||
<span className="truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sortFavorites(entries: VaultEntry[]) {
|
||||
return entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity))
|
||||
}
|
||||
|
||||
function reorderFavoriteIds(favoriteIds: string[], event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return null
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return null
|
||||
return arrayMove(favoriteIds, oldIndex, newIndex)
|
||||
}
|
||||
|
||||
export function FavoritesSection({
|
||||
entries,
|
||||
selection,
|
||||
@@ -381,22 +435,14 @@ export function FavoritesSection({
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
const favorites = useMemo(() => sortFavorites(entries), [entries])
|
||||
const favoriteIds = useMemo(() => favorites.map((entry) => entry.path), [favorites])
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
onReorder?.(arrayMove(favoriteIds, oldIndex, newIndex))
|
||||
const reordered = reorderFavoriteIds(favoriteIds, event)
|
||||
if (reordered) onReorder?.(reordered)
|
||||
}, [favoriteIds, onReorder])
|
||||
|
||||
if (favorites.length === 0) return null
|
||||
@@ -413,6 +459,7 @@ export function FavoritesSection({
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isSelectionActive(selection, { kind: 'entity', entry })}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 }
|
||||
1
src/constants/feedback.ts
Normal file
1
src/constants/feedback.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues'
|
||||
@@ -22,6 +22,7 @@ export interface KeyboardActions {
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
}
|
||||
@@ -38,8 +39,10 @@ const VIEW_MODE_KEYS: Record<string, ViewMode> = {
|
||||
}
|
||||
|
||||
function isTextInputFocused(): boolean {
|
||||
const tag = document.activeElement?.tagName
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return false
|
||||
if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return true
|
||||
return active.isContentEditable || active.closest('[contenteditable="true"]') !== null
|
||||
}
|
||||
|
||||
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
|
||||
@@ -75,7 +78,7 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
|
||||
s: actions.onSave,
|
||||
',': actions.onOpenSettings,
|
||||
d: withActiveTab(activeTabPathRef, (path) => actions.onToggleFavorite?.(path)),
|
||||
e: withActiveTab(activeTabPathRef, actions.onArchiveNote),
|
||||
e: withActiveTab(activeTabPathRef, (path) => actions.onToggleOrganized?.(path)),
|
||||
Backspace: withActiveTab(activeTabPathRef, actions.onDeleteNote),
|
||||
Delete: withActiveTab(activeTabPathRef, actions.onDeleteNote),
|
||||
'[': () => actions.onGoBack?.(),
|
||||
@@ -119,7 +122,8 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
|
||||
}
|
||||
|
||||
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
|
||||
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
|
||||
const matchesAiPanelShortcut = e.code === 'KeyL' || e.key.toLowerCase() === 'l'
|
||||
if (isCommandShiftOnly(e) === false || matchesAiPanelShortcut === false || onToggleAIChat === undefined) return false
|
||||
e.preventDefault()
|
||||
onToggleAIChat()
|
||||
return true
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
execute: () => { if (activeTabPath) onDeleteNote(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
@@ -62,7 +62,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note',
|
||||
id: 'toggle-organized', label: isOrganized ? 'Mark as Unorganized' : 'Mark as Organized', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: hasActiveNote && !!onToggleOrganized,
|
||||
execute: () => { if (activeTabPath) onToggleOrganized?.(activeTabPath) },
|
||||
|
||||
@@ -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?.() },
|
||||
|
||||
@@ -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' })
|
||||
@@ -115,6 +118,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleFavorite: config.onToggleFavorite,
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
@@ -138,6 +142,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleDiff: config.onToggleDiff,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleOrganized: config.onToggleOrganized,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
@@ -169,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,
|
||||
@@ -187,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,
|
||||
|
||||
@@ -2,9 +2,21 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
|
||||
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}) {
|
||||
function fireKey(
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
fireKeyOnTarget(window, key, mods)
|
||||
}
|
||||
|
||||
function fireKeyOnTarget(
|
||||
target: EventTarget,
|
||||
key: string,
|
||||
mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; code?: string } = {},
|
||||
) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code: mods.code,
|
||||
altKey: mods.altKey ?? false,
|
||||
metaKey: mods.metaKey ?? false,
|
||||
ctrlKey: mods.ctrlKey ?? false,
|
||||
@@ -12,7 +24,7 @@ function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlK
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
|
||||
function makeActions() {
|
||||
@@ -26,6 +38,7 @@ function makeActions() {
|
||||
onOpenSettings: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
@@ -87,6 +100,14 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+E triggers toggle organized on active note, not archive', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('e', { metaKey: true })
|
||||
expect(actions.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
|
||||
expect(actions.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -130,6 +151,14 @@ describe('useAppKeyboard', () => {
|
||||
try { fn() } finally { document.body.removeChild(input) }
|
||||
}
|
||||
|
||||
function withFocusedContentEditable(fn: (editable: HTMLDivElement) => void) {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
document.body.appendChild(editable)
|
||||
editable.focus()
|
||||
try { fn(editable) } finally { document.body.removeChild(editable) }
|
||||
}
|
||||
|
||||
it('Cmd+Backspace does not delete note when text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -139,6 +168,15 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Backspace does not delete note when contenteditable is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
withFocusedContentEditable((editable) => {
|
||||
fireKeyOnTarget(editable, 'Backspace', { metaKey: true })
|
||||
expect(actions.onDeleteNote).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Backspace deletes note when no text input is focused', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
@@ -201,6 +239,25 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L works when editor stops propagation', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
withFocusedContentEditable((editable) => {
|
||||
editable.addEventListener('keydown', (event) => event.stopPropagation())
|
||||
fireKeyOnTarget(editable, 'l', { metaKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+Shift+L matches by physical key code when the localized key differs', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('¬', { code: 'KeyL', metaKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
@@ -14,7 +14,7 @@ export function useAppKeyboard(actions: KeyboardActions) {
|
||||
onKeyDown(event)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
||||
window.addEventListener('keydown', handleWindowKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onDeleteNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onUnarchiveNote: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
onResolveConflicts: vi.fn(),
|
||||
onSetViewMode: vi.fn(),
|
||||
@@ -192,6 +193,32 @@ describe('useCommandRegistry', () => {
|
||||
const cmd = findCommand(result.current, 'customize-inbox-columns')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,11 +36,11 @@ export function useEditorSaveWithLinks(config: {
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const fmPatch = {
|
||||
...frontmatterPatch,
|
||||
...deriveDisplayTitleState(
|
||||
...deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
),
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
}),
|
||||
}
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onZoomReset: vi.fn(),
|
||||
onToggleOrganized: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onDeleteNote: vi.fn(),
|
||||
onSearch: vi.fn(),
|
||||
@@ -143,6 +144,19 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onArchiveNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('note-toggle-organized triggers organized toggle on active tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-toggle-organized', h)
|
||||
expect(h.onToggleOrganized).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('note-toggle-organized does nothing when no active tab', () => {
|
||||
const h = makeHandlers()
|
||||
h.activeTabPathRef = { current: null }
|
||||
dispatchMenuEvent('note-toggle-organized', h)
|
||||
expect(h.onToggleOrganized).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('note-delete triggers delete on active tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('note-delete', h)
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface MenuEventHandlers {
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onDeleteNote: (path: string) => void
|
||||
onSearch: () => void
|
||||
@@ -106,7 +107,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
const path = h.activeTabPathRef.current
|
||||
if (!path) return id === 'note-archive' || id === 'note-delete'
|
||||
if (!path) return id === 'note-toggle-organized' || id === 'note-archive' || id === 'note-delete'
|
||||
if (id === 'note-toggle-organized') { h.onToggleOrganized?.(path); return true }
|
||||
if (id === 'note-archive') { h.onArchiveNote(path); return true }
|
||||
if (id === 'note-delete') { h.onDeleteNote(path); return true }
|
||||
return false
|
||||
|
||||
@@ -641,4 +641,3 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
58
src/hooks/useNoteActions.persistence.test.ts
Normal file
58
src/hooks/useNoteActions.persistence.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
updateMockFrontmatter: vi.fn().mockReturnValue('---\nupdated: true\n---\n'),
|
||||
deleteMockFrontmatterProperty: vi.fn().mockReturnValue('---\n---\n'),
|
||||
}))
|
||||
|
||||
function makeConfig(onFrontmatterPersisted: () => void): NoteActionsConfig {
|
||||
return {
|
||||
addEntry: vi.fn(),
|
||||
removeEntry: vi.fn(),
|
||||
entries: [],
|
||||
setToastMessage: vi.fn(),
|
||||
updateEntry: vi.fn(),
|
||||
vaultPath: '/test/vault',
|
||||
onFrontmatterPersisted,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNoteActions frontmatter persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'update',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', '_list_properties_display', ['Owner', 'Status'])
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'delete',
|
||||
run: async (result: ReturnType<typeof renderHook<typeof useNoteActions>>['result']) => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
},
|
||||
},
|
||||
])('notifies after a frontmatter $label completes', async ({ run }) => {
|
||||
const onFrontmatterPersisted = vi.fn()
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig(onFrontmatterPersisted)))
|
||||
|
||||
await act(async () => {
|
||||
await run(result)
|
||||
})
|
||||
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
|
||||
onFrontmatterContentChanged?: (path: string, content: string) => void
|
||||
/** Called after a frontmatter mutation is fully persisted, including follow-up renames. */
|
||||
onFrontmatterPersisted?: () => void
|
||||
}
|
||||
|
||||
function isTitleKey(key: string): boolean {
|
||||
@@ -44,6 +46,12 @@ interface TitleRenameDeps {
|
||||
updateTabContent: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
function applyFrontmatterCallbacks(config: NoteActionsConfig, path: string, newContent: string | undefined): boolean {
|
||||
if (!newContent) return false
|
||||
config.onFrontmatterContentChanged?.(path, newContent)
|
||||
return true
|
||||
}
|
||||
|
||||
async function renameAfterTitleChange(path: string, newTitle: string, deps: TitleRenameDeps): Promise<void> {
|
||||
const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title
|
||||
const result = await performRename(path, newTitle, deps.vaultPath, oldTitle)
|
||||
@@ -71,6 +79,20 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
|
||||
else console.warn(`Navigation target not found: ${target}`)
|
||||
}
|
||||
|
||||
async function maybeRenameAfterFrontmatterUpdate(
|
||||
path: string,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
deps: TitleRenameDeps,
|
||||
): Promise<void> {
|
||||
if (!shouldRenameOnTitleUpdate(key, value)) return
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, deps)
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { entries, setToastMessage, updateEntry } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
@@ -108,25 +130,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (shouldRenameOnTitleUpdate(key, value)) {
|
||||
try {
|
||||
await renameAfterTitleChange(path, value, {
|
||||
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
|
||||
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
await maybeRenameAfterFrontmatterUpdate(path, key, value, {
|
||||
vaultPath: config.vaultPath, tabsRef: rename.tabsRef, replaceEntry: config.replaceEntry,
|
||||
setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent,
|
||||
})
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
|
||||
if (!applyFrontmatterCallbacks(config, path, newContent)) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { deriveDisplayTitleState, extractH1TitleFromContent, filenameStemToTitle } from './noteTitle'
|
||||
import {
|
||||
contentDefinesDisplayTitle,
|
||||
deriveDisplayTitleState,
|
||||
extractFrontmatterTitleFromContent,
|
||||
extractH1TitleFromContent,
|
||||
filenameStemToTitle,
|
||||
} from './noteTitle'
|
||||
|
||||
describe('filenameStemToTitle', () => {
|
||||
it('converts kebab-case filenames into title case', () => {
|
||||
@@ -23,10 +29,32 @@ describe('extractH1TitleFromContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractFrontmatterTitleFromContent', () => {
|
||||
it('extracts the frontmatter title when present', () => {
|
||||
const content = '---\ntitle: Legacy Title\nstatus: Active\n---\n## Body'
|
||||
expect(extractFrontmatterTitleFromContent(content)).toBe('Legacy Title')
|
||||
})
|
||||
|
||||
it('returns null when the frontmatter title is missing', () => {
|
||||
expect(extractFrontmatterTitleFromContent('---\nstatus: Active\n---\n## Body')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentDefinesDisplayTitle', () => {
|
||||
it('returns true when the document title comes from frontmatter', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(contentDefinesDisplayTitle(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when title still comes from the filename', () => {
|
||||
expect(contentDefinesDisplayTitle('Body only')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDisplayTitleState', () => {
|
||||
it('prefers H1 over frontmatter title and filename', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\n# Updated Title\n\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Updated Title',
|
||||
hasH1: true,
|
||||
})
|
||||
@@ -34,14 +62,22 @@ describe('deriveDisplayTitleState', () => {
|
||||
|
||||
it('falls back to frontmatter title when no H1 is present', () => {
|
||||
const content = '---\ntitle: Legacy Title\n---\nBody'
|
||||
expect(deriveDisplayTitleState(content, 'legacy-title.md', 'Legacy Title')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content, filename: 'legacy-title.md', frontmatterTitle: 'Legacy Title' })).toEqual({
|
||||
title: 'Legacy Title',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the frontmatter title from content when no explicit title is passed', () => {
|
||||
const content = '---\ntitle: Spring 2026\n---\n## Goals'
|
||||
expect(deriveDisplayTitleState({ content, filename: 'spring-2026.md' })).toEqual({
|
||||
title: 'Spring 2026',
|
||||
hasH1: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to filename title when there is no H1 or frontmatter title', () => {
|
||||
expect(deriveDisplayTitleState('Body only', 'renamed-note.md')).toEqual({
|
||||
expect(deriveDisplayTitleState({ content: 'Body only', filename: 'renamed-note.md' })).toEqual({
|
||||
title: 'Renamed Note',
|
||||
hasH1: false,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
import { splitFrontmatter } from './wikilinks'
|
||||
|
||||
interface ResolvedContentTitle {
|
||||
source: 'h1' | 'frontmatter'
|
||||
title: string
|
||||
}
|
||||
|
||||
interface DisplayTitleInput {
|
||||
content: string
|
||||
filename: string
|
||||
frontmatterTitle?: string | null
|
||||
}
|
||||
|
||||
interface DisplayTitleState {
|
||||
title: string
|
||||
hasH1: boolean
|
||||
}
|
||||
|
||||
function replaceWikilinkAliases(text: string): string {
|
||||
return text.replace(/\[\[[^|\]]+\|([^\]]+)\]\]/g, '$1')
|
||||
}
|
||||
@@ -49,20 +66,46 @@ export function extractH1TitleFromContent(content: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState(
|
||||
content: string,
|
||||
filename: string,
|
||||
frontmatterTitle?: string | null,
|
||||
): { title: string, hasH1: boolean } {
|
||||
export function extractFrontmatterTitleFromContent(content: string): string | null {
|
||||
const title = parseFrontmatter(content).title
|
||||
if (typeof title !== 'string') return null
|
||||
const trimmed = title.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function resolveContentTitle(content: string, frontmatterTitle?: string | null): ResolvedContentTitle | null {
|
||||
const h1Title = extractH1TitleFromContent(content)
|
||||
if (h1Title) {
|
||||
return { title: h1Title, hasH1: true }
|
||||
return { title: h1Title, source: 'h1' }
|
||||
}
|
||||
|
||||
const trimmedFrontmatterTitle = frontmatterTitle?.trim()
|
||||
if (trimmedFrontmatterTitle) {
|
||||
return { title: trimmedFrontmatterTitle, hasH1: false }
|
||||
const resolvedFrontmatterTitle = frontmatterTitle?.trim() || extractFrontmatterTitleFromContent(content)
|
||||
if (resolvedFrontmatterTitle) {
|
||||
return { title: resolvedFrontmatterTitle, source: 'frontmatter' }
|
||||
}
|
||||
|
||||
return { title: filenameStemToTitle(filename), hasH1: false }
|
||||
return null
|
||||
}
|
||||
|
||||
export function contentDefinesDisplayTitle(content: string): boolean {
|
||||
return resolveContentTitle(content) !== null
|
||||
}
|
||||
|
||||
export function deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle,
|
||||
}: DisplayTitleInput): DisplayTitleState {
|
||||
const resolvedTitle = resolveContentTitle(content, frontmatterTitle)
|
||||
if (resolvedTitle) {
|
||||
return {
|
||||
title: resolvedTitle.title,
|
||||
hasH1: resolvedTitle.source === 'h1',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: filenameStemToTitle(filename),
|
||||
hasH1: false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
10
tests/fixtures/test-vault/quarter/spring-2026.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Spring 2026
|
||||
Is A: Quarter
|
||||
Status: Active
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- [[Alpha Project]]
|
||||
- [[Note B]]
|
||||
@@ -8,9 +8,10 @@ test.describe('AI panel shortcut', () => {
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('Cmd/Ctrl+Shift+L opens the AI panel', async ({ page }) => {
|
||||
test('Cmd+Shift+L opens the AI panel from the editor', async ({ page }) => {
|
||||
await page.locator('.app__note-list .cursor-pointer').first().click()
|
||||
await sendShortcut(page, 'L', ['Control', 'Shift'])
|
||||
await page.locator('.bn-editor').click()
|
||||
await sendShortcut(page, 'L', ['Meta', 'Shift'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
|
||||
await expect(page.getByTitle('Close AI panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -69,12 +69,17 @@ test('creating an untitled draft hides the legacy title section in the editor',
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke older notes with an H1 do not render the legacy title section', async ({ page }) => {
|
||||
test('@smoke older notes with a document title do not render the legacy title section', async ({ page }) => {
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
|
||||
await openNote(page, 'Spring 2026')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
|
||||
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user