feat(tui): section archive context panes
This commit is contained in:
parent
a2fe832435
commit
2f13897830
167
tui/tui.go
167
tui/tui.go
@ -683,7 +683,7 @@ func newModel(opts Options) model {
|
||||
sourceLocation: strings.TrimSpace(opts.SourceLocation),
|
||||
layoutPreset: layout,
|
||||
sortMode: initialGroupSortMode(layout),
|
||||
memberSortMode: sortNewest,
|
||||
memberSortMode: initialMemberSortMode(layout),
|
||||
compactDetail: initialCompactDetail(layout),
|
||||
detailView: viewport.New(1, 1),
|
||||
}
|
||||
@ -717,6 +717,17 @@ func initialCompactDetail(layout LayoutPreset) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func initialMemberSortMode(layout LayoutPreset) sortMode {
|
||||
switch layout {
|
||||
case LayoutChat:
|
||||
return sortDefault
|
||||
case LayoutDocument:
|
||||
return sortKind
|
||||
default:
|
||||
return sortDefault
|
||||
}
|
||||
}
|
||||
|
||||
func (m *model) applyInitialGroupMode() {
|
||||
if m.layoutPreset != LayoutChat || m.groupMode != groupByDefault || len(m.groups) > 1 {
|
||||
return
|
||||
@ -752,6 +763,12 @@ type tableColumn struct {
|
||||
|
||||
type tableRow []string
|
||||
|
||||
type contextRow struct {
|
||||
ItemIndex int
|
||||
Label string
|
||||
Selectable bool
|
||||
}
|
||||
|
||||
type archiveLayout struct {
|
||||
rows rect
|
||||
context rect
|
||||
@ -1023,12 +1040,18 @@ func (m *model) selectMemberAt(rect rect, x, y int) (int, bool) {
|
||||
m.clearLastClick()
|
||||
return 0, false
|
||||
}
|
||||
members := m.currentGroupMembers()
|
||||
contextRows := m.currentContextRows()
|
||||
memberOffset := m.contextOffset + row
|
||||
if row < 0 || row >= rowsViewportHeight(rect.h) || memberOffset < 0 || memberOffset >= len(members) {
|
||||
if row < 0 || row >= rowsViewportHeight(rect.h) || memberOffset < 0 || memberOffset >= len(contextRows) {
|
||||
return 0, false
|
||||
}
|
||||
itemIndex := members[memberOffset]
|
||||
contextRow := contextRows[memberOffset]
|
||||
if !contextRow.Selectable {
|
||||
m.status = contextRow.Label
|
||||
m.clearLastClick()
|
||||
return memberOffset, false
|
||||
}
|
||||
itemIndex := contextRow.ItemIndex
|
||||
m.selectItemIndex(itemIndex)
|
||||
m.detailView.GotoTop()
|
||||
m.ensureVisible()
|
||||
@ -1903,18 +1926,25 @@ func (m model) renderContextPane(rect rect) string {
|
||||
if !ok {
|
||||
return pane(m.memberPaneTitle(), "", []string{"No group selected."}, rect, focusContext, m.focus, contextPaneAccent)
|
||||
}
|
||||
members := m.currentGroupMembers()
|
||||
contextRows := m.currentContextRows()
|
||||
columns := m.memberColumns(width)
|
||||
rows := m.memberTableRows(columns, members)
|
||||
if len(members) == 0 {
|
||||
rows := m.memberTableRows(columns, contextRows)
|
||||
if len(contextRows) == 0 {
|
||||
rows = []tableRow{messageTableRow(columns, "no rows in group")}
|
||||
}
|
||||
selectedItem := m.currentItemIndex()
|
||||
tableView := renderStyledTable(columns, rows, m.contextOffset, height, width, contextPaneAccent, func(index int) lipgloss.Style {
|
||||
if index < 0 || index >= len(members) {
|
||||
if index < 0 || index >= len(contextRows) {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveTextFG))
|
||||
}
|
||||
return rowStyle(width, members[index] == selectedItem, m.focus == focusContext, itemInactive(m.items[members[index]]))
|
||||
row := contextRows[index]
|
||||
if !row.Selectable {
|
||||
return sectionRowStyle(width)
|
||||
}
|
||||
if row.ItemIndex < 0 || row.ItemIndex >= len(m.items) {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveTextFG))
|
||||
}
|
||||
return rowStyle(width, row.ItemIndex == selectedItem, m.focus == focusContext, itemInactive(m.items[row.ItemIndex]))
|
||||
})
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, paneTitleWithLabelForWidth(m.memberPaneTitle(), focusContext, m.focus, m.memberPositionLabel()+" "+group.Title, width), tableView)
|
||||
return paneStyle(focusContext, m.focus, rect.w, rect.h, contextPaneAccent).Render(content)
|
||||
@ -2365,7 +2395,7 @@ func (m model) focusedPageSize() int {
|
||||
}
|
||||
|
||||
func (m model) maxContextOffset() int {
|
||||
return maxInt(0, len(m.currentGroupMembers())-rowsViewportHeight(m.layout().context.h))
|
||||
return maxInt(0, len(m.currentContextRows())-rowsViewportHeight(m.layout().context.h))
|
||||
}
|
||||
|
||||
func (m *model) applyFilter() {
|
||||
@ -2700,12 +2730,12 @@ func (m *model) ensureVisible() {
|
||||
}
|
||||
m.offset = clampInt(m.offset, 0, maxInt(len(m.groups)-1, 0))
|
||||
memberPage := rowsViewportHeight(m.layout().context.h)
|
||||
memberIndex := m.currentMemberOffset()
|
||||
if memberIndex < m.contextOffset {
|
||||
m.contextOffset = memberIndex
|
||||
contextRowIndex := m.currentContextRowOffset()
|
||||
if contextRowIndex < m.contextOffset {
|
||||
m.contextOffset = contextRowIndex
|
||||
}
|
||||
if memberIndex >= m.contextOffset+memberPage {
|
||||
m.contextOffset = memberIndex - memberPage + 1
|
||||
if contextRowIndex >= m.contextOffset+memberPage {
|
||||
m.contextOffset = contextRowIndex - memberPage + 1
|
||||
}
|
||||
m.contextOffset = clampInt(m.contextOffset, 0, m.maxContextOffset())
|
||||
}
|
||||
@ -2851,6 +2881,78 @@ func (m model) currentGroupMembers() []int {
|
||||
return group.Members
|
||||
}
|
||||
|
||||
func (m model) currentContextRows() []contextRow {
|
||||
return m.contextRowsForMembers(m.currentGroupMembers())
|
||||
}
|
||||
|
||||
func (m model) contextRowsForMembers(members []int) []contextRow {
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch m.layoutPreset {
|
||||
case LayoutChat:
|
||||
if m.memberSortMode == sortDefault || m.memberSortMode == sortNewest || m.memberSortMode == sortOldest {
|
||||
return m.contextRowsWithSections(members, chatDateSectionLabel)
|
||||
}
|
||||
case LayoutDocument:
|
||||
if m.memberSortMode == sortDefault || m.memberSortMode == sortKind {
|
||||
return m.contextRowsWithSections(members, documentKindSectionLabel)
|
||||
}
|
||||
}
|
||||
rows := make([]contextRow, 0, len(members))
|
||||
for _, itemIndex := range members {
|
||||
rows = append(rows, contextRow{ItemIndex: itemIndex, Selectable: true})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m model) contextRowsWithSections(members []int, labelFor func(Item) string) []contextRow {
|
||||
labels := make([]string, len(members))
|
||||
counts := map[string]int{}
|
||||
for index, itemIndex := range members {
|
||||
if itemIndex < 0 || itemIndex >= len(m.items) {
|
||||
continue
|
||||
}
|
||||
label := labelFor(m.items[itemIndex])
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = "OTHER"
|
||||
}
|
||||
labels[index] = label
|
||||
counts[label]++
|
||||
}
|
||||
rows := make([]contextRow, 0, len(members)+len(counts))
|
||||
previous := ""
|
||||
for index, itemIndex := range members {
|
||||
label := labels[index]
|
||||
if label != "" && label != previous {
|
||||
rows = append(rows, contextRow{Label: fmt.Sprintf("%s (%d)", label, counts[label])})
|
||||
previous = label
|
||||
}
|
||||
rows = append(rows, contextRow{ItemIndex: itemIndex, Selectable: true})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func chatDateSectionLabel(item Item) string {
|
||||
if t, ok := itemSortTime(item); ok {
|
||||
return strings.ToUpper(t.UTC().Format("2006-01-02 Mon"))
|
||||
}
|
||||
return "UNDATED"
|
||||
}
|
||||
|
||||
func documentKindSectionLabel(item Item) string {
|
||||
switch strings.ToLower(strings.TrimSpace(itemKind(item))) {
|
||||
case "database", "collection":
|
||||
return "DATABASES"
|
||||
case "page":
|
||||
return "PAGES"
|
||||
case "block":
|
||||
return "BLOCKS"
|
||||
default:
|
||||
return strings.ToUpper(firstNonEmpty(itemKind(item), "items"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) currentMemberOffset() int {
|
||||
itemIndex := m.currentItemIndex()
|
||||
members := m.currentGroupMembers()
|
||||
@ -2862,6 +2964,20 @@ func (m model) currentMemberOffset() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m model) currentContextRowOffset() int {
|
||||
itemIndex := m.currentItemIndex()
|
||||
if itemIndex < 0 {
|
||||
return 0
|
||||
}
|
||||
rows := m.currentContextRows()
|
||||
for index, row := range rows {
|
||||
if row.Selectable && row.ItemIndex == itemIndex {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *model) selectItemIndex(itemIndex int) {
|
||||
for index, filteredIndex := range m.filtered {
|
||||
if filteredIndex == itemIndex {
|
||||
@ -3350,13 +3466,17 @@ func (m model) groupTableRows(columns []tableColumn) []tableRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m model) memberTableRows(columns []tableColumn, members []int) []tableRow {
|
||||
rows := make([]tableRow, 0, len(members))
|
||||
for _, itemIndex := range members {
|
||||
if itemIndex < 0 || itemIndex >= len(m.items) {
|
||||
func (m model) memberTableRows(columns []tableColumn, contextRows []contextRow) []tableRow {
|
||||
rows := make([]tableRow, 0, len(contextRows))
|
||||
for _, contextRow := range contextRows {
|
||||
if !contextRow.Selectable {
|
||||
rows = append(rows, messageTableRow(columns, contextRow.Label))
|
||||
continue
|
||||
}
|
||||
item := m.items[itemIndex]
|
||||
if contextRow.ItemIndex < 0 || contextRow.ItemIndex >= len(m.items) {
|
||||
continue
|
||||
}
|
||||
item := m.items[contextRow.ItemIndex]
|
||||
title := displayRowTitle(item.Title)
|
||||
if item.Depth > 0 {
|
||||
title = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + title
|
||||
@ -5052,6 +5172,13 @@ func rowStyle(width int, selected bool, focused bool, inactive bool) lipgloss.St
|
||||
Background(lipgloss.Color(archiveActiveRowBG))
|
||||
}
|
||||
|
||||
func sectionRowStyle(width int) lipgloss.Style {
|
||||
return lipgloss.NewStyle().
|
||||
Width(width).
|
||||
Foreground(lipgloss.Color(archiveSubtleAccentFG)).
|
||||
Bold(true)
|
||||
}
|
||||
|
||||
func itemInactive(item Item) bool {
|
||||
for _, value := range []string{
|
||||
fieldValue(item, "status"),
|
||||
|
||||
@ -744,7 +744,7 @@ func TestFooterControlsPrioritizeGitcrawlMuscleMemory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMembersDefaultToNewestFirstLikeGitcrawl(t *testing.T) {
|
||||
func TestChatMembersDefaultToChronologicalSectionsLikeGitcrawl(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "slacrawl archive",
|
||||
Layout: LayoutChat,
|
||||
@ -757,13 +757,17 @@ func TestChatMembersDefaultToNewestFirstLikeGitcrawl(t *testing.T) {
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("members = %#v", members)
|
||||
}
|
||||
if got := m.items[members[0]].ID; got != "new" {
|
||||
t.Fatalf("first member = %q, want newest message first", got)
|
||||
}
|
||||
m.setMemberSortMode(sortOldest)
|
||||
members = m.currentGroupMembers()
|
||||
if got := m.items[members[0]].ID; got != "old" {
|
||||
t.Fatalf("oldest sort first member = %q, want oldest message first", got)
|
||||
t.Fatalf("first member = %q, want chronological message first", got)
|
||||
}
|
||||
contextRows := m.currentContextRows()
|
||||
if len(contextRows) != 3 || contextRows[0].Selectable || !strings.Contains(contextRows[0].Label, "2026-05-01") {
|
||||
t.Fatalf("chat context rows should start with a date section: %#v", contextRows)
|
||||
}
|
||||
m.setMemberSortMode(sortNewest)
|
||||
members = m.currentGroupMembers()
|
||||
if got := m.items[members[0]].ID; got != "new" {
|
||||
t.Fatalf("newest sort first member = %q, want newest message first", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -781,7 +785,8 @@ func TestChatMemberColumnsExposeThreadState(t *testing.T) {
|
||||
if !strings.Contains(header, "rel") {
|
||||
t.Fatalf("chat member header should expose relation column:\n%s", header)
|
||||
}
|
||||
rows := m.memberTableRows(columns, m.currentGroupMembers())
|
||||
m.setMemberSortMode(sortTitle)
|
||||
rows := m.memberTableRows(columns, m.currentContextRows())
|
||||
rendered := stripANSI(strings.Join([]string{
|
||||
renderTableRow(columns, rows[0], 52, lipgloss.NewStyle()),
|
||||
renderTableRow(columns, rows[1], 52, lipgloss.NewStyle()),
|
||||
@ -1429,7 +1434,7 @@ func TestGitcrawlKeymapCyclesGroupAndMemberSort(t *testing.T) {
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}})
|
||||
m = updated.(model)
|
||||
if m.menuOpen || m.memberSortMode != sortOldest || !strings.Contains(m.status, "Member sort: oldest") {
|
||||
if m.menuOpen || m.memberSortMode != sortNewest || !strings.Contains(m.status, "Member sort: newest") {
|
||||
t.Fatalf("m should cycle member sort, menu=%v member=%v status=%q", m.menuOpen, m.memberSortMode, m.status)
|
||||
}
|
||||
}
|
||||
@ -1580,7 +1585,7 @@ func TestChatExplorerGroupsChannelsAndListsMessages(t *testing.T) {
|
||||
}
|
||||
m.focus = focusContext
|
||||
item, ok := m.selectedItem()
|
||||
if !ok || item.Title != "second" {
|
||||
if !ok || item.Title != "first" {
|
||||
t.Fatalf("selected member = %#v ok=%v", item, ok)
|
||||
}
|
||||
}
|
||||
@ -1681,6 +1686,28 @@ func TestDocumentContextColumnsAvoidEmptyChatAuthorSlot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentContextStartsWithKindSections(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "notcrawl archive",
|
||||
Layout: LayoutDocument,
|
||||
Items: []Item{
|
||||
Row{Kind: "page", ParentID: "Marketing", Title: "Gideon's SF Events", UpdatedAt: "2026-05-01T17:52:33Z"}.ItemForLayout(LayoutDocument),
|
||||
Row{Kind: "database", ParentID: "Marketing", Title: "Launch database", UpdatedAt: "2026-05-01T16:00:00Z"}.ItemForLayout(LayoutDocument),
|
||||
},
|
||||
})
|
||||
if m.memberSortMode != sortKind {
|
||||
t.Fatalf("document member sort = %v, want kind", m.memberSortMode)
|
||||
}
|
||||
rows := m.currentContextRows()
|
||||
if len(rows) != 4 || rows[0].Selectable || rows[0].Label != "DATABASES (1)" || rows[2].Selectable || rows[2].Label != "PAGES (1)" {
|
||||
t.Fatalf("document context rows should section databases and pages: %#v", rows)
|
||||
}
|
||||
view := stripANSI(m.View())
|
||||
if !strings.Contains(view, "DATABASES") || !strings.Contains(view, "PAGES") {
|
||||
t.Fatalf("document view missing section rows:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentExplorerCyclesGroupViews(t *testing.T) {
|
||||
m := newModel(Options{
|
||||
Title: "notcrawl archive",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user