tui.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. // The package defines an extensible TUI via the bubbletea framework.
  2. //
  3. // While the package remains in v0.0.X releases, this TUI may be undocumented.
  4. //
  5. // TODO enable collection recursing (i.e, embedded collections)
  6. //
  7. // TODO enable scroll/viewport logic
  8. package issues
  9. import (
  10. "fmt"
  11. "path/filepath"
  12. "strings"
  13. "github.com/charmbracelet/bubbles/textinput"
  14. tea "github.com/charmbracelet/bubbletea"
  15. "github.com/charmbracelet/lipgloss"
  16. )
  17. // Type and Style definitions -------------------------------------------------
  18. // ----------------------------------------------------------------------------
  19. // [lipgloss] style definitions, stores the currently displayed "widget"
  20. var (
  21. titleStyle = lipgloss.NewStyle().
  22. Width(80).
  23. Bold(true).
  24. Underline(true)
  25. detailStyle = lipgloss.NewStyle().
  26. Width(80).
  27. Faint(true).
  28. Italic(true)
  29. variadicTitleStyle = lipgloss.NewStyle().
  30. Width(80).
  31. Align(lipgloss.Left).
  32. Italic(true)
  33. variadicDataStyle = lipgloss.NewStyle().
  34. Width(40).
  35. BorderStyle(lipgloss.ASCIIBorder())
  36. borderStyle = lipgloss.NewStyle().
  37. Width(80).
  38. Padding(1, 2).
  39. Margin(1).
  40. BorderStyle(lipgloss.NormalBorder())
  41. indexStyle = lipgloss.NewStyle().
  42. Italic(true)
  43. pointerStyle = lipgloss.NewStyle().
  44. Faint(true)
  45. collectionStyleLeft = lipgloss.NewStyle().
  46. Width(80).
  47. Align(lipgloss.Left)
  48. )
  49. // MAIN MODEL DEFINITIONS -----------------------------------------------------
  50. // ----------------------------------------------------------------------------
  51. // The main bubbletea Model
  52. type Model struct {
  53. widget widget
  54. content string
  55. Path string
  56. // viewport viewport.Model
  57. }
  58. // The bubbletea init function
  59. func (m Model) Init() tea.Cmd { return m.load }
  60. // Core tea.Model update loop
  61. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  62. var cmds []tea.Cmd
  63. // general message handling
  64. switch msg := msg.(type) {
  65. case tea.KeyMsg: // KeyMsg capture that is always present
  66. switch msg.String() {
  67. case "ctrl+c":
  68. cmds = append(cmds, tea.Quit)
  69. }
  70. case widget: // widget is initialized from m.load()
  71. switch T := msg.(type) {
  72. case edit:
  73. m.widget = T
  74. cmds = append(cmds, T.render, T.init())
  75. default:
  76. m.widget = T
  77. cmds = append(cmds, T.render)
  78. }
  79. case loadPath:
  80. m.Path = string(msg)
  81. return m, m.load
  82. case createInCollectionMsg:
  83. wg := createInCollection{Path: string(msg)}
  84. i := textinput.New()
  85. i.Placeholder = "a short title"
  86. i.Focus()
  87. i.CharLimit = 80
  88. i.Width = 30
  89. wg.input = i
  90. return m, func() tea.Msg { return wg }
  91. case string:
  92. m.content = msg
  93. }
  94. // finally, pass msg to widget
  95. var cmd tea.Cmd
  96. switch w := m.widget.(type) {
  97. case edit:
  98. m.widget, cmd = w.update(msg)
  99. cmds = append(cmds, cmd)
  100. case Issue:
  101. m.widget, cmd = w.update(msg)
  102. cmds = append(cmds, cmd)
  103. case IssueCollection:
  104. m.widget, cmd = w.update(msg)
  105. cmds = append(cmds, cmd)
  106. case createInCollection:
  107. m.widget, cmd = w.update(msg)
  108. cmds = append(cmds, cmd)
  109. }
  110. return m, tea.Batch(cmds...)
  111. }
  112. // Handles top level view functionality
  113. func (m Model) View() string {
  114. var output string
  115. if len(m.content) == 0 {
  116. return "loading..."
  117. }
  118. output = output + m.content
  119. output = output + lipgloss.NewStyle().Faint(true).Margin(1).Render(m.widget.keyhelp())
  120. return output
  121. }
  122. // Handles load logic
  123. func (m Model) load() tea.Msg {
  124. if IsIssue(m.Path) {
  125. issue, err := Issue{}.NewFromPath(m.Path)
  126. if err != nil {
  127. return nil
  128. }
  129. return issue
  130. }
  131. if IsIssueCollection(m.Path) {
  132. collection, err := IssueCollection{}.NewFromPath(m.Path)
  133. if err != nil {
  134. return nil
  135. }
  136. return collection
  137. }
  138. return newEditWidget(m.Path)
  139. }
  140. // WIDGET DEFINITIONS ---------------------------------------------------------
  141. // ----------------------------------------------------------------------------
  142. // interface definition for widgets
  143. type widget interface {
  144. update(tea.Msg) (widget, tea.Cmd) // implements widget specific update life cycles
  145. render() tea.Msg // renders content
  146. keyhelp() string // renders key usage
  147. }
  148. // -------- edit widget definitions -----------------------------------------
  149. // ----------------------------------------------------------------------------
  150. // TODO(create widget) implement description field in create.create
  151. type ( // Type definitions for use in tea.Msg life cycle for create widget.
  152. createResult Issue // type wrapper for create.create() result
  153. writeResult any // type wrapper for create.write() result
  154. editorResult struct { // type wrapper for create.editor() result
  155. err error // any error returned by InvokeEditor
  156. issue Issue // the data in the template file
  157. }
  158. )
  159. // data struct for create widget
  160. type inputField struct {
  161. input textinput.Model
  162. title string
  163. }
  164. // struct definition for edit widget
  165. type edit struct {
  166. inputFields []inputField
  167. Path string
  168. selected int
  169. existing bool // flag to edit existing description
  170. err error // not implemented
  171. }
  172. // constructor for the edit widget
  173. func newEditWidget(path string) widget {
  174. // data prep
  175. var e edit
  176. var issue Issue
  177. var err error
  178. if IsIssue(path) { // if path is existing issue, load Issue from path
  179. issue, err = Issue{}.NewFromPath(path)
  180. if err != nil {
  181. return e
  182. }
  183. e.existing = true
  184. } else { // if path is not existing issue, create new Issue with sensible defaults
  185. issue = Issue{
  186. Path: path, Title: parsePathToHuman(path),
  187. Status: Field{Path: "/status", Data: "open"},
  188. }
  189. }
  190. // anon function for spawning instantiated textinput.Model's
  191. spawnInput := func(f bool) textinput.Model {
  192. ti := textinput.New()
  193. if f {
  194. ti.Focus()
  195. }
  196. ti.CharLimit = 80
  197. ti.Width = 30
  198. return ti
  199. }
  200. // inputFields data prep
  201. var fields []inputField
  202. var input textinput.Model
  203. // title
  204. input = spawnInput(true)
  205. input.SetValue(issue.Title)
  206. input.Placeholder = "title"
  207. fields = append(fields, inputField{input: input, title: "title"})
  208. // status
  209. input = spawnInput(false)
  210. input.SetValue(issue.Status.Data)
  211. input.Placeholder = "status"
  212. fields = append(fields, inputField{input: input, title: "status"})
  213. // tags
  214. input = spawnInput(false)
  215. input.SetValue(issue.Tags.AsString())
  216. input.Placeholder = "tags, separated by comma"
  217. fields = append(fields, inputField{input: input, title: "tags"})
  218. // blockedby
  219. input = spawnInput(false)
  220. input.SetValue(issue.Blockedby.AsString())
  221. input.Placeholder = "blockers, separated by comma"
  222. fields = append(fields, inputField{input: input, title: "blockers"})
  223. e.inputFields = fields
  224. e.Path = path
  225. return e
  226. }
  227. // init cmd for create widget
  228. func (e edit) init() tea.Cmd { return textinput.Blink }
  229. // update cmd for create widget
  230. func (e edit) update(msg tea.Msg) (widget, tea.Cmd) {
  231. var cmds []tea.Cmd
  232. var cmd tea.Cmd
  233. // simple anon functions to increment the selected index
  234. incrementSelected := func() {
  235. if e.selected < len(e.inputFields) {
  236. e.selected++
  237. for i := 0; i < len(e.inputFields); i++ {
  238. if i == e.selected {
  239. e.inputFields[i].input.Focus()
  240. } else {
  241. e.inputFields[i].input.Blur()
  242. }
  243. }
  244. } else {
  245. e.selected = 0
  246. e.inputFields[e.selected].input.Focus()
  247. }
  248. }
  249. decrementSelected := func() {
  250. if e.selected != 0 {
  251. e.selected--
  252. for i := 0; i < len(e.inputFields); i++ {
  253. if i == e.selected {
  254. e.inputFields[i].input.Focus()
  255. } else {
  256. e.inputFields[i].input.Blur()
  257. }
  258. }
  259. } else {
  260. for i := 0; i < len(e.inputFields); i++ {
  261. e.inputFields[i].input.Blur()
  262. }
  263. e.selected = len(e.inputFields)
  264. }
  265. }
  266. switch msg := msg.(type) { // keybinding handler
  267. case tea.KeyMsg:
  268. switch msg.String() {
  269. case "tab":
  270. incrementSelected()
  271. case "shift+tab":
  272. decrementSelected()
  273. case "enter":
  274. if e.selected == len(e.inputFields) { // confirm create
  275. e.selected++
  276. } else if e.selected == len(e.inputFields)+1 { // confirmed
  277. cmds = append(cmds, e.createIssueObject)
  278. } else {
  279. incrementSelected()
  280. }
  281. case "esc": // reset
  282. for i, field := range e.inputFields {
  283. field.input.Reset()
  284. switch field.title {
  285. case "title":
  286. parsed := parsePathToHuman(e.Path)
  287. if parsed == "." {
  288. parsed = ""
  289. }
  290. field.input.SetValue(parsed)
  291. case "status":
  292. field.input.SetValue("open")
  293. }
  294. e.inputFields[i] = field
  295. }
  296. }
  297. cmds = append(cmds, e.render)
  298. case createResult:
  299. if !e.existing {
  300. cmds = append(cmds, e.editBlankDescription(Issue(msg)))
  301. } else {
  302. cmds = append(cmds, e.editExistingDescription(Issue(msg)))
  303. }
  304. case editorResult:
  305. if msg.err != nil {
  306. e.err = msg.err
  307. } else {
  308. cmds = append(cmds, e.write(msg.issue))
  309. }
  310. case writeResult:
  311. switch value := msg.(type) {
  312. case bool:
  313. if !value {
  314. } else {
  315. cmds = append(cmds, func() tea.Msg { return loadPath(e.Path) })
  316. }
  317. case error:
  318. e.selected = -100
  319. e.err = value
  320. }
  321. }
  322. for i, ti := range e.inputFields {
  323. e.inputFields[i].input, cmd = ti.input.Update(msg)
  324. cmds = append(cmds, cmd)
  325. }
  326. cmds = append(cmds, cmd)
  327. return e, tea.Batch(cmds...)
  328. }
  329. // A tea.Cmd to translate createIssueObject.inputs to a new Issue object
  330. func (e edit) createIssueObject() tea.Msg {
  331. data := make(map[string]string)
  332. commaSplit := func(t string) []string {
  333. s := strings.Split(t, ",")
  334. for i, v := range s {
  335. s[i] = strings.TrimLeft(v, " \t\n")
  336. s[i] = strings.TrimRight(s[i], " \t\n")
  337. s[i] = parseHumanToPath(s[i])
  338. }
  339. return s
  340. }
  341. for _, field := range e.inputFields {
  342. data[field.title] = field.input.Value()
  343. }
  344. var newIssue = Issue{
  345. Path: e.Path,
  346. Tags: VariadicField{Path: "/tags"},
  347. Blockedby: VariadicField{Path: "/blockedby"},
  348. }
  349. for key, value := range data {
  350. switch key {
  351. case "title":
  352. newIssue.Title = value
  353. if parsePathToHuman(newIssue.Path) != value {
  354. dir, _ := filepath.Split(newIssue.Path)
  355. newIssue.Path = filepath.Join(dir, parseHumanToPath(value))
  356. }
  357. case "status":
  358. newIssue.Status = Field{Path: "/status", Data: value}
  359. case "description":
  360. newIssue.Description = Field{Path: "/description", Data: value}
  361. case "tags":
  362. splitTags := commaSplit(value)
  363. for _, tag := range splitTags {
  364. newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag})
  365. }
  366. case "blockers":
  367. splitBlockedby := commaSplit(value)
  368. for _, blocker := range splitBlockedby {
  369. newIssue.Blockedby.Fields = append(
  370. newIssue.Blockedby.Fields, Field{Path: blocker},
  371. )
  372. }
  373. }
  374. }
  375. return createResult(newIssue)
  376. }
  377. // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue()
  378. func (e edit) write(issue Issue) tea.Cmd {
  379. return func() tea.Msg {
  380. result, err := WriteIssue(issue, true)
  381. if err != nil {
  382. return writeResult(err)
  383. }
  384. return writeResult(result)
  385. }
  386. }
  387. // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output
  388. // and errors
  389. //
  390. // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR!
  391. func (e edit) editBlankDescription(issue Issue) tea.Cmd {
  392. data, err := EditTemplate(DescriptionTemplate, InvokeEditor)
  393. var output string
  394. for _, line := range data {
  395. output = output + line
  396. }
  397. issue.Description = Field{Path: "/description", Data: output}
  398. return func() tea.Msg {
  399. return editorResult{issue: issue, err: err}
  400. }
  401. }
  402. // Calls InvokeEditor with e.Path, reports output via ReadTemplate
  403. func (e edit) editExistingDescription(issue Issue) tea.Cmd {
  404. err := InvokeEditor(filepath.Join(e.Path, "description"))
  405. if err != nil {
  406. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  407. }
  408. data, err := ReadTemplate(filepath.Join(e.Path, "description"))
  409. var output string
  410. for _, line := range data {
  411. output = output + line
  412. }
  413. issue.Description = Field{Path: "/description", Data: output}
  414. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  415. }
  416. // render cmd for create widget
  417. func (e edit) render() tea.Msg {
  418. if e.err != nil {
  419. return fmt.Sprintf("failed to create issue... %s", e.err.Error())
  420. }
  421. borderStyle := lipgloss.NewStyle().
  422. BorderStyle(lipgloss.NormalBorder()).
  423. Margin(1).
  424. Padding(0, 1)
  425. ulStyle := lipgloss.NewStyle().Underline(true)
  426. var output string
  427. for _, field := range e.inputFields {
  428. output = output + fmt.Sprintf(
  429. "\n%s:%s",
  430. field.title,
  431. borderStyle.Render(field.input.View()),
  432. )
  433. }
  434. output = strings.TrimLeft(output, "\n")
  435. if e.selected < len(e.inputFields) {
  436. output = output + borderStyle.Render("press enter to submit...")
  437. } else if e.selected == len(e.inputFields) {
  438. output = output + borderStyle.Render(ulStyle.Render("press enter to submit..."))
  439. } else if e.selected == len(e.inputFields)+1 {
  440. confirmPrompt := fmt.Sprintf(
  441. "create issue titled \"%s\"?\n\n%s",
  442. ulStyle.Render(e.inputFields[0].input.Value()),
  443. ulStyle.Render("press enter to write description..."),
  444. )
  445. output = output + borderStyle.Render(confirmPrompt)
  446. }
  447. return output
  448. }
  449. // keyhelp cmd for create widget
  450. func (e edit) keyhelp() string {
  451. var output string
  452. output = output + "\ntab/shift+tab: down/up\t\tenter: input value\t\tesc: reset\t\tctrl+e: quit"
  453. return output
  454. }
  455. // -------- Issue widget definitions ------------------------------------------
  456. // ----------------------------------------------------------------------------
  457. // enforce widget interface compliance
  458. func (i Issue) update(msg tea.Msg) (widget, tea.Cmd) {
  459. var cmds []tea.Cmd
  460. switch msg := msg.(type) {
  461. case tea.KeyMsg:
  462. switch msg.String() {
  463. case "e":
  464. cmds = append(cmds, i.edit)
  465. case "esc":
  466. cmds = append(cmds, i.back)
  467. }
  468. }
  469. return i, tea.Batch(cmds...)
  470. }
  471. func (i Issue) edit() tea.Msg { return newEditWidget(i.Path) }
  472. func (i Issue) back() tea.Msg {
  473. remainder, _ := filepath.Split(i.Path)
  474. remainder = strings.TrimRight(remainder, "/")
  475. if len(remainder) == 0 {
  476. return nil
  477. }
  478. return loadPath(remainder)
  479. }
  480. // render cmd for Issue widget
  481. func (i Issue) render() tea.Msg {
  482. var output string
  483. // title
  484. output = output + titleStyle.Render(i.Title)
  485. // status
  486. output = output + fmt.Sprintf("\n%s", detailStyle.Render(i.Status.Data))
  487. // variadics
  488. var tags string
  489. for _, field := range i.Tags.Fields {
  490. tags = tags + parsePathToHuman(field.Path) + ", "
  491. }
  492. tags = strings.TrimRight(tags, ", ")
  493. var blockedby string
  494. for _, field := range i.Blockedby.Fields {
  495. blockedby = blockedby + parsePathToHuman(field.Path) + ", "
  496. }
  497. blockedby = strings.TrimRight(blockedby, ", ")
  498. if len(i.Tags.Fields) > 0 {
  499. output = output + variadicTitleStyle.Render("\n\nTags:")
  500. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(tags))
  501. }
  502. if len(i.Blockedby.Fields) > 0 {
  503. output = output + variadicTitleStyle.Render("\n\nBlockedby:")
  504. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(blockedby))
  505. }
  506. // description
  507. output = output + titleStyle.Render("\n\nDescription:\n")
  508. output = output + fmt.Sprintf("\n%s", i.Description.Data)
  509. return borderStyle.Render(output)
  510. }
  511. // keyhelp cmd for Issue widget
  512. func (i Issue) keyhelp() string {
  513. var output string
  514. var escStr string
  515. remainder, _ := filepath.Split(i.Path)
  516. if IsIssueCollection(remainder) {
  517. escStr = "\t\tesc: back"
  518. }
  519. output = output + fmt.Sprintf("\ne: edit issue%s\t\tctrl+c: quit", escStr)
  520. return output
  521. }
  522. // -------- IssueCollection widget definitions --------------------------------
  523. // ----------------------------------------------------------------------------
  524. type ( // Type definitions for use in tea.Msg life cycle for IssueCollection widget.
  525. loadPath string // thrown when user selects a path to load.
  526. createInCollectionMsg string //thrown when user opts to create new issue in collection
  527. )
  528. // enforce widget interface compliance
  529. func (ic IssueCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  530. switch msg := msg.(type) {
  531. case tea.KeyMsg:
  532. switch msg.String() {
  533. case "j":
  534. if ic.selection+1 < len(ic.Collection) {
  535. ic.selection = ic.selection + 1
  536. } else {
  537. ic.selection = 0
  538. }
  539. return ic, ic.render
  540. case "k":
  541. if ic.selection != 0 {
  542. ic.selection = ic.selection - 1
  543. } else {
  544. ic.selection = len(ic.Collection) - 1
  545. }
  546. return ic, ic.render
  547. case "enter":
  548. ic.Path = ic.Collection[ic.selection].Path
  549. return ic, ic.sendLoad
  550. case "q":
  551. return ic, tea.Quit
  552. case "c":
  553. return ic, ic.newIssueInCollection
  554. case "e":
  555. ic.Path = ic.Collection[ic.selection].Path
  556. return ic, ic.edit
  557. }
  558. }
  559. return ic, nil
  560. }
  561. func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) }
  562. func (ic IssueCollection) newIssueInCollection() tea.Msg { return createInCollectionMsg(ic.Path) }
  563. func (ic IssueCollection) edit() tea.Msg { return newEditWidget(ic.Path) }
  564. // render cmd for IssueCollection widget
  565. func (ic IssueCollection) render() tea.Msg {
  566. var output string
  567. var left string
  568. output = output + "Issues in " + ic.Path + "...\n\n"
  569. for i, issue := range ic.Collection {
  570. // pointer render
  571. if i == ic.selection {
  572. left = left + pointerStyle.Render("-> ")
  573. } else {
  574. left = left + pointerStyle.Render("•• ")
  575. }
  576. // index render
  577. // left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
  578. // title render
  579. left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
  580. left = left + detailStyle.Render(fmt.Sprintf(" %s: %s", "tags",
  581. lipgloss.NewStyle().Italic(true).Render(issue.Tags.AsString())))
  582. left = left + detailStyle.Render(fmt.Sprintf("\n %s: %s", "blockers",
  583. lipgloss.NewStyle().Italic(true).Render(issue.Blockedby.AsString())))
  584. }
  585. output = output + collectionStyleLeft.Render(left)
  586. return output
  587. }
  588. // keyhelp cmd for IssueCollection widget
  589. func (ic IssueCollection) keyhelp() string {
  590. var output string
  591. output = output + "\nc: create new issue\t\tenter: view issue\t\tj/k: down/up\t\tq/ctrl+c: quit"
  592. return output
  593. }
  594. // -------- createInCollection widget definitions -----------------------------
  595. // ----------------------------------------------------------------------------
  596. type createInCollection struct {
  597. Path string // base path for the new issue
  598. name string // the name input by the user
  599. input textinput.Model // the input widget
  600. }
  601. func (w createInCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  602. var cmds []tea.Cmd
  603. switch msg := msg.(type) {
  604. case tea.KeyMsg:
  605. switch msg.String() {
  606. case "enter":
  607. cmds = append(cmds, w.create)
  608. }
  609. cmds = append(cmds, w.render)
  610. }
  611. var inputCmd tea.Cmd
  612. w.input, inputCmd = w.input.Update(msg)
  613. cmds = append(cmds, inputCmd)
  614. w.name = w.input.Value()
  615. return w, tea.Batch(cmds...)
  616. }
  617. func (w createInCollection) create() tea.Msg {
  618. w.Path = filepath.Join(w.Path, parseHumanToPath(w.name))
  619. return newEditWidget(w.Path)
  620. }
  621. func (w createInCollection) render() tea.Msg {
  622. return borderStyle.Render(fmt.Sprintf("Creating new issue in %s\ntitle: %s",
  623. w.Path, w.input.View()))
  624. }
  625. func (w createInCollection) keyhelp() string { return "enter: submit\t\tctrl+c: quit" }