Skip to content

About

Interactive YAML tree editor for Go TUI apps, with schema validation and fine-grained editing policies. Built with Bubble Tea.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

45 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

StructiveEdit

StructiveEdit is a Go library and reference terminal app for editing YAML as an ordered tree. Schemas define valid document structure and values, while policies control which nodes users can edit, add, delete, or reorder.

The core editor, document model, codecs, and terminal UI are separate packages, so applications can own persistence and choose which parts to embed.

demo

Packages

Package Responsibility
document Immutable document snapshots, node identities, paths, and builders
structivedit Editing, validation, permissions, focus, and undo/redo
codec Format-independent decode and encode session contract
codec/yaml YAML decoding, metadata preservation, and encoding
adapter/bubbletea Bubble Tea terminal editor and input handling
cmd/structivedit Reference CLI for editing an existing YAML file

Requirements and concurrency

  • Go 1.27 or later.
  • YAML is the only bundled format. The codec interface can be implemented for other formats.
  • An Editor instance is not safe for concurrent use. Keep all calls on one Editor on a single goroutine, including read methods.
  • A document.Document is an immutable snapshot. After capturing a snapshot, its read-only Document and Node methods can be called concurrently. Do not call Editor.Document concurrently with other use of that Editor.

Reference CLI

Run the editor from a checkout:

go run ./cmd/structivedit [--expand-aliases] [--no-color] [--inline-edit] config.yaml

The CLI opens one existing YAML file. It does not create a file when starting. The file is written after Ctrl+S or after choosing overwrite in the external change prompt.

Option Behavior
--expand-aliases Show alias targets as read-only rows
--inline-edit Edit scalar values in their tree rows
--no-color Disable terminal colors; NO_COLOR has the same effect
--max-input-bytes Maximum input size; default 8 MiB
--max-nodes Maximum accepted node count; default 100,000
--max-depth Maximum accepted nesting depth; default 128

Resource limits also apply when reloading. Set a limit to 0 for trusted input that needs unlimited behavior. Node and depth limits are checked after YAML parsing; the byte limit bounds the parser's input size.

Key Behavior
Up / Down Move focus through visible nodes
Left / Right Collapse or expand a container; move to its parent or first child
Enter Edit a scalar or expand/collapse a container
Tab Switch between row-head and Value-cell cursor modes
Space Toggle a boolean
Alt+Up / Alt+Down Reorder a sequence item when allowed
Ctrl+Z / Ctrl+Y Undo / redo
Ctrl+S Save through the CLI's file handler
? Show or hide the full shortcut list
a / d Start schema-driven add / delete when available
Escape Cancel the current prompt
q / Ctrl+Q / Ctrl+C Leave the editor from the browse view

Double-clicking an editable scalar edits it; double-clicking an editable boolean toggles it. The reference CLI has no schema file option, so Add and Delete are unavailable there.

Save and external changes

The CLI compares the file's SHA-256 fingerprint with the version last loaded or saved whenever Ctrl+S is pressed. If the content changed or the file was removed, choose:

Key Behavior
o Overwrite the external version with the current document
r Reload the file and discard local edits
Escape Cancel saving and keep local edits

If reload fails because the file is missing or invalid YAML, the error remains visible and the prompt stays open so the user can retry, overwrite, or cancel. Save errors remain visible and do not clear the dirty state. There is no background file watcher or file lock: changes are checked when saving, and a separate process can still write between the check and the save. When leaving with unsaved edits, the CLI asks whether to discard them or continue editing.

The CLI writes a temporary file in the destination directory, flushes and closes it, then replaces the destination. A write or preparation failure leaves the original file intact. Existing symlinks are retained and their targets are updated. File mode bits are preserved; on Windows the existing file's ACL is also retained. Atomic replacement changes the file identity, so other hard links keep their previous contents. This does not provide a file lock or a transaction with external writers.

Embed the editor

The embedding application owns file I/O and decides when a save succeeds. A typical integration decodes a document, creates an Editor, attaches a save handler, then runs the Bubble Tea program:

func edit(path string, persist func(string, []byte) error) error {
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	session, err := yamlcodec.Decode(file)
	closeErr := file.Close()
	if err != nil {
		return err
	}
	if closeErr != nil {
		return closeErr
	}

	editor, err := structivedit.New(session.Document(), structivedit.WithPolicy(
		structivedit.Policy{
			Default: structivedit.ScopePolicy{Editable: structivedit.Allow},
		},
	))
	if err != nil {
		return err
	}
	editor.MarkClean()

	model := bubbletea.NewModel(editor)
	model.SetSaveHandler(func() error {
		var output bytes.Buffer
		if err := session.Encode(&output, editor.Document()); err != nil {
			return err
		}
		if err := persist(path, output.Bytes()); err != nil {
			return err
		}
		editor.MarkClean()
		return nil
	})
	_, err = tea.NewProgram(model).Run()
	return err
}

The example uses these imports:

import (
	"bytes"
	"os"

	structivedit "github.com/yuyosy/structivedit"
	"github.com/yuyosy/structivedit/adapter/bubbletea"
	yamlcodec "github.com/yuyosy/structivedit/codec/yaml"
	tea "charm.land/bubbletea/v2"
)

bubbletea.NewModel also accepts these options:

Option Default / behavior
WithAliasExpansion(true) Disabled by default; show alias targets as read-only rows
WithAliasRowLimit(n) At most 10,000 projected rows by default; nonpositive values hide projections
WithInlineEditing(true) Disabled by default; edit scalar values in their rows
WithColors(false) Enabled by default; NO_COLOR disables colors
WithMouseDoubleClick(false) Enabled by default for editable scalars and booleans

Call Editor.MarkClean only after encoding and writing both succeed. The caller is responsible for the dirty-exit decision and for selecting file permissions and other persistence behavior. Editor.Apply accepts typed operations such as SetValue, Toggle, Move, Add, Delete, Focus, Undo, and Redo.

Supply a persistence callback that prepares and flushes a temporary file before replacing the destination, as the reference CLI does. The application chooses file permissions and conflict handling. Input controls and bidirectional formatting controls are escaped in the terminal view; the stored values remain unchanged. Display widths, clipping, cursor movement, and deletion account for wide characters and grapheme clusters.

External change handling

The Bubble Tea adapter does not read, watch, or lock files. An application that wants conflict handling can compare a saved fingerprint or version in its SetSaveHandler and return bubbletea.ErrSaveConflict when the source changed. Register SetSaveConflictHandler to resolve that error:

  • SaveConflictOverwrite: write the current document and return a nil Editor.
  • SaveConflictReload: decode the external version, create a clean Editor from its new session, and return that Editor. The model switches to it.
  • Escape cancels the save without calling the conflict handler; the current Editor and its local changes stay in place.

After reload, use model.Editor() to get the Editor now controlled by the model. If application callbacks retain their own Editor or codec Session references, update those references in the reload handler as well. The reference CLI implements this flow with SHA-256 fingerprints.

Schemas and permissions

Schemas describe scalar constraints, declared object fields, array items, and the shapes that Add can create. They can include defaults, enums, numeric ranges, required fields, array length limits, and custom validators. Schema validators run synchronously against immutable document snapshots.

Permissions are configured separately with Policy. Unspecified operations are denied. Scope rules can allow or deny editing, adding, deleting, or reordering at selected locations; TypePolicy controls scalar editing by kind. Selectors use paths beginning with $, with key and index segments, wildcards (.*, [*]), and the recursive wildcard (.**). For example, $.services[0].port selects an array item's port value and $["service name"].** uses a quoted key and recursive wildcard. This is a small selector grammar, not full JSONPath.

Object schemas accept undeclared keys by default. Set UnknownFields to schema.UnknownFieldWarn or schema.UnknownFieldDeny to report them as warnings or errors. AdditionalProperties validates undeclared values and provides the schema for adding new keys when policy permits it. Array item schemas use a pointer so recursive Go schema types have finite size. Schemas are copied and validated when an Editor is created; schema cycles are rejected.

additionalValue := schema.Node{Kind: schema.StringKind}
shape := schema.Node{
	Kind: schema.ObjectKind,
	Object: schema.ObjectSchema{
		Fields: []schema.Field{{
			Name:     "name",
			Schema:   schema.Node{Kind: schema.StringKind},
			Required: true,
		}},
		AdditionalProperties: &additionalValue,
		UnknownFields:        schema.UnknownFieldWarn,
	},
}

This schema fragment uses github.com/yuyosy/structivedit/schema. Pass shape to structivedit.WithSchema when creating the Editor; allow Addable in the applicable Policy scope to enable adding entries.

See the basic embedding example and the schema example for focused integrations.

YAML codec

The bundled codec reads one YAML 1.2 Core Schema document. It supports ordered sequences and mappings, duplicate keys, non-string keys, comments, scalar and collection styles, anchors, aliases, and merge entries. Alias references and merge entries are read-only; alias target rows can be shown with bubbletea.WithAliasExpansion(true). Custom tags retain their tag and representable payload and make the tagged node read-only. Integers outside the signed 64-bit range and multiple YAML documents are rejected.

Encoding preserves YAML metadata by NodeID across edits, reordering, undo, and redo. Output is UTF-8 with LF line endings, two-space indentation, and a final newline. Byte-for-byte source reproduction is not promised; unrepresentable nodes or tag payloads return errors rather than being silently discarded.

Moves that place an alias before its anchor are rejected without changing the document or history. Mapping keys cannot be edited, but optional entries can be deleted when schema and policy permit it. Custom tags and merge entries retain their stronger read-only restrictions. NaN values remain supported; schemas with a numeric minimum or maximum report NaN as a validation error.

For untrusted input, use yamlcodec.DecodeWithOptions to limit input bytes, node count, or nesting depth. A zero limit is unlimited. yamlcodec.Decode keeps unlimited behavior for compatibility.

Node and depth checks happen after the YAML parser builds its tree, so combine them with a byte limit. The reference CLI enables all three limits by default.

Implementing another codec

Implement codec.Codec and codec.Session for the format, then encode later snapshots from the same Document lineage without silently discarding content. codec/conformance.Run can be called from the codec's _test.go file to check decode, round-trip, edited-snapshot, lineage, and reader/writer error behavior. Format-specific preservation behavior belongs in that codec's own tests.

Development

Build all packages:

go build ./...

Run all package tests:

go test ./...

GitHub Actions tests, vets, and builds on Linux and Windows with Go 1.27 and the current stable Go release. A Linux job additionally runs race detection, bounded YAML and selector fuzzing, and govulncheck for reachable known vulnerabilities.

License

This project is licensed under the MIT License. See the LICENSE file for details.

About

Interactive YAML tree editor for Go TUI apps, with schema validation and fine-grained editing policies. Built with Bubble Tea.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages