Files
ibrahim dursunandGitHub e33670bb44 refactor: replace owner-based dispatch with scope-layer routing (#625)
* refactor: move `helpkeys` into `help`

* refactor: replace owner-based dispatch with scope-layer routing

Move UI action dispatch to explicit routing layers exposed by each model
instead of special-casing owner-based paths in ui.Model

This also tightens dispatch behaviour for blocking views so cancel/esc and
stacked overlays route consistently.

* refactor(revisions): activate revision operations

* refactor(quick_search): use consistent naming

* refactor: rename bindings.Scope

* refactor: rename Layer to Scope

* refactor: move `routing` to `dispatch`

* fix: AllowLeak

* refactor: add `VisibleScopes`

* refactor: move help building to `status`

* feat(revisions): stacked operations

* fix(revisions): tighten architecture

* refactor: change `AllowLeak` to a policy

* feat(status): group bindings by scopes

* feat(status): mark overriden keybindings with strikethrough
2026-04-11 17:42:38 +01:00

705 lines
18 KiB
Go

package scripting
import (
stdcontext "context"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
"github.com/idursun/jjui/internal/ui/actionmeta"
"github.com/idursun/jjui/internal/ui/choose"
"github.com/idursun/jjui/internal/ui/common"
uicontext "github.com/idursun/jjui/internal/ui/context"
"github.com/idursun/jjui/internal/ui/exec_process"
"github.com/idursun/jjui/internal/ui/input"
"github.com/idursun/jjui/internal/ui/intents"
"github.com/idursun/jjui/internal/ui/revisions"
lua "github.com/yuin/gopher-lua"
)
type step struct {
cmd tea.Cmd
matcher func(tea.Msg) (bool, []lua.LValue)
}
type Runner struct {
ctx *uicontext.MainContext
main *lua.LState
thread *lua.LState
cancel stdcontext.CancelFunc
fn *lua.LFunction
started bool
await func(tea.Msg) (bool, []lua.LValue)
resumeArgs []lua.LValue
done bool
}
func RunScript(ctx *uicontext.MainContext, src string) (*Runner, tea.Cmd, error) {
L, err := vmFromContext(ctx)
if err != nil {
return nil, nil, err
}
r := &Runner{ctx: ctx, main: L}
fn, err := L.LoadString(src)
if err != nil {
return nil, nil, fmt.Errorf("lua: %w", err)
}
r.fn = fn
r.thread, r.cancel = L.NewThread()
cmd := r.resume()
if r.done {
r.close()
}
return r, cmd, nil
}
func (r *Runner) close() {
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
}
func (r *Runner) resume() tea.Cmd {
if r.done {
return nil
}
var cmds []tea.Cmd
for {
var fn *lua.LFunction
if !r.started {
fn = r.fn
}
args := r.resumeArgs
r.resumeArgs = nil
state, err, values := r.main.Resume(r.thread, fn, args...)
r.started = true
if err != nil {
r.done = true
cmds = append(cmds, intents.Invoke(intents.AddMessage{Text: err.Error(), Err: err}))
break
}
for _, v := range values {
if ud, ok := v.(*lua.LUserData); ok {
if st, ok := ud.Value.(step); ok {
if st.matcher != nil {
r.await = st.matcher
if st.cmd != nil {
cmds = append(cmds, st.cmd)
}
return tea.Sequence(cmds...)
}
if st.cmd != nil {
cmds = append(cmds, st.cmd)
}
}
}
}
if state == lua.ResumeOK {
r.done = true
break
}
// continue to resume to collect subsequent steps until an await or completion
if len(cmds) > 0 {
continue
}
}
if len(cmds) == 0 {
return nil
}
return tea.Sequence(cmds...)
}
// HandleMsg resumes the script if waiting for a matching message.
func (r *Runner) HandleMsg(msg tea.Msg) tea.Cmd {
if r.await == nil {
return nil
}
ok, resume := r.await(msg)
if !ok {
return nil
}
r.await = nil
r.resumeArgs = resume
cmd := r.resume()
if r.done {
r.close()
}
return cmd
}
func (r *Runner) Done() bool {
return r.done && r.await == nil
}
func registerAPI(L *lua.LState, ctx *uicontext.MainContext) {
revisionsTable := L.NewTable()
revisionsTable.RawSetString("current", L.NewFunction(func(L *lua.LState) int {
if rev, ok := ctx.SelectedItem.(uicontext.SelectedRevision); ok {
L.Push(lua.LString(rev.ChangeId))
return 1
}
return 0
}))
revisionsTable.RawSetString("checked", L.NewFunction(func(L *lua.LState) int {
tbl := L.NewTable()
for _, item := range ctx.CheckedItems {
if rev, ok := item.(uicontext.SelectedRevision); ok {
tbl.Append(lua.LString(rev.ChangeId))
}
}
L.Push(tbl)
return 1
}))
revisionsTable.RawSetString("refresh", L.NewFunction(func(L *lua.LState) int {
payload := payloadFromTop(L)
intent := intents.Refresh{
KeepSelections: boolVal(payload, "keep_selections"),
SelectedRevision: stringVal(payload, "selected_revision"),
}
return yieldStep(L, step{cmd: revisions.RevisionsCmd(intent), matcher: matchUpdateRevisionsSuccess})
}))
revisionsTable.RawSetString("navigate", L.NewFunction(func(L *lua.LState) int {
payload := payloadFromTop(L)
target := parseNavigateTarget(stringVal(payload, "target"))
intent := intents.Navigate{
Delta: intVal(payload, "by"),
IsPage: boolVal(payload, "page"),
Target: target,
ChangeID: stringVal(payload, "to"),
FallbackID: stringVal(payload, "fallback"),
}
if v, ok := payload["ensureView"]; ok {
if b, ok := v.(bool); ok {
intent.EnsureView = boolPtr(b)
}
}
if v, ok := payload["allowStream"]; ok {
if b, ok := v.(bool); ok {
intent.AllowStream = boolPtr(b)
}
}
return yieldStep(L, step{cmd: revisions.RevisionsCmd(intent)})
}))
revsetTable := L.NewTable()
revsetTable.RawSetString("current", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(ctx.CurrentRevset))
return 1
}))
revsetTable.RawSetString("default", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(ctx.DefaultRevset))
return 1
}))
contextTable := L.NewTable()
contextTable.RawSetString("change_id", L.NewFunction(func(L *lua.LState) int {
switch item := ctx.SelectedItem.(type) {
case uicontext.SelectedRevision:
L.Push(lua.LString(item.ChangeId))
return 1
case uicontext.SelectedFile:
L.Push(lua.LString(item.ChangeId))
return 1
}
return 0
}))
contextTable.RawSetString("commit_id", L.NewFunction(func(L *lua.LState) int {
switch item := ctx.SelectedItem.(type) {
case uicontext.SelectedRevision:
L.Push(lua.LString(item.CommitId))
return 1
case uicontext.SelectedFile:
L.Push(lua.LString(item.CommitId))
return 1
case uicontext.SelectedCommit:
L.Push(lua.LString(item.CommitId))
return 1
}
return 0
}))
contextTable.RawSetString("file", L.NewFunction(func(L *lua.LState) int {
if item, ok := ctx.SelectedItem.(uicontext.SelectedFile); ok {
L.Push(lua.LString(item.File))
return 1
}
return 0
}))
contextTable.RawSetString("operation_id", L.NewFunction(func(L *lua.LState) int {
if item, ok := ctx.SelectedItem.(uicontext.SelectedOperation); ok {
L.Push(lua.LString(item.OperationId))
return 1
}
return 0
}))
contextTable.RawSetString("checked_files", L.NewFunction(func(L *lua.LState) int {
tbl := L.NewTable()
for _, item := range ctx.CheckedItems {
if f, ok := item.(uicontext.SelectedFile); ok {
tbl.Append(lua.LString(f.File))
}
}
L.Push(tbl)
return 1
}))
contextTable.RawSetString("checked_change_ids", L.NewFunction(func(L *lua.LState) int {
tbl := L.NewTable()
for _, item := range ctx.CheckedItems {
switch i := item.(type) {
case uicontext.SelectedRevision:
tbl.Append(lua.LString(i.ChangeId))
case uicontext.SelectedFile:
tbl.Append(lua.LString(i.ChangeId))
}
}
L.Push(tbl)
return 1
}))
contextTable.RawSetString("checked_commit_ids", L.NewFunction(func(L *lua.LState) int {
tbl := L.NewTable()
for _, item := range ctx.CheckedItems {
switch i := item.(type) {
case uicontext.SelectedRevision:
tbl.Append(lua.LString(i.CommitId))
case uicontext.SelectedFile:
tbl.Append(lua.LString(i.CommitId))
case uicontext.SelectedCommit:
tbl.Append(lua.LString(i.CommitId))
}
}
L.Push(tbl)
return 1
}))
jjAsyncFn := L.NewFunction(func(L *lua.LState) int {
args := argsFromLua(L)
return yieldStep(L, step{cmd: ctx.RunCommand(args)})
})
jjInteractiveFn := L.NewFunction(func(L *lua.LState) int {
args := argsFromLua(L)
return yieldStep(L, step{cmd: ctx.RunInteractiveCommand(args, nil)})
})
jjFn := L.NewFunction(func(L *lua.LState) int {
args := argsFromLua(L)
out, err := ctx.RunCommandImmediate(args)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LString(out))
L.Push(lua.LNil)
return 2
})
flashFn := L.NewFunction(func(L *lua.LState) int {
intent := intents.AddMessage{}
switch v := L.Get(1).(type) {
case *lua.LTable:
payload := luaTableToMap(v)
intent.Text = stringVal(payload, "text")
if boolVal(payload, "error") {
intent.Err = fmt.Errorf("%s", intent.Text)
}
intent.Sticky = boolVal(payload, "sticky")
default:
intent.Text = L.CheckString(1)
}
return yieldStep(L, step{cmd: intents.Invoke(intent)})
})
copyToClipboardFn := L.NewFunction(func(L *lua.LState) int {
text := L.CheckString(1)
if err := clipboard.WriteAll(text); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(lua.LBool(true))
L.Push(lua.LNil)
return 2
})
execShellFn := L.NewFunction(func(L *lua.LState) int {
command := L.CheckString(1)
msg := common.ExecMsg{
Line: command,
Mode: common.ExecShell,
}
return yieldStep(L, step{cmd: exec_process.ExecLine(ctx, msg)})
})
splitLinesFn := L.NewFunction(func(L *lua.LState) int {
text := L.CheckString(1)
keepEmpty := false
if L.GetTop() >= 2 {
keepEmpty = L.CheckBool(2)
}
tbl := L.NewTable()
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSuffix(line, "\r")
if line == "" && !keepEmpty {
continue
}
tbl.Append(lua.LString(line))
}
L.Push(tbl)
return 1
})
chooseFn := L.NewFunction(func(L *lua.LState) int {
var (
options []string
title string
filter bool
ordered bool
)
if L.GetTop() == 1 {
if tbl, ok := L.Get(1).(*lua.LTable); ok {
if optVal := tbl.RawGetString("options"); optVal != lua.LNil {
if optTbl, ok := optVal.(*lua.LTable); ok {
options = stringSliceFromTable(optTbl)
} else if s, ok := optVal.(lua.LString); ok {
options = []string{s.String()}
}
}
if titleVal := tbl.RawGetString("title"); titleVal != lua.LNil {
title = titleVal.String()
}
if filterVal := tbl.RawGetString("filter"); filterVal != lua.LNil {
filter = bool(filterVal.(lua.LBool))
}
if orderedVal := tbl.RawGetString("ordered"); orderedVal != lua.LNil {
ordered = bool(orderedVal.(lua.LBool))
}
if options == nil {
options = stringSliceFromTable(tbl)
}
return yieldStep(L, step{cmd: choose.ShowOrdered(options, title, filter, ordered), matcher: matchChoose})
}
}
options = argsFromLua(L)
return yieldStep(L, step{cmd: choose.ShowWithTitle(options, "", false), matcher: matchChoose})
})
inputFn := L.NewFunction(func(L *lua.LState) int {
var title, prompt string
if L.GetTop() == 1 {
if tbl, ok := L.Get(1).(*lua.LTable); ok {
if titleVal := tbl.RawGetString("title"); titleVal != lua.LNil {
title = titleVal.String()
}
if promptVal := tbl.RawGetString("prompt"); promptVal != lua.LNil {
prompt = promptVal.String()
}
return yieldStep(L, step{cmd: input.ShowWithTitle(title, prompt), matcher: matchInput})
}
}
return yieldStep(L, step{cmd: input.ShowWithTitle("", ""), matcher: matchInput})
})
waitCloseFn := L.NewFunction(func(L *lua.LState) int {
return yieldStep(L, step{matcher: matchCloseViewMsg})
})
waitRefreshFn := L.NewFunction(func(L *lua.LState) int {
return yieldStep(L, step{matcher: matchUpdateRevisionsSuccess})
})
// make sure we have a `jjui` namespace
root := L.NewTable()
root.RawSetString("revisions", revisionsTable)
root.RawSetString("revset", revsetTable)
root.RawSetString("context", contextTable)
root.RawSetString("jj_async", jjAsyncFn)
root.RawSetString("jj_interactive", jjInteractiveFn)
root.RawSetString("jj", jjFn)
root.RawSetString("flash", flashFn)
root.RawSetString("copy_to_clipboard", copyToClipboardFn)
root.RawSetString("exec_shell", execShellFn)
root.RawSetString("split_lines", splitLinesFn)
root.RawSetString("choose", chooseFn)
root.RawSetString("input", inputFn)
root.RawSetString("wait_close", waitCloseFn)
root.RawSetString("wait_refresh", waitRefreshFn)
builtinRoot := L.NewTable()
root.RawSetString("builtin", builtinRoot)
registerGeneratedActionAPI(L, root, false)
registerGeneratedActionAPI(L, builtinRoot, true)
L.SetGlobal("jjui", root)
// but also expose at the top level for convenience
L.SetGlobal("revisions", revisionsTable)
L.SetGlobal("revset", revsetTable)
if diffTable, ok := root.RawGetString("diff").(*lua.LTable); ok {
L.SetGlobal("diff", diffTable)
}
if uiTable, ok := root.RawGetString("ui").(*lua.LTable); ok {
L.SetGlobal("ui", uiTable)
}
L.SetGlobal("context", contextTable)
L.SetGlobal("jj_async", jjAsyncFn)
L.SetGlobal("jj_interactive", jjInteractiveFn)
L.SetGlobal("jj", jjFn)
L.SetGlobal("flash", flashFn)
L.SetGlobal("copy_to_clipboard", copyToClipboardFn)
L.SetGlobal("exec_shell", execShellFn)
L.SetGlobal("split_lines", splitLinesFn)
L.SetGlobal("choose", chooseFn)
L.SetGlobal("input", inputFn)
L.SetGlobal("wait_close", waitCloseFn)
L.SetGlobal("wait_refresh", waitRefreshFn)
}
func registerGeneratedActionAPI(L *lua.LState, root *lua.LTable, builtIn bool) {
actions := actionmeta.BuiltInActions()
for _, actionID := range actions {
scopes := actionmeta.ActionScopes(actionID)
for _, scope := range scopes {
scopeTable := ensureScopeTable(L, root, scope)
token := actionTokenFromCanonical(actionID)
if token == "" {
continue
}
// Keep existing utility helpers (e.g. jjui.revisions.refresh) intact.
if scopeTable.RawGetString(token) != lua.LNil {
continue
}
scopeTable.RawSetString(token, generatedActionFn(L, actionID, builtIn))
if token == "cancel" && scopeTable.RawGetString("close") == lua.LNil {
scopeTable.RawSetString("close", generatedActionFn(L, actionID, builtIn))
}
}
}
}
func ensureScopeTable(L *lua.LState, root *lua.LTable, scope string) *lua.LTable {
current := root
for segment := range strings.SplitSeq(scope, ".") {
existing := current.RawGetString(segment)
if tbl, ok := existing.(*lua.LTable); ok {
current = tbl
continue
}
next := L.NewTable()
current.RawSetString(segment, next)
current = next
}
return current
}
func generatedActionFn(L *lua.LState, canonical string, builtIn bool) *lua.LFunction {
var positionalKey string
if required := actionmeta.ActionRequiredArgs(canonical); len(required) == 1 && actionmeta.ActionArgSchema(canonical)[required[0]] == "string" {
positionalKey = required[0]
}
return L.NewFunction(func(L *lua.LState) int {
var args map[string]any
if positionalKey != "" && L.GetTop() >= 1 {
if s, ok := L.Get(1).(lua.LString); ok {
args = map[string]any{positionalKey: s.String()}
} else {
args = optionalLuaMapArg(L, 1)
}
} else {
args = optionalLuaMapArg(L, 1)
}
return yieldStep(L, step{cmd: func() tea.Msg {
return common.DispatchActionMsg{
Action: canonical,
Args: args,
BuiltIn: builtIn,
}
}})
})
}
func optionalLuaMapArg(L *lua.LState, pos int) map[string]any {
if L.GetTop() < pos || L.Get(pos) == lua.LNil {
return nil
}
tbl, ok := L.Get(pos).(*lua.LTable)
if !ok {
L.ArgError(pos, "expected table or nil")
return nil
}
return luaTableToMap(tbl)
}
func payloadFromTop(L *lua.LState) map[string]any {
if L.GetTop() >= 1 && L.CheckAny(1) != lua.LNil {
if tbl, ok := L.Get(1).(*lua.LTable); ok {
return luaTableToMap(tbl)
}
}
return map[string]any{}
}
func boolVal(payload map[string]any, key string) bool {
if v, ok := payload[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return false
}
func intVal(payload map[string]any, key string) int {
if v, ok := payload[key]; ok {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
}
}
return 0
}
func stringVal(payload map[string]any, key string) string {
if v, ok := payload[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func argsFromLua(L *lua.LState) []string {
if L.GetTop() == 0 {
return nil
}
if tbl, ok := L.Get(1).(*lua.LTable); ok {
return stringSliceFromTable(tbl)
}
var out []string
top := L.GetTop()
for i := 1; i <= top; i++ {
out = append(out, L.CheckString(i))
}
return out
}
func stringSliceFromTable(tbl *lua.LTable) []string {
var out []string
tbl.ForEach(func(_, value lua.LValue) {
if s, ok := value.(lua.LString); ok {
out = append(out, s.String())
}
})
return out
}
func luaTableToMap(tbl *lua.LTable) map[string]any {
result := map[string]any{}
tbl.ForEach(func(key, value lua.LValue) {
if key.Type() != lua.LTString {
return
}
result[key.String()] = luaValueToGo(value)
})
return result
}
func luaTableToSlice(tbl *lua.LTable) []any {
var result []any
tbl.ForEach(func(_, value lua.LValue) {
result = append(result, luaValueToGo(value))
})
return result
}
func luaValueToGo(value lua.LValue) any {
switch value.Type() {
case lua.LTBool:
return bool(value.(lua.LBool))
case lua.LTNumber:
return float64(value.(lua.LNumber))
case lua.LTString:
return value.String()
case lua.LTTable:
t := value.(*lua.LTable)
// Heuristic: if keys are string, convert to map; otherwise, slice.
isMap := false
t.ForEach(func(key, _ lua.LValue) {
if key.Type() == lua.LTString {
isMap = true
}
})
if isMap {
return luaTableToMap(t)
}
return luaTableToSlice(t)
default:
return nil
}
}
func yieldStep(L *lua.LState, st step) int {
ud := L.NewUserData()
ud.Value = st
return L.Yield(ud)
}
func boolPtr(v bool) *bool {
return &v
}
func parseNavigateTarget(val string) intents.NavigationTarget {
switch strings.ToLower(val) {
case "parent":
return intents.TargetParent
case "child", "children":
return intents.TargetChild
case "working", "working_copy", "work":
return intents.TargetWorkingCopy
default:
return intents.TargetNone
}
}
func matchUpdateRevisionsSuccess(msg tea.Msg) (bool, []lua.LValue) {
switch msg.(type) {
case common.UpdateRevisionsSuccessMsg, common.UpdateRevisionsFailedMsg:
return true, nil
default:
return false, nil
}
}
func matchCloseViewMsg(msg tea.Msg) (bool, []lua.LValue) {
if closeMsg, ok := msg.(common.CloseViewMsg); ok {
return true, []lua.LValue{lua.LBool(closeMsg.Applied)}
}
return false, nil
}
func matchChoose(msg tea.Msg) (bool, []lua.LValue) {
switch msg := msg.(type) {
case choose.SelectedMsg:
return true, []lua.LValue{lua.LString(msg.Value)}
case choose.CancelledMsg:
return true, []lua.LValue{lua.LNil}
default:
return false, nil
}
}
func matchInput(msg tea.Msg) (bool, []lua.LValue) {
switch msg := msg.(type) {
case input.SelectedMsg:
return true, []lua.LValue{lua.LString(msg.Value)}
case input.CancelledMsg:
return true, []lua.LValue{lua.LNil}
default:
return false, nil
}
}
func actionTokenFromCanonical(actionID string) string {
if idx := strings.LastIndexByte(actionID, '.'); idx >= 0 && idx < len(actionID)-1 {
return actionID[idx+1:]
}
return actionID
}