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) }