Merge pull request #28 from refactoringhq/task/modified-notes-indicator
feat: modified notes indicator (orange dot in NoteList, TabBar, StatusBar)
This commit is contained in:
30
.claude-done
30
.claude-done
@@ -1,18 +1,20 @@
|
||||
Task: performance-note-list
|
||||
Summary: Implemented virtual list rendering for NoteList using react-virtuoso.
|
||||
Task: modified-notes-indicator
|
||||
Summary: Implemented visual indicators for uncommitted modified notes across the UI.
|
||||
|
||||
Changes:
|
||||
- NoteList ListView now uses <Virtuoso> component instead of .map() for flat lists
|
||||
- Only visible items are rendered in the DOM (overscan: 200px)
|
||||
- EntityView (relationship groups) unchanged — groups are small (<100 items)
|
||||
- mock-tauri.ts generates 9000 bulk entries for testing
|
||||
- Test setup mocks react-virtuoso for JSDOM compatibility
|
||||
- 7 new tests covering large dataset rendering, search, sorting, filtering
|
||||
- Design file: design/performance-note-list.pen (4 frames)
|
||||
- Docs updated: ARCHITECTURE.md notes virtual rendering approach
|
||||
- NoteItem: Orange dot (accent-orange) before title for notes in git modified/added state
|
||||
- TabBar: Orange dot between title and close button for tabs with uncommitted changes
|
||||
- StatusBar: "N pending" counter with CircleDot icon when modified files exist
|
||||
- useEditorSave: Added onAfterSave callback to refresh modified files list after save
|
||||
- mock-tauri: Dynamically tracks saved files as modified, clears on commit
|
||||
- Design file: 3 frames showing NoteList, TabBar, and StatusBar indicator states
|
||||
|
||||
Product decisions:
|
||||
- Only virtualized ListView (flat lists), not EntityView — relationship groups
|
||||
are inherently small and do not need virtualization
|
||||
- Used overscan={200} for smooth scrolling experience
|
||||
- PinnedCard and TrashWarningBanner rendered as Virtuoso Header component
|
||||
- Used accent-orange (#D9730D) for the indicator dot to stand out from type-colored icons
|
||||
- Dot appears before title in NoteList (consistent with how editors show file state)
|
||||
- Dot appears between title and close button in TabBar (visible but non-intrusive)
|
||||
- StatusBar shows "N pending" text (not just a number) for clarity
|
||||
- Indicators disappear after git commit (mockHasChanges + mockSavedPaths cleared)
|
||||
|
||||
Tests: 12 new tests covering NoteList (4), TabBar (3), StatusBar (3), useEditorSave (2)
|
||||
All 543 tests pass. Build succeeds. Code health gates passed.
|
||||
|
||||
537
design/modified-notes-indicator.pen
Normal file
537
design/modified-notes-indicator.pen
Normal file
@@ -0,0 +1,537 @@
|
||||
{
|
||||
"version": "2.8",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni01",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Modified Note Indicator — NoteList States",
|
||||
"width": 340,
|
||||
"height": "fit_content(600)",
|
||||
"fill": "$--card",
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni01t",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "NoteList — Modified vs Clean",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"textTransform": "uppercase",
|
||||
"letterSpacing": 1,
|
||||
"width": "fill_container",
|
||||
"padding": [8, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni02",
|
||||
"name": "Note Item — Modified",
|
||||
"width": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni02r",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni02tg",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "ellipse",
|
||||
"id": "mni02dot",
|
||||
"name": "Modified Indicator",
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"fill": "$--accent-orange"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni02ti",
|
||||
"fill": "$--foreground",
|
||||
"content": "Build Laputa App",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni02ic",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "wrench",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--accent-red"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni02sn",
|
||||
"fill": "$--muted-foreground",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Personal knowledge management app built with Tauri + React.",
|
||||
"lineHeight": 1.5,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni02tm",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "2m ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni03",
|
||||
"name": "Note Item — Clean (no indicator)",
|
||||
"width": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni03r",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni03ti",
|
||||
"fill": "$--foreground",
|
||||
"content": "Facebook Ads Strategy",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni03ic",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "file-text",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni03sn",
|
||||
"fill": "$--muted-foreground",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Lookalike audiences convert 3x better than cold.",
|
||||
"lineHeight": 1.5,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni03tm",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "1h ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni04",
|
||||
"name": "Note Item — Added (new file)",
|
||||
"width": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [14, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni04r",
|
||||
"name": "titleRow",
|
||||
"width": "fill_container",
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni04tg",
|
||||
"gap": 6,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "ellipse",
|
||||
"id": "mni04dot",
|
||||
"name": "Modified Indicator",
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"fill": "$--accent-orange"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni04ti",
|
||||
"fill": "$--foreground",
|
||||
"content": "AI Agents Primer",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni04ic",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "file-text",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni04sn",
|
||||
"fill": "$--muted-foreground",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "How autonomous agents are changing software development.",
|
||||
"lineHeight": 1.5,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni04tm",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "5m ago",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni10",
|
||||
"x": 380,
|
||||
"y": 0,
|
||||
"name": "Modified Note Indicator — TabBar States",
|
||||
"width": 800,
|
||||
"height": 120,
|
||||
"fill": "$--sidebar",
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni10t",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "TabBar — Modified Dot on Dirty Tabs",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"textTransform": "uppercase",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11",
|
||||
"name": "Tab Bar with Modified Indicators",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--sidebar-border" },
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11a",
|
||||
"name": "Tab Active — Modified",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"stroke": { "align": "inside", "thickness": { "right": 1 }, "fill": "$--border" },
|
||||
"gap": 6,
|
||||
"padding": [0, 14],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni11at",
|
||||
"fill": "$--foreground",
|
||||
"content": "Build Laputa App",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "ellipse",
|
||||
"id": "mni11adot",
|
||||
"name": "Tab Modified Dot",
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"fill": "$--accent-orange"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni11ax",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11b",
|
||||
"name": "Tab Inactive — Clean",
|
||||
"height": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "right": 1, "bottom": 1 }, "fill": "$--sidebar-border" },
|
||||
"gap": 6,
|
||||
"padding": [0, 14],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni11bt",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Facebook Ads Strategy",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni11bx",
|
||||
"opacity": 0,
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11c",
|
||||
"name": "Tab Inactive — Modified",
|
||||
"height": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "right": 1, "bottom": 1 }, "fill": "$--sidebar-border" },
|
||||
"gap": 6,
|
||||
"padding": [0, 14],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni11ct",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "AI Agents Primer",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "ellipse",
|
||||
"id": "mni11cdot",
|
||||
"name": "Tab Modified Dot",
|
||||
"width": 6,
|
||||
"height": 6,
|
||||
"fill": "$--accent-orange"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni11cx",
|
||||
"opacity": 0,
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11sp",
|
||||
"name": "tabSpacer",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" }
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni11ctrl",
|
||||
"name": "tabControls",
|
||||
"height": "fill_container",
|
||||
"stroke": { "align": "inside", "thickness": { "bottom": 1, "left": 1 }, "fill": "$--border" },
|
||||
"gap": 12,
|
||||
"padding": [0, 12],
|
||||
"justifyContent": "space_around",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "mni11plus",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "plus",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni20",
|
||||
"x": 0,
|
||||
"y": 400,
|
||||
"name": "Modified Note Indicator — StatusBar Pending Count",
|
||||
"width": 800,
|
||||
"height": 80,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mni20t",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "StatusBar — Pending Changes Counter",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"textTransform": "uppercase",
|
||||
"letterSpacing": 1,
|
||||
"padding": [8, 16]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21",
|
||||
"name": "StatusBar with Pending Count",
|
||||
"width": "fill_container",
|
||||
"height": 30,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": { "align": "inside", "thickness": { "top": 1 }, "fill": "$--border" },
|
||||
"padding": [0, 8],
|
||||
"justifyContent": "space_between",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21l",
|
||||
"name": "left",
|
||||
"gap": 12,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21v",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "icon_font", "id": "mni21fi", "width": 13, "height": 13, "iconFontName": "folder-open", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
|
||||
{ "type": "text", "id": "mni21fl", "fill": "$--muted-foreground", "content": "Demo v2", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "mni21s1", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21br",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "icon_font", "id": "mni21gi", "width": 13, "height": 13, "iconFontName": "git-branch", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
|
||||
{ "type": "text", "id": "mni21gl", "fill": "$--muted-foreground", "content": "main", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "mni21s2", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21sy",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "icon_font", "id": "mni21ri", "width": 13, "height": 13, "iconFontName": "refresh-cw", "iconFontFamily": "lucide", "fill": "$--accent-green" },
|
||||
{ "type": "text", "id": "mni21rl", "fill": "$--muted-foreground", "content": "Synced 2m ago", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
},
|
||||
{ "type": "text", "id": "mni21s3", "fill": "$--border", "content": "|", "fontFamily": "Inter", "fontSize": 11 },
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21pd",
|
||||
"name": "Pending Count",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "icon_font", "id": "mni21pi", "width": 13, "height": 13, "iconFontName": "circle-dot", "iconFontFamily": "lucide", "fill": "$--accent-orange" },
|
||||
{ "type": "text", "id": "mni21pl", "fill": "$--muted-foreground", "content": "3 pending", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21r",
|
||||
"name": "right",
|
||||
"gap": 12,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "mni21nc",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{ "type": "icon_font", "id": "mni21ni", "width": 13, "height": 13, "iconFontName": "file-text", "iconFontFamily": "lucide", "fill": "$--muted-foreground" },
|
||||
{ "type": "text", "id": "mni21nl", "fill": "$--muted-foreground", "content": "9,200 notes", "fontFamily": "Inter", "fontSize": 11 }
|
||||
]
|
||||
},
|
||||
{ "type": "icon_font", "id": "mni21set", "width": 14, "height": 14, "iconFontName": "settings", "iconFontFamily": "lucide", "fill": "$--muted-foreground" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"themes": { "Mode": ["Dark"] },
|
||||
"variables": {},
|
||||
"fonts": []
|
||||
}
|
||||
@@ -69,13 +69,13 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
│Events │ │ │ │
|
||||
│Topics │ │ │ │
|
||||
├────────┴─────────────┴─────────────────────────┴────────────┤
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago 1,247 notes│
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
|
||||
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. The flat list view (All Notes, type sections, etc.) uses **react-virtuoso** for virtual rendering — only visible items are in the DOM, enabling smooth scrolling with 9000+ notes. Entity/relationship views are not virtualized (groups are small).
|
||||
- **Editor** (flex, fills remaining space): Tab bar, breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files.
|
||||
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
|
||||
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files.
|
||||
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -439,6 +439,7 @@ export const Editor = memo(function Editor({
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabPath={activeTabPath}
|
||||
isModified={isModified}
|
||||
onSwitchTab={onSwitchTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -716,6 +716,47 @@ 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 },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} 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', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={[]} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-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 },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} modifiedFiles={modifiedFiles} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show modified indicator when modifiedFiles 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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -128,6 +128,22 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified count when modifiedCount is > 0', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.getByTestId('status-modified-count')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 pending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show modified count when modifiedCount is 0', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show modified count when modifiedCount is not provided', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes menu after clicking "Open local folder"', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -110,6 +110,28 @@ describe('TabBar', () => {
|
||||
expect(onReorderTabs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 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} />)
|
||||
expect(screen.queryByTestId('tab-modified-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} />)
|
||||
expect(screen.getAllByTestId('tab-modified-indicator')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('switches tab on click', () => {
|
||||
const onSwitchTab = vi.fn()
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
|
||||
@@ -15,9 +15,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
}))
|
||||
|
||||
describe('useEditorSave', () => {
|
||||
let updateVaultContent: vi.Mock
|
||||
let setTabs: vi.Mock
|
||||
let setToastMessage: vi.Mock
|
||||
let updateVaultContent: Mock
|
||||
let setTabs: Mock
|
||||
let setToastMessage: Mock
|
||||
|
||||
beforeEach(() => {
|
||||
updateVaultContent = vi.fn()
|
||||
@@ -107,6 +107,43 @@ describe('useEditorSave', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onAfterSave callback after successful save', async () => {
|
||||
const onAfterSave = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/test/note.md', 'new content')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onAfterSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call onAfterSave when save fails', async () => {
|
||||
mockInvokeFn.mockRejectedValueOnce(new Error('Disk full'))
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const onAfterSave = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/test/note.md', 'content')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onAfterSave).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('handleContentChange buffers the latest content', () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user