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:
90
design/differenzia-pallino.pen
Normal file
90
design/differenzia-pallino.pen
Normal 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":{}}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface VaultEntry {
|
||||
order: number | null
|
||||
}
|
||||
|
||||
export type NoteStatus = 'new' | 'modified' | 'clean'
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
|
||||
Reference in New Issue
Block a user