feat: implement create new type feature

Core implementation:
- CreateTypeDialog: simple dialog with name field for creating new types
- handleCreateType in useNoteActions: creates Type documents in type/ folder
- Dynamic sidebar sections: custom types (Recipe, Book, etc.) appear as new
  sidebar sections after built-in ones, each with a + button for instances
- Updated CreateNoteDialog: accepts custom types, shows them with blue accent
- handleCreateNote now supports custom types (folder = lowercased type name)

Product decisions:
- The + on Types section opens CreateTypeDialog (not CreateNoteDialog)
- Custom type sections use FileText icon and blue accent color by default
- Section labels are pluralized (e.g., "Recipes", "Books")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 09:41:46 +01:00
parent e739ae0028
commit 170de2a7e0
5 changed files with 277 additions and 104 deletions

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
import { ResizeHandle } from './components/ResizeHandle'
import { CreateNoteDialog, type NoteType } from './components/CreateNoteDialog'
import { CreateNoteDialog } from './components/CreateNoteDialog'
import { CreateTypeDialog } from './components/CreateTypeDialog'
import { QuickOpenPalette } from './components/QuickOpenPalette'
import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
@@ -29,6 +30,12 @@ const VAULTS = [
{ label: 'Demo', path: '/Users/luca/Workspace/laputa-app/demo-vault' },
]
const BUILT_IN_TYPE_NAMES = new Set([
'Project', 'Experiment', 'Responsibility', 'Procedure',
'Person', 'Event', 'Topic', 'Type', 'Note', 'Essay',
'Quarter', 'Journal', 'Evergreen',
])
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [sidebarWidth, setSidebarWidth] = useState(250)
@@ -37,7 +44,8 @@ function App() {
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createNoteDefaultType, setCreateNoteDefaultType] = useState<NoteType | undefined>()
const [createNoteDefaultType, setCreateNoteDefaultType] = useState<string | undefined>()
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
const [showQuickOpen, setShowQuickOpen] = useState(false)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
@@ -47,6 +55,15 @@ function App() {
const vault = useVaultLoader(vaultPath)
const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage)
// Derive custom types from vault (Type entries not in built-in list)
const customTypes = useMemo(
() => vault.entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPE_NAMES.has(e.title))
.map((e) => e.title)
.sort(),
[vault.entries],
)
// Reset UI state when vault changes
const handleSwitchVault = useCallback((path: string) => {
setVaultPath(path)
@@ -65,10 +82,19 @@ function App() {
}, [notes.activeTabPath, vault.loadGitHistory])
const openCreateDialog = useCallback((type?: string) => {
setCreateNoteDefaultType(type as NoteType | undefined)
setCreateNoteDefaultType(type)
setShowCreateDialog(true)
}, [])
const openCreateTypeDialog = useCallback(() => {
setShowCreateTypeDialog(true)
}, [])
const handleCreateType = useCallback((name: string) => {
notes.handleCreateType(name)
setToastMessage(`Type "${name}" created`)
}, [notes, setToastMessage])
useAppKeyboard({
onQuickOpen: () => setShowQuickOpen(true),
onCreateNote: openCreateDialog,
@@ -107,7 +133,7 @@ function App() {
<div className="app-shell">
<div className="app">
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={openCreateDialog} onCreateNewType={openCreateTypeDialog} modifiedCount={vault.modifiedFiles.length} onCommitPush={() => setShowCommitDialog(true)} />
</div>
<ResizeHandle onResize={handleSidebarResize} />
<div className="app__note-list" style={{ width: noteListWidth }}>
@@ -154,6 +180,12 @@ function App() {
onClose={() => setShowCreateDialog(false)}
onCreate={notes.handleCreateNote}
defaultType={createNoteDefaultType}
customTypes={customTypes}
/>
<CreateTypeDialog
open={showCreateTypeDialog}
onClose={() => setShowCreateTypeDialog(false)}
onCreate={handleCreateType}
/>
<CommitDialog
open={showCommitDialog}

View File

@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const NOTE_TYPES = [
const BUILT_IN_TYPES = [
'Note',
'Project',
'Experiment',
@@ -15,18 +15,20 @@ const NOTE_TYPES = [
'Topic',
] as const
export type NoteType = (typeof NOTE_TYPES)[number]
export type NoteType = (typeof BUILT_IN_TYPES)[number]
interface CreateNoteDialogProps {
open: boolean
onClose: () => void
onCreate: (title: string, type: NoteType) => void
defaultType?: NoteType
onCreate: (title: string, type: string) => void
defaultType?: string
/** Custom types from the vault (Type documents not in built-in list) */
customTypes?: string[]
}
export function CreateNoteDialog({ open, onClose, onCreate, defaultType }: CreateNoteDialogProps) {
export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customTypes = [] }: CreateNoteDialogProps) {
const [title, setTitle] = useState('')
const [type, setType] = useState<NoteType>('Note')
const [type, setType] = useState<string>('Note')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -68,7 +70,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType }: Creat
Type
</label>
<div className="flex flex-wrap gap-1.5">
{NOTE_TYPES.map((t) => (
{BUILT_IN_TYPES.map((t) => (
<Button
key={t}
type="button"
@@ -83,6 +85,23 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType }: Creat
{t}
</Button>
))}
{customTypes.map((t) => (
<Button
key={t}
type="button"
variant={type === t ? 'default' : 'outline'}
size="sm"
className={cn(
"rounded-full text-xs",
type === t
? "bg-[var(--accent-blue)] text-white"
: "border-[var(--accent-blue)] text-[var(--accent-blue)]"
)}
onClick={() => setType(t)}
>
{t}
</Button>
))}
</div>
</div>
<DialogFooter>

View File

@@ -0,0 +1,64 @@
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'
interface CreateTypeDialogProps {
open: boolean
onClose: () => void
onCreate: (name: string) => void
}
export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogProps) {
const [name, setName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName('')
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onCreate(trimmed)
onClose()
}
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>Create New Type</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Type Name
</label>
<Input
ref={inputRef}
placeholder="e.g. Recipe, Book, Habit..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Creates a type document. Its properties become defaults for new docs of this type.
</p>
</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

@@ -1,4 +1,4 @@
import { useState, memo, type ComponentType } from 'react'
import { useState, useMemo, memo, type ComponentType } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus } from 'lucide-react'
@@ -25,6 +25,7 @@ interface SidebarProps {
onSelect: (selection: SidebarSelection) => void
onSelectNote?: (entry: VaultEntry) => void
onCreateType?: (type: string) => void
onCreateNewType?: () => void
modifiedCount?: number
onCommitPush?: () => void
}
@@ -34,7 +35,13 @@ const TOP_NAV = [
{ label: 'Favorites', filter: 'favorites' as const, Icon: Star },
]
const SECTION_GROUPS: { label: string; type: string; Icon: ComponentType<IconProps> }[] = [
interface SectionGroup {
label: string
type: string
Icon: ComponentType<IconProps>
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Projects', type: 'Project', Icon: Wrench },
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
@@ -45,7 +52,9 @@ const SECTION_GROUPS: { label: string; type: string; Icon: ComponentType<IconPro
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, modifiedCount = 0, onCommitPush }: SidebarProps) {
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, modifiedCount = 0, onCommitPush }: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggleSection = (type: string) => {
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
@@ -62,6 +71,113 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
return false
}
// Derive custom type sections from Type entries not in the built-in list
const customSectionGroups: SectionGroup[] = useMemo(() => {
return entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
.sort((a, b) => a.title.localeCompare(b.title))
.map((e) => ({
label: e.title + 's',
type: e.title,
Icon: FileText,
}))
}, [entries])
const allSectionGroups = useMemo(
() => [...BUILT_IN_SECTION_GROUPS, ...customSectionGroups],
[customSectionGroups],
)
const renderSection = ({ label, type, Icon }: SectionGroup) => {
const items = entries.filter((e) => e.isA === type)
const isCollapsed = collapsed[type] ?? false
const isTopic = type === 'Topic'
const isTypeSection = type === 'Type'
const handlePlusClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (isTypeSection) {
onCreateNewType?.()
} else {
onCreateType?.(type)
}
}
return (
<div key={type} style={{ padding: '4px 6px' }}>
{/* Section header row */}
<div
className={cn(
"group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors",
isActive({ kind: 'sectionGroup', type })
? "bg-secondary"
: "hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4, gap: 8 }}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
>
<div className="flex items-center" style={{ gap: 8 }}>
<Icon size={16} style={{ color: getTypeColor(type) }} />
<span className="text-[13px] font-medium text-foreground">{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{(onCreateType || (isTypeSection && onCreateNewType)) && (
<button
className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer"
style={{ width: 20, height: 20 }}
onClick={handlePlusClick}
aria-label={isTypeSection ? 'Create new Type' : `Create new ${type}`}
title={isTypeSection ? 'New Type' : `New ${type}`}
>
<Plus size={14} />
</button>
)}
<button
className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
toggleSection(type)
}}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
</div>
</div>
{/* Children items */}
{!isCollapsed && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map((entry) => (
<div
key={entry.path}
className={cn(
"cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors",
isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
? "text-foreground"
: "text-muted-foreground hover:bg-accent"
)}
style={{
padding: '4px 16px 4px 28px',
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
backgroundColor: getTypeLightColor(entry.isA ?? ''),
color: getSectionColor(entry),
}),
}}
onClick={() => {
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
onSelectNote?.(entry)
}}
>
{entry.title}
</div>
))}
</div>
)}
</div>
)
}
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground" style={{ paddingTop: 38 } as React.CSSProperties}>
{/* Native macOS title bar on top */}
@@ -116,91 +232,8 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
</div>
</div>
{/* Section Groups */}
{SECTION_GROUPS.map(({ label, type, Icon }) => {
const items = type === 'Topic'
? entries.filter((e) => e.isA === 'Topic')
: entries.filter((e) => e.isA === type)
const isCollapsed = collapsed[type] ?? false
const isTopic = type === 'Topic'
return (
<div key={type} style={{ padding: '4px 6px' }}>
{/* Section header row */}
<div
className={cn(
"group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors",
isActive({ kind: 'sectionGroup', type })
? "bg-secondary"
: "hover:bg-accent"
)}
style={{ padding: '6px 16px', borderRadius: 4, gap: 8 }}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
>
<div className="flex items-center" style={{ gap: 8 }}>
<Icon size={16} style={{ color: getTypeColor(type) }} />
<span className="text-[13px] font-medium text-foreground">{label}</span>
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{onCreateType && (
<button
className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer"
style={{ width: 20, height: 20 }}
onClick={(e) => {
e.stopPropagation()
onCreateType(type)
}}
aria-label={`Create new ${type}`}
title={`New ${type}`}
>
<Plus size={14} />
</button>
)}
<button
className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer"
onClick={(e) => {
e.stopPropagation()
toggleSection(type)
}}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
</div>
</div>
{/* Children items */}
{!isCollapsed && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map((entry) => (
<div
key={entry.path}
className={cn(
"cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors",
isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
? "text-foreground"
: "text-muted-foreground hover:bg-accent"
)}
style={{
padding: '4px 16px 4px 28px',
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
backgroundColor: getTypeLightColor(entry.isA ?? ''),
color: getSectionColor(entry),
}),
}}
onClick={() => {
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
onSelectNote?.(entry)
}}
>
{entry.title}
</div>
))}
</div>
)}
</div>
)
})}
{/* Section Groups (built-in + custom) */}
{allSectionGroups.map(renderSection)}
</nav>
{/* Commit button — always visible */}

View File

@@ -3,7 +3,6 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import type { NoteType } from '../components/CreateNoteDialog'
interface Tab {
entry: VaultEntry
@@ -173,21 +172,23 @@ export function useNoteActions(
}
}, [entries, handleSelectNote])
const handleCreateNote = useCallback(async (title: string, type: NoteType) => {
const handleCreateNote = useCallback(async (title: string, type: string) => {
const typeToFolder: Record<string, string> = {
Note: 'note', Project: 'project', Experiment: 'experiment',
Responsibility: 'responsibility', Procedure: 'procedure',
Person: 'person', Event: 'event', Topic: 'topic',
}
const folder = typeToFolder[type] || 'note'
// Custom types use lowercased type name as folder
const folder = typeToFolder[type] || type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const path = `/Users/luca/Laputa/${folder}/${slug}.md`
const now = Math.floor(Date.now() / 1000)
const noStatusTypes = new Set(['Topic', 'Person'])
const newEntry: VaultEntry = {
path, filename: `${slug}.md`, title, isA: type,
aliases: [], belongsTo: [], relatedTo: [],
status: type === 'Topic' || type === 'Person' ? null : 'Active',
status: noStatusTypes.has(type) ? null : 'Active',
owner: null, cadence: null, modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {},
}
@@ -207,6 +208,29 @@ export function useNoteActions(
handleSelectNote(newEntry)
}, [handleSelectNote, addEntry])
const handleCreateType = useCallback(async (typeName: string) => {
const slug = typeName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const path = `/Users/luca/Laputa/type/${slug}.md`
const now = Math.floor(Date.now() / 1000)
const newEntry: VaultEntry = {
path, filename: `${slug}.md`, title: typeName, isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', relationships: {},
}
const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n`
if (!isTauri()) {
addMockEntry(newEntry, content)
}
addEntry(newEntry, content)
handleSelectNote(newEntry)
}, [handleSelectNote, addEntry])
const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
try {
let newContent: string
@@ -272,6 +296,7 @@ export function useNoteActions(
handleSwitchTab,
handleNavigateWikilink,
handleCreateNote,
handleCreateType,
handleUpdateFrontmatter,
handleDeleteProperty,
handleAddProperty,