feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s

Polish the office TUI with proportional pane layout, drag resize, live
filtering, HTML mail preview, paginated mail lists, overflow scrollbars,
flattened navigation, and project save/status fixes; add UpdateProjectStatus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 22:23:25 +01:00
co-authored by Cursor
parent 92285e439d
commit 72844dfe24
45 changed files with 2611 additions and 206 deletions
+282 -51
View File
@@ -18,6 +18,11 @@ type listLoadedMsg struct {
err error
}
type listMoreLoadedMsg struct {
items []model.Item
err error
}
type detailLoadedMsg struct {
item model.Item
document bool
@@ -27,9 +32,9 @@ type detailLoadedMsg struct {
}
type detailSavedMsg struct {
item model.Item
title, description string
err error
item model.Item
fields model.FormFields
err error
}
type actionDoneMsg struct {
@@ -51,6 +56,8 @@ type Model struct {
hasList bool
items []model.Item
selection *model.Selection
listHasMore bool
listLoadingMore bool
focus model.FocusPane
width int
height int
@@ -63,6 +70,11 @@ type Model struct {
showMenu bool
showList bool
showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
}
// NewModel constructs the TUI with an authenticated client.
@@ -74,20 +86,21 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit",
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
height: h,
width: w,
}
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable()
m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd(), filterSearchBlinkCmd())
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -95,12 +108,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports()
m.syncAllContent()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "esc" && m.filterActive {
m.exitFilter()
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active
default:
cmd := m.filterSearch.Update(msg)
m.applyFilter()
return m, cmd
}
}
if key == "?" {
m.status = helpText()
return m, nil
@@ -108,7 +138,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) {
return m, nil
}
if m.focus == model.FocusPreview {
if m.filterActive && m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
@@ -121,11 +153,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit:
return m, tea.Quit
case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus()
return m, nil
case ActionMoveUp:
@@ -144,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.onListRowChanged()
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1)
m.detail.MoveTabStop(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
@@ -153,7 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1)
m.detail.MoveTabStop(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
@@ -167,10 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case ActionToggleSelect:
if m.hasList {
idx := m.listTable.ItemIndex()
if idx >= 0 {
m.selection.Toggle(&m.items, idx)
m.listTable.UpdateItems(m.items)
if it, ok := m.listTableItem(); ok {
for i := range m.items {
if m.items[i].ID == it.ID {
m.selection.Toggle(&m.items, i)
break
}
}
m.syncListTable()
}
}
return m, nil
@@ -178,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasList && m.showDetail {
m.focus = model.FocusPreview
m.syncPaneFocus()
m.detail.FocusContent()
m.detail.FocusFirstStop()
return m, m.onListRowChanged()
}
return m, nil
@@ -191,6 +235,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionToggleDetailPane:
m.togglePane(3)
return m, nil
case ActionFilter:
m.enterFilter()
return m, nil
case ActionRefresh:
if m.hasList {
m.loading = true
@@ -222,10 +269,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case listLoadedMsg:
m.loading = false
m.listLoadingMore = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
m.listHasMore = false
m.listTable.Clear()
m.detail.Clear()
} else {
@@ -233,9 +282,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items)
if m.filterActive {
m.applyFilter()
}
}
return m, m.onListRowChanged()
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
case listMoreLoadedMsg:
m.listLoadingMore = false
m.listTable.SetLoadingMore(false)
if msg.err != nil {
m.err = msg.err.Error()
return m, nil
}
if len(msg.items) == 0 {
m.listHasMore = false
return m, nil
}
added := m.listTable.AppendItems(msg.items)
if added > 0 {
m.items = m.listTable.Items()
}
m.listHasMore = len(msg.items) >= fetch.MailListPageSize
m.err = ""
return m, nil
case detailLoadedMsg:
m.loading = false
@@ -263,11 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.updateItemAfterSave(msg.item, msg.title, msg.description)
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
fields.Primary = msg.title
fields.Secondary = msg.description
m.detail.LoadForm(msg.item, fields)
m.updateItemAfterSave(msg.item, msg.fields)
m.detail.LoadForm(msg.item, msg.fields)
m.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved"
m.err = ""
@@ -296,6 +365,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.MouseMsg:
if m.handlePaneResizeMouse(msg) {
return m, nil
}
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
return m, cmd
}
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
@@ -303,7 +378,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
cmd = m.detail.Update(msg)
if !m.filterActive {
cmd = m.detail.Update(msg)
}
}
return m, cmd
}
@@ -315,7 +392,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
cmd = m.detail.Update(msg)
if !m.filterActive {
cmd = m.detail.Update(msg)
}
}
return m, cmd
}
@@ -323,7 +402,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key {
case "tab", "shift+tab", "backtab":
m.detail.ToggleZone()
reverse := key != "tab"
if reverse {
if m.detail.TabBackward() {
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
}
} else if m.detail.TabForward() {
m.focus = NextVisibleFocus(m.focus, m.paneVis())
}
m.syncPaneFocus()
return nil, true
case "ctrl+s":
m.loading = true
@@ -347,16 +434,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
return nil, false
}
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
return nil, false
}
if msg.Y > m.paneHeight()+1 {
return nil, false
}
x0, x1 := DetailPaneXRange(m.paneLayout())
if x0 >= x1 || msg.X < x0 || msg.X >= x1 {
return nil, false
}
cmd := m.detail.ScrollDocumentMouse(msg)
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
return cmd, true
}
return cmd, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(delta)
if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
key := "down"
if delta < 0 {
key = "up"
}
m.detail.ScrollDocument(key)
return m, nil
}
if delta < 0 {
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
m.detail.MoveTabStop(delta)
return m, nil
}
@@ -369,7 +477,14 @@ func (m Model) View() string {
var parts []string
if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
parts = append(parts, menuStyle.Render(m.menuVP.View()))
menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
m.menuVP.Height,
m.menuVP.TotalLineCount(),
m.menuVP.YOffset,
)
parts = append(parts, menuStyle.Render(menuBody))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
@@ -377,7 +492,13 @@ func (m Model) View() string {
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
var detailBody string
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
}
status := m.status
@@ -397,11 +518,21 @@ func (m Model) paneVis() PaneVisibility {
}
func (m Model) paneLayout() PaneWidths {
return LayoutWidths(m.width, m.paneVis())
vis := m.paneVis()
if m.customPaneLayout {
return FitPaneWidths(m.width, vis, m.paneSizes)
}
return LayoutWidths(m.width, vis)
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
if m.filterActive {
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
m.detail.SetFocused(false)
return
}
m.filterSearch.SetFocused(false)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
@@ -427,6 +558,9 @@ func (m *Model) togglePane(which int) {
if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis())
}
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports()
m.syncAllContent()
m.syncPaneFocus()
@@ -436,6 +570,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.hasList = false
m.listHasMore = false
m.listLoadingMore = false
m.focus = model.FocusList
m.listTable.SetFocused(true)
m.detail.Clear()
@@ -455,6 +591,7 @@ func (m *Model) layoutViewports() {
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
}
}
@@ -468,12 +605,62 @@ func (m *Model) syncListTable() {
m.listTable.Clear()
return
}
m.listTable.UpdateItems(m.items)
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
m.listTable.UpdateItems(items)
}
func (m *Model) enterFilter() {
if !m.showDetail {
m.showDetail = true
m.layoutViewports()
}
m.filterActive = true
m.focus = model.FocusPreview
m.syncPaneFocus()
m.applyFilter()
}
func (m *Model) exitFilter() {
m.filterActive = false
m.filterSearch.Clear()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
m.syncPaneFocus()
}
func (m *Model) applyFilter() {
q := m.filterSearch.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
}
func (m Model) listTableItem() (model.Item, bool) {
idx := m.listTable.ItemIndex()
if idx < 0 {
return model.Item{}, false
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
if idx >= len(items) {
return model.Item{}, false
}
return items[idx], true
}
func (m *Model) syncMenuContent() {
var b strings.Builder
b.WriteString("Navigation\n\n")
title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\n\n")
menuW := m.menuVP.Width
if menuW < 10 {
menuW = 20
@@ -603,21 +790,52 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
}
func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList {
m.detail.Clear()
return nil
return m.maybeLoadMoreList()
}
idx := m.listTable.ItemIndex()
if idx < 0 || idx >= len(m.items) {
it, ok := m.listTableItem()
if !ok {
m.detail.Clear()
return m.maybeLoadMoreList()
}
var cmds []tea.Cmd
cmds = append(cmds, m.maybeLoadMoreList())
if m.detail.LoadedID() != it.ID {
m.loading = true
cmds = append(cmds, m.loadDetailCmd(it))
}
return tea.Batch(cmds...)
}
const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive {
return nil
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
if !model.SubjectIsMail(m.listSpec.Subject) {
return nil
}
m.loading = true
return m.loadDetailCmd(it)
if !m.listTable.NearEnd(mailListLoadThreshold) {
return nil
}
m.listLoadingMore = true
m.listTable.SetLoadingMore(true)
return m.loadListMoreCmd()
}
func (m *Model) loadListMoreCmd() tea.Cmd {
start := len(m.items)
spec := m.listSpec
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.ListMailMore(ctx, spec, start)
return listMoreLoadedMsg{items: items, err: err}
}
}
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
@@ -634,31 +852,44 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
title := m.detail.form.Primary()
desc := m.detail.form.Secondary()
fields := m.detail.form.FormFields()
return func() tea.Msg {
ctx := context.Background()
err := m.loader.SaveItem(ctx, item, title, desc)
return detailSavedMsg{item: item, title: title, description: desc, err: err}
err := m.loader.SaveItem(ctx, item, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
for i := range m.items {
if m.items[i].ID != item.ID {
continue
}
m.items[i].Title = title
m.items[i].Title = fields.Primary
if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{}
}
m.items[i].Raw["title"] = title
m.items[i].Raw["description"] = description
m.listTable.UpdateItems(m.items)
m.items[i].Raw["title"] = fields.Primary
m.items[i].Raw["description"] = fields.Secondary
if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
m.syncListTable()
return
}
}
func statusInt(s model.ProjectLifecycle) int {
switch s {
case model.ProjectLifecycleClosed:
return 2
case model.ProjectLifecyclePaused:
return 1
default:
return 0
}
}
func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if focused {
@@ -668,5 +899,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
func helpText() string {
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
}