feat(tui): add bubbletea terminal UI #30
+3
-76
@@ -1,11 +1,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
"github.com/lerko/nib/internal/display"
|
"github.com/lerko/nib/internal/display"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -47,9 +45,9 @@ func runPromote(_ *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cardData := generateCardData(cardType, e.Body)
|
cd := carddata.GenerateCardData(cardType, e.Body)
|
||||||
|
|
||||||
if err := store.Promote(id, cardType, cardData); err != nil {
|
if err := store.Promote(id, cardType, cd); err != nil {
|
||||||
if err == db.ErrAlreadyPromoted {
|
if err == db.ErrAlreadyPromoted {
|
||||||
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
return fmt.Errorf("invalid_promote — entity %s is already a %s",
|
||||||
display.FormatID(id), *e.CardType)
|
display.FormatID(id), *e.CardType)
|
||||||
@@ -60,74 +58,3 @@ func runPromote(_ *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
|
||||||
|
|
||||||
func generateCardData(ct db.CardType, body string) *string {
|
|
||||||
var data string
|
|
||||||
switch ct {
|
|
||||||
case db.CardTemplate:
|
|
||||||
matches := templateSlotRe.FindAllStringSubmatch(body, -1)
|
|
||||||
type slot struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Default string `json:"default"`
|
|
||||||
}
|
|
||||||
var slots []slot
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range matches {
|
|
||||||
name := m[1]
|
|
||||||
if !seen[name] {
|
|
||||||
slots = append(slots, slot{Name: name, Default: ""})
|
|
||||||
seen[name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slots == nil {
|
|
||||||
slots = []slot{}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"slots": slots})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardChecklist:
|
|
||||||
type step struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
}
|
|
||||||
var steps []step
|
|
||||||
for _, line := range strings.Split(body, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
|
||||||
text := strings.TrimSpace(line[3:])
|
|
||||||
done := strings.HasPrefix(line, "[x]")
|
|
||||||
steps = append(steps, step{Text: text, Done: done})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if steps == nil {
|
|
||||||
steps = []step{{Text: body, Done: false}}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"steps": steps})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardDecision:
|
|
||||||
b, _ := json.Marshal(map[string]any{
|
|
||||||
"chose": "",
|
|
||||||
"why": "",
|
|
||||||
"rejected": []string{},
|
|
||||||
})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
case db.CardLink:
|
|
||||||
url := ""
|
|
||||||
for _, word := range strings.Fields(body) {
|
|
||||||
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
|
||||||
url = word
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(map[string]any{"url": url})
|
|
||||||
data = string(b)
|
|
||||||
|
|
||||||
default:
|
|
||||||
data = "{}"
|
|
||||||
}
|
|
||||||
return &data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package carddata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TemplateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`)
|
||||||
|
|
||||||
|
func GenerateCardData(ct db.CardType, body string) *string {
|
||||||
|
var data string
|
||||||
|
switch ct {
|
||||||
|
case db.CardTemplate:
|
||||||
|
matches := TemplateSlotRe.FindAllStringSubmatch(body, -1)
|
||||||
|
type slot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Default string `json:"default"`
|
||||||
|
}
|
||||||
|
var slots []slot
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range matches {
|
||||||
|
name := m[1]
|
||||||
|
if !seen[name] {
|
||||||
|
slots = append(slots, slot{Name: name, Default: ""})
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slots == nil {
|
||||||
|
slots = []slot{}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"slots": slots})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardChecklist:
|
||||||
|
type step struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
var steps []step
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") {
|
||||||
|
text := strings.TrimSpace(line[3:])
|
||||||
|
done := strings.HasPrefix(line, "[x]")
|
||||||
|
steps = append(steps, step{Text: text, Done: done})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if steps == nil {
|
||||||
|
steps = []step{{Text: body, Done: false}}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"steps": steps})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
b, _ := json.Marshal(map[string]any{
|
||||||
|
"chose": "",
|
||||||
|
"why": "",
|
||||||
|
"rejected": []string{},
|
||||||
|
})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
url := ""
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
url = word
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"url": url})
|
||||||
|
data = string(b)
|
||||||
|
|
||||||
|
default:
|
||||||
|
data = "{}"
|
||||||
|
}
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectCardType(body string) *db.CardType {
|
||||||
|
if TemplateSlotRe.MatchString(body) {
|
||||||
|
ct := db.CardTemplate
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "chose:") || strings.Contains(body, "why:") {
|
||||||
|
ct := db.CardDecision
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "[ ]") || strings.Contains(body, "[x]") {
|
||||||
|
ct := db.CardChecklist
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
for _, word := range strings.Fields(body) {
|
||||||
|
if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") {
|
||||||
|
ct := db.CardLink
|
||||||
|
return &ct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package cmd
|
package carddata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateCardData_Snippet(t *testing.T) {
|
func TestGenerateCardData_Snippet(t *testing.T) {
|
||||||
data := generateCardData(db.CardSnippet, "some snippet")
|
data := GenerateCardData(db.CardSnippet, "some snippet")
|
||||||
if data == nil || *data != "{}" {
|
if data == nil || *data != "{}" {
|
||||||
t.Errorf("snippet should produce {}, got %v", data)
|
t.Errorf("snippet should produce {}, got %v", data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Template(t *testing.T) {
|
func TestGenerateCardData_Template(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestGenerateCardData_Template(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "${x} and ${x}")
|
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Slots []struct {
|
Slots []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -54,7 +54,7 @@ func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
||||||
data := generateCardData(db.CardTemplate, "no placeholders here")
|
data := GenerateCardData(db.CardTemplate, "no placeholders here")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Slots []struct {
|
Slots []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -68,7 +68,7 @@ func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerateCardData_Checklist(t *testing.T) {
|
func TestGenerateCardData_Checklist(t *testing.T) {
|
||||||
body := "[ ] step one\n[x] step two\n[ ] step three"
|
body := "[ ] step one\n[x] step two\n[ ] step three"
|
||||||
data := generateCardData(db.CardChecklist, body)
|
data := GenerateCardData(db.CardChecklist, body)
|
||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestGenerateCardData_Checklist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
||||||
data := generateCardData(db.CardChecklist, "no checkbox syntax")
|
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Steps []struct {
|
Steps []struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@@ -111,7 +111,7 @@ func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Decision(t *testing.T) {
|
func TestGenerateCardData_Decision(t *testing.T) {
|
||||||
data := generateCardData(db.CardDecision, "which db?")
|
data := GenerateCardData(db.CardDecision, "which db?")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
Chose string `json:"chose"`
|
Chose string `json:"chose"`
|
||||||
Why string `json:"why"`
|
Why string `json:"why"`
|
||||||
@@ -129,7 +129,7 @@ func TestGenerateCardData_Decision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_Link(t *testing.T) {
|
func TestGenerateCardData_Link(t *testing.T) {
|
||||||
data := generateCardData(db.CardLink, "check https://example.com/path for details")
|
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ func TestGenerateCardData_Link(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
||||||
data := generateCardData(db.CardLink, "no url here")
|
data := GenerateCardData(db.CardLink, "no url here")
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type intent int
|
||||||
|
|
||||||
|
const (
|
||||||
|
intentAll intent = iota
|
||||||
|
intentGrab
|
||||||
|
intentRead
|
||||||
|
intentFill
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i intent) String() string {
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return "grab"
|
||||||
|
case intentRead:
|
||||||
|
return "read"
|
||||||
|
case intentFill:
|
||||||
|
return "fill"
|
||||||
|
default:
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i intent) next() intent {
|
||||||
|
switch i {
|
||||||
|
case intentAll:
|
||||||
|
return intentGrab
|
||||||
|
case intentGrab:
|
||||||
|
return intentRead
|
||||||
|
case intentRead:
|
||||||
|
return intentFill
|
||||||
|
default:
|
||||||
|
return intentAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesIntent(e *db.Entity, i intent) bool {
|
||||||
|
if i == intentAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ct := e.CardType
|
||||||
|
if ct == nil {
|
||||||
|
return i == intentGrab
|
||||||
|
}
|
||||||
|
switch i {
|
||||||
|
case intentGrab:
|
||||||
|
return *ct == db.CardSnippet
|
||||||
|
case intentRead:
|
||||||
|
return *ct == db.CardNote || *ct == db.CardLink || *ct == db.CardDecision
|
||||||
|
case intentFill:
|
||||||
|
return *ct == db.CardTemplate || *ct == db.CardChecklist
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type cardsModel struct {
|
||||||
|
entities []*db.Entity
|
||||||
|
filtered []*db.Entity
|
||||||
|
cursor int
|
||||||
|
offset int
|
||||||
|
height int
|
||||||
|
width int
|
||||||
|
intent intent
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCardsModel() cardsModel {
|
||||||
|
return cardsModel{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setEntities(entities []*db.Entity) {
|
||||||
|
c.entities = entities
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setIntent(i intent) {
|
||||||
|
c.intent = i
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
c.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) applyFilter() {
|
||||||
|
c.filtered = nil
|
||||||
|
var pinned, rest []*db.Entity
|
||||||
|
for _, e := range c.entities {
|
||||||
|
if !matchesIntent(e, c.intent) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Pinned {
|
||||||
|
pinned = append(pinned, e)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.filtered = append(pinned, rest...)
|
||||||
|
if c.cursor >= len(c.filtered) {
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cardsModel) setSize(width, height int) {
|
||||||
|
c.width = width
|
||||||
|
c.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) selected() *db.Entity {
|
||||||
|
if len(c.filtered) == 0 || c.cursor >= len(c.filtered) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.filtered[c.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) update(msg tea.KeyMsg) cardsModel {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if c.cursor > 0 {
|
||||||
|
c.cursor--
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if c.cursor < len(c.filtered)-1 {
|
||||||
|
c.cursor++
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "home", "g":
|
||||||
|
c.cursor = 0
|
||||||
|
c.offset = 0
|
||||||
|
case "end", "G":
|
||||||
|
c.cursor = max(0, len(c.filtered)-1)
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
c.cursor = max(0, c.cursor-c.visibleCount())
|
||||||
|
if c.cursor < c.offset {
|
||||||
|
c.offset = c.cursor
|
||||||
|
}
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
c.cursor = min(len(c.filtered)-1, c.cursor+c.visibleCount())
|
||||||
|
visible := c.visibleCount()
|
||||||
|
if c.cursor >= c.offset+visible {
|
||||||
|
c.offset = c.cursor - visible + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) view(width int) string {
|
||||||
|
if len(c.filtered) == 0 {
|
||||||
|
return statusStyle.Render("no cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
visible := c.visibleCount()
|
||||||
|
end := min(c.offset+visible, len(c.filtered))
|
||||||
|
|
||||||
|
for i := c.offset; i < end; i++ {
|
||||||
|
e := c.filtered[i]
|
||||||
|
line := renderCard(e, width-4)
|
||||||
|
|
||||||
|
if i == c.cursor {
|
||||||
|
b.WriteString(selectedItemStyle.Render(" " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(listItemStyle.Render(line))
|
||||||
|
}
|
||||||
|
if i < end-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cardsModel) visibleCount() int {
|
||||||
|
if c.height <= 0 {
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
return c.height
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCard(e *db.Entity, maxWidth int) string {
|
||||||
|
glyph := glyphStyle.Render(display.DisplayGlyph(e.Glyph, e.CardType))
|
||||||
|
id := idStyle.Render("[" + display.FormatID(e.ID) + "]")
|
||||||
|
|
||||||
|
body := e.Body
|
||||||
|
if e.Title != nil {
|
||||||
|
body = *e.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
affordance := detectAffordance(e)
|
||||||
|
affordStr := ""
|
||||||
|
if affordance != "" {
|
||||||
|
affordStr = " " + affordanceStyle.Render(affordance)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extras []string
|
||||||
|
if e.Pinned {
|
||||||
|
extras = append(extras, pinnedStyle.Render("•"))
|
||||||
|
}
|
||||||
|
if len(e.Tags) > 0 {
|
||||||
|
limit := min(2, len(e.Tags))
|
||||||
|
for _, t := range e.Tags[:limit] {
|
||||||
|
extras = append(extras, tagStyle.Render("#"+t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraStr := ""
|
||||||
|
if len(extras) > 0 {
|
||||||
|
extraStr = " " + strings.Join(extras, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
useStr := ""
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
useStr = " " + useCountStyle.Render(fmt.Sprintf("%d×", e.UseCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
|
||||||
|
|
||||||
|
if maxWidth > 0 && len(stripAnsi(line)) > maxWidth {
|
||||||
|
body = truncate(body, maxWidth-30)
|
||||||
|
line = fmt.Sprintf("%s %s%s%s%s %s", glyph, body, affordStr, extraStr, useStr, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectAffordance(e *db.Entity) string {
|
||||||
|
if e.CardType == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardSnippet:
|
||||||
|
return "code"
|
||||||
|
case db.CardTemplate:
|
||||||
|
return "fill"
|
||||||
|
case db.CardChecklist:
|
||||||
|
return "steps"
|
||||||
|
case db.CardDecision:
|
||||||
|
return "decide"
|
||||||
|
case db.CardLink:
|
||||||
|
return "link"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/atotto/clipboard"
|
"github.com/atotto/clipboard"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ type entityUpdatedMsg struct {
|
|||||||
|
|
||||||
type entityPromotedMsg struct {
|
type entityPromotedMsg struct {
|
||||||
id string
|
id string
|
||||||
|
cardType db.CardType
|
||||||
}
|
}
|
||||||
|
|
||||||
type entityDemotedMsg struct {
|
type entityDemotedMsg struct {
|
||||||
@@ -122,12 +124,13 @@ func pinEntity(store *db.Store, e *db.Entity) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func promoteEntity(store *db.Store, id string) tea.Cmd {
|
func promoteEntity(store *db.Store, id string, ct db.CardType, body string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := store.Promote(id, db.CardSnippet, nil); err != nil {
|
cd := carddata.GenerateCardData(ct, body)
|
||||||
|
if err := store.Promote(id, ct, cd); err != nil {
|
||||||
return errMsg{err}
|
return errMsg{err}
|
||||||
}
|
}
|
||||||
return entityPromotedMsg{id}
|
return entityPromotedMsg{id, ct}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -54,6 +54,9 @@ func (d detailModel) view(width int) string {
|
|||||||
|
|
||||||
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
|
||||||
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
header := fmt.Sprintf("%s %s", glyph, display.FormatID(e.ID))
|
||||||
|
if e.CardType != nil {
|
||||||
|
header += " " + affordanceStyle.Render(string(*e.CardType))
|
||||||
|
}
|
||||||
b.WriteString(detailHeaderStyle.Render(header))
|
b.WriteString(detailHeaderStyle.Render(header))
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
@@ -65,6 +68,14 @@ func (d detailModel) view(width int) string {
|
|||||||
b.WriteString(detailBodyStyle.Render(e.Body))
|
b.WriteString(detailBodyStyle.Render(e.Body))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if e.CardType != nil {
|
||||||
|
cardSection := renderCardData(e)
|
||||||
|
if cardSection != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(cardSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(e.Tags) > 0 {
|
if len(e.Tags) > 0 {
|
||||||
tagParts := make([]string, len(e.Tags))
|
tagParts := make([]string, len(e.Tags))
|
||||||
for i, t := range e.Tags {
|
for i, t := range e.Tags {
|
||||||
@@ -84,11 +95,14 @@ func (d detailModel) view(width int) string {
|
|||||||
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
meta += fmt.Sprintf("\nanchored @%s", *e.TimeAnchor)
|
||||||
}
|
}
|
||||||
if e.Pinned {
|
if e.Pinned {
|
||||||
meta += "\npinned"
|
meta += "\n" + pinnedStyle.Render("pinned")
|
||||||
}
|
}
|
||||||
if e.CardType != nil {
|
if e.CardType != nil {
|
||||||
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||||||
}
|
}
|
||||||
|
if e.UseCount > 0 {
|
||||||
|
meta += fmt.Sprintf("\nused %d×", e.UseCount)
|
||||||
|
}
|
||||||
if e.CompletedAt != nil {
|
if e.CompletedAt != nil {
|
||||||
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
meta += fmt.Sprintf("\ndone %s", e.CompletedAt.Format(time.DateTime))
|
||||||
}
|
}
|
||||||
@@ -104,3 +118,87 @@ func (d detailModel) view(width int) string {
|
|||||||
|
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderCardData(e *db.Entity) string {
|
||||||
|
if e.CardData == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := e.CardDataJSON()
|
||||||
|
if err != nil || data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
switch *e.CardType {
|
||||||
|
case db.CardChecklist:
|
||||||
|
steps, ok := data["steps"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
done := 0
|
||||||
|
for _, s := range steps {
|
||||||
|
step, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, _ := step["text"].(string)
|
||||||
|
isDone, _ := step["done"].(bool)
|
||||||
|
if isDone {
|
||||||
|
done++
|
||||||
|
b.WriteString(" " + checkDoneStyle.Render("[✓] "+text) + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + checkPendingStyle.Render("[ ] "+text) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress := fmt.Sprintf(" %d/%d steps", done, len(steps))
|
||||||
|
b.WriteString(detailLabelStyle.Render(progress))
|
||||||
|
|
||||||
|
case db.CardTemplate:
|
||||||
|
slots, ok := data["slots"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(detailLabelStyle.Render(" slots:") + "\n")
|
||||||
|
for _, s := range slots {
|
||||||
|
slot, ok := s.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := slot["name"].(string)
|
||||||
|
def, _ := slot["default"].(string)
|
||||||
|
line := " ${" + name + "}"
|
||||||
|
if def != "" {
|
||||||
|
line += " " + detailValueStyle.Render("default: "+def)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
case db.CardDecision:
|
||||||
|
if chose, ok := data["chose"].(string); ok && chose != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("chose: ") + detailValueStyle.Render(chose) + "\n")
|
||||||
|
}
|
||||||
|
if why, ok := data["why"].(string); ok && why != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("why: ") + detailValueStyle.Render(why) + "\n")
|
||||||
|
}
|
||||||
|
if rejected, ok := data["rejected"].([]interface{}); ok && len(rejected) > 0 {
|
||||||
|
items := make([]string, 0, len(rejected))
|
||||||
|
for _, r := range rejected {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("rejected: ") + detailValueStyle.Render(strings.Join(items, ", ")) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case db.CardLink:
|
||||||
|
if url, ok := data["url"].(string); ok && url != "" {
|
||||||
|
b.WriteString(" " + detailLabelStyle.Render("↗ ") + detailValueStyle.Render(url) + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ func renderHelp(width, height int) string {
|
|||||||
{"g/G home/end", "top / bottom"},
|
{"g/G home/end", "top / bottom"},
|
||||||
{"pgup/pgdn", "page up / down"},
|
{"pgup/pgdn", "page up / down"},
|
||||||
{"enter", "view detail"},
|
{"enter", "view detail"},
|
||||||
{"esc", "back / cancel"},
|
{"esc", "back / clear filter"},
|
||||||
|
}},
|
||||||
|
{"Views", [][2]string{
|
||||||
|
{"1", "stream view"},
|
||||||
|
{"2", "cards view"},
|
||||||
|
{"s", "cycle sort (cards)"},
|
||||||
|
{"tab", "cycle intent (cards)"},
|
||||||
}},
|
}},
|
||||||
{"Actions", [][2]string{
|
{"Actions", [][2]string{
|
||||||
{"a", "add entity"},
|
{"a", "add entity"},
|
||||||
@@ -20,6 +26,7 @@ func renderHelp(width, height int) string {
|
|||||||
{"x", "toggle todo completion"},
|
{"x", "toggle todo completion"},
|
||||||
{"!", "toggle pin"},
|
{"!", "toggle pin"},
|
||||||
{"#", "filter by tag"},
|
{"#", "filter by tag"},
|
||||||
|
{"p", "promote to card"},
|
||||||
}},
|
}},
|
||||||
{"Detail View", [][2]string{
|
{"Detail View", [][2]string{
|
||||||
{"p", "promote to card"},
|
{"p", "promote to card"},
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type keyMap struct {
|
|||||||
Demote key.Binding
|
Demote key.Binding
|
||||||
Copy key.Binding
|
Copy key.Binding
|
||||||
Edit key.Binding
|
Edit key.Binding
|
||||||
|
Stream key.Binding
|
||||||
|
Cards key.Binding
|
||||||
|
Sort key.Binding
|
||||||
|
Intent key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -44,4 +48,8 @@ var keys = keyMap{
|
|||||||
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
|
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
|
||||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
|
||||||
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
||||||
|
Stream: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "stream")),
|
||||||
|
Cards: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "cards")),
|
||||||
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
|
Intent: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "intent")),
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-15
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
@@ -14,22 +16,64 @@ const (
|
|||||||
stateInput
|
stateInput
|
||||||
stateTagFilter
|
stateTagFilter
|
||||||
stateConfirm
|
stateConfirm
|
||||||
|
statePromote
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type viewMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeStream viewMode = iota
|
||||||
|
modeCards
|
||||||
|
)
|
||||||
|
|
||||||
|
type cardsSort int
|
||||||
|
|
||||||
|
const (
|
||||||
|
sortNewest cardsSort = iota
|
||||||
|
sortOldest
|
||||||
|
sortMostUsed
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s cardsSort) String() string {
|
||||||
|
switch s {
|
||||||
|
case sortOldest:
|
||||||
|
return "oldest"
|
||||||
|
case sortMostUsed:
|
||||||
|
return "most used"
|
||||||
|
default:
|
||||||
|
return "newest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s cardsSort) next() cardsSort {
|
||||||
|
switch s {
|
||||||
|
case sortNewest:
|
||||||
|
return sortOldest
|
||||||
|
case sortOldest:
|
||||||
|
return sortMostUsed
|
||||||
|
default:
|
||||||
|
return sortNewest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
store *db.Store
|
store *db.Store
|
||||||
state viewState
|
state viewState
|
||||||
|
mode viewMode
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
list listModel
|
list listModel
|
||||||
|
cards cardsModel
|
||||||
detail detailModel
|
detail detailModel
|
||||||
input inputModel
|
input inputModel
|
||||||
filter filterModel
|
filter filterModel
|
||||||
|
promote promoteModel
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
|
||||||
filterTag string
|
filterTag string
|
||||||
confirmID string
|
confirmID string
|
||||||
|
cardsSort cardsSort
|
||||||
|
|
||||||
status string
|
status string
|
||||||
err error
|
err error
|
||||||
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
|
|||||||
return model{
|
return model{
|
||||||
store: store,
|
store: store,
|
||||||
state: stateList,
|
state: stateList,
|
||||||
|
mode: modeStream,
|
||||||
list: newListModel(),
|
list: newListModel(),
|
||||||
|
cards: newCardsModel(),
|
||||||
detail: newDetailModel(),
|
detail: newDetailModel(),
|
||||||
input: newInputModel(),
|
input: newInputModel(),
|
||||||
filter: newFilterModel(),
|
filter: newFilterModel(),
|
||||||
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
|
|||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
p.Tag = &m.filterTag
|
p.Tag = &m.filterTag
|
||||||
}
|
}
|
||||||
|
if m.mode == modeCards {
|
||||||
|
p.CardsOnly = true
|
||||||
|
switch m.cardsSort {
|
||||||
|
case sortNewest:
|
||||||
|
p.Sort = "created"
|
||||||
|
p.Order = "desc"
|
||||||
|
case sortOldest:
|
||||||
|
p.Sort = "created"
|
||||||
|
p.Order = "asc"
|
||||||
|
case sortMostUsed:
|
||||||
|
p.Sort = "use_count"
|
||||||
|
p.Order = "desc"
|
||||||
|
}
|
||||||
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.list.setSize(m.width, m.contentHeight())
|
m.list.setSize(m.width, m.contentHeight())
|
||||||
|
m.cards.setSize(m.width, m.contentHeight())
|
||||||
m.detail.setSize(m.width, m.contentHeight())
|
m.detail.setSize(m.width, m.contentHeight())
|
||||||
m.filter.setHeight(m.contentHeight())
|
m.filter.setHeight(m.contentHeight())
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case entitiesLoadedMsg:
|
case entitiesLoadedMsg:
|
||||||
|
if m.mode == modeCards {
|
||||||
|
m.cards.setEntities(msg.entities)
|
||||||
|
} else {
|
||||||
m.list.setEntities(msg.entities)
|
m.list.setEntities(msg.entities)
|
||||||
|
}
|
||||||
m.err = nil
|
m.err = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -92,8 +157,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, loadEntities(m.store, m.listParams())
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
|
||||||
case entityPromotedMsg:
|
case entityPromotedMsg:
|
||||||
m.status = "promoted → snippet"
|
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
||||||
return m, m.reloadDetail(msg.id)
|
m.state = stateList
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
|
||||||
case entityDemotedMsg:
|
case entityDemotedMsg:
|
||||||
m.status = "demoted → fluid"
|
m.status = "demoted → fluid"
|
||||||
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.updateTagFilter(msg)
|
return m.updateTagFilter(msg)
|
||||||
case stateConfirm:
|
case stateConfirm:
|
||||||
return m.updateConfirm(msg)
|
return m.updateConfirm(msg)
|
||||||
|
case statePromote:
|
||||||
|
return m.updatePromote(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateKeys(msg)
|
return m.updateKeys(msg)
|
||||||
}
|
}
|
||||||
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.showHelp = true
|
m.showHelp = true
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "1":
|
||||||
|
if m.mode != modeStream {
|
||||||
|
m.mode = modeStream
|
||||||
|
m.state = stateList
|
||||||
|
m.status = ""
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "2":
|
||||||
|
if m.mode != modeCards {
|
||||||
|
m.mode = modeCards
|
||||||
|
m.state = stateList
|
||||||
|
m.status = ""
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "s":
|
||||||
|
if m.mode == modeCards && m.state == stateList {
|
||||||
|
m.cardsSort = m.cardsSort.next()
|
||||||
|
m.status = "sort: " + m.cardsSort.String()
|
||||||
|
return m, loadEntities(m.store, m.listParams())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "tab":
|
||||||
|
if m.mode == modeCards && m.state == stateList {
|
||||||
|
m.cards.setIntent(m.cards.intent.next())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "a":
|
case "a":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
m.state = stateInput
|
m.state = stateInput
|
||||||
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
if e := m.list.selected(); e != nil {
|
if e := m.selectedEntity(); e != nil {
|
||||||
m.detail.setEntity(e)
|
m.detail.setEntity(e)
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "d":
|
case "d":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
if e := m.list.selected(); e != nil {
|
if e := m.selectedEntity(); e != nil {
|
||||||
m.confirmID = e.ID
|
m.confirmID = e.ID
|
||||||
m.state = stateConfirm
|
m.state = stateConfirm
|
||||||
return m, confirmTimeout()
|
return m, confirmTimeout()
|
||||||
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "x":
|
case "x":
|
||||||
if m.state == stateList {
|
if m.state == stateList {
|
||||||
if e := m.list.selected(); e != nil && e.Glyph == db.GlyphTodo {
|
if e := m.selectedEntity(); e != nil && e.Glyph == db.GlyphTodo {
|
||||||
return m, toggleTodo(m.store, e)
|
return m, toggleTodo(m.store, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "p":
|
case "p":
|
||||||
if m.state == stateDetail && m.detail.entity != nil {
|
e := m.selectedEntity()
|
||||||
if m.detail.entity.CardType != nil {
|
if e != nil {
|
||||||
|
if e.CardType != nil {
|
||||||
m.status = "already a card"
|
m.status = "already a card"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, promoteEntity(m.store, m.detail.entity.ID)
|
m.promote = newPromoteModel(e.ID, e.Body)
|
||||||
|
m.state = statePromote
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateList:
|
case stateList:
|
||||||
|
if m.mode == modeCards {
|
||||||
|
m.cards = m.cards.update(msg)
|
||||||
|
} else {
|
||||||
m.list = m.list.update(msg)
|
m.list = m.list.update(msg)
|
||||||
|
}
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
m.detail = m.detail.update(msg)
|
m.detail = m.detail.update(msg)
|
||||||
}
|
}
|
||||||
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) updatePromote(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateList
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
ct := m.promote.selectedType()
|
||||||
|
return m, promoteEntity(m.store, m.promote.entityID, ct, m.promote.body)
|
||||||
|
default:
|
||||||
|
m.promote = m.promote.update(msg.String())
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
return renderHelp(m.width, m.height)
|
return renderHelp(m.width, m.height)
|
||||||
@@ -327,21 +449,47 @@ func (m model) View() string {
|
|||||||
var content string
|
var content string
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateList, stateInput, stateConfirm:
|
case stateList, stateInput, stateConfirm:
|
||||||
|
if m.mode == modeCards {
|
||||||
|
content = m.cards.view(m.width)
|
||||||
|
} else {
|
||||||
content = m.list.view(m.width)
|
content = m.list.view(m.width)
|
||||||
|
}
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
content = m.detail.view(m.width)
|
content = m.detail.view(m.width)
|
||||||
case stateTagFilter:
|
case stateTagFilter:
|
||||||
content = m.filter.view(m.width)
|
content = m.filter.view(m.width)
|
||||||
|
case statePromote:
|
||||||
|
content = m.promote.view(m.width)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header := m.headerView()
|
||||||
|
footer := m.footerView()
|
||||||
|
|
||||||
|
return header + "\n" + content + "\n" + footer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) headerView() string {
|
||||||
header := titleStyle.Render("nib")
|
header := titleStyle.Render("nib")
|
||||||
|
|
||||||
|
modeName := "stream"
|
||||||
|
if m.mode == modeCards {
|
||||||
|
modeName = "cards"
|
||||||
|
}
|
||||||
|
header += " " + modeStyle.Render(modeName)
|
||||||
|
|
||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
header += " " + filterPillStyle.Render("#"+m.filterTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
footer := m.footerView()
|
if m.mode == modeCards && m.cards.intent != intentAll {
|
||||||
|
header += " " + affordanceStyle.Render(m.cards.intent.String())
|
||||||
|
}
|
||||||
|
|
||||||
return header + "\n" + content + "\n" + footer
|
if m.mode == modeCards {
|
||||||
|
header += " " + idStyle.Render("("+m.cardsSort.String()+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) footerView() string {
|
func (m model) footerView() string {
|
||||||
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) selectedEntity() *db.Entity {
|
func (m model) selectedEntity() *db.Entity {
|
||||||
switch m.state {
|
switch {
|
||||||
case stateList:
|
case m.state == stateDetail:
|
||||||
return m.list.selected()
|
|
||||||
case stateDetail:
|
|
||||||
return m.detail.entity
|
return m.detail.entity
|
||||||
|
case m.mode == modeCards:
|
||||||
|
return m.cards.selected()
|
||||||
|
default:
|
||||||
|
return m.list.selected()
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) reloadDetail(id string) tea.Cmd {
|
func (m model) reloadDetail(id string) tea.Cmd {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/lerko/nib/internal/carddata"
|
||||||
|
"github.com/lerko/nib/internal/db"
|
||||||
|
"github.com/lerko/nib/internal/display"
|
||||||
|
)
|
||||||
|
|
||||||
|
type promoteOption struct {
|
||||||
|
cardType db.CardType
|
||||||
|
label string
|
||||||
|
group string
|
||||||
|
}
|
||||||
|
|
||||||
|
var promoteOptions = []promoteOption{
|
||||||
|
{db.CardSnippet, "snippet", "grab"},
|
||||||
|
{db.CardNote, "note", "read"},
|
||||||
|
{db.CardLink, "link", "read"},
|
||||||
|
{db.CardDecision, "decision", "read"},
|
||||||
|
{db.CardTemplate, "template", "fill"},
|
||||||
|
{db.CardChecklist, "checklist", "fill"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type promoteModel struct {
|
||||||
|
cursor int
|
||||||
|
entityID string
|
||||||
|
body string
|
||||||
|
suggested *db.CardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPromoteModel(entityID, body string) promoteModel {
|
||||||
|
return promoteModel{
|
||||||
|
entityID: entityID,
|
||||||
|
body: body,
|
||||||
|
suggested: carddata.DetectCardType(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) selectedType() db.CardType {
|
||||||
|
return promoteOptions[p.cursor].cardType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) update(key string) promoteModel {
|
||||||
|
switch key {
|
||||||
|
case "up", "k":
|
||||||
|
if p.cursor > 0 {
|
||||||
|
p.cursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if p.cursor < len(promoteOptions)-1 {
|
||||||
|
p.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p promoteModel) view(width int) string {
|
||||||
|
var b string
|
||||||
|
b += titleStyle.Render("promote to card") + "\n\n"
|
||||||
|
|
||||||
|
currentGroup := ""
|
||||||
|
for i, opt := range promoteOptions {
|
||||||
|
if opt.group != currentGroup {
|
||||||
|
currentGroup = opt.group
|
||||||
|
b += dateHeaderStyle.Render("── "+currentGroup+" ──") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
glyph := display.DisplayGlyph(db.GlyphNote, &opt.cardType)
|
||||||
|
label := glyph + " " + opt.label
|
||||||
|
|
||||||
|
if p.suggested != nil && *p.suggested == opt.cardType {
|
||||||
|
label += " " + affordanceStyle.Render("*")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == p.cursor {
|
||||||
|
b += selectedItemStyle.Render(" "+label) + "\n"
|
||||||
|
} else {
|
||||||
|
b += listItemStyle.Render(label) + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b += "\n" + helpStyle.Render("enter:select esc:cancel")
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -23,7 +23,12 @@ func renderStatusBar(m model, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func countText(m model) string {
|
func countText(m model) string {
|
||||||
total := len(m.list.entities)
|
var total int
|
||||||
|
if m.mode == modeCards {
|
||||||
|
total = len(m.cards.filtered)
|
||||||
|
} else {
|
||||||
|
total = len(m.list.entities)
|
||||||
|
}
|
||||||
if m.filterTag != "" {
|
if m.filterTag != "" {
|
||||||
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
return fmt.Sprintf("%d entities #%s", total, m.filterTag)
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,12 @@ func contextHints(m model) string {
|
|||||||
return "j/k:nav enter:select esc:cancel"
|
return "j/k:nav enter:select esc:cancel"
|
||||||
case stateConfirm:
|
case stateConfirm:
|
||||||
return "y:confirm n:cancel"
|
return "y:confirm n:cancel"
|
||||||
|
case statePromote:
|
||||||
|
return "j/k:nav enter:select esc:cancel"
|
||||||
default:
|
default:
|
||||||
return "a:add d:del x:todo #:filter ?:help q:quit"
|
if m.mode == modeCards {
|
||||||
|
return "1:stream 2:cards s:sort tab:intent a:add ?:help q:quit"
|
||||||
|
}
|
||||||
|
return "1:stream 2:cards a:add d:del x:todo #:filter ?:help q:quit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,4 +77,28 @@ var (
|
|||||||
|
|
||||||
helpDescStyle = lipgloss.NewStyle().
|
helpDescStyle = lipgloss.NewStyle().
|
||||||
Foreground(dim)
|
Foreground(dim)
|
||||||
|
|
||||||
|
affordanceStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#5B8EF0", Dark: "#7AAFFF"}).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
useCountStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#B07D3A", Dark: "#D4A54A"})
|
||||||
|
|
||||||
|
modeStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(dim).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
detailLabelStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(highlight).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
detailValueStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#555555", Dark: "#BBBBBB"})
|
||||||
|
|
||||||
|
checkDoneStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||||
|
|
||||||
|
checkPendingStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(dim)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user