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
8 changes: 6 additions & 2 deletions docs/allocations.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ A line under the title shows the state of three toggles:
in the current directory, as the filter shows it. `c` copies it to the
clipboard.

`f` asks for a word and lights it in the log without hiding any line. `n` and
`N` go to the next and the previous match.

## Logs of every allocation

`l` on a job, a task group or a list of allocations opens the log of one task
Expand All @@ -139,7 +142,7 @@ shows the lines of one allocation.
allocations again and opens the logs of the ones that run now, for example
after a deployment.
- `ctrl-e` switches every log between stdout and stderr.
- Autoscroll, timestamps, wrap and saving work as on the log of one task.
- Autoscroll, timestamps, wrap, find and saving work as on the log of one task.
- `esc` closes every log and goes back.

## Files
Expand All @@ -161,7 +164,8 @@ The list shows the name, size and age of each file. Directories come first.
- It opens at the top, with Autoscroll off. `s` turns Autoscroll on to follow
the end as the file grows.
- A file larger than 1 MiB opens at its last 1 MiB. The title says so.
- `w` wraps long lines, `/` filters them, `ctrl-s` saves the file, `c` copies it.
- `w` wraps long lines, `/` filters them, `f` finds a word without hiding
lines, `ctrl-s` saves the file, `c` copies it.

urga does not open:

Expand Down
19 changes: 19 additions & 0 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ On what was done to the count of a group, `d` describes its policy.
| `w` | Wrap long lines | |
| `ctrl-s` | Save the plan to a file | |
| `c` | Copy the plan to the clipboard | |
| `f` | Find a word: every line stays and the word is lit | |
| `n`, `N` | Next or previous match. Shown once a word is found. | |

### Allocations

Expand Down Expand Up @@ -308,6 +310,8 @@ evaluation.
| `p` | Same log in the allocation this one replaced |
| `ctrl-s` | Save the log to a file |
| `c` | Copy the log to the clipboard, the last 64 KiB at most |
| `f` | Find a word: every line stays and the word is lit |
| `n`, `N` | Next or previous match. Shown once a word is found. |

### Logs of every allocation

Expand All @@ -320,6 +324,8 @@ evaluation.
| `w` | Wrap on or off |
| `ctrl-s` | Save the logs to a file |
| `c` | Copy the logs to the clipboard |
| `f` | Find a word: every line stays and the word is lit |
| `n`, `N` | Next or previous match. Shown once a word is found. |

In the list of tasks that opens first when there are several, `enter` opens
the logs of the task under the cursor.
Expand All @@ -338,6 +344,8 @@ On an open file:
| `w` | Wrap on or off |
| `ctrl-s` | Save the file to a local file |
| `c` | Copy the file to the clipboard |
| `f` | Find a word: every line stays and the word is lit |
| `n`, `N` | Next or previous match. Shown once a word is found. |

### Overview

Expand Down Expand Up @@ -521,7 +529,18 @@ Descriptions, job files and diffs:
| `w` | Wrap on or off |
| `ctrl-s` | Save to a file |
| `c` | Copy to the clipboard |
| `f` | Find a word: every line stays and the word is lit |
| `n`, `N` | Next or previous match. Shown once a word is found. |

On every text screen, logs, files and plans included, `c` copies up to 64 KiB:
the last whole lines when the text is longer, so the start of a long log is
left out. `ctrl-s` saves all of it.

On every text screen, `f` asks for a word and keeps every line on the screen,
lighting the word where it is. The window goes to the first match from its
top, and the status line says which one it is. `n` and `N` go to the next and
the previous match, round past the end and the start; after you scroll away
from a match, they start from what the window shows. An empty word drops the find, and
`esc` keeps the word as it was. The filter (`/`) still narrows the lines, and
find looks within what it left. Like the filter, the word is a regular
expression when it is one, and matches in any case.
4 changes: 2 additions & 2 deletions internal/ui/joblogs.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ func (p jobLogsPage) lines(allocID string, style *lipgloss.Style, lines ...strin
}

// jobLogsKeys are the keys of the logs of a job.
var jobLogsKeys = []pageKey[jobLogsPage]{
var jobLogsKeys = append([]pageKey[jobLogsPage]{
{press: "r", label: "Reload", do: reloadJobLogs},
// The key that opens stderr from the tasks switches between stdout and
// stderr here, and its label names the one it switches to.
Expand All @@ -512,7 +512,7 @@ var jobLogsKeys = []pageKey[jobLogsPage]{
timesKey[jobLogsPage](),
saveKey[jobLogsPage](),
copyTextKey[jobLogsPage](),
}
}, findWordKeys[jobLogsPage]()...)

func (p jobLogsPage) keys(e env) []keyHint { return hintsOf(p, e, jobLogsKeys) }

Expand Down
1 change: 1 addition & 0 deletions internal/ui/joblogs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ func TestJobLogs_TheHeaderSaysWhatTheLogsCanDo(t *testing.T) {
{Key: "<t>", Description: "Toggle Timestamps"},
{Key: "<ctrl-s>", Description: "Save"},
{Key: "<c>", Description: "Copy"},
{Key: "<f>", Description: "Find"},
}, m.hints())
}

Expand Down
4 changes: 2 additions & 2 deletions internal/ui/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (p logsPage) take(msg tea.Msg, _ env) (page, outcome, bool) {

// logsKeys are the keys of a log: the ones of any text, and the ones of a
// stream that is still being followed.
var logsKeys = []pageKey[logsPage]{
var logsKeys = append([]pageKey[logsPage]{
followKey[logsPage](),
// The key that opens stderr from the tasks switches to the other of
// the two here, and its label names the one it goes to.
Expand All @@ -147,7 +147,7 @@ var logsKeys = []pageKey[logsPage]{
timesKey[logsPage](),
saveKey[logsPage](),
copyTextKey[logsPage](),
}
}, findWordKeys[logsPage]()...)

func (p logsPage) keys(e env) []keyHint { return hintsOf(p, e, logsKeys) }

Expand Down
2 changes: 2 additions & 0 deletions internal/ui/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ func TestLogs_TheHeaderSaysWhatALogCanDo(t *testing.T) {
{Key: "<t>", Description: "Toggle Timestamps"},
{Key: "<ctrl-s>", Description: "Save"},
{Key: "<c>", Description: "Copy"},
{Key: "<f>", Description: "Find"},
}, m.hints())

// The allocation before is offered where there is one.
Expand All @@ -649,6 +650,7 @@ func TestLogs_TheHeaderSaysWhatALogCanDo(t *testing.T) {
{Key: "<t>", Description: "Toggle Timestamps"},
{Key: "<ctrl-s>", Description: "Save"},
{Key: "<c>", Description: "Copy"},
{Key: "<f>", Description: "Find"},
}, m.hints())
}

Expand Down
37 changes: 26 additions & 11 deletions internal/ui/overlay.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,22 +421,37 @@ func matchIn(line, filter string) []int {
return nil
}

rx, err := regexp.Compile("(?i)" + filter)
return wordIn(line, filter)
}

// wordIn is where a word matches in a line, as it is written: a regular
// expression, or the plain text when it is not one.
func wordIn(line, word string) []int { return wordFinder(word)(line) }

// wordFinder is wordIn with the word read once, for many lines.
func wordFinder(word string) func(line string) []int {
rx, err := regexp.Compile("(?i)" + word)
if err != nil {
at := strings.Index(strings.ToLower(line), strings.ToLower(filter))
if at < 0 || filter == "" {
return nil
}
lower := strings.ToLower(word)

return []int{at, at + len(filter)}
}
return func(line string) []int {
at := strings.Index(strings.ToLower(line), lower)
if at < 0 || word == "" {
return nil
}

at := rx.FindStringIndex(line)
if at == nil || at[0] == at[1] {
return nil
return []int{at, at + len(word)}
}
}

return at
return func(line string) []int {
at := rx.FindStringIndex(line)
if at == nil || at[0] == at[1] {
return nil
}

return at
}
}

// The prefixes a filter can start with, which change how it matches.
Expand Down
22 changes: 21 additions & 1 deletion internal/ui/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ type env struct {
// readOnly takes away every key that changes the cluster.
readOnly bool

// found says a word is found in the text on the screen.
found bool

// build is this urga, for the about screen.
build buildInfo
}
Expand All @@ -102,6 +105,7 @@ func (m Model) env() env {
clusters: m.opts.Clusters,
namespaces: m.namespaces,
readOnly: m.opts.ReadOnly,
found: m.text.found != "",
build: buildInfo{
version: m.opts.Version,
commit: m.opts.Commit,
Expand Down Expand Up @@ -249,6 +253,11 @@ type (
followMsg struct{}
timesMsg struct{}

// findWordMsg asks for a word to find in the text, and matchMsg goes to the
// next match of it, or the previous one when negative.
findWordMsg struct{}
matchMsg int

// reopenMsg opens the stream of the page again, the way entering the
// page does, in the same window: the page now reads another stream.
reopenMsg struct{}
Expand Down Expand Up @@ -370,7 +379,18 @@ type reader interface {

// textKeys are the keys of a page that reads as text.
func textKeys[P page]() []pageKey[P] {
return []pageKey[P]{wrapKey[P](), saveKey[P](), copyTextKey[P]()}
return append([]pageKey[P]{wrapKey[P](), saveKey[P](), copyTextKey[P]()}, findWordKeys[P]()...)
}

// findWordKeys find a word in a text, and go between the matches of it.
func findWordKeys[P page]() []pageKey[P] {
found := func(_ P, e env) bool { return e.found }

return []pageKey[P]{
windowKey[P]("f", "Find", findWordMsg{}),
{press: "n", label: "Next Match", offered: found, do: func(p P, _ env) (P, outcome) { return p, then(matchMsg(1)) }},
{press: "N", label: "Previous Match", offered: found, do: func(p P, _ env) (P, outcome) { return p, then(matchMsg(-1)) }},
}
}

func wrapKey[P page]() pageKey[P] { return windowKey[P]("w", "Toggle Wrap", wrapMsg{}) }
Expand Down
2 changes: 2 additions & 0 deletions internal/ui/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ func TestPlan_TheHeaderSaysWhatAPlanCanDo(t *testing.T) {
{Key: "<w>", Description: "Toggle Wrap"},
{Key: "<ctrl-s>", Description: "Save"},
{Key: "<c>", Description: "Copy"},
{Key: "<f>", Description: "Find"},
}, m.hints())

// With nothing to send, there is no key that sends.
Expand All @@ -445,6 +446,7 @@ func TestPlan_TheHeaderSaysWhatAPlanCanDo(t *testing.T) {
{Key: "<w>", Description: "Toggle Wrap"},
{Key: "<ctrl-s>", Description: "Save"},
{Key: "<c>", Description: "Copy"},
{Key: "<f>", Description: "Find"},
}, m.hints())
}

Expand Down
66 changes: 62 additions & 4 deletions internal/ui/text.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ui

import (
"sort"
"strings"
"time"

Expand All @@ -17,6 +18,13 @@ type textModel struct {
// filter keeps only the lines that match it.
filter string

// found is the word lit in the lines, which n and N go between the
// matches of. It hides no line.
found string

// at is the row of the match the window went to last.
at int

// wrap lays a long line over as many rows as it takes, instead of
// cutting it at the edge of the screen.
wrap bool
Expand Down Expand Up @@ -391,6 +399,7 @@ func (t *textModel) refresh() {
k.rows = k.rows[gone:]
k.counts = k.counts[t.dropped-first:]
t.top = max(t.top-gone, 0)
t.at = max(t.at-gone, 0)

rows, counts := t.draw(k.lines-t.dropped, len(t.lines))
k.rows = append(k.rows, rows...)
Expand Down Expand Up @@ -464,7 +473,7 @@ func (t textModel) view() string {
return strings.Join(rows, "\n")
}

// line draws one row in its colour, with the filter matches highlighted.
// line draws one row in its colour, with the matches highlighted.
func (t textModel) line(row textRow) string {
line := truncate(row.text, t.width)
gap := strings.Repeat(" ", max(t.width-ansi.StringWidth(line), 0))
Expand All @@ -487,20 +496,26 @@ func (t textModel) line(row textRow) string {
t.lit(ansi.TruncateLeft(line, row.tagTo, ""), style) + style.Render(gap)
}

// lit is a piece of a row in its style, with the filter matches highlighted.
// lit is a piece of a row in its style, with the matches of the found word
// highlighted, or of the filter when no word is found.
func (t textModel) lit(piece string, style lipgloss.Style) string {
if piece == "" {
return ""
}

if t.filter == "" {
word, find := t.filter, matchIn
if t.found != "" {
word, find = t.found, wordIn
}

if word == "" {
return style.Render(piece)
}

out := strings.Builder{}

for rest := piece; rest != ""; {
at := matchIn(rest, t.filter)
at := find(rest, word)
if at == nil {
out.WriteString(style.Render(rest))

Expand All @@ -515,3 +530,46 @@ func (t textModel) lit(piece string, style lipgloss.Style) string {

return out.String()
}

// matchRows are the rows the found word is in.
func (t textModel) matchRows() []int {
var hits []int

find := wordFinder(t.found)

for i, row := range t.rows() {
if find(row.text) != nil {
hits = append(hits, i)
}
}

return hits
}

// seek moves the window to the first match from row from on, or the last one
// before it when backward, going round past the end. It says which match that
// is out of how many; of is 0 when no row matches, and the window stays.
func (t *textModel) seek(from int, backward bool) (nth, of int) {
hits := t.matchRows()
if len(hits) == 0 {
return 0, 0
}

i := sort.SearchInts(hits, from)
if backward {
i = sort.SearchInts(hits, from+1) - 1
}

// Past either end, it goes round.
if i < 0 {
i = len(hits) - 1
} else if i >= len(hits) {
i = 0
}

t.at = hits[i]
t.move(max(t.at-t.height/3, 0) - t.top)
t.following = false

return i + 1, len(hits)
}
Loading
Loading