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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Choosing a monospace font for the interface in Settings › Appearance asks whether the terminal and the editor should use it too. Use everywhere sets both; Interface only leaves them as they were. A proportional font or System asks nothing.
- A comment waiting in the review batch can be reworded before it is sent: its pencil opens it in a dialog, saved with Save or Ctrl+Enter. A line comment in a pull request's pending review has an Edit button beside Discard that opens it in a box, saved with Save or Ctrl+Enter.

### Removed

Expand Down
76 changes: 76 additions & 0 deletions native/cmd/lich-native/dock_comment_edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"path"
"strings"

"gioui.org/io/key"
"gioui.org/layout"
"gioui.org/widget"

"github.com/lichdotdev/lich/native/i18n"
"github.com/lichdotdev/lich/native/ui"
)

// commentEditDialog rewords the batch comment a pencil was clicked on, in a
// textarea big enough for a long one. One per window: every surface's batch
// opens it through the shared commentStore. Ctrl+Enter saves, Enter is a new
// line.
type commentEditDialog struct {
dialog ui.DialogState
editor widget.Editor
resize ui.TextareaResize
save, cancel widget.Clickable
// shown is the comment the editor was filled from, and where is its
// anchor, kept so the panel still reads right while it closes.
shown int
where string
}

// layout is laid out by the window before its content, as every dialog is.
func (d *commentEditDialog) layout(gtx C, th *ui.Theme, store *commentStore) {
c, open := store.edited()
if open && d.shown != c.id {
d.fill(gtx, c)
}
text := strings.TrimSpace(d.editor.Text())
if (d.save.Clicked(gtx) || ctrlEnter(gtx, &d.editor)) && open && text != "" {
store.edit(store.editing.target, c.id, text)
open = false
}
if d.cancel.Clicked(gtx) {
open = false
}
on := th.On(th.Popover)
cancel := on.Button(&d.cancel, i18n.T("common.action.cancel"))
cancel.Variant = ui.VariantOutline
save := on.Button(&d.save, i18n.T("diff.commentEdit.save"))
save.Disabled = text == ""
dlg := th.Dialog(&d.dialog, open, i18n.T("diff.commentEdit.title"), d.where)
dlg.MaxWidth = ui.DialogWidthXL
dlg.Body = func(gtx C) D {
gtx.Constraints.Max.Y = min(gtx.Constraints.Max.Y, gtx.Dp(ui.Space(64)))
ta := on.Textarea(&d.editor, "")
ta.Resize = &d.resize
return ta.Layout(gtx)
}
dlg.Actions = []layout.Widget{cancel.Layout, save.Layout}
if dlg.Layout(gtx) {
open = false
}
if !open {
store.endEdit()
d.shown = 0
}
}

func (d *commentEditDialog) fill(gtx C, c reviewComment) {
d.shown = c.id
d.where = ""
if c.path != "" {
d.where = path.Base(c.path) + ":" + c.lines
}
d.editor.SetText(c.txt)
d.editor.SetCaret(d.editor.Len(), d.editor.Len())
gtx.Execute(key.FocusCmd{Tag: &d.editor})
}
68 changes: 53 additions & 15 deletions native/cmd/lich-native/dock_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main
// comments := new(commentStore) // one per window
// comments.add(target, path, lines, text) // target is the checkout path
// comments.list(target) // what the strip shows
// comments.beginEdit(target, id) // a row's pencil; the window's commentEditDialog opens on it
//
// var c commentComposer // one per surface
// c.open(path, lines) // "Comment for the session" chosen on lines "12-30"
Expand Down Expand Up @@ -43,6 +44,7 @@ import (

var (
icBatchRemove = icons.Lucide("x")
icBatchEdit = icons.Lucide("pencil")
icBatchSend = icons.Lucide("send")
)

Expand All @@ -59,6 +61,14 @@ type reviewComment struct {
type commentStore struct {
byTarget map[string][]reviewComment
next int
// editing is the comment the window's commentEditDialog is open on.
editing commentRef
}

// commentRef names one comment of one checkout's batch; id 0 is none.
type commentRef struct {
target string
id int
}

// add appends a comment; blank text is not a comment and is dropped.
Expand All @@ -81,8 +91,33 @@ func (s *commentStore) remove(target string, id int) {
s.byTarget[target] = slices.DeleteFunc(s.byTarget[target], func(c reviewComment) bool { return c.id == id })
}

// edit rewords a comment in place, keeping where it points.
func (s *commentStore) edit(target string, id int, text string) {
for i, c := range s.byTarget[target] {
if c.id == id {
s.byTarget[target][i].txt = strings.TrimSpace(text)
}
}
}

func (s *commentStore) clear(target string) { delete(s.byTarget, target) }

func (s *commentStore) beginEdit(target string, id int) { s.editing = commentRef{target, id} }

func (s *commentStore) endEdit() { s.editing = commentRef{} }

func (s *commentStore) isEditing() bool { return s.editing.id != 0 }

// edited is the comment being edited, if it is still in its batch.
func (s *commentStore) edited() (reviewComment, bool) {
for _, c := range s.byTarget[s.editing.target] {
if c.id == s.editing.id {
return c, true
}
}
return reviewComment{}, false
}

// list is target's batch in the order it was written. The slice is the
// store's: read it, don't keep it across a change.
func (s *commentStore) list(target string) []reviewComment { return s.byTarget[target] }
Expand Down Expand Up @@ -263,10 +298,12 @@ func (c *commentComposer) box(th *ui.Theme) layout.Widget {

// commentBatch is the strip at the foot of a review panel: the comments so
// far, a note about the whole change, and the one action that hands them all
// to the session as one prompt.
// to the session as one prompt. A comment's pencil opens the window's
// commentEditDialog on it.
type commentBatch struct {
list widget.List
removes map[int]*widget.Clickable
edits map[int]*widget.Clickable
note widget.Editor
clear, send widget.Clickable
// sending is the batch on its way to the session, still in the store:
Expand Down Expand Up @@ -314,6 +351,9 @@ func (b *commentBatch) layout(gtx C, th *ui.Theme, store *commentStore, target s
// update runs the strip's clicks against the store before it is drawn.
func (b *commentBatch) update(gtx C, store *commentStore, target string, submit func(string, func(error)) bool, notify func(ui.Toast)) {
b.note.SingleLine, b.note.Submit = true, true
if b.removes == nil {
b.removes, b.edits = map[int]*widget.Clickable{}, map[int]*widget.Clickable{}
}
for {
ev, ok := b.note.Update(gtx)
if !ok {
Expand All @@ -328,6 +368,12 @@ func (b *commentBatch) update(gtx C, store *commentStore, target string, submit
if c.Clicked(gtx) {
store.remove(target, id)
delete(b.removes, id)
delete(b.edits, id)
}
}
for id, c := range b.edits {
if c.Clicked(gtx) {
store.beginEdit(target, id)
}
}
if b.clear.Clicked(gtx) {
Expand Down Expand Up @@ -384,29 +430,20 @@ func (b *commentBatch) drop(store *commentStore, target string) {
b.note.SetText("")
}

func (b *commentBatch) remove(id int) *widget.Clickable {
if b.removes == nil {
b.removes = map[int]*widget.Clickable{}
}
c, ok := b.removes[id]
if !ok {
c = new(widget.Clickable)
b.removes[id] = c
}
return c
}

func (b *commentBatch) rows(gtx C, th *ui.Theme, comments []reviewComment) D {
b.list.Axis = layout.Vertical
return th.ScrollArea(&b.list).Layout(gtx, len(comments), func(gtx C, i int) D {
return b.row(gtx, th, comments[i])
})
}

// row is one comment: where (mono, muted), what (truncated), and its ×.
// row is one comment: where (mono, muted), what (truncated), its pencil and
// its ×.
func (b *commentBatch) row(gtx C, th *ui.Theme, c reviewComment) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
remove := th.Button(b.remove(c.id), "")
edit := th.Button(clickableAt(b.edits, c.id), "")
edit.Icon, edit.Variant, edit.Size = icBatchEdit, ui.VariantGhost, ui.SizeIconXS
remove := th.Button(clickableAt(b.removes, c.id), "")
remove.Icon, remove.Variant, remove.Size = icBatchRemove, ui.VariantGhost, ui.SizeIconXS
return layout.Inset{Left: ui.Space(1.5), Right: ui.Space(1.5), Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
Expand All @@ -423,6 +460,7 @@ func (b *commentBatch) row(gtx C, th *ui.Theme, c reviewComment) D {
return l.Layout(gtx)
}),
ui.Gap(2),
layout.Rigid(edit.Layout),
layout.Rigid(remove.Layout),
)
})
Expand Down
30 changes: 30 additions & 0 deletions native/cmd/lich-native/dock_comments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,33 @@ func TestTakenBatchDropsOnlyWhatWasSent(t *testing.T) {
t.Fatalf("batch after the send was taken = %+v, want only the later comment", got)
}
}

func TestCommentBatchEditsOneInPlace(t *testing.T) {
var s commentStore
s.add("/repo", "a.go", "1", "first")
s.add("/repo", "b.go", "2", "second")
s.edit("/repo", s.list("/repo")[0].id, " first, reworded ")
got := s.list("/repo")
if len(got) != 2 || got[0].txt != "first, reworded" || got[0].path != "a.go" || got[0].lines != "1" || got[1].txt != "second" {
t.Fatalf("after edit = %+v, want the first reworded and trimmed, in place, the second untouched", got)
}
}

func TestCommentEditFollowsTheCommentItWasOpenedOn(t *testing.T) {
var s commentStore
s.add("/repo", "a.go", "1", "first")
s.add("/repo", "b.go", "2", "second")
second := s.list("/repo")[1].id
s.beginEdit("/repo", second)
if c, ok := s.edited(); !ok || c.txt != "second" || !s.isEditing() {
t.Fatalf("edited() = %+v, %v; want the second comment", c, ok)
}
s.remove("/repo", second)
if _, ok := s.edited(); ok {
t.Fatal("edited() still finds a removed comment")
}
s.endEdit()
if s.isEditing() {
t.Fatal("still editing after endEdit")
}
}
16 changes: 10 additions & 6 deletions native/cmd/lich-native/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,13 @@ type window struct {
model *model
terms *terminals
review *reviewPanel
dock *dock
sidebar *sidebar
footer *footer
topbar *topbar
// comments is the review batch every surface shares.
comments commentStore
commentEdit commentEditDialog
dock *dock
sidebar *sidebar
footer *footer
topbar *topbar
// settings is the Settings screen, drawn over the stage while open.
settings *settingsScreen
gate *spawnGate
Expand Down Expand Up @@ -251,7 +254,7 @@ func run(w *app.Window, rt lichclient.Runtime) error {
u.footer = newFooter(ctx, client, &u.root, u.model.usage, u.model.quota, w.Invalidate)
u.gate = &spawnGate{probe: client, invalidate: w.Invalidate, park: u.parkSession, notify: func(t ui.Toast) { u.toaster.Show(t) }}
u.footer.toaster = &u.toaster
comments, notify := new(commentStore), func(t ui.Toast) { u.toaster.Show(t) }
comments, notify := &u.comments, func(t ui.Toast) { u.toaster.Show(t) }
if u.review, err = newReviewPanel(client, &u.root, comments, *flagFont, unit.Sp(*flagSize), w.Invalidate, notify); err != nil {
return err
}
Expand Down Expand Up @@ -490,6 +493,7 @@ func (u *window) layout(gtx C) error {
u.layoutSubmitDialog(gtx, u.theme)
u.layoutMergeDialog(gtx, u.theme)
u.review.layoutDialogs(gtx, u.theme)
u.commentEdit.layout(gtx, u.theme, &u.comments)
u.settings.theme.layoutDialogs(gtx, u.theme)
u.settings.layoutFontReplication(gtx, u.theme)
u.layoutProviderSetup(gtx, u.theme)
Expand Down Expand Up @@ -806,7 +810,7 @@ func (u *window) lostNotice(gtx C) D {
// this frame: not while a field or a dialog over it is typing.
func (u *window) terminalTakesKeys(gtx C) bool {
typing := u.sidebar.holdsKeys() || u.footer.holdsKeys() || u.dock.holdsKeys(gtx) || u.topbar.holdsKeys()
return !typing && !u.palette.open && !u.worktree.open
return !typing && !u.palette.open && !u.worktree.open && !u.comments.isEditing()
}

func (u *window) apply(act sidebarAction) {
Expand Down
Loading
Loading