fix: keep vault loading shell visible

This commit is contained in:
lucaronin
2026-04-29 22:52:19 +02:00
parent 2a2ea1b8ef
commit 1f4889fe66
14 changed files with 938 additions and 124 deletions

View File

@@ -517,10 +517,16 @@ describe('App', () => {
render(<App />)
await waitFor(() => {
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
})
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
expect(await screen.findByTestId('sidebar-loading-favorites', {}, { timeout: 5000 })).toBeInTheDocument()
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
expect(screen.getByTestId('sidebar-top-nav')).toHaveTextContent('Inbox')
expect(screen.getByTestId('sidebar-loading-views')).toBeInTheDocument()
expect(screen.getByTestId('sidebar-loading-types')).toBeInTheDocument()
expect(screen.getByTestId('sidebar-loading-folders')).toBeInTheDocument()
expect(screen.getByTestId('note-list-loading-skeleton')).toBeInTheDocument()
expect(screen.getByTestId('breadcrumb-title-skeleton')).toBeInTheDocument()
expect(screen.getByTestId('editor-content-skeleton')).toBeInTheDocument()
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
await act(async () => {
fireEvent.keyDown(window, { key: 'p', code: 'KeyP', metaKey: true })
@@ -535,6 +541,9 @@ describe('App', () => {
await waitFor(() => {
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
expect(screen.queryByTestId('note-list-loading-skeleton')).not.toBeInTheDocument()
expect(screen.queryByTestId('breadcrumb-title-skeleton')).not.toBeInTheDocument()
expect(screen.queryByTestId('editor-content-skeleton')).not.toBeInTheDocument()
expect(screen.queryByTestId('status-vault-reloading')).not.toBeInTheDocument()
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
})

View File

@@ -1609,17 +1609,7 @@ function App() {
)
}
// Show the full skeleton only while app-level vault capabilities are unknown.
if (!noteWindowParams && onboarding.state.status === 'ready' && gitRepoState === 'checking') {
return (
<AppLoadingSkeleton
noteListWidth={layout.noteListWidth}
showNoteList={noteListVisible}
showSidebar={sidebarVisible}
sidebarWidth={layout.sidebarWidth}
/>
)
}
const isVaultContentLoading = !noteWindowParams && onboarding.state.status === 'ready' && vault.isLoading
return (
<div className="app-shell">
@@ -1627,7 +1617,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} 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} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} 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} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} loading={isVaultContentLoading} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1638,7 +1628,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} locale={appLocale} />
) : (
<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={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1648,6 +1638,7 @@ function App() {
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
entries={vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}

View File

@@ -55,6 +55,7 @@ interface BreadcrumbBarProps {
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
loadingTitle?: boolean
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -615,6 +616,16 @@ function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<Breadcru
)
}
function BreadcrumbTitleSkeleton() {
return (
<span
aria-hidden="true"
data-testid="breadcrumb-title-skeleton"
className="h-4 w-36 animate-pulse rounded bg-muted"
/>
)
}
function BreadcrumbActions({
entry,
showDiffToggle,
@@ -664,15 +675,18 @@ function BreadcrumbActions({
function BreadcrumbTitle({
entry,
locale,
loadingTitle,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'loadingTitle' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<div className="flex min-w-0 items-center gap-1 truncate">
<FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
{loadingTitle
? <BreadcrumbTitleSkeleton />
: <FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />}
</div>
</div>
)
@@ -682,6 +696,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
locale = 'en',
loadingTitle = false,
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
@@ -703,7 +718,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
<BreadcrumbTitle
entry={entry}
locale={locale}
loadingTitle={loadingTitle}
onRenameFilename={onRenameFilename}
/>
</div>
<div
aria-hidden="true"

View File

@@ -461,6 +461,26 @@ describe('Editor', () => {
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
})
it('keeps editor chrome visible while active note content is loading', () => {
renderEditor({
tabs: [],
activeTabPath: mockEntry.path,
entries: [mockEntry],
inspectorCollapsed: false,
inspectorEntry: mockEntry,
inspectorContent: mockContent,
})
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
const skeleton = screen.getByTestId('editor-content-skeleton')
expect(skeleton.closest('.editor-content-wrapper')).not.toBeNull()
expect(skeleton.closest('.editor-scroll-area')).not.toBeNull()
})
it('hides the legacy title field for untitled draft notes', () => {
const draftEntry: VaultEntry = {
...mockEntry,

View File

@@ -41,6 +41,7 @@ interface Tab {
interface EditorProps {
tabs: Tab[]
activeTabPath: string | null
isVaultLoading?: boolean
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onUnsupportedAiPaste?: (message: string) => void
@@ -291,8 +292,10 @@ function useEditorFindCommand({
function EditorLayout({
tabs,
activeTabPath,
activeTab,
isLoadingNewTab,
isVaultLoading,
entries,
editor,
diffMode,
@@ -353,8 +356,10 @@ function EditorLayout({
locale,
}: {
tabs: Tab[]
activeTabPath: string | null
activeTab: Tab | null
isLoadingNewTab: boolean
isVaultLoading?: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
@@ -415,11 +420,12 @@ function EditorLayout({
locale?: AppLocale
}) {
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
const showEmptyState = tabs.length === 0 && activeTabPath === null && !isVaultLoading
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
{tabs.length === 0
{showEmptyState
? <EditorEmptyState locale={locale} />
: activeBinaryTab
? (
@@ -432,7 +438,9 @@ function EditorLayout({
)
: <EditorContent
activeTab={activeTab}
activeTabPath={activeTabPath}
isLoadingNewTab={isLoadingNewTab}
isVaultLoading={isVaultLoading}
entries={entries}
editor={editor}
diffMode={diffMode}
@@ -512,6 +520,7 @@ function EditorLayout({
export const Editor = memo(function Editor(props: EditorProps) {
const {
tabs, activeTabPath, entries, onNavigateWikilink,
isVaultLoading,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
@@ -527,8 +536,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
noteWidth, onToggleNoteWidth,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef,
locale,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
} = props
const {
@@ -566,8 +574,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
return (
<EditorLayout
tabs={tabs}
activeTabPath={props.activeTabPath}
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
isVaultLoading={isVaultLoading}
entries={entries}
editor={editor}
diffMode={diffMode}

View File

@@ -21,6 +21,11 @@ import {
TypesSection,
ViewsSection,
} from './sidebar/SidebarSections'
import {
SidebarCreatableLoadingSection,
SidebarFavoritesLoadingSection,
SidebarTypesLoadingSection,
} from './sidebar/SidebarLoadingSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
@@ -56,6 +61,7 @@ interface SidebarProps {
inboxCount?: number
locale?: AppLocale
onCollapse?: () => void
loading?: boolean
}
interface SidebarNavigationProps extends Pick<
@@ -82,6 +88,7 @@ interface SidebarNavigationProps extends Pick<
| 'inboxCount'
| 'onCreateNewType'
| 'locale'
| 'loading'
> {
activeCount: number
archivedCount: number
@@ -98,6 +105,248 @@ interface SidebarNavigationProps extends Pick<
toggleVisibility: (type: string) => void
}
type SidebarFavoritesNavigationProps = Pick<
SidebarNavigationProps,
| 'loading'
| 'entries'
| 'selection'
| 'onSelect'
| 'onSelectFavorite'
| 'onReorderFavorites'
| 'groupCollapsed'
| 'toggleGroup'
| 'locale'
>
type SidebarViewsNavigationProps = Pick<
SidebarNavigationProps,
| 'loading'
| 'views'
| 'selection'
| 'onSelect'
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'onReorderViews'
| 'groupCollapsed'
| 'toggleGroup'
| 'sensors'
| 'entries'
| 'locale'
>
type SidebarTypesNavigationProps = Pick<
SidebarNavigationProps,
| 'loading'
| 'visibleSections'
| 'allSectionGroups'
| 'sectionIds'
| 'sensors'
| 'handleDragEnd'
| 'sectionProps'
| 'groupCollapsed'
| 'toggleGroup'
| 'typeInteractions'
| 'isSectionVisible'
| 'toggleVisibility'
| 'onCreateNewType'
| 'locale'
>
type SidebarFoldersNavigationProps = Pick<
SidebarNavigationProps,
| 'loading'
| 'folders'
| 'selection'
| 'onSelect'
| 'onCreateFolder'
| 'onRenameFolder'
| 'onDeleteFolder'
| 'folderFileActions'
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'groupCollapsed'
| 'toggleGroup'
| 'locale'
>
function SidebarFavoritesNavigation({
loading,
entries,
selection,
onSelect,
onSelectFavorite,
onReorderFavorites,
groupCollapsed,
toggleGroup,
locale,
}: SidebarFavoritesNavigationProps) {
if (loading) {
return (
<SidebarFavoritesLoadingSection
collapsed={groupCollapsed.favorites}
locale={locale}
onToggle={() => toggleGroup('favorites')}
/>
)
}
return (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
locale={locale}
onToggle={() => toggleGroup('favorites')}
/>
</div>
)
}
function SidebarViewsNavigation({
loading,
views,
selection,
onSelect,
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
groupCollapsed,
toggleGroup,
sensors,
entries,
locale,
}: SidebarViewsNavigationProps) {
if (loading) {
return (
<SidebarCreatableLoadingSection
collapsed={groupCollapsed.views}
kind="views"
locale={locale}
onCreate={onCreateView}
onToggle={() => toggleGroup('views')}
/>
)
}
return (
<ViewsSection
views={views ?? []}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
sensors={sensors}
entries={entries}
locale={locale}
/>
)
}
function SidebarTypesNavigation({
loading,
visibleSections,
allSectionGroups,
sectionIds,
sensors,
handleDragEnd,
sectionProps,
groupCollapsed,
toggleGroup,
typeInteractions,
isSectionVisible,
toggleVisibility,
onCreateNewType,
locale,
}: SidebarTypesNavigationProps) {
if (loading) {
return (
<SidebarTypesLoadingSection
collapsed={groupCollapsed.sections}
locale={locale}
onCreateNewType={onCreateNewType}
onToggle={() => toggleGroup('sections')}
/>
)
}
return (
<TypesSection
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
locale={locale}
/>
)
}
function SidebarFoldersNavigation({
loading,
folders,
selection,
onSelect,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
groupCollapsed,
toggleGroup,
locale,
}: SidebarFoldersNavigationProps) {
if (loading) {
return (
<SidebarCreatableLoadingSection
collapsed={groupCollapsed.folders}
kind="folders"
locale={locale}
onToggle={() => toggleGroup('folders')}
/>
)
}
return (
<FolderTree
folders={folders ?? []}
selection={selection}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
locale={locale}
onToggle={() => toggleGroup('folders')}
/>
)
}
function SidebarNavigation({
entries,
selection,
@@ -120,6 +369,7 @@ function SidebarNavigation({
showInbox = true,
inboxCount = 0,
locale = 'en',
loading = false,
onCreateNewType,
activeCount,
archivedCount,
@@ -135,8 +385,8 @@ function SidebarNavigation({
isSectionVisible,
toggleVisibility,
}: SidebarNavigationProps) {
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = views.length > 0 || !!onCreateView
const hasFavorites = loading || entries.some((entry) => entry.favorite && !entry.archived)
const hasViews = loading || views.length > 0 || !!onCreateView
return (
<nav className="flex-1 overflow-y-auto">
@@ -148,55 +398,56 @@ function SidebarNavigation({
activeCount={activeCount}
archivedCount={archivedCount}
locale={locale}
loading={loading}
/>
{hasFavorites && (
<div className="border-b border-border">
<FavoritesSection
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectNote={onSelectFavorite}
onReorder={onReorderFavorites}
collapsed={groupCollapsed.favorites}
locale={locale}
onToggle={() => toggleGroup('favorites')}
/>
</div>
<SidebarFavoritesNavigation
loading={loading}
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
locale={locale}
/>
)}
{hasViews && (
<ViewsSection
<SidebarViewsNavigation
loading={loading}
views={views}
selection={selection}
onSelect={onSelect}
collapsed={groupCollapsed.views}
onToggle={() => toggleGroup('views')}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
sensors={sensors}
entries={entries}
locale={locale}
/>
)}
<TypesSection
<SidebarTypesNavigation
loading={loading}
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
collapsed={groupCollapsed.sections}
onToggle={() => toggleGroup('sections')}
showCustomize={typeInteractions.showCustomize}
setShowCustomize={typeInteractions.setShowCustomize}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
typeInteractions={typeInteractions}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
locale={locale}
/>
<FolderTree
<SidebarFoldersNavigation
loading={loading}
folders={folders}
selection={selection}
onSelect={onSelect}
@@ -207,9 +458,9 @@ function SidebarNavigation({
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
collapsed={groupCollapsed.folders}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
locale={locale}
onToggle={() => toggleGroup('folders')}
/>
</nav>
)
@@ -251,6 +502,7 @@ export const Sidebar = memo(function Sidebar({
locale = 'en',
onCollapse,
onCreateNewType,
loading = false,
}: SidebarProps) {
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const { activeCount, archivedCount } = useEntryCounts(entries)
@@ -310,6 +562,7 @@ export const Sidebar = memo(function Sidebar({
showInbox={showInbox}
inboxCount={inboxCount}
locale={locale}
loading={loading}
onCreateNewType={onCreateNewType}
activeCount={activeCount}
archivedCount={archivedCount}

View File

@@ -118,21 +118,35 @@ export function SidebarCountPill({
)
}
export function SidebarLoadingCountPill({ compact, testId = 'sidebar-count-skeleton' }: { compact?: boolean; testId?: string }) {
return (
<span
aria-hidden="true"
data-testid={testId}
className="inline-flex animate-pulse rounded-full bg-muted"
style={{ width: compact ? 22 : 28, height: compact ? 18 : 20 }}
/>
)
}
function NavItemLabel({ label, compact }: { label: string; compact?: boolean }) {
return <span className={cn("flex-1 font-medium", getNavItemTextClass(compact))}>{label}</span>
}
function NavItemCount({
count,
countLoading,
className,
style,
compact,
}: {
count?: number
countLoading?: boolean
className?: string
style?: React.CSSProperties
compact?: boolean
}) {
if (countLoading) return <SidebarLoadingCountPill compact={compact} />
if (!hasSidebarCount(count)) return null
return (
<SidebarCountPill
@@ -172,6 +186,7 @@ function ClickableNavItem({
emoji,
label,
count,
countLoading,
isActive,
activeClassName,
badgeClassName,
@@ -186,6 +201,7 @@ function ClickableNavItem({
emoji?: string | null
label: string
count?: number
countLoading?: boolean
isActive?: boolean
activeClassName: string
badgeClassName?: string
@@ -206,6 +222,7 @@ function ClickableNavItem({
<NavItemLabel label={label} compact={compact} />
<NavItemCount
count={count}
countLoading={countLoading}
className={resolveBadgeClassName(isActive, activeBadgeClassName, badgeClassName)}
style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)}
compact={compact}
@@ -214,11 +231,12 @@ function ClickableNavItem({
)
}
export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
export function NavItem({ icon: Icon, emoji, label, count, countLoading, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
icon: ComponentType<IconProps>
emoji?: string | null
label: string
count?: number
countLoading?: boolean
isActive?: boolean
activeClassName?: string
badgeClassName?: string
@@ -230,7 +248,7 @@ export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClass
disabledTooltip?: string
compact?: boolean
}) {
const padding = getNavItemPadding(compact, hasSidebarCount(count))
const padding = getNavItemPadding(compact, countLoading || hasSidebarCount(count))
if (disabled) {
return (
<DisabledNavItem
@@ -250,6 +268,7 @@ export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClass
emoji={emoji}
label={label}
count={count}
countLoading={countLoading}
isActive={isActive}
activeClassName={activeClassName}
badgeClassName={badgeClassName}

View File

@@ -2,6 +2,7 @@ import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../../lib/i18n'
import type { VaultEntry } from '../../types'
import { dispatchEditorFindAvailability } from '../../utils/editorFindEvents'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
@@ -38,9 +39,43 @@ type BreadcrumbActions = Pick<
| 'onToggleNoteWidth'
>
const LOADING_BREADCRUMB_ENTRY: VaultEntry = {
path: '',
filename: 'loading.md',
title: '',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: false,
fileKind: 'markdown',
}
function EditorLoadingSkeleton() {
return (
<div className="flex flex-1 flex-col gap-3 p-8 animate-pulse" style={{ minHeight: 0 }}>
<div data-testid="editor-content-skeleton" className="flex flex-1 flex-col gap-3 py-5 animate-pulse" style={{ minHeight: 0 }}>
<div className="h-6 w-2/5 rounded bg-muted" />
<div className="h-4 w-4/5 rounded bg-muted" />
<div className="h-4 w-3/5 rounded bg-muted" />
@@ -50,6 +85,16 @@ function EditorLoadingSkeleton() {
)
}
function EditorLoadingCanvas({ cssVars }: Pick<EditorContentModel, 'cssVars'>) {
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<EditorLoadingSkeleton />
</div>
</div>
)
}
function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffContent: string | null; locale?: AppLocale; onToggleDiff: () => void }) {
const label = translate(locale, 'editor.toolbar.rawReturn')
@@ -117,6 +162,7 @@ function ActiveTabBreadcrumb({
path,
actions,
locale,
loadingTitle,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
@@ -124,12 +170,14 @@ function ActiveTabBreadcrumb({
path: string
actions: BreadcrumbActions
locale?: AppLocale
loadingTitle?: boolean
}) {
return (
<BreadcrumbBar
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
loadingTitle={loadingTitle}
showDiffToggle={actions.showDiffToggle}
diffMode={actions.diffMode}
diffLoading={actions.diffLoading}
@@ -156,6 +204,106 @@ function ActiveTabBreadcrumb({
)
}
function EditorLoadingBreadcrumb({
actions,
barRef,
locale,
}: {
actions: BreadcrumbActions
barRef: React.RefObject<HTMLDivElement | null>
locale?: AppLocale
}) {
return (
<BreadcrumbBar
entry={LOADING_BREADCRUMB_ENTRY}
wordCount={0}
barRef={barRef}
loadingTitle
showDiffToggle={false}
diffMode={false}
diffLoading={false}
onToggleDiff={actions.onToggleDiff}
rawMode={false}
forceRawMode={false}
showAIChat={actions.showAIChat}
onToggleAIChat={actions.onToggleAIChat}
inspectorCollapsed={actions.inspectorCollapsed}
onToggleInspector={actions.onToggleInspector}
noteWidth={actions.noteWidth}
onToggleNoteWidth={actions.onToggleNoteWidth}
locale={locale}
/>
)
}
function buildBreadcrumbActions(model: EditorContentModel): BreadcrumbActions {
return {
diffMode: model.diffMode,
diffLoading: model.diffLoading,
onToggleDiff: model.onToggleDiff,
effectiveRawMode: model.effectiveRawMode,
onToggleRaw: model.onToggleRaw,
forceRawMode: model.forceRawMode,
showAIChat: model.showAIChat,
onToggleAIChat: model.onToggleAIChat,
inspectorCollapsed: model.inspectorCollapsed,
onToggleInspector: model.onToggleInspector,
showDiffToggle: model.showDiffToggle,
onToggleFavorite: model.onToggleFavorite,
onToggleOrganized: model.onToggleOrganized,
onRevealFile: model.onRevealFile,
onCopyFilePath: model.onCopyFilePath,
onDeleteNote: model.onDeleteNote,
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
noteWidth: model.noteWidth,
onToggleNoteWidth: model.onToggleNoteWidth,
}
}
function EditorBreadcrumbArea({
actions,
barRef,
chromePath,
chromeTab,
chromeWordCount,
isVaultLoading,
locale,
}: {
actions: BreadcrumbActions
barRef: React.RefObject<HTMLDivElement | null>
chromePath: string
chromeTab: EditorContentModel['activeTab'] | EditorContentModel['loadingTab']
chromeWordCount: number
isVaultLoading?: boolean
locale?: AppLocale
}) {
if (chromeTab) {
return (
<ActiveTabBreadcrumb
activeTab={chromeTab}
barRef={barRef}
wordCount={chromeWordCount}
path={chromePath}
locale={locale}
loadingTitle={isVaultLoading}
actions={actions}
/>
)
}
if (!isVaultLoading) return null
return (
<EditorLoadingBreadcrumb
actions={actions}
barRef={barRef}
locale={locale}
/>
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
@@ -262,6 +410,7 @@ function EditorFindScope({
export function EditorContentLayout(model: EditorContentModel) {
const {
activeTab,
loadingTab,
isLoadingNewTab,
entries,
editor,
@@ -290,87 +439,69 @@ export function EditorContentLayout(model: EditorContentModel) {
noteWidth,
findRequest,
locale,
isVaultLoading,
} = model
const rootClassName = cn(
'flex flex-1 flex-col min-w-0 min-h-0',
noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal',
)
if (!activeTab) {
return (
<div className={rootClassName}>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}
const chromeTab = activeTab ?? loadingTab
const chromePath = chromeTab?.entry.path ?? path
const chromeWordCount = activeTab ? wordCount : 0
const showActiveContent = activeTab && !isVaultLoading
const showLoadingContent = isVaultLoading || (isLoadingNewTab && showEditor)
const breadcrumbActions = buildBreadcrumbActions(model)
return (
<div className={rootClassName}>
<ActiveTabBreadcrumb
activeTab={activeTab}
<EditorBreadcrumbArea
actions={breadcrumbActions}
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
locale={locale}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
onToggleDiff: model.onToggleDiff,
effectiveRawMode: model.effectiveRawMode,
onToggleRaw: model.onToggleRaw,
forceRawMode: model.forceRawMode,
showAIChat: model.showAIChat,
onToggleAIChat: model.onToggleAIChat,
inspectorCollapsed: model.inspectorCollapsed,
onToggleInspector: model.onToggleInspector,
showDiffToggle: model.showDiffToggle,
onToggleFavorite: model.onToggleFavorite,
onToggleOrganized: model.onToggleOrganized,
onRevealFile: model.onRevealFile,
onCopyFilePath: model.onCopyFilePath,
onDeleteNote: model.onDeleteNote,
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
noteWidth: model.noteWidth,
onToggleNoteWidth: model.onToggleNoteWidth,
}}
/>
<EditorChrome
isArchived={isArchived}
onUnarchiveNote={onUnarchiveNote}
path={path}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
chromePath={chromePath}
chromeTab={chromeTab}
chromeWordCount={chromeWordCount}
isVaultLoading={isVaultLoading}
locale={locale}
/>
<RawModeEditorSection
activeTab={activeTab}
entries={entries}
findRequest={findRequest}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
vaultPath={vaultPath}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={onEditorChange}
isDeletedPreview={isDeletedPreview}
/>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
{showActiveContent && (
<>
<EditorChrome
isArchived={isArchived}
onUnarchiveNote={onUnarchiveNote}
path={path}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
diffMode={diffMode}
diffContent={diffContent}
onToggleDiff={onToggleDiff}
locale={locale}
/>
<RawModeEditorSection
activeTab={activeTab}
entries={entries}
findRequest={findRequest}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
vaultPath={vaultPath}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onEditorChange={onEditorChange}
isDeletedPreview={isDeletedPreview}
/>
</>
)}
{showLoadingContent && <EditorLoadingCanvas cssVars={cssVars} />}
</div>
)
}

View File

@@ -14,7 +14,9 @@ export interface Tab {
export interface EditorContentProps {
activeTab: Tab | null
activeTabPath: string | null
isLoadingNewTab: boolean
isVaultLoading?: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
@@ -56,6 +58,7 @@ export interface EditorContentProps {
export function useEditorContentModel(props: EditorContentProps) {
const {
activeTab,
activeTabPath,
entries,
rawMode,
diffMode,
@@ -77,6 +80,10 @@ export function useEditorContentModel(props: EditorContentProps) {
activeStatus: props.activeStatus,
})
const showEditor = !diffMode && showContentEditor
const loadingEntry = !activeTab && activeTabPath
? entries.find((entry) => entry.path === activeTabPath) ?? null
: null
const loadingTab = loadingEntry ? { entry: loadingEntry, content: '' } : null
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
@@ -88,6 +95,7 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
forceRawMode: isNonMarkdownText || isDeletedPreview,
showEditor,
loadingTab,
path,
breadcrumbBarRef,
wordCount,

View File

@@ -8,6 +8,57 @@ type NoteListLayoutProps = ReturnType<typeof useNoteListModel> & {
handleBulkOrganize?: () => void
}
const NOTE_LIST_LOADING_ROWS = [
{ title: 184, line: 254, selected: false },
{ title: 142, line: 220, selected: true },
{ title: 98, line: 242, selected: false },
{ title: 212, line: 198, selected: false },
]
function NoteListLoadingBar({ width }: { width: number }) {
return <span aria-hidden="true" className="block h-4 rounded bg-muted" style={{ width }} />
}
function NoteListLoadingRow({
title,
line,
selected,
}: {
title: number
line: number
selected: boolean
}) {
return (
<div
className="border-b border-border"
style={{ padding: '12px 12px 10px', background: selected ? 'var(--accent-green-light)' : undefined }}
>
<div className="mb-3 flex items-start justify-between gap-3">
<NoteListLoadingBar width={title} />
<span aria-hidden="true" className="h-4 w-4 shrink-0 rounded bg-muted" />
</div>
<div className="flex flex-col gap-2">
<NoteListLoadingBar width={line} />
<NoteListLoadingBar width={Math.round(line * 0.72)} />
</div>
<div className="mt-3 flex items-center justify-between">
<NoteListLoadingBar width={44} />
<NoteListLoadingBar width={82} />
</div>
</div>
)
}
function NoteListLoadingSkeleton() {
return (
<div data-testid="note-list-loading-skeleton" className="animate-pulse">
{NOTE_LIST_LOADING_ROWS.map((row, index) => (
<NoteListLoadingRow key={index} {...row} />
))}
</div>
)
}
function MultiSelectBar({
multiSelect,
isArchivedView,
@@ -47,6 +98,7 @@ function NoteListContent({
searched,
noteListVirtuosoRef,
locale,
loading,
}: Pick<
NoteListLayoutProps,
| 'entitySelection'
@@ -64,10 +116,13 @@ function NoteListContent({
| 'searched'
| 'noteListVirtuosoRef'
| 'locale'
| 'loading'
>) {
return (
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
{loading ? (
<NoteListLoadingSkeleton />
) : entitySelection ? (
<EntityView
entity={entitySelection.entry}
groups={searchedGroups}
@@ -121,6 +176,7 @@ function NoteListBody({
noteListFilter,
filterCounts,
onNoteListFilterChange,
loading,
}: Pick<
NoteListLayoutProps,
| 'handleListKeyDown'
@@ -147,6 +203,7 @@ function NoteListBody({
| 'noteListFilter'
| 'filterCounts'
| 'onNoteListFilterChange'
| 'loading'
>) {
return (
<div
@@ -176,6 +233,7 @@ function NoteListBody({
searched={searched}
noteListVirtuosoRef={noteListVirtuosoRef}
locale={locale}
loading={loading}
/>
{showFilterPills && (
<FilterPills

View File

@@ -437,6 +437,7 @@ export interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
selectedNote: VaultEntry | null
loading?: boolean
noteListFilter: NoteListFilter
onNoteListFilterChange: (filter: NoteListFilter) => void
inboxPeriod?: InboxPeriod
@@ -471,6 +472,7 @@ function buildNoteListLayoutModel(params: {
selection: SidebarSelection
views?: ViewFile[]
sidebarCollapsed?: boolean
loading: boolean
modifiedFilesError?: string | null
noteListFilter: NoteListFilter
filterCounts: ReturnType<typeof useFilterCounts>
@@ -487,6 +489,7 @@ function buildNoteListLayoutModel(params: {
}) {
return {
title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views, params.locale),
loading: params.loading,
locale: params.locale,
typeDocument: params.content.typeDocument,
isEntityView: params.content.isEntityView,
@@ -545,6 +548,7 @@ export function useNoteListModel({
entries,
selection,
selectedNote,
loading = false,
noteListFilter,
onNoteListFilterChange,
inboxPeriod = 'all',
@@ -663,6 +667,7 @@ export function useNoteListModel({
selection,
views,
sidebarCollapsed,
loading,
onOpenType: onReplaceActiveTab,
modifiedFilesError,
noteListFilter,

View File

@@ -0,0 +1,277 @@
import type { MouseEvent, ReactNode } from 'react'
import { Folder, Funnel, Plus } from '@phosphor-icons/react'
import { SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
import { SidebarGroupHeader } from './SidebarGroupHeader'
interface SidebarLoadingSectionsProps {
collapsed: boolean
locale?: AppLocale
onToggle: () => void
}
interface SidebarLoadingActionProps {
label: string
onClick?: () => void
testId?: string
children: ReactNode
}
interface SidebarLoadingRowProps {
icon?: ReactNode
iconColor?: string
labelWidth: number
showCount?: boolean
}
interface SidebarLoadingSectionProps extends SidebarLoadingSectionsProps {
label: string
rows: SidebarLoadingRowProps[]
testId: string
children?: ReactNode
}
interface CreatableLoadingSectionProps extends SidebarLoadingSectionsProps {
actionLabel: string
actionTestId?: string
label: string
onCreate?: () => void
rows: SidebarLoadingRowProps[]
testId: string
}
interface CreatableLoadingSectionConfig {
actionLabelKey: Parameters<typeof translate>[1]
actionTestId?: string
labelKey: Parameters<typeof translate>[1]
rows: SidebarLoadingRowProps[]
testId: string
}
const FAVORITE_ROWS = [
{ iconColor: 'var(--accent-yellow)', labelWidth: 132 },
{ iconColor: 'var(--accent-red)', labelWidth: 118 },
]
const VIEW_ROWS = [
{ icon: <Funnel size={16} />, labelWidth: 118 },
{ icon: <Funnel size={16} />, labelWidth: 146, showCount: true },
]
const TYPE_ROWS = [
{ iconColor: 'var(--accent-red)', labelWidth: 72, showCount: true },
{ iconColor: 'var(--accent-orange)', labelWidth: 92, showCount: true },
{ iconColor: 'var(--accent-purple)', labelWidth: 104, showCount: true },
{ iconColor: 'var(--accent-blue)', labelWidth: 126, showCount: true },
{ iconColor: 'var(--accent-green)', labelWidth: 112, showCount: true },
{ iconColor: 'var(--accent-yellow)', labelWidth: 96, showCount: true },
]
const FOLDER_ROWS = [
{ icon: <Folder size={16} />, labelWidth: 118 },
{ icon: <Folder size={16} />, labelWidth: 92 },
]
type CreatableLoadingSectionKind = 'views' | 'folders'
const CREATABLE_LOADING_SECTIONS: Record<CreatableLoadingSectionKind, CreatableLoadingSectionConfig> = {
views: {
actionLabelKey: 'sidebar.action.createView',
labelKey: 'sidebar.group.views',
rows: VIEW_ROWS,
testId: 'sidebar-loading-views',
},
folders: {
actionLabelKey: 'sidebar.action.createFolder',
actionTestId: 'create-folder-btn',
labelKey: 'sidebar.group.folders',
rows: FOLDER_ROWS,
testId: 'sidebar-loading-folders',
},
}
interface ConfiguredCreatableLoadingSectionProps extends SidebarLoadingSectionsProps {
kind: CreatableLoadingSectionKind
onCreate?: () => void
}
function SidebarLoadingAction({
label,
onClick,
testId,
children,
}: SidebarLoadingActionProps) {
const handleClick = onClick
? (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
onClick()
}
: undefined
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid={testId}
title={label}
aria-label={label}
aria-disabled={onClick ? undefined : true}
onClick={handleClick}
>
{children}
</Button>
)
}
function SidebarLoadingIcon({ icon, iconColor }: Pick<SidebarLoadingRowProps, 'icon' | 'iconColor'>) {
if (icon) return <span className="shrink-0 text-muted-foreground">{icon}</span>
return (
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 rounded-sm"
style={{ background: iconColor ?? 'var(--muted)' }}
/>
)
}
function SidebarLoadingBar({ width }: { width: number }) {
return (
<span
aria-hidden="true"
className="h-3.5 rounded bg-muted"
style={{ width }}
/>
)
}
function SidebarLoadingRow({
icon,
iconColor,
labelWidth,
showCount,
}: SidebarLoadingRowProps) {
return (
<div
className="flex select-none items-center gap-2 rounded"
style={{ padding: '6px 8px 6px 16px', borderRadius: 4 }}
>
<SidebarLoadingIcon icon={icon} iconColor={iconColor} />
<div className="flex min-w-0 flex-1 items-center">
<SidebarLoadingBar width={labelWidth} />
</div>
{showCount && <span aria-hidden="true" className="h-5 w-7 rounded-full bg-muted" />}
</div>
)
}
function SidebarLoadingSection({
label,
collapsed,
onToggle,
rows,
testId,
children,
}: SidebarLoadingSectionProps) {
return (
<div className="border-b border-border" data-testid={testId} style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={label} collapsed={collapsed} onToggle={onToggle}>
{children}
</SidebarGroupHeader>
{!collapsed && (
<div className="flex flex-col gap-0.5 pb-2 animate-pulse" aria-hidden="true">
{rows.map((row, index) => (
<SidebarLoadingRow key={`${testId}-${index}`} {...row} />
))}
</div>
)}
</div>
)
}
export function SidebarFavoritesLoadingSection(props: SidebarLoadingSectionsProps) {
return (
<SidebarLoadingSection
{...props}
label={translate(props.locale ?? 'en', 'sidebar.group.favorites')}
rows={FAVORITE_ROWS}
testId="sidebar-loading-favorites"
/>
)
}
export function SidebarCreatableLoadingSection({
kind,
onCreate,
...props
}: ConfiguredCreatableLoadingSectionProps) {
const config = CREATABLE_LOADING_SECTIONS[kind]
const locale = props.locale ?? 'en'
return (
<CreatableLoadingSection
{...props}
actionLabel={translate(locale, config.actionLabelKey)}
actionTestId={config.actionTestId}
label={translate(locale, config.labelKey)}
onCreate={onCreate}
rows={config.rows}
testId={config.testId}
/>
)
}
function CreatableLoadingSection({
actionLabel,
actionTestId,
label,
onCreate,
rows,
testId,
...props
}: CreatableLoadingSectionProps) {
return (
<SidebarLoadingSection
{...props}
label={label}
rows={rows}
testId={testId}
>
{onCreate && (
<SidebarLoadingAction label={actionLabel} onClick={onCreate} testId={actionTestId}>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</SidebarLoadingAction>
)}
</SidebarLoadingSection>
)
}
export function SidebarTypesLoadingSection({
onCreateNewType,
...props
}: SidebarLoadingSectionsProps & {
onCreateNewType?: () => void
}) {
const locale = props.locale ?? 'en'
return (
<SidebarLoadingSection
{...props}
label={translate(locale, 'sidebar.group.types')}
rows={TYPE_ROWS}
testId="sidebar-loading-types"
>
<div className="flex items-center gap-1.5">
<SidebarLoadingAction label={translate(locale, 'sidebar.action.customizeSections')}>
<SlidersHorizontal size={12} className="text-muted-foreground" />
</SidebarLoadingAction>
{onCreateNewType && (
<SidebarLoadingAction label={translate(locale, 'sidebar.action.createType')} onClick={onCreateNewType} testId="create-type-btn">
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</SidebarLoadingAction>
)}
</div>
</SidebarLoadingSection>
)
}

View File

@@ -11,6 +11,7 @@ interface SidebarTopNavProps {
activeCount: number
archivedCount: number
locale?: AppLocale
loading?: boolean
}
export function SidebarTopNav({
@@ -21,6 +22,7 @@ export function SidebarTopNav({
activeCount,
archivedCount,
locale = 'en',
loading = false,
}: SidebarTopNavProps) {
return (
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
@@ -29,6 +31,7 @@ export function SidebarTopNav({
icon={Tray}
label={translate(locale, 'sidebar.nav.inbox')}
count={inboxCount}
countLoading={loading}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}
@@ -40,6 +43,7 @@ export function SidebarTopNav({
icon={FileText}
label={translate(locale, 'sidebar.nav.allNotes')}
count={activeCount}
countLoading={loading}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}
@@ -50,6 +54,7 @@ export function SidebarTopNav({
icon={Archive}
label={translate(locale, 'sidebar.nav.archive')}
count={archivedCount}
countLoading={loading}
isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })}
badgeClassName="text-muted-foreground"
badgeStyle={{ background: 'var(--muted)' }}

View File

@@ -99,8 +99,16 @@ async function installSlowVaultMock(page: Page): Promise<void> {
}
async function expectResponsiveShellWhileVaultLoads(page: Page): Promise<void> {
await expect(page.getByTestId('sidebar-loading-favorites')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('vault-loading-skeleton')).not.toBeVisible()
await expect(page.getByText('Select a note to start editing')).toBeVisible()
await expect(page.getByTestId('sidebar-top-nav')).toContainText('Inbox')
await expect(page.getByTestId('sidebar-loading-views')).toBeVisible()
await expect(page.getByTestId('sidebar-loading-types')).toBeVisible()
await expect(page.getByTestId('sidebar-loading-folders')).toBeVisible()
await expect(page.getByTestId('note-list-loading-skeleton')).toBeVisible()
await expect(page.getByTestId('breadcrumb-title-skeleton')).toBeVisible()
await expect(page.getByTestId('editor-content-skeleton')).toBeVisible()
await expect(page.getByText('Select a note to start editing')).not.toBeVisible()
await expect(page.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
await sendShortcut(page, 'p', ['Control'])