feat: add action tooltips to note controls
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -49,6 +49,19 @@ const defaultProps = {
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
|
||||
act(() => {
|
||||
fireEvent.focus(trigger)
|
||||
})
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
for (const part of parts) {
|
||||
expect(tooltip).toHaveTextContent(part)
|
||||
}
|
||||
act(() => {
|
||||
fireEvent.blur(trigger)
|
||||
})
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar — drag region', () => {
|
||||
it('has data-tauri-drag-region on the container', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
@@ -67,13 +80,13 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
it('shows delete button', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
|
||||
expect(screen.getByTitle('Delete (Cmd+Delete)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByTitle('Delete (Cmd+Delete)'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete this note' }))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -81,40 +94,40 @@ 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')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Archive this note' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Restore this archived note' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Restore this archived note' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Archive this note' })).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'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Archive this note' }))
|
||||
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'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore this archived note' }))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', async () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Set note as organized' }), 'Set note as organized', '⌘E')
|
||||
})
|
||||
|
||||
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()
|
||||
expect(screen.queryByRole('button', { name: 'Set note as organized' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,32 +251,32 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Back to editor" tooltip when rawMode is on', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Return to the editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRaw when raw button is clicked', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open the raw editor' }))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
|
||||
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open the raw editor' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Return to the editor' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
@@ -96,46 +97,44 @@ function handleFilenameInputKeyDown(
|
||||
}
|
||||
|
||||
function IconActionButton({
|
||||
title,
|
||||
copy,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
tabIndex,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
title: string
|
||||
copy: ActionTooltipCopy
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
tabIndex?: number
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<ActionTooltip copy={copy} side="bottom">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
aria-label={copy.label}
|
||||
aria-disabled={onClick ? undefined : true}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
const copy: ActionTooltipCopy = { label: rawMode ? 'Return to the editor' : 'Open the raw editor' }
|
||||
return (
|
||||
<IconActionButton
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
copy={copy}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -145,9 +144,13 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: favorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
shortcut: '⌘D',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
copy={copy}
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -164,9 +167,13 @@ function OrganizedAction({
|
||||
onToggleOrganized?: () => void
|
||||
}) {
|
||||
if (!onToggleOrganized) return null
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: organized ? 'Set note as not organized' : 'Set note as organized',
|
||||
shortcut: '⌘E',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
copy={copy}
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -177,7 +184,7 @@ function OrganizedAction({
|
||||
|
||||
function SearchAction() {
|
||||
return (
|
||||
<IconActionButton title="Search in file" className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Search within this note' }} className="hover:text-foreground">
|
||||
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -191,17 +198,19 @@ function DiffAction({
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const copy: ActionTooltipCopy = diffLoading
|
||||
? { label: 'Loading the diff' }
|
||||
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
|
||||
return (
|
||||
<IconActionButton
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
copy={copy}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
@@ -209,18 +218,22 @@ function DiffAction({
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
|
||||
function PlaceholderAction({ copy, children }: { copy: ActionTooltipCopy; children: ReactNode }) {
|
||||
return (
|
||||
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<IconActionButton copy={copy} style={DISABLED_ICON_STYLE}>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
|
||||
shortcut: '⇧⌘L',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
copy={copy}
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -236,14 +249,14 @@ function ArchiveAction({
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -251,7 +264,7 @@ function ArchiveAction({
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
|
||||
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -263,7 +276,7 @@ function InspectorAction({
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Open the properties panel', shortcut: '⌘⇧I' }} onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -351,26 +364,19 @@ function SyncFilenameButton({
|
||||
}) {
|
||||
if (!syncStem || !onRenameFilename) return null
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename file to match title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Rename file to match title
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename the file to match the title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -480,14 +486,14 @@ function BreadcrumbActions({
|
||||
onToggleDiff={onToggleDiff}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<PlaceholderAction copy={{ label: 'Backlinks are coming soon' }}>
|
||||
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<PlaceholderAction copy={{ label: 'More note actions are coming soon' }}>
|
||||
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
</div>
|
||||
@@ -517,27 +523,29 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
data-title-hidden=""
|
||||
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 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
data-title-hidden=""
|
||||
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 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('Editor', () => {
|
||||
activeTabPath: mockEntry.path,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Search within this note' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the legacy title field for untitled draft notes', () => {
|
||||
@@ -248,7 +248,7 @@ describe('Editor', () => {
|
||||
onLoadDiff={async () => '+ added line'}
|
||||
/>
|
||||
)
|
||||
const diffBtn = screen.getByTitle('Show diff')
|
||||
const diffBtn = screen.getByRole('button', { name: 'Show the current diff' })
|
||||
expect(diffBtn).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,19 @@ const installedAiAgentsStatus = {
|
||||
codex: { status: 'installed' as const, version: '0.37.0' },
|
||||
}
|
||||
|
||||
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
|
||||
act(() => {
|
||||
fireEvent.focus(trigger)
|
||||
})
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
for (const part of parts) {
|
||||
expect(tooltip).toHaveTextContent(part)
|
||||
}
|
||||
act(() => {
|
||||
fireEvent.blur(trigger)
|
||||
})
|
||||
}
|
||||
|
||||
describe('StatusBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -46,9 +59,9 @@ describe('StatusBar', () => {
|
||||
expect(onCheckForUpdates).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('build number has "Check for updates" title', () => {
|
||||
it('build number shows the update tooltip on focus', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={vi.fn()} />)
|
||||
expect(screen.getByTitle('Check for updates')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Check for updates' }), 'Check for updates')
|
||||
})
|
||||
|
||||
it('does not display branch name', () => {
|
||||
@@ -83,7 +96,7 @@ describe('StatusBar', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
// Click the vault button to open menu
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
})
|
||||
@@ -92,7 +105,7 @@ describe('StatusBar', () => {
|
||||
const onSwitchVault = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={onSwitchVault} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
// Click "Work Vault"
|
||||
fireEvent.click(screen.getByText('Work Vault'))
|
||||
|
||||
@@ -102,7 +115,7 @@ describe('StatusBar', () => {
|
||||
it('closes vault menu when clicking outside', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
|
||||
// Click outside the menu
|
||||
@@ -114,7 +127,7 @@ describe('StatusBar', () => {
|
||||
it('toggles vault menu open and closed', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
const vaultButton = screen.getByTitle('Switch vault')
|
||||
const vaultButton = screen.getByRole('button', { name: 'Switch vault' })
|
||||
fireEvent.click(vaultButton)
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
|
||||
@@ -127,7 +140,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -136,7 +149,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={onOpenLocalFolder} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Open local folder'))
|
||||
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -152,7 +165,7 @@ describe('StatusBar', () => {
|
||||
onCloneVault={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
|
||||
})
|
||||
@@ -168,7 +181,7 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Clone Getting Started Vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -184,7 +197,7 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Clone Getting Started Vault'))
|
||||
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -210,7 +223,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Open local folder'))
|
||||
// Menu should close after clicking an action
|
||||
expect(screen.queryByText('Open local folder')).not.toBeInTheDocument()
|
||||
@@ -225,27 +238,27 @@ describe('StatusBar', () => {
|
||||
expect(onClickPending).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pending count has title for accessibility', () => {
|
||||
it('pending changes tooltip is available on keyboard focus', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onClickPending={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'View pending changes' }), 'View pending changes')
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is not_installed', () => {
|
||||
it('shows MCP warning badge when status is not_installed', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
|
||||
)
|
||||
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('MCP server not installed — click to install')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'MCP server not installed — click to install' }), 'MCP server not installed — click to install')
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is no_claude_cli', () => {
|
||||
it('shows MCP warning badge when status is no_claude_cli', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
|
||||
)
|
||||
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Claude CLI not found — install it first')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude CLI not found — install it first' }), 'Claude CLI not found — install it first')
|
||||
})
|
||||
|
||||
it('hides MCP badge when status is installed', () => {
|
||||
@@ -378,7 +391,7 @@ describe('StatusBar', () => {
|
||||
expect(onCommitPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
|
||||
it('uses a local-only tooltip for the commit button when no remote is configured', async () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
@@ -390,7 +403,7 @@ describe('StatusBar', () => {
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Commit changes locally' }), 'Commit changes locally')
|
||||
})
|
||||
|
||||
it('shows Commit button even when no modified files', () => {
|
||||
@@ -403,20 +416,20 @@ describe('StatusBar', () => {
|
||||
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Claude Code badge when installed', () => {
|
||||
it('shows Claude Code badge when installed', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="installed" claudeCodeVersion="1.0.20" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
||||
expect(badge.getAttribute('title')).toBe('Claude Code 1.0.20')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code 1.0.20' }), 'Claude Code 1.0.20')
|
||||
})
|
||||
|
||||
it('shows Claude Code missing badge with warning when missing', () => {
|
||||
it('shows Claude Code missing badge with warning when missing', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
|
||||
expect(badge.getAttribute('title')).toBe('Claude Code not found — click to install')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code not found — click to install' }), 'Claude Code not found — click to install')
|
||||
})
|
||||
|
||||
it('opens install URL when clicking missing Claude Code badge', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, SyncStatus } from '../types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
StatusBarPrimarySection,
|
||||
StatusBarSecondarySection,
|
||||
@@ -97,62 +98,64 @@ export function StatusBar({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
height: 30,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 8px',
|
||||
fontSize: 11,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimarySection
|
||||
modifiedCount={modifiedCount}
|
||||
vaultPath={vaultPath}
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onClickPending={onClickPending}
|
||||
onClickPulse={onClickPulse}
|
||||
onCommitPush={onCommitPush}
|
||||
isOffline={isOffline}
|
||||
isGitVault={isGitVault}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
conflictCount={conflictCount}
|
||||
remoteStatus={remoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
buildNumber={buildNumber}
|
||||
onCheckForUpdates={onCheckForUpdates}
|
||||
onRemoveVault={onRemoveVault}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
/>
|
||||
<StatusBarSecondarySection
|
||||
noteCount={noteCount}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomReset={onZoomReset}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</footer>
|
||||
<TooltipProvider>
|
||||
<footer
|
||||
style={{
|
||||
height: 30,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 8px',
|
||||
fontSize: 11,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimarySection
|
||||
modifiedCount={modifiedCount}
|
||||
vaultPath={vaultPath}
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onClickPending={onClickPending}
|
||||
onClickPulse={onClickPulse}
|
||||
onCommitPush={onCommitPush}
|
||||
isOffline={isOffline}
|
||||
isGitVault={isGitVault}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
conflictCount={conflictCount}
|
||||
remoteStatus={remoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
buildNumber={buildNumber}
|
||||
onCheckForUpdates={onCheckForUpdates}
|
||||
onRemoveVault={onRemoveVault}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
/>
|
||||
<StatusBarSecondarySection
|
||||
noteCount={noteCount}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomReset={onZoomReset}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</footer>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AiAgentsBadge } from './AiAgentsBadge'
|
||||
|
||||
vi.mock('../../utils/url', async () => {
|
||||
@@ -12,6 +14,10 @@ const installedStatuses = {
|
||||
codex: { status: 'installed' as const, version: '0.37.0' },
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
describe('AiAgentsBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, ChevronsUpDown, Terminal } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
@@ -199,23 +200,25 @@ export function AiAgentsBadge({
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium"
|
||||
title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
|
||||
data-testid="status-ai-agents"
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Terminal size={13} />
|
||||
{triggerLabel(defaultAgent, selectedAgentReady)}
|
||||
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<ActionTooltip copy={{ label: badgeTooltip(statuses, defaultAgent, guidanceStatus) }} side="top">
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium"
|
||||
aria-label="Open AI agent options"
|
||||
data-testid="status-ai-agents"
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Terminal size={13} />
|
||||
{triggerLabel(defaultAgent, selectedAgentReady)}
|
||||
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ActionTooltip>
|
||||
<AgentMenuContent
|
||||
statuses={statuses}
|
||||
guidanceStatus={guidanceStatus}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
@@ -15,6 +10,9 @@ import {
|
||||
Terminal,
|
||||
} from 'lucide-react'
|
||||
import { GitDiff, Pulse } from '@phosphor-icons/react'
|
||||
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
|
||||
@@ -48,47 +46,6 @@ const MCP_TOOLTIPS: Partial<Record<McpStatus, string>> = {
|
||||
|
||||
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
|
||||
|
||||
type HoverHandlers = {
|
||||
onMouseEnter?: (event: ReactMouseEvent<HTMLSpanElement>) => void
|
||||
onMouseLeave?: (event: ReactMouseEvent<HTMLSpanElement>) => void
|
||||
}
|
||||
|
||||
interface ClaudeCodeRenderState extends HoverHandlers {
|
||||
role?: 'button'
|
||||
tabIndex?: number
|
||||
onKeyDown?: (event: ReactKeyboardEvent<HTMLSpanElement>) => void
|
||||
color: string
|
||||
cursor: string
|
||||
warningIcon: ReactNode
|
||||
}
|
||||
|
||||
function createHoverHandlers(interactive: boolean): HoverHandlers {
|
||||
if (!interactive) {
|
||||
return {
|
||||
onMouseEnter: undefined,
|
||||
onMouseLeave: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseEnter: (event) => {
|
||||
event.currentTarget.style.background = 'var(--hover)'
|
||||
},
|
||||
onMouseLeave: (event) => {
|
||||
event.currentTarget.style.background = 'transparent'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createEnterKeyHandler(onActivate?: () => void) {
|
||||
return (event: ReactKeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onActivate?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
@@ -103,11 +60,12 @@ function syncIconColor(status: SyncStatus): string {
|
||||
return SYNC_COLORS[status] ?? 'var(--accent-green)'
|
||||
}
|
||||
|
||||
function syncBadgeTitle(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'Click to resolve conflicts'
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'pull_required') return 'Click to pull from remote and push'
|
||||
return 'Click to sync now'
|
||||
function syncBadgeTooltipCopy(status: SyncStatus): ActionTooltipCopy {
|
||||
if (status === 'conflict') return { label: 'Resolve merge conflicts' }
|
||||
if (status === 'syncing') return { label: 'Sync in progress' }
|
||||
if (status === 'pull_required') return { label: 'Pull from remote and push' }
|
||||
if (status === 'error') return { label: 'Retry sync' }
|
||||
return { label: 'Sync now' }
|
||||
}
|
||||
|
||||
function syncStatusText(status: SyncStatus): string {
|
||||
@@ -127,10 +85,12 @@ function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): bool
|
||||
return remoteStatus?.hasRemote === false
|
||||
}
|
||||
|
||||
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
|
||||
return isRemoteMissing(remoteStatus)
|
||||
? 'Commit locally (no remote configured)'
|
||||
: 'Commit & Push'
|
||||
function commitButtonTooltipCopy(remoteStatus: GitRemoteStatus | null | undefined): ActionTooltipCopy {
|
||||
return {
|
||||
label: isRemoteMissing(remoteStatus)
|
||||
? 'Commit changes locally'
|
||||
: 'Commit and push changes',
|
||||
}
|
||||
}
|
||||
|
||||
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
|
||||
@@ -154,30 +114,57 @@ function getClaudeCodeBadgeConfig(status: ClaudeCodeStatus, version?: string | n
|
||||
}
|
||||
}
|
||||
|
||||
function createClaudeCodeRenderState(
|
||||
config: NonNullable<ReturnType<typeof getClaudeCodeBadgeConfig>>,
|
||||
): ClaudeCodeRenderState {
|
||||
if (!config.missing) {
|
||||
return {
|
||||
role: undefined,
|
||||
tabIndex: undefined,
|
||||
onKeyDown: undefined,
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'default',
|
||||
warningIcon: null,
|
||||
...createHoverHandlers(false),
|
||||
}
|
||||
}
|
||||
function handleStatusBarActionKeyDown(
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
onClick?: () => void,
|
||||
) {
|
||||
if (!onClick) return
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onKeyDown: createEnterKeyHandler(config.onActivate),
|
||||
color: 'var(--accent-orange)',
|
||||
cursor: 'pointer',
|
||||
warningIcon: <AlertTriangle size={10} style={{ marginLeft: 2 }} />,
|
||||
...createHoverHandlers(true),
|
||||
}
|
||||
function StatusBarAction({
|
||||
copy,
|
||||
children,
|
||||
onClick,
|
||||
testId,
|
||||
ariaLabel,
|
||||
className,
|
||||
style,
|
||||
disabled = false,
|
||||
}: {
|
||||
copy: ActionTooltipCopy
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
testId?: string
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ActionTooltip copy={copy} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onKeyDown={(event) => handleStatusBarActionKeyDown(event, disabled ? undefined : onClick)}
|
||||
aria-label={ariaLabel ?? copy.label}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) {
|
||||
@@ -405,16 +392,12 @@ export function SyncBadge({
|
||||
|
||||
return (
|
||||
<div ref={popupRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncBadgeTitle(status)}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync">
|
||||
<span style={ICON_STYLE}>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
{showPopup && (
|
||||
<GitStatusPopup
|
||||
status={status}
|
||||
@@ -433,25 +416,17 @@ export function ConflictBadge({ count, onClick }: { count: number; onClick?: ()
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
<StatusBarAction
|
||||
copy={{ label: 'Resolve merge conflicts' }}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: 'var(--destructive, #e03e3e)',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title="Resolve merge conflicts"
|
||||
onMouseEnter={onClick ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onClick ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid="status-conflict-count"
|
||||
testId="status-conflict-count"
|
||||
className="text-[var(--destructive,#e03e3e)]"
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<AlertTriangle size={13} />
|
||||
{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -462,35 +437,29 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
>
|
||||
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--accent-orange)',
|
||||
color: '#fff',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count">
|
||||
<span style={ICON_STYLE}>
|
||||
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--accent-orange)',
|
||||
color: '#fff',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
Changes
|
||||
</span>
|
||||
Changes
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -507,20 +476,12 @@ export function CommitButton({
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={createEnterKeyHandler(onClick)}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title={commitButtonTitle(remoteStatus)}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-commit-push"
|
||||
>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push">
|
||||
<span style={ICON_STYLE}>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -529,25 +490,17 @@ export function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabl
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={disabled ? undefined : 'button'}
|
||||
<StatusBarAction
|
||||
copy={{ label: disabled ? 'History is only available for git-enabled vaults' : 'Open change history' }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}}
|
||||
title={disabled ? 'History is only available for git-enabled vaults' : 'View history'}
|
||||
onMouseEnter={disabled ? undefined : (event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={disabled ? undefined : (event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-pulse"
|
||||
testId="status-pulse"
|
||||
disabled={Boolean(disabled)}
|
||||
>
|
||||
<Pulse size={13} />
|
||||
History
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Pulse size={13} />
|
||||
History
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -559,26 +512,18 @@ export function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?:
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={config.clickable ? 'button' : undefined}
|
||||
<StatusBarAction
|
||||
copy={{ label: config.tooltip }}
|
||||
onClick={config.onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: 'var(--accent-orange)',
|
||||
cursor: config.clickable ? 'pointer' : 'default',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title={config.tooltip}
|
||||
data-testid="status-mcp"
|
||||
onMouseEnter={config.clickable ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={config.clickable ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
testId="status-mcp"
|
||||
className="text-[var(--accent-orange)]"
|
||||
>
|
||||
<Cpu size={13} />
|
||||
MCP
|
||||
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Cpu size={13} />
|
||||
MCP
|
||||
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -587,33 +532,21 @@ export function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus;
|
||||
const config = getClaudeCodeBadgeConfig(status, version)
|
||||
if (!config) return null
|
||||
|
||||
const renderState = createClaudeCodeRenderState(config)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={renderState.role}
|
||||
tabIndex={renderState.tabIndex}
|
||||
<StatusBarAction
|
||||
copy={{ label: config.tooltip }}
|
||||
onClick={config.onActivate}
|
||||
onKeyDown={renderState.onKeyDown}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: renderState.color,
|
||||
cursor: renderState.cursor,
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title={config.tooltip}
|
||||
data-testid="status-claude-code"
|
||||
onMouseEnter={renderState.onMouseEnter}
|
||||
onMouseLeave={renderState.onMouseLeave}
|
||||
testId="status-claude-code"
|
||||
className={config.missing ? 'text-[var(--accent-orange)]' : undefined}
|
||||
>
|
||||
<Terminal size={13} />
|
||||
{config.label}
|
||||
{renderState.warningIcon}
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Terminal size={13} />
|
||||
{config.label}
|
||||
{config.missing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, SyncStatus } from '../../types'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { AiAgentsBadge } from './AiAgentsBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -22,6 +23,12 @@ import { DISABLED_STYLE, ICON_STYLE, SEP_STYLE } from './styles'
|
||||
import type { VaultOption } from './types'
|
||||
import { VaultMenu } from './VaultMenu'
|
||||
|
||||
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
|
||||
const ZOOM_RESET_TOOLTIP = { label: 'Reset the zoom level', shortcut: '⌘0' } as const
|
||||
const FEEDBACK_TOOLTIP = { label: 'Share feedback' } as const
|
||||
const NOTIFICATIONS_TOOLTIP = { label: 'Notifications are coming soon' } as const
|
||||
const SETTINGS_TOOLTIP = { label: 'Open settings', shortcut: '⌘,' } as const
|
||||
|
||||
interface StatusBarPrimarySectionProps {
|
||||
modifiedCount: number
|
||||
vaultPath: string
|
||||
@@ -109,18 +116,23 @@ export function StatusBarPrimarySection({
|
||||
onRemoveVault={onRemoveVault}
|
||||
/>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onCheckForUpdates}
|
||||
style={{ ...ICON_STYLE, cursor: onCheckForUpdates ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Check for updates"
|
||||
data-testid="status-build-number"
|
||||
onMouseEnter={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
>
|
||||
<Package size={13} />
|
||||
{buildNumber ?? 'b?'}
|
||||
</span>
|
||||
<ActionTooltip copy={UPDATE_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onCheckForUpdates}
|
||||
aria-label={UPDATE_TOOLTIP.label}
|
||||
aria-disabled={onCheckForUpdates ? undefined : true}
|
||||
data-testid="status-build-number"
|
||||
>
|
||||
<span style={ICON_STYLE}>
|
||||
<Package size={13} />
|
||||
{buildNumber ?? 'b?'}
|
||||
</span>
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
<OfflineBadge isOffline={isOffline} />
|
||||
<NoRemoteBadge remoteStatus={remoteStatus} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
|
||||
@@ -163,45 +175,54 @@ export function StatusBarSecondarySection({
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{zoomLevel === 100 ? null : (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onZoomReset}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Reset zoom (⌘0)"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-zoom"
|
||||
>
|
||||
{zoomLevel}%
|
||||
</span>
|
||||
<ActionTooltip copy={ZOOM_RESET_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onZoomReset}
|
||||
aria-label={ZOOM_RESET_TOOLTIP.label}
|
||||
data-testid="status-zoom"
|
||||
>
|
||||
<span style={ICON_STYLE}>{zoomLevel}%</span>
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
{onOpenFeedback && (
|
||||
<ActionTooltip copy={FEEDBACK_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenFeedback}
|
||||
aria-label={FEEDBACK_TOOLTIP.label}
|
||||
data-testid="status-feedback"
|
||||
>
|
||||
<Megaphone size={14} />
|
||||
Feedback
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
<ActionTooltip copy={NOTIFICATIONS_TOOLTIP} side="top">
|
||||
<span style={DISABLED_STYLE} aria-label={NOTIFICATIONS_TOOLTIP.label}>
|
||||
<Bell size={14} />
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip copy={SETTINGS_TOOLTIP} side="top">
|
||||
<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"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onOpenSettings}
|
||||
aria-label={SETTINGS_TOOLTIP.label}
|
||||
data-testid="status-settings"
|
||||
>
|
||||
<Megaphone size={14} />
|
||||
Feedback
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<span style={DISABLED_STYLE} title="Coming soon">
|
||||
<Bell size={14} />
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onOpenSettings}
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Settings (⌘,)"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { VaultOption } from './types'
|
||||
import { useDismissibleLayer } from './useDismissibleLayer'
|
||||
|
||||
@@ -204,23 +206,22 @@ export function VaultMenu({
|
||||
|
||||
return (
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: open ? 'var(--hover)' : 'transparent',
|
||||
}}
|
||||
title="Switch vault"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
{activeVault?.label ?? 'Vault'}
|
||||
</span>
|
||||
<ActionTooltip copy={{ label: 'Switch vault' }} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={open
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-label="Switch vault"
|
||||
data-testid="status-vault-trigger"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
{activeVault?.label ?? 'Vault'}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
40
src/components/ui/action-tooltip.tsx
Normal file
40
src/components/ui/action-tooltip.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export interface ActionTooltipCopy {
|
||||
label: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface ActionTooltipProps {
|
||||
copy: ActionTooltipCopy
|
||||
children: ReactNode
|
||||
className?: string
|
||||
side?: ComponentProps<typeof TooltipContent>['side']
|
||||
sideOffset?: number
|
||||
}
|
||||
|
||||
export function ActionTooltip({
|
||||
copy,
|
||||
children,
|
||||
className,
|
||||
side = 'top',
|
||||
sideOffset = 6,
|
||||
}: ActionTooltipProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} sideOffset={sideOffset} className={cn('px-2.5 py-2', className)}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="min-w-0 flex-1 text-[11px] font-medium leading-tight">{copy.label}</span>
|
||||
{copy.shortcut && (
|
||||
<span className="shrink-0 rounded border border-background/20 bg-background/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-background/80">
|
||||
{copy.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user