Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
### v0.9.0

- adds Break(key,value,...) for temporary runtime inspection.
- add optional filename to Dump()

### v0.8.1

- Explore replaces object with same root label.
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ Note: if the list contains just one structural value then selecting it can be sk

## explore while debugging

### Break

The following instruction will start the explorer on a struct, opens a Browser and provides a `resume` button to stop the explorer and resume the Go-routine that started it.

structexplorer.Break("myStruct", myStruct)

### Dump

Currently, the standard Go debugger `delve` stops all goroutines while in a debugging session.
This means that if you have started the `structexplorer` service in your program, it will not respond to any HTTP requests during that session.

Expand All @@ -57,10 +65,14 @@ The explorer can also be asked to dump an HTML page with the current state of va
s := structexplorer.NewService()
s.Explore("yours", yourStruct)
s.ExplorePath("yours.field") // dotted path of fields starting with an explore label
s.Dump()
s.Dump()
// or s.Dump("yourfile.html")

Another method is to use a special test case which starts an explorer at the end of a test and then run it with a longer acceptable timeout.

## examples

See folder `examples` for simple programs demonstrating each feature.


© 2025. https://ernestmicklei.com. MIT License
20 changes: 20 additions & 0 deletions examples/break/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package main

import (
"log"

"github.com/emicklei/structexplorer"
)

// go run .
func main() {
greeting := map[string]any{}
hello := struct{ Field string }{Field: "hello"}
greeting["hi"] = hello

log.Println("before opening the explorer to see state")

structexplorer.Break("map", greeting)

log.Println("after opening the explorer to see state")
}
18 changes: 18 additions & 0 deletions examples/break/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"log"
"testing"

"github.com/emicklei/structexplorer"
)

func TestWithBreak(t *testing.T) {
target := struct{ Field string }{Field: "hello"}

log.Println("before opening the explorer to see state")

structexplorer.Break("debugging", target)

log.Println("after opening the explorer to see state")
}
3 changes: 3 additions & 0 deletions explorer.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ func (e *explorer) putObjectStartingAt(row, col int, access objectAccess, option
}

func (e *explorer) buildIndexData(b *indexDataBuilder) indexData {
// was it starting using Break?
b.data.IsBreaking = b.isBreaking

for row, each := range e.accessMap {
for col, access := range each {
info := b.build(row, col, access)
Expand Down
9 changes: 5 additions & 4 deletions index_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import (
)

type indexDataBuilder struct {
data indexData
seq int
notLive bool
selectID string // id of the added fieldList (select element)
data indexData
seq int
notLive bool
isBreaking bool // service is started with Break(...)
selectID string // id of the added fieldList (select element)
}

func newIndexDataBuilder() *indexDataBuilder {
Expand Down
7 changes: 4 additions & 3 deletions index_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ var styleCSS string

type (
indexData struct {
Rows []tableRow
Script template.JS
Style template.CSS
Rows []tableRow
Script template.JS
Style template.CSS
IsBreaking bool
}
tableRow struct {
Cells []fieldList
Expand Down
5 changes: 5 additions & 0 deletions index_tmpl.html
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@
<span id="theme-toggle" class="theme-toggle" title="Toggle Theme"
>🔄</span
>
{{- if .IsBreaking }}
<button class="btn" title="resume from a break" onclick="javascript:resume();">
Resume from Breakpoint
</button>
{{- end }}
</p>

<script>
Expand Down
21 changes: 21 additions & 0 deletions open.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package structexplorer

import (
"fmt"
"os/exec"
"runtime"
)

// Open calls the OS default program for uri
func open(uri string) error {
switch {
case "windows" == runtime.GOOS:
return exec.Command("rundll32", "url.dll,FileProtocolHandler", uri).Start()
case "darwin" == runtime.GOOS:
return exec.Command("open", uri).Start()
case "linux" == runtime.GOOS:
return exec.Command("xdg-open", uri).Start()
default:
return fmt.Errorf("unable to open uri:%v on:%v", uri, runtime.GOOS)

Copilot AI Oct 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space in error message format string.

Suggested change
return fmt.Errorf("unable to open uri:%v on:%v", uri, runtime.GOOS)
return fmt.Errorf("unable to open uri: %v on: %v", uri, runtime.GOOS)

Copilot uses AI. Check for mistakes.
}
}
14 changes: 12 additions & 2 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ function explore(row, column, selectNode, action) {
action: action,
selections: getSelectValues(selectNode)
}));
xhr.onload = function () { window.location.reload(); }
xhr.onload = function() { window.location.reload(); }
}

function resume() {
const xhr = new XMLHttpRequest();
xhr.open("POST", window.location.href);
xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8")

Copilot AI Oct 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing semicolon at end of statement. This is inconsistent with the coding style used elsewhere in the file.

Suggested change
xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8")
xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8");

Copilot uses AI. Check for mistakes.
xhr.send(JSON.stringify({
action: "resume"
}));
}

// Return an array of the selected option values in the control.
// Select is an HTML select element.
function getSelectValues(select) {
Expand All @@ -34,4 +44,4 @@ function getSelectValues(select) {
result.push(opt.value || opt.text);
}
return result;
}
}
66 changes: 61 additions & 5 deletions service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package structexplorer

import (
"context"
_ "embed"
"encoding/json"
"fmt"
Expand All @@ -18,8 +19,11 @@ type Service interface {
// Start accepts 0 or 1 Options
Start(opts ...Options)

// Break accepts 0 or 1 Options
Break(opts ...Options)

// Dump writes an HTML file for displaying the current state of the explorer and its entries.
Dump()
Dump(optionFilename ...string)

// Explore adds or replaces (matching on label) a new entry for a value unless it cannot be explored.
// The object will be placed on the next available column on row 1.
Expand All @@ -44,6 +48,7 @@ func (s *service) init() {
type service struct {
explorer *explorer
indexTemplate *template.Template
httpServer *http.Server
}

// NewService creates a new to explore one or more values (structures).
Expand All @@ -53,6 +58,45 @@ func NewService(labelValuePairs ...any) Service {
return s
}

// Break will listen and serve on the default endpoint and opens a window.
// The explorer page will have a button "Resume" that stops the server
// and unblocks the go-routine that started it.
func Break(keyvaluePairs ...any) {
NewService(keyvaluePairs...).Break(Options{
ServeMux: new(http.ServeMux),
})
}

// Break will listen and serve on the given http port and path.
// it accepts 0 or 1 Options to override defaults.
// The opened explorer page will have a button "Resume" that stops the server
// and unblocks the go-routine that started it.
func (s *service) Break(opts ...Options) {
if len(opts) > 0 {
s.explorer.options = &opts[0]
}
port := s.explorer.options.httpPort()
serveMux := s.explorer.options.serveMux()
rootPath := s.explorer.options.rootPath()
serveMux.Handle(rootPath, s)
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: serveMux,
}
s.httpServer = server
open(fmt.Sprintf("http://localhost:%d", port))

Copilot AI Oct 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error return value from open() is ignored. If opening the browser fails, the user won't be notified, which could be confusing since they might expect a browser window to appear.

Suggested change
open(fmt.Sprintf("http://localhost:%d", port))
if err := open(fmt.Sprintf("http://localhost:%d", port)); err != nil {
slog.Error("failed to open browser", "err", err)
}

Copilot uses AI. Check for mistakes.
// this blocks until server is closed by resume operation.
server.ListenAndServe()

Copilot AI Oct 4, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return value of ListenAndServe() should be checked. If the server fails to start, the error will be ignored and the function will return silently, potentially leaving the caller in an unexpected state.

Suggested change
server.ListenAndServe()
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server failed", "err", err)
}

Copilot uses AI. Check for mistakes.
}

func (s *service) resume() {
if s.httpServer == nil {
return
}
s.httpServer.Shutdown(context.Background())
s.httpServer = nil
}

// Start will listen and serve on the given http port and path.
// it accepts 0 or 1 Options to override defaults.
func (s *service) Start(opts ...Options) {
Expand All @@ -64,7 +108,7 @@ func (s *service) Start(opts ...Options) {
rootPath := s.explorer.options.rootPath()
slog.Info(fmt.Sprintf("starting go struct explorer at http://localhost:%d%s on %v", port, rootPath, s.explorer.rootKeys()))
serveMux.Handle(rootPath, s)
if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil {
if err := http.ListenAndServe(fmt.Sprintf(":%d", port), serveMux); err != nil {
slog.Error("[structexplorer] failed to start service", "err", err)
}
}
Expand Down Expand Up @@ -98,7 +142,11 @@ func (s *service) serveIndex(w http.ResponseWriter, _ *http.Request) {
defer s.protect()()

w.Header().Set("content-type", "text/html")
if err := s.indexTemplate.Execute(w, s.explorer.buildIndexData(newIndexDataBuilder())); err != nil {

builder := newIndexDataBuilder()
builder.isBreaking = s.httpServer != nil

if err := s.indexTemplate.Execute(w, s.explorer.buildIndexData(builder)); err != nil {
slog.Error("failed to execute template", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
Expand Down Expand Up @@ -142,10 +190,14 @@ func (s *service) Explore(label string, value any, options ...ExploreOption) Ser
}

// Dump writes an HTML file for displaying the current state of the explorer and its entries.
func (s *service) Dump() {
func (s *service) Dump(optionalFilename ...string) {
defer s.protect()()

out, err := os.Create("structexplorer.html")
fName := "structexplorer.html"
if len(optionalFilename) > 0 && optionalFilename[0] != "" {
fName = optionalFilename[0]
}
out, err := os.Create(fName)
if err != nil {
slog.Error("failed to create dump file", "err", err)
}
Expand Down Expand Up @@ -204,6 +256,10 @@ func (s *service) serveInstructions(w http.ResponseWriter, r *http.Request) {
case "clear":
s.explorer.removeNonRootObjects()
return
case "resume":
s.resume()
return

default:
slog.Warn("[structexplorer] invalid direction", "action", cmd.Action)
http.Error(w, "invalid action", http.StatusBadRequest)
Expand Down