Last updated

Item types

Register a first-class item kind, its own file extension, route, icon, and editor, and Undra treats it like a built-in note or canvas: it persists the file, you own the body and the renderer.

This is the deepest contribution an extension can make. One registerItemType call, plus a presentation and a renderer, gives you a new kind of document that lives in the explorer, opens in a tab, and shows up in the right-click New menu. This page covers every field, the two registration modes, and exactly how persistence works.

If you have not read the extension overview yet, start there: the format (manifest + activate(ctx)), the no-import rule, and the ctx API shape are assumed here.

The three calls

A working item type is three registrations inside activate(ctx):

  1. ctx.registry.registerItemType(manifest.id, def), claim the type (identity, file, route, template).
  2. ctx.registerItemTabPresentations([...]), give it an icon and a title in tabs and the dock.
  3. ctx.registerItemTabRenderers([...]), supply the React component that renders the editor.

The type id (a single lowercase token, no dots) is the thread that ties all three together. The same id you pass to registerItemType is the id you use in the presentation and renderer entries.

The type id is not the manifest id

manifest.id is dot-namespaced (community.example.book). The type id is one lowercase token with no dots (book). A dot in the type id is one of the three most common reasons an extension fails to load.

ItemTypeContribution field by field

This is the object you pass as the second argument to ctx.registry.registerItemType(manifest.id, def).

javascript
ctx.registry.registerItemType(manifest.id, {
  id: 'book',                    // the TYPE: one lowercase token, no dots
  label: 'Book',                 // singular human label
  pluralLabel: 'Books',          // optional; defaults to `${label}s`
  dockIconClassSuffix: 'book',   // optional; presentation/dock suffix
  fileExtension: '.ubook',       // full mode: leading-dot, lowercase
  routePrefix: '/books',         // full mode: route under which items open
  emptyBodyTemplateKind: 'json', // full mode: 'json' or 'markdown'
})
Field Required What it does
id yes The type identity. One lowercase token, no dots. Reused verbatim in the presentation and renderer entries, and is the type you pass to ctx.workspace.create.
label yes Singular human-readable name (Book). Shown in menus and surfaces.
pluralLabel no Plural for list and “New X” surfaces. Defaults to `${label}s`, so Book gives Books for free. Set it only when the default is wrong (Person to People, not Persons).
dockIconClassSuffix no Presentation suffix used by the explorer and dock. Match it to the same suffix in your tab presentation so the icon is consistent everywhere. By convention this is just the type id.
fileExtension full mode The file extension Undra owns for this type. Leading dot, lowercase (.ubook). Convention is .u + the type id.
routePrefix full mode The route prefix under which items of this type open (/books). Convention is / + the plural.
emptyBodyTemplateKind full mode The body the kernel writes to a brand-new file: 'json' writes {}, 'markdown' writes an empty string. Pick 'json' for structured types, 'markdown' for prose.
editor no Reserved. A future kernel-routing hook. Omit it. You supply the editor through registerItemTabRenderers, not here.

Full first-class mode vs metadata-only

The same call has two modes, decided entirely by whether you set the three “file lifecycle” fields.

Full first-class mode

Set all three of fileExtension, routePrefix, and emptyBodyTemplateKind. You are declaring a type that behaves like a built-in. The split of responsibility:

  • The host owns the file lifecycle. When the user picks New to Book, the kernel creates a real .ubook file, seeds it with the empty template ({} for json), opens it under /books, persists it, and reloads it on restart. You do not touch files.
  • You own the body and the renderer. The body is an opaque string whose meaning is yours. You read it with ctx.workspace.getDocument, write it with ctx.workspace.update, and render it with the component you registered. This is a custom-editor model: the host handles the document, you handle its contents and its UI.

This is what almost every item-type extension wants. The Book example below is full mode.

Metadata-only mode

Omit all three of fileExtension, routePrefix, and emptyBodyTemplateKind. You register only identity (id, label, dockIconClassSuffix). There is no host-managed file, no route, no “New” entry that creates a document. Use this when you only need the type to exist for labelling and presentation, not as a createable, persisted document.

It is all-or-nothing

The three full-mode fields go together. Set them all for a real document type, or set none of them for metadata-only. A partial set (for example fileExtension without routePrefix) is not a supported configuration.

Presentations

ctx.registerItemTabPresentations([...]) gives the type its visual identity in tabs, the explorer, and the dock. Each entry:

javascript
ctx.registerItemTabPresentations([
  {
    id: 'book',                  // MUST match the item type id
    title: 'Book',               // label shown on the tab
    icon: Icon,                  // a component: (props) => element
    dockIconClassSuffix: 'book', // match the registerItemType suffix
  },
])
Field What it does
id The item type id. This is how the presentation is bound to your type.
title The title shown on the tab for this type.
icon A component called with a props object. Read props.size (a pixel number) and render an element. Build it with ctx.runtime.createElement.
dockIconClassSuffix Optional. The dock/explorer icon suffix; keep it equal to the suffix on the type itself.

The icon is a plain function component. A minimal one:

javascript
function Icon(p) {
  return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘')
}

The renderer contract

ctx.registerItemTabRenderers([...]) supplies the actual editor. Each entry is { id, render }, where render(p) is called for every tab Undra opens, not just yours. The guard is mandatory: return your component only when the tab is an item of your type, and null otherwise.

javascript
ctx.registerItemTabRenderers([
  {
    id: 'book',
    render: function (p) {
      return (p.tab && p.tab.kind === 'item' && p.tab.itemType === 'book')
        ? h(Editor, { itemId: p.tab.itemId })
        : null
    },
  },
])

What render receives in p:

  • p.tab.kind, the tab kind. Check it equals 'item'.
  • p.tab.itemType, the registered type id. Check it equals your id.
  • p.tab.itemId, the id of the open item. Pass this to your component so it can load and save the body.

Always guard, always return null

render runs for tabs that are not yours (notes, canvases, other extensions’ types). If you do not check p.tab.itemType and return null for non-matches, you will try to render your editor over unrelated tabs. The (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) ? ... : null pattern is the contract, not a suggestion.

Persistence: you own the body string

In full mode the host creates and tracks the file, but the body is an opaque string you control. The contract is two methods on ctx.workspace:

  • ctx.workspace.getDocument(itemId) resolves to { id, title, content }. content is the raw body string.
  • ctx.workspace.update(itemId, patch) writes it back. patch is { title?, content? }; pass content to save the body, title to rename the item.

There is no schema and no validation. If you stored JSON, you parse it; if you stored markdown, you read it as text. For a structured type the standard pattern is: keep a JSON string on disk, parse on load into a defaulted object, stringify on every save.

javascript
// LOAD: parse defensively, always fall back to a default shape.
const doc = await ctx.workspace.getDocument(itemId)
const data = { author: '', rating: 0, notes: '' }
try { Object.assign(data, JSON.parse(doc.content || '{}')) } catch (e) { /* keep defaults */ }

// SAVE: stringify the whole object back into content.
ctx.workspace.update(itemId, { content: JSON.stringify(data) })

Three things to get right:

  • Tolerate an empty body. A freshly created json item arrives as {} (the empty template). Parsing it gives an empty object, so your defaults fill in the missing fields. Always merge onto a default shape rather than trusting the parsed result.
  • Wrap the parse in try/catch. The body is a string you (or a past version of your code) wrote. Never assume it parses.
  • Save the whole object. content is the entire body. update replaces it; there is no field-level patch on the body. Read-modify-write the full object each time.

getDocument is the body, not just metadata

getDocument returns the item’s title plus its raw body content. That is everything your editor needs. You do not read files yourself and you do not reach into renderer internals; the ctx.workspace contract is the whole surface.

Complete runnable example

A first-class Book type: author, rating, and notes, persisted as JSON, with its own editor. Save it at <workspace>/.undra/extensions/book/extension.js, restart Undra, then right-click a folder to get New to Book.

extension.js
export const manifest = {
  id: 'community.example.book',
  version: '0.1.0',
  displayName: 'Book',
  publisher: 'example',
  capabilities: ['itemTypes.registry'],
}

export function activate(ctx) {
  const { createElement: h, useState, useEffect } = ctx.runtime
  const TYPE = 'book'

  // 1. Claim the type. All three file-lifecycle fields set = full first-class.
  ctx.registry.registerItemType(manifest.id, {
    id: TYPE,
    label: 'Book',
    pluralLabel: 'Books',
    dockIconClassSuffix: TYPE,
    fileExtension: '.u' + TYPE,        // '.ubook'
    routePrefix: '/' + TYPE + 's',     // '/books'
    emptyBodyTemplateKind: 'json',     // new files start as '{}'
  })

  // 2. Icon + title for tabs, explorer, and the dock.
  function Icon(p) {
    return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘')
  }
  ctx.registerItemTabPresentations([
    { id: TYPE, title: 'Book', icon: Icon, dockIconClassSuffix: TYPE },
  ])

  // 3. The editor component. Owns load (parse) and save (stringify) of the body.
  function Editor(props) {
    const itemId = props.itemId
    const [data, setData] = useState(null)

    useEffect(function () {
      let cancelled = false
      ctx.workspace.getDocument(itemId).then(function (doc) {
        if (cancelled) return
        var v = { author: '', rating: 0, notes: '' }
        try { Object.assign(v, JSON.parse(doc.content || '{}')) } catch (e) {}
        setData(v)
      }).catch(function () {
        if (!cancelled) setData({ author: '', rating: 0, notes: '' })
      })
      return function () { cancelled = true }
    }, [itemId])

    if (!data) {
      return h('div', { style: { padding: '24px', color: 'var(--u-text2,#8c969f)' } }, 'Loading...')
    }

    // Read-modify-write the whole object, then persist the full body string.
    var save = function (next) {
      setData(next)
      ctx.workspace.update(itemId, { content: JSON.stringify(next) })
    }

    var field = {
      width: '100%', boxSizing: 'border-box', padding: '9px 12px', borderRadius: '9px',
      font: 'inherit', fontSize: '13.5px',
      border: '1px solid var(--u-border0,rgba(255,255,255,0.12))',
      background: 'var(--u-bgPanel,#16191c)', color: 'var(--u-text0,#f2f4f5)',
      outline: 'none', marginBottom: '10px',
    }

    return h('div', {
      style: {
        height: '100%', overflow: 'auto', padding: '32px',
        background: 'var(--u-bgEditor,var(--u-bg0,#111315))', color: 'var(--u-text0,#f2f4f5)',
      },
    },
      h('div', { style: { maxWidth: '520px' } },
        h('div', { style: { fontSize: '18px', fontWeight: 650, marginBottom: '14px' } }, '📘 Book'),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Author'),
        h('input', {
          value: data.author,
          onChange: function (e) { save(Object.assign({}, data, { author: e.target.value })) },
          style: field,
        }),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Rating (0-5)'),
        h('input', {
          type: 'number', min: 0, max: 5, value: data.rating,
          onChange: function (e) { save(Object.assign({}, data, { rating: Number(e.target.value) })) },
          style: field,
        }),
        h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Notes'),
        h('textarea', {
          value: data.notes, rows: 8,
          onChange: function (e) { save(Object.assign({}, data, { notes: e.target.value })) },
          style: Object.assign({}, field, { resize: 'vertical' }),
        }),
      ),
    )
  }

  // 4. Bind the renderer to the type. Guard: only render OUR item tabs.
  ctx.registerItemTabRenderers([
    {
      id: TYPE,
      render: function (p) {
        return (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE)
          ? h(Editor, { itemId: p.tab.itemId })
          : null
      },
    },
  ])
}

The item’s title is the book’s name (the host owns it; rename it through ctx.workspace.update(itemId, { title }) if you want an in-editor rename). The body (author, rating, notes) is the JSON string you persist to the .ubook file. Restart Undra and the data reloads intact.

Verify the round-trip

After editing a Book, restart Undra and reopen it. If your fields are gone, you almost certainly saved partially or forgot to merge onto defaults on load. The getDocument to parse to default-merge to update to stringify loop is what makes the round-trip survive a restart.

It appears in the New menu

Once a full-mode type is registered, Undra wires it into the explorer automatically. Right-click a folder to get New to (your label) and the kernel creates a new file with your extension, seeds it with the empty-body template, opens it under your route prefix, and hands it to your renderer. You wrote a type, an icon, and an editor; the create, open, persist, and reload path is the host’s. That is the whole payoff of full first-class mode.

Where to go next