diff --git a/CHANGELOG.md b/CHANGELOG.md index 1571388..c0910e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added +- Calendar events in the interactive TUI now open into a scrollable detail view + with description, organizer, attendees, location, and explicit meeting and + Outlook link availability. `j` opens the supported + `onlineMeeting.joinUrl`; `o` opens the event `webLink`. - `gh msft mail view ` displays a message's metadata and safe plain-text body, with `--json` for clean scriptable output. - A cohesive, adaptive visual design system for the interactive TUI, including diff --git a/README.md b/README.md index ca71c8a..e13b688 100644 --- a/README.md +++ b/README.md @@ -114,15 +114,19 @@ terminals use compact panels and two-line inbox rows to preserve message context | `g` / `G` or home/end | jump to top / bottom of a list or message | | `tab` | switch mail / calendar | | `a` | archive selected message (mail only) | -| `enter` | open selected message; close an open message | +| `enter` | open selected mail or calendar event; close detail | | `esc` / `?` | dismiss / toggle expanded help | | `r` | toggle read state (visual only; mail only) | | `R` | refresh current view or retry after an error | | `q` | quit, or close an open message | Open messages use `j` / `k`, arrow keys, `g`, and `G` to scroll their full -contents. Their footer lists the detail-specific bindings; list footers and -expanded help list only bindings that apply to the active mail or calendar mode. +contents. Open calendar events show their description, participants, location, +and available links. Press `j` to join through `onlineMeeting.joinUrl` (the +preferred meeting link) and `o` to open the Outlook event when its `webLink` is +available. Missing links and browser-launch failures remain visible in the event +detail view. Detail footers, list footers, and expanded help list only bindings +that apply to the active view. ## Why this over WorkIQ directly? diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..497bd86 --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,44 @@ +// Package browser opens validated web URLs with the operating system's default browser. +package browser + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" +) + +// OpenURL opens rawURL in the default browser without invoking a shell. +func OpenURL(rawURL string) error { + return openURL(runtime.GOOS, rawURL, func(name string, args ...string) error { + return exec.Command(name, args...).Run() + }) +} + +func openURL(goos, rawURL string, run func(string, ...string) error) error { + name, args, err := commandForURL(goos, rawURL) + if err != nil { + return err + } + if err := run(name, args...); err != nil { + return fmt.Errorf("open browser: %w", err) + } + return nil +} + +func commandForURL(goos, rawURL string) (string, []string, error) { + parsed, err := url.ParseRequestURI(rawURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", nil, fmt.Errorf("invalid browser URL %q", rawURL) + } + switch goos { + case "darwin": + return "open", []string{rawURL}, nil + case "linux": + return "xdg-open", []string{rawURL}, nil + case "windows": + return "rundll32.exe", []string{"url.dll,FileProtocolHandler", rawURL}, nil + default: + return "", nil, fmt.Errorf("opening browser links is not supported on %s", goos) + } +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 0000000..5e58a22 --- /dev/null +++ b/internal/browser/browser_test.go @@ -0,0 +1,72 @@ +package browser + +import ( + "errors" + "reflect" + "testing" +) + +func TestCommandForURL(t *testing.T) { + tests := []struct { + name string + goos string + rawURL string + wantName string + wantArgs []string + wantErr bool + }{ + {"macOS", "darwin", "https://teams.microsoft.com/l/meetup-join/abc", "open", []string{"https://teams.microsoft.com/l/meetup-join/abc"}, false}, + {"Linux", "linux", "https://outlook.office.com/calendar/item", "xdg-open", []string{"https://outlook.office.com/calendar/item"}, false}, + {"Windows", "windows", "https://outlook.office.com/calendar/item", "rundll32.exe", []string{"url.dll,FileProtocolHandler", "https://outlook.office.com/calendar/item"}, false}, + {"rejects non-web URL", "darwin", "file:///tmp/event", "", nil, true}, + {"rejects malformed URL", "darwin", "not a URL", "", nil, true}, + {"rejects unsupported platform", "plan9", "https://example.com", "", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotArgs, err := commandForURL(tt.goos, tt.rawURL) + if (err != nil) != tt.wantErr { + t.Fatalf("commandForURL() error = %v, wantErr %v", err, tt.wantErr) + } + if gotName != tt.wantName { + t.Errorf("commandForURL() name = %q, want %q", gotName, tt.wantName) + } + if len(gotArgs) != len(tt.wantArgs) { + t.Fatalf("commandForURL() args = %v, want %v", gotArgs, tt.wantArgs) + } + for i, arg := range gotArgs { + if arg != tt.wantArgs[i] { + t.Errorf("commandForURL() arg %d = %q, want %q", i, arg, tt.wantArgs[i]) + } + } + }) + } +} + +func TestOpenURLPropagatesLauncherResult(t *testing.T) { + tests := []struct { + name string + runErr error + wantErr bool + }{ + {"successful launcher", nil, false}, + {"failed launcher", errors.New("exit status 1"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotName string + var gotArgs []string + err := openURL("linux", "https://outlook.office.com/calendar/item", func(name string, args ...string) error { + gotName = name + gotArgs = args + return tt.runErr + }) + if (err != nil) != tt.wantErr { + t.Fatalf("openURL() error = %v, wantErr %v", err, tt.wantErr) + } + if gotName != "xdg-open" || !reflect.DeepEqual(gotArgs, []string{"https://outlook.office.com/calendar/item"}) { + t.Errorf("launcher = %q %v, want xdg-open [https://outlook.office.com/calendar/item]", gotName, gotArgs) + } + }) + } +} diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 908b400..301428b 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -6,9 +6,12 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "strings" "time" "github.com/maxbeizer/gh-msft/internal/mstime" + "github.com/maxbeizer/gh-msft/internal/plaintext" "github.com/maxbeizer/gh-msft/internal/workiq" ) @@ -22,10 +25,35 @@ type Event struct { Organizer string `json:"organizer"` } +// Participant identifies an event organizer or attendee. +type Participant struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// Detail contains the full event data rendered by the interactive TUI. +type Detail struct { + ID string `json:"id"` + Subject string `json:"subject"` + Start mstime.Time `json:"start"` + End mstime.Time `json:"end"` + IsAllDay bool `json:"isAllDay"` + Organizer Participant `json:"organizer"` + Attendees []Participant `json:"attendees"` + Location string `json:"location"` + Body string `json:"body"` + BodyPreview string `json:"bodyPreview"` + WebLink string `json:"webLink"` + JoinURL string `json:"joinUrl"` + IsOnlineMeeting bool `json:"isOnlineMeeting"` +} + // Provider reads calendar data. type Provider interface { // Upcoming returns up to top events starting from now, ordered by start time. Upcoming(ctx context.Context, top int) ([]Event, error) + // GetDetail returns the full event data by id. + GetDetail(ctx context.Context, id string) (Detail, error) } // graphClient is the subset of the WorkIQ client this package needs. @@ -61,6 +89,25 @@ type graphEvent struct { Address string `json:"address"` } `json:"emailAddress"` } `json:"organizer"` + Attendees []struct { + EmailAddress struct { + Name string `json:"name"` + Address string `json:"address"` + } `json:"emailAddress"` + } `json:"attendees"` + Body struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + } `json:"body"` + BodyPreview string `json:"bodyPreview"` + Location struct { + DisplayName string `json:"displayName"` + } `json:"location"` + WebLink string `json:"webLink"` + IsOnlineMeeting bool `json:"isOnlineMeeting"` + OnlineMeeting struct { + JoinURL string `json:"joinUrl"` + } `json:"onlineMeeting"` } type graphEventCollection struct { @@ -100,6 +147,29 @@ func (p *WorkIQProvider) upcomingViaEvents(ctx context.Context, top int) ([]Even return parseEvents(results) } +// GetDetail retrieves one event with its body, attendees, and browser links. +func (p *WorkIQProvider) GetDetail(ctx context.Context, id string) (Detail, error) { + if id == "" { + return Detail{}, fmt.Errorf("calendar: GetDetail requires an event id") + } + url := fmt.Sprintf( + "/me/events/%s?$select=subject,body,bodyPreview,organizer,attendees,start,end,location,webLink,onlineMeeting,isOnlineMeeting", + url.PathEscape(id), + ) + results, err := p.c.Fetch(ctx, url) + if err != nil { + return Detail{}, err + } + if len(results) == 0 { + return Detail{}, fmt.Errorf("calendar: event %q was not found", id) + } + var event graphEvent + if err := json.Unmarshal(results[0].Data, &event); err != nil { + return Detail{}, fmt.Errorf("calendar: decode event: %w", err) + } + return detailFromGraph(event), nil +} + func parseEvents(results []workiq.FetchResult) ([]Event, error) { if len(results) == 0 { return nil, nil @@ -110,18 +180,51 @@ func parseEvents(results []workiq.FetchResult) ([]Event, error) { } events := make([]Event, 0, len(coll.Value)) for _, ge := range coll.Value { - organizer := ge.Organizer.EmailAddress.Name - if organizer == "" { - organizer = ge.Organizer.EmailAddress.Address - } - events = append(events, Event{ - ID: ge.ID, - Subject: ge.Subject, - Start: mstime.Parse(ge.Start.DateTime), - End: mstime.Parse(ge.End.DateTime), - IsAllDay: ge.IsAllDay, - Organizer: organizer, - }) + events = append(events, eventFromGraph(ge)) } return events, nil } + +func eventFromGraph(event graphEvent) Event { + organizer := event.Organizer.EmailAddress.Name + if organizer == "" { + organizer = event.Organizer.EmailAddress.Address + } + return Event{ + ID: event.ID, + Subject: event.Subject, + Start: mstime.Parse(event.Start.DateTime), + End: mstime.Parse(event.End.DateTime), + IsAllDay: event.IsAllDay, + Organizer: organizer, + } +} + +func detailFromGraph(event graphEvent) Detail { + attendees := make([]Participant, 0, len(event.Attendees)) + for _, attendee := range event.Attendees { + attendees = append(attendees, Participant{ + Name: attendee.EmailAddress.Name, + Email: attendee.EmailAddress.Address, + }) + } + body := event.Body.Content + if strings.EqualFold(event.Body.ContentType, "html") { + body = plaintext.HTMLToText(body) + } + return Detail{ + ID: event.ID, + Subject: event.Subject, + Start: mstime.Parse(event.Start.DateTime), + End: mstime.Parse(event.End.DateTime), + IsAllDay: event.IsAllDay, + Organizer: Participant{Name: event.Organizer.EmailAddress.Name, Email: event.Organizer.EmailAddress.Address}, + Attendees: attendees, + Location: event.Location.DisplayName, + Body: body, + BodyPreview: event.BodyPreview, + WebLink: event.WebLink, + JoinURL: event.OnlineMeeting.JoinURL, + IsOnlineMeeting: event.IsOnlineMeeting, + } +} diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index b290429..e60cd3a 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -117,6 +117,55 @@ func TestUpcomingEmpty(t *testing.T) { } } +func TestGetDetailParsesEventAndLinks(t *testing.T) { + fg := &fakeGraph{byURL: map[string]string{"/me/events/E1": `{ + "id":"E1", + "subject":"Planning", + "start":{"dateTime":"2026-07-20T16:30:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-20T17:00:00.0000000","timeZone":"UTC"}, + "organizer":{"emailAddress":{"name":"Ada Lovelace","address":"ada@example.com"}}, + "attendees":[{"emailAddress":{"name":"Grace Hopper","address":"grace@example.com"}}], + "location":{"displayName":"Conference room"}, + "body":{"contentType":"HTML","content":"

Discuss roadmap

"}, + "bodyPreview":"Discuss roadmap", + "webLink":"https://outlook.office.com/calendar/item", + "onlineMeeting":{"joinUrl":"https://teams.microsoft.com/l/meetup-join/abc"}, + "isOnlineMeeting":true + }`}} + p := NewWorkIQProvider(fg) + + got, err := p.GetDetail(context.Background(), "E1") + if err != nil { + t.Fatalf("GetDetail: %v", err) + } + if got.Subject != "Planning" || got.Location != "Conference room" || got.Body != "Discuss roadmap" { + t.Errorf("detail = %+v", got) + } + if got.Organizer.Email != "ada@example.com" || len(got.Attendees) != 1 || got.Attendees[0].Email != "grace@example.com" { + t.Errorf("participants = organizer:%+v attendees:%+v", got.Organizer, got.Attendees) + } + if got.JoinURL != "https://teams.microsoft.com/l/meetup-join/abc" || got.WebLink == "" || !got.IsOnlineMeeting { + t.Errorf("links = %+v", got) + } + if len(fg.calls) != 1 { + t.Fatalf("fetch calls = %v, want one", fg.calls) + } + wantSelect := "$select=subject,body,bodyPreview,organizer,attendees,start,end,location,webLink,onlineMeeting,isOnlineMeeting" + if !strings.Contains(fg.calls[0], wantSelect) { + t.Errorf("fetch URL %q missing detail fields", fg.calls[0]) + } + if strings.Contains(fg.calls[0], "onlineMeetingUrl") { + t.Errorf("fetch URL %q must not use deprecated onlineMeetingUrl", fg.calls[0]) + } +} + +func TestGetDetailRequiresID(t *testing.T) { + p := NewWorkIQProvider(&fakeGraph{}) + if _, err := p.GetDetail(context.Background(), ""); err == nil { + t.Fatal("expected an error for an empty event id") + } +} + // errThenOK errors on the calendarView call and succeeds on /me/events. type errThenOK struct { errURLSub string diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 65d1c7c..a74df06 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -54,12 +54,17 @@ func (f *fakeMail) Body(ctx context.Context, id string) (string, error) { type fakeCal struct { events []calendar.Event err error + detail calendar.Detail } func (f *fakeCal) Upcoming(ctx context.Context, top int) ([]calendar.Event, error) { return f.events, f.err } +func (f *fakeCal) GetDetail(ctx context.Context, id string) (calendar.Detail, error) { + return f.detail, f.err +} + type fakeEULA struct { called bool err error diff --git a/internal/mail/mail.go b/internal/mail/mail.go index 2e56105..6952641 100644 --- a/internal/mail/mail.go +++ b/internal/mail/mail.go @@ -8,11 +8,10 @@ import ( "context" "encoding/json" "fmt" - "html" - "regexp" "strings" "github.com/maxbeizer/gh-msft/internal/mstime" + "github.com/maxbeizer/gh-msft/internal/plaintext" "github.com/maxbeizer/gh-msft/internal/workiq" ) @@ -160,7 +159,7 @@ func (p *WorkIQProvider) GetDetail(ctx context.Context, id string) (Detail, erro } body := gm.Body.Content if strings.EqualFold(gm.Body.ContentType, "html") { - body = htmlToText(body) + body = plaintext.HTMLToText(body) } return NewDetail(messageFromGraph(gm), body), nil } @@ -213,30 +212,7 @@ func (p *WorkIQProvider) Body(ctx context.Context, id string) (string, error) { } content := gm.Body.Content if strings.EqualFold(gm.Body.ContentType, "html") { - content = htmlToText(content) + content = plaintext.HTMLToText(content) } return content, nil } - -var ( - htmlScriptStyleRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?`) - htmlBreakRE = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])[^>]*>`) - htmlTagRE = regexp.MustCompile(`(?s)<[^>]+>`) - htmlBlankRE = regexp.MustCompile(`\n{3,}`) -) - -// htmlToText turns an HTML mail body into readable plain text. -func htmlToText(s string) string { - s = htmlScriptStyleRE.ReplaceAllString(s, "") - s = htmlBreakRE.ReplaceAllString(s, "\n") - s = htmlTagRE.ReplaceAllString(s, "") - s = html.UnescapeString(s) - s = strings.ReplaceAll(s, "\r", "") - lines := strings.Split(s, "\n") - for i, ln := range lines { - lines[i] = strings.TrimRight(ln, " \t") - } - s = strings.Join(lines, "\n") - s = htmlBlankRE.ReplaceAllString(s, "\n\n") - return strings.TrimSpace(s) -} diff --git a/internal/plaintext/plaintext.go b/internal/plaintext/plaintext.go new file mode 100644 index 0000000..907eb97 --- /dev/null +++ b/internal/plaintext/plaintext.go @@ -0,0 +1,31 @@ +// Package plaintext converts rich text content into readable terminal text. +package plaintext + +import ( + "html" + "regexp" + "strings" +) + +var ( + htmlScriptStyleRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?`) + htmlBreakRE = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])[^>]*>`) + htmlTagRE = regexp.MustCompile(`(?s)<[^>]+>`) + htmlBlankRE = regexp.MustCompile(`\n{3,}`) +) + +// HTMLToText turns HTML into readable plain text. +func HTMLToText(s string) string { + s = htmlScriptStyleRE.ReplaceAllString(s, "") + s = htmlBreakRE.ReplaceAllString(s, "\n") + s = htmlTagRE.ReplaceAllString(s, "") + s = html.UnescapeString(s) + s = strings.ReplaceAll(s, "\r", "") + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + s = strings.Join(lines, "\n") + s = htmlBlankRE.ReplaceAllString(s, "\n\n") + return strings.TrimSpace(s) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 48efd54..849af87 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/maxbeizer/gh-msft/internal/browser" "github.com/maxbeizer/gh-msft/internal/calendar" "github.com/maxbeizer/gh-msft/internal/mail" ) @@ -22,6 +23,19 @@ type bodyLoadedMsg struct { id string body string } +type eventDetailLoadedMsg struct { + request uint + detail calendar.Detail +} +type eventDetailErrMsg struct { + request uint + err error +} +type urlOpenedMsg struct{ label string } +type urlOpenErrMsg struct { + label string + err error +} type archivedMsg struct{ id string } type errMsg struct{ err error } @@ -54,9 +68,14 @@ type Model struct { quitting bool viewing bool + viewingEvent bool body string bodyLoading bool + eventDetail calendar.Detail + eventLoading bool + eventRequest uint detailOffset int + openURL func(string) error width int height int @@ -68,7 +87,7 @@ func New(provider mail.Provider, top int, all bool) Model { if top <= 0 { top = 50 } - return Model{provider: provider, top: top, all: all, loading: true} + return Model{provider: provider, top: top, all: all, loading: true, openURL: browser.OpenURL} } // Init kicks off the initial load for the starting mode. @@ -123,6 +142,28 @@ func bodyCmd(provider mail.Provider, id string) tea.Cmd { } } +func eventDetailCmd(provider calendar.Provider, id string, request uint) tea.Cmd { + return func() tea.Msg { + if provider == nil { + return eventDetailErrMsg{request: request, err: fmt.Errorf("calendar details are unavailable")} + } + detail, err := provider.GetDetail(context.Background(), id) + if err != nil { + return eventDetailErrMsg{request: request, err: err} + } + return eventDetailLoadedMsg{request: request, detail: detail} + } +} + +func openURLCmd(openURL func(string) error, rawURL, label string) tea.Cmd { + return func() tea.Msg { + if err := openURL(rawURL); err != nil { + return urlOpenErrMsg{label: label, err: err} + } + return urlOpenedMsg{label: label} + } +} + // Update satisfies tea.Model. The concrete-typed update method below carries the // real logic so it can be unit-tested without interface boxing. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -160,11 +201,41 @@ func (m Model) update(msg tea.Msg) (Model, tea.Cmd) { m.clampDetailOffset() return m, nil + case eventDetailLoadedMsg: + if !m.isActiveEventRequest(msg.request) { + return m, nil + } + m.eventDetail = msg.detail + m.eventLoading = false + m.clampDetailOffset() + return m, nil + + case eventDetailErrMsg: + if !m.isActiveEventRequest(msg.request) { + return m, nil + } + m.err = msg.err + m.loading = false + m.eventLoading = false + m.viewing = false + m.viewingEvent = false + return m, nil + + case urlOpenedMsg: + m.status = "Opened " + msg.label + " in your browser." + return m, nil + + case urlOpenErrMsg: + m.status = fmt.Sprintf("Could not open %s: %v", msg.label, msg.err) + return m, nil + case errMsg: m.err = msg.err m.loading = false m.bodyLoading = false + m.eventLoading = false m.viewing = false + m.viewingEvent = false return m, nil case archivedMsg: @@ -205,6 +276,20 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { } return m, nil case "enter": + if m.mode == calendarMode { + sel := m.selectedEvent() + if sel == nil { + return m, nil + } + m.viewing = true + m.viewingEvent = true + m.eventLoading = true + m.eventRequest++ + m.eventDetail = calendar.Detail{} + m.detailOffset = 0 + m.status = "" + return m, eventDetailCmd(m.cal, sel.ID, m.eventRequest) + } sel := m.selected() if sel == nil { return m, nil @@ -256,11 +341,19 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc", "enter", "q", "backspace": - m.viewing = false - m.body = "" - m.bodyLoading = false - m.detailOffset = 0 - case "j", "down": + m.closeDetail() + case "j": + if m.viewingEvent { + return m.openEventURL(m.eventDetail.JoinURL, "meeting link") + } + m.detailOffset++ + m.clampDetailOffset() + case "o": + if m.viewingEvent { + return m.openEventURL(m.eventDetail.WebLink, "Outlook event") + } + return m, nil + case "down": m.detailOffset++ m.clampDetailOffset() case "k", "up": @@ -274,6 +367,34 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } +func (m *Model) closeDetail() { + m.viewing = false + m.viewingEvent = false + m.body = "" + m.bodyLoading = false + m.eventDetail = calendar.Detail{} + m.eventLoading = false + m.detailOffset = 0 + m.status = "" +} + +func (m Model) isActiveEventRequest(request uint) bool { + return m.viewing && m.viewingEvent && m.eventRequest == request +} + +func (m Model) openEventURL(rawURL, label string) (Model, tea.Cmd) { + if rawURL == "" { + m.status = "This event has no " + label + "." + return m, nil + } + if m.openURL == nil { + m.status = "Could not open " + label + ": browser launcher is unavailable." + return m, nil + } + m.status = "Opening " + label + "…" + return m, openURLCmd(m.openURL, rawURL, label) +} + // loadForModeCmd returns a load command when the current mode's data has not been // fetched yet, or nil when it is already loaded. func (m Model) loadForModeCmd() tea.Cmd { @@ -329,7 +450,7 @@ func (m *Model) clampDetailOffset() { } // selected returns the highlighted mail message, or nil when not in mail mode or -// the inbox is empty. Calendar mode has no selectable mail actions yet. +// the inbox is empty. func (m *Model) selected() *mail.Message { if m.mode != mailMode { return nil @@ -340,6 +461,16 @@ func (m *Model) selected() *mail.Message { return &m.messages[m.cursor] } +func (m *Model) selectedEvent() *calendar.Event { + if m.mode != calendarMode { + return nil + } + if m.cursor < 0 || m.cursor >= len(m.events) { + return nil + } + return &m.events[m.cursor] +} + func (m *Model) removeByID(id string) { for i, msg := range m.messages { if msg.ID == id { @@ -477,20 +608,23 @@ func (m Model) footer() string { func (m Model) compactHelp() string { if m.mode == calendarMode { - return "j/k move · tab switch · ? help · q quit" + return "j/k move · enter open · tab switch · ? help · q quit" } return "j/k move · enter open · tab switch · ? help · q quit" } func (m Model) expandedHelp() string { if m.mode == calendarMode { - return "j/k or ↑/↓ move · g/G or home/end top/bottom · R refresh · tab mail · ?/esc close help · q quit" + return "j/k or ↑/↓ move · g/G or home/end top/bottom · enter open · R refresh · tab mail · ?/esc close help · q quit" } return "j/k or ↑/↓ move · g/G or home/end top/bottom · enter open · a archive · r toggle read · R refresh · tab calendar · ?/esc close help · q quit" } -// detailView renders the body of the selected message. +// detailView renders the active mail or calendar detail. func (m Model) detailView() string { + if m.viewingEvent { + return m.eventDetailView() + } sel := m.selected() if sel == nil { return m.screen(m.chrome("Message", -1), styles.empty.Render("No message.")+"\n\n"+styles.help.Render("Help: esc to go back")) @@ -511,6 +645,9 @@ func (m Model) detailView() string { } func (m Model) detailLines() []string { + if m.viewingEvent { + return m.eventDetailLines() + } sel := m.selected() if sel == nil { return []string{"No message."} @@ -537,6 +674,95 @@ func (m Model) detailLines() []string { return lines } +func (m Model) eventDetailView() string { + var b strings.Builder + lines := m.detailLines() + m.clampDetailOffset() + end := m.detailOffset + m.detailHeight() + if end > len(lines) { + end = len(lines) + } + b.WriteString(strings.Join(lines[m.detailOffset:end], "\n")) + b.WriteString("\n") + if m.status != "" { + b.WriteString(styles.status.Render("Status: " + m.status)) + b.WriteString("\n") + } + b.WriteString(styles.help.Render("Help: j join meeting · o open Outlook · ↓/k scroll · g/G top/bottom · esc/enter/q back · ctrl+c quit")) + b.WriteString("\n") + return m.screen(m.chrome("Calendar event", -1), b.String()) +} + +func (m Model) eventDetailLines() []string { + if m.eventLoading { + return []string{styles.loading.Render("Loading event…")} + } + detail := m.eventDetail + width := m.listWidth() + subject := detail.Subject + if subject == "" { + subject = "(untitled event)" + } + lines := []string{styles.header.Render(truncate(subject, width)), ""} + when := eventWhen(calendar.Event{Start: detail.Start, End: detail.End, IsAllDay: detail.IsAllDay}) + lines = append(lines, + styles.metadata.Render("When: "+when), + styles.metadata.Render(truncate("Organizer: "+formatParticipant(detail.Organizer), width)), + styles.metadata.Render(truncate("Attendees: "+formatParticipants(detail.Attendees), width)), + styles.metadata.Render(truncate("Location: "+orDash(detail.Location), width)), + ) + if detail.JoinURL == "" { + lines = append(lines, styles.metadata.Render("Meeting link: unavailable")) + } else { + lines = append(lines, styles.metadata.Render("Meeting link: available (j to join)")) + } + if detail.WebLink == "" { + lines = append(lines, styles.metadata.Render("Outlook link: unavailable")) + } else { + lines = append(lines, styles.metadata.Render("Outlook link: available (o to open)")) + } + lines = append(lines, "") + body := detail.Body + if body == "" { + body = detail.BodyPreview + } + if body == "" { + lines = append(lines, styles.empty.Render("This event has no description.")) + } else { + lines = append(lines, strings.Split(lipgloss.NewStyle().Width(width).Render(body), "\n")...) + } + return lines +} + +func formatParticipant(participant calendar.Participant) string { + switch { + case participant.Name != "" && participant.Email != "": + return fmt.Sprintf("%s <%s>", participant.Name, participant.Email) + case participant.Email != "": + return participant.Email + default: + return orDash(participant.Name) + } +} + +func formatParticipants(participants []calendar.Participant) string { + if len(participants) == 0 { + return "-" + } + formatted := make([]string, len(participants)) + for i, participant := range participants { + formatted[i] = formatParticipant(participant) + } + return strings.Join(formatted, ", ") +} + +func orDash(value string) string { + if value == "" { + return "-" + } + return value +} + func (m Model) detailHeight() int { if m.height <= 0 { return 20 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ef769a5..a50c4cc 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -51,8 +51,11 @@ func (f *fakeProvider) Archive(ctx context.Context, id string) error { } type fakeCal struct { - events []calendar.Event - err error + events []calendar.Event + err error + detail calendar.Detail + detailErr error + detailIDs []string } func (f *fakeCal) Upcoming(ctx context.Context, top int) ([]calendar.Event, error) { @@ -62,6 +65,14 @@ func (f *fakeCal) Upcoming(ctx context.Context, top int) ([]calendar.Event, erro return f.events, nil } +func (f *fakeCal) GetDetail(ctx context.Context, id string) (calendar.Detail, error) { + f.detailIDs = append(f.detailIDs, id) + if f.detailErr != nil { + return calendar.Detail{}, f.detailErr + } + return f.detail, nil +} + func (f *fakeProvider) Body(ctx context.Context, id string) (string, error) { f.bodyIDs = append(f.bodyIDs, id) if f.bodyErr != nil { @@ -398,6 +409,185 @@ func TestEnterOnEmptyInboxIsNoOp(t *testing.T) { } } +func TestEnterOpensCalendarEventAndBrowserActions(t *testing.T) { + fc := &fakeCal{ + events: sampleEvents(), + detail: calendar.Detail{ + Subject: "Standup", + Start: mstime.Parse("2026-01-02T15:00:00Z"), + End: mstime.Parse("2026-01-02T15:15:00Z"), + Organizer: calendar.Participant{Name: "Alice", Email: "alice@example.com"}, + Attendees: []calendar.Participant{{Name: "Bob", Email: "bob@example.com"}}, + Location: "Room 1", + Body: "Agenda", + JoinURL: "https://teams.microsoft.com/l/meetup-join/standup", + WebLink: "https://outlook.office.com/calendar/standup", + }, + } + var opened []string + m := New(&fakeProvider{}, 10, false) + m.cal = fc + m.openURL = func(rawURL string) error { + opened = append(opened, rawURL) + return nil + } + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()}) + + m, cmd := m.update(key("enter")) + if !m.viewing || !m.viewingEvent || !m.eventLoading { + t.Fatal("enter should open a loading calendar detail view") + } + if cmd == nil { + t.Fatal("enter should fetch event detail") + } + msg := cmd() + detailMsg, ok := msg.(eventDetailLoadedMsg) + if !ok { + t.Fatalf("expected eventDetailLoadedMsg, got %T", msg) + } + if detailMsg.request != m.eventRequest { + t.Errorf("detail request = %d, want %d", detailMsg.request, m.eventRequest) + } + m, _ = m.update(detailMsg) + if m.eventLoading || len(fc.detailIDs) != 1 || fc.detailIDs[0] != "e1" { + t.Errorf("detail loading state = %v, ids = %v", m.eventLoading, fc.detailIDs) + } + + for _, want := range []string{"Organizer: Alice ", "Attendees: Bob ", "Location: Room 1", "Agenda", "Meeting link: available"} { + if !strings.Contains(m.View(), want) { + t.Errorf("calendar detail missing %q; got:\n%s", want, m.View()) + } + } + + m, cmd = m.update(key("j")) + if cmd == nil { + t.Fatal("j should open the online-meeting join link") + } + m, _ = m.update(cmd()) + if len(opened) != 1 || opened[0] != fc.detail.JoinURL { + t.Errorf("opened URLs = %v", opened) + } + if !strings.Contains(m.View(), "Opened meeting link in your browser.") { + t.Errorf("success status missing; got:\n%s", m.View()) + } + + m, cmd = m.update(key("o")) + if cmd == nil { + t.Fatal("o should open the Outlook web link") + } + m, _ = m.update(cmd()) + if len(opened) != 2 || opened[1] != fc.detail.WebLink { + t.Errorf("opened URLs = %v", opened) + } +} + +func TestStaleEventDetailResultsAreIgnored(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m.cal = &fakeCal{events: sampleEvents()} + m.mode = calendarMode + m, _ = m.update(eventsLoadedMsg{sampleEvents()}) + + m, _ = m.update(key("enter")) + firstRequest := m.eventRequest + m, _ = m.update(key("esc")) + m.cursor = 1 + m, _ = m.update(key("enter")) + secondRequest := m.eventRequest + if secondRequest == firstRequest { + t.Fatal("each event detail request must have a new request id") + } + + m, _ = m.update(eventDetailLoadedMsg{ + request: firstRequest, + detail: calendar.Detail{Subject: "Stale event"}, + }) + m, _ = m.update(eventDetailErrMsg{request: firstRequest, err: errors.New("stale failure")}) + if !m.viewing || !m.eventLoading || m.eventDetail.Subject != "" || m.err != nil { + t.Fatalf("stale detail changed active view: %+v", m) + } + + m, _ = m.update(eventDetailLoadedMsg{ + request: secondRequest, + detail: calendar.Detail{Subject: "Current event"}, + }) + if m.eventLoading || m.eventDetail.Subject != "Current event" { + t.Errorf("current detail = %+v, loading = %v", m.eventDetail, m.eventLoading) + } +} + +func TestEventDetailCommandScopesErrorsToRequest(t *testing.T) { + wantErr := errors.New("event unavailable") + msg := eventDetailCmd(&fakeCal{detailErr: wantErr}, "e1", 7)() + got, ok := msg.(eventDetailErrMsg) + if !ok { + t.Fatalf("expected eventDetailErrMsg, got %T", msg) + } + if got.request != 7 || !errors.Is(got.err, wantErr) { + t.Errorf("event detail error = %+v, want request 7 with %v", got, wantErr) + } +} + +func TestCalendarDetailMissingAndFailedLinksSurfaceStatus(t *testing.T) { + tests := []struct { + name string + detail calendar.Detail + openURL func(string) error + key string + wantStatus string + wantCmd bool + }{ + { + name: "missing meeting link", + detail: calendar.Detail{Subject: "Offline"}, + key: "j", + wantStatus: "This event has no meeting link.", + }, + { + name: "browser launch failure", + detail: calendar.Detail{Subject: "Planning", JoinURL: "https://teams.microsoft.com/l/meetup-join/abc"}, + openURL: func(string) error { + return errors.New("browser unavailable") + }, + key: "j", + wantStatus: "Could not open meeting link: browser unavailable", + wantCmd: true, + }, + { + name: "missing Outlook link", + detail: calendar.Detail{Subject: "Planning"}, + key: "o", + wantStatus: "This event has no Outlook event.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New(&fakeProvider{}, 10, false) + m.loading = false + m.viewing = true + m.viewingEvent = true + m.eventDetail = tt.detail + if tt.openURL != nil { + m.openURL = tt.openURL + } + + m, cmd := m.update(key(tt.key)) + if (cmd != nil) != tt.wantCmd { + t.Fatalf("command = %v, want command %v", cmd != nil, tt.wantCmd) + } + if cmd != nil { + m, _ = m.update(cmd()) + } + if m.status != tt.wantStatus { + t.Errorf("status = %q, want %q", m.status, tt.wantStatus) + } + if !strings.Contains(m.View(), tt.wantStatus) { + t.Errorf("detail view missing status %q; got:\n%s", tt.wantStatus, m.View()) + } + }) + } +} + func TestDetailViewKeysClose(t *testing.T) { fp := &fakeProvider{body: "body text"} m := New(fp, 10, false)