CLI/TUI for drafting, repeating, and publishing daily standup updates as GitHub issues
github go cli golang management project tui daily
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(editor): add clipboard copy/cut/paste support with system clipboard fallback

+455 -20
+125 -20
internal/tui/editor.go
··· 5 5 "fmt" 6 6 "strings" 7 7 8 + "github.com/atotto/clipboard" 8 9 "github.com/charmbracelet/bubbles/textarea" 9 10 "github.com/charmbracelet/bubbles/viewport" 10 11 tea "github.com/charmbracelet/bubbletea" ··· 14 15 ) 15 16 16 17 var ErrCanceled = errors.New("edit canceled") 18 + 19 + var writeClipboard = clipboard.WriteAll 17 20 18 21 type editorMode int 19 22 ··· 43 46 ) 44 47 45 48 type model struct { 46 - mode editorMode 47 - entry daily.Entry 48 - fields []issueform.Field 49 - index int 50 - editor textarea.Model 51 - preview viewport.Model 52 - width int 53 - height int 54 - message string 55 - messageIsError bool 56 - confirm bool 57 - submitted bool 58 - canceled bool 59 - result daily.Entry 49 + mode editorMode 50 + entry daily.Entry 51 + fields []issueform.Field 52 + index int 53 + editor textarea.Model 54 + preview viewport.Model 55 + width int 56 + height int 57 + message string 58 + messageIsError bool 59 + clipboard string 60 + clipboardSynced bool 61 + clipboardMessage string 62 + clipboardCount int 63 + confirm bool 64 + submitted bool 65 + canceled bool 66 + result daily.Entry 60 67 } 61 68 62 69 func Edit(entry daily.Entry) (daily.Entry, error) { ··· 131 138 return m, nil 132 139 133 140 case tea.KeyMsg: 141 + m.clearClipboardMessageOnKey(msg) 142 + 134 143 if m.confirm { 135 144 return m.updateConfirmation(msg) 136 145 } 137 146 147 + if m.currentFieldIsText() { 148 + switch msg.String() { 149 + case "ctrl+c": 150 + m.copyCurrentField(false) 151 + return m, nil 152 + case "ctrl+x": 153 + m.copyCurrentField(true) 154 + return m, nil 155 + case "ctrl+v": 156 + if !m.clipboardSynced && m.clipboard != "" { 157 + m.pasteClipboard() 158 + return m, nil 159 + } 160 + } 161 + } 162 + 138 163 switch msg.String() { 139 164 case "ctrl+c", "esc": 140 165 m.canceled = true ··· 187 212 case "y", "enter": 188 213 entry := m.finalEntry() 189 214 if err := entry.ValidateForCreate(); err != nil { 190 - m.message = err.Error() 191 - m.messageIsError = true 215 + m.setMessage(err.Error(), true) 192 216 m.confirm = false 193 217 m.refreshPreview() 194 218 return m, nil ··· 211 235 } 212 236 213 237 if err := entry.ValidateForCreate(); err != nil { 214 - m.message = err.Error() 215 - m.messageIsError = true 238 + m.setMessage(err.Error(), true) 216 239 m.refreshPreview() 217 240 return m, nil 218 241 } 219 242 220 - m.message = "" 221 - m.messageIsError = false 243 + m.setMessage("", false) 222 244 m.confirm = true 223 245 m.refreshPreview() 224 246 return m, nil ··· 349 371 helpDividerStyle.Render(" • "), 350 372 helpItem(cancelKeyStyle, "esc", "cancel"), 351 373 )) 374 + if m.currentFieldIsText() { 375 + lines = append(lines, lipgloss.JoinHorizontal( 376 + lipgloss.Top, 377 + helpItem(secondaryKeyStyle, "ctrl+c", "copy field"), 378 + helpDividerStyle.Render(" • "), 379 + helpItem(secondaryKeyStyle, "ctrl+x", "cut field"), 380 + helpDividerStyle.Render(" • "), 381 + helpItem(secondaryKeyStyle, "ctrl+v", "paste"), 382 + )) 383 + } 352 384 353 385 return lipgloss.JoinVertical(lipgloss.Left, lines...) 354 386 } ··· 411 443 content = lipgloss.NewStyle().Width(m.preview.Width).Render(content) 412 444 } 413 445 m.preview.SetContent(content) 446 + } 447 + 448 + func (m *model) copyCurrentField(clear bool) { 449 + value := m.editor.Value() 450 + if strings.TrimSpace(value) == "" { 451 + m.setMessage("Current field is empty.", false) 452 + return 453 + } 454 + 455 + m.clipboard = value 456 + 457 + action := "Copied" 458 + if clear { 459 + action = "Cut" 460 + m.editor.SetValue("") 461 + m.persistCurrentField() 462 + m.refreshPreview() 463 + } 464 + 465 + m.messageIsError = false 466 + if err := writeClipboard(value); err != nil { 467 + m.clipboardSynced = false 468 + m.setClipboardActionMessage(fmt.Sprintf("%s current field to pad clipboard only.", action)) 469 + return 470 + } 471 + 472 + m.clipboardSynced = true 473 + m.setClipboardActionMessage(fmt.Sprintf("%s current field to clipboard.", action)) 474 + } 475 + 476 + func (m *model) pasteClipboard() { 477 + if m.clipboard == "" { 478 + m.setMessage("Pad clipboard is empty.", false) 479 + return 480 + } 481 + 482 + m.editor.InsertString(m.clipboard) 483 + m.persistCurrentField() 484 + m.refreshPreview() 485 + m.setMessage("Pasted clipboard into current field.", false) 486 + } 487 + 488 + func (m *model) setMessage(message string, isError bool) { 489 + m.message = message 490 + m.messageIsError = isError 491 + m.clipboardMessage = "" 492 + m.clipboardCount = 0 493 + } 494 + 495 + func (m *model) clearClipboardMessageOnKey(msg tea.KeyMsg) { 496 + if m.clipboardMessage == "" { 497 + return 498 + } 499 + if msg.String() == "ctrl+c" || msg.String() == "ctrl+x" { 500 + return 501 + } 502 + 503 + m.setMessage("", false) 504 + } 505 + 506 + func (m *model) setClipboardActionMessage(base string) { 507 + m.messageIsError = false 508 + if m.clipboardMessage == base { 509 + m.clipboardCount++ 510 + } else { 511 + m.clipboardMessage = base 512 + m.clipboardCount = 1 513 + } 514 + 515 + m.message = base 516 + if m.clipboardCount > 1 { 517 + m.message = fmt.Sprintf("%s (%d)", base, m.clipboardCount) 518 + } 414 519 } 415 520 416 521 func previewContent(entry daily.Entry) string {
+330
internal/tui/editor_test.go
··· 1 1 package tui 2 2 3 3 import ( 4 + "errors" 4 5 "strings" 5 6 "testing" 6 7 8 + tea "github.com/charmbracelet/bubbletea" 7 9 "github.com/vieitesss/pad/internal/daily" 8 10 "github.com/vieitesss/pad/internal/issueform" 9 11 ) ··· 73 75 content := previewContent(m.finalEntry()) 74 76 if !strings.Contains(content, "- [x] ✅ Yes, I need a Parking Lot or escalation") { 75 77 t.Fatalf("expected preview to show checked checkbox, got %q", content) 78 + } 79 + } 80 + 81 + func TestCtrlCCopiesCurrentFieldToClipboard(t *testing.T) { 82 + originalWriteClipboard := writeClipboard 83 + t.Cleanup(func() { 84 + writeClipboard = originalWriteClipboard 85 + }) 86 + 87 + var copied string 88 + writeClipboard = func(value string) error { 89 + copied = value 90 + return nil 91 + } 92 + 93 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 94 + m.editor.SetValue("- first field text") 95 + 96 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 97 + got := updated.(model) 98 + 99 + if got.clipboard != "- first field text" { 100 + t.Fatalf("expected pad clipboard to store current field, got %q", got.clipboard) 101 + } 102 + if !got.clipboardSynced { 103 + t.Fatalf("expected clipboard sync to be marked successful") 104 + } 105 + if copied != "- first field text" { 106 + t.Fatalf("expected system clipboard write, got %q", copied) 107 + } 108 + if got.editor.Value() != "- first field text" { 109 + t.Fatalf("expected copy to keep field contents, got %q", got.editor.Value()) 110 + } 111 + if got.message != "Copied current field to clipboard." { 112 + t.Fatalf("unexpected message %q", got.message) 113 + } 114 + } 115 + 116 + func TestCtrlXCutsCurrentFieldToClipboard(t *testing.T) { 117 + originalWriteClipboard := writeClipboard 118 + t.Cleanup(func() { 119 + writeClipboard = originalWriteClipboard 120 + }) 121 + 122 + writeClipboard = func(value string) error { 123 + return nil 124 + } 125 + 126 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 127 + m.editor.SetValue("- carry this forward") 128 + m.persistCurrentField() 129 + 130 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 131 + got := updated.(model) 132 + 133 + if got.clipboard != "- carry this forward" { 134 + t.Fatalf("expected pad clipboard to store moved field, got %q", got.clipboard) 135 + } 136 + if got.editor.Value() != "" { 137 + t.Fatalf("expected move to clear field, got %q", got.editor.Value()) 138 + } 139 + if got.entry.Text("yesterday") != "" { 140 + t.Fatalf("expected move to persist cleared field, got %q", got.entry.Text("yesterday")) 141 + } 142 + } 143 + 144 + func TestCtrlCDoesNotCopyEmptyField(t *testing.T) { 145 + originalWriteClipboard := writeClipboard 146 + t.Cleanup(func() { 147 + writeClipboard = originalWriteClipboard 148 + }) 149 + 150 + called := false 151 + writeClipboard = func(string) error { 152 + called = true 153 + return nil 154 + } 155 + 156 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 157 + m.clipboard = "- existing clipboard" 158 + m.editor.SetValue(" \n") 159 + 160 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 161 + got := updated.(model) 162 + 163 + if called { 164 + t.Fatalf("expected empty field copy to skip system clipboard") 165 + } 166 + if got.clipboard != "- existing clipboard" { 167 + t.Fatalf("expected pad clipboard to stay unchanged, got %q", got.clipboard) 168 + } 169 + if got.message != "Current field is empty." { 170 + t.Fatalf("unexpected empty copy message %q", got.message) 171 + } 172 + } 173 + 174 + func TestCtrlXDoesNotCutEmptyField(t *testing.T) { 175 + originalWriteClipboard := writeClipboard 176 + t.Cleanup(func() { 177 + writeClipboard = originalWriteClipboard 178 + }) 179 + 180 + called := false 181 + writeClipboard = func(string) error { 182 + called = true 183 + return nil 184 + } 185 + 186 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 187 + m.clipboard = "- existing clipboard" 188 + m.editor.SetValue("\n\t") 189 + before := m.editor.Value() 190 + 191 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 192 + got := updated.(model) 193 + 194 + if called { 195 + t.Fatalf("expected empty field cut to skip system clipboard") 196 + } 197 + if got.clipboard != "- existing clipboard" { 198 + t.Fatalf("expected pad clipboard to stay unchanged, got %q", got.clipboard) 199 + } 200 + if got.editor.Value() != before { 201 + t.Fatalf("expected empty field cut to leave editor unchanged, got %q", got.editor.Value()) 202 + } 203 + if got.message != "Current field is empty." { 204 + t.Fatalf("unexpected empty cut message %q", got.message) 205 + } 206 + } 207 + 208 + func TestCtrlVPastesPadClipboardIntoCurrentFieldWhenSystemClipboardUnavailable(t *testing.T) { 209 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 210 + m.clipboard = "- moved text" 211 + m.clipboardSynced = false 212 + m.editor.SetValue("Start\n") 213 + 214 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) 215 + got := updated.(model) 216 + 217 + if got.editor.Value() != "Start\n- moved text" { 218 + t.Fatalf("expected paste to insert clipboard at cursor, got %q", got.editor.Value()) 219 + } 220 + if got.entry.Text("yesterday") != "Start\n- moved text" { 221 + t.Fatalf("expected pasted value to persist, got %q", got.entry.Text("yesterday")) 222 + } 223 + if got.message != "Pasted clipboard into current field." { 224 + t.Fatalf("unexpected message %q", got.message) 225 + } 226 + } 227 + 228 + func TestCopyFallsBackToPadClipboardWhenSystemClipboardFails(t *testing.T) { 229 + originalWriteClipboard := writeClipboard 230 + t.Cleanup(func() { 231 + writeClipboard = originalWriteClipboard 232 + }) 233 + 234 + writeClipboard = func(string) error { 235 + return errors.New("clipboard unavailable") 236 + } 237 + 238 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 239 + m.editor.SetValue("- first field text") 240 + 241 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 242 + got := updated.(model) 243 + 244 + if got.clipboard != "- first field text" { 245 + t.Fatalf("expected pad clipboard to still store value, got %q", got.clipboard) 246 + } 247 + if got.clipboardSynced { 248 + t.Fatalf("expected clipboard sync to be marked failed") 249 + } 250 + if got.message != "Copied current field to pad clipboard only." { 251 + t.Fatalf("unexpected fallback message %q", got.message) 252 + } 253 + } 254 + 255 + func TestCtrlCCopyMessageCountsConsecutiveCopies(t *testing.T) { 256 + originalWriteClipboard := writeClipboard 257 + t.Cleanup(func() { 258 + writeClipboard = originalWriteClipboard 259 + }) 260 + 261 + writeClipboard = func(string) error { 262 + return nil 263 + } 264 + 265 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 266 + m.editor.SetValue("- first field text") 267 + 268 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 269 + got := updated.(model) 270 + if got.message != "Copied current field to clipboard." { 271 + t.Fatalf("unexpected first copy message %q", got.message) 272 + } 273 + 274 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 275 + got = updated.(model) 276 + if got.message != "Copied current field to clipboard. (2)" { 277 + t.Fatalf("unexpected second copy message %q", got.message) 278 + } 279 + 280 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 281 + got = updated.(model) 282 + if got.message != "Copied current field to clipboard. (3)" { 283 + t.Fatalf("unexpected third copy message %q", got.message) 284 + } 285 + } 286 + 287 + func TestCopyMessageClearsOnNextNonCopyKeystroke(t *testing.T) { 288 + originalWriteClipboard := writeClipboard 289 + t.Cleanup(func() { 290 + writeClipboard = originalWriteClipboard 291 + }) 292 + 293 + writeClipboard = func(string) error { 294 + return nil 295 + } 296 + 297 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 298 + m.editor.SetValue("- first field text") 299 + 300 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 301 + got := updated.(model) 302 + if got.message != "Copied current field to clipboard." { 303 + t.Fatalf("unexpected copy message %q", got.message) 304 + } 305 + 306 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyTab}) 307 + got = updated.(model) 308 + if got.message != "" { 309 + t.Fatalf("expected copy message to clear on next non-copy key, got %q", got.message) 310 + } 311 + } 312 + 313 + func TestCtrlXCutMessageCountsConsecutiveCuts(t *testing.T) { 314 + originalWriteClipboard := writeClipboard 315 + t.Cleanup(func() { 316 + writeClipboard = originalWriteClipboard 317 + }) 318 + 319 + writeClipboard = func(string) error { 320 + return nil 321 + } 322 + 323 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 324 + m.editor.SetValue("- first cut") 325 + 326 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 327 + got := updated.(model) 328 + if got.message != "Cut current field to clipboard." { 329 + t.Fatalf("unexpected first cut message %q", got.message) 330 + } 331 + 332 + got.editor.SetValue("- second cut") 333 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 334 + got = updated.(model) 335 + if got.message != "Cut current field to clipboard. (2)" { 336 + t.Fatalf("unexpected second cut message %q", got.message) 337 + } 338 + 339 + got.editor.SetValue("- third cut") 340 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 341 + got = updated.(model) 342 + if got.message != "Cut current field to clipboard. (3)" { 343 + t.Fatalf("unexpected third cut message %q", got.message) 344 + } 345 + } 346 + 347 + func TestCutMessageClearsOnNextNonCutKeystroke(t *testing.T) { 348 + originalWriteClipboard := writeClipboard 349 + t.Cleanup(func() { 350 + writeClipboard = originalWriteClipboard 351 + }) 352 + 353 + writeClipboard = func(string) error { 354 + return nil 355 + } 356 + 357 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 358 + m.editor.SetValue("- first cut") 359 + 360 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 361 + got := updated.(model) 362 + if got.message != "Cut current field to clipboard." { 363 + t.Fatalf("unexpected cut message %q", got.message) 364 + } 365 + 366 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyTab}) 367 + got = updated.(model) 368 + if got.message != "" { 369 + t.Fatalf("expected cut message to clear on next non-cut key, got %q", got.message) 370 + } 371 + } 372 + 373 + func TestCutResetsCopyMessageCount(t *testing.T) { 374 + originalWriteClipboard := writeClipboard 375 + t.Cleanup(func() { 376 + writeClipboard = originalWriteClipboard 377 + }) 378 + 379 + writeClipboard = func(string) error { 380 + return nil 381 + } 382 + 383 + m := newModel(daily.New("2026-04-16", mustTemplate(t)), modeCreate) 384 + m.editor.SetValue("- first field text") 385 + 386 + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 387 + got := updated.(model) 388 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 389 + got = updated.(model) 390 + if got.message != "Copied current field to clipboard. (2)" { 391 + t.Fatalf("unexpected copy message before cut %q", got.message) 392 + } 393 + 394 + got.editor.SetValue("- cut text") 395 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlX}) 396 + got = updated.(model) 397 + if got.message != "Cut current field to clipboard." { 398 + t.Fatalf("unexpected cut message %q", got.message) 399 + } 400 + 401 + got.editor.SetValue("- copied again") 402 + updated, _ = got.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 403 + got = updated.(model) 404 + if got.message != "Copied current field to clipboard." { 405 + t.Fatalf("expected copy count reset after cut, got %q", got.message) 76 406 } 77 407 } 78 408