fix: create type documents at vault root

This commit is contained in:
lucaronin
2026-04-29 20:56:07 +02:00
parent f3b4e5e61d
commit 670aae9f2e
10 changed files with 69 additions and 75 deletions

View File

@@ -185,17 +185,18 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
├── project.md ← type: Type (definition document)
├── person.md ← type: Type (definition document)
├── ...
└── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. Legacy `type/` and `types/` folders are still scanned like other non-hidden vault folders, so existing type documents in those folders continue to work, but new type documents created by Tolaria are written at the vault root. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
### Types as Files
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
Each entity type can have a corresponding **type document**: any markdown note with `type: Type` in its frontmatter. Tolaria creates new type documents at the vault root (e.g., `project.md`, `person.md`) and still reads existing type documents from subfolders. Type documents:
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
@@ -215,7 +216,7 @@ Each entity type can have a corresponding **type document** in the `type/` folde
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
| `visible` | bool | Whether type appears in sidebar (default: true) |
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
**UI behavior**:
- Clicking a section group header pins the type document at the top of the NoteList if it exists
@@ -351,7 +352,7 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.

View File

@@ -195,7 +195,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).

View File

@@ -418,7 +418,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Add a new entity type
1. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
1. Create a type document at the vault root: `mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
2. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
3. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
4. Notes of this type are created at the vault root with `type: MyType` in frontmatter — no dedicated folder needed

View File

@@ -0,0 +1,37 @@
---
type: ADR
id: "0096"
title: "Root-created type documents"
status: active
date: 2026-04-29
---
## Context
Tolaria identifies type definitions by markdown frontmatter (`type: Type`), not by filesystem location. Older documentation and UI creation flows still treated `type/` as the canonical destination for new type documents, with a compatibility fallback for vaults that already used `types/`.
That folder-based creation policy conflicted with the broader vault model: notes are scanned from all non-hidden folders, type identity comes from metadata, and root `type.md` / `note.md` definitions are already used by repair and bootstrap flows.
## Decision
**Tolaria creates new type documents at the vault root.**
- A type document is any markdown note with `type: Type` in frontmatter.
- New UI-created type documents use `{vault}/{slug}.md`.
- Existing type documents in `type/`, `types/`, or other scanned folders remain valid and continue to drive templates, icons, colors, visibility, sorting, and sidebar grouping.
- Creation does not silently migrate or move existing type documents.
- Root filename collisions are handled as file collisions; Tolaria must not overwrite an existing note when creating a type document.
## Options considered
- **Root-created type documents** (chosen): matches the metadata-first model, removes special casing from creation, and aligns new types with root-managed default type scaffolding.
- **Canonical `type/` folder**: avoids root filename collisions, but makes path special even though type identity is already defined by frontmatter.
- **Preserve existing folder convention dynamically**: minimizes change for plural `types/` vaults, but creates inconsistent behavior across vaults and leaves `types/` only partially supported.
## Consequences
- Users can inspect and edit type documents as ordinary root notes by default.
- Existing vaults with `type/` or `types/` type documents remain readable because vault scanning already includes non-hidden subdirectories.
- If a root note with the same slug already exists, type creation fails with a collision message instead of writing into a fallback folder.
- Legacy `type/` may remain hidden from the folder tree so old type documents do not duplicate the Types sidebar section.
- Re-evaluate if users need a guided migration from folder-based type documents to root type documents.

View File

@@ -148,5 +148,6 @@ proposed → active → superseded
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
| [0096](0096-root-created-type-documents.md) | Root-created type documents | active |
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |

View File

@@ -304,9 +304,9 @@ describe('resolveNewNote', () => {
})
describe('resolveNewType', () => {
it('creates a type entry in the canonical type directory', () => {
it('creates a type entry at the vault root', () => {
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/my/vault' })
expect(entry.path).toBe('/my/vault/type/recipe.md')
expect(entry.path).toBe('/my/vault/recipe.md')
expect(entry.isA).toBe('Type')
expect(entry.status).toBeNull()
expect(content).toContain('type: Type')
@@ -315,7 +315,7 @@ describe('resolveNewType', () => {
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveNewType({ typeName: 'Responsibility', vaultPath: '/other/vault' })
expect(entry.path).toBe('/other/vault/type/responsibility.md')
expect(entry.path).toBe('/other/vault/responsibility.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})

View File

@@ -172,23 +172,18 @@ describe('resolveNewNote', () => {
})
describe('resolveNewType', () => {
it('creates a type entry in the canonical type directory', () => {
it('creates a type entry at the vault root', () => {
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault' })
expect(entry.path).toBe('/vault/type/recipe.md')
expect(entry.path).toBe('/vault/recipe.md')
expect(entry.isA).toBe('Type')
expect(content).toContain('type: Type')
})
it('uses the unicode title when the type name has no ASCII characters', () => {
const { entry } = resolveNewType({ typeName: '停智慧', vaultPath: '/vault' })
expect(entry.path).toBe('/vault/type/停智慧.md')
expect(entry.path).toBe('/vault/停智慧.md')
expect(entry.filename).toBe('停智慧.md')
})
it('can target an existing plural types directory', () => {
const { entry } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault', typeDirectory: 'types' })
expect(entry.path).toBe('/vault/types/recipe.md')
})
})
describe('resolveTemplate', () => {
@@ -432,7 +427,7 @@ describe('useNoteCreation hook', () => {
it('handleCreateType blocks when the target type file already exists', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('File already exists: /test/vault/type/briefing.md'))
vi.mocked(invoke).mockRejectedValueOnce(new Error('File already exists: /test/vault/briefing.md'))
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
let created = true
@@ -442,10 +437,10 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(false)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: '/test/vault/type/briefing.md',
path: '/test/vault/briefing.md',
content: expect.stringContaining('type: Type'),
})
expect(removeEntry).toHaveBeenCalledWith('/test/vault/type/briefing.md')
expect(removeEntry).toHaveBeenCalledWith('/test/vault/briefing.md')
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists')
})
@@ -453,7 +448,7 @@ describe('useNoteCreation hook', () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const staleEntry = makeEntry({
path: '/test/vault/type/pttep.md',
path: '/test/vault/pttep.md',
filename: 'pttep.md',
title: 'Stale cache entry',
isA: 'Note',
@@ -467,11 +462,11 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(true)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: '/test/vault/type/pttep.md',
path: '/test/vault/pttep.md',
content: expect.stringContaining('type: Type'),
})
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/type/pttep.md',
path: '/test/vault/pttep.md',
filename: 'pttep.md',
title: 'PTTEP',
isA: 'Type',
@@ -479,7 +474,7 @@ describe('useNoteCreation hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handleCreateType uses an existing plural types folder convention', async () => {
it('handleCreateType writes new type entries to the vault root even when older type entries live in a folder', async () => {
const existingType = makeEntry({
path: '/test/vault/types/project.md',
filename: 'project.md',
@@ -493,7 +488,7 @@ describe('useNoteCreation hook', () => {
})
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/types/hotel.md',
path: '/test/vault/hotel.md',
filename: 'hotel.md',
title: 'Hotel',
isA: 'Type',
@@ -512,7 +507,7 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(true)
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/type/停智慧.md',
path: '/test/vault/停智慧.md',
filename: '停智慧.md',
title: '停智慧',
isA: 'Type',

View File

@@ -123,50 +123,11 @@ export function resolveNewNote({ title, type, vaultPath, template }: NewNotePara
export interface NewTypeParams {
typeName: string
vaultPath: string
typeDirectory?: string
}
const DEFAULT_TYPE_DEFINITION_DIR = 'type'
const TYPE_DEFINITION_DIRS = new Set([DEFAULT_TYPE_DEFINITION_DIR, 'types'])
interface RelativePathParams {
entryPath: string
vaultPath: string
}
interface TypeDirectoryParams {
entry: VaultEntry
vaultPath: string
}
function relativePathFromVault({ entryPath, vaultPath }: RelativePathParams): string | null {
const normalizedPath = entryPath.replace(/\\/g, '/')
const normalizedVaultPath = vaultPath.replace(/\\/g, '/').replace(/\/+$/, '')
const prefix = `${normalizedVaultPath}/`
if (normalizedPath.toLocaleLowerCase().startsWith(prefix.toLocaleLowerCase())) {
return normalizedPath.slice(prefix.length)
}
return null
}
function typeDirectoryFromEntry({ entry, vaultPath }: TypeDirectoryParams): string | null {
const relativePath = relativePathFromVault({ entryPath: entry.path, vaultPath })
const directory = relativePath?.replace(/\\/g, '/').split('/').filter(Boolean)[0] ?? null
return directory && TYPE_DEFINITION_DIRS.has(directory.toLocaleLowerCase()) ? directory : null
}
function resolveTypeDefinitionDirectory(entries: VaultEntry[], vaultPath: string): string {
for (const entry of entries) {
if (entry.isA !== 'Type') continue
const directory = typeDirectoryFromEntry({ entry, vaultPath })
if (directory?.toLocaleLowerCase() === 'types') return directory
}
return DEFAULT_TYPE_DEFINITION_DIR
}
export function resolveNewType({ typeName, vaultPath, typeDirectory = DEFAULT_TYPE_DEFINITION_DIR }: NewTypeParams): { entry: VaultEntry; content: string } {
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `${vaultPath}/${typeDirectory}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` }
}
@@ -238,8 +199,7 @@ export function planNewTypeCreation({
const existingType = findEquivalentTypeEntry(entries, typeName)
if (existingType) return { status: 'existing', entry: existingType }
const typeDirectory = resolveTypeDefinitionDirectory(entries, vaultPath)
const resolved = resolveNewType({ typeName, vaultPath, typeDirectory })
const resolved = resolveNewType({ typeName, vaultPath })
return { status: 'create', resolved }
}

View File

@@ -40,7 +40,7 @@ test.describe('Collision-safe create flows', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('missing-type creation ignores a root filename collision and writes the type document under type/', async ({ page }) => {
test('missing-type creation keeps the dialog open when a root filename already exists', async ({ page }) => {
const collidingPath = writeFixtureNote(
tempVaultDir,
'hotel.md',
@@ -66,8 +66,8 @@ test.describe('Collision-safe create flows', () => {
await expect(dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')).toHaveValue('Hotel')
await page.keyboard.press('Enter')
await expect(dialog).toHaveCount(0)
await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'type', 'hotel.md'))).toBe(true)
await expect(dialog).toBeVisible()
await expect(toast(page)).toContainText('Cannot create type "Hotel" because hotel.md already exists')
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Existing Hotel Note')
})
@@ -103,7 +103,7 @@ test.describe('Collision-safe create flows', () => {
'untitled.md',
'---\ntype: Note\n---\n# Existing Untitled Note\n',
)
const createdTypePath = path.join(tempVaultDir, 'type', '停智慧.md')
const createdTypePath = path.join(tempVaultDir, '停智慧.md')
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await createTypeFromCommandPalette(page, '停智慧')

View File

@@ -52,7 +52,7 @@ test.describe('Missing type warning', () => {
await expect(warning).toHaveCount(0)
await expect(page.getByRole('combobox')).toContainText('Hotel')
await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'type', 'hotel.md'))).toBe(true)
await expect.poll(() => fs.existsSync(path.join(tempVaultDir, 'hotel.md'))).toBe(true)
await page.getByText('Alpha Project', { exact: true }).click()
await page.getByText('Hotel Guide', { exact: true }).click()