| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921 |
- // The package defines an extensible TUI via the bubbletea framework.
- //
- // While the package remains in v0.0.X releases, this TUI may be undocumented.
- //
- // TODO enable collection recursing (i.e, embedded collections)
- package issues
- import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "charm.land/bubbles/v2/viewport"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/bubbles/textinput"
- tea "github.com/charmbracelet/bubbletea"
- )
- // Type and Style definitions -------------------------------------------------
- // ----------------------------------------------------------------------------
- // [lipgloss] style definitions, stores the currently displayed "widget"
- var (
- titleStyle = lipgloss.NewStyle().
- Width(80).
- Bold(true).
- Underline(true)
- detailStyle = lipgloss.NewStyle().
- Width(80).
- Faint(true).
- Italic(true)
- variadicMarginStyle = lipgloss.NewStyle().
- Padding(0, 1)
- variadicDataStyle = lipgloss.NewStyle().
- Padding(0, 1).
- BorderForeground(lipgloss.Color("8")).
- Italic(true).
- BorderStyle(lipgloss.ASCIIBorder())
- borderStyle = lipgloss.NewStyle().
- BorderForeground(lipgloss.Color("8")).
- Width(80).
- Padding(1, 2).
- Margin(1).
- BorderStyle(lipgloss.NormalBorder())
- pointerStyle = lipgloss.NewStyle().
- Faint(true)
- collectionStyleLeft = lipgloss.NewStyle().
- Width(80).
- Align(lipgloss.Left)
- )
- // MAIN MODEL DEFINITIONS -----------------------------------------------------
- // ----------------------------------------------------------------------------
- // The main bubbletea Model
- type Model struct {
- widget widget
- content string
- Path string
- viewport viewport.Model
- viewportReady bool
- width int
- }
- // update signal types
- type (
- validateMsg bool
- deleteResult string
- deletePath string
- )
- // The bubbletea init function
- func (m Model) Init() tea.Cmd { return m.load }
- // Core tea.Model update loop
- func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- // general message handling
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- var headerHeight int
- headerHeight = lipgloss.Height(m.header())
- var footerHeight int
- if len(m.content) > 0 {
- footerHeight = lipgloss.Height(m.footer())
- }
- verticalMarginHeight := headerHeight + footerHeight + 5
- if !m.viewportReady {
- m.viewport = viewport.New(
- viewport.WithWidth(msg.Width),
- viewport.WithHeight(msg.Height-verticalMarginHeight),
- )
- m.viewport.YPosition = headerHeight
- m.viewport.SetContent(m.content)
- m.viewportReady = true
- } else {
- m.viewport.SetWidth(msg.Width)
- m.viewport.SetHeight(msg.Height - verticalMarginHeight)
- }
- case tea.KeyMsg: // KeyMsg capture that is always present
- switch msg.String() {
- case "ctrl+c":
- cmds = append(cmds, tea.Quit)
- }
- switch msg.Type {
- case tea.KeyUp:
- m.viewport.ScrollUp(1)
- case tea.KeyDown:
- m.viewport.ScrollDown(1)
- }
- case widget: // widget is initialized from m.load()
- switch T := msg.(type) {
- case edit:
- m.widget = T
- cmds = append(cmds, T.render, T.init())
- default:
- m.widget = T
- cmds = append(cmds, T.render)
- }
- case loadPath:
- m.Path = string(msg)
- return m, m.load
- case setTitleMsg:
- wg := setTitle{Path: string(msg)}
- i := textinput.New()
- i.Placeholder = "a short title"
- i.Focus()
- i.CharLimit = 80
- i.Width = 30
- wg.input = i
- cmds = append(cmds, func() tea.Msg { return wg })
- case deletePath:
- wg := confirmDelete{Path: string(msg), prompt: "Delete", validateString: "yes"}
- i := textinput.New()
- i.Placeholder = "yes"
- i.Focus()
- i.CharLimit = 80
- i.Width = 30
- wg.input = i
- cmds = append(cmds, func() tea.Msg { return wg })
- case string:
- m.content = msg
- m.viewport.SetContent(m.content)
- cmds = append(cmds, tea.EnterAltScreen)
- }
- // finally, pass msg to widget
- var cmd tea.Cmd
- switch w := m.widget.(type) {
- case edit:
- m.widget, cmd = w.update(msg)
- cmds = append(cmds, cmd)
- case Issue:
- m.widget, cmd = w.update(msg)
- cmds = append(cmds, cmd)
- case IssueCollection:
- m.widget, cmd = w.update(msg)
- cmds = append(cmds, cmd)
- case setTitle:
- m.widget, cmd = w.update(msg)
- cmds = append(cmds, cmd)
- case confirmDelete:
- m.widget, cmd = w.update(msg)
- cmds = append(cmds, cmd)
- }
- return m, tea.Batch(cmds...)
- }
- // Handles top level view functionality
- func (m Model) View() string {
- var output string
- if len(m.content) == 0 {
- return "loading..."
- } else {
- output = lipgloss.JoinVertical(lipgloss.Left,
- m.header(),
- m.viewport.View(),
- m.footer(),
- )
- return output
- }
- }
- // Handles load logic
- func (m Model) load() tea.Msg {
- if IsIssue(m.Path) {
- issue, err := Issue{}.NewFromPath(m.Path)
- if err != nil {
- return nil
- }
- return issue
- }
- if IsIssueCollection(m.Path) {
- collection, err := IssueCollection{}.NewFromPath(m.Path)
- if err != nil {
- return nil
- }
- return collection
- }
- return newEditWidget(m.Path)
- }
- // renders a header for the program
- func (m Model) header() string {
- var borderStyle = lipgloss.NewStyle().
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("8")).
- Margin(1, 0, 0, 0).
- Padding(0, 1)
- title := "tissues v0.0"
- title = borderStyle.Render(title)
- line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(title)))
- line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line)
- return lipgloss.JoinHorizontal(lipgloss.Center, line, title)
- }
- // renders a footer for the program
- func (m Model) footer() string {
- footerStyle := lipgloss.NewStyle().Faint(true).
- BorderStyle(lipgloss.NormalBorder()).
- BorderForeground(lipgloss.Color("8")).
- Margin(1, 0, 0, 0).
- Padding(0, 1)
- footerLeft := footerStyle.Render(m.widget.keyhelp())
- footerRight := footerStyle.Render("j/k: scroll\n\nctrl+c: quit")
- line := strings.Repeat("─",
- max(0, m.viewport.Width()-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight)))
- line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line)
- var footer string
- footer = lipgloss.JoinHorizontal(lipgloss.Center,
- footerLeft,
- line,
- footerRight,
- )
- if lipgloss.Width(footer) > m.viewport.Width() {
- footer = lipgloss.JoinVertical(
- lipgloss.Left,
- lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(strings.Repeat("-", max(0, m.viewport.Width()))),
- footerLeft,
- footerRight,
- )
- }
- return footer
- }
- // WIDGET DEFINITIONS ---------------------------------------------------------
- // ----------------------------------------------------------------------------
- // interface definition for widgets
- type widget interface {
- update(tea.Msg) (widget, tea.Cmd) // implements widget specific update life cycles
- render() tea.Msg // renders content
- keyhelp() string // renders key usage
- }
- // -------- edit widget definitions -----------------------------------------
- // ----------------------------------------------------------------------------
- type ( // Type definitions for use in tea.Msg life cycle for create widget.
- createResult Issue // type wrapper for create.create() result
- writeResult any // type wrapper for create.write() result
- editorResult struct { // type wrapper for create.editor() result
- err error // any error returned by InvokeEditor
- issue Issue // the data in the template file
- }
- )
- // data struct for create widget
- type inputField struct {
- input textinput.Model
- title string
- }
- // struct definition for edit widget
- type edit struct {
- inputFields []inputField
- Path string
- selected int
- existing bool // flag to edit existing description
- err error // not implemented
- }
- // constructor for the edit widget
- func newEditWidget(path string) widget {
- // data prep
- var e edit
- var issue Issue
- var err error
- if IsIssue(path) { // if path is existing issue, load Issue from path
- issue, err = Issue{}.NewFromPath(path)
- if err != nil {
- return e
- }
- e.existing = true
- } else { // if path is not existing issue, create new Issue with sensible defaults
- issue = Issue{
- Path: path, Title: parsePathToHuman(path),
- Status: Field{Path: "/status", Data: "open"},
- }
- }
- // anon function for spawning instantiated textinput.Model's
- spawnInput := func(f bool) textinput.Model {
- ti := textinput.New()
- if f {
- ti.Focus()
- }
- ti.CharLimit = 80
- ti.Width = 30
- return ti
- }
- // inputFields data prep
- var fields []inputField
- var input textinput.Model
- // title
- input = spawnInput(true)
- input.SetValue(issue.Title)
- input.Placeholder = "title"
- fields = append(fields, inputField{input: input, title: "title"})
- // status
- input = spawnInput(false)
- input.SetValue(issue.Status.Data)
- input.Placeholder = "status"
- fields = append(fields, inputField{input: input, title: "status"})
- // tags
- input = spawnInput(false)
- input.SetValue(issue.Tags.AsString())
- input.Placeholder = "tags, separated by comma"
- fields = append(fields, inputField{input: input, title: "tags"})
- // blockedby
- input = spawnInput(false)
- input.SetValue(issue.Blockedby.AsString())
- input.Placeholder = "blockers, separated by comma"
- fields = append(fields, inputField{input: input, title: "blockers"})
- e.inputFields = fields
- e.Path = path
- return e
- }
- // init cmd for create widget
- func (e edit) init() tea.Cmd { return textinput.Blink }
- // update cmd for create widget
- func (e edit) update(msg tea.Msg) (widget, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
- // simple anon functions to increment the selected index
- incrementSelected := func() {
- if e.selected < len(e.inputFields) {
- e.selected++
- for i := 0; i < len(e.inputFields); i++ {
- if i == e.selected {
- e.inputFields[i].input.Focus()
- } else {
- e.inputFields[i].input.Blur()
- }
- }
- } else {
- e.selected = 0
- e.inputFields[e.selected].input.Focus()
- }
- }
- decrementSelected := func() {
- if e.selected != 0 {
- e.selected--
- for i := 0; i < len(e.inputFields); i++ {
- if i == e.selected {
- e.inputFields[i].input.Focus()
- } else {
- e.inputFields[i].input.Blur()
- }
- }
- } else {
- for i := 0; i < len(e.inputFields); i++ {
- e.inputFields[i].input.Blur()
- }
- e.selected = len(e.inputFields)
- }
- }
- switch msg := msg.(type) { // keybinding handler
- case tea.KeyMsg:
- switch msg.String() {
- case "tab":
- incrementSelected()
- case "shift+tab":
- decrementSelected()
- case "enter":
- if e.selected == len(e.inputFields) { // confirm create
- e.selected++
- } else if e.selected == len(e.inputFields)+1 { // confirmed
- cmds = append(cmds, e.createIssueObject)
- } else {
- incrementSelected()
- }
- case "esc": // reset
- for i, field := range e.inputFields {
- field.input.Reset()
- switch field.title {
- case "title":
- parsed := parsePathToHuman(e.Path)
- if parsed == "." {
- parsed = ""
- }
- field.input.SetValue(parsed)
- case "status":
- field.input.SetValue("open")
- }
- e.inputFields[i] = field
- }
- }
- cmds = append(cmds, e.render)
- case createResult:
- if !e.existing {
- cmds = append(cmds, tea.ExitAltScreen, e.editBlankDescription(Issue(msg)))
- } else {
- cmds = append(cmds, tea.ExitAltScreen, e.editExistingDescription(Issue(msg)))
- }
- case editorResult:
- if msg.err != nil {
- e.err = msg.err
- } else {
- cmds = append(cmds, e.write(msg.issue))
- }
- case writeResult:
- switch value := msg.(type) {
- case bool:
- if !value {
- } else {
- cmds = append(cmds, func() tea.Msg { return loadPath(e.Path) })
- }
- case error:
- e.selected = -100
- e.err = value
- }
- }
- for i, ti := range e.inputFields {
- e.inputFields[i].input, cmd = ti.input.Update(msg)
- cmds = append(cmds, cmd)
- }
- cmds = append(cmds, cmd)
- return e, tea.Batch(cmds...)
- }
- // render cmd for create widget
- func (e edit) render() tea.Msg {
- if e.err != nil {
- return fmt.Sprintf("failed to create issue... %s", e.err.Error())
- }
- borderStyle := lipgloss.NewStyle().
- BorderForeground(lipgloss.Color("8")).
- BorderStyle(lipgloss.NormalBorder()).
- Margin(1).
- Padding(0, 1)
- ulStyle := lipgloss.NewStyle().Underline(true)
- var output string
- for _, field := range e.inputFields {
- output = output + fmt.Sprintf(
- "\n%s:%s",
- field.title,
- borderStyle.Render(field.input.View()),
- )
- }
- output = strings.TrimLeft(output, "\n")
- if e.selected < len(e.inputFields) {
- output = output + borderStyle.Render("press enter to submit...")
- } else if e.selected == len(e.inputFields) {
- output = output + borderStyle.Render(ulStyle.Render("press enter to submit..."))
- } else if e.selected == len(e.inputFields)+1 {
- confirmPrompt := fmt.Sprintf(
- "create issue titled \"%s\"?\n\n%s",
- ulStyle.Render(e.inputFields[0].input.Value()),
- ulStyle.Render("press enter to write description..."),
- )
- output = output + borderStyle.Render(confirmPrompt)
- }
- return output
- }
- // keyhelp cmd for create widget
- func (e edit) keyhelp() string {
- var output string
- output = output + "tab/shift+tab: down/up\t\tenter: input value\n\nesc: reset\t\tctrl+c: quit"
- return output
- }
- // A tea.Cmd to translate createIssueObject.inputs to a new Issue object
- func (e edit) createIssueObject() tea.Msg {
- data := make(map[string]string)
- commaSplit := func(t string) []string {
- s := strings.Split(t, ",")
- for i, v := range s {
- s[i] = strings.TrimLeft(v, " \t\n")
- s[i] = strings.TrimRight(s[i], " \t\n")
- s[i] = parseHumanToPath(s[i])
- }
- return s
- }
- for _, field := range e.inputFields {
- data[field.title] = field.input.Value()
- }
- var newIssue = Issue{
- Path: e.Path,
- Tags: VariadicField{Path: "/tags"},
- Blockedby: VariadicField{Path: "/blockedby"},
- }
- for key, value := range data {
- switch key {
- case "title":
- newIssue.Title = value
- if parsePathToHuman(newIssue.Path) != value {
- dir, _ := filepath.Split(newIssue.Path)
- newIssue.Path = filepath.Join(dir, parseHumanToPath(value))
- }
- case "status":
- newIssue.Status = Field{Path: "/status", Data: value}
- case "description":
- newIssue.Description = Field{Path: "/description", Data: value}
- case "tags":
- splitTags := commaSplit(value)
- for _, tag := range splitTags {
- newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag})
- }
- case "blockers":
- splitBlockedby := commaSplit(value)
- for _, blocker := range splitBlockedby {
- newIssue.Blockedby.Fields = append(
- newIssue.Blockedby.Fields, Field{Path: blocker},
- )
- }
- }
- }
- return createResult(newIssue)
- }
- // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue()
- func (e edit) write(issue Issue) tea.Cmd {
- return func() tea.Msg {
- if e.existing {
- err := issue.Tags.CleanDisk(issue, false)
- if err != nil {
- return writeResult(err)
- }
- err = issue.Blockedby.CleanDisk(issue, false)
- if err != nil {
- return writeResult(err)
- }
- }
- result, err := WriteIssue(issue, true)
- if err != nil {
- return writeResult(err)
- }
- return writeResult(result)
- }
- }
- // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output
- // and errors
- //
- // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR!
- func (e edit) editBlankDescription(issue Issue) tea.Cmd {
- data, err := EditTemplate(DescriptionTemplate, InvokeEditor)
- var output string
- for _, line := range data {
- output = output + line
- }
- issue.Description = Field{Path: "/description", Data: output}
- return func() tea.Msg {
- return editorResult{issue: issue, err: err}
- }
- }
- // Calls InvokeEditor with e.Path, reports output via ReadTemplate
- func (e edit) editExistingDescription(issue Issue) tea.Cmd {
- err := InvokeEditor(filepath.Join(e.Path, "description"))
- if err != nil {
- return func() tea.Msg { return editorResult{issue: issue, err: err} }
- }
- data, err := ReadTemplate(filepath.Join(e.Path, "description"))
- var output string
- for _, line := range data {
- output = output + line
- }
- issue.Description = Field{Path: "/description", Data: output}
- return func() tea.Msg { return editorResult{issue: issue, err: err} }
- }
- // -------- Issue widget definitions ------------------------------------------
- // ----------------------------------------------------------------------------
- // enforce widget interface compliance
- func (i Issue) update(msg tea.Msg) (widget, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "e":
- cmds = append(cmds, i.edit)
- case "esc":
- cmds = append(cmds, i.back)
- case "d":
- return i, func() tea.Msg { return deletePath(i.Path) }
- }
- }
- return i, tea.Batch(cmds...)
- }
- // render cmd for Issue widget
- func (i Issue) render() tea.Msg {
- var output string
- // title
- output = output + titleStyle.Render(i.Title)
- // status
- output = output + fmt.Sprintf("\n%s\n", detailStyle.Render(i.Status.Data))
- // variadics
- var tags string
- for _, field := range i.Tags.Fields {
- tags = tags + parsePathToHuman(field.Path) + ", "
- }
- tags = strings.TrimRight(tags, ", ")
- var blockedby string
- for _, field := range i.Blockedby.Fields {
- blockedby = blockedby + parsePathToHuman(field.Path) + ", "
- }
- blockedby = strings.TrimRight(blockedby, ", ")
- var tagsString string // placeholder for variadic styling
- if len(i.Tags.Fields) > 0 {
- tagsString = tagsString + "\nTags:\n"
- tagsString = tagsString + variadicDataStyle.Render(tags)
- tagsString = variadicMarginStyle.Render(tagsString)
- }
- output = output + tagsString
- var blockersString string // placeholder for variadic styling
- if len(i.Blockedby.Fields) > 0 {
- blockersString = blockersString + "\nBlockedby:\n"
- blockersString = blockersString + variadicDataStyle.Render(blockedby)
- blockersString = variadicMarginStyle.Render(blockersString)
- }
- output = output + blockersString
- // description
- output = output + titleStyle.Render("\n\nDescription:\n")
- output = output + fmt.Sprintf("\n%s", i.Description.Data)
- return lipgloss.Wrap(output, 80, "\n")
- }
- // keyhelp cmd for Issue widget
- func (i Issue) keyhelp() string {
- var output string
- var escStr string
- remainder, _ := filepath.Split(i.Path)
- if IsIssueCollection(remainder) {
- escStr = "esc: back\t\t"
- }
- output = output + fmt.Sprintf("e: edit\t\td: delete\n\n%sctrl+c: quit", escStr)
- return output
- }
- func (i Issue) edit() tea.Msg { return newEditWidget(i.Path) }
- func (i Issue) back() tea.Msg {
- remainder, _ := filepath.Split(i.Path)
- remainder = strings.TrimRight(remainder, "/")
- if len(remainder) == 0 {
- return nil
- }
- return loadPath(remainder)
- }
- // -------- IssueCollection widget definitions --------------------------------
- // ----------------------------------------------------------------------------
- type ( // Type definitions for use in tea.Msg life cycle for IssueCollection widget.
- loadPath string // thrown when user selects a path to load.
- setTitleMsg string //thrown when user opts to create new issue in collection
- )
- // enforce widget interface compliance
- func (ic IssueCollection) update(msg tea.Msg) (widget, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "tab":
- if ic.selection+1 < len(ic.Collection) {
- ic.selection = ic.selection + 1
- } else {
- ic.selection = 0
- }
- return ic, ic.render
- case "shift+tab":
- if ic.selection != 0 {
- ic.selection = ic.selection - 1
- } else {
- ic.selection = len(ic.Collection) - 1
- }
- return ic, ic.render
- case "enter":
- ic.Path = ic.Collection[ic.selection].Path
- return ic, ic.sendLoad
- case "c":
- return ic, func() tea.Msg { return setTitleMsg(ic.Path) }
- case "e":
- ic.Path = ic.Collection[ic.selection].Path
- return ic, ic.edit
- case "d":
- ic.Path = ic.Collection[ic.selection].Path
- return ic, func() tea.Msg { return deletePath(ic.Path) }
- }
- }
- return ic, nil
- }
- // render cmd for IssueCollection widget
- func (ic IssueCollection) render() tea.Msg {
- var output string
- var left string
- output = output + "\nIssues in " + ic.Path + "...\n\n"
- for i, issue := range ic.Collection {
- // pointer render
- if i == ic.selection {
- left = left + pointerStyle.Render("-> ")
- } else {
- left = left + pointerStyle.Render("•• ")
- }
- // index render
- // left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
- // title render
- left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
- left = left + detailStyle.Render(fmt.Sprintf(" %s: %s", "tags",
- lipgloss.NewStyle().Italic(true).Render(issue.Tags.AsString())))
- left = left + detailStyle.Render(fmt.Sprintf("\n %s: %s", "blockers",
- lipgloss.NewStyle().Italic(true).Render(issue.Blockedby.AsString())))
- }
- output = output + collectionStyleLeft.Render(left)
- return output
- }
- // keyhelp cmd for IssueCollection widget
- func (ic IssueCollection) keyhelp() string {
- var output string
- output = output + "tab/shift+tab: select\t\tenter: view\nc: create\t\td: delete\ne: edit"
- return output
- }
- func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) }
- func (ic IssueCollection) edit() tea.Msg { return newEditWidget(ic.Path) }
- // -------- setTitle widget definitions ---------------------------------------
- // ----------------------------------------------------------------------------
- type setTitle struct {
- Path string // base path for the new issue
- name string
- input textinput.Model // the input widget
- }
- func (w setTitle) update(msg tea.Msg) (widget, tea.Cmd) {
- var cmds []tea.Cmd
- i, cmd := w.input.Update(msg)
- w.input = i
- w.name = i.Value()
- cmds = append(cmds, cmd)
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- cmds = append(cmds, w.create)
- }
- cmds = append(cmds, w.render)
- }
- return w, tea.Batch(cmds...)
- }
- func (w setTitle) render() tea.Msg {
- var header string
- if len(w.Path) == 0 {
- header = "Setting title for issue..."
- } else {
- header = fmt.Sprintf("Setting title for issue in %s", w.Path)
- }
- return borderStyle.Render(fmt.Sprintf("%s\ntitle: %s", header, w.input.View()))
- }
- func (w setTitle) keyhelp() string { return "enter: submit" }
- func (w setTitle) create() tea.Msg {
- w.Path = filepath.Join(w.Path, parseHumanToPath(w.name))
- return newEditWidget(w.Path)
- }
- // -------- confirmDelete widget definitions ----------------------------------
- // ----------------------------------------------------------------------------
- type confirmDelete struct {
- Path string
- input textinput.Model
- prompt string
- validateString string
- }
- func (w confirmDelete) update(msg tea.Msg) (widget, tea.Cmd) {
- var cmds []tea.Cmd
- var cmd tea.Cmd
- w.input, cmd = w.input.Update(msg)
- cmds = append(cmds, cmd)
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "enter":
- cmds = append(cmds, w.validate)
- case "esc":
- cmds = append(cmds, w.back)
- }
- cmds = append(cmds, w.render)
- case validateMsg:
- switch msg {
- case true:
- cmds = append(cmds, w.deletePath)
- case false:
- cmds = append(cmds, w.back)
- }
- case deleteResult:
- cmds = append(cmds, w.back)
- }
- return w, tea.Batch(cmds...)
- }
- func (w confirmDelete) render() tea.Msg {
- prompt := fmt.Sprintf("%s (%s)...\n", w.prompt, w.Path)
- return fmt.Sprintf("%s%s", prompt, w.input.View())
- }
- func (w confirmDelete) keyhelp() string {
- return "enter: submit\t\tesc: cancel"
- }
- func (w confirmDelete) validate() tea.Msg {
- if w.input.Value() == w.validateString {
- return validateMsg(true)
- }
- return validateMsg(false)
- }
- func (w confirmDelete) deletePath() tea.Msg {
- if err := os.RemoveAll(w.Path); err != nil {
- return deleteResult(err.Error())
- }
- return deleteResult("")
- }
- func (w confirmDelete) back() tea.Msg {
- remainder, _ := filepath.Split(w.Path)
- remainder = strings.TrimRight(remainder, "/")
- if len(remainder) == 0 {
- return nil
- }
- return loadPath(remainder)
- }
|