Skip to content

feat: collapsible group blocks with nested content #165

@oobagi

Description

@oobagi

Problem

There's no way to organize blocks into collapsible sections. Long notes become hard to scan, and related blocks can't be visually grouped. Users want to bundle blocks under a named group, collapse it to hide contents, and move both the group and its children naturally.

Context

Blocks are currently stored as a flat []block.Block list in the editor model (internal/editor/editor.go:49). There is no nesting — every block is a peer. Block operations (insert, delete, merge, swap) all operate on flat indices. The parser (internal/block/parse.go) and serializer (internal/block/serialize.go) process blocks linearly. The gutter system (internal/editor/render.go:347-361) renders a fixed-width label per block type.

Key files:

  • internal/block/block.go — BlockType enum, Block struct
  • internal/block/parse.go — markdown → blocks
  • internal/block/serialize.go — blocks → markdown
  • internal/editor/editor.go — model, block ops, move up/down (swapBlocks), backspace merge (mergeBlockUp)
  • internal/editor/render.go — gutter labels, block rendering
  • internal/editor/palette.go/ command palette items

Proposed approach

Data model

Add Group to BlockType. Introduce a Children []Block field on Block (only populated for groups). The editor's flat []block.Block stays flat at the top level — a group block's children live inside it, not as siblings.

type Block struct {
    Type     BlockType
    Content  string     // group title for Group blocks
    Children []Block    // only for Group type
    Checked  bool
    Collapsed bool      // only for Group type — persisted in markdown
}

Markdown format

Use HTML <details> / <summary> — it's valid markdown, renders in GitHub/most renderers, and round-trips cleanly:

<details>
<summary>Group title</summary>

- item one
- item two

</details>

Collapsed state: <details> (closed by default) vs <details open> (expanded).

Editor behavior

  • Rendering: Group header rendered with a distinct gutter label ("gr") and visual container (left border bar for children, dimmed / collapse indicator). Collapsed groups show ▸ Group title (N items).
  • Collapse toggle: Keybinding on group header (e.g., Enter or Tab when cursor is on group header, or Ctrl+E to mirror preview toggle).
  • Cursor navigation: Collapsed children are skipped — Up/Down jumps over them. Expanding restores normal traversal.
  • Move within group (Alt+Up/Down): Reorders children among siblings. Moving past first/last child promotes the block out of the group.
  • Move group (Alt+Up/Down on group header): Moves the entire group (header + all children) as a unit.
  • Creating groups: Via / command palette → "Group". Starts as empty group; blocks can be moved into it.
  • Adding blocks to group: Moving a block down into a group's last position (or up into its first) adopts it. Also support creating new blocks inside a group via Enter.
  • Deleting groups: Backspace on empty group header deletes the group. If group has children, unwrap them (promote to siblings).
  • Nesting limit: 1 level for v1 — groups cannot contain groups.

Palette

Add paletteItem{Label: "Group", Type: block.Group, Icon: "▸"} to the palette items list.

Tasks

  • Add Group to BlockType, add Children, Collapsed fields to Block
  • Update parser to detect <details>/<summary> and produce Group blocks with nested children
  • Update serializer to emit <details> format, preserving collapsed state
  • Add round-trip tests for group blocks (empty, with children, collapsed, nested content types)
  • Add group to / command palette
  • Render group header with gutter label gr, collapse indicator (/), and title
  • Render expanded group children with left border bar indent
  • Render collapsed group as single line with item count
  • Implement collapse/expand toggle keybinding
  • Skip collapsed children during cursor navigation (Up/Down)
  • Implement move-within-group (Alt+Up/Down reorders children, boundary = promote out)
  • Implement move-group-as-unit (Alt+Up/Down on header moves entire group)
  • Implement block adoption (moving a block into adjacent group boundary)
  • Handle Enter inside group (new child block)
  • Handle Backspace on group header (delete empty group, unwrap non-empty)
  • Enforce 1-level nesting limit
  • Update help overlay with group keybindings
  • Update textarea width calculations for indented children

Test plan

  • Parse <details><summary>Title</summary> with children → Group block with correct children
  • Serialize Group block → valid <details> markdown
  • Round-trip: Serialize(Parse(md)) preserves group structure and collapsed state
  • Graceful degradation: markdown without <details> parses normally (no groups)
  • Collapse/expand toggles visibility of children
  • Cursor skips collapsed children on Up/Down navigation
  • Move child within group reorders correctly
  • Move child past group boundary promotes it to sibling
  • Move group header moves all children as unit
  • Backspace on empty group deletes it
  • Backspace on non-empty group unwraps children
  • Enter inside group creates child block (not sibling)
  • Existing tests still pass (no regressions in flat block behavior)

Scope

Type: feature
Size: large

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions