4517b2e37c
CI / test (pull_request) Successful in 2m25s
Link picker now shows both outgoing [[links]] and backlinks in a unified list with section headers. Backlinks follow by entity ID directly, outgoing links resolve by text. Navigating into a backlink works the same as following an outgoing link — pushes to nav stack, esc pops back.
369 lines
7.9 KiB
Go
369 lines
7.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/atotto/clipboard"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/lerko/nib/internal/carddata"
|
|
"github.com/lerko/nib/internal/db"
|
|
)
|
|
|
|
type entitiesLoadedMsg struct {
|
|
entities []*db.Entity
|
|
}
|
|
|
|
type entityCreatedMsg struct {
|
|
entity *db.Entity
|
|
}
|
|
|
|
type entityDeletedMsg struct {
|
|
id string
|
|
}
|
|
|
|
type entityUpdatedMsg struct {
|
|
entity *db.Entity
|
|
action string
|
|
}
|
|
|
|
type entityPromotedMsg struct {
|
|
id string
|
|
cardType db.CardType
|
|
}
|
|
|
|
type entityDemotedMsg struct {
|
|
id string
|
|
}
|
|
|
|
type entityCopiedMsg struct{}
|
|
|
|
type entityAbsorbedMsg struct {
|
|
targetID string
|
|
}
|
|
|
|
type absorbSourcesLoadedMsg struct {
|
|
targetID string
|
|
entities []*db.Entity
|
|
}
|
|
|
|
type stepsPersistedMsg struct{}
|
|
|
|
type templateCopiedMsg struct{}
|
|
|
|
type backlinksLoadedMsg struct {
|
|
backlinks []db.Backlink
|
|
}
|
|
|
|
type linkFollowedMsg struct {
|
|
entity *db.Entity
|
|
}
|
|
|
|
type tagsLoadedMsg struct {
|
|
tags []db.TagCount
|
|
}
|
|
|
|
type railTagsLoadedMsg struct {
|
|
tags []db.TagCount
|
|
}
|
|
|
|
type staleEntitiesLoadedMsg struct {
|
|
entities []*db.Entity
|
|
}
|
|
|
|
type stumbleActionMsg struct {
|
|
action string
|
|
}
|
|
|
|
type statusClearMsg struct{ seq int }
|
|
|
|
type editorFinishedMsg struct {
|
|
err error
|
|
}
|
|
|
|
type errMsg struct {
|
|
err error
|
|
}
|
|
|
|
func loadEntities(store *db.Store, params db.ListParams) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entities, err := store.List(context.Background(), params)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entitiesLoadedMsg{entities}
|
|
}
|
|
}
|
|
|
|
func createEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := store.Create(context.Background(), e); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityCreatedMsg{e}
|
|
}
|
|
}
|
|
|
|
func deleteEntity(store *db.Store, id string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityDeletedMsg{id}
|
|
}
|
|
}
|
|
|
|
func toggleTodo(store *db.Store, e *db.Entity) tea.Cmd {
|
|
return func() tea.Msg {
|
|
var update db.EntityUpdate
|
|
if e.CompletedAt == nil {
|
|
now := time.Now().UTC()
|
|
update = db.EntityUpdate{CompletedAt: &now}
|
|
} else {
|
|
update = db.EntityUpdate{ClearCompleted: true}
|
|
}
|
|
|
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
updated, err := store.Get(context.Background(), e.ID)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
action := "completed"
|
|
if e.CompletedAt != nil {
|
|
action = "reopened"
|
|
}
|
|
return entityUpdatedMsg{updated, action}
|
|
}
|
|
}
|
|
|
|
func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
|
return func() tea.Msg {
|
|
newPinned := !e.Pinned
|
|
update := db.EntityUpdate{Pinned: &newPinned}
|
|
if err := store.Update(context.Background(), e.ID, &update); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
updated, err := store.Get(context.Background(), e.ID)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
action := "pinned"
|
|
if !newPinned {
|
|
action = "unpinned"
|
|
}
|
|
return entityUpdatedMsg{updated, action}
|
|
}
|
|
}
|
|
|
|
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
cd := carddata.GenerateCardData(ct, body)
|
|
if err := store.Promote(context.Background(), id, ct, cd); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityPromotedMsg{id, ct}
|
|
}
|
|
}
|
|
|
|
func demoteEntity(store *db.Store, id string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := store.Demote(context.Background(), id); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityDemotedMsg{id}
|
|
}
|
|
}
|
|
|
|
func copyToClipboard(store *db.Store, e *db.Entity) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := clipboard.WriteAll(e.Body); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
if err := store.IncrementUse(context.Background(), e.ID); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityCopiedMsg{}
|
|
}
|
|
}
|
|
|
|
func loadTags(store *db.Store) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tags, err := store.ListTags(context.Background(), false)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return tagsLoadedMsg{tags}
|
|
}
|
|
}
|
|
|
|
func loadBacklinks(store *db.Store, entityID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
backlinks, err := store.LoadBacklinks(context.Background(), entityID)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return backlinksLoadedMsg{backlinks}
|
|
}
|
|
}
|
|
|
|
func followLink(store *db.Store, linkText string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entity, err := store.ResolveLink(context.Background(), linkText)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return linkFollowedMsg{entity}
|
|
}
|
|
}
|
|
|
|
func followLinkByID(store *db.Store, entityID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entity, err := store.Get(context.Background(), entityID)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return linkFollowedMsg{entity}
|
|
}
|
|
}
|
|
|
|
func loadRailTags(store *db.Store) tea.Cmd {
|
|
return func() tea.Msg {
|
|
tags, err := store.ListTags(context.Background(), false)
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return railTagsLoadedMsg{tags}
|
|
}
|
|
}
|
|
|
|
func editInEditor(store *db.Store, e *db.Entity) tea.Cmd {
|
|
editorEnv := os.Getenv("EDITOR")
|
|
if editorEnv == "" {
|
|
editorEnv = os.Getenv("VISUAL")
|
|
}
|
|
if editorEnv == "" {
|
|
editorEnv = "vi"
|
|
}
|
|
parts := strings.Fields(editorEnv)
|
|
editor, editorArgs := parts[0], parts[1:]
|
|
|
|
f, err := os.CreateTemp("", "nib-edit-*.md")
|
|
if err != nil {
|
|
return func() tea.Msg { return errMsg{err} }
|
|
}
|
|
if _, err := f.WriteString(e.Body); err != nil {
|
|
f.Close()
|
|
os.Remove(f.Name())
|
|
return func() tea.Msg { return errMsg{err} }
|
|
}
|
|
f.Close()
|
|
|
|
c := exec.Command(editor, append(editorArgs, f.Name())...)
|
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
|
defer os.Remove(f.Name())
|
|
if err != nil {
|
|
return editorFinishedMsg{err}
|
|
}
|
|
|
|
content, readErr := os.ReadFile(f.Name())
|
|
if readErr != nil {
|
|
return editorFinishedMsg{readErr}
|
|
}
|
|
|
|
newBody := string(content)
|
|
if newBody == e.Body {
|
|
return editorFinishedMsg{nil}
|
|
}
|
|
|
|
update := db.EntityUpdate{Body: &newBody}
|
|
if updateErr := store.Update(context.Background(), e.ID, &update); updateErr != nil {
|
|
return editorFinishedMsg{updateErr}
|
|
}
|
|
|
|
return editorFinishedMsg{nil}
|
|
})
|
|
}
|
|
|
|
func loadAbsorbSources(store *db.Store, targetID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entities, err := store.List(context.Background(), db.DefaultListParams())
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return absorbSourcesLoadedMsg{targetID, entities}
|
|
}
|
|
}
|
|
|
|
func absorbEntity(store *db.Store, targetID, sourceID string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := store.Absorb(context.Background(), targetID, sourceID); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return entityAbsorbedMsg{targetID}
|
|
}
|
|
}
|
|
|
|
func persistSteps(store *db.Store, entityID string, stepsJSON string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
update := db.EntityUpdate{CardData: &stepsJSON}
|
|
if err := store.Update(context.Background(), entityID, &update); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return stepsPersistedMsg{}
|
|
}
|
|
}
|
|
|
|
func copyResolved(store *db.Store, entityID string, resolved string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := clipboard.WriteAll(resolved); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
if err := store.IncrementUse(context.Background(), entityID); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return templateCopiedMsg{}
|
|
}
|
|
}
|
|
|
|
func clearStatusAfter(d time.Duration, seq int) tea.Cmd {
|
|
return tea.Tick(d, func(time.Time) tea.Msg {
|
|
return statusClearMsg{seq: seq}
|
|
})
|
|
}
|
|
|
|
func loadStaleEntities(store *db.Store) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entities, err := store.List(context.Background(), staleParams())
|
|
if err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return staleEntitiesLoadedMsg{entities}
|
|
}
|
|
}
|
|
|
|
func stumbleDismiss(store *db.Store, id string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := store.SoftDelete(context.Background(), id); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return stumbleActionMsg{"dismissed"}
|
|
}
|
|
}
|
|
|
|
func stumblePin(store *db.Store, id string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
pinned := true
|
|
update := db.EntityUpdate{Pinned: &pinned}
|
|
if err := store.Update(context.Background(), id, &update); err != nil {
|
|
return errMsg{err}
|
|
}
|
|
return stumbleActionMsg{"pinned"}
|
|
}
|
|
}
|