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:
lucaronin
2026-02-24 15:54:24 +01:00
parent 0e9de3c1e2
commit 4f03751da5
16 changed files with 380 additions and 123 deletions

View File

@@ -40,7 +40,7 @@ const trashedEntry: VaultEntry = {
const defaultProps = {
wordCount: 100,
isModified: false,
noteStatus: 'clean' as const,
showDiffToggle: false,
diffMode: false,
diffLoading: false,

View File

@@ -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' }}>&middot;</span>
<span className="text-muted-foreground">{wordCount.toLocaleString()} words</span>
{isModified && (
{noteStatus === 'new' && (
<>
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>&middot;</span>
<span className="font-semibold" style={{ color: 'var(--accent-green)' }}>N</span>
</>
)}
{noteStatus === 'modified' && (
<>
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>&middot;</span>
<span className="font-semibold" style={{ color: 'var(--accent-yellow)' }}>M</span>

View File

@@ -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'}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'])

View File

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