Compare commits

...

3 Commits

Author SHA1 Message Date
Test
2bec65a445 chore: claude code loops forever — waits 10min when no tasks instead of exiting 2026-04-03 15:10:40 +02:00
Test
4fe6f15aa1 docs: add ADR 0041 for fileKind/all-files-in-vault-scanner; update README index for 0038–0041 (guard — from commit 284af17) 2026-04-03 11:47:26 +02:00
Test
a093ff4631 feat: add Custom Views UI — create dialog, filter builder, sidebar integration
CreateViewDialog with FilterBuilder for constructing filter conditions
(field/operator/value rows). Wire onCreateView and onDeleteView in App.tsx
with Tauri command integration. Sidebar VIEWS section shows "+" button
for discoverability even when empty, and delete buttons on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:54:06 +02:00
13 changed files with 352 additions and 13 deletions

View File

@@ -39,4 +39,4 @@ curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \
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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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 |

View File

@@ -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'] || '',

View File

@@ -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}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)}

View File

@@ -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')

View File

@@ -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

View File

@@ -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,
}
}