update QoL and ai integration
This commit is contained in:
131
tui.go
131
tui.go
@@ -185,6 +185,8 @@ type App struct {
|
||||
knownComments map[string]bool
|
||||
initializedPRs map[string]bool
|
||||
unreadThreads map[string]bool
|
||||
unreadComments map[string]bool
|
||||
newThreads map[string]bool
|
||||
updatedThreads map[string]bool
|
||||
ai *AIController
|
||||
aiStore *AIStore
|
||||
@@ -268,6 +270,7 @@ func NewAppWithSettings(
|
||||
drafts: settings.Drafts,
|
||||
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
|
||||
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
|
||||
unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
|
||||
updatedThreads: make(map[string]bool),
|
||||
ai: settings.AI, aiStore: settings.AIStore,
|
||||
}
|
||||
@@ -941,7 +944,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.details.Threads[index].ID == msg.threadID {
|
||||
m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment)
|
||||
m.threadIndex = index
|
||||
m.markCurrentThreadRead()
|
||||
m.markCommentRead(msg.comment.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1223,6 +1226,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.screen == threadScreen {
|
||||
m.moveToUnread(-1)
|
||||
}
|
||||
case "m":
|
||||
if m.screen == threadScreen {
|
||||
m.markCurrentThreadRead()
|
||||
}
|
||||
case "d":
|
||||
if m.screen == prScreen && len(m.prs) > 0 {
|
||||
return m, m.openSelectedPR(dashboardScreen)
|
||||
@@ -1245,7 +1252,6 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if m.screen == threadScreen {
|
||||
m.focus = threadDetailPane
|
||||
m.markCurrentThreadRead()
|
||||
}
|
||||
case "h":
|
||||
if m.screen == threadScreen {
|
||||
@@ -1263,7 +1269,6 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.screen == threadScreen && len(m.details.Threads) > 0 {
|
||||
thread := m.details.Threads[m.threadIndex]
|
||||
m.folded[thread.ID] = !m.folded[thread.ID]
|
||||
m.markCurrentThreadRead()
|
||||
m.scroll = 0
|
||||
}
|
||||
case "b", "esc":
|
||||
@@ -1386,6 +1391,8 @@ func (m *App) trackThreadUpdates(details PRDetails) {
|
||||
m.knownComments = make(map[string]bool)
|
||||
m.initializedPRs = make(map[string]bool)
|
||||
m.unreadThreads = make(map[string]bool)
|
||||
m.unreadComments = make(map[string]bool)
|
||||
m.newThreads = make(map[string]bool)
|
||||
}
|
||||
m.updatedThreads = make(map[string]bool)
|
||||
prID := details.ID
|
||||
@@ -1412,10 +1419,15 @@ func (m *App) trackThreadUpdates(details PRDetails) {
|
||||
return
|
||||
}
|
||||
for _, thread := range details.Threads {
|
||||
updated := !state.Threads[thread.ID]
|
||||
threadIsNew := !state.Threads[thread.ID]
|
||||
updated := threadIsNew
|
||||
if threadIsNew {
|
||||
m.newThreads[thread.ID] = true
|
||||
}
|
||||
for _, comment := range thread.Comments {
|
||||
if !state.Comments[comment.ID] {
|
||||
updated = true
|
||||
m.unreadComments[comment.ID] = true
|
||||
}
|
||||
m.knownComments[comment.ID] = true
|
||||
}
|
||||
@@ -1432,16 +1444,20 @@ func (m *App) markCurrentThreadRead() {
|
||||
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
|
||||
thread := m.details.Threads[m.threadIndex]
|
||||
delete(m.unreadThreads, thread.ID)
|
||||
delete(m.newThreads, thread.ID)
|
||||
prID := m.currentPRKey()
|
||||
state := m.readState.Data[prID]
|
||||
if state.Threads == nil {
|
||||
state.Threads = make(map[string]bool)
|
||||
}
|
||||
if state.Comments == nil {
|
||||
state.Comments = make(map[string]bool)
|
||||
}
|
||||
state.Initialized = true
|
||||
state.Threads[thread.ID] = true
|
||||
for _, comment := range thread.Comments {
|
||||
state.Comments[comment.ID] = true
|
||||
delete(m.unreadComments, comment.ID)
|
||||
}
|
||||
m.readState.Data[prID] = state
|
||||
if err := m.readState.save(); err != nil {
|
||||
@@ -1450,6 +1466,23 @@ func (m *App) markCurrentThreadRead() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *App) markCommentRead(commentID string) {
|
||||
if commentID == "" {
|
||||
return
|
||||
}
|
||||
delete(m.unreadComments, commentID)
|
||||
prID := m.currentPRKey()
|
||||
state := m.readState.Data[prID]
|
||||
if state.Comments == nil {
|
||||
state.Comments = make(map[string]bool)
|
||||
}
|
||||
state.Comments[commentID] = true
|
||||
m.readState.Data[prID] = state
|
||||
if err := m.readState.save(); err != nil {
|
||||
m.recordHealth("read state", healthWarning, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) currentPRKey() string {
|
||||
if m.details.ID != "" {
|
||||
return m.details.ID
|
||||
@@ -1457,6 +1490,60 @@ func (m App) currentPRKey() string {
|
||||
return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number)
|
||||
}
|
||||
|
||||
func (m *App) scrollToFirstUnread() {
|
||||
if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
|
||||
return
|
||||
}
|
||||
thread := m.details.Threads[m.threadIndex]
|
||||
firstUnread := ""
|
||||
for _, comment := range thread.Comments {
|
||||
if m.unreadComments[comment.ID] {
|
||||
firstUnread = comment.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstUnread == "" {
|
||||
return
|
||||
}
|
||||
width, _ := m.detailPaneSize()
|
||||
dividerAnchor := "unread:" + firstUnread
|
||||
commentAnchor := "comment:" + firstUnread + ":header"
|
||||
for index, line := range m.renderedDetailLines(width) {
|
||||
if line.anchor == dividerAnchor || line.anchor == commentAnchor {
|
||||
m.scroll = min(index, m.detailMaxScroll())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *App) acknowledgeVisibleUnread() {
|
||||
if m.screen != threadScreen || m.focus != threadDetailPane ||
|
||||
m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
|
||||
return
|
||||
}
|
||||
thread := m.details.Threads[m.threadIndex]
|
||||
lastUnread := ""
|
||||
for _, comment := range thread.Comments {
|
||||
if m.unreadComments[comment.ID] {
|
||||
lastUnread = comment.ID
|
||||
}
|
||||
}
|
||||
if lastUnread == "" {
|
||||
return
|
||||
}
|
||||
width, _ := m.detailPaneSize()
|
||||
lines := m.renderedDetailLines(width)
|
||||
viewportHeight := m.detailViewportHeight()
|
||||
scroll := min(m.scroll, max(0, len(lines)-viewportHeight))
|
||||
lastAnchor := "comment:" + lastUnread + ":header"
|
||||
for index, line := range lines {
|
||||
if line.anchor == lastAnchor && index >= scroll && index < scroll+viewportHeight {
|
||||
m.markCurrentThreadRead()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *App) moveToUnread(direction int) {
|
||||
count := len(m.details.Threads)
|
||||
if count == 0 {
|
||||
@@ -1469,7 +1556,8 @@ func (m *App) moveToUnread(direction int) {
|
||||
}
|
||||
if m.unreadThreads[m.details.Threads[index].ID] {
|
||||
m.threadIndex, m.scroll = index, 0
|
||||
m.markCurrentThreadRead()
|
||||
m.focus = threadDetailPane
|
||||
m.scrollToFirstUnread()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1491,12 +1579,10 @@ func (m *App) move(delta int) {
|
||||
}
|
||||
m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)]
|
||||
m.scroll = 0
|
||||
m.markCurrentThreadRead()
|
||||
return
|
||||
}
|
||||
m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1)
|
||||
m.scroll = 0
|
||||
m.markCurrentThreadRead()
|
||||
}
|
||||
|
||||
func (m *App) moveSearch(delta int) {
|
||||
@@ -1695,6 +1781,7 @@ func (m *App) toStart() {
|
||||
m.scroll = 0
|
||||
} else if m.focus == threadDetailPane {
|
||||
m.scroll = 0
|
||||
m.acknowledgeVisibleUnread()
|
||||
} else {
|
||||
m.threadIndex, m.scroll = 0, 0
|
||||
}
|
||||
@@ -1708,6 +1795,7 @@ func (m *App) toEnd() {
|
||||
m.healthScroll = m.healthMaxScroll()
|
||||
} else if m.focus == threadDetailPane {
|
||||
m.scroll = m.detailMaxScroll()
|
||||
m.acknowledgeVisibleUnread()
|
||||
} else {
|
||||
m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0
|
||||
}
|
||||
@@ -1734,6 +1822,7 @@ func (m *App) page(direction int) {
|
||||
|
||||
func (m *App) scrollDetail(delta int) {
|
||||
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
|
||||
m.acknowledgeVisibleUnread()
|
||||
}
|
||||
|
||||
func (m *App) scrollDashboard(delta int) {
|
||||
@@ -2088,6 +2177,7 @@ func (m App) helpBindings() []helpBinding {
|
||||
{keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
|
||||
{keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"},
|
||||
{combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
|
||||
{keyLabel(m.keybindings.Threads.MarkRead), "Mark the selected thread read"},
|
||||
{keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
|
||||
{keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
|
||||
{keyLabel(m.keybindings.Views.AI), "Open the local AI review or selected-thread discussion menu"},
|
||||
@@ -3120,7 +3210,11 @@ func (m App) threadList(width, height int) string {
|
||||
}
|
||||
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
|
||||
if m.unreadThreads[thread.ID] {
|
||||
suffix += " " + warnStyle.Render("NEW")
|
||||
label := "NEW"
|
||||
if m.newThreads[thread.ID] {
|
||||
label = "NEW THREAD"
|
||||
}
|
||||
suffix += " " + warnStyle.Render(label)
|
||||
}
|
||||
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
|
||||
path := truncatePath(thread.Path, pathWidth)
|
||||
@@ -3194,7 +3288,11 @@ func (m App) detailLines(width int) []detailLine {
|
||||
status += ", LOCAL AI · LOCAL ONLY"
|
||||
}
|
||||
if m.unreadThreads[thread.ID] {
|
||||
status += ", new updates"
|
||||
if m.newThreads[thread.ID] {
|
||||
status += ", new thread"
|
||||
} else {
|
||||
status += ", new updates"
|
||||
}
|
||||
}
|
||||
if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" {
|
||||
status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID)
|
||||
@@ -3229,9 +3327,24 @@ func (m App) detailLines(width int) []detailLine {
|
||||
lines = append(lines, wrapped...)
|
||||
}
|
||||
}
|
||||
unreadDivider := false
|
||||
for _, comment := range thread.Comments {
|
||||
commentUnread := m.unreadComments[comment.ID]
|
||||
if commentUnread && !m.newThreads[thread.ID] && !unreadDivider {
|
||||
lines = append(lines,
|
||||
detailLine{},
|
||||
detailLine{
|
||||
anchor: "unread:" + comment.ID,
|
||||
text: warnStyle.Render("── NEW MESSAGES ──"),
|
||||
},
|
||||
)
|
||||
unreadDivider = true
|
||||
}
|
||||
content := parseCommentBody(comment.Body)
|
||||
rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ")
|
||||
if commentUnread && !m.newThreads[thread.ID] {
|
||||
rail = warnStyle.Render("┃ ")
|
||||
}
|
||||
lines = append(lines, detailLine{}, detailLine{
|
||||
rail: rail,
|
||||
anchor: "comment:" + comment.ID + ":header",
|
||||
|
||||
Reference in New Issue
Block a user