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 error (open, or a late write on web), shown in the sidebar
paneErr string // last failed save/delete/create, shown in the pane until the next succeeds
// 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 }) },
}})
// A folder error belongs here whether or not a folder is open: on web a
// failed write reports late, through the store, long after the prompt
// that used to be the only place this was rendered has gone.
if s.storeErr != "" {
items = append(items, errorLine(th, s.storeErr))
}
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, errorLine(th, s.storeErr))
}
col := widget.Column(items...)
col.CrossAlign = layout.CrossStart
return widget.Decorated{Color: th.Bg, Child: col}
}
// errorLine is a wrapped caption in the danger colour, for a failure the user
// has to act on.
func errorLine(th theme.Theme, msg string) widget.Widget {
return widget.Padding{Insets: geom.InsetsSymmetric(16, 6),
Child: widget.Text{Value: msg, Size: th.Type.Caption, Color: th.Danger, Wrap: true}}
}
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"
}
empty := widget.Column(widget.Text{Value: msg, Color: th.Muted})
if s.paneErr != "" {
empty.Children = append(empty.Children, errorLine(th, s.paneErr))
}
empty.CrossAlign = layout.CrossCenter
return widget.Fill{Color: th.Surface, Child: widget.Center(empty)}
}
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}},
)
if s.paneErr != "" {
// Under the title, where the Save button that failed is. A failed save
// that says nothing looks exactly like one that worked.
content.Children = append(content.Children,
widget.Decorated{Color: th.Bg, Child: errorLine(th, s.paneErr)},
widget.Sized{H: 1, Child: widget.Decorated{Color: th.Border}})
}
content.Children = append(content.Children, 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}}
}
// open shows the note at path in read mode, saving the current draft first.
//
// Every route here is a single click — a sidebar row, a backlink, and a
// [[wikilink]] in the editor's own live preview — so leaving the buffer
// behind on navigation meant one mis-click lost the edits. It saves rather
// than asks because the file already is the note (the vault is a write-through
// of the folder, and the editor a view of it), so a draft has nowhere else to
// be; and because on web a write reports success before the bytes land, which
// leaves a confirm dialog nothing truthful to ask. A save that fails refuses
// the navigation and shows why, so the draft stays on screen.
func (s *workspaceState) open(path string) {
if !s.flushDraft() {
return
}
s.SetState(func() {
s.OpenPath, s.Editing, s.Draft, s.confirmDelete, s.paneErr = path, false, "", false, ""
})
}
// flushDraft saves an edited draft and reports whether the pane may move on.
// A draft identical to the saved body is nothing to save; a failed save is
// shown and keeps the editor where it is.
func (s *workspaceState) flushDraft() bool {
if !s.Editing {
return true
}
v := s.W().Vault
n, ok := v.Get(s.OpenPath)
if !ok || s.Draft == n.Body {
return true
}
if err := v.Save(s.OpenPath, s.Draft); err != nil {
s.SetState(func() { s.paneErr = "Could not save " + n.Name + ": " + err.Error() })
return false
}
return true
}
func (s *workspaceState) startEdit(n Note) {
s.SetState(func() { s.Editing, s.Draft = true, n.Body })
}
// save writes the draft. On failure edit mode stays open: leaving it would
// seed the next Edit from the body still on disk, and the draft — the only
// copy of the edits — would be gone with nothing having said so.
func (s *workspaceState) save(v *Vault) {
if err := v.Save(s.OpenPath, s.Draft); err != nil {
s.SetState(func() { s.paneErr = "Could not save: " + err.Error() })
return
}
s.SetState(func() {
// A folder store may have reported the write's failure before Save
// returned, through storeResult, which has then already put the
// vault and the editor back; closing the editor here would undo
// that. Edit mode ends once the vault holds the draft.
if n, ok := v.Get(s.OpenPath); ok && n.Body == s.Draft {
s.Editing, s.paneErr = false, ""
}
})
}
// storeResult hears from the folder store once a write or removal has
// actually landed — on web, after the call that asked for it returned
// success and the app acted on that. A failure has to undo what the success
// was taken to mean. The vault's body goes back to what the file holds, and
// if the note is the one on screen the editor comes back with the failed
// body as its draft: the state a synchronous failure leaves on desktop, and
// the only place the edits still exist. A note the user has moved on from
// can only be reported, so the sidebar names it. A success clears the
// sidebar, so what it says is about the last write, not the worst one.
func (s *workspaceState) storeResult(r lateResult) {
v := s.W().Vault
s.SetState(func() {
switch {
case r.Err == nil:
s.storeErr = ""
case r.Removed:
v.restore(r.Note)
s.storeErr = "Could not delete " + r.Note.Name + ": " + r.Err.Error()
default:
v.revert(r.Note, r.Body)
msg := "Could not save " + r.Note.Name + ": " + r.Err.Error()
if s.OpenPath != r.Note.Path {
s.storeErr = msg
return
}
if !s.Editing {
s.Editing, s.Draft = true, r.Body
}
s.paneErr = msg
}
})
}
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. Opening the new note is a navigation, so
// the draft of the note being left is saved first, exactly as open does.
func (s *workspaceState) createNote(v *Vault) {
if strings.TrimSpace(s.newName) == "" {
s.SetState(func() { s.creating, s.newName = false, "" })
return
}
if !s.flushDraft() {
return
}
n, err := v.Create(s.newName)
if err != nil {
// The name stays in the input. The message says why it did not
// work; typing it again is not part of fixing that.
s.SetState(func() { s.paneErr = "Could not create note: " + err.Error() })
return
}
s.SetState(func() {
s.creating, s.newName = false, ""
s.OpenPath, s.Editing, s.Draft, s.confirmDelete, s.paneErr = n.Path, true, n.Body, false, ""
})
}
// deleteNote deletes the open note from disk and clears the pane. If the file
// will not go, the note stays — a pane cleared over a file still on disk would
// show the note as gone until the next launch brought it back.
func (s *workspaceState) deleteNote(v *Vault) {
if err := v.Delete(s.OpenPath); err != nil {
s.SetState(func() { s.confirmDelete, s.paneErr = false, "Could not delete: "+err.Error() })
return
}
s.SetState(func() { s.OpenPath, s.Editing, s.Draft, s.confirmDelete, s.paneErr = "", 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"
"github.com/doug/gophics/theme"
)
// 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,
// The tree's theme follows the platform scheme per frame, so the
// window ground has to as well, or a dark first frame and every
// resize gap paint light behind dark-theme text.
BackgroundDark: theme.Dark().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
// done hears the outcome of every write and removal once it is actually
// known, which is after the call that asked for it has returned. Success
// is reported too: it is what lets a message about the last failure be
// cleared by the next write that lands, instead of standing for the
// session.
done func(lateResult)
}
// lateResult is the outcome of a folderStore write or removal.
type lateResult struct {
Note Note // as it was when the call was made — for a write, with the body still on disk
Body string // what a write wrote; unused for a removal
Removed bool // a removal rather than a write
Err error // nil when it landed
}
func newFolderStore(f shell.Folder, done func(lateResult)) *folderStore {
return &folderStore{f: f, done: done}
}
func (s *folderStore) Label() string { return s.f.Name() }
// Create writes a new 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 done,
// 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) Create(name, body string) (Note, error) {
file := name + ".md"
// The one failure known before asking is a name the folder refuses. The
// capability would report it through done, inline, before the vault had
// even added the note; the synchronous error path exists, so it goes
// there, and the vault never holds a note the folder could not have.
if err := shell.CheckFolderName(file); err != nil {
return Note{}, err
}
n := Note{Path: file, Name: name, Body: body}
s.f.Write(file, []byte(body), func(err error) { s.report(lateResult{Note: n, Body: body, Err: err}) })
return n, nil
}
// Write overwrites an existing note's file, with the same late reporting as
// Create.
func (s *folderStore) Write(n Note, body string) error {
s.f.Write(n.Path, []byte(body), func(err error) { s.report(lateResult{Note: n, Body: body, Err: err}) })
return nil
}
func (s *folderStore) Remove(n Note) error {
s.f.Remove(n.Path, func(err error) { s.report(lateResult{Note: n, Removed: true, Err: err}) })
return nil
}
func (s *folderStore) report(r lateResult) {
if s.done != nil {
s.done(r)
}
}
// 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, s.storeResult)
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) Create(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) Write(n Note, body string) error {
return os.WriteFile(n.Path, []byte(body), 0o644)
}
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
// (localvault.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.
//
// Create and Write are separate because only a new note gets to choose its
// file name. An existing note is written to its own Path: loading accepts any
// case of ".md", so rebuilding the name as name+".md" would send "Foo.MD" to
// "Foo.md" — a second file on a case-sensitive disk, and two "Foo" notes at the
// next launch.
type store interface {
Create(name, body string) (Note, error) // write a new note named name; returns it with its Path
Write(n Note, body string) error // overwrite the note's own file
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")
}
// The body is taken before the write, not after. A folder store reports
// the write's outcome through its own callback, and nothing says that
// arrives after Write returns; revert looks for the body it is undoing,
// so it has to be in place by the time the store can speak.
v.setBody(path, body)
if err := v.store.Write(n, body); err != nil {
v.setBody(path, n.Body)
return err
}
return nil
}
func (v *Vault) setBody(path, body string) {
for i := range v.Notes {
if v.Notes[i].Path == path {
v.Notes[i].Body = body
return
}
}
}
// revert undoes a save whose write failed after Save had returned success
// (the web store reports late). The body goes back to n.Body — what the file
// still holds, as the note was when the write was asked for. A body that a
// later save has since replaced is left alone: that save's own result speaks
// for it. Reports whether anything was reverted.
func (v *Vault) revert(n Note, failed string) bool {
for i := range v.Notes {
if v.Notes[i].Path != n.Path {
continue
}
if v.Notes[i].Body != failed {
return false
}
v.Notes[i].Body = n.Body
return true
}
return false
}
// restore puts back a note whose removal failed after Delete had returned
// success. The file is still there, so the vault lists it again now rather
// than leaving that to the next launch.
func (v *Vault) restore(n Note) {
if _, ok := v.Get(n.Path); ok {
return
}
v.Notes = append(v.Notes, n)
sortNotes(v.Notes)
}
// 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.Create(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
}