feat: distinguish new notes (green dot) from modified notes (orange dot)
New notes created in-session show a green dot; existing notes with
uncommitted git changes show an orange dot. Saving a new note clears
the green dot; git commit clears the orange dot.
Introduces NoteStatus type ('new' | 'modified' | 'clean'), extracts
useNewNoteTracker hook and resolveNoteStatus pure function, and adds
onNoteSaved callback to useEditorSave for clearing new-note tracking.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@ const trashedEntry: VaultEntry = {
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
isModified: false,
|
||||
noteStatus: 'clean' as const,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
wordCount: number
|
||||
isModified: boolean
|
||||
noteStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
@@ -37,7 +37,7 @@ const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'isModified'>) {
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
@@ -143,7 +143,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, wordCount, isModified, ...actionProps
|
||||
entry, wordCount, noteStatus, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -162,7 +162,13 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
<span className="font-medium text-foreground">{entry.title}</span>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
{isModified && (
|
||||
{noteStatus === 'new' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-green)' }}>N</span>
|
||||
</>
|
||||
)}
|
||||
{noteStatus === 'modified' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-yellow)' }}>M</span>
|
||||
|
||||
@@ -206,20 +206,33 @@ describe('Editor', () => {
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
isModified={() => true}
|
||||
getNoteStatus={() => 'modified'}
|
||||
/>
|
||||
)
|
||||
// Modified indicator shows "M" in the breadcrumb
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows new indicator when file is new', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
getNoteStatus={() => 'new'}
|
||||
/>
|
||||
)
|
||||
// New indicator shows "N" in the breadcrumb
|
||||
expect(screen.getByText('N')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders diff toggle button when file is modified', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
isModified={() => true}
|
||||
getNoteStatus={() => 'modified'}
|
||||
onLoadDiff={async () => '+ added line'}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuControl
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { uploadImageFile, useImageDrop } from '../hooks/useImageDrop'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { DiffView } from './DiffView'
|
||||
@@ -34,7 +34,7 @@ interface EditorProps {
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onLoadDiff?: (path: string) => Promise<string>
|
||||
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
|
||||
isModified?: (path: string) => boolean
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
onCreateNote?: () => void
|
||||
// Inspector props
|
||||
inspectorCollapsed: boolean
|
||||
@@ -182,7 +182,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor({
|
||||
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, onLoadDiffAtCommit, isModified, onCreateNote,
|
||||
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink, onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, allContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
@@ -392,7 +392,8 @@ export const Editor = memo(function Editor({
|
||||
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const showDiffToggle = activeTab && (diffMode || isModified?.(activeTab.entry.path))
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = activeTab && (diffMode || activeStatus === 'modified')
|
||||
|
||||
useEffect(() => {
|
||||
setDiffMode(false)
|
||||
@@ -432,14 +433,13 @@ export const Editor = memo(function Editor({
|
||||
}
|
||||
}, [activeTabPath, onLoadDiffAtCommit])
|
||||
|
||||
const activeModified = activeTab ? isModified?.(activeTab.entry.path) ?? false : false
|
||||
const wordCount = activeTab ? countWords(activeTab.content) : 0
|
||||
|
||||
const tabBar = (
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabPath={activeTabPath}
|
||||
isModified={isModified}
|
||||
getNoteStatus={getNoteStatus}
|
||||
onSwitchTab={onSwitchTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
@@ -452,7 +452,7 @@ export const Editor = memo(function Editor({
|
||||
<BreadcrumbBar
|
||||
entry={activeTab.entry}
|
||||
wordCount={wordCount}
|
||||
isModified={activeModified}
|
||||
noteStatus={activeStatus}
|
||||
showDiffToggle={!!showDiffToggle}
|
||||
diffMode={diffMode}
|
||||
diffLoading={diffLoading}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
@@ -46,10 +46,15 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isModified, typeEntryMap, onClickNote }: {
|
||||
const NOTE_STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
new: { color: 'var(--accent-green)', testId: 'new-indicator', title: 'New (unsaved)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
isModified?: boolean
|
||||
noteStatus?: NoteStatus
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
}) {
|
||||
@@ -75,12 +80,12 @@ export function NoteItem({ entry, isSelected, isModified, typeEntryMap, onClickN
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{isModified && (
|
||||
{noteStatus !== 'clean' && (
|
||||
<span
|
||||
className="mr-1.5 inline-block align-middle"
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-orange)', verticalAlign: 'middle' }}
|
||||
data-testid="modified-indicator"
|
||||
title="Modified (uncommitted)"
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: NOTE_STATUS_DOT[noteStatus].color, verticalAlign: 'middle' }}
|
||||
data-testid={NOTE_STATUS_DOT[noteStatus].testId}
|
||||
title={NOTE_STATUS_DOT[noteStatus].title}
|
||||
/>
|
||||
)}
|
||||
{entry.title}
|
||||
|
||||
@@ -716,44 +716,51 @@ describe('filterEntries — trash', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList — modified indicators', () => {
|
||||
it('shows modified indicator dot for entries in modifiedFiles', () => {
|
||||
const modifiedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
]
|
||||
describe('NoteList — status indicators', () => {
|
||||
it('shows modified indicator dot for modified notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const indicators = screen.getAllByTestId('modified-indicator')
|
||||
expect(indicators).toHaveLength(1)
|
||||
// The indicator's parent div contains the title text
|
||||
const noteRow = indicators[0].closest('[data-testid="modified-indicator"]')!.parentElement!.parentElement!
|
||||
expect(noteRow.textContent).toContain('Build Laputa App')
|
||||
})
|
||||
|
||||
it('does not show modified indicator when modifiedFiles is empty', () => {
|
||||
it('does not show indicator when all notes are clean', () => {
|
||||
const getNoteStatus = () => 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={[]} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows multiple modified indicators for multiple modified files', () => {
|
||||
const modifiedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
|
||||
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show modified indicator when modifiedFiles prop is undefined', () => {
|
||||
it('does not show indicator when getNoteStatus prop is undefined', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows green new indicator for new notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback, memo } from 'react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
||||
import type { VaultEntry, SidebarSelection, NoteStatus } from '../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
|
||||
@@ -21,7 +21,7 @@ interface NoteListProps {
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
@@ -248,17 +248,14 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
|
||||
const defaultGetNoteStatus = (): NoteStatus => 'clean'
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, getNoteStatus = defaultGetNoteStatus, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
|
||||
|
||||
const modifiedPathSet = useMemo(
|
||||
() => new Set((modifiedFiles ?? []).map((f) => f.path)),
|
||||
[modifiedFiles],
|
||||
)
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
|
||||
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
|
||||
}, [])
|
||||
@@ -279,8 +276,8 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
}, [onSelectNote, onReplaceActiveTab])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isModified={modifiedPathSet.has(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, modifiedPathSet])
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} noteStatus={getNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, getNoteStatus])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
|
||||
@@ -112,26 +112,35 @@ describe('TabBar', () => {
|
||||
|
||||
it('shows modified indicator dot on modified tabs', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const isModified = (path: string) => path === tabs[0].entry.path
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
|
||||
const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'modified' as const : 'clean' as const
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
|
||||
const indicators = screen.getAllByTestId('tab-modified-indicator')
|
||||
expect(indicators).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not show modified indicator when no tabs are modified', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const isModified = () => false
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
|
||||
const getNoteStatus = () => 'clean' as const
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
|
||||
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified indicator on multiple tabs', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
|
||||
const isModified = () => true
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} isModified={isModified} {...defaultProps} />)
|
||||
const getNoteStatus = () => 'modified' as const
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
|
||||
expect(screen.getAllByTestId('tab-modified-indicator')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows green new indicator on new tabs', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const getNoteStatus = (path: string) => path === tabs[0].entry.path ? 'new' as const : 'clean' as const
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} getNoteStatus={getNoteStatus} {...defaultProps} />)
|
||||
expect(screen.getAllByTestId('tab-new-indicator')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('tab-modified-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not reorder on drag cancel (dragEnd without drop)', () => {
|
||||
const onReorderTabs = vi.fn()
|
||||
const tabs = makeTabs(['Alpha', 'Beta', 'Gamma'])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import { Plus, Columns, ArrowsOutSimple } from '@phosphor-icons/react'
|
||||
@@ -12,7 +12,7 @@ interface Tab {
|
||||
interface TabBarProps {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
isModified?: (path: string) => boolean
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
onSwitchTab: (path: string) => void
|
||||
onCloseTab: (path: string) => void
|
||||
onCreateNote?: () => void
|
||||
@@ -173,11 +173,29 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isEditing, isModified, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string }> = {
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (unsaved)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: NoteStatus }) {
|
||||
const cfg = STATUS_DOT[status]
|
||||
if (!cfg) return null
|
||||
return (
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: cfg.color }}
|
||||
data-testid={cfg.testId}
|
||||
title={cfg.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
isModified: boolean
|
||||
noteStatus: NoteStatus
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
@@ -215,14 +233,7 @@ function TabItem({ tab, isActive, isEditing, isModified, isDragging, showDropBef
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
)}
|
||||
{isModified && (
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-orange)' }}
|
||||
data-testid="tab-modified-indicator"
|
||||
title="Modified (uncommitted)"
|
||||
/>
|
||||
)}
|
||||
<StatusDot status={noteStatus} />
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
@@ -264,7 +275,7 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
|
||||
// --- Main TabBar ---
|
||||
|
||||
export const TabBar = memo(function TabBar({
|
||||
tabs, activeTabPath, isModified, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
|
||||
tabs, activeTabPath, getNoteStatus, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
@@ -282,7 +293,7 @@ export const TabBar = memo(function TabBar({
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
isModified={isModified?.(tab.entry.path) ?? false}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
|
||||
Reference in New Issue
Block a user