Merge pull request #48 from refactoringhq/task/differenzia-pallino

feat: distinguish new notes (green dot) from modified notes (orange dot)
This commit is contained in:
Luca Rossi
2026-02-24 17:13:58 +01:00
committed by GitHub
16 changed files with 389 additions and 117 deletions

View File

@@ -0,0 +1,90 @@
{"version":"2.8","children":[
{"type":"frame","id":"dp01","x":0,"y":0,"name":"NoteStatus — NoteList (New vs Modified)","width":340,"height":"fit_content(600)","fill":"$--card","layout":"vertical","children":[
{"type":"text","id":"dp01t","fill":"$--muted-foreground","content":"NoteList — New vs Modified vs Clean","fontFamily":"Inter","fontSize":10,"fontWeight":"600","textTransform":"uppercase","letterSpacing":1,"width":"fill_container","padding":[8,16]},
{"type":"frame","id":"dp02","name":"Note Item — New (green dot)","width":"fill_container","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"$--border"},"layout":"vertical","gap":4,"padding":[14,16],"children":[
{"type":"frame","id":"dp02r","name":"titleRow","width":"fill_container","justifyContent":"space_between","alignItems":"center","children":[
{"type":"frame","id":"dp02tg","gap":6,"alignItems":"center","children":[
{"type":"ellipse","id":"dp02dot","name":"New Indicator (green)","width":6,"height":6,"fill":"$--accent-green"},
{"type":"text","id":"dp02ti","fill":"$--foreground","content":"Untitled Note","fontFamily":"Inter","fontSize":13,"fontWeight":"500"}
]},
{"type":"text","id":"dp02d","fill":"$--muted-foreground","content":"just now","fontFamily":"Inter","fontSize":11}
]},
{"type":"text","id":"dp02s","fill":"$--muted-foreground","content":"New note created in this session, never saved to disk","fontFamily":"Inter","fontSize":12,"width":"fill_container"}
]},
{"type":"frame","id":"dp03","name":"Note Item — Modified (orange dot)","width":"fill_container","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"$--border"},"layout":"vertical","gap":4,"padding":[14,16],"children":[
{"type":"frame","id":"dp03r","name":"titleRow","width":"fill_container","justifyContent":"space_between","alignItems":"center","children":[
{"type":"frame","id":"dp03tg","gap":6,"alignItems":"center","children":[
{"type":"ellipse","id":"dp03dot","name":"Modified Indicator (orange)","width":6,"height":6,"fill":"$--accent-orange"},
{"type":"text","id":"dp03ti","fill":"$--foreground","content":"Build Laputa App","fontFamily":"Inter","fontSize":13,"fontWeight":"500"}
]},
{"type":"text","id":"dp03d","fill":"$--muted-foreground","content":"2 hours ago","fontFamily":"Inter","fontSize":11}
]},
{"type":"text","id":"dp03s","fill":"$--muted-foreground","content":"Existing note with uncommitted git changes","fontFamily":"Inter","fontSize":12,"width":"fill_container"}
]},
{"type":"frame","id":"dp04","name":"Note Item — Clean (no dot)","width":"fill_container","stroke":{"align":"inside","thickness":{"bottom":1},"fill":"$--border"},"layout":"vertical","gap":4,"padding":[14,16],"children":[
{"type":"frame","id":"dp04r","name":"titleRow","width":"fill_container","justifyContent":"space_between","alignItems":"center","children":[
{"type":"frame","id":"dp04tg","gap":6,"alignItems":"center","children":[
{"type":"text","id":"dp04ti","fill":"$--foreground","content":"Meeting Notes","fontFamily":"Inter","fontSize":13,"fontWeight":"500"}
]},
{"type":"text","id":"dp04d","fill":"$--muted-foreground","content":"yesterday","fontFamily":"Inter","fontSize":11}
]},
{"type":"text","id":"dp04s","fill":"$--muted-foreground","content":"No indicator — clean, committed note","fontFamily":"Inter","fontSize":12,"width":"fill_container"}
]}
]},
{"type":"frame","id":"dp10","x":360,"y":0,"name":"NoteStatus — TabBar (New vs Modified)","width":600,"height":"fit_content(200)","fill":"$--sidebar","layout":"vertical","children":[
{"type":"text","id":"dp10t","fill":"$--muted-foreground","content":"TabBar — Green vs Orange Status Dots","fontFamily":"Inter","fontSize":10,"fontWeight":"600","textTransform":"uppercase","letterSpacing":1,"width":"fill_container","padding":[8,16]},
{"type":"frame","id":"dp11","name":"Tab Row","width":"fill_container","height":45,"alignItems":"center","children":[
{"type":"frame","id":"dp11a","name":"Active Tab (new)","height":"fill_container","fill":"$--background","stroke":{"align":"inside","thickness":{"right":1},"fill":"$--border"},"padding":[0,12],"gap":6,"alignItems":"center","children":[
{"type":"text","id":"dp11at","fill":"$--foreground","content":"Untitled Note","fontFamily":"Inter","fontSize":12,"fontWeight":"500"},
{"type":"ellipse","id":"dp11adot","name":"New dot (green)","width":6,"height":6,"fill":"$--accent-green"},
{"type":"text","id":"dp11ax","fill":"$--muted-foreground","content":"×","fontFamily":"Inter","fontSize":14}
]},
{"type":"frame","id":"dp11b","name":"Inactive Tab (modified)","height":"fill_container","stroke":{"align":"inside","thickness":{"right":1,"bottom":1},"fill":"$--sidebar-border"},"padding":[0,12],"gap":6,"alignItems":"center","children":[
{"type":"text","id":"dp11bt","fill":"$--muted-foreground","content":"Build Laputa App","fontFamily":"Inter","fontSize":12},
{"type":"ellipse","id":"dp11bdot","name":"Modified dot (orange)","width":6,"height":6,"fill":"$--accent-orange"}
]},
{"type":"frame","id":"dp11c","name":"Inactive Tab (clean)","height":"fill_container","stroke":{"align":"inside","thickness":{"right":1,"bottom":1},"fill":"$--sidebar-border"},"padding":[0,12],"gap":6,"alignItems":"center","children":[
{"type":"text","id":"dp11ct","fill":"$--muted-foreground","content":"Meeting Notes","fontFamily":"Inter","fontSize":12}
]}
]}
]},
{"type":"frame","id":"dp20","x":360,"y":220,"name":"NoteStatus — BreadcrumbBar (N vs M badge)","width":600,"height":"fit_content(200)","fill":"$--background","layout":"vertical","children":[
{"type":"text","id":"dp20t","fill":"$--muted-foreground","content":"BreadcrumbBar — Status Badge","fontFamily":"Inter","fontSize":10,"fontWeight":"600","textTransform":"uppercase","letterSpacing":1,"width":"fill_container","padding":[8,16]},
{"type":"frame","id":"dp21","name":"Breadcrumb — New Note","width":"fill_container","padding":[8,16],"gap":8,"alignItems":"center","children":[
{"type":"text","id":"dp21type","fill":"$--muted-foreground","content":"Note","fontFamily":"Inter","fontSize":12},
{"type":"text","id":"dp21sep","fill":"$--muted-foreground","content":"/","fontFamily":"Inter","fontSize":12},
{"type":"text","id":"dp21title","fill":"$--foreground","content":"Untitled Note","fontFamily":"Inter","fontSize":12,"fontWeight":"500"},
{"type":"frame","id":"dp21badge","name":"New Badge","fill":"$--accent-green","cornerRadius":3,"padding":[1,5],"children":[
{"type":"text","id":"dp21bl","fill":"#ffffff","content":"N","fontFamily":"Inter","fontSize":10,"fontWeight":"700"}
]}
]},
{"type":"frame","id":"dp22","name":"Breadcrumb — Modified Note","width":"fill_container","padding":[8,16],"gap":8,"alignItems":"center","stroke":{"align":"inside","thickness":{"top":1},"fill":"$--border"},"children":[
{"type":"text","id":"dp22type","fill":"$--muted-foreground","content":"Project","fontFamily":"Inter","fontSize":12},
{"type":"text","id":"dp22sep","fill":"$--muted-foreground","content":"/","fontFamily":"Inter","fontSize":12},
{"type":"text","id":"dp22title","fill":"$--foreground","content":"Build Laputa App","fontFamily":"Inter","fontSize":12,"fontWeight":"500"},
{"type":"frame","id":"dp22badge","name":"Modified Badge","fill":"$--accent-yellow","cornerRadius":3,"padding":[1,5],"children":[
{"type":"text","id":"dp22bl","fill":"#ffffff","content":"M","fontFamily":"Inter","fontSize":10,"fontWeight":"700"}
]}
]}
]},
{"type":"frame","id":"dp30","x":0,"y":640,"name":"NoteStatus — State Transitions","width":960,"height":"fit_content(300)","fill":"$--card","layout":"vertical","children":[
{"type":"text","id":"dp30t","fill":"$--muted-foreground","content":"State Transition Flow","fontFamily":"Inter","fontSize":10,"fontWeight":"600","textTransform":"uppercase","letterSpacing":1,"width":"fill_container","padding":[8,16]},
{"type":"frame","id":"dp31","name":"Flow","width":"fill_container","padding":[16,16],"gap":16,"alignItems":"center","children":[
{"type":"frame","id":"dp31a","name":"Create","fill":"$--accent-green","cornerRadius":8,"padding":[12,20],"children":[
{"type":"text","id":"dp31at","fill":"#ffffff","content":"Create Note → Green Dot","fontFamily":"Inter","fontSize":13,"fontWeight":"600"}
]},
{"type":"text","id":"dp31arr1","fill":"$--muted-foreground","content":"→","fontFamily":"Inter","fontSize":20,"fontWeight":"700"},
{"type":"frame","id":"dp31b","name":"Save","fill":"$--muted","cornerRadius":8,"padding":[12,20],"children":[
{"type":"text","id":"dp31bt","fill":"$--foreground","content":"Cmd+S → Dot Removed","fontFamily":"Inter","fontSize":13,"fontWeight":"600"}
]},
{"type":"text","id":"dp31arr2","fill":"$--muted-foreground","content":"→","fontFamily":"Inter","fontSize":20,"fontWeight":"700"},
{"type":"frame","id":"dp31c","name":"Edit+Save","fill":"$--accent-orange","cornerRadius":8,"padding":[12,20],"children":[
{"type":"text","id":"dp31ct","fill":"#ffffff","content":"Edit Existing → Orange Dot","fontFamily":"Inter","fontSize":13,"fontWeight":"600"}
]},
{"type":"text","id":"dp31arr3","fill":"$--muted-foreground","content":"→","fontFamily":"Inter","fontSize":20,"fontWeight":"700"},
{"type":"frame","id":"dp31d","name":"Commit","fill":"$--muted","cornerRadius":8,"padding":[12,20],"children":[
{"type":"text","id":"dp31dt","fill":"$--foreground","content":"Git Commit → Dot Removed","fontFamily":"Inter","fontSize":13,"fontWeight":"600"}
]}
]}
]}
],"variables":{}}

View File

@@ -87,7 +87,7 @@ function App() {
updateVaultContent: vault.updateContent,
setTabs: notes.setTabs,
setToastMessage,
onAfterSave: vault.loadModifiedFiles,
onAfterSave: vault.loadModifiedFiles, onNoteSaved: vault.markSaved,
})
const commitFlow = useCommitFlow({
@@ -207,7 +207,7 @@ function App() {
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={handleCreateNoteImmediate} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={handleCreateNoteImmediate} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
<div className="app__editor">
@@ -221,7 +221,7 @@ function App() {
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
isModified={vault.isFileModified}
getNoteStatus={vault.getNoteStatus}
onCreateNote={handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={() => layout.setInspectorCollapsed((c) => !c)}

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, ModifiedFile, NoteStatus } from '../types'
import { Input } from '@/components/ui/input'
import {
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
@@ -22,6 +22,7 @@ interface NoteListProps {
selectedNote: VaultEntry | null
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
getNoteStatus?: (path: string) => NoteStatus
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onCreateNote: () => void
@@ -248,7 +249,9 @@ 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, modifiedFiles, getNoteStatus, onSelectNote, onReplaceActiveTab, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -259,6 +262,15 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[modifiedFiles],
)
// Resolve note status: prefer explicit getNoteStatus prop; fall back to modifiedFiles-derived status
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(() => {
if (getNoteStatus) return getNoteStatus
if (modifiedFiles && modifiedFiles.length > 0) {
return (path: string) => modifiedPathSet.has(path) ? 'modified' : 'clean'
}
return defaultGetNoteStatus
}, [getNoteStatus, modifiedFiles, modifiedPathSet])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
@@ -279,8 +291,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={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus])
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}

View File

@@ -107,10 +107,13 @@ describe('useEditorSave', () => {
})
})
it('calls onAfterSave callback after successful save', async () => {
const onAfterSave = vi.fn()
it.each([
{ name: 'onAfterSave', key: 'onAfterSave' as const },
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
])('calls $name callback after successful save', async ({ key }) => {
const cb = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
)
act(() => {
@@ -121,7 +124,7 @@ describe('useEditorSave', () => {
await result.current.handleSave()
})
expect(onAfterSave).toHaveBeenCalledOnce()
expect(cb).toHaveBeenCalled()
})
it('calls onAfterSave even when nothing is pending (e.g. after rename)', async () => {
@@ -139,12 +142,15 @@ describe('useEditorSave', () => {
expect(onAfterSave).toHaveBeenCalledOnce()
})
it('does not call onAfterSave when save fails', async () => {
it.each([
{ name: 'onAfterSave', key: 'onAfterSave' as const },
{ name: 'onNoteSaved', key: 'onNoteSaved' as const },
])('does not call $name when save fails', async ({ key }) => {
mockInvokeFn.mockRejectedValueOnce(new Error('Disk full'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const onAfterSave = vi.fn()
const cb = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
useEditorSave({ updateVaultContent, setTabs, setToastMessage, [key]: cb })
)
act(() => {
@@ -155,7 +161,7 @@ describe('useEditorSave', () => {
await result.current.handleSave()
})
expect(onAfterSave).not.toHaveBeenCalled()
expect(cb).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})

View File

@@ -13,13 +13,16 @@ interface EditorSaveConfig {
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => void
onNoteSaved?: (path: string) => void
}
/**
* Hook that manages explicit save (Cmd+S) for editor content.
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
*/
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }: EditorSaveConfig) {
const noop = () => {}
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNoteSaved }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
@@ -38,15 +41,16 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
if (pathFilter && pending.path !== pathFilter) return false
await saveNote(pending.path, pending.content)
pendingContentRef.current = null
onNoteSaved?.(pending.path)
return true
}, [saveNote])
}, [saveNote, onNoteSaved])
/** Called by Cmd+S — persists the current editor content to disk */
const handleSave = useCallback(async () => {
try {
const saved = await flushPending()
setToastMessage(saved ? 'Saved' : 'Nothing to save')
onAfterSave?.()
onAfterSave()
} catch (err) {
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)

View File

@@ -133,16 +133,114 @@ describe('useVaultLoader', () => {
})
})
describe('isFileModified', () => {
it('returns true for modified files', async () => {
describe('getNoteStatus', () => {
it('returns modified for git-modified files', async () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.isFileModified('/vault/note/hello.md')).toBe(true)
expect(result.current.isFileModified('/vault/note/other.md')).toBe(false)
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('modified')
expect(result.current.getNoteStatus('/vault/note/other.md')).toBe('clean')
})
it('returns new for freshly added entries', async () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.entries).toHaveLength(1)
})
const newEntry: VaultEntry = {
...mockEntries[0],
path: '/vault/note/brand-new.md',
filename: 'brand-new.md',
title: 'Brand New',
}
act(() => {
result.current.addEntry(newEntry, '# Brand New')
})
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
})
it('returns clean after markSaved clears new status', async () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.entries).toHaveLength(1)
})
const newEntry: VaultEntry = {
...mockEntries[0],
path: '/vault/note/brand-new.md',
filename: 'brand-new.md',
title: 'Brand New',
}
act(() => {
result.current.addEntry(newEntry, '# Brand New')
})
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
act(() => {
result.current.markSaved('/vault/note/brand-new.md')
})
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('clean')
})
it('new status takes priority over git modified', async () => {
// If a path is both new and in modifiedFiles, it should show as new
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([
{ path: '/vault/note/new.md', relativePath: 'note/new.md', status: 'modified' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
const newEntry: VaultEntry = {
...mockEntries[0],
path: '/vault/note/new.md',
filename: 'new.md',
title: 'New',
}
act(() => {
result.current.addEntry(newEntry, '# New')
})
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
})
it('ignores untracked git status for orange dot', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([
{ path: '/vault/note/hello.md', relativePath: 'note/hello.md', status: 'untracked' },
])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => {
expect(result.current.modifiedFiles).toHaveLength(1)
})
expect(result.current.getNoteStatus('/vault/note/hello.md')).toBe('clean')
})
})

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile } from '../types'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
@@ -30,18 +30,46 @@ async function commitWithPush(vaultPath: string, message: string): Promise<strin
}
}
function useNewNoteTracker() {
const [newPaths, setNewPaths] = useState<Set<string>>(new Set())
const trackNew = useCallback((path: string) => {
setNewPaths((prev) => new Set(prev).add(path))
}, [])
const markSaved = useCallback((path: string) => {
setNewPaths((prev) => {
if (!prev.has(path)) return prev
const next = new Set(prev)
next.delete(path)
return next
})
}, [])
const clear = useCallback(() => setNewPaths(new Set()), [])
return { newPaths, trackNew, markSaved, clear }
}
export function resolveNoteStatus(path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[]): NoteStatus {
if (newPaths.has(path)) return 'new'
if (modifiedFiles.some((f) => f.path === path && f.status === 'modified')) return 'modified'
return 'clean'
}
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const tracker = useNewNoteTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([])
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
}, [vaultPath])
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
const loadModifiedFiles = useCallback(async () => {
try {
@@ -57,24 +85,18 @@ export function useVaultLoader(vaultPath: string) {
const addEntry = useCallback((entry: VaultEntry, content: string) => {
setEntries((prev) => [entry, ...prev])
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
}, [])
tracker.trackNew(entry.path)
}, [tracker])
const updateContent = useCallback((path: string, content: string) => {
setAllContent((prev) => ({ ...prev, [path]: content }))
}, [])
const updateContent = useCallback((path: string, content: string) =>
setAllContent((prev) => ({ ...prev, [path]: content })), [])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e))
}, [])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
setAllContent((prev) => {
const next = { ...prev }
delete next[oldPath]
next[patch.path] = newContent
return next
})
setAllContent((prev) => { const next = { ...prev }; delete next[oldPath]; next[patch.path] = newContent; return next })
}, [])
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
@@ -83,34 +105,21 @@ export function useVaultLoader(vaultPath: string) {
}, [vaultPath])
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }),
[vaultPath])
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath])
const loadDiff = useCallback((path: string): Promise<string> =>
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }),
[vaultPath])
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
const isFileModified = useCallback((path: string): boolean =>
modifiedFiles.some((f) => f.path === path),
[modifiedFiles])
const getNoteStatus = useCallback((path: string): NoteStatus =>
resolveNoteStatus(path, tracker.newPaths, modifiedFiles), [tracker.newPaths, modifiedFiles])
const commitAndPush = useCallback((message: string): Promise<string> =>
commitWithPush(vaultPath, message),
[vaultPath])
commitWithPush(vaultPath, message), [vaultPath])
return {
entries,
allContent,
modifiedFiles,
addEntry,
updateEntry,
replaceEntry,
updateContent,
loadModifiedFiles,
loadGitHistory,
loadDiff,
loadDiffAtCommit,
isFileModified,
commitAndPush,
entries, allContent, modifiedFiles,
addEntry, updateEntry, replaceEntry, updateContent,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, markSaved: tracker.markSaved, commitAndPush,
}
}

View File

@@ -26,6 +26,8 @@ export interface VaultEntry {
order: number | null
}
export type NoteStatus = 'new' | 'modified' | 'clean'
export interface GitCommit {
hash: string
shortHash: string