Add configuration

This commit is contained in:
2026-07-28 09:35:15 +02:00
parent 547cd123da
commit 80f24bcfa8
12 changed files with 629 additions and 41 deletions

148
tui.go
View File

@@ -28,6 +28,7 @@ const (
)
type tickMsg time.Time
type pathTickMsg time.Time
type prsLoadedMsg struct {
prs []PullRequest
err error
@@ -66,23 +67,65 @@ type App struct {
searchOrigin int
helpVisible bool
helpScroll int
foldResolved bool
threadListWidthPercent int
pathScroll bool
pathScrollInterval time.Duration
pathScrollStep int
}
type AppSettings struct {
FoldResolved bool
ThreadListWidthPercent int
PathScroll bool
PathScrollInterval time.Duration
}
func defaultAppSettings() AppSettings {
return AppSettings{
FoldResolved: true,
ThreadListWidthPercent: 33,
PathScrollInterval: 350 * time.Millisecond,
}
}
func NewApp(service GitHubService, owner, repo string, showAll bool, limit int, poll time.Duration) App {
return NewAppWithSettings(service, owner, repo, showAll, limit, poll, defaultAppSettings())
}
func NewAppWithSettings(
service GitHubService,
owner, repo string,
showAll bool,
limit int,
poll time.Duration,
settings AppSettings,
) App {
return App{
service: service, owner: owner, repo: repo, showAll: showAll, limit: limit, poll: poll,
folded: make(map[string]bool), loading: true,
foldResolved: settings.FoldResolved, threadListWidthPercent: settings.ThreadListWidthPercent,
pathScroll: settings.PathScroll, pathScrollInterval: settings.PathScrollInterval,
}
}
func (m App) Init() tea.Cmd {
return tea.Batch(m.loadPRs(), m.nextTick())
commands := []tea.Cmd{m.loadPRs(), m.nextTick()}
if m.pathScroll {
commands = append(commands, m.nextPathTick())
}
return tea.Batch(commands...)
}
func (m App) nextTick() tea.Cmd {
return tea.Tick(m.poll, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m App) nextPathTick() tea.Cmd {
return tea.Tick(m.pathScrollInterval, func(t time.Time) tea.Msg { return pathTickMsg(t) })
}
func (m App) loadPRs() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
@@ -117,6 +160,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(m.loadPRs(), m.nextTick())
}
return m, m.nextTick()
case pathTickMsg:
if m.pathScroll && m.screen == threadScreen {
m.pathScrollStep++
}
if m.pathScroll {
return m, m.nextPathTick()
}
return m, nil
case prsLoadedMsg:
m.loading = false
if msg.err != nil {
@@ -158,7 +209,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.scroll = 0
}
for _, thread := range m.details.Threads {
if _, set := m.folded[thread.ID]; !set && thread.IsResolved {
if _, set := m.folded[thread.ID]; !set && thread.IsResolved && m.foldResolved {
m.folded[thread.ID] = true
}
}
@@ -296,6 +347,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details = PRDetails{PullRequest: m.prs[m.prIndex]}
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
m.searching, m.searchQuery = false, ""
m.pathScrollStep = 0
return m, m.loadDetails(m.details.PullRequest)
}
if m.screen == threadScreen {
@@ -312,6 +364,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.details = PRDetails{PullRequest: m.prs[m.prIndex]}
m.threadIndex, m.scroll, m.focus, m.listHidden, m.loading, m.err = 0, 0, threadListPane, false, true, nil
m.searching, m.searchQuery = false, ""
m.pathScrollStep = 0
return m, m.loadDetails(m.details.PullRequest)
}
if m.screen == threadScreen && len(m.details.Threads) > 0 {
@@ -430,10 +483,18 @@ func (m App) detailPaneSize() (int, int) {
if m.width < 70 || m.listHidden {
return max(3, m.width), height
}
leftWidth := clamp(m.width/3, 30, 48)
leftWidth := m.threadListWidth()
return max(20, m.width-leftWidth-1), height
}
func (m App) threadListWidth() int {
percent := m.threadListWidthPercent
if percent == 0 {
percent = 33
}
return clamp(m.width*percent/100, 30, max(30, m.width-20))
}
func (m App) detailViewportHeight() int {
_, height := m.detailPaneSize()
return max(1, height-2)
@@ -533,18 +594,26 @@ func (m App) viewHelp() string {
}
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F0B72F"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
activeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Background(lipgloss.Color("#3B4261"))
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67C587"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5C07B"))
badStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E06C75"))
paneInactiveColor = lipgloss.Color("#50566F")
paneActiveColor = lipgloss.Color("#F0B72F")
selectedLineBackground = "\x1b[48;5;24m"
suggestionRemoveBackground = "\x1b[48;5;52m"
suggestionAddBackground = "\x1b[48;5;22m"
changedRemoveBackground = "\x1b[48;2;55;0;0m"
changedAddBackground = "\x1b[48;2;0;55;0m"
)
func paneStyle(active bool) lipgloss.Style {
color := lipgloss.Color("#50566F")
color := paneInactiveColor
if active {
color = lipgloss.Color("#F0B72F")
color = paneActiveColor
}
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color)
}
@@ -636,7 +705,7 @@ func (m App) viewThreads() string {
} else if m.listHidden {
body = m.threadDetail(m.width, contentHeight)
} else {
leftWidth := clamp(m.width/3, 30, 48)
leftWidth := m.threadListWidth()
rightWidth := max(20, m.width-leftWidth-1)
left := m.threadList(leftWidth, contentHeight)
right := m.threadDetail(rightWidth, contentHeight)
@@ -684,7 +753,11 @@ func (m App) threadList(width, height int) string {
}
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
line := icon + " " + pad(truncatePath(thread.Path, pathWidth), pathWidth) + suffix
path := truncatePath(thread.Path, pathWidth)
if m.pathScroll {
path = scrollingPath(thread.Path, pathWidth, m.pathScrollStep)
}
line := icon + " " + pad(path, pathWidth) + suffix
line = ansi.Truncate(line, innerWidth, "")
if thread.IsResolved {
line = dimStyle.Render(line)
@@ -861,9 +934,9 @@ func highlightCodeRange(code string, changed codeRange, change byte) string {
return code
}
background := "\x1b[48;2;55;0;0m"
background := changedRemoveBackground
if change == '+' {
background = "\x1b[48;2;0;55;0m"
background = changedAddBackground
}
const reset = "\x1b[0m"
before := ansi.Cut(code, 0, start)
@@ -953,19 +1026,17 @@ func shortOID(oid string) string {
}
func selectedBackground(line string, width int) string {
const (
background = "\x1b[48;5;24m"
reset = "\x1b[0m"
)
const reset = "\x1b[0m"
background := selectedLineBackground
line = pad(ansi.Truncate(line, width, ""), width)
line = strings.ReplaceAll(line, reset, reset+background)
return background + line + reset
}
func suggestionHighlight(gutter, code string, width int, change byte) string {
background := "\x1b[48;5;52m"
background := suggestionRemoveBackground
if change == '+' {
background = "\x1b[48;5;22m"
background = suggestionAddBackground
}
const reset = "\x1b[0m"
gutter = ansi.Truncate(gutter, width, "")
@@ -1138,6 +1209,41 @@ func truncatePath(path string, width int) string {
return "…" + ansi.Cut(path, pathWidth-width+1, pathWidth)
}
func scrollingPath(path string, width, step int) string {
const pauseSteps = 4
if width <= 0 {
return ""
}
pathWidth := ansi.StringWidth(path)
if pathWidth <= width {
return path
}
if width == 1 {
return "…"
}
visibleWidth := width - 1
maxOffset := pathWidth - visibleWidth
cycle := pauseSteps + maxOffset + pauseSteps
phase := step % cycle
offset := 0
switch {
case phase < pauseSteps:
offset = 0
case phase < pauseSteps+maxOffset:
offset = phase - pauseSteps
default:
offset = maxOffset
}
if offset == 0 {
return ansi.Cut(path, 0, visibleWidth) + "…"
}
if offset == maxOffset {
return "…" + ansi.Cut(path, pathWidth-visibleWidth, pathWidth)
}
return "…" + ansi.Cut(path, offset, offset+width-2) + "…"
}
func fuzzyPathScore(path, query string) (int, bool) {
candidate := []rune(strings.ToLower(path))
terms := strings.Fields(strings.ToLower(query))