Compare commits
4 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088e0bca8e | ||
|
|
45249bade4 | ||
|
|
917773bc4c | ||
|
|
d39d691ccd |
@@ -127,7 +127,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
}
|
||||
|
||||
/// Parse a non-markdown file into a minimal VaultEntry.
|
||||
/// Uses filename as title, no frontmatter extraction.
|
||||
/// Uses filename as title, except for `.yml` files where the YAML `name` field is used.
|
||||
pub(crate) fn parse_non_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
@@ -142,11 +142,12 @@ pub(crate) fn parse_non_md_file(
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
let title = extract_yml_name(path).unwrap_or_else(|| filename.clone());
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title: filename,
|
||||
title,
|
||||
file_kind,
|
||||
modified_at,
|
||||
created_at,
|
||||
@@ -155,6 +156,17 @@ pub(crate) fn parse_non_md_file(
|
||||
})
|
||||
}
|
||||
|
||||
/// For `.yml` files, try to extract the `name` field from the YAML content.
|
||||
fn extract_yml_name(path: &Path) -> Option<String> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if ext != "yml" && ext != "yaml" {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
|
||||
mapping.get("name")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
@@ -1491,6 +1491,36 @@ fn test_list_properties_display_not_in_properties_or_relationships() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yml_file_uses_name_field_as_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let yml_content = "name: Active Projects\nicon: rocket\ncolor: blue\n";
|
||||
let yml_path = dir.path().join("active-projects.yml");
|
||||
std::fs::write(&yml_path, yml_content).unwrap();
|
||||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||||
assert_eq!(entry.title, "Active Projects");
|
||||
assert_eq!(entry.filename, "active-projects.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yml_file_without_name_falls_back_to_filename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let yml_content = "key: value\n";
|
||||
let yml_path = dir.path().join("config.yml");
|
||||
std::fs::write(&yml_path, yml_content).unwrap();
|
||||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||||
assert_eq!(entry.title, "config.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_yml_file_uses_filename_as_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let txt_path = dir.path().join("notes.txt");
|
||||
std::fs::write(&txt_path, "some content").unwrap();
|
||||
let entry = super::parse_non_md_file(&txt_path, None).unwrap();
|
||||
assert_eq!(entry.title, "notes.txt");
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -587,7 +587,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} views={vault.views} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
@@ -113,9 +112,7 @@
|
||||
|
||||
.editor__blocknote-container .bn-editor {
|
||||
width: 100%;
|
||||
padding: 20px 40px;
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
@@ -188,12 +185,21 @@
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* --- Title Section: wraps icon + title + separator, aligned to editor --- */
|
||||
.title-section {
|
||||
width: 100%;
|
||||
/* --- Editor content wrapper: single source of truth for horizontal padding --- */
|
||||
.editor-content-wrapper {
|
||||
max-width: var(--editor-max-width, 760px);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--editor-padding-horizontal, 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- Title Section: wraps icon + title + separator --- */
|
||||
.title-section {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -364,12 +370,25 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Disable BlockNote/Mantine editor animations
|
||||
=============================================
|
||||
Scoped to .editor__blocknote-container so app-wide
|
||||
components (dialogs, sidebar, etc.) are unaffected.
|
||||
Preserves cursor blink and text selection. */
|
||||
.editor__blocknote-container *,
|
||||
.editor__blocknote-container *::before,
|
||||
.editor__blocknote-container *::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Reduce padding at narrow editor widths so content isn't cramped */
|
||||
@container editor (max-width: 600px) {
|
||||
.editor__blocknote-container .bn-editor {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.title-section {
|
||||
.editor-content-wrapper {
|
||||
padding: 0 16px;
|
||||
}
|
||||
.editor__blocknote-container .bn-editor {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,25 @@ describe('FilterBuilder wikilink autocomplete', () => {
|
||||
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
|
||||
})
|
||||
|
||||
it('renders calendar date picker button for date operators', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'created', op: 'before', value: '2024-06-01' }],
|
||||
})
|
||||
const dateButton = screen.getByTestId('date-picker-trigger')
|
||||
expect(dateButton).toBeInTheDocument()
|
||||
expect(dateButton).toHaveTextContent('Jun 1, 2024')
|
||||
// Should NOT have a native input type="date"
|
||||
expect(screen.queryByDisplayValue('2024-06-01')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders date picker placeholder when no date is selected', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'created', op: 'after', value: '' }],
|
||||
})
|
||||
const dateButton = screen.getByTestId('date-picker-trigger')
|
||||
expect(dateButton).toHaveTextContent('Pick a date')
|
||||
})
|
||||
|
||||
it('shows body field in field dropdown separated from property fields', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
@@ -282,6 +285,32 @@ function WikilinkValueInput({ value, entries, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const parsed = value ? parseISO(value) : undefined
|
||||
const selected = parsed && !isNaN(parsed.getTime()) ? parsed : undefined
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 flex-1 min-w-0 justify-start gap-2 px-2 text-sm font-normal"
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
{selected ? format(selected, 'MMM d, yyyy') : <span className="text-muted-foreground">Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
@@ -290,14 +319,7 @@ function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
if (isDateOp) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
return <DateValueInput value={value} onChange={onChange} />
|
||||
}
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
|
||||
@@ -139,6 +139,28 @@ function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProp
|
||||
)
|
||||
}
|
||||
|
||||
const CHANGE_STATUS_DISPLAY: Record<string, { label: string; color: string; symbol: string }> = {
|
||||
modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' },
|
||||
added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
|
||||
untracked: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
|
||||
deleted: { label: 'Deleted', color: 'var(--destructive, #ef4444)', symbol: '−' },
|
||||
renamed: { label: 'Renamed', color: 'var(--accent-orange, #f59e0b)', symbol: 'R' },
|
||||
}
|
||||
|
||||
function ChangeStatusIcon({ status }: { status: string }) {
|
||||
const display = CHANGE_STATUS_DISPLAY[status] ?? CHANGE_STATUS_DISPLAY.modified
|
||||
return (
|
||||
<span
|
||||
className="absolute right-3 top-2.5 text-xs font-bold"
|
||||
style={{ color: display.color, fontSize: status === 'modified' ? 18 : 14 }}
|
||||
title={display.label}
|
||||
data-testid="change-status-icon"
|
||||
>
|
||||
{display.symbol}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
|
||||
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
|
||||
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
|
||||
@@ -152,12 +174,14 @@ function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttribu
|
||||
return FileText
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch, onContextMenu }: {
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, onClickNote, onPrefetch, onContextMenu }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
isMultiSelected?: boolean
|
||||
isHighlighted?: boolean
|
||||
noteStatus?: NoteStatus
|
||||
/** When set, renders in Changes-view style: filename + change type icon */
|
||||
changeStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
onPrefetch?: (path: string) => void
|
||||
@@ -194,27 +218,40 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
data-highlighted={isHighlighted || undefined}
|
||||
title={isBinary ? 'Cannot open this file type' : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<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]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
{!isBinary && <StateBadge archived={entry.archived} trashed={entry.trashed} />}
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && !isBinary && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
|
||||
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
|
||||
)}
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
{changeStatus ? (
|
||||
<>
|
||||
<ChangeStatusIcon status={changeStatus} />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] font-mono", isSelected ? "font-semibold" : "font-normal")} style={{ fontSize: 12 }}>
|
||||
{entry.filename}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<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]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
{!isBinary && <StateBadge archived={entry.archived} trashed={entry.trashed} />}
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && !isBinary && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
|
||||
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
|
||||
)}
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -950,12 +950,13 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
|
||||
it('shows only modified notes in changes view', () => {
|
||||
it('shows only modified notes in changes view with filenames', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
// Changes view shows filenames instead of titles
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Kickoff Meeting')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -978,16 +979,16 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
const { rerender } = render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
|
||||
// Simulate one file being committed (removed from modifiedFiles)
|
||||
const fewerModified = [modifiedFiles[0]]
|
||||
rerender(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified notes when both getNoteStatus and modifiedFiles are provided', () => {
|
||||
@@ -997,9 +998,9 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => {
|
||||
@@ -1016,9 +1017,9 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
<NoteList {...defaultFilterProps} entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Even though absolute paths differ, entries should match via relative path suffix
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('facebook-ads-strategy.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('matteo-cellini.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message when modifiedFilesError is set', () => {
|
||||
@@ -1037,9 +1038,21 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('matteo-cellini.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('facebook-ads-strategy.md')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows change status icons for each modified file', () => {
|
||||
const mixedFiles = [
|
||||
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const icons = screen.getAllByTestId('change-status-icon')
|
||||
expect(icons).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows deleted notes banner when files are deleted', () => {
|
||||
@@ -1051,7 +1064,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 notes deleted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1087,7 +1100,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
|
||||
)
|
||||
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
|
||||
@@ -1097,7 +1110,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1107,7 +1120,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
|
||||
)
|
||||
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
expect(screen.getByTestId('discard-confirm-dialog')).toBeInTheDocument()
|
||||
@@ -1121,7 +1134,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
|
||||
)
|
||||
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
fireEvent.click(screen.getByTestId('discard-confirm-button'))
|
||||
@@ -1133,7 +1146,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
|
||||
)
|
||||
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
|
||||
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
|
||||
fireEvent.contextMenu(noteItem)
|
||||
fireEvent.click(screen.getByTestId('discard-changes-button'))
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
@@ -1553,3 +1566,34 @@ describe('NoteItem — binary file rendering', () => {
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteItem — changeStatus rendering', () => {
|
||||
it('shows filename instead of title when changeStatus is set', () => {
|
||||
const entry = makeEntry({ filename: 'my-note.md', title: 'My Note Title' })
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
|
||||
expect(screen.getByText('my-note.md')).toBeInTheDocument()
|
||||
expect(screen.queryByText('My Note Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows change status icon with correct symbol for modified', () => {
|
||||
const entry = makeEntry({ filename: 'note.md' })
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="modified" />)
|
||||
const icon = screen.getByTestId('change-status-icon')
|
||||
expect(icon.textContent).toBe('·')
|
||||
})
|
||||
|
||||
it('shows + symbol for added files', () => {
|
||||
const entry = makeEntry({ filename: 'new-note.md' })
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} changeStatus="added" />)
|
||||
const icon = screen.getByTestId('change-status-icon')
|
||||
expect(icon.textContent).toBe('+')
|
||||
})
|
||||
|
||||
it('shows normal title rendering when changeStatus is not set', () => {
|
||||
const entry = makeEntry({ filename: 'note.md', title: 'My Note' })
|
||||
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
|
||||
expect(screen.getByText('My Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('note.md')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,10 +138,11 @@ interface NoteListProps {
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
onDiscardFile?: (relativePath: string) => Promise<void>
|
||||
onAutoTriggerDiff?: () => void
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, views }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
|
||||
@@ -157,6 +158,16 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const changeStatusMap = useMemo(() => {
|
||||
if (!isChangesView || !modifiedFiles) return undefined
|
||||
const map = new Map<string, ModifiedFile['status']>()
|
||||
for (const mf of modifiedFiles) {
|
||||
map.set(mf.path, mf.status)
|
||||
// Also index by suffix for matching (vault entries may use different path formats)
|
||||
map.set('/' + mf.relativePath, mf.status)
|
||||
}
|
||||
return map
|
||||
}, [isChangesView, modifiedFiles])
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
@@ -170,16 +181,31 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
|
||||
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
|
||||
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect])
|
||||
if (isChangesView && onAutoTriggerDiff) {
|
||||
// Small delay to let the tab open before triggering diff
|
||||
setTimeout(onAutoTriggerDiff, 50)
|
||||
}
|
||||
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
|
||||
|
||||
const { handleBulkArchive, handleBulkTrash, handleBulkRestore, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrRestore, bulkTrashOrDelete } = useBulkActions(multiSelect, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, isTrashView, isArchivedView)
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
|
||||
|
||||
const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
|
||||
|
||||
const getChangeStatus = useCallback((path: string) => {
|
||||
if (!changeStatusMap) return undefined
|
||||
const direct = changeStatusMap.get(path)
|
||||
if (direct) return direct
|
||||
// Try suffix match
|
||||
for (const [key, status] of changeStatusMap) {
|
||||
if (path.endsWith(key) || key.endsWith(path.split('/').slice(-1)[0])) return status
|
||||
}
|
||||
return undefined
|
||||
}, [changeStatusMap])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
|
||||
|
||||
const handleCreateNote = useCallback(() => {
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
|
||||
Reference in New Issue
Block a user