Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c
github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719
github.com/mattn/go-runewidth v0.0.19
github.com/muesli/termenv v0.16.0
golang.org/x/term v0.41.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.57.0
Expand All @@ -33,6 +32,7 @@ require (
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c h1:5J4T0r8xWkt3JiN8BquRQAoIEK+6OzbNwBiqecahHzY=
github.com/keyolk/tuikit v0.0.0-20261021024723-c19aa4f0fc4c/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw=
github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719 h1:Edpqtu56QDZVThm2iqiEP4UOq4uH1pWmG0c5xZbmvmQ=
github.com/keyolk/tuikit v0.0.0-20261021080153-b0beaa45b719/go.mod h1:f1m38AtKzIx8QT7vR3N0ghjtCcn8bhcoeYtwveRIcxw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
Expand Down
39 changes: 32 additions & 7 deletions internal/cli/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"github.com/keyolk/tuikit/link"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -289,8 +290,11 @@ func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, kittyPaneCmd(kitty.PaneCmd(), false)
case tea.MouseMsg:
return m.handleMouse(msg)
case selection.AutoScrollMsg:
cmd, _ := m.handleSelectionMouse(msg)
return m, cmd
case selection.CopiedMsg:
m.notice = copiedNotice(msg)
m.notice = msg.Note()
return m, nil
case tea.KeyMsg:
// A key between two clicks breaks the pair: click, move away and back
Expand Down Expand Up @@ -881,7 +885,9 @@ func (m pickerModel) renderListRow(item PickerItem, cursored bool, check string,
labelStyle = sel
prefixStyled = sel.Render(">")
}
row := prefixStyled + check + cat.Render(badgePlain) + " " + labelStyle.Render(label) +
// The label is a PR key, a title or a cut URL: link it to the full URL.
// File rows carry a path, which link.Wrap leaves as plain text.
row := prefixStyled + check + cat.Render(badgePlain) + " " + link.Wrap(item.Item.URL, labelStyle.Render(label)) +
dim.Render(refBadgePlain) + dim.Render(sourcePlain)
if statusPlain != "" {
row += " " + dim.Render(statusPlain)
Expand Down Expand Up @@ -945,6 +951,14 @@ func (m pickerModel) listFirstVisible(contentH int) int {
return 0
}

// pickerPreviewFrame is the preview box's border and padding. View draws the
// box with it and the drag selection insets the box by it, so the two cannot
// disagree on where the preview's text starts.
var pickerPreviewFrame = lipgloss.NewStyle().
BorderLeft(true).
BorderStyle(lipgloss.NormalBorder()).
PaddingLeft(1)

// screen draws the picker without the drag-selection highlight; image is the
// Kitty layer, kept apart because it is cursor-positioned escape output that
// the highlight must not cut and selection must not read as text.
Expand Down Expand Up @@ -1001,12 +1015,9 @@ func (m pickerModel) screen() (frame, image string) {
if m.previewFocused {
borderColor = lipgloss.Color("#38BDF8")
}
previewBox := lipgloss.NewStyle().
previewBox := pickerPreviewFrame.
Width(pw).Height(contentH).
BorderLeft(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(borderColor).
PaddingLeft(1).
MaxWidth(pw + 1). // +1 for the border column
MaxHeight(contentH).
Render(m.preview.View())
Expand Down Expand Up @@ -1334,10 +1345,24 @@ func (m pickerModel) refStatusPlain(url string, budget int) string {

func (m pickerModel) openItems(urls []string) {
for _, u := range urls {
_ = opener.Open(m.opener, u)
// URL lists come from transcripts: only http(s) URLs reach the
// opener. Other kinds hold local paths, which open as files.
if m.listsURLs() && !link.Valid(u) {
continue
}
_ = pickerOpen(m.opener, u)
}
}

// pickerOpen launches a URL or path; tests replace it so no real opener runs.
var pickerOpen = opener.Open

// listsURLs reports whether the picker's items are web URLs (urls, refs)
// rather than local paths (files, changes, images).
func (m pickerModel) listsURLs() bool {
return m.kind == "urls" || m.kind == "refs"
}

func (m pickerModel) editItems(paths []string) {
editor := os.Getenv("EDITOR")
if editor == "" {
Expand Down
88 changes: 88 additions & 0 deletions internal/cli/picker_links_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cli

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/keyolk/tuikit/tuitest"

"github.com/sendbird/ccx/internal/extract"
"github.com/sendbird/ccx/internal/opener"
)

// stubPickerOpen records what the picker hands its opener instead of running
// one.
func stubPickerOpen(t *testing.T) *[]string {
t.Helper()
var got []string
prev := pickerOpen
pickerOpen = func(_ opener.Config, u string) error { got = append(got, u); return nil }
t.Cleanup(func() { pickerOpen = prev })
return &got
}

func pressO(m pickerModel) pickerModel {
return feed(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
}

// A URL row's label is an OSC 8 link to the full URL, and the row still fits
// the list column.
func TestPickerURLRowLabelIsLinked(t *testing.T) {
m, _ := mousePicker(t, "urls", linkItems(3), 100, 24)
frame := m.View()
var found bool
for _, l := range tuitest.Links(frame) {
if l.URL == "https://example.com/1" {
found = true
if l.Text != "link-01" {
t.Fatalf("link covers %q; want exactly the label link-01", l.Text)
}
}
}
if !found {
t.Fatalf("no link to https://example.com/1:\n%s", ansi.Strip(frame))
}
if !strings.Contains(frame, "\x1b]8;;https://example.com/1\x1b\\") {
t.Fatal("the label is not wrapped in an OSC 8 escape")
}
for i, row := range strings.Split(frame, "\n") {
if w := ansi.StringWidth(row); w > m.width {
t.Fatalf("row %d is %d cells, wider than %d", i, w, m.width)
}
}
}

// The picker's existing `o` keeps opening the row, and refuses a non-http URL
// a transcript put in the list.
func TestPickerOpenSkipsNonHTTPURL(t *testing.T) {
items := linkItems(2)
items[1].Item = extract.Item{URL: "javascript:alert(1)", Label: "evil", Category: "other"}
m, _ := mousePicker(t, "urls", items, 100, 24)
opened := stubPickerOpen(t)
m = pressO(m)
m.cursor = 1
pressO(m)
if len(*opened) != 1 || (*opened)[0] != "https://example.com/0" {
t.Fatalf("opened %q; want only https://example.com/0", *opened)
}
if links := tuitest.Links(m.View()); len(links) != 1 {
t.Fatalf("links %+v; the javascript: row must not be linked", links)
}
}

// File pickers open local paths: the URL filter must not apply to them.
func TestPickerFilesStillOpenPaths(t *testing.T) {
items := linkItems(1)
items[0].Item = extract.Item{URL: "/tmp/repo/app.go", Label: "app.go", Category: "file"}
m, _ := mousePicker(t, "files", items, 100, 24)
opened := stubPickerOpen(t)
pressO(m)
if len(*opened) != 1 || (*opened)[0] != "/tmp/repo/app.go" {
t.Fatalf("opened %q; want the path", *opened)
}
if links := tuitest.Links(m.View()); len(links) != 0 {
t.Fatalf("a path was linked: %+v", links)
}
}
7 changes: 7 additions & 0 deletions internal/cli/picker_mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func (m pickerModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if consumed {
return m, selCmd
}
mm, cmd := m.handlePickerMouse(msg)
return mm, tea.Batch(selCmd, cmd)
}

// handlePickerMouse is the picker's own click and wheel handling, for events
// the drag selection did not consume.
func (m pickerModel) handlePickerMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.width == 0 || m.height == 0 || m.searching {
return m, nil
}
Expand Down
60 changes: 13 additions & 47 deletions internal/cli/picker_selection.go
Original file line number Diff line number Diff line change
@@ -1,46 +1,29 @@
package cli

import (
"fmt"
"io"
"time"
"unicode/utf8"

"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/keyolk/tuikit/mouse"
"github.com/keyolk/tuikit/selection"
"github.com/muesli/termenv"
)

// pickerSelectionStyle is reverse video on a renderer pinned to the ANSI
// profile: the default renderer drops every SGR under NO_COLOR, reverse
// included, and the selection would be invisible.
var pickerSelectionStyle = func() lipgloss.Style {
// SetColorProfile, not termenv.WithProfile: the renderer re-detects the
// profile from the environment unless it is set explicitly.
r := lipgloss.NewRenderer(io.Discard)
r.SetColorProfile(termenv.ANSI)
return r.NewStyle().Reverse(true)
}()

// View is the picker frame with the drag-selection highlight drawn last, then
// the Kitty image layer.
func (m pickerModel) View() string {
frame, image := m.screen()
if m.sel.Active() {
frame = m.sel.Render(frame, m.selectionPanes(), pickerSelectionStyle)
frame = m.sel.Render(frame, m.selectionPanes(), selection.DefaultStyle())
}
return frame + image
}

// previewTextRect is the preview's text cells: previewRect minus the border
// column and the one-cell left padding View draws the preview box with.
// column and padding the preview box is drawn with, capped at the viewport's
// height.
func (m pickerModel) previewTextRect() mouse.Rect {
r := m.previewRect()
r.X += 2
r.W = max(r.W-2, 0)
r := m.previewRect().InsetStyle(pickerPreviewFrame)
r.H = min(r.H, m.preview.Height)
return r
}
Expand Down Expand Up @@ -76,39 +59,22 @@ func viewportLines(vp viewport.Model) []string {
return full.GotoBottom()
}

// handleSelectionMouse feeds the drag selection before the picker's click
// handling. done means the selection owns the event; a press that does not
// move stays the picker's click.
func (m *pickerModel) handleSelectionMouse(msg tea.MouseMsg) (cmd tea.Cmd, done bool) {
if !mouse.IsLeftPress(msg) && !m.sel.Dragging() {
return nil, false
}
if mouse.IsLeftPress(msg) {
// handleSelectionMouse feeds the drag selection, or the auto-scroll tick of a
// drag resting past the preview's edge, before the picker's click handling.
// done means the selection owns the event; a press that does not move stays
// the picker's click.
func (m *pickerModel) handleSelectionMouse(msg tea.Msg) (cmd tea.Cmd, done bool) {
if mm, ok := msg.(tea.MouseMsg); ok && mouse.IsLeftPress(mm) {
m.notice = ""
}
now := time.Now
if m.clicks.now != nil {
now = m.clicks.now
}
frame, _ := m.screen()
r := m.sel.Mouse(msg, m.selectionPanes(), frame, now())
frame := func() string { f, _ := m.screen(); return f }
r, cmd := m.sel.Update(msg, m.selectionPanes, frame, now())
if r.Scroll != 0 && r.ScrollPane == "preview" {
// The same viewport scroll the wheel uses over the preview.
if r.Scroll < 0 {
m.preview.LineUp(-r.Scroll)
} else {
m.preview.LineDown(r.Scroll)
}
}
if r.Copy != "" {
cmd = selection.Copy(r.Copy)
m.preview.SetYOffset(r.Offset)
}
return cmd, r.Consumed
}

func copiedNotice(msg selection.CopiedMsg) string {
if msg.Err != nil {
return "copy failed: " + msg.Err.Error()
}
return fmt.Sprintf("copied %d chars", utf8.RuneCountInString(msg.Text))
}
Loading
Loading