List Entities Implementation Plan
Overview
List entities allow users to collect graph nodes into ordered lists. Similar to link-entities (which reference two nodes by key), list-entities reference multiple nodes by key and maintain ordering. Users can add, remove, and reorder nodes within a list.
Color: Cyan (glm.vec4 0.0 0.6 0.7 1 / glm.vec4 0.05 0.65 0.75 1)
Architecture
Components to Create
- Storage Layer:
assets/lua/entities/list.fnl - Graph Nodes:
assets/lua/graph/nodes/list-entity.fnl- Individual list entity nodeassets/lua/graph/nodes/list-entity-list.fnl- List of all list entities
- Graph Views:
assets/lua/graph/view/views/list-entity.fnl- View for managing a single listassets/lua/graph/view/views/list-entity-list.fnl- View for browsing all lists
- Integration:
- Update
assets/lua/graph/nodes/entities.fnl- Add:listtype - Update
assets/lua/menu-manager.fnl- Add "Create List Entity" action
- Update
- Tests:
assets/lua/tests/test-list-entities.fnlassets/lua/tests/e2e/test-list-entity-view.fnl
1. Storage Layer (assets/lua/entities/list.fnl)
Data Structure
{:id "uuid"
:name "" ;; Optional user-defined name
:items ["node-key-1" "node-key-2" "node-key-3"] ;; Ordered list of node keys
:created-at 1234567890 ;; Unix timestamp
:updated-at 1234567890} ;; Unix timestampPersistence
- Location:
<user-data-dir>/entities/list/ - Format: JSON files - one file per entity, named
<uuid>.json(same as link-entities)
API
(fn ListEntityStore [opts])
;; opts: {:base-dir optional-path}
;; CRUD operations
:get-entity (fn [self id] ...)
:create-entity (fn [self opts] ...) ;; opts: {:name :items}
:update-entity (fn [self id updates] ...)
:delete-entity (fn [self id] ...)
:list-entities (fn [self] ...) ;; Returns all, sorted by updated-at desc
;; List item operations
:add-item (fn [self id node-key] ...) ;; Append to end
:remove-item (fn [self id node-key] ...) ;; Remove first occurrence
:reorder-items (fn [self id new-order] ...) ;; Replace items array
:move-item (fn [self id from-index to-index] ...) ;; Move item position
;; Signals
:list-entity-created (Signal)
:list-entity-updated (Signal)
:list-entity-deleted (Signal)
:list-entity-items-changed (Signal) ;; Emits {id, items} on item changes
(fn get-default [opts]) ;; Singleton accessImplementation Notes
- Items are stored as an ordered array of node key strings
- No duplicates:
add-itemchecks if key already exists and is a no-op if so add-itemappends to end (if not duplicate), emitslist-entity-updatedremove-itemremoves first matching key, emitslist-entity-updatedmove-itemhandles reordering, emitslist-entity-updated- All item operations update
updated-at
2. ListEntityNode (assets/lua/graph/nodes/list-entity.fnl)
Properties
:key entity-id
:label (make-label entity) ;; Name if set, else entity-id
:color CYAN
:sub-color CYAN_ACCENT
:size 8.0
:view ListEntityNodeViewMethods
:get-entity (fn [self] ...)
:update-name (fn [self new-name] ...)
:add-item (fn [self node-key] ...)
:remove-item (fn [self node-key] ...)
:move-item (fn [self from-index to-index] ...)
:delete-entity (fn [self] ...)
:refresh-label (fn [self] ...)
;; Graph node creation for list items
:add-item-nodes (fn [self] ...) ;; Add existing nodes from items to graph as edgesSignals
:entity-deleted (Signal)
:items-changed (Signal) ;; Proxies store's list-entity-items-changed for this entitySignal Connections
- Listen to
store.list-entity-updated->refresh-label - Listen to
store.list-entity-deleted-> emitentity-deleted, remove from graph - Listen to
store.list-entity-items-changed-> emititems-changedif matching id - Attach membership edges via graph lifecycle (see “Problems Encountered”)
3. ListEntityListNode (assets/lua/graph/nodes/list-entity-list.fnl)
Properties
:key "list-entity-list"
:label "list entities"
:color CYAN
:sub-color CYAN_ACCENT
:size 8.0
:view ListEntityListNodeViewMethods
:collect-items (fn [self] ...) ;; Returns [[entity, label], ...]
:emit-items (fn [self] ...)
:add-entity-node (fn [self entity] ...) ;; Add ListEntityNode as edge target
:create-entity (fn [self opts] ...)Signals
:items-changed (Signal)4. ListEntityNodeView (assets/lua/graph/view/views/list-entity.fnl)
UI Layout
┌─────────────────────────────────────────────────┐
│ [Name Input ] │
├─────────────────────────────────────────────────┤
│ Items (3): │
│ ┌─────────────────────────────────────────────┐ │
│ │ [node-key-alpha] [↑] [↓] [×] │ │
│ │ [node-key-beta] [↑] [↓] [×] │ │
│ │ [node-key-gamma] [↑] [↓] [×] │ │
│ └─────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ [+ Add Selected] [Delete List] │
└─────────────────────────────────────────────────┘Components
- Name Input - Text input for list name, updates via
node:update-name - Items List - Scrollable list of items with:
- Item button (truncated node key) - clicking focuses the node in the graph
- Move up button (↑) - disabled for first item
- Move down button (↓) - disabled for last item
- Remove button (×)
- Action Row:
- "Add Selected" button - adds all currently selected nodes to the list (duplicates are ignored)
- "Delete" button - deletes the entire list entity
Selection Integration
The "Add Selected" button reads from app.graph-view.selection.selected-nodes and calls node:add-item(key) for each selected node. Duplicates are silently ignored (no-op).
Focus Node on Click
When clicking an item button:
- Look up node via
app.graph:lookup(key) - If node exists, get its focus-node from
app.graph-view.focus-nodes[node] - Call
focus-node:request-focus()to focus it
If the node is not currently in the graph, the click does nothing (the node may have been removed). In the future, this could be extended to add the node to the graph if it doesn't exist.
Signal Connections
- Listen to
node.items-changed-> rebuild items list UI
5. ListEntityListNodeView (assets/lua/graph/view/views/list-entity-list.fnl)
UI Layout
┌──────────────────────────────────────┐
│ [Create] │
├──────────────────────────────────────┤
│ SearchView with list entities │
│ - My List (5 items) │
│ - Todo Items (12 items) │
│ - ... │
└──────────────────────────────────────┘Components
- Create Button - Creates new empty list entity and adds node to graph
- SearchView - Paginated list of all list entities
- Each item is a button showing name (or item count)
- Clicking adds ListEntityNode to graph
Signal Connections
- Listen to
node.items-changed-> refresh list
6. Integration Updates
assets/lua/graph/nodes/entities.fnl
Add list type to collect-types:
(fn collect-types [_self]
(local produced [])
(table.insert produced [:string "string"])
(table.insert produced [:link "link"])
(table.insert produced [:list "list"]) ;; NEW
produced)Add list handling to add-type-node:
(= type-key :list)
(do
(local list-node (ListEntityListNode {}))
(graph:add-edge (GraphEdge {:source self
:target list-node})))Import at top:
(local ListEntityListNode (require :graph/nodes/list-entity-list))assets/lua/menu-manager.fnl
Add new action to default-root-actions:
(table.insert actions
{:name "Create List Entity"
:icon "playlist_add"
:fn (fn [_button _event]
(local ListEntityStore (require :entities/list))
(local store (ListEntityStore.get-default))
(local selected (or (and app.graph-view
app.graph-view.selection
app.graph-view.selection.selected-nodes)
[]))
;; Collect keys from selected nodes
(local items
(icollect [_ node (ipairs selected)]
(when node.key node.key)))
(local entity (store:create-entity {:items items}))
(when (and app.graph entity)
(local ListEntityNode (require :graph/nodes/list-entity))
(local node (ListEntityNode {:entity-id entity.id
:store store}))
(app.graph:add-node node)))})7. Tests (assets/lua/tests/test-list-entities.fnl)
Store Tests
list-entity-store-creates-entities- Create with name and itemslist-entity-store-retrieves-entitieslist-entity-store-updates-entities- Update namelist-entity-store-deletes-entitieslist-entity-store-lists-entities- Sorted by updated-atlist-entity-store-emits-created-signallist-entity-store-emits-updated-signallist-entity-store-emits-deleted-signallist-entity-store-adds-items- Test add-itemlist-entity-store-removes-items- Test remove-itemlist-entity-store-moves-items- Test move-itemlist-entity-store-emits-items-changed-signal
Node Tests
list-entity-node-loadslist-entity-node-creates-with-correct-propertieslist-entity-list-node-loadslist-entity-list-node-creates-with-correct-properties
View Tests
list-entity-node-view-loadslist-entity-list-node-view-loads
Integration Tests
entities-node-includes-list-type
E2E Snapshot Test (assets/lua/tests/e2e/test-list-entity-view.fnl)
- Render ListEntityNodeView with sample items
- Snapshot to
assets/lua/tests/data/snapshots/list-entity-view.png
8. File Summary
Files to Create
| File | Description |
|---|---|
assets/lua/entities/list.fnl | Storage layer with CRUD + item operations |
assets/lua/graph/nodes/list-entity.fnl | Individual list entity graph node |
assets/lua/graph/nodes/list-entity-list.fnl | List browser graph node |
assets/lua/graph/view/views/list-entity.fnl | Single list management view |
assets/lua/graph/view/views/list-entity-list.fnl | List browser view |
assets/lua/tests/test-list-entities.fnl | Unit tests |
assets/lua/tests/e2e/test-list-entity-view.fnl | E2E snapshot test |
Files to Modify
| File | Changes |
|---|---|
assets/lua/graph/core.fnl | Add optional node:added(graph) lifecycle hook (post-insert) |
assets/lua/graph/nodes/entities.fnl | Add :list type, import ListEntityListNode |
assets/lua/menu-manager.fnl | Add "Create List Entity" action |
assets/lua/tests/fast.fnl | Add test-list-entities to test suite |
assets/lua/tests/e2e.fnl | Add test-list-entity-view to E2E suite |
9. Implementation Order
- Storage layer (
entities/list.fnl) - Foundation, can be tested independently - ListEntityNode (
graph/nodes/list-entity.fnl) - Depends on storage - ListEntityListNode (
graph/nodes/list-entity-list.fnl) - Depends on storage and ListEntityNode - ListEntityNodeView (
graph/view/views/list-entity.fnl) - Depends on node - ListEntityListNodeView (
graph/view/views/list-entity-list.fnl) - Depends on list node - Integration - Update entities.fnl and menu-manager.fnl
- Tests - Unit tests and E2E tests
- Verification - Run full test suite
10. Verification
Run the full test suite:
SKIP_KEYRING_TESTS=1 XDG_DATA_HOME=/tmp/space/tests/xdg-data SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH=$(pwd)/assets make testRun E2E tests:
SPACE_DISABLE_AUDIO=1 SPACE_ASSETS_PATH=$(pwd)/assets make test-e2eManual verification:
- Start the app with
make run - Right-click to open context menu
- Select "Create List Entity" - should create empty list or list with selected nodes
- Double-click the list entity node to open view
- Add/remove/reorder items in the view
- Verify changes persist after restart
11. Problems Encountered (and Resolutions)
A) C stack overflow / recursion when adding list entity nodes
Symptom: Creating a list entity (especially from the root context menu with selected nodes) could crash with C stack overflow, with repeated recursion through:
ListEntityNode:add-item-nodesListEntityNode:mountGraph:add-node/Graph:add-edge
Root cause: Graph.add-node mounts the node before inserting it into graph.nodes. If mount calls graph:add-edge, graph:add-edge calls graph:add-node for the edge endpoints. Since the source node is not in graph.nodes yet, it re-enters mount and loops forever.
Resolution: Introduce an explicit post-add lifecycle hook:
Graph.add-nodecalls optionalnode:added(graph)after it inserts the node and emitsnode-added.ListEntityNodeusesaddedto create membership edges to any items that already exist in the graph.ListEntityNodealso listens tograph.node-addedto attach membership edges when item nodes are added later.
This keeps mount side-effect-free with respect to graph mutations and makes the lifecycle ordering explicit for any future nodes that need to react to being inserted.
B) E2E snapshot golden creation and environment constraints
Symptom: When the snapshot golden didn’t exist yet, snapshot runs could fail before update logic ran, and E2E runs could fail in restricted environments with SDL/X11 errors.
Resolution:
- E2E snapshots require an environment that can start SDL + GL under Xvfb (or equivalent).
make test-e2ealready usesxvfb-run. - To create/update the list entity golden:
SPACE_SNAPSHOT_UPDATE=list-entity-view make test-e2e- For “does the golden exist?” checks, prefer a direct filesystem path derived from
SPACE_ASSETS_PATHso missing goldens don’t fail asset lookup beforeSPACE_SNAPSHOT_UPDATEcan create them.
