Files
diple/drafts.go
2026-07-29 13:12:39 +02:00

219 lines
5.9 KiB
Go

package main
import (
"encoding/json"
"errors"
"os"
"strconv"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const draftSchemaVersion = 1
type savedDraft struct {
Kind string `json:"kind"`
Owner string `json:"owner"`
Repository string `json:"repository"`
Number int `json:"number"`
ThreadID string `json:"thread_id,omitempty"`
Reply string `json:"reply,omitempty"`
Title string `json:"title,omitempty"`
BaseRef string `json:"base_ref,omitempty"`
Reviewers string `json:"reviewers,omitempty"`
Assignees string `json:"assignees,omitempty"`
PeopleSet bool `json:"people_set,omitempty"`
Body string `json:"body,omitempty"`
OriginalUpdatedAt time.Time `json:"original_updated_at,omitempty"`
SavedAt time.Time `json:"saved_at"`
}
type draftEnvelope struct {
Version int `json:"version"`
Drafts map[string]savedDraft `json:"drafts"`
}
type draftStore struct {
mu sync.Mutex
path string
data map[string]savedDraft
dirty bool
loadErr error
}
func loadDraftStore(path string) *draftStore {
store := &draftStore{path: path, data: make(map[string]savedDraft)}
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
store.loadErr = err
}
return store
}
var envelope draftEnvelope
if json.Unmarshal(data, &envelope) == nil && envelope.Version == draftSchemaVersion {
store.data = envelope.Drafts
if store.data == nil {
store.data = make(map[string]savedDraft)
}
} else {
store.loadErr = errors.New("draft file is corrupt or has an unsupported schema version")
}
return store
}
func (s *draftStore) get(key string) (savedDraft, bool) {
if s == nil {
return savedDraft{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
draft, ok := s.data[key]
return draft, ok
}
func (s *draftStore) put(key string, draft savedDraft) {
if s == nil || key == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.data[key]; ok {
existing.SavedAt = time.Time{}
candidate := draft
candidate.SavedAt = time.Time{}
if existing == candidate {
return
}
}
draft.SavedAt = time.Now()
s.data[key] = draft
s.dirty = true
}
func (s *draftStore) delete(key string) error {
if s == nil || key == "" {
return nil
}
s.mu.Lock()
delete(s.data, key)
s.dirty = true
s.mu.Unlock()
return s.flush()
}
func (s *draftStore) flush() error {
if s == nil || s.path == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.dirty {
return nil
}
envelope := draftEnvelope{Version: draftSchemaVersion, Drafts: s.data}
if err := atomicWriteJSON(s.path, envelope, 0o600); err != nil {
return err
}
s.dirty = false
return nil
}
type draftFlushMsg struct{ err error }
func flushDraftsAfter(store *draftStore) tea.Cmd {
if store == nil {
return nil
}
return tea.Tick(400*time.Millisecond, func(time.Time) tea.Msg {
return draftFlushMsg{err: store.flush()}
})
}
func replyDraftKey(owner, repo string, number int, threadID string) string {
return "reply:" + owner + "/" + repo + ":" +
fmtInt(number) + ":" + threadID
}
func prMetadataDraftKey(owner, repo string, number int) string {
return "pr:" + owner + "/" + repo + ":" + fmtInt(number)
}
func fmtInt(value int) string {
return strconv.Itoa(value)
}
func (m *App) restoreReplyDraft(threadID string) {
key := replyDraftKey(m.details.Owner, m.details.Repository, m.details.Number, threadID)
if draft, ok := m.drafts.get(key); ok && draft.Kind == "reply" && draft.Reply != "" {
m.replyDraft = draft.Reply
m.recordHealth(
"draft recovery", healthWarning,
"restored reply draft saved "+draft.SavedAt.Local().Format("2006-01-02 15:04:05"),
)
}
}
func (m *App) queueReplyDraft() tea.Cmd {
if m.drafts == nil || m.writeThreadID == "" {
return nil
}
key := replyDraftKey(
m.details.Owner, m.details.Repository, m.details.Number, m.writeThreadID,
)
m.drafts.put(key, savedDraft{
Kind: "reply", Owner: m.details.Owner, Repository: m.details.Repository,
Number: m.details.Number, ThreadID: m.writeThreadID, Reply: m.replyDraft,
})
return flushDraftsAfter(m.drafts)
}
func (m *App) restorePREditDraft() {
key := prMetadataDraftKey(m.details.Owner, m.details.Repository, m.details.Number)
draft, ok := m.drafts.get(key)
if !ok || draft.Kind != "pr-metadata" {
return
}
if !draft.OriginalUpdatedAt.Equal(m.details.UpdatedAt) {
m.recordHealth(
"draft recovery", healthWarning,
"saved PR metadata draft was not restored because the pull request changed",
)
return
}
m.prEditEditors[prEditTitleField] = newTextEditor(draft.Title, false)
m.prEditEditors[prEditBaseField] = newTextEditor(draft.BaseRef, false)
if draft.PeopleSet {
m.prEditEditors[prEditReviewersField] = newTextEditor(draft.Reviewers, false)
m.prEditEditors[prEditAssigneesField] = newTextEditor(draft.Assignees, false)
}
m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(draft.Body), m.editorMode == "vim",
)
m.prEditEditors[prEditBodyField].highlightMarkdown = true
m.recordHealth(
"draft recovery", healthWarning,
"restored PR metadata draft saved "+draft.SavedAt.Local().Format("2006-01-02 15:04:05"),
)
}
func (m *App) queuePREditDraft() tea.Cmd {
if m.drafts == nil {
return nil
}
key := prMetadataDraftKey(m.details.Owner, m.details.Repository, m.details.Number)
m.drafts.put(key, savedDraft{
Kind: "pr-metadata", Owner: m.details.Owner, Repository: m.details.Repository,
Number: m.details.Number, Title: m.prEditEditors[prEditTitleField].Text,
BaseRef: m.prEditEditors[prEditBaseField].Text,
Reviewers: m.prEditEditors[prEditReviewersField].Text,
Assignees: m.prEditEditors[prEditAssigneesField].Text,
PeopleSet: true,
Body: m.prEditEditors[prEditBodyField].Text,
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
})
return flushDraftsAfter(m.drafts)
}