main.go

// Command notes is a local-first Markdown notes app built on gophics — one
// codebase for desktop, web, and terminal. The widget tree, Root, and Config
// live in the importable examples/notes/ui package.
package main

import (
	"log"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/examples/notes/ui"
)

func main() {
	if err := app.Run(ui.Root(), ui.Config()); err != nil {
		log.Fatal(err)
	}
}

ui/app.go

// Package ui is a local-first Markdown notes app — the driving example for
// gophics's text-editing and state-preservation stories. A vault is a folder
// of .md files; edit and read them side by side, follow [[wikilinks]], and —
// because all UI state is plain serializable data — a `gophics dev` hot-restart
// drops you back on the same note, in the same mode, with unsaved edits intact.
package ui

import (
	"strings"

	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/theme"
	"github.com/doug/gophics/widget"
)

// BG is the window background used before a widget context exists (Config.
// Background). Inside the tree every color comes from theme.Of(ctx), so the app
// follows the platform light/dark scheme for free; this matches the light Bg.
var BG = theme.Light().Bg

// mdTheme maps the active Theme onto the markdown renderer's style: neutrals to
// the text/muted/surface tokens, the link accent to Primary, and the body size
// to the type scale. The heading size ramp stays bespoke (a 6-level scale the
// TypeScale doesn't cover).
func mdTheme(th theme.Theme) mdStyle {
	return mdStyle{
		Text:    th.Text,
		Heading: th.Text,
		Code:    th.Text,
		CodeBG:  th.SurfaceHover,
		Link:    th.Primary,
		Meta:    th.Muted,
		Size:    th.Type.Body,
	}
}

// Root loads the vault and returns the workspace. The vault directory comes from
// $NOTES_DIR, else ./examples/notes/vault (when run from the repo root), else
// ./vault.
func Root() widget.Widget {
	return Workspace{Vault: defaultVault()}
}

// Workspace is the whole UI: a note list beside a reader/editor pane.
type Workspace struct{ Vault *Vault }

func (Workspace) CreateState() widget.State { return &workspaceState{} }

// stateHook lets tests observe the mounted workspace state.
var stateHook func(*workspaceState)

func (s *workspaceState) Init(ctx widget.Ctx) {
	if stateHook != nil {
		stateHook(s)
	}
	// Desktop opens a local folder at launch and has nothing to restore. In a
	// browser there is no local folder, so this is the only way back to the
	// vault the user picked last time.
	if !s.W().Vault.HasStore() {
		restoreFolder(ctx.FolderPicker(), ctx.Preferences(), s)
	}
}

// workspaceState's exported fields are session state: a hot-restart (or any
// snapshot/restore) puts you back on the same note, in the same mode, with the
// same unsaved draft.
type workspaceState struct {
	widget.StateBase[Workspace]
	OpenPath string // open note's absolute path
	Editing  bool   // edit vs read mode
	Draft    string // unsaved edit buffer
	Query    string // sidebar search filter

	// Transient (unexported → not persisted): note-management UI.
	creating      bool   // the new-note name input is showing
	newName       string // name being typed for a new note
	confirmDelete bool   // delete is armed (second click confirms)
	storeErr      string // last folder-open error (web), shown in the sidebar
	// reopen is set when the remembered folder is still there but the browser
	// has dropped the permission, which only a user gesture can restore. It
	// turns the prompt into "reopen the one you had" rather than "find it
	// again", which is the difference between a vault the app remembers and
	// one it merely used to know about.
	reopen bool
}

func (s *workspaceState) Build(ctx widget.Ctx) widget.Widget {
	// Resolve the theme from the platform color scheme and provide it to the
	// tree, so every panel below reads colors with theme.Of(ctx) and the whole
	// app follows light/dark automatically.
	th := theme.Auto(ctx)
	v := s.W().Vault
	children := []widget.Widget{
		widget.Sized{W: 240, Child: s.sidebar(ctx, th, v)},
		widget.Sized{W: 1, Child: widget.Decorated{Color: th.Border}},
		widget.Expand(s.pane(ctx, th, v)),
	}
	// Outline is a third column, shown in read mode when the open note has
	// headings. It lives at the top level (not nested in the pane) so it shares
	// the proven outer split layout.
	if note, ok := v.Get(s.OpenPath); ok && !s.Editing && len(extractHeadings(note.Body)) > 0 {
		children = append(children,
			widget.Sized{W: 1, Child: widget.Decorated{Color: th.Border}},
			widget.Sized{W: 200, Child: outlinePanel(th, note)},
		)
	}
	row := widget.Row(children...)
	row.CrossAlign = layout.CrossStretch
	return widget.Provide[theme.Theme]{
		Value: th,
		Child: widget.Fill{Color: th.Bg, Child: row},
	}
}

func (s *workspaceState) sidebar(ctx widget.Ctx, th theme.Theme, v *Vault) widget.Widget {
	// No folder is open until the user picks one — the starting state in a
	// browser. Desktop finds a local folder at launch, so this never shows there.
	if !v.HasStore() {
		return s.folderPrompt(ctx, th)
	}
	head := widget.Row(
		widget.Expand(widget.Text{Value: "NOTES", Font: "bold", Size: th.Type.Label, Color: th.Muted}),
		widget.Interactive{
			Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.creating = true; s.newName = "" }) }},
			Child:    widget.Text{Value: "+ New", Size: th.Type.Label, Color: th.Primary},
		},
	)
	head.CrossAlign = layout.CrossCenter

	items := []widget.Widget{
		widget.Padding{Insets: geom.Insets{Left: 16, Right: 12, Top: 14, Bottom: 6}, Child: head},
	}
	if s.creating {
		items = append(items, widget.Padding{Insets: geom.Insets{Left: 12, Right: 12, Bottom: 6}, Child: theme.Field{
			Value:       s.newName,
			Placeholder: "Name, then Enter…",
			OnChange:    func(t string) { s.SetState(func() { s.newName = t }) },
			OnSubmit:    func(string) { s.createNote(v) },
		}})
	}
	items = append(items, widget.Padding{Insets: geom.Insets{Left: 12, Right: 12, Bottom: 6}, Child: theme.Field{
		Value:       s.Query,
		Placeholder: "Search notes…",
		OnChange:    func(t string) { s.SetState(func() { s.Query = t }) },
	}})

	for _, n := range v.Search(s.Query) {
		bg := th.Bg
		if n.Path == s.OpenPath {
			bg = th.Selection
		}
		items = append(items, theme.Tappable{
			OnTap:      func() { s.open(n.Path) },
			Background: bg,
			Pad:        geom.InsetsSymmetric(16, 9),
			Child:      widget.Text{Value: n.Name, Size: th.Type.Body, Color: th.Text},
		})
	}
	col := widget.Column(items...)
	col.CrossAlign = layout.CrossStretch
	return widget.Decorated{Color: th.Bg, Child: widget.Scroll{Child: col}}
}

// folderPrompt is the starting state with no folder open: invite the user to
// pick one. It hangs off a button because the picker needs a user gesture.
func (s *workspaceState) folderPrompt(ctx widget.Ctx, th theme.Theme) widget.Widget {
	items := []widget.Widget{
		widget.Padding{Insets: geom.Insets{Left: 16, Right: 12, Top: 14, Bottom: 10},
			Child: widget.Text{Value: "NOTES", Font: "bold", Size: th.Type.Label, Color: th.Muted}},
		widget.Padding{Insets: geom.InsetsSymmetric(12, 4),
			Child: s.button(th, "Open folder…", func() { openFolder(ctx.FolderPicker(), ctx.Preferences(), s) })},
		widget.Padding{Insets: geom.InsetsSymmetric(16, 6),
			Child: widget.Text{Value: "Pick a folder of .md files to read and edit them locally.",
				Size: th.Type.Caption, Color: th.Muted, Wrap: true}},
	}
	// The folder from last session is still remembered but the browser has
	// dropped the grant. Re-asking needs a gesture, so it goes behind a button.
	if s.reopen {
		items = append(items,
			widget.Padding{Insets: geom.InsetsSymmetric(12, 4),
				Child: s.button(th, "Reopen last folder", func() { restoreFolder(ctx.FolderPicker(), ctx.Preferences(), s) })},
			widget.Padding{Insets: geom.InsetsSymmetric(16, 6),
				Child: widget.Text{Value: "Your browser needs permission again to reopen it.",
					Size: th.Type.Caption, Color: th.Muted, Wrap: true}})
	}
	if s.storeErr != "" {
		items = append(items, widget.Padding{Insets: geom.InsetsSymmetric(16, 6),
			Child: widget.Text{Value: s.storeErr, Size: th.Type.Caption, Color: th.Danger, Wrap: true}})
	}
	col := widget.Column(items...)
	col.CrossAlign = layout.CrossStart
	return widget.Decorated{Color: th.Bg, Child: col}
}

func (s *workspaceState) pane(ctx widget.Ctx, th theme.Theme, v *Vault) widget.Widget {
	note, ok := v.Get(s.OpenPath)
	if !ok {
		msg := "Select a note"
		if !v.HasStore() {
			msg = "Open a folder to start"
		}
		return widget.Fill{Color: th.Surface, Child: widget.Center(widget.Text{Value: msg, Color: th.Muted})}
	}

	var action, body widget.Widget
	if s.Editing {
		action = s.button(th, "Save", func() { s.save(v) })
		body = s.editorSplit(ctx, th, v)
	} else {
		edit := s.button(th, "Edit", func() { s.startEdit(note) })
		row := widget.Row(s.deleteControl(th, v), widget.Sized{W: 8}, edit)
		row.CrossAlign = layout.CrossCenter
		action = row
		body = s.reader(ctx, th, v, note)
	}

	bar := widget.Row(
		widget.Expand(widget.Text{Value: note.Name, Font: "bold", Size: th.Type.Heading, Color: th.Text}),
		action,
	)
	bar.CrossAlign = layout.CrossCenter

	content := widget.Column(
		widget.Padding{Insets: geom.InsetsSymmetric(20, 12), Child: bar},
		widget.Sized{H: 1, Child: widget.Decorated{Color: th.Border}},
		widget.Expand(body),
	)
	content.CrossAlign = layout.CrossStretch
	return widget.Decorated{Color: th.Surface, Child: content}
}

// editorSplit is the live-preview editor: a plain-text pane beside the rendered
// markdown, re-rendering as you type (OnChange updates Draft, which rebuilds).
// On a narrow pane it collapses to the editor alone.
func (s *workspaceState) editorSplit(ctx widget.Ctx, th theme.Theme, v *Vault) widget.Widget {
	editor := scrollPad(widget.TextField{
		Value:          s.Draft,
		Multiline:      true,
		Size:           th.Type.Body,
		TextColor:      th.Text,
		CaretColor:     th.Primary,
		SelectionColor: th.Selection,
		OnChange:       func(t string) { s.SetState(func() { s.Draft = t }) },
	})
	return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
		if cs.BoundedW() && cs.Max.W < 560 {
			return editor // too narrow to split
		}
		row := widget.Row(
			widget.Expand(editor),
			widget.Sized{W: 1, Child: widget.Decorated{Color: th.Border}},
			widget.Expand(scrollPad(s.markdown(th, s.Draft, ctx, v))),
		)
		row.CrossAlign = layout.CrossStretch
		return row
	}}
}

// markdown renders src to a left-aligned Column of block widgets.
func (s *workspaceState) markdown(th theme.Theme, src string, ctx widget.Ctx, v *Vault) widget.Widget {
	col := widget.Column(renderMarkdown(src, mdTheme(th), func(url string) { s.onLink(ctx, v, url) })...)
	col.CrossAlign = layout.CrossStart
	return col
}

// reader is the read view: the rendered note followed by its backlinks section.
func (s *workspaceState) reader(ctx widget.Ctx, th theme.Theme, v *Vault, note Note) widget.Widget {
	blocks := renderMarkdown(note.Body, mdTheme(th), func(url string) { s.onLink(ctx, v, url) })
	blocks = append(blocks, s.backlinks(th, v, note)...)
	body := widget.Column(blocks...)
	body.CrossAlign = layout.CrossStart
	return scrollPad(body)
}

// backlinks renders the "Linked references" section — the notes that [[link]]
// to this one — appended beneath the note body. Empty when there are none.
func (s *workspaceState) backlinks(th theme.Theme, v *Vault, note Note) []widget.Widget {
	refs := v.Backlinks(note.Name)
	if len(refs) == 0 {
		return nil
	}
	out := []widget.Widget{
		block(widget.Padding{Insets: geom.Insets{Top: 12, Bottom: 8}, Child: widget.Sized{H: 1, Child: widget.Decorated{Color: th.Border}}}),
		block(widget.Text{Value: "Linked references", Font: "bold", Size: th.Type.Label, Color: th.Muted}),
	}
	for _, n := range refs {
		out = append(out, block(widget.Interactive{
			Gestures: widget.Gestures{OnTap: func() { s.open(n.Path) }},
			Child:    widget.Text{Value: "← " + n.Name, Size: th.Type.Body, Color: th.Primary},
		}))
	}
	return out
}

// outlinePanel lists the note's headings, indented by level — a live document
// map. (Click-to-scroll awaits a public scroll-to-position API in the framework.)
func outlinePanel(th theme.Theme, note Note) widget.Widget {
	items := []widget.Widget{
		widget.Padding{Insets: geom.Insets{Left: 14, Right: 14, Top: 16, Bottom: 8},
			Child: widget.Text{Value: "OUTLINE", Font: "bold", Size: th.Type.Label, Color: th.Muted}},
	}
	hs := extractHeadings(note.Body)
	if len(hs) == 0 {
		items = append(items, widget.Padding{Insets: geom.InsetsSymmetric(14, 4),
			Child: widget.Text{Value: "No headings", Size: th.Type.Label, Color: th.Muted}})
	}
	for _, h := range hs {
		items = append(items, widget.Padding{
			Insets: geom.Insets{Left: 14 + float32(h.Level-1)*12, Right: 12, Top: 3, Bottom: 3},
			Child:  widget.Text{Value: h.Text, Size: th.Type.Label, Color: th.Text, Wrap: true},
		})
	}
	col := widget.Column(items...)
	col.CrossAlign = layout.CrossStart
	return widget.Decorated{Color: th.Bg, Child: widget.Scroll{Child: col}}
}

// scrollPad wraps content in a scroll view with the standard reading padding.
func scrollPad(child widget.Widget) widget.Widget {
	return widget.Scroll{Child: widget.Padding{Insets: geom.InsetsSymmetric(20, 12), Child: child}}
}

func (s *workspaceState) open(path string) {
	s.SetState(func() { s.OpenPath, s.Editing, s.Draft, s.confirmDelete = path, false, "", false })
}

func (s *workspaceState) startEdit(n Note) {
	s.SetState(func() { s.Editing, s.Draft = true, n.Body })
}

func (s *workspaceState) save(v *Vault) {
	_ = v.Save(s.OpenPath, s.Draft)
	s.SetState(func() { s.Editing = false })
}

func (s *workspaceState) onLink(ctx widget.Ctx, v *Vault, url string) {
	if name, ok := strings.CutPrefix(url, "note:"); ok {
		s.followNote(v, name)
		return
	}
	_ = ctx.OpenURL(url)
}

// followNote opens the note a [[wikilink]] names, if it exists.
func (s *workspaceState) followNote(v *Vault, name string) {
	if n, ok := v.ByName(name); ok {
		s.open(n.Path)
	}
}

// createNote creates the note named in the new-note input and opens it in edit
// mode; a blank name just cancels.
func (s *workspaceState) createNote(v *Vault) {
	n, err := v.Create(s.newName)
	s.SetState(func() {
		s.creating, s.newName = false, ""
		if err == nil {
			s.OpenPath, s.Editing, s.Draft, s.confirmDelete = n.Path, true, n.Body, false
		}
	})
}

// deleteNote deletes the open note from disk and clears the pane.
func (s *workspaceState) deleteNote(v *Vault) {
	_ = v.Delete(s.OpenPath)
	s.SetState(func() { s.OpenPath, s.Editing, s.Draft, s.confirmDelete = "", false, "", false })
}

func (s *workspaceState) button(th theme.Theme, label string, onTap func()) widget.Widget {
	return widget.Interactive{
		Gestures: widget.Gestures{OnTap: onTap},
		Child: widget.Decorated{Color: th.Primary, Radius: 7, Child: widget.Padding{
			Insets: geom.InsetsSymmetric(16, 8),
			Child:  widget.Text{Value: label, Size: th.Type.Label, Color: th.OnPrimary},
		}},
	}
}

// deleteControl is a two-click delete: the first click arms it (turning into a
// red "Delete?" with a Cancel), the second removes the note.
func (s *workspaceState) deleteControl(th theme.Theme, v *Vault) widget.Widget {
	if !s.confirmDelete {
		return widget.Interactive{
			Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.confirmDelete = true }) }},
			Child:    widget.Padding{Insets: geom.InsetsSymmetric(10, 8), Child: widget.Text{Value: "Delete", Size: th.Type.Label, Color: th.Muted}},
		}
	}
	cancel := widget.Interactive{
		Gestures: widget.Gestures{OnTap: func() { s.SetState(func() { s.confirmDelete = false }) }},
		Child:    widget.Padding{Insets: geom.InsetsSymmetric(10, 8), Child: widget.Text{Value: "Cancel", Size: th.Type.Label, Color: th.Muted}},
	}
	confirm := widget.Interactive{
		Gestures: widget.Gestures{OnTap: func() { s.deleteNote(v) }},
		Child: widget.Decorated{Color: th.Danger, Radius: 7, Child: widget.Padding{
			Insets: geom.InsetsSymmetric(14, 8),
			Child:  widget.Text{Value: "Delete?", Size: th.Type.Label, Color: th.OnPrimary},
		}},
	}
	r := widget.Row(cancel, widget.Sized{W: 4}, confirm)
	r.CrossAlign = layout.CrossCenter
	return r
}

ui/config.go

package ui

import (
	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goitalic"
	"golang.org/x/image/font/gofont/gomono"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
)

// Config returns the app's window/runtime configuration, including the named
// font families the markdown renderer uses (bold/italic/mono).
func Config() app.Config {
	return app.Config{
		Title:      "gophics · notes",
		Size:       geom.Size{W: 900, H: 640},
		Background: BG,
		Font:       goregular.TTF,
		FontFamilies: map[string][]byte{
			"bold":   gobold.TTF,
			"italic": goitalic.TTF,
			"mono":   gomono.TTF,
		},
	}
}

ui/folderstore.go

package ui

import (
	"errors"
	"strings"

	"github.com/doug/gophics/shell"
)

// prefFolderToken is where the open folder's token is remembered between
// sessions. The token itself is opaque and platform-shaped — a path on desktop,
// a handle key in the browser — so this only ever stores and returns it.
const prefFolderToken = "notes.folder"

// folderStore persists notes in a shell.Folder — a directory the user picked.
//
// This file used to be 142 lines of syscall/js driving the File System Access
// API, including a helper that blocked a goroutine on a JS promise and a
// comment naming the one goroutine that must never call it. That is the
// FolderPicker capability now, and what is left here is the part that is
// actually about notes: .md files, and what a Note's identity is.
//
// It has no build tag. The capability is nil where the platform cannot offer a
// folder, which is a runtime answer, so an app asks rather than compiling two
// versions of itself.
type folderStore struct {
	f     shell.Folder
	onErr func(error)
}

func newFolderStore(f shell.Folder, onErr func(error)) *folderStore {
	return &folderStore{f: f, onErr: onErr}
}

func (s *folderStore) Label() string { return s.f.Name() }

// Write saves the note and reports it as written before the bytes have landed.
//
// The vault is the in-memory model and the file is the write-through, so the
// editor cannot wait for a round trip on every keystroke pause — and on web
// there is no way to wait that does not block the frame. A failure therefore
// cannot come back through the return value; it arrives later through onErr,
// which is strictly more than the code this replaces did. That one awaited the
// real error and handed it to a caller that wrote `_ =`.
func (s *folderStore) Write(name, body string) (Note, error) {
	file := name + ".md"
	s.f.Write(file, []byte(body), s.report)
	return Note{Path: file, Name: name, Body: body}, nil
}

func (s *folderStore) Remove(n Note) error {
	s.f.Remove(n.Path, s.report)
	return nil
}

func (s *folderStore) report(err error) {
	if err != nil && s.onErr != nil {
		s.onErr(err)
	}
}

// openFolder asks the user for a folder and loads it into the vault.
//
// The picker must be reached from a tap, which is why this hangs off the
// button's handler rather than running at startup: a browser opens a directory
// chooser only during a user gesture. The capability spends that gesture
// synchronously inside Open, so nothing here has to be careful about it.
func openFolder(picker shell.FolderPicker, prefs shell.Preferences, s *workspaceState) {
	if picker == nil {
		s.SetState(func() {
			s.storeErr = "This browser can't open a folder — try Chrome or Edge."
		})
		return
	}
	picker.Open(func(f shell.Folder, err error) {
		switch {
		case err != nil:
			s.SetState(func() { s.storeErr = "Could not open that folder." })
		case f == nil:
			// The user dismissed the picker, which is not an error and should
			// not leave a message behind.
		default:
			loadFolder(s, f)
			remember(prefs, f)
		}
	})
}

// restoreFolder reopens the folder from the last session, if there is one.
//
// Called at mount, and again from the reopen button. Both matter: the first
// covers the case where the browser still has the grant, and the second is the
// only way to get it back when it has lapsed, because re-asking requires a user
// gesture and mounting is not one.
func restoreFolder(picker shell.FolderPicker, prefs shell.Preferences, s *workspaceState) {
	if picker == nil || prefs == nil {
		return
	}
	token, ok := prefs.Get(prefFolderToken)
	if !ok || token == "" {
		return
	}
	picker.Restore(token, func(f shell.Folder, err error) {
		switch {
		case errors.Is(err, shell.ErrFolderPermission):
			// Still there, still ours, just not granted right now.
			s.SetState(func() { s.reopen = true })
		case err != nil:
			s.SetState(func() { s.storeErr = "Could not reopen the last folder." })
		case f == nil:
			// Moved, deleted, or an unplugged drive. Forget it rather than
			// offering to reopen something that is not there.
			_ = prefs.Delete(prefFolderToken)
			s.SetState(func() { s.reopen = false })
		default:
			s.SetState(func() { s.reopen = false })
			loadFolder(s, f)
		}
	})
}

// remember stores the folder's token so the next session can reopen it.
// Failing to remember is not worth telling the user about: the folder is open
// and working, and the cost is being asked again next time.
func remember(prefs shell.Preferences, f shell.Folder) {
	if prefs == nil || f == nil {
		return
	}
	if token := f.Token(); token != "" {
		_ = prefs.Set(prefFolderToken, token)
	}
}

// loadFolder reads every .md file in f and adopts it as the vault.
//
// The reads are sequential rather than fanned out: each is a round trip to the
// browser's file system, and issuing hundreds at once is how a folder of notes
// becomes a stalled tab. A file that fails to read is skipped rather than
// failing the whole vault — one unreadable note should not cost the user the
// other forty.
func loadFolder(s *workspaceState, f shell.Folder) {
	f.List(shell.FolderListOptions{Accept: []string{".md"}}, func(entries []shell.FolderEntry, err error) {
		if err != nil {
			s.SetState(func() { s.storeErr = "Could not read that folder." })
			return
		}
		notes := make([]Note, 0, len(entries))
		var read func(int)
		read = func(i int) {
			if i == len(entries) {
				store := newFolderStore(f, func(error) {
					s.SetState(func() { s.storeErr = "Could not save to that folder." })
				})
				s.SetState(func() {
					s.storeErr = ""
					s.W().Vault.adopt(store, notes)
				})
				return
			}
			name := entries[i].Name
			f.Read(name, func(b []byte, err error) {
				if err == nil {
					notes = append(notes, Note{Path: name, Name: noteName(name), Body: string(b)})
				}
				read(i + 1)
			})
		}
		read(0)
	})
}

// noteName is a file name without its .md extension — the display name and the
// [[wikilink]] target. TrimSuffix on the lowered name would return the lowered
// name, so the length is what gets trimmed.
func noteName(file string) string {
	if strings.HasSuffix(strings.ToLower(file), ".md") {
		return file[:len(file)-len(".md")]
	}
	return file
}

ui/localvault.go

package ui

import (
	"os"
	"path/filepath"
	"strings"
)

// osStore persists notes as .md files in a local directory — the desktop and
// terminal backing, and what LoadVault opens.
type osStore struct{ dir string }

func (s *osStore) Write(name, body string) (Note, error) {
	path := filepath.Join(s.dir, name+".md")
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		return Note{}, err
	}
	return Note{Path: path, Name: name, Body: body}, nil
}

func (s *osStore) Remove(n Note) error { return os.Remove(n.Path) }

func (s *osStore) Label() string { return s.dir }

// readNotes loads every .md file in dir.
func readNotes(dir string) ([]Note, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var notes []Note
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
			continue
		}
		p := filepath.Join(dir, e.Name())
		data, err := os.ReadFile(p)
		if err != nil {
			continue
		}
		notes = append(notes, Note{Path: p, Name: noteName(e.Name()), Body: string(data)})
	}
	return notes, nil
}

// LoadVault opens the vault at dir — used by tests and by callers that already
// know the folder.
func LoadVault(dir string) (*Vault, error) {
	notes, err := readNotes(dir)
	if err != nil {
		return newVault(nil, nil), err
	}
	return newVault(&osStore{dir: dir}, notes), nil
}

// defaultVault is the vault the app starts with.
//
// There is no build tag here, and that is the point: the question "is there a
// local folder of notes?" has a runtime answer on every platform. Desktop finds
// one and opens it. A browser has no local filesystem, so ReadDir fails and the
// vault starts empty — which is exactly the state the sidebar's open-folder
// prompt exists for. Compiling two versions of the app to reach the same two
// outcomes was the more complicated way to be less honest about it.
func defaultVault() *Vault {
	v, err := LoadVault(vaultDir())
	if err != nil {
		return newVault(nil, nil)
	}
	return v
}

func vaultDir() string {
	if d := os.Getenv("NOTES_DIR"); d != "" {
		return d
	}
	if _, err := os.Stat("examples/notes/vault"); err == nil {
		return "examples/notes/vault"
	}
	return "vault"
}

ui/markdown.go

package ui

import (
	"strings"

	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/widget"
)

// A small CommonMark subset renderer: markdown text → a list of block widgets
// (headings, paragraphs, fenced code, bullet lists), with inline bold/italic/
// code/links/[[wikilinks]]. Hand-rolled in the spirit of HN's parseSpans — no
// markdown dependency. Good enough to read notes; not a spec-complete parser.

type mdStyle struct {
	Text    paint.Color
	Heading paint.Color
	Code    paint.Color
	CodeBG  paint.Color
	Link    paint.Color
	Meta    paint.Color
	Size    float32 // base body size
}

// seg is the base style inline spans inherit (bold/italic/code override it).
type seg struct {
	font  string
	color paint.Color
}

// renderMarkdown turns src into block widgets. onLink is invoked when a link or
// [[wikilink]] span is tapped (url is the raw href, or "note:Name" for a wikilink).
func renderMarkdown(src string, sty mdStyle, onLink func(url string)) []widget.Widget {
	lines := strings.Split(src, "\n")
	var blocks []widget.Widget
	i := 0
	for i < len(lines) {
		t := strings.TrimSpace(lines[i])
		switch {
		case t == "":
			i++
		case strings.HasPrefix(t, "```"):
			i++
			var code []string
			for i < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[i]), "```") {
				code = append(code, lines[i])
				i++
			}
			if i < len(lines) {
				i++ // consume closing fence
			}
			blocks = append(blocks, codeBlock(strings.Join(code, "\n"), sty))
		case headingLevel(t) > 0:
			lvl := headingLevel(t)
			blocks = append(blocks, heading(strings.TrimSpace(t[lvl:]), lvl, sty, onLink))
			i++
		case isBullet(t):
			var items []widget.Widget
			for i < len(lines) && isBullet(strings.TrimSpace(lines[i])) {
				items = append(items, bulletItem(strings.TrimSpace(lines[i])[2:], sty, onLink))
				i++
			}
			blocks = append(blocks, block(widget.Column(items...)))
		case isIndentedCode(lines[i]):
			// A run of lines indented by a tab or 4+ spaces is a code block.
			var code []string
			for i < len(lines) && (isIndentedCode(lines[i]) || strings.TrimSpace(lines[i]) == "") {
				code = append(code, dedentCode(lines[i]))
				i++
			}
			blocks = append(blocks, codeBlock(strings.TrimRight(strings.Join(code, "\n"), "\n"), sty))
		default:
			var para []string
			for i < len(lines) {
				lt := strings.TrimSpace(lines[i])
				if lt == "" || headingLevel(lt) > 0 || isBullet(lt) || strings.HasPrefix(lt, "```") {
					break
				}
				para = append(para, lt)
				i++
			}
			blocks = append(blocks, paragraph(strings.Join(para, " "), sty, onLink))
		}
	}
	return blocks
}

// headingItem is one entry in a note's outline.
type headingItem struct {
	Level int
	Text  string
}

// extractHeadings returns the note's ATX headings in order, skipping fenced
// code blocks (so a "# comment" inside code is not mistaken for a heading).
func extractHeadings(src string) []headingItem {
	var out []headingItem
	inFence := false
	for line := range strings.SplitSeq(src, "\n") {
		t := strings.TrimSpace(line)
		if strings.HasPrefix(t, "```") {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if lvl := headingLevel(t); lvl > 0 {
			out = append(out, headingItem{Level: lvl, Text: strings.TrimSpace(t[lvl:])})
		}
	}
	return out
}

// wikilinkTargets returns the names referenced by [[wikilinks]] in src.
func wikilinkTargets(src string) []string {
	var out []string
	for i := 0; ; {
		j := strings.Index(src[i:], "[[")
		if j < 0 {
			break
		}
		start := i + j + 2
		end := strings.Index(src[start:], "]]")
		if end < 0 {
			break
		}
		out = append(out, strings.TrimSpace(src[start:start+end]))
		i = start + end + 2
	}
	return out
}

func headingLevel(t string) int {
	n := 0
	for n < len(t) && t[n] == '#' {
		n++
	}
	if n >= 1 && n <= 6 && n < len(t) && t[n] == ' ' {
		return n
	}
	return 0
}

func isBullet(t string) bool {
	return len(t) >= 2 && (t[0] == '-' || t[0] == '*' || t[0] == '+') && t[1] == ' '
}

// isIndentedCode reports whether a raw line is an indented code line: a leading
// tab or 4+ spaces, with actual content.
func isIndentedCode(line string) bool {
	if strings.TrimSpace(line) == "" {
		return false
	}
	return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "    ")
}

// dedentCode strips one level of code indentation (a tab or four spaces).
func dedentCode(line string) string {
	switch {
	case strings.HasPrefix(line, "\t"):
		return line[1:]
	case strings.HasPrefix(line, "    "):
		return line[4:]
	}
	return line
}

// block adds the standard gap beneath a rendered block.
func block(w widget.Widget) widget.Widget {
	return widget.Padding{Insets: geom.Insets{Bottom: 10}, Child: w}
}

func heading(text string, lvl int, sty mdStyle, onLink func(string)) widget.Widget {
	size := []float32{30, 24, 19, 16, 15, 14}[lvl-1]
	return block(widget.Rich{
		Spans:  inlineSpans(text, seg{font: "bold", color: sty.Heading}, sty),
		Size:   size,
		OnLink: onLink,
	})
}

func paragraph(text string, sty mdStyle, onLink func(string)) widget.Widget {
	return block(widget.Rich{
		Spans:  inlineSpans(text, seg{color: sty.Text}, sty),
		Size:   sty.Size,
		OnLink: onLink,
	})
}

func codeBlock(code string, sty mdStyle) widget.Widget {
	return block(widget.Decorated{Color: sty.CodeBG, Radius: 6, Child: widget.Padding{
		Insets: geom.InsetsSymmetric(12, 10),
		Child:  widget.Rich{Spans: []layout.RichSpan{{Text: code, Font: "mono", Color: sty.Code}}, Size: sty.Size},
	}})
}

func bulletItem(text string, sty mdStyle, onLink func(string)) widget.Widget {
	row := widget.Row(
		widget.Sized{W: 18, Child: widget.Text{Value: "•", Size: sty.Size, Color: sty.Meta}},
		widget.Expand(widget.Rich{Spans: inlineSpans(text, seg{color: sty.Text}, sty), Size: sty.Size, OnLink: onLink}),
	)
	row.CrossAlign = layout.CrossStart
	return block(row)
}

// inlineSpans scans text for inline markers, producing styled spans. base is the
// inherited style; bold/italic recurse with an overridden font.
func inlineSpans(text string, base seg, sty mdStyle) []layout.RichSpan {
	var out []layout.RichSpan
	var buf strings.Builder
	flush := func() {
		if buf.Len() > 0 {
			out = append(out, layout.RichSpan{Text: buf.String(), Font: base.font, Color: base.color})
			buf.Reset()
		}
	}
	i := 0
	for i < len(text) {
		rest := text[i:]
		switch {
		case strings.HasPrefix(rest, "[["):
			if end := strings.Index(rest[2:], "]]"); end >= 0 {
				name := rest[2 : 2+end]
				flush()
				out = append(out, layout.RichSpan{Text: name, Font: base.font, Color: sty.Link, Underline: true, Link: "note:" + name})
				i += 2 + end + 2
				continue
			}
		case rest[0] == '[':
			if label, url, n, ok := parseLink(rest); ok {
				flush()
				out = append(out, layout.RichSpan{Text: label, Font: base.font, Color: sty.Link, Underline: true, Link: url})
				i += n
				continue
			}
		case rest[0] == '`':
			if end := strings.IndexByte(rest[1:], '`'); end >= 0 {
				flush()
				out = append(out, layout.RichSpan{Text: rest[1 : 1+end], Font: "mono", Color: sty.Code})
				i += 1 + end + 1
				continue
			}
		case strings.HasPrefix(rest, "**"), strings.HasPrefix(rest, "__"):
			d := rest[:2]
			if inner := closeDelim(rest, d); inner > 0 {
				flush()
				out = append(out, inlineSpans(rest[2:inner], seg{font: "bold", color: base.color}, sty)...)
				i += inner + 2
				continue
			}
		case rest[0] == '*', rest[0] == '_':
			d := rest[:1]
			if inner := closeDelim(rest, d); inner > 0 {
				flush()
				out = append(out, inlineSpans(rest[1:inner], seg{font: "italic", color: base.color}, sty)...)
				i += inner + 1
				continue
			}
		}
		buf.WriteByte(text[i])
		i++
	}
	flush()
	return out
}

// closeDelim returns the index in s where the closing delimiter d begins, given
// s starts with d, or 0 if there is no non-empty closing match.
func closeDelim(s, d string) int {
	if idx := strings.Index(s[len(d):], d); idx > 0 {
		return len(d) + idx
	}
	return 0
}

// parseLink parses a leading "[label](url)"; n is the bytes consumed.
func parseLink(s string) (label, url string, n int, ok bool) {
	if !strings.HasPrefix(s, "[") {
		return
	}
	close := strings.IndexByte(s, ']')
	if close < 0 || close+1 >= len(s) || s[close+1] != '(' {
		return
	}
	end := strings.IndexByte(s[close+2:], ')')
	if end < 0 {
		return
	}
	return s[1:close], s[close+2 : close+2+end], close + 2 + end + 1, true
}

ui/vault.go

package ui

import (
	"errors"
	"sort"
	"strings"
)

// Note is one markdown file in the vault.
type Note struct {
	Path string // stable identity: the file path on desktop, the note name on web
	Name string // base name without .md (display + [[wikilink]] target)
	Body string // file contents
}

// store persists a vault's notes. A local directory backs it on desktop
// (store_os.go); a folder the user picked backs it anywhere the FolderPicker
// capability exists (folderstore.go). Everything else about a Vault is pure
// in-memory logic that works identically on every platform.
//
// Loading is not part of this. Both backings already produce their notes while
// opening — one reads a directory, the other is handed a folder — so a List
// method here would have been a second way to do it that only one caller used.
type store interface {
	Write(name, body string) (Note, error) // create or overwrite; returns the note with its Path
	Remove(n Note) error                   // delete the note's file
	Label() string                         // folder path/name, for display
}

// Vault is a folder of .md notes — the app's whole data model, held in memory
// and written back through its store. Deliberately plain data.
type Vault struct {
	store store
	Notes []Note
}

// newVault holds notes that have already been loaded. A nil store yields an
// empty vault — the starting state before the user opens a folder.
func newVault(s store, notes []Note) *Vault {
	v := &Vault{store: s, Notes: notes}
	sortNotes(v.Notes)
	return v
}

// HasStore reports whether a backing folder is open — always true on desktop,
// false on web until the user opens one.
func (v *Vault) HasStore() bool { return v.store != nil }

// Label is the open folder's path or name, for display.
func (v *Vault) Label() string {
	if v.store == nil {
		return ""
	}
	return v.store.Label()
}

// adopt swaps in a freshly opened store and its notes (the web open-folder flow).
func (v *Vault) adopt(s store, notes []Note) {
	v.store = s
	v.Notes = notes
	sortNotes(v.Notes)
}

func sortNotes(ns []Note) {
	sort.Slice(ns, func(i, j int) bool { return ns[i].Name < ns[j].Name })
}

// Save writes body to the note identified by path and updates the in-memory copy.
func (v *Vault) Save(path, body string) error {
	if v.store == nil {
		return errors.New("no folder open")
	}
	n, ok := v.Get(path)
	if !ok {
		return errors.New("note not found")
	}
	if _, err := v.store.Write(n.Name, body); err != nil {
		return err
	}
	for i := range v.Notes {
		if v.Notes[i].Path == path {
			v.Notes[i].Body = body
			break
		}
	}
	return nil
}

// Create adds a new note named name (a "# name" stub), writes it, and returns
// it. An existing note by that name is returned unchanged. Returns an error for
// an empty or unsafe name, or when no folder is open.
func (v *Vault) Create(name string) (Note, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return Note{}, errors.New("note name is empty")
	}
	if strings.ContainsAny(name, `/\`) {
		return Note{}, errors.New("note name cannot contain path separators")
	}
	if n, ok := v.ByName(name); ok {
		return n, nil
	}
	if v.store == nil {
		return Note{}, errors.New("no folder open")
	}
	n, err := v.store.Write(name, "# "+name+"\n\n")
	if err != nil {
		return Note{}, err
	}
	v.Notes = append(v.Notes, n)
	sortNotes(v.Notes)
	return n, nil
}

// Delete removes the note identified by path from its folder and the vault.
func (v *Vault) Delete(path string) error {
	if v.store == nil {
		return errors.New("no folder open")
	}
	n, ok := v.Get(path)
	if !ok {
		return errors.New("note not found")
	}
	if err := v.store.Remove(n); err != nil {
		return err
	}
	for i := range v.Notes {
		if v.Notes[i].Path == path {
			v.Notes = append(v.Notes[:i], v.Notes[i+1:]...)
			break
		}
	}
	return nil
}

// Get returns the note with the given identity path.
func (v *Vault) Get(path string) (Note, bool) {
	for _, n := range v.Notes {
		if n.Path == path {
			return n, true
		}
	}
	return Note{}, false
}

// ByName returns the note whose display name matches (case-insensitive) — the
// [[wikilink]] resolver.
func (v *Vault) ByName(name string) (Note, bool) {
	for _, n := range v.Notes {
		if strings.EqualFold(n.Name, name) {
			return n, true
		}
	}
	return Note{}, false
}

// Search returns the notes matching query (case-insensitive substring in name
// or body); an empty query returns every note. Order is preserved.
func (v *Vault) Search(query string) []Note {
	q := strings.TrimSpace(strings.ToLower(query))
	if q == "" {
		return v.Notes
	}
	var out []Note
	for _, n := range v.Notes {
		if strings.Contains(strings.ToLower(n.Name), q) || strings.Contains(strings.ToLower(n.Body), q) {
			out = append(out, n)
		}
	}
	return out
}

// Backlinks returns the notes that link to name via a [[wikilink]].
func (v *Vault) Backlinks(name string) []Note {
	var out []Note
	for _, n := range v.Notes {
		if strings.EqualFold(n.Name, name) {
			continue
		}
		for _, tgt := range wikilinkTargets(n.Body) {
			if strings.EqualFold(tgt, name) {
				out = append(out, n)
				break
			}
		}
	}
	return out
}