209 lines
5.4 KiB
Go
209 lines
5.4 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"`
|
|
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)
|
|
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,
|
|
Body: m.prEditEditors[prEditBodyField].Text,
|
|
OriginalUpdatedAt: m.prEditOriginal.UpdatedAt,
|
|
})
|
|
return flushDraftsAfter(m.drafts)
|
|
}
|