summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 23:01:11 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 23:01:11 +0200
commitc0ed8269e5bb8342a4be0ab1f969129a4bd9a9f6 (patch)
tree377482d871ec805ea4dd342a9e034a224eb13c99 /internal
parent0a2ad3650208a6cd7221974ed0c0acf38eb22f79 (diff)
Task 353: add TUI entry edit/delete/add flows
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/entries.go182
-rw-r--r--internal/tui/entries_test.go68
2 files changed, 249 insertions, 1 deletions
diff --git a/internal/tui/entries.go b/internal/tui/entries.go
index 450748f..635f903 100644
--- a/internal/tui/entries.go
+++ b/internal/tui/entries.go
@@ -22,6 +22,7 @@ type EntriesModel struct {
height int
pendingG bool
+ pendingD bool
searchMode bool
searchQuery string
@@ -29,6 +30,9 @@ type EntriesModel struct {
filterMode bool
filterQuery string
+ editMode bool
+ confirmDelete bool
+
input string
}
@@ -65,13 +69,45 @@ func (m *EntriesModel) SetEntries(entries []worktime.Entry) {
m.applyFilters()
}
-// Update handles keyboard navigation and search/filter interaction.
+// Update handles keyboard navigation and search/filter/edit interaction.
func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
+ if m.confirmDelete {
+ switch keyMsg.String() {
+ case "y":
+ m.deleteSelected()
+ m.confirmDelete = false
+ case "n", "esc":
+ m.confirmDelete = false
+ }
+ return m, nil
+ }
+
+ if m.editMode {
+ switch keyMsg.String() {
+ case "enter":
+ m.saveEdit()
+ m.editMode = false
+ m.input = ""
+ case "esc":
+ m.editMode = false
+ m.input = ""
+ case "backspace":
+ if len(m.input) > 0 {
+ m.input = m.input[:len(m.input)-1]
+ }
+ default:
+ if keyMsg.Type == tea.KeyRunes {
+ m.input += string(keyMsg.Runes)
+ }
+ }
+ return m, nil
+ }
+
if m.searchMode || m.filterMode {
switch keyMsg.String() {
case "enter":
@@ -105,42 +141,74 @@ func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
m.searchMode = true
m.input = ""
m.pendingG = false
+ m.pendingD = false
case "f":
m.filterMode = true
m.input = m.filterQuery
m.pendingG = false
+ m.pendingD = false
+ case "e", "enter":
+ m.beginEdit()
+ m.pendingG = false
+ m.pendingD = false
+ case "o":
+ m.insertEntry(false)
+ m.pendingG = false
+ m.pendingD = false
+ case "O":
+ m.insertEntry(true)
+ m.pendingG = false
+ m.pendingD = false
+ case "d":
+ if m.pendingD {
+ m.confirmDelete = true
+ m.pendingD = false
+ return m, nil
+ }
+ m.pendingD = true
+ m.pendingG = false
case "j", "down":
m.moveCursor(1)
m.pendingG = false
+ m.pendingD = false
case "k", "up":
m.moveCursor(-1)
m.pendingG = false
+ m.pendingD = false
case "G":
m.cursor = len(m.visible) - 1
m.ensureCursorVisible()
m.pendingG = false
+ m.pendingD = false
case "g":
if m.pendingG {
m.cursor = 0
m.ensureCursorVisible()
m.pendingG = false
+ m.pendingD = false
return m, nil
}
m.pendingG = true
+ m.pendingD = false
case "ctrl+d":
m.moveCursor(m.halfPage())
m.pendingG = false
+ m.pendingD = false
case "ctrl+u":
m.moveCursor(-m.halfPage())
m.pendingG = false
+ m.pendingD = false
case "ctrl+f":
m.moveCursor(m.pageSize())
m.pendingG = false
+ m.pendingD = false
case "ctrl+b":
m.moveCursor(-m.pageSize())
m.pendingG = false
+ m.pendingD = false
default:
m.pendingG = false
+ m.pendingD = false
}
return m, nil
@@ -162,6 +230,12 @@ func (m EntriesModel) View(styles Styles) string {
if m.filterMode {
return styles.Body.Render(title + "\n\nf " + m.input)
}
+ if m.editMode {
+ return styles.Body.Render(title + "\n\nEdit description: " + m.input)
+ }
+ if m.confirmDelete {
+ return styles.Body.Render(title + "\n\nDelete selected entry? (y/n)")
+ }
if len(m.visible) == 0 {
return styles.Body.Render(title + "\n\nNo entries match current search/filter.")
@@ -239,6 +313,89 @@ func (m *EntriesModel) moveCursor(delta int) {
m.ensureCursorVisible()
}
+func (m *EntriesModel) beginEdit() {
+ if len(m.visible) == 0 || m.cursor >= len(m.visible) {
+ return
+ }
+
+ m.editMode = true
+ m.input = m.visible[m.cursor].Descr
+}
+
+func (m *EntriesModel) saveEdit() {
+ if len(m.visible) == 0 || m.cursor >= len(m.visible) {
+ return
+ }
+
+ oldEntry := m.visible[m.cursor]
+ newEntry := oldEntry
+ newEntry.Descr = strings.TrimSpace(m.input)
+ m.replaceEntry(oldEntry, newEntry)
+}
+
+func (m *EntriesModel) deleteSelected() {
+ if len(m.visible) == 0 || m.cursor >= len(m.visible) {
+ return
+ }
+
+ target := m.visible[m.cursor]
+ idx := findEntryIndex(m.allEntries, target)
+ if idx < 0 {
+ return
+ }
+
+ m.allEntries = append(m.allEntries[:idx], m.allEntries[idx+1:]...)
+ m.applyFilters()
+}
+
+func (m *EntriesModel) insertEntry(above bool) {
+ newEntry := worktime.Entry{
+ Action: "add",
+ What: "work",
+ Epoch: time.Now().Unix(),
+ Source: "local",
+ Human: time.Now().Format("Mon 02.01.2006 15:04:05"),
+ Value: int64(time.Hour / time.Second),
+ }
+
+ insertAt := len(m.allEntries)
+ if len(m.visible) > 0 && m.cursor < len(m.visible) {
+ target := m.visible[m.cursor]
+ if idx := findEntryIndex(m.allEntries, target); idx >= 0 {
+ insertAt = idx
+ if !above {
+ insertAt++
+ }
+ }
+ }
+
+ m.allEntries = insertEntryAt(m.allEntries, insertAt, newEntry)
+ m.applyFilters()
+
+ if idx := findEntryIndex(m.visible, newEntry); idx >= 0 {
+ m.cursor = idx
+ m.ensureCursorVisible()
+ }
+
+ m.editMode = true
+ m.input = ""
+}
+
+func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) {
+ idx := findEntryIndex(m.allEntries, oldEntry)
+ if idx < 0 {
+ return
+ }
+
+ m.allEntries[idx] = newEntry
+ m.applyFilters()
+
+ if newIdx := findEntryIndex(m.visible, newEntry); newIdx >= 0 {
+ m.cursor = newIdx
+ m.ensureCursorVisible()
+ }
+}
+
func (m *EntriesModel) ensureCursorVisible() {
if len(m.visible) == 0 {
m.cursor = 0
@@ -322,3 +479,26 @@ func minInt(a, b int) int {
}
return b
}
+
+func findEntryIndex(entries []worktime.Entry, target worktime.Entry) int {
+ for idx, entry := range entries {
+ if entry == target {
+ return idx
+ }
+ }
+ return -1
+}
+
+func insertEntryAt(entries []worktime.Entry, idx int, entry worktime.Entry) []worktime.Entry {
+ if idx < 0 {
+ idx = 0
+ }
+ if idx > len(entries) {
+ idx = len(entries)
+ }
+
+ entries = append(entries, worktime.Entry{})
+ copy(entries[idx+1:], entries[idx:])
+ entries[idx] = entry
+ return entries
+}
diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go
index 5f6d5f5..09139fc 100644
--- a/internal/tui/entries_test.go
+++ b/internal/tui/entries_test.go
@@ -100,6 +100,74 @@ func TestEntriesSearchAndFilter(t *testing.T) {
}
}
+func TestEntriesEditFlow(t *testing.T) {
+ model := NewEntriesModel(sampleEntries(3))
+ model.SetSize(120, 12)
+
+ original := model.visible[0].Descr
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
+ if !model.editMode {
+ t.Fatal("editMode = false, want true after e")
+ }
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ if model.editMode {
+ t.Fatal("editMode = true, want false after Enter")
+ }
+ if model.visible[0].Descr != original+"!" {
+ t.Fatalf("edited description = %q, want %q", model.visible[0].Descr, original+"!")
+ }
+}
+
+func TestEntriesDeleteWithConfirmation(t *testing.T) {
+ model := NewEntriesModel(sampleEntries(3))
+ model.SetSize(120, 12)
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ if !model.confirmDelete {
+ t.Fatal("confirmDelete = false, want true after dd")
+ }
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
+ if model.confirmDelete {
+ t.Fatal("confirmDelete = true after cancel")
+ }
+ if len(model.visible) != 3 {
+ t.Fatalf("entries len = %d, want 3 after cancel", len(model.visible))
+ }
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
+
+ if len(model.visible) != 2 {
+ t.Fatalf("entries len = %d, want 2 after delete confirmation", len(model.visible))
+ }
+}
+
+func TestEntriesInsertWithOAndShiftO(t *testing.T) {
+ model := NewEntriesModel(sampleEntries(2))
+ model.SetSize(120, 12)
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
+ if len(model.visible) != 3 {
+ t.Fatalf("entries len = %d, want 3 after o", len(model.visible))
+ }
+ if !model.editMode {
+ t.Fatal("editMode = false after o insertion")
+ }
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc})
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'O'}})
+ if len(model.visible) != 4 {
+ t.Fatalf("entries len = %d, want 4 after O", len(model.visible))
+ }
+}
+
func sampleEntries(count int) []worktime.Entry {
entries := make([]worktime.Entry, 0, count)
for idx := 0; idx < count; idx++ {