Add offline-first task-master TUI with tickets, board lists, and undo.
Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,694 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"task/internal/command"
|
||||
"task/internal/config"
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/ui/views"
|
||||
"task/internal/version"
|
||||
)
|
||||
|
||||
// editorFinishedMsg is sent after the external $EDITOR exits.
|
||||
type editorFinishedMsg struct {
|
||||
ticketID string
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
// Mode is the active UI mode.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
ModeList Mode = iota
|
||||
ModeCreate
|
||||
ModeSearch
|
||||
ModeProjects
|
||||
ModeStatus
|
||||
ModeBoard
|
||||
)
|
||||
|
||||
// Model is the root Bubble Tea model.
|
||||
type Model struct {
|
||||
cfg *config.Config
|
||||
store ticket.Store
|
||||
projects project.Store
|
||||
exec command.Executor
|
||||
|
||||
mode Mode
|
||||
tickets views.TicketList
|
||||
projectList list.Model
|
||||
statusMenu views.StatusMenu
|
||||
boardMenu views.BoardMenu
|
||||
boardList ticket.BoardList
|
||||
input textinput.Model
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
statusIsError bool
|
||||
filter string
|
||||
keys KeyMap
|
||||
styles Styles
|
||||
version string
|
||||
allTickets []ticket.Ticket
|
||||
creatingProject bool
|
||||
}
|
||||
|
||||
// New creates the root UI model.
|
||||
func New(cfg *config.Config, store ticket.Store, projects project.Store, exec command.Executor) Model {
|
||||
styles := DefaultStyles()
|
||||
if exec == nil {
|
||||
exec = command.NewHistory()
|
||||
}
|
||||
|
||||
m := Model{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
projects: projects,
|
||||
exec: exec,
|
||||
mode: ModeList,
|
||||
boardList: ticket.ParseBoardList(cfg.ActiveBoardList(cfg.ActiveProject)),
|
||||
tickets: views.TicketList{
|
||||
Styles: views.TicketListStyles{
|
||||
Card: styles.Card,
|
||||
CardSelected: styles.CardSelected,
|
||||
StatusOPEN: styles.StatusTodo,
|
||||
StatusWIP: styles.StatusWIP,
|
||||
StatusDONE: styles.StatusDone,
|
||||
Headline: styles.Headline,
|
||||
},
|
||||
},
|
||||
input: views.NewCreateInput(),
|
||||
keys: DefaultKeyMap(),
|
||||
styles: styles,
|
||||
version: version.BuildVersion,
|
||||
}
|
||||
_ = m.reloadTickets()
|
||||
return m
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.resizeLists()
|
||||
return m, nil
|
||||
|
||||
case editorFinishedMsg:
|
||||
return m.handleEditorFinished(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
case ModeCreate, ModeSearch:
|
||||
return m.updateInput(msg)
|
||||
case ModeProjects:
|
||||
return m.updateProjects(msg)
|
||||
case ModeStatus:
|
||||
return m.updateStatus(msg)
|
||||
case ModeBoard:
|
||||
return m.updateBoard(msg)
|
||||
default:
|
||||
return m.updateList(msg)
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
switch m.mode {
|
||||
case ModeProjects:
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
case ModeCreate, ModeSearch:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…"
|
||||
}
|
||||
header := m.renderHeader()
|
||||
footer := m.renderFooter()
|
||||
bodyH := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
body := m.renderBody(bodyH)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
||||
}
|
||||
|
||||
func (m *Model) resizeLists() {
|
||||
headerH := 1
|
||||
footerH := 1
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.tickets.SetSize(m.width, bodyH)
|
||||
if m.mode == ModeProjects {
|
||||
m.projectList.SetSize(m.width, bodyH)
|
||||
}
|
||||
m.input.Width = max(10, m.width-4)
|
||||
m.statusMenu.Width = m.width
|
||||
m.boardMenu.Width = m.width
|
||||
}
|
||||
|
||||
func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, m.keys.New):
|
||||
m.clearStatus()
|
||||
m.creatingProject = false
|
||||
m.mode = ModeCreate
|
||||
m.input = views.NewCreateInput()
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Search):
|
||||
m.clearStatus()
|
||||
m.creatingProject = false
|
||||
m.mode = ModeSearch
|
||||
m.input = views.NewSearchInput()
|
||||
m.input.SetValue(m.filter)
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Project):
|
||||
m.clearStatus()
|
||||
if err := m.openProjects(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeProjects
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Board):
|
||||
m.clearStatus()
|
||||
m.boardMenu = views.NewBoardMenu(m.boardList, m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
m.mode = ModeBoard
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
m.clearStatus()
|
||||
if err := m.deleteSelected(); err != nil {
|
||||
m.setError(err)
|
||||
} else {
|
||||
m.setStatus("deleted")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Undo):
|
||||
m.clearStatus()
|
||||
if err := m.undo(); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Redo):
|
||||
m.clearStatus()
|
||||
if err := m.redo(); err != nil {
|
||||
m.setError(err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
m.clearStatus()
|
||||
cmd, err := m.openEditorForSelected()
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case key.Matches(msg, m.keys.Status):
|
||||
m.clearStatus()
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
m.setError(fmt.Errorf("no ticket selected"))
|
||||
return m, nil
|
||||
}
|
||||
m.statusMenu = views.NewStatusMenu(t.Status, m.width, views.StatusMenuStyles{
|
||||
Box: m.styles.StatusMenuBox,
|
||||
Title: m.styles.StatusMenuTitle,
|
||||
Selected: m.styles.StatusMenuSelected,
|
||||
Normal: m.styles.StatusMenuNormal,
|
||||
})
|
||||
m.mode = ModeStatus
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.tickets.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.tickets.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateStatus(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.applyStatus(m.statusMenu.Selected()); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.statusMenu.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.statusMenu.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if err := m.applyBoardList(m.boardMenu.Selected()); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.boardMenu.MoveDown()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.boardMenu.MoveUp()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
wasProject := m.creatingProject
|
||||
m.creatingProject = false
|
||||
m.input.Blur()
|
||||
m.clearStatus()
|
||||
if wasProject {
|
||||
m.mode = ModeProjects
|
||||
} else {
|
||||
m.mode = ModeList
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if m.mode == ModeCreate {
|
||||
if value == "" {
|
||||
m.setError(fmt.Errorf("name required"))
|
||||
return m, nil
|
||||
}
|
||||
if m.creatingProject {
|
||||
id := sanitizeProjectID(value)
|
||||
if err := m.projects.Ensure(id); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if err := m.switchProject(id); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.creatingProject = false
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
m.setStatus(fmt.Sprintf("created project %s", id))
|
||||
return m, nil
|
||||
}
|
||||
if err := m.createTicket(value); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
m.setStatus("created")
|
||||
return m, nil
|
||||
}
|
||||
m.filter = value
|
||||
m.applyFilter()
|
||||
m.mode = ModeList
|
||||
m.input.Blur()
|
||||
if value == "" {
|
||||
m.setStatus("filter cleared")
|
||||
} else {
|
||||
m.setStatus(fmt.Sprintf("filter: %q", value))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeList
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.New):
|
||||
m.clearStatus()
|
||||
m.creatingProject = true
|
||||
m.mode = ModeCreate
|
||||
m.input = views.NewCreateInput()
|
||||
m.input.Placeholder = "Project name…"
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
item, ok := m.projectList.SelectedItem().(views.ProjectItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
if err := m.switchProject(item.Project.ID); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeList
|
||||
m.setStatus(fmt.Sprintf("switched to %s", item.Project.ID))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) openEditorForSelected() (tea.Cmd, error) {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no ticket selected")
|
||||
}
|
||||
path, err := ticket.WriteEditFile(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := ticket.EditorCmd(path)
|
||||
ticketID := t.ID
|
||||
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||
return editorFinishedMsg{ticketID: ticketID, path: path, err: err}
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) {
|
||||
defer func() { _ = os.Remove(msg.path) }()
|
||||
|
||||
if msg.err != nil {
|
||||
m.setError(fmt.Errorf("editor: %w", msg.err))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(msg.path)
|
||||
if err != nil {
|
||||
m.setError(fmt.Errorf("read edited file: %w", err))
|
||||
return m, nil
|
||||
}
|
||||
title, description, err := ticket.ParseEditDocument(string(data))
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
before, err := m.store.Get(msg.ticketID)
|
||||
if err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
after := before
|
||||
after.Title = title
|
||||
after.Description = description
|
||||
if err := m.exec.Do(&command.UpdateTicket{
|
||||
Store: m.store,
|
||||
Before: before,
|
||||
After: after,
|
||||
}); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
m.setStatus("saved")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) createTicket(title string) error {
|
||||
cmd := &command.CreateTicket{
|
||||
Store: m.store,
|
||||
Ticket: ticket.Ticket{
|
||||
Title: title,
|
||||
ProjectID: m.cfg.ActiveProject,
|
||||
Status: m.boardList.DefaultStatus(),
|
||||
},
|
||||
}
|
||||
if err := m.exec.Do(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.reloadTickets()
|
||||
}
|
||||
|
||||
func (m *Model) deleteSelected() error {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.exec.Do(&command.DeleteTicket{Store: m.store, Snapshot: t}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if idx >= m.tickets.Len() && m.tickets.Len() > 0 {
|
||||
m.tickets.Select(m.tickets.Len() - 1)
|
||||
} else if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyStatus(st ticket.Status) error {
|
||||
t, ok := m.tickets.Selected()
|
||||
if !ok {
|
||||
return fmt.Errorf("no ticket selected")
|
||||
}
|
||||
after := t
|
||||
after.Status = st
|
||||
if err := m.exec.Do(&command.UpdateTicket{
|
||||
Store: m.store,
|
||||
Before: t,
|
||||
After: after,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
idx := m.tickets.CursorIndex()
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if idx < m.tickets.Len() {
|
||||
m.tickets.Select(idx)
|
||||
}
|
||||
m.setStatus(fmt.Sprintf("status → %s", st.Label()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) undo() error {
|
||||
name := m.exec.LastUndoName()
|
||||
if err := m.exec.Undo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if name != "" {
|
||||
m.setStatus("undo: " + name)
|
||||
} else {
|
||||
m.setStatus("undo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) redo() error {
|
||||
name := m.exec.LastRedoName()
|
||||
if err := m.exec.Redo(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.reloadTickets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if name != "" {
|
||||
m.setStatus("redo: " + name)
|
||||
} else {
|
||||
m.setStatus("redo")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyBoardList(bl ticket.BoardList) error {
|
||||
m.boardList = bl
|
||||
m.cfg.SetActiveBoardList(m.cfg.ActiveProject, string(bl))
|
||||
if err := config.Save(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.applyFilter()
|
||||
m.setStatus(fmt.Sprintf("list → %s", bl.Label()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) switchProject(id string) error {
|
||||
if err := m.projects.Ensure(id); err != nil {
|
||||
return err
|
||||
}
|
||||
m.cfg.ActiveProject = id
|
||||
m.boardList = ticket.ParseBoardList(m.cfg.ActiveBoardList(id))
|
||||
if err := config.Save(m.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
m.filter = ""
|
||||
return m.reloadTickets()
|
||||
}
|
||||
|
||||
func (m *Model) openProjects() error {
|
||||
projects, err := m.projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
if err := m.projects.Ensure(m.cfg.ActiveProject); err != nil {
|
||||
return err
|
||||
}
|
||||
projects, err = m.projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
headerH, footerH := 1, 1
|
||||
bodyH := m.height - headerH - footerH
|
||||
if bodyH < 1 {
|
||||
bodyH = 1
|
||||
}
|
||||
m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) reloadTickets() error {
|
||||
tickets, err := m.store.List(m.cfg.ActiveProject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.allTickets = tickets
|
||||
m.applyFilter()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) applyFilter() {
|
||||
filtered := make([]ticket.Ticket, 0, len(m.allTickets))
|
||||
for _, t := range m.allTickets {
|
||||
if m.boardList.Matches(t.Status) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
filtered = views.FilterTickets(filtered, m.filter)
|
||||
cursor := m.tickets.CursorIndex()
|
||||
m.tickets.SetItems(filtered)
|
||||
if cursor < m.tickets.Len() {
|
||||
m.tickets.Select(cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) ticketCount() int {
|
||||
return m.tickets.Len()
|
||||
}
|
||||
|
||||
func (m *Model) setStatus(s string) {
|
||||
m.status = s
|
||||
m.statusIsError = false
|
||||
}
|
||||
|
||||
func (m *Model) setError(err error) {
|
||||
m.status = err.Error()
|
||||
m.statusIsError = true
|
||||
}
|
||||
|
||||
func (m *Model) clearStatus() {
|
||||
m.status = ""
|
||||
m.statusIsError = false
|
||||
}
|
||||
|
||||
func sanitizeProjectID(name string) string {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
case r == ' ':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
id := b.String()
|
||||
if id == "" {
|
||||
return "project"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user