Compare commits
6 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bec65a445 | ||
|
|
4fe6f15aa1 | ||
|
|
a093ff4631 | ||
|
|
3ed1fb4ec4 | ||
|
|
7ed0787990 | ||
|
|
56ddaba105 |
@@ -1,6 +1,6 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, notify Brian, then self-dispatch the next task.
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, then self-dispatch the next task.
|
||||
|
||||
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass **and `git push origin main` has succeeded**.
|
||||
|
||||
@@ -35,16 +35,8 @@ curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \
|
||||
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
|
||||
```
|
||||
|
||||
**3. Notify Luca (informational only — no action needed from him)**
|
||||
|
||||
```bash
|
||||
openclaw system event --text "laputa-task-done:$ARGUMENTS" --mode now
|
||||
```
|
||||
|
||||
This is a passive notification. Luca reviews tasks in his own time. Do NOT wait for approval — proceed immediately to step 4.
|
||||
|
||||
**4. Pick the next task**
|
||||
**3. Pick the next task**
|
||||
|
||||
Run `/laputa-next-task` to get the next task and start working on it immediately.
|
||||
|
||||
If `/laputa-next-task` returns `NO_TASKS` → exit cleanly. The hourly watchdog will restart you when new tasks arrive.
|
||||
If there are no tasks, `/laputa-next-task` will wait 10 minutes and retry automatically. Do NOT exit — stay alive and let it loop.
|
||||
|
||||
@@ -40,4 +40,17 @@ curl -s "https://api.todoist.com/api/v1/comments?task_id=<task_id>" \
|
||||
6. For To Rework tasks: read the ❌ QA failed comment — it tells you exactly what to fix
|
||||
7. Output: task ID, title, and full description so you can start working immediately
|
||||
|
||||
If no tasks are available in either section → output `NO_TASKS` and exit cleanly.
|
||||
If no tasks are available in either section → wait 10 minutes and try again (loop forever):
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
# ... check tasks ...
|
||||
if no_tasks; then
|
||||
sleep 600 # 10 minutes
|
||||
else
|
||||
break # got a task, proceed
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Do NOT exit when there are no tasks. Keep looping until a task appears. This keeps Claude Code alive permanently — the watchdog is a safety net only, not the primary dispatcher.
|
||||
|
||||
@@ -27,3 +27,16 @@ As Laputa added more features that store configuration in note frontmatter (pinn
|
||||
- Power users can still access and edit system properties via the raw editor.
|
||||
- Type documents use `_icon`, `_color`, `_order`, `_sidebar_label`, `_pinned_properties`.
|
||||
- Re-evaluation trigger: if the number of system properties grows large enough to warrant a structured sub-object.
|
||||
|
||||
## Normalized system properties
|
||||
|
||||
| Canonical key | Old keys (read with fallback) | Written by |
|
||||
|---|---|---|
|
||||
| `_archived` | `Archived`, `archived` | Archive action |
|
||||
| `_trashed` | `Trashed`, `trashed` | Trash action |
|
||||
| `_trashed_at` | `Trashed at`, `trashed_at` | Trash action |
|
||||
| `_favorite` | — | Favorite toggle |
|
||||
| `_favorite_index` | — | Favorite reorder |
|
||||
|
||||
**Write rule**: always use the canonical `_`-prefixed key.
|
||||
**Read rule**: accept both canonical and legacy keys (case-insensitive). Do NOT rewrite on read — migration is a separate concern.
|
||||
|
||||
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0041"
|
||||
title: "fileKind field — scan all vault files, not just markdown"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa vaults often contain non-markdown files alongside notes: images, PDFs, YAML configs, JSON exports, scripts, etc. Previously the vault scanner only indexed `.md` files — all other files were invisible to the app. This made the Folder view incomplete: navigating a folder containing a `config.yml` or `photo.png` showed nothing, even though the file was physically there.
|
||||
|
||||
The need arose when adding a Folder tree view that is meant to mirror the actual filesystem structure. Users expect to see all files in a folder, as any file manager would show.
|
||||
|
||||
## Decision
|
||||
|
||||
**The vault scanner now indexes all files (not just `.md`). Every `VaultEntry` carries a `fileKind` field (`"markdown"`, `"text"`, or `"binary"`) that controls how the frontend renders and opens it.**
|
||||
|
||||
- **`"markdown"`**: full Laputa behavior — frontmatter parsing, BlockNote editor, title sync, type system.
|
||||
- **`"text"`**: filename as title, no frontmatter, opens in raw CodeMirror editor. Covers `.yml`, `.json`, `.ts`, `.py`, `.sh`, etc.
|
||||
- **`"binary"`**: filename as title, grayed out, non-clickable. Covers images, PDFs, binaries.
|
||||
- **Hidden files** (starting with `.`) are skipped regardless of extension.
|
||||
- **Non-folder views** (All Notes, type sections, Custom Views) still show only `"markdown"` entries.
|
||||
- **Folder view** shows all file kinds.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Single `VaultEntry` model with a `fileKind` discriminator. All files go through the same pipeline; rendering is gated by `fileKind`. Simple, incremental — existing code paths untouched for markdown files.
|
||||
- **Option B**: Separate data model for non-markdown files (e.g. `AssetEntry`). Cleaner type hierarchy, but requires duplicating list/filter/sort logic for two types across the codebase.
|
||||
- **Option C**: Only scan `.md` + explicitly listed extensions (e.g. `.yml`, `.json`). Simpler initial implementation, but requires ongoing maintenance of an allowlist and still misses user files. Abandoned in favor of a deny-list approach (only `.`-prefixed hidden files are excluded).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Non-markdown files are visible in Folder view — the app now behaves like a file manager in that context.
|
||||
- All views except Folder view continue to show only markdown files (the `isMarkdown` guard in `filterEntries`).
|
||||
- `countByFilter` / `countAllByFilter` exclude non-markdown entries to keep sidebar counters accurate.
|
||||
- The vault cache version was bumped to `11` to force a full rescan after this change.
|
||||
- Binary files have no click action — clicking does nothing (no editor opened).
|
||||
- Re-evaluation trigger: if users need to preview or edit binary files (e.g. images), a dedicated preview pane would need a separate ADR.
|
||||
@@ -93,3 +93,7 @@ proposed → active → superseded
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
| [0038](0038-frontmatter-backed-favorites.md) | Frontmatter-backed favorites (_favorite, _favorite_index) | active |
|
||||
| [0039](0039-git-history-for-note-dates.md) | Git history as source of truth for note creation/modification dates | active |
|
||||
| [0040](0040-custom-views-yml-filter-engine.md) | Custom Views — .laputa/views/*.yml with YAML filter engine | active |
|
||||
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
|
||||
|
||||
@@ -1117,6 +1117,29 @@ fn test_parse_underscore_archived_canonical() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- favorite field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n";
|
||||
let entry = parse_test_entry(&dir, "fav.md", content);
|
||||
assert!(
|
||||
entry.favorite,
|
||||
"'_favorite: true' must be parsed as favorite"
|
||||
);
|
||||
assert_eq!(entry.favorite_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_absent_defaults_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n---\n# Not Fav\n";
|
||||
let entry = parse_test_entry(&dir, "not-fav.md", content);
|
||||
assert!(!entry.favorite, "absent _favorite must default to false");
|
||||
assert_eq!(entry.favorite_index, None);
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -64,6 +64,7 @@ const mockAllContent: Record<string, string> = {
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -4,6 +4,7 @@ import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
||||
import { CreateViewDialog } from './components/CreateViewDialog'
|
||||
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
@@ -326,6 +327,37 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection])
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
setToastMessage('View deleted')
|
||||
}, [resolvedPath, vault, selection, handleSetSelection])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customProps = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (e.properties) {
|
||||
for (const key of Object.keys(e.properties)) customProps.add(key)
|
||||
}
|
||||
}
|
||||
return [...builtIn, ...Array.from(customProps).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -488,7 +520,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -567,6 +599,7 @@ function App() {
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
|
||||
92
src/components/CreateViewDialog.tsx
Normal file
92
src/components/CreateViewDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterCondition, ViewDefinition } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (definition: ViewDefinition) => void
|
||||
availableFields: string[]
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([
|
||||
{ field: 'type', op: 'equals', value: '' },
|
||||
])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setConditions([{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }]) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, availableFields])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const validConditions = conditions.filter((c) => c.field)
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: validConditions },
|
||||
}
|
||||
onCreate(definition)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create View</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
<Input
|
||||
placeholder="📋"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
className="text-center"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Active Projects, Reading List..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
conditions={conditions}
|
||||
onChange={setConditions}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
105
src/components/FilterBuilder.tsx
Normal file
105
src/components/FilterBuilder.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { FilterCondition, FilterOp } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'not_contains', label: 'does not contain' },
|
||||
{ value: 'is_empty', label: 'is empty' },
|
||||
{ value: 'is_not_empty', label: 'is not empty' },
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
|
||||
interface FilterBuilderProps {
|
||||
conditions: FilterCondition[]
|
||||
onChange: (conditions: FilterCondition[]) => void
|
||||
availableFields: string[]
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={condition.field}
|
||||
onChange={(e) => onUpdate({ ...condition, field: e.target.value })}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
value={String(condition.value ?? '')}
|
||||
onChange={(e) => onUpdate({ ...condition, value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterBuilder({ conditions, onChange, availableFields }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
|
||||
const handleUpdate = (index: number, updated: FilterCondition) => {
|
||||
const next = [...conditions]
|
||||
next[index] = updated
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(conditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
onChange([...conditions, { field: fields[0], op: 'equals', value: '' }])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conditions.length > 1 && (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Match all of:</span>
|
||||
)}
|
||||
{conditions.map((c, i) => (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={c}
|
||||
fields={fields}
|
||||
onUpdate={(updated) => handleUpdate(i, updated)}
|
||||
onRemove={() => handleRemove(i)}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={handleAdd}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ interface SidebarProps {
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
views?: ViewFile[]
|
||||
onCreateView?: () => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
@@ -303,7 +304,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
|
||||
views = [], onCreateView,
|
||||
views = [], onCreateView, onDeleteView,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -392,7 +393,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
|
||||
{/* Views */}
|
||||
{views.length > 0 && (
|
||||
{(views.length > 0 || onCreateView) && (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Views</span>
|
||||
@@ -403,14 +404,24 @@ export const Sidebar = memo(function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<NavItem
|
||||
key={v.filename}
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -102,6 +102,21 @@ describe('TitleField', () => {
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('resets stale localValue when title prop changes after focus', () => {
|
||||
// Regression: creating a new note fires focus-editor before React re-renders,
|
||||
// so handleFocus captures the OLD note's title into localValue.
|
||||
// When React re-renders with the new title, localValue should be cleared.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate: focus fires while title prop is still "Old Note"
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('Old Note')
|
||||
// React re-renders with new note's title (tab switched)
|
||||
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Untitled note')
|
||||
})
|
||||
|
||||
it('shows vault-relative path for notes in subdirectories', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack.md')
|
||||
|
||||
@@ -19,6 +19,15 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const prevTitleRef = useRef(title)
|
||||
|
||||
// Reset local edit when the title prop changes (e.g. note switch).
|
||||
// This prevents a stale handleFocus closure from locking in the old note's title
|
||||
// when focus-editor fires before React re-renders with the new tab.
|
||||
if (prevTitleRef.current !== title) {
|
||||
prevTitleRef.current = title
|
||||
if (localValue !== null) setLocalValue(null)
|
||||
}
|
||||
|
||||
// Clear optimistic once the prop changes (rename completed or tab switched)
|
||||
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useDialogs() {
|
||||
const [showGitHubVault, setShowGitHubVault] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showConflictResolver, setShowConflictResolver] = useState(false)
|
||||
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false)
|
||||
|
||||
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
|
||||
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
|
||||
@@ -25,6 +26,8 @@ export function useDialogs() {
|
||||
const closeSearch = useCallback(() => setShowSearch(false), [])
|
||||
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
|
||||
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
|
||||
const openCreateView = useCallback(() => setShowCreateViewDialog(true), [])
|
||||
const closeCreateView = useCallback(() => setShowCreateViewDialog(false), [])
|
||||
|
||||
return {
|
||||
showCreateTypeDialog, openCreateType, closeCreateType,
|
||||
@@ -35,5 +38,6 @@ export function useDialogs() {
|
||||
showGitHubVault, openGitHubVault, closeGitHubVault,
|
||||
showSearch, openSearch, closeSearch,
|
||||
showConflictResolver, openConflictResolver, closeConflictResolver,
|
||||
showCreateViewDialog, openCreateView, closeCreateView,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +521,94 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleToggleFavorite', () => {
|
||||
it('favorites a note: writes _favorite and _favorite_index', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null })
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', 1, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: true, favoriteIndex: 1 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unfavorites a note: deletes _favorite and _favorite_index', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: true, favoriteIndex: 0 })
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null })
|
||||
})
|
||||
|
||||
it('assigns next available index when favoriting', async () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/a.md', favorite: true, favoriteIndex: 3 }),
|
||||
makeEntry({ path: '/vault/b.md', favorite: true, favoriteIndex: 5 }),
|
||||
makeEntry({ path: '/vault/c.md', favorite: false, favoriteIndex: null }),
|
||||
]
|
||||
const { result } = setup(entries)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/c.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 6, { silent: true })
|
||||
})
|
||||
|
||||
it('rolls back on failure', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null })
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to favorite — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does nothing if entry not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/nonexistent.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(handleDeleteProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderFavorites', () => {
|
||||
it('updates _favorite_index for all reordered paths', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReorderFavorites(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/a.md', '_favorite_index', 0, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/b.md', '_favorite_index', 1, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 2, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/a.md', { favoriteIndex: 0 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/b.md', { favoriteIndex: 1 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/c.md', { favoriteIndex: 2 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBeforeAction callback', () => {
|
||||
function setupWithBeforeAction(onBeforeAction: ReturnType<typeof vi.fn>) {
|
||||
return renderHook(() =>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
// Compared case-insensitively via isVisibleProperty()
|
||||
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_trashed', 'trashed', '_trashed_at', 'trashed_at', 'trashed at', '_archived', 'archived', 'archived_at', 'icon'])
|
||||
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'is a', '_trashed', 'trashed', '_trashed_at', 'trashed_at', 'trashed at', '_archived', 'archived', 'archived_at', 'icon', '_favorite', '_favorite_index'])
|
||||
|
||||
function coerceValue(raw: string): FrontmatterValue {
|
||||
if (raw.toLowerCase() === 'true') return true
|
||||
|
||||
Reference in New Issue
Block a user