Skip to content

Commit 3b5997b

Browse files
committed
feat: add SkipReport for xcode
1 parent fd0e1ae commit 3b5997b

14 files changed

Lines changed: 612 additions & 72 deletions

File tree

internal/cmd/export.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,15 @@ func exportRun(
117117
return err
118118
}
119119

120+
// Show skipped actions in interactive mode for visibility
121+
if f.interactive && report != nil && len(report.SkipActions) > 0 {
122+
vm := views.NewSkipReportViewModel(report.SkipActions)
123+
p := tea.NewProgram(vm)
124+
if _, err := p.Run(); err != nil {
125+
logger.Error("could not start program", "error", err)
126+
}
127+
}
128+
120129
// Show diff preview
121130
cmd.Println("================ Export Diff Preview ================")
122131
if report != nil && strings.TrimSpace(report.Diff) != "" {

internal/export/marker.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package export
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"sort"
7+
"strings"
8+
9+
"github.com/xinnjie/onekeymap-cli/pkg/pluginapi"
10+
keymapv1 "github.com/xinnjie/onekeymap-cli/protogen/keymap/v1"
11+
)
12+
13+
type Marker struct {
14+
keymap *keymapv1.Keymap
15+
// per-action exported key set (canonical keybinding string -> true)
16+
exported map[string]map[string]bool
17+
// per-action per-keybinding skip reason
18+
skippedKeys map[string]map[string]error
19+
// action-level skip reason (applies to all unexported keybindings)
20+
skippedAction map[string]error
21+
}
22+
23+
func NewMarker(keymap *keymapv1.Keymap) *Marker {
24+
return &Marker{
25+
keymap: keymap,
26+
exported: make(map[string]map[string]bool),
27+
skippedKeys: make(map[string]map[string]error),
28+
skippedAction: make(map[string]error),
29+
}
30+
}
31+
32+
// MarkExported marks a specific keybinding of an action as exported.
33+
// Exporter should always call this method for each exported keybinding.
34+
func (m *Marker) MarkExported(action string, keybinding *keymapv1.Keybinding) {
35+
if m == nil || keybinding == nil {
36+
return
37+
}
38+
if _, ok := m.exported[action]; !ok {
39+
m.exported[action] = make(map[string]bool)
40+
}
41+
m.exported[action][canonicalKeybindingID(keybinding)] = true
42+
}
43+
44+
// MarkSkippedForReason marks an action or a specific keybinding as skipped for a reason.
45+
// If keybinding is nil, the reason is applied at action level for all unexported keybindings.
46+
// If not called, any keybinding not marked as exported will be filled with
47+
// pluginapi.ErrActionNotSupported in SkipReport.
48+
func (m *Marker) MarkSkippedForReason(action string, keybinding *keymapv1.Keybinding, reasonErr error) {
49+
if m == nil {
50+
return
51+
}
52+
if reasonErr == nil {
53+
reasonErr = pluginapi.ErrActionNotSupported
54+
}
55+
if keybinding == nil {
56+
if _, exists := m.skippedAction[action]; !exists {
57+
m.skippedAction[action] = reasonErr
58+
}
59+
return
60+
}
61+
if _, ok := m.skippedKeys[action]; !ok {
62+
m.skippedKeys[action] = make(map[string]error)
63+
}
64+
k := canonicalKeybindingID(keybinding)
65+
if _, exists := m.skippedKeys[action][k]; !exists {
66+
m.skippedKeys[action][k] = reasonErr
67+
}
68+
}
69+
70+
func (m *Marker) Report() pluginapi.SkipReport {
71+
if m == nil || m.keymap == nil {
72+
return pluginapi.SkipReport{}
73+
}
74+
actions := m.keymap.GetActions()
75+
// ensure stable order by sorting action IDs for determinism in tests
76+
ids := make([]string, 0, len(actions))
77+
for _, a := range actions {
78+
if a == nil {
79+
continue
80+
}
81+
ids = append(ids, a.GetName())
82+
}
83+
slices.Sort(ids)
84+
var result []pluginapi.SkipAction
85+
for _, id := range ids {
86+
// Find the action in the original slice to access its bindings
87+
var act *keymapv1.Action
88+
for _, a := range actions {
89+
if a != nil && a.GetName() == id {
90+
act = a
91+
break
92+
}
93+
}
94+
if act == nil {
95+
continue
96+
}
97+
// iterate each binding
98+
for _, br := range act.GetBindings() {
99+
if br == nil || br.GetKeyChords() == nil {
100+
continue
101+
}
102+
kb := br.GetKeyChords()
103+
key := canonicalKeybindingID(kb)
104+
// exported? skip
105+
if expForAct, ok := m.exported[id]; ok {
106+
if expForAct[key] {
107+
continue
108+
}
109+
}
110+
// explicit per-key skip reason?
111+
if perKey, ok := m.skippedKeys[id]; ok {
112+
if err, ok2 := perKey[key]; ok2 {
113+
result = append(result, pluginapi.SkipAction{Action: id, Error: err})
114+
continue
115+
}
116+
}
117+
// action-level skip reason?
118+
if err, ok := m.skippedAction[id]; ok {
119+
result = append(result, pluginapi.SkipAction{Action: id, Error: err})
120+
continue
121+
}
122+
// default
123+
result = append(result, pluginapi.SkipAction{Action: id, Error: pluginapi.ErrActionNotSupported})
124+
}
125+
}
126+
return pluginapi.SkipReport{SkipActions: result}
127+
}
128+
129+
// canonicalKeybindingID builds a stable string identifier for a keybinding.
130+
// It sorts modifiers in each chord to ensure consistent identity.
131+
func canonicalKeybindingID(kb *keymapv1.Keybinding) string {
132+
if kb == nil {
133+
return ""
134+
}
135+
parts := make([]string, 0, len(kb.GetChords()))
136+
for _, ch := range kb.GetChords() {
137+
if ch == nil {
138+
continue
139+
}
140+
mods := append([]keymapv1.KeyModifier(nil), ch.GetModifiers()...)
141+
sort.Slice(mods, func(i, j int) bool { return mods[i] < mods[j] })
142+
parts = append(parts, fmt.Sprintf("%d:%v", ch.GetKeyCode(), mods))
143+
}
144+
return strings.Join(parts, " ")
145+
}

internal/export/marker_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package export_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
exp "github.com/xinnjie/onekeymap-cli/internal/export"
10+
"github.com/xinnjie/onekeymap-cli/internal/keymap"
11+
"github.com/xinnjie/onekeymap-cli/pkg/pluginapi"
12+
keymapv1 "github.com/xinnjie/onekeymap-cli/protogen/keymap/v1"
13+
)
14+
15+
func newKeymapWithTwoBindings(action string, k1, k2 string) (*keymapv1.Keymap, *keymapv1.Action) {
16+
a := keymap.NewActioinBinding(action, k1)
17+
a.Bindings = append(a.Bindings, &keymapv1.KeybindingReadable{KeyChords: keymap.MustParseKeyBinding(k2).KeyChords})
18+
return &keymapv1.Keymap{Actions: []*keymapv1.Action{a}}, a
19+
}
20+
21+
func TestMarker_ImplicitSkipForUnexportedBindings(t *testing.T) {
22+
km, a := newKeymapWithTwoBindings("actions.test.dual", "cmd+a", "cmd+c")
23+
m := exp.NewMarker(km)
24+
25+
// Export only first binding
26+
m.MarkExported(a.GetName(), a.GetBindings()[0].GetKeyChords())
27+
28+
rep := m.Report()
29+
require.Len(t, rep.SkipActions, 1)
30+
// The skipped one should be the second binding with default not supported
31+
sk := rep.SkipActions[0]
32+
assert.Equal(t, a.GetName(), sk.Action)
33+
assert.ErrorIs(t, sk.Error, pluginapi.ErrActionNotSupported)
34+
}
35+
36+
func TestMarker_ExplicitKeySkip(t *testing.T) {
37+
km, a := newKeymapWithTwoBindings("actions.test.dual", "cmd+a", "cmd+b")
38+
m := exp.NewMarker(km)
39+
40+
// Export first, explicitly skip second
41+
m.MarkExported(a.GetName(), a.GetBindings()[0].GetKeyChords())
42+
m.MarkSkippedForReason(
43+
a.GetName(),
44+
keymap.MustParseKeyBinding("cmd+b").KeyChords,
45+
&pluginapi.EditorSupportOnlyOneKeybindingPerActionError{
46+
SkipKeybinding: keymap.MustParseKeyBinding("cmd+b").KeyChords,
47+
},
48+
)
49+
50+
rep := m.Report()
51+
require.Len(t, rep.SkipActions, 1)
52+
sk := rep.SkipActions[0]
53+
assert.Equal(t, a.GetName(), sk.Action)
54+
var ose *pluginapi.EditorSupportOnlyOneKeybindingPerActionError
55+
require.ErrorAs(t, sk.Error, &ose)
56+
require.NotNil(t, ose)
57+
require.NotNil(t, ose.SkipKeybinding)
58+
assert.Equal(t, ose.SkipKeybinding, keymap.MustParseKeyBinding("cmd+b").KeyChords)
59+
}
60+
61+
func TestMarker_ActionLevelSkipAppliesToAllUnexported(t *testing.T) {
62+
km, a := newKeymapWithTwoBindings("actions.test.multi", "cmd+a", "cmd+b")
63+
m := exp.NewMarker(km)
64+
65+
// Export only first, mark action-level skip
66+
note := "not available on this editor"
67+
m.MarkExported(a.GetName(), keymap.MustParseKeyBinding("cmd+a").KeyChords)
68+
m.MarkSkippedForReason(a.GetName(), nil, &pluginapi.NotSupportedError{Note: note})
69+
70+
rep := m.Report()
71+
require.Len(t, rep.SkipActions, 1)
72+
sk := rep.SkipActions[0]
73+
assert.Equal(t, a.GetName(), sk.Action)
74+
require.ErrorContains(t, sk.Error, note)
75+
}
76+
77+
func TestMarker_PerKeyReasonOverridesActionLevel(t *testing.T) {
78+
km, a := newKeymapWithTwoBindings("actions.test.override", "cmd+x", "cmd+b")
79+
m := exp.NewMarker(km)
80+
81+
// No exported; set action-level reason and a different per-key reason for second
82+
m.MarkSkippedForReason(a.GetName(), nil, &pluginapi.NotSupportedError{Note: "action"})
83+
m.MarkSkippedForReason(
84+
a.GetName(),
85+
keymap.MustParseKeyBinding("cmd+b").KeyChords,
86+
&pluginapi.EditorSupportOnlyOneKeybindingPerActionError{
87+
SkipKeybinding: keymap.MustParseKeyBinding("cmd+b").KeyChords,
88+
},
89+
)
90+
91+
rep := m.Report()
92+
// Both keybindings should be reported as skipped
93+
require.Len(t, rep.SkipActions, 2)
94+
// Identify entries by error type and its embedded keybinding
95+
var first, second *pluginapi.SkipAction
96+
for i := range rep.SkipActions {
97+
var ose *pluginapi.EditorSupportOnlyOneKeybindingPerActionError
98+
if errors.As(rep.SkipActions[i].Error, &ose) {
99+
second = &rep.SkipActions[i]
100+
} else {
101+
first = &rep.SkipActions[i]
102+
}
103+
}
104+
require.NotNil(t, first)
105+
require.NotNil(t, second)
106+
// First uses action-level reason
107+
require.ErrorContains(t, first.Error, "action")
108+
// Second uses per-key reason
109+
var ose2 *pluginapi.EditorSupportOnlyOneKeybindingPerActionError
110+
require.ErrorAs(t, second.Error, &ose2)
111+
assert.Equal(t, ose2.SkipKeybinding, keymap.MustParseKeyBinding("cmd+b").KeyChords)
112+
}

internal/export_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func (s *exportService) Export(
105105
if err != nil {
106106
return nil, err
107107
}
108-
return &exportapi.ExportReport{Diff: diffStr}, nil
108+
return &exportapi.ExportReport{Diff: diffStr, SkipActions: report.SkipReport.SkipActions}, nil
109109
}
110110

111111
// computeDiff centralizes diff generation for export results based on requested options.

internal/export_service_test.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ type testExportPlugin struct {
2525
exporter pluginapi.PluginExporter
2626
}
2727

28+
func TestExportService_PropagatesSkipActions(t *testing.T) {
29+
exp := &testExporter{skipActions: []pluginapi.SkipAction{{Action: "a1", Error: pluginapi.ErrActionNotSupported}}}
30+
service := newTestExportService(t, exp)
31+
32+
var out bytes.Buffer
33+
report, err := service.Export(context.Background(), &out, &keymapv1.Keymap{}, exportapi.ExportOptions{
34+
EditorType: pluginapi.EditorType("test"),
35+
DiffType: keymapv1.ExportKeymapRequest_DIFF_TYPE_UNSPECIFIED,
36+
})
37+
require.NoError(t, err)
38+
require.NotNil(t, report)
39+
require.Len(t, report.SkipActions, 1)
40+
assert.Equal(t, "a1", report.SkipActions[0].Action)
41+
}
42+
2843
func (p *testExportPlugin) EditorType() pluginapi.EditorType { return p.editorType }
2944
func (p *testExportPlugin) ConfigDetect(_ pluginapi.ConfigDetectOptions) ([]string, bool, error) {
3045
return nil, false, pluginapi.ErrNotSupported
@@ -40,6 +55,7 @@ type testExporter struct {
4055
baseEditorConfig any
4156
exportEditorConfig any
4257
reportDiff *string
58+
skipActions []pluginapi.SkipAction
4359
}
4460

4561
func (e *testExporter) Export(
@@ -59,17 +75,17 @@ func (e *testExporter) Export(
5975
Diff: e.reportDiff,
6076
BaseEditorConfig: e.baseEditorConfig,
6177
ExportEditorConfig: e.exportEditorConfig,
78+
SkipReport: pluginapi.SkipReport{SkipActions: e.skipActions},
6279
}, nil
6380
}
6481

6582
func newTestExportService(
6683
t *testing.T,
6784
exporter pluginapi.PluginExporter,
68-
editorType pluginapi.EditorType,
6985
) exportapi.Exporter {
7086
t.Helper()
7187
r := plugins.NewRegistry()
72-
r.Register(&testExportPlugin{editorType: editorType, exporter: exporter})
88+
r.Register(&testExportPlugin{editorType: pluginapi.EditorType("test"), exporter: exporter})
7389
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
7490
recorder := metrics.NewNoop()
7591
return internal.NewExportService(
@@ -84,7 +100,7 @@ func TestExportService_Diff_Unified(t *testing.T) {
84100
before := "line1\n"
85101
after := "line1\nline2\n"
86102
exp := &testExporter{writeContent: after}
87-
service := newTestExportService(t, exp, pluginapi.EditorType("test"))
103+
service := newTestExportService(t, exp)
88104

89105
var out bytes.Buffer
90106
report, err := service.Export(context.Background(), &out, &keymapv1.Keymap{}, exportapi.ExportOptions{
@@ -104,7 +120,7 @@ func TestExportService_Diff_JSONASCII_FromStructuredConfigs(t *testing.T) {
104120
baseCfg := map[string]any{"k": "v1"}
105121
afterCfg := map[string]any{"k": "v2"}
106122
exp := &testExporter{baseEditorConfig: baseCfg, exportEditorConfig: afterCfg}
107-
service := newTestExportService(t, exp, pluginapi.EditorType("test"))
123+
service := newTestExportService(t, exp)
108124

109125
var out bytes.Buffer
110126
report, err := service.Export(context.Background(), &out, &keymapv1.Keymap{}, exportapi.ExportOptions{
@@ -120,7 +136,7 @@ func TestExportService_Diff_JSONASCII_FromStructuredConfigs(t *testing.T) {
120136
func TestExportService_Diff_FallbackFromPlugin(t *testing.T) {
121137
fallback := "fallback-diff"
122138
exp := &testExporter{reportDiff: &fallback}
123-
service := newTestExportService(t, exp, pluginapi.EditorType("test"))
139+
service := newTestExportService(t, exp)
124140

125141
var out bytes.Buffer
126142
report, err := service.Export(context.Background(), &out, &keymapv1.Keymap{}, exportapi.ExportOptions{

0 commit comments

Comments
 (0)