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
}