okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(terminal): share application lifecycle4ad1172

main
4.7 KiB156 linesraw
1// Package terminal owns interactive confirmation, notices, and Bubble Tea launch.
2package terminal
3
4import (
5	"bufio"
6	"context"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"runtime/debug"
12	"slices"
13	"strings"
14
15	"albedo/cli/internal/app"
16	"albedo/cli/internal/daemon"
17	"albedo/cli/internal/tui"
18
19	tea "charm.land/bubbletea/v2"
20	"github.com/charmbracelet/x/ansi"
21	"github.com/mattn/go-isatty"
22)
23
24type Service struct {
25	In          io.Reader
26	Out, Err    io.Writer
27	OpenBrowser func(string)
28	Home        string
29	Capable     bool
30}
31
32func IsTTY(in io.Reader, out io.Writer) bool {
33	input, inputOK := in.(*os.File)
34	output, outputOK := out.(*os.File)
35	return inputOK && outputOK && isatty.IsTerminal(input.Fd()) && isatty.IsTerminal(output.Fd())
36}
37
38// announceMigration presents retained daemon notices before the full screen opens.
39func (t *Service) announceMigration(ctx context.Context, conn *daemon.Connection, settings daemon.Settings) (daemon.Settings, error) {
40	server, err := daemon.ProbeServer(ctx, conn)
41	if err != nil {
42		return daemon.Settings{}, err
43	}
44	dismissed := slices.Clone(settings.UI.DismissedNotices)
45	shown := false
46	for _, notice := range server.Notices {
47		if slices.Contains(dismissed, notice.ID) {
48			continue
49		}
50		if _, err = fmt.Fprintln(t.Out, notice.Message); err != nil {
51			return daemon.Settings{}, err
52		}
53		dismissed = append(dismissed, notice.ID)
54		shown = true
55	}
56	if !shown {
57		return settings, nil
58	}
59	if _, err = fmt.Fprint(t.Out, "Press Enter to continue. "); err != nil {
60		return daemon.Settings{}, err
61	}
62	_, err = bufio.NewReader(t.In).ReadString('\n')
63	if err != nil && !errors.Is(err, io.EOF) {
64		return daemon.Settings{}, err
65	}
66	if err = daemon.DismissServerNotices(ctx, conn, settings.UI.ETag, dismissed); err != nil {
67		return daemon.Settings{}, err
68	}
69	// Acknowledgement changes the UI validator; seed the TUI from that result.
70	return daemon.GetSettings(ctx, conn)
71}
72
73// Confirmed reads a [y/N] answer. A late reply to the terminal's startup
74// queries can precede it.
75func Confirmed(answer string) bool {
76	switch strings.ToLower(strings.TrimSpace(ansi.Strip(answer))) {
77	case "y", "yes":
78		return true
79	}
80	return false
81}
82
83func (t *Service) Open(ctx context.Context, prepared app.PreparedOpen) error {
84	final, err := t.runApp(ctx, prepared, func(settings daemon.Settings) *tui.AppModel {
85		return tui.NewAppModel(prepared.Connection, tui.Bootstrap{Sessions: prepared.Sessions, Settings: settings}, prepared.Selected, prepared.Workspace, prepared.LoginRequired, t.OpenBrowser)
86	})
87	if err != nil {
88		return err
89	}
90	if m, ok := final.(*tui.AppModel); ok && m.ActiveSession != nil {
91		_, err = fmt.Fprintf(t.Out, "To reopen this session, run albedo resume %s\n", m.ActiveSession.ID)
92	}
93	return err
94}
95
96func (t *Service) Login(ctx context.Context, prepared app.PreparedOpen, workspace, name string) error {
97	_, err := t.runApp(ctx, prepared, func(settings daemon.Settings) *tui.AppModel {
98		return tui.NewLoginAppModel(prepared.Connection, tui.Bootstrap{Settings: settings}, workspace, name, t.OpenBrowser)
99	})
100	return err
101}
102
103func (t *Service) runApp(ctx context.Context, prepared app.PreparedOpen, build func(daemon.Settings) *tui.AppModel) (tea.Model, error) {
104	settings, err := t.announceMigration(ctx, prepared.Connection, prepared.Settings)
105	if err != nil {
106		return nil, err
107	}
108	if err = tui.DetectInk(); err != nil {
109		return nil, err
110	}
111	leanHeap()
112	model := build(settings)
113	program := tea.NewProgram(model, tea.WithInput(t.In), tea.WithOutput(t.Out))
114	stopCancellation := context.AfterFunc(ctx, func() { program.Send(tea.Quit()) })
115	defer stopCancellation()
116	final, err := program.Run()
117	closed, ok := final.(*tui.AppModel)
118	if !ok {
119		closed = model
120	}
121	if cleanup := closed.Close(); cleanup != nil {
122		cleanup()
123	}
124	if ctx.Err() != nil {
125		return final, ctx.Err()
126	}
127	return final, err
128}
129
130func (t *Service) ConfirmCleanup(sessions bool) (bool, error) {
131	if !t.Capable {
132		return false, errors.New("no files have been removed; run cleanup in a terminal to confirm, or add --yes to approve the changes shown above")
133	}
134	prompt := "Apply the cleanup shown above? [y/N] "
135	if sessions {
136		prompt = "Permanently delete the sessions listed above? [y/N] "
137	}
138	if _, err := fmt.Fprint(t.Out, prompt); err != nil {
139		return false, err
140	}
141	answer, err := bufio.NewReader(t.In).ReadString('\n')
142	if err != nil && !errors.Is(err, io.EOF) {
143		return false, err
144	}
145	return Confirmed(answer), nil
146}
147
148// leanHeap lets the heap grow half past what it holds before collecting,
149// not the default doubling: a terminal UI idles at a few kilobytes a second,
150// so the extra collections cost nothing a person would see and the resident
151// size drops by a sixth. A GOGC set in the environment still wins.
152func leanHeap() {
153	if os.Getenv("GOGC") == "" {
154		debug.SetGCPercent(50)
155	}
156}