jawstree

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 18 Imported by: 0

README

build coverage Docs

jawstree

Provides a statically served and embedded version of Quercus.js, a lightweight and customizable JavaScript treeview library with no dependencies.

Asset provenance

The embedded third-party files are vendored Quercus.js tree assets from https://github.com/stefaneichert/quercus.js. The assets/jawstree.* files are local adapter source in this repository and are covered by normal code review.

File Source
assets/treeview.js Quercus.js from https://github.com/stefaneichert/quercus.js
assets/treeview.css Quercus.js styles from https://github.com/stefaneichert/quercus.js

When bumping the vendored Quercus files, update this table in the same change.

package main

import (
	"embed"
	"log/slog"
	"net/http"
	"sync"

	"github.com/linkdata/jaws"
	"github.com/linkdata/jaws/jawsboot"
	"github.com/linkdata/jawstree"
	"github.com/linkdata/jaws/lib/templatereloader"
	"github.com/linkdata/jaws/lib/ui"
	"github.com/linkdata/staticserve"
)

// This example assumes an 'assets' directory:
//
//.  assets/
//.    static/
//.      images/
//.        favicon.png
//.    ui/
//.      index.html

//go:embed assets
var assetsFS embed.FS

func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) {
	mux.Handle("GET /jaws/", jw) // Ensure the JaWS routes are handled
	var tmpl jaws.TemplateLookuper
	if tmpl, err = templatereloader.New(assetsFS, "assets/ui/*.html", ""); err == nil {
		jw.AddTemplateLookuper(tmpl)
		// Initialize jawsboot; we will serve the JavaScript and CSS from /static/*.[js|css].
		// All files under assets/static will be available under /static. Any favicon loaded
		// this way will have its URL available using jw.FaviconURL().
		if err = jw.Setup(mux.Handle, "/static",
			jawsboot.Setup,
			jawstree.Setup,
			staticserve.MustNewFS(assetsFS, "assets/static", "images/favicon.png"),
		); err == nil {
			var mu sync.RWMutex
			root := &jawstree.Node{Children: []*jawstree.Node{
				{Name: "Documents", Children: []*jawstree.Node{{Name: "report.pdf"}}},
				{Name: "Pictures"},
			}}
			tree, terr := jawstree.New(&mu, root, jawstree.InitiallyExpanded)
			if terr != nil {
				return terr
			}
			mux.Handle("GET /", ui.Handler(jw, "index.html", tree))
		}
	}
	return
}

func main() {
	jw, err := jaws.New()
	if err == nil {
		jw.Logger = slog.Default()
		if err = setupJaws(jw, http.DefaultServeMux); err == nil {
			// start the JaWS processing loop and the HTTP server
			go jw.Serve()
			slog.Error(http.ListenAndServe("localhost:8080", nil).Error())
		}
	}
	if err != nil {
		panic(err)
	}
}

The example expects an assets directory in the source tree:

assets
├── static
│   └── images
│       └── favicon.png
└── ui
    └── index.html

The examples use {{$.HeadHTML}} inside <head> to emit the configured resources and Request key metadata. Applications that provide equivalent markup may omit it. {{$.TailHTML}} is optional; placing it before the closing </body> tag applies updates queued during initial rendering before the WebSocket connects.

Using the tree widget

A Tree is shared, server-authoritative UI state and the jaws.UI that renders it. Build it once before serving, then render that same *Tree for every request that should show the same collaborative tree; it holds no per-request state, so sharing it is safe.

New takes the lock guarding the tree (which may be shared with other application state) and the root *Node. It returns ErrInvalidTree for an invalid graph (nil, cyclic, shared node, unknown option bit, more than MaxTreeNodes nodes, nesting deeper than MaxTreeDepth, or depth-weighted serialized node data exceeding MaxTreeRenderBytes) and ErrInvalidSelection when the initial Selected flags violate the mode's policy. It assigns each node's positional-path ID, a preorder wire index, and the parent back-pointers the name-path API needs, so it must run before rendering. Once New returns, only the selection may change, through Tree.SetSelected or browser events. Each node's Name, Disabled, assigned ID, and the topology (Children) are fixed; changing any of them afterward is unsupported, with a different consequence per field: altering the topology or an ID breaks the ID-to-wire-position mapping used by Quercus.js; enlarging a Name defeats the size bounds New enforced (rendering re-serializes the live tree); toggling Disabled can desync the selection policy.

Build a Node tree (by hand, or from a directory with Root) and pass it with a lock to New. Browser container IDs are managed internally:

var mu sync.RWMutex
root := &jawstree.Node{Children: []*jawstree.Node{
	{Name: "Documents", Children: []*jawstree.Node{{Name: "report.pdf"}}},
	{Name: "Pictures"},
}}
tree, err := jawstree.New(&mu, root, jawstree.InitiallyExpanded)
if err != nil {
	// handle an invalid tree or initial selection
}
mux.Handle("GET /", ui.Handler(jw, "index.html", tree))

In the page template, render the tree directly. Its JaWS-managed Jid.N element is also the Quercus.js container; the browser initializer unhides it after the deferred page assets are ready. The same initialization works when a JaWS container or template inserts the Tree through a live DOM update:

<!DOCTYPE html>
<html>
<head>{{$.HeadHTML}}</head>
<body>
  {{$.NewUI .Dot}}
  {{$.TailHTML}}
</body>
</html>

Selections made in the browser are validated and applied to the Node tree under the Tree's lock; read them with Tree.GetSelected or change them with Tree.SetSelected. After mutating the tree server-side, push the new state to all rendered clients with Tree.Dirty:

if err := tree.SetSelected([][]string{{"Documents", "report.pdf"}}); err == nil {
	tree.Dirty(jw)
}

The selection policy follows the configured mode. With neither MultiSelectEnabled nor CascadeSelectChildren, at most one node may be selected. With cascade but not multi-select, the selection is empty or one connected rooted subtree: descendant branches may be pruned, but every selected descendant must have its selectable ancestors selected back to the subtree root. Disabled ancestors are transparent. Tree.SetSelected returns ErrInvalidSelection for a selection the browser cannot represent, and a new browser cascade replaces the previous subtree. MultiSelectEnabled permits independent selected nodes.

Documentation

Overview

Package jawstree provides a JaWS widget and embedded assets for the Quercus.js treeview library.

The package embeds a small JaWS adapter plus vendored Quercus.js assets from https://github.com/stefaneichert/quercus.js. When updating the vendored Quercus files, update README.md's provenance table in the same change.

Example

Example wires jawstree (and jawsboot) into an HTTP server. It is a compile-checked illustration only: it starts a blocking server, so it has no testable Output and is not executed by "go test".

package main

import (
	"embed"
	"log/slog"
	"net/http"
	"sync"

	"github.com/linkdata/jaws"
	"github.com/linkdata/jaws/jawsboot"
	"github.com/linkdata/jaws/lib/templatereloader"
	"github.com/linkdata/jaws/lib/ui"
	"github.com/linkdata/jawstree"
	"github.com/linkdata/staticserve"
)

// This example assumes an 'assets' directory:
//
//.  assets/
//.    static/
//.      images/
//.        favicon.png
//.    ui/
//.      index.html

//go:embed assets
var assetsFS embed.FS

func setupJaws(jw *jaws.Jaws, mux *http.ServeMux) (err error) {
	mux.Handle("GET /jaws/", jw) // Ensure the JaWS routes are handled
	var tmpl jaws.TemplateLookuper
	if tmpl, err = templatereloader.New(assetsFS, "assets/ui/*.html", ""); err == nil {
		_ = jw.AddTemplateLookuper(tmpl)
		// Initialize jawsboot; we will serve the JavaScript and CSS from /static/*.[js|css].
		// All files under assets/static will be available under /static. Any favicon loaded
		// this way will have its URL available using jaws.FaviconURL().
		if err = jw.Setup(
			mux.Handle, "/static",
			jawsboot.Setup,
			jawstree.Setup,
			staticserve.MustNewFS(assetsFS, "assets/static", "images/favicon.png"),
		); err == nil {
			var mu sync.RWMutex
			root := &jawstree.Node{Children: []*jawstree.Node{
				{Name: "Documents", Children: []*jawstree.Node{{Name: "report.pdf"}}},
				{Name: "Pictures"},
			}}
			tree, terr := jawstree.New(&mu, root, jawstree.InitiallyExpanded)
			if terr != nil {
				return terr
			}
			mux.Handle("GET /", ui.Handler(jw, "index.html", tree))
		}
	}
	return
}

// Example wires jawstree (and jawsboot) into an HTTP server. It is a
// compile-checked illustration only: it starts a blocking server, so it has no
// testable Output and is not executed by "go test".
func main() {
	jw, err := jaws.New()
	if err == nil {
		jw.Logger = slog.Default()
		if err = setupJaws(jw, http.DefaultServeMux); err == nil {
			// start the JaWS processing loop and the HTTP server
			go jw.Serve()
			slog.Error(http.ListenAndServe("localhost:8080", nil).Error())
		}
	}
	if err != nil {
		panic(err)
	}
}

Index

Examples

Constants

View Source
const MaxTreeDepth = 128

MaxTreeDepth is the deepest nesting New accepts, measured in edges from the root (the root is depth 0, its children depth 1).

The browser renderer (the vendored Quercus.js treeview) recurses once per level, so its stack use grows with the tree depth. A tree deeper than a browser can render is rejected here with ErrInvalidTree so it never reaches the client. The bound is far above any realistic UI nesting yet well below where the renderer's recursion overflows.

View Source
const MaxTreeNodes = 180000

MaxTreeNodes is the largest node count New accepts.

It bounds the largest possible selection message, whose size grows with the node count, to stay within the WebSocket size limit. New rejects a larger tree with ErrInvalidTree; TestSelectionFrameFitsInboundLimit pins the guarantee.

View Source
const MaxTreeRenderBytes = 64 << 20 // 64 MiB

MaxTreeRenderBytes is the largest depth-weighted serialized node size New accepts: the sum over all nodes of (depth+1) times the node's own wire size (its JSON-escaped name, positional-path ID, and fixed structural bytes).

The browser holds each node's whole serialized data — its potentially large Node.Name included — in the init payload and in the data-node-data of every element whose subtree contains it. The root is not rendered, so its data is held once (the payload). A non-root node at depth d is held d+1 times: once in the payload, once on its own element, and once on each of its d-1 rendered ancestors. Weighting each node by depth+1 covers both cases. This depth-weighted sum is what the independent MaxTreeNodes and MaxTreeDepth bounds do not cap: a shallow spine with a wide, deep fan-out, or a deep chain of large names, passes them yet duplicates its data across every level. New rejects a tree whose depth-weighted serialized size exceeds this limit with ErrInvalidTree.

The bound is on the retained serialized node data (the per-element data plus the init payload), which is a proxy for, not the exact size of, client memory: the renderer also builds DOM from it, and cascade mode copies descendant data, so actual client memory is a bounded multiple of this ceiling.

Variables

View Source
var ErrInvalidSelection = errors.New("jawstree: invalid selection")

ErrInvalidSelection identifies a selection rejected by a Tree policy.

New and Tree.SetSelected return it for more than one selected node in ordinary single-select mode, a disconnected selection in cascade-only mode, selection on a tree configured with NodeSelectionDisabled, or a selected node that is disabled or the root. Match with errors.Is.

View Source
var ErrInvalidTree = errors.New("jawstree: invalid tree")

ErrInvalidTree is returned by New when the supplied node graph cannot back a Tree: a nil root, a cyclic or shared-node graph (a node reachable more than once), a negative or unknown Option bit, more nodes than MaxTreeNodes, nesting deeper than MaxTreeDepth, or depth-weighted serialized node data exceeding MaxTreeRenderBytes. The error text carries the specific reason; match the class with errors.Is.

View Source
var ErrPathRejected = errors.New("jawstree: refusing client selection write")

ErrPathRejected identifies structurally invalid browser selection input.

Tree.JawsInput uses it for a malformed payload, an out-of-range or root node index, a disabled node, or an absolute selection that the configured mode cannot represent. Other selection-policy failures may match ErrInvalidSelection instead. The error text carries the specific reason; match the class with errors.Is.

Functions

func Setup

func Setup(jw *jaws.Jaws, handleFn jaws.HandleFunc, prefix string) (urls []*url.URL, err error)

Setup registers embedded jawstree static assets under prefix.

The prefix may be absolute ("/static"), relative ("static") or empty; the registered handler paths and the returned URLs are kept identical in all cases.

It is intended to be passed to jaws.Jaws.Setup. Returned URLs should be included in the page head through jaws.Jaws.GenerateHeadHTML.

Types

type Node

type Node struct {
	Parent   *Node   `json:"-"`                 // parent node, set by New, nil for root
	Name     string  `json:"name"`              // display name; do not change after New (see [Tree])
	ID       string  `json:"id,omitzero"`       // positional JSON path, set by New; also the DOM data-id
	Selected bool    `json:"selected,omitzero"` // selected state
	Disabled bool    `json:"disabled,omitzero"` // emitted as "selectable":false (inverted) on the wire
	Children []*Node `json:"children,omitzero"` // child nodes
}

Node is one tree node rendered by Tree.

Concurrency: once the owning Tree has been rendered, its Node tree is shared with the JaWS event goroutines, which access it under the Tree's lock. The exported Node accessors below are not internally synchronized, so callers must hold that lock when using them on a rendered Tree: the Tree's read lock (RLock) for the read-only helpers (Node.Walk, Node.HasNames, Node.GetNames, Node.GetSelected, Node.MarshalJSON). No locking is needed before the Tree is rendered (for example while building it in New).

The struct json tags are documentation only: the wire encoding sent to Quercus.js is produced by Node.MarshalJSON, and Disabled is emitted inverted (as "selectable":false), so the tags do not all mirror the wire shape.

func Root

func Root(r *os.Root, filterFn func(dirpath string, ent fs.DirEntry) (include bool)) (rootnode *Node, err error)

Root builds a root node from an os.Root. It panics if r is nil.

If filterFn is not nil, a directory entry is included only when filterFn returns true for it. Entries that are neither regular files nor directories (such as symbolic links) are always excluded, regardless of filterFn.

Building the tree is best-effort: if one or more directories cannot be read, Root returns the tree built from the readable entries together with a non-nil error joining every read failure (see errors.Join). A directory whose own listing fails to read is omitted from its parent, but its readable siblings — and the readable entries of any directory with a deeper failure — are kept.

The returned nodes have no parent back-pointers and are not yet prepared for rendering; pass the tree to New, which assigns node identities and the parent back-pointers the name-path helpers need.

func (*Node) GetNames

func (node *Node) GetNames() (names []string)

GetNames returns the path of names from the root to node.

func (*Node) GetSelected

func (node *Node) GetSelected() (nameLists [][]string)

GetSelected returns the name-paths (root-to-node name lists) of all selected nodes.

Selection is reported by name-path, not by the unique node identity used on the wire. If sibling nodes share the same name their name-paths are identical, so the round-trip is lossy: Tree.SetSelected cannot tell them apart and selects every sibling sharing a selected name-path. Give siblings distinct names if they must be addressed independently through this API, or use the index-based wire selection.

func (*Node) HasNames

func (node *Node) HasNames(names []string) (yes bool)

HasNames reports whether node matches names as a path from the root.

The root (nil Parent) matches only an empty names slice. The match walks the parent chain, comparing each name against the corresponding ancestor, so a call is O(len(names)); resolving large selections over deep trees is therefore O(nodes x depth x paths).

func (Node) MarshalJSON

func (node Node) MarshalJSON() (b []byte, err error)

MarshalJSON writes the Quercus.js JSON shape for node.

func (*Node) Walk

func (node *Node) Walk(jsPath string, fn func(jsPath string, node *Node))

Walk calls fn for node and all descendants with their JSON paths.

node is visited with the supplied jsPath; callers pass "" for the root. Each descendant is visited with "children.<i>" appended to its parent's path, where i is the child's index in node.Children. A nil child is skipped while i keeps the raw slice index, so on a Tree built by New — where [Node.stripNilChildren] has already removed nil entries — these indices stay dense and match the wire positions emitted by [Node.marshalJSON].

type Option

type Option int

Option configures a Tree.

const (
	// SearchEnabled enables tree search controls.
	SearchEnabled Option = (1 << iota)
	// InitiallyExpanded renders nodes expanded initially.
	InitiallyExpanded
	// MultiSelectEnabled allows independent selected nodes.
	MultiSelectEnabled
	// ShowSelectAllButton shows a select-all control.
	ShowSelectAllButton
	// ShowInvertSelectionButton shows an invert-selection control.
	ShowInvertSelectionButton
	// ShowExpandCollapseAllButtons shows expand/collapse-all controls.
	ShowExpandCollapseAllButtons
	// NodeSelectionDisabled disables node selection.
	NodeSelectionDisabled
	// CascadeSelectChildren cascades selection to child nodes.
	//
	// Without [MultiSelectEnabled], the selection is empty or one connected rooted
	// subtree with disabled nodes treated as transparent, and selecting a new node
	// replaces the previous subtree.
	CascadeSelectChildren
	// CheckboxSelectionEnabled renders checkbox selection controls.
	CheckboxSelectionEnabled
)

The bit positions below are wired one-to-one to the literal bit tests in jawstreeDecodeOptions (assets/jawstree.js); do not reorder or insert constants mid-block without updating that script.

type Tree

type Tree struct {
	bind.RWLocker // guards root selection state and is the concurrency contract for Node accessors
	// contains filtered or unexported fields
}

Tree is the shared, server-authoritative model behind a Quercus.js tree, and the jaws.UI that renders it.

A Tree holds the node tree and the master selection state, guarded by the lock passed to New (which the application may share with other state). A single Tree is built once and rendered by every request that should show the same collaborative tree; it holds no per-request state, so sharing it is safe.

The tree is fixed once New returns: it derives each node's identity from its position and validates the wire-shaping state against the MaxTreeNodes, MaxTreeDepth, and MaxTreeRenderBytes bounds. Only the selection may change afterward, through Tree.SetSelected or browser events (safe under the lock); each node's Name, Disabled, assigned ID, and the topology (Children) are fixed. Changing any of them is unsupported, with a different consequence per field: altering the topology or an ID breaks the wire-index-to-node identity mapping; enlarging a Name defeats the size bounds New enforced (rendering re-serializes the live tree); toggling Disabled can desync the selection policy.

func New

func New(l sync.Locker, root *Node, options ...Option) (t *Tree, err error)

New returns a shared tree model for root, guarded by l.

It returns ErrInvalidTree for a nil root, a cyclic or shared-node graph, a negative or unknown Option bit, more than MaxTreeNodes nodes, nesting deeper than MaxTreeDepth, or depth-weighted serialized node data exceeding MaxTreeRenderBytes, and ErrInvalidSelection when the initial Selected flags violate the selection policy (see Tree.SetSelected).

New must run before rendering the Tree or using the name-path selection API. The Tree renders its own container, so no caller-provided HTML id is required.

func (*Tree) Dirty

func (t *Tree) Dirty(jw *jaws.Jaws)

Dirty marks the Tree changed so every rendered view resynchronizes its client on the next update pass. Call it after mutating selection server-side.

func (*Tree) GetSelected

func (t *Tree) GetSelected() (nameLists [][]string)

GetSelected returns the name-paths of all selected nodes.

It reads under the read lock. Selection is reported by name-path, which is lossy for duplicate sibling names; see Node.GetSelected.

func (*Tree) JawsInput

func (t *Tree) JawsInput(elem *jaws.Element, value string) error

JawsInput applies a browser selection change to the shared Tree.

On a real change it dirties the Tree so every rendered client reconverges. On a rejected, malformed, or no-op change it resynchronizes only the originating client, whose optimistic view may be ahead of the server, leaving peers untouched. It always returns nil: a routine client rejection is logged, not surfaced as an alert.

func (*Tree) JawsRender

func (t *Tree) JawsRender(elem *jaws.Element, w io.Writer, params []any) (err error)

JawsRender writes the hidden tree container and schedules its browser initialization.

The current selection is included in the initializer, so a freshly rendered client is correct before any update arrives. A Tree is shared UI state: one *Tree may be rendered by many requests, each getting its own element.

func (*Tree) JawsUpdate

func (t *Tree) JawsUpdate(elem *jaws.Element)

JawsUpdate pushes the current selection to this element's client.

It is safe to call concurrently with the browser event handling that mutates selection, and never rebuilds the tree, so the client's local expansion state is preserved.

func (*Tree) SetSelected

func (t *Tree) SetSelected(nameLists [][]string) (err error)

SetSelected sets the selection to the nodes matching the given name-paths.

It runs under the write lock and enforces the selection policy, returning ErrInvalidSelection when the match violates it (for example matching more than one node in a single-select tree, a disconnected selection with CascadeSelectChildren but not MultiSelectEnabled, or a disabled node). Matching is by name-path and lossy for duplicate sibling names; see Node.GetSelected.

func (*Tree) Walk

func (t *Tree) Walk(fn func(jsPath string, node *Node))

Walk calls fn for the tree root and all descendants under the read lock; the callback must not call methods that acquire the same tree lock.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL