Skip to content

feat: collapsible group blocks with nested content #165

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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