feat(tui): add cards view, mode switching, promote picker, and card detail
Stream/cards toggle with 1/2 keys. Cards view with intent filtering (tab cycles grab/read/fill/all), sort cycling (s key), pinned-first ordering, and affordance badges. Promote picker (p key) with card type selection and auto-detection from body content. Detail view renders card_data per type: checklist steps, template slots, decision fields, link URLs. Extracts generateCardData to internal/carddata for reuse across cmd and tui packages.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package carddata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
func TestGenerateCardData_Snippet(t *testing.T) {
|
||||
data := GenerateCardData(db.CardSnippet, "some snippet")
|
||||
if data == nil || *data != "{}" {
|
||||
t.Errorf("snippet should produce {}, got %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_Template(t *testing.T) {
|
||||
data := GenerateCardData(db.CardTemplate, "deploy ${host} to ${env}")
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Slots []struct {
|
||||
Name string `json:"name"`
|
||||
Default string `json:"default"`
|
||||
} `json:"slots"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parsed.Slots) != 2 {
|
||||
t.Fatalf("expected 2 slots, got %d", len(parsed.Slots))
|
||||
}
|
||||
if parsed.Slots[0].Name != "host" {
|
||||
t.Errorf("first slot: %q", parsed.Slots[0].Name)
|
||||
}
|
||||
if parsed.Slots[1].Name != "env" {
|
||||
t.Errorf("second slot: %q", parsed.Slots[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_TemplateDedupe(t *testing.T) {
|
||||
data := GenerateCardData(db.CardTemplate, "${x} and ${x}")
|
||||
var parsed struct {
|
||||
Slots []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"slots"`
|
||||
}
|
||||
json.Unmarshal([]byte(*data), &parsed)
|
||||
if len(parsed.Slots) != 1 {
|
||||
t.Errorf("duplicate slots should be deduped, got %d", len(parsed.Slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_TemplateNoSlots(t *testing.T) {
|
||||
data := GenerateCardData(db.CardTemplate, "no placeholders here")
|
||||
var parsed struct {
|
||||
Slots []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"slots"`
|
||||
}
|
||||
json.Unmarshal([]byte(*data), &parsed)
|
||||
if len(parsed.Slots) != 0 {
|
||||
t.Errorf("expected empty slots, got %d", len(parsed.Slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_Checklist(t *testing.T) {
|
||||
body := "[ ] step one\n[x] step two\n[ ] step three"
|
||||
data := GenerateCardData(db.CardChecklist, body)
|
||||
if data == nil {
|
||||
t.Fatal("expected non-nil data")
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
Steps []struct {
|
||||
Text string `json:"text"`
|
||||
Done bool `json:"done"`
|
||||
} `json:"steps"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parsed.Steps) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(parsed.Steps))
|
||||
}
|
||||
if parsed.Steps[0].Text != "step one" || parsed.Steps[0].Done {
|
||||
t.Errorf("step 0: %+v", parsed.Steps[0])
|
||||
}
|
||||
if parsed.Steps[1].Text != "step two" || !parsed.Steps[1].Done {
|
||||
t.Errorf("step 1: %+v", parsed.Steps[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_ChecklistFallback(t *testing.T) {
|
||||
data := GenerateCardData(db.CardChecklist, "no checkbox syntax")
|
||||
var parsed struct {
|
||||
Steps []struct {
|
||||
Text string `json:"text"`
|
||||
Done bool `json:"done"`
|
||||
} `json:"steps"`
|
||||
}
|
||||
json.Unmarshal([]byte(*data), &parsed)
|
||||
if len(parsed.Steps) != 1 {
|
||||
t.Fatalf("fallback should produce 1 step, got %d", len(parsed.Steps))
|
||||
}
|
||||
if parsed.Steps[0].Text != "no checkbox syntax" {
|
||||
t.Errorf("fallback step text: %q", parsed.Steps[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_Decision(t *testing.T) {
|
||||
data := GenerateCardData(db.CardDecision, "which db?")
|
||||
var parsed struct {
|
||||
Chose string `json:"chose"`
|
||||
Why string `json:"why"`
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*data), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Chose != "" || parsed.Why != "" {
|
||||
t.Error("decision fields should start empty")
|
||||
}
|
||||
if len(parsed.Rejected) != 0 {
|
||||
t.Error("rejected should start empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_Link(t *testing.T) {
|
||||
data := GenerateCardData(db.CardLink, "check https://example.com/path for details")
|
||||
var parsed struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
json.Unmarshal([]byte(*data), &parsed)
|
||||
if parsed.URL != "https://example.com/path" {
|
||||
t.Errorf("url: %q", parsed.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCardData_LinkNoURL(t *testing.T) {
|
||||
data := GenerateCardData(db.CardLink, "no url here")
|
||||
var parsed struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
json.Unmarshal([]byte(*data), &parsed)
|
||||
if parsed.URL != "" {
|
||||
t.Errorf("expected empty url, got %q", parsed.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"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/carddata"
|
||||
"github.com/lerko/nib/internal/db"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,8 @@ type entityUpdatedMsg struct {
|
||||
}
|
||||
|
||||
type entityPromotedMsg struct {
|
||||
id string
|
||||
id string
|
||||
cardType db.CardType
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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)
|
||||
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("\n\n")
|
||||
|
||||
@@ -65,6 +68,14 @@ func (d detailModel) view(width int) string {
|
||||
b.WriteString(detailBodyStyle.Render(e.Body))
|
||||
b.WriteString("\n")
|
||||
|
||||
if e.CardType != nil {
|
||||
cardSection := renderCardData(e)
|
||||
if cardSection != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(cardSection)
|
||||
}
|
||||
}
|
||||
|
||||
if len(e.Tags) > 0 {
|
||||
tagParts := make([]string, len(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)
|
||||
}
|
||||
if e.Pinned {
|
||||
meta += "\npinned"
|
||||
meta += "\n" + pinnedStyle.Render("pinned")
|
||||
}
|
||||
if e.CardType != nil {
|
||||
meta += fmt.Sprintf("\ncard %s", *e.CardType)
|
||||
}
|
||||
if e.UseCount > 0 {
|
||||
meta += fmt.Sprintf("\nused %d×", e.UseCount)
|
||||
}
|
||||
if e.CompletedAt != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
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"},
|
||||
{"pgup/pgdn", "page up / down"},
|
||||
{"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{
|
||||
{"a", "add entity"},
|
||||
@@ -20,6 +26,7 @@ func renderHelp(width, height int) string {
|
||||
{"x", "toggle todo completion"},
|
||||
{"!", "toggle pin"},
|
||||
{"#", "filter by tag"},
|
||||
{"p", "promote to card"},
|
||||
}},
|
||||
{"Detail View", [][2]string{
|
||||
{"p", "promote to card"},
|
||||
|
||||
@@ -22,6 +22,10 @@ type keyMap struct {
|
||||
Demote key.Binding
|
||||
Copy key.Binding
|
||||
Edit key.Binding
|
||||
Stream key.Binding
|
||||
Cards key.Binding
|
||||
Sort key.Binding
|
||||
Intent key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
@@ -44,4 +48,8 @@ var keys = keyMap{
|
||||
Demote: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "demote")),
|
||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy")),
|
||||
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")),
|
||||
}
|
||||
|
||||
+167
-18
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/lerko/nib/internal/db"
|
||||
@@ -14,22 +16,64 @@ const (
|
||||
stateInput
|
||||
stateTagFilter
|
||||
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 {
|
||||
store *db.Store
|
||||
state viewState
|
||||
mode viewMode
|
||||
width int
|
||||
height int
|
||||
|
||||
list listModel
|
||||
cards cardsModel
|
||||
detail detailModel
|
||||
input inputModel
|
||||
filter filterModel
|
||||
promote promoteModel
|
||||
showHelp bool
|
||||
|
||||
filterTag string
|
||||
confirmID string
|
||||
cardsSort cardsSort
|
||||
|
||||
status string
|
||||
err error
|
||||
@@ -39,7 +83,9 @@ func newModel(store *db.Store) model {
|
||||
return model{
|
||||
store: store,
|
||||
state: stateList,
|
||||
mode: modeStream,
|
||||
list: newListModel(),
|
||||
cards: newCardsModel(),
|
||||
detail: newDetailModel(),
|
||||
input: newInputModel(),
|
||||
filter: newFilterModel(),
|
||||
@@ -55,6 +101,20 @@ func (m model) listParams() db.ListParams {
|
||||
if 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
|
||||
}
|
||||
|
||||
@@ -64,12 +124,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.setSize(m.width, m.contentHeight())
|
||||
m.cards.setSize(m.width, m.contentHeight())
|
||||
m.detail.setSize(m.width, m.contentHeight())
|
||||
m.filter.setHeight(m.contentHeight())
|
||||
return m, nil
|
||||
|
||||
case entitiesLoadedMsg:
|
||||
m.list.setEntities(msg.entities)
|
||||
if m.mode == modeCards {
|
||||
m.cards.setEntities(msg.entities)
|
||||
} else {
|
||||
m.list.setEntities(msg.entities)
|
||||
}
|
||||
m.err = 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())
|
||||
|
||||
case entityPromotedMsg:
|
||||
m.status = "promoted → snippet"
|
||||
return m, m.reloadDetail(msg.id)
|
||||
m.status = fmt.Sprintf("promoted → %s", msg.cardType)
|
||||
m.state = stateList
|
||||
return m, loadEntities(m.store, m.listParams())
|
||||
|
||||
case entityDemotedMsg:
|
||||
m.status = "demoted → fluid"
|
||||
@@ -136,6 +202,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.updateTagFilter(msg)
|
||||
case stateConfirm:
|
||||
return m.updateConfirm(msg)
|
||||
case statePromote:
|
||||
return m.updatePromote(msg)
|
||||
default:
|
||||
return m.updateKeys(msg)
|
||||
}
|
||||
@@ -166,6 +234,39 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.showHelp = true
|
||||
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":
|
||||
if m.state == stateList {
|
||||
m.state = stateInput
|
||||
@@ -187,7 +288,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "enter":
|
||||
if m.state == stateList {
|
||||
if e := m.list.selected(); e != nil {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.detail.setEntity(e)
|
||||
m.state = stateDetail
|
||||
}
|
||||
@@ -196,7 +297,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "d":
|
||||
if m.state == stateList {
|
||||
if e := m.list.selected(); e != nil {
|
||||
if e := m.selectedEntity(); e != nil {
|
||||
m.confirmID = e.ID
|
||||
m.state = stateConfirm
|
||||
return m, confirmTimeout()
|
||||
@@ -206,7 +307,7 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "x":
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -231,12 +332,15 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case "p":
|
||||
if m.state == stateDetail && m.detail.entity != nil {
|
||||
if m.detail.entity.CardType != nil {
|
||||
e := m.selectedEntity()
|
||||
if e != nil {
|
||||
if e.CardType != nil {
|
||||
m.status = "already a card"
|
||||
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
|
||||
|
||||
@@ -265,7 +369,11 @@ func (m model) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
switch m.state {
|
||||
case stateList:
|
||||
m.list = m.list.update(msg)
|
||||
if m.mode == modeCards {
|
||||
m.cards = m.cards.update(msg)
|
||||
} else {
|
||||
m.list = m.list.update(msg)
|
||||
}
|
||||
case stateDetail:
|
||||
m.detail = m.detail.update(msg)
|
||||
}
|
||||
@@ -319,6 +427,20 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
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 {
|
||||
if m.showHelp {
|
||||
return renderHelp(m.width, m.height)
|
||||
@@ -327,21 +449,47 @@ func (m model) View() string {
|
||||
var content string
|
||||
switch m.state {
|
||||
case stateList, stateInput, stateConfirm:
|
||||
content = m.list.view(m.width)
|
||||
if m.mode == modeCards {
|
||||
content = m.cards.view(m.width)
|
||||
} else {
|
||||
content = m.list.view(m.width)
|
||||
}
|
||||
case stateDetail:
|
||||
content = m.detail.view(m.width)
|
||||
case stateTagFilter:
|
||||
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")
|
||||
|
||||
modeName := "stream"
|
||||
if m.mode == modeCards {
|
||||
modeName = "cards"
|
||||
}
|
||||
header += " " + modeStyle.Render(modeName)
|
||||
|
||||
if 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 {
|
||||
@@ -369,13 +517,14 @@ func (m model) contentHeight() int {
|
||||
}
|
||||
|
||||
func (m model) selectedEntity() *db.Entity {
|
||||
switch m.state {
|
||||
case stateList:
|
||||
return m.list.selected()
|
||||
case stateDetail:
|
||||
switch {
|
||||
case m.state == stateDetail:
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
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 != "" {
|
||||
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"
|
||||
case stateConfirm:
|
||||
return "y:confirm n:cancel"
|
||||
case statePromote:
|
||||
return "j/k:nav enter:select esc:cancel"
|
||||
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().
|
||||
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