feat: add modified note indicators in NoteList, TabBar, and StatusBar

- NoteItem: orange dot before title for uncommitted modified notes
- TabBar: orange dot on tabs with uncommitted changes
- StatusBar: "N pending" counter with CircleDot icon when modified files exist
- useEditorSave: onAfterSave callback to refresh modified files after save
- mock-tauri: track dynamically saved files as modified, clear on commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 21:13:25 +01:00
parent 504bfa4907
commit 64de189db7
8 changed files with 60 additions and 14 deletions

View File

@@ -91,6 +91,7 @@ function App() {
updateVaultContent: vault.updateContent,
setTabs: notes.setTabs,
setToastMessage,
onAfterSave: vault.loadModifiedFiles,
})
const entryActions = useEntryActions({
@@ -271,7 +272,7 @@ function App() {
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onCreateNewVault={handleCreateNewVault} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultPath} vaults={allVaults} onSwitchVault={handleSwitchVault} onOpenSettings={() => setShowSettings(true)} onOpenLocalFolder={handleOpenLocalFolder} onCreateNewVault={handleCreateNewVault} onConnectGitHub={() => setShowGitHubVault(true)} hasGitHub={!!settings.github_token} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={() => setShowQuickOpen(false)} />
<CreateTypeDialog open={showCreateTypeDialog} onClose={() => setShowCreateTypeDialog(false)} onCreate={handleCreateType} />

View File

@@ -439,6 +439,7 @@ export const Editor = memo(function Editor({
<TabBar
tabs={tabs}
activeTabPath={activeTabPath}
isModified={isModified}
onSwitchTab={onSwitchTab}
onCloseTab={onCloseTab}
onCreateNote={onCreateNote}

View File

@@ -46,9 +46,10 @@ function TrashDateLine({ entry }: { entry: VaultEntry }) {
)
}
export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: {
export function NoteItem({ entry, isSelected, isModified, typeEntryMap, onClickNote }: {
entry: VaultEntry
isSelected: boolean
isModified?: boolean
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
@@ -74,6 +75,14 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onClickNote }: {
<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 && (
<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)"
/>
)}
{entry.title}
{entry.archived && (
<span className="ml-1.5 inline-block align-middle text-muted-foreground" style={{ fontSize: 9, fontWeight: 500, background: 'var(--muted)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}>

View File

@@ -247,6 +247,11 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
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 })
}, [])
@@ -267,8 +272,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} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap])
<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])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, FolderPlus } from 'lucide-react'
import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, FolderPlus, CircleDot } from 'lucide-react'
export interface VaultOption {
label: string
@@ -8,6 +8,7 @@ export interface VaultOption {
interface StatusBarProps {
noteCount: number
modifiedCount?: number
vaultPath: string
vaults: VaultOption[]
onSwitchVault: (path: string) => void
@@ -121,7 +122,7 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, hasGitHub }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onCreateNewVault, onConnectGitHub, hasGitHub }: StatusBarProps) {
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -132,6 +133,12 @@ export function StatusBar({ noteCount, vaultPath, vaults, onSwitchVault, onOpenS
<span style={ICON_STYLE}><GitBranch size={13} />main</span>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><RefreshCw size={13} style={{ color: 'var(--accent-green)' }} />Synced 2m ago</span>
{modifiedCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE} data-testid="status-modified-count"><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>

View File

@@ -12,6 +12,7 @@ interface Tab {
interface TabBarProps {
tabs: Tab[]
activeTabPath: string | null
isModified?: (path: string) => boolean
onSwitchTab: (path: string) => void
onCloseTab: (path: string) => void
onCreateNote?: () => void
@@ -151,10 +152,11 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
)
}
function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
function TabItem({ tab, isActive, isEditing, isModified, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
tab: Tab
isActive: boolean
isEditing: boolean
isModified: boolean
isDragging: boolean
showDropBefore: boolean
showDropAfter: boolean
@@ -192,6 +194,14 @@ function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDro
{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)"
/>
)}
<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",
@@ -233,7 +243,7 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
// --- Main TabBar ---
export const TabBar = memo(function TabBar({
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
tabs, activeTabPath, isModified, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
@@ -251,6 +261,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}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}

View File

@@ -12,13 +12,14 @@ interface EditorSaveConfig {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tab types vary between layers
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => 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 }: EditorSaveConfig) {
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
@@ -41,11 +42,12 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage }:
await saveNote(pending.path, pending.content)
pendingContentRef.current = null
setToastMessage('Saved')
onAfterSave?.()
} catch (err) {
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)
}
}, [saveNote, setToastMessage])
}, [saveNote, setToastMessage, onAfterSave])
/** Called by Editor onChange — buffers the latest content without saving */
const handleContentChange = useCallback((path: string, content: string) => {

View File

@@ -1654,9 +1654,9 @@ function mockModifiedFiles(): ModifiedFile[] {
status: 'modified',
},
{
path: '/Users/luca/Laputa/note/new-draft.md',
relativePath: 'note/new-draft.md',
status: 'untracked',
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
relativePath: 'essay/ai-agents-primer.md',
status: 'added',
},
]
}
@@ -1700,6 +1700,7 @@ index abc1234..${shortHash} 100644
}
let mockHasChanges = true
const mockSavedPaths = new Set<string>()
let mockSettings: Settings = {
anthropic_key: null,
@@ -1714,11 +1715,19 @@ const mockHandlers: Record<string, (args: any) => any> = {
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
get_all_content: () => MOCK_CONTENT,
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
get_modified_files: () => mockHasChanges ? mockModifiedFiles() : [],
get_modified_files: () => {
const base = mockHasChanges ? mockModifiedFiles() : []
const basePaths = new Set(base.map((f) => f.path))
const extra: ModifiedFile[] = [...mockSavedPaths]
.filter((p) => !basePaths.has(p))
.map((p) => ({ path: p, relativePath: p.split('/').slice(-2).join('/'), status: 'modified' as const }))
return [...base, ...extra]
},
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
git_commit: (args: { message: string }) => {
mockHasChanges = false
mockSavedPaths.clear()
return `[main abc1234] ${args.message}\n 3 files changed`
},
git_push: () => {
@@ -1743,6 +1752,7 @@ const mockHandlers: Record<string, (args: any) => any> = {
},
save_note_content: (args: { path: string; content: string }) => {
MOCK_CONTENT[args.path] = args.content
mockSavedPaths.add(args.path)
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
}