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

### Added

- **Split layouts in the native window (experimental).** Put sessions from
any open project side by side and split them however you like: Ctrl+Shift+G
splits the focused session to the right, Ctrl+Shift+I below, each asking
which session goes there, or drag a card from the sidebar onto a pane's
edge. Rearrange a layout by dragging a pane by its header onto another
pane's edge, or onto its middle to swap the two, and turn a pair between
side by side and stacked with Ctrl+Shift+R. Every layout is a tab after the
projects, with the status of its panes, renamed by double-clicking it; drag
the gap between two panes to resize them, zoom one with Ctrl+Shift+M, move
between them with Ctrl+Shift+F, and take one out with Ctrl+Shift+W; a
layout down to one pane closes. Rest a dragged session on another tab and
that tab comes up, as a browser does with a dragged file. The sidebar and
the footer follow the pane in focus.
- **Show a session's delegates beside it.** In the native window, a session
that opened others, in any project, offers them in the footer: Show beside
lays it out with its delegates stacked at its right, as many as fit. In a
layout its pane header counts how many are there and adds the rest, asking
first before taking one out of another layout. A card in a layout names it.
- **Required checks are labelled.** In the native window, a pull request's
Checks tab marks the checks that gate its merge with Required, as GitHub
does.
Expand Down
22 changes: 22 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,28 @@ work when nobody knows it and that the call site never shows. The mechanism and
project's sessions drop out of every wall, and a wall left with one live member is not drawn. "Show
beside its N delegates" still reads the routed project only (`delegatesOf`), so a delegate opened in
another project is left out of the wall it builds.
- **The native window's layouts are not the web's walls** (`native/cmd/lich-native/layouts.go`,
`model_layouts.go`, `stage_layout.go`): a layout is a free split tree stored under `lich.native.layouts`,
and neither window reads the other's. A session sits in one pane of one layout, because its PTY has one
size; clicking its card anywhere shows it in that layout, while its project's tab shows it alone. A split
is refused, with a toast, when a pane would drop under 480dp (51 columns at the default 13sp, measured with
Claude Code and Codex): a larger terminal font fits fewer columns in the same floor. Only the focused pane
draws its exit band and takes a dropped file; a pane in the background whose process ended keeps its last
frame until focused, and a file dropped on it goes nowhere. A pinned card cannot be dragged onto a pane: the
pinned block shares no carry with the panes, so its card never leaves the sidebar. The panes' drop zones are
not SortCarry targets: Gio's transfer delivered Enter to targets laid out mid-drag only some of the time, so
the window reads the pointer from a handler over the whole window and works the zone out from where the
panes were drawn (`layouts_drag.go`). A new drop target over the stage belongs there, not in the carry.
Resting a drag on a strip tab switches to it, which can take the drag's own list off screen; Gio drops a
handler nobody asked about in a frame and then dereferences the forgotten source on the next move, so the
window calls `SortCarry.Hold` every frame to keep the source asked about, and lets a drag whose list never
heard the release go (`endStrandedDrag`). The layout hotkeys the web window has no
action for (split below, zoom, take out of the layout, rotate) live in the shared `hotkeys.bindings` value,
which the web window rewrites with its own actions only: rebinding anything there drops a custom chord set
for those four in the native window. Show beside lays out only the delegates that fit the 180dp pane
floor, counted from where the panes were drawn the frame before (`columnRoom`); the rest stay out and
the pane header offers them. Its footer item is not an item of the Footer layout, so Settings can neither
move nor hide it: it shows only while the session, in no layout, has delegates.
- **A theme switch reaches only the apps that asked, and only since the page loaded**
(`frontend/src/lib/terminal/theme-notify.ts`): lich answers mode 2031 and sends the light or dark report on a
switch, but the "asked" flag lives on the page's terminal entry. A full page reload forgets it, so a session
Expand Down
66 changes: 66 additions & 0 deletions native/cmd/lich-native/delegates_window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"gioui.org/layout"

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

var icDelegates = icons.Lucide("network")

// delegatesChip is a pane header's offer to lay out the delegates its
// session opened that are not in the layout yet, nothing while the layout
// is zoomed or every delegate is there.
func (u *window) delegatesChip(gtx C, th *ui.Theme, v stageView, sv sessionView, p *paneUI) D {
if v.layout.Zoomed {
return D{}
}
o := u.model.delegateOffer(sv.ID)
if len(o.missing) == 0 {
return D{}
}
tip := th.Tooltip(&u.root, &p.delegatesTip, i18n.T("layouts.delegates.hint"))
return layout.Inset{Right: ui.Space(2)}.Layout(gtx, func(gtx C) D {
return tip.Layout(gtx, func(gtx C) D {
return p.delegates.Layout(gtx, func(gtx C) D {
return pill(gtx, th, func(gtx C) D {
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx C) D { return icDelegates.Layout(gtx, 12, th.MutedForeground) }),
ui.Gap(1.5),
layout.Rigid(th.Text(ui.TextXS, delegatesText(o)).Muted().Layout),
)
}, ui.Space(2), ui.Space(2))
})
})
})
}

// delegatesText is the chip's words: how many delegates there are, and how
// many of them the layout shows once it shows any.
func delegatesText(o delegateOffer) string {
if o.here == 0 {
return i18n.T("layouts.delegates.showBeside", i18n.Args{"count": len(o.all)})
}
return i18n.T("layouts.delegates.add", i18n.Args{"here": o.here, "count": len(o.all), "missing": len(o.missing)})
}

// layDelegatesOut lays out as many of orchestrator's missing delegates as
// fit at delegateSlot, refused with a toast when none does and asked about
// first when any sits in another layout.
func (u *window) layDelegatesOut(orchestrator string) {
o := u.model.delegateOffer(orchestrator)
target, below := u.model.delegateSlot(orchestrator, o.all)
ids := o.missing[:min(len(o.missing), u.layoutStage.pointer.columnRoom(target, below))]
if len(ids) == 0 {
u.toaster.Show(ui.Toast{Title: i18n.T("layouts.split.noRoom")})
return
}
if away := u.model.inOtherLayouts(orchestrator, ids); away > 0 {
u.askMoveDelegates(orchestrator, ids, away)
return
}
u.model.showDelegates(u.ctx, orchestrator, ids)
u.model.invalidate()
}
30 changes: 25 additions & 5 deletions native/cmd/lich-native/footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ type footer struct {
invalidate func()
// openPullRequest opens the Pulls screen on a pull request.
openPullRequest func(number int)
usage *footerUsage
quota *planQuota
handsOn *handsOn
cost *costGate
// showDelegates lays a session's delegates out beside it.
showDelegates func(id string)
usage *footerUsage
quota *planQuota
handsOn *handsOn
cost *costGate

mu sync.Mutex
layout footerLayout
Expand Down Expand Up @@ -116,6 +118,9 @@ func (f *footer) input(m *model, now time.Time) footerInput {
if in.provider == "" {
in.provider = sv.Kind
}
if m.layoutOf(sv.ID) == "" {
in.delegates = len(m.delegatesOf(sv.ID))
}
} else if p, ok := m.project(); ok {
in.path = p.Path
}
Expand Down Expand Up @@ -173,13 +178,23 @@ func (f *footer) layoutBar(gtx C, th *ui.Theme, in footerInput) D {
// min-h-11 counts the padding, and Inset leaves Min as it finds it.
gtx.Constraints.Min.Y = gtx.Dp(ui.Space(11)) - 2*gtx.Dp(pad)
return ui.Wrap{GapX: ui.Space(4), GapY: ui.Space(1), Justify: ui.JustifyBetween}.Layout(gtx,
ui.WrapItem(f.side(th, footerSlots(l.Left, in), in, ui.JustifyStart)),
ui.WrapItem(f.side(th, withDelegates(footerSlots(l.Left, in), in), in, ui.JustifyStart)),
ui.WrapMLAuto(f.side(th, footerSlots(l.Right, in), in, ui.JustifyEnd)),
)
})
})
}

// withDelegates ends the left side with the delegates item while the
// active session has delegates to lay out. It is no item of the user's
// layout: it shows only when there is something to offer.
func withDelegates(left []footerSlot, in footerInput) []footerSlot {
if in.delegates == 0 {
return left
}
return append(left, footerSlot{ID: "delegates", Text: i18n.T("layouts.delegates.showBeside", i18n.Args{"count": in.delegates})})
}

func (f *footer) side(th *ui.Theme, slots []footerSlot, in footerInput, justify ui.Justify) layout.Widget {
return func(gtx C) D {
kids := make([]ui.WrapChild, len(slots))
Expand All @@ -206,6 +221,9 @@ func (f *footer) act(gtx C, in footerInput) {
if f.w("pr").click.Clicked(gtx) && in.pr != nil && f.openPullRequest != nil {
f.openPullRequest(in.pr.Number)
}
if f.w("delegates").click.Clicked(gtx) && in.delegates > 0 {
f.showDelegates(in.sessionID)
}
}

// attach picks a file for the session and pastes its path at the prompt.
Expand Down Expand Up @@ -248,6 +266,8 @@ func (f *footer) slot(gtx C, th *ui.Theme, s footerSlot, in footerInput) D {
case "model":
glyph := func(gtx C) D { return providerIcon(in.provider).Layout(gtx, 14, th.MutedForeground) }
return footerSpan(gtx, th, f.root, &w.tip, s.Hint, modelMaxWidth, glyph, s.Text)
case "delegates":
return footerButton(gtx, th, f.root, w, i18n.T("layouts.delegates.hint"), true, false, glyphText(icDelegates, s.Text))
case "clock":
return layout.Inset{Left: ui.Space(2), Right: ui.Space(2)}.Layout(gtx, th.Text(ui.TextXS, s.Text).Muted().Layout)
}
Expand Down
3 changes: 3 additions & 0 deletions native/cmd/lich-native/footer_slots.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ type footerInput struct {
cost bool // the cost readout is turned on
budget float64 // the spend ceiling, 0 for none
now time.Time
// delegates counts the sessions the active one opened, while it is in no
// layout: in one, its pane header offers them instead.
delegates int
}

// footerSlot is one item the strip draws, with what it shows; an item whose
Expand Down
8 changes: 8 additions & 0 deletions native/cmd/lich-native/hotkeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const (
hotkeySearchInFiles hotkeyID = "searchInFiles"
hotkeySplitBeside hotkeyID = "splitBeside"
hotkeyOtherPane hotkeyID = "otherPane"
hotkeySplitBelow hotkeyID = "splitBelow"
hotkeyZoomPane hotkeyID = "zoomPane"
hotkeyUnsplitPane hotkeyID = "unsplitPane"
hotkeyRotateSplit hotkeyID = "rotateSplit"
hotkeySettings hotkeyID = "settings"
hotkeyPulls hotkeyID = "pulls"
hotkeyShortcuts hotkeyID = "shortcuts"
Expand Down Expand Up @@ -115,7 +119,11 @@ var hotkeyActions = labelActions([]hotkeyAction{
{id: hotkeyToggleDock, labelKey: "hotkeys.action.toggleDock", group: hotkeyGroupView, combo: shiftChord("d")},
{id: hotkeySearchInFiles, labelKey: "hotkeys.action.searchInFiles", group: hotkeyGroupView, combo: comboUnassigned},
{id: hotkeySplitBeside, labelKey: "hotkeys.action.splitBeside", group: hotkeyGroupView, combo: shiftChord("g")},
{id: hotkeySplitBelow, labelKey: "hotkeys.action.splitBelow", group: hotkeyGroupView, combo: shiftChord("i")},
{id: hotkeyOtherPane, labelKey: "hotkeys.action.otherPane", group: hotkeyGroupView, combo: shiftChord("f")},
{id: hotkeyZoomPane, labelKey: "hotkeys.action.zoomPane", group: hotkeyGroupView, combo: shiftChord("m")},
{id: hotkeyUnsplitPane, labelKey: "hotkeys.action.unsplitPane", group: hotkeyGroupView, combo: shiftChord("w")},
{id: hotkeyRotateSplit, labelKey: "hotkeys.action.rotateSplit", group: hotkeyGroupView, combo: shiftChord("r")},
{id: hotkeyCommandPalette, labelKey: "hotkeys.action.commandPalette", group: hotkeyGroupApp, combo: combo{Mod: true, Key: "k"}},
{id: hotkeySettings, labelKey: "hotkeys.action.settings", group: hotkeyGroupApp, combo: combo{Mod: true, Key: ","}},
{id: hotkeyPulls, labelKey: "hotkeys.action.pulls", group: hotkeyGroupApp, combo: shiftChord("p")},
Expand Down
17 changes: 11 additions & 6 deletions native/cmd/lich-native/hotkeys_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ type hotkeyHandler struct {
}

// hotkeyHandlers are the actions with a native screen or command. Absent,
// and so inert: delegate (no delegate flow),
// splitBeside and otherPane (no split panes).
// and so inert: delegate (no delegate flow).
var hotkeyHandlers = map[hotkeyID]hotkeyHandler{
hotkeySplitBeside: {(*window).hasActiveSession, func(u *window, _ C) { u.pickBeside(edgeRight) }},
hotkeySplitBelow: {(*window).hasActiveSession, func(u *window, _ C) { u.pickBeside(edgeBottom) }},
hotkeyOtherPane: {(*window).hasLayout, func(u *window, _ C) { u.model.cyclePane(u.ctx, 1); u.model.invalidate() }},
hotkeyZoomPane: {(*window).hasLayout, func(u *window, _ C) { u.model.toggleZoom(); u.model.invalidate() }},
hotkeyUnsplitPane: {(*window).hasLayout, (*window).hotkeyUnsplit},
hotkeyRotateSplit: {(*window).hasLayout, func(u *window, _ C) { u.model.rotateFocused(); u.model.invalidate() }},
hotkeyNewSession: {(*window).canNewSession, (*window).hotkeyNewSession},
hotkeyNewWorktree: {(*window).hasProject, func(u *window, _ C) { u.showWorktreeDialog() }},
hotkeyRenameSession: {(*window).hasActiveSession, (*window).hotkeyRename},
Expand Down Expand Up @@ -230,10 +235,10 @@ func (u *window) hotkeyFocusTerminal(gtx C) {
gtx.Execute(key.FocusCmd{})
}

// neighborTab is the project the step lands on in the strip's order, Home
// first, and false when the strip has nowhere to go. "" is Home.
// neighborTab is the project or layout the step lands on in the strip's
// order, Home first, and false when the strip has nowhere to go. "" is Home.
func (u *window) neighborTab(step int) (string, bool) {
tabs, active := u.model.projectTabs()
tabs, active := u.model.stripTabs()
ids := make([]string, 0, len(tabs)+1)
ids = append(ids, "")
for _, t := range tabs {
Expand All @@ -256,7 +261,7 @@ func (u *window) hotkeyTab(step int) {
u.saveScreen("")
return
}
u.showProject(target)
u.showStripTab(target)
}

// setSidebarOpen is the sidebar's own toggle, stored and morphed to.
Expand Down
Loading
Loading