detached 5095dec

Merge pull request #208 from bnema/fix/session-transfer-observability

fix(daemon): trace tab and pane move outcomes
commit
5095dec9f47791fabaaee5d8017ba18db215bf18
author
bnema · · 2026-08-20 14:26 UTC
committer
GitHub · · 2026-08-20 14:26 UTC
merge of
3730d96 + d6fec55 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +244 -4
M internal/usecase/daemon/move.go +25 -2
@@ -32,8 +32,30 @@ type moveTabRequest struct {
3232 // section starts, the caller holds the ordered architecture locks and only
3333 // non-failing in-memory writes are permitted.
3434 func (d *Daemon) movePane(req movePaneRequest) (result error) {
35- defer func() { result = normalizeMoveRejection(result) }()
36- if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||
35+ if d == nil {
36+ return errMovePaneInvalid
37+ }
38+ d.log.Info("pane move requested",
39+ "source_session_id", req.Source.ID,
40+ "source_tab_id", req.SourceTabID,
41+ "source_pane_id", req.SourcePaneID,
42+ "destination_session_id", req.Destination.ID,
43+ "destination_tab_id", req.DestinationTabID,
44+ )
45+ defer func() {
46+ result = normalizeMoveRejection(result)
47+ if result != nil {
48+ d.log.Warn("pane move rejected",
49+ "err", result,
50+ "source_session_id", req.Source.ID,
51+ "source_tab_id", req.SourceTabID,
52+ "source_pane_id", req.SourcePaneID,
53+ "destination_session_id", req.Destination.ID,
54+ "destination_tab_id", req.DestinationTabID,
55+ )
56+ }
57+ }()
58+ if req.Source.ID == "" || req.Destination.ID == "" ||
3759 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
3860 return errMovePaneInvalid
3961 }
@@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
150172 sourceTab: sourceTab,
151173 destinationTab: destinationTab,
152174 movedPane: movedPane,
175+ operation: "pane",
153176 sourceAttachments: admission.sourceAttachments,
154177 syncCleanup: commit.syncCleanup,
155178 frozenEffects: frozen,
@@ -32,8 +32,30 @@ type moveTabRequest struct {
32 // section starts, the caller holds the ordered architecture locks and only32 // section starts, the caller holds the ordered architecture locks and only
33 // non-failing in-memory writes are permitted.33 // non-failing in-memory writes are permitted.
34 func (d *Daemon) movePane(req movePaneRequest) (result error) {34 func (d *Daemon) movePane(req movePaneRequest) (result error) {
35- defer func() { result = normalizeMoveRejection(result) }()35+ if d == nil {
36- if d == nil || req.Source.ID == "" || req.Destination.ID == "" ||36+ return errMovePaneInvalid
37+ }
38+ d.log.Info("pane move requested",
39+ "source_session_id", req.Source.ID,
40+ "source_tab_id", req.SourceTabID,
41+ "source_pane_id", req.SourcePaneID,
42+ "destination_session_id", req.Destination.ID,
43+ "destination_tab_id", req.DestinationTabID,
44+ )
45+ defer func() {
46+ result = normalizeMoveRejection(result)
47+ if result != nil {
48+ d.log.Warn("pane move rejected",
49+ "err", result,
50+ "source_session_id", req.Source.ID,
51+ "source_tab_id", req.SourceTabID,
52+ "source_pane_id", req.SourcePaneID,
53+ "destination_session_id", req.Destination.ID,
54+ "destination_tab_id", req.DestinationTabID,
55+ )
56+ }
57+ }()
58+ if req.Source.ID == "" || req.Destination.ID == "" ||
37 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {59 req.SourceTabID == "" || req.SourcePaneID == "" || req.DestinationTabID == "" {
38 return errMovePaneInvalid60 return errMovePaneInvalid
39 }61 }
@@ -150,6 +172,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
150 sourceTab: sourceTab,172 sourceTab: sourceTab,
151 destinationTab: destinationTab,173 destinationTab: destinationTab,
152 movedPane: movedPane,174 movedPane: movedPane,
175+ operation: "pane",
153 sourceAttachments: admission.sourceAttachments,176 sourceAttachments: admission.sourceAttachments,
154 syncCleanup: commit.syncCleanup,177 syncCleanup: commit.syncCleanup,
155 frozenEffects: frozen,178 frozenEffects: frozen,
A internal/usecase/daemon/move_observability_test.go +172 -0
@@ -0,0 +1,172 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+type moveObservabilityFixture struct {
15+ daemon *Daemon
16+ source *session
17+ sourceTab *tab
18+ sourcePane *pane
19+ destination *session
20+ destinationTab *tab
21+ logs *bytes.Buffer
22+}
23+
24+func newMoveObservabilityFixture(t *testing.T) moveObservabilityFixture {
25+ t.Helper()
26+ logs := &bytes.Buffer{}
27+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
28+ d.log = slog.New(slog.NewJSONHandler(logs, nil))
29+
30+ sourceTab := source.tabs[0]
31+ sourceTab.stableID = "source-tab"
32+ sourcePane := sourceTab.focusedPane()
33+ sourcePane.stableID = "source-pane"
34+
35+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
36+ t.Cleanup(destinationCancel)
37+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
38+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
39+ destination := &session{
40+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}},
41+ ctx: destinationCtx,
42+ cancel: destinationCancel,
43+ tabs: []*tab{destinationTab},
44+ }
45+ publishTiledPaneOwners(destination, destinationTab)
46+ d.mu.Lock()
47+ d.sessions[destination.id] = destination
48+ d.mu.Unlock()
49+
50+ return moveObservabilityFixture{
51+ daemon: d, source: source, sourceTab: sourceTab, sourcePane: sourcePane,
52+ destination: destination, destinationTab: destinationTab, logs: logs,
53+ }
54+}
55+
56+func TestMoveLogsCommitAndSourceAttachmentTermination(t *testing.T) {
57+ tests := []struct {
58+ name string
59+ operation string
60+ move func(moveObservabilityFixture) error
61+ assertLog func(*testing.T, string)
62+ }{
63+ {
64+ name: "tab",
65+ operation: "tab",
66+ move: func(f moveObservabilityFixture) error {
67+ return f.daemon.moveTab(moveTabRequest{
68+ Source: moveSessionLocator{ID: f.source.id, Incarnation: f.source.incarnation},
69+ SourceTabID: domain.TabStableID(f.sourceTab.stableID),
70+ Destination: moveSessionLocator{ID: f.destination.id, Incarnation: f.destination.incarnation},
71+ })
72+ },
73+ assertLog: func(t *testing.T, output string) {
74+ t.Helper()
75+ require.Contains(t, output, `"msg":"tab move requested"`)
76+ },
77+ },
78+ {
79+ name: "pane",
80+ operation: "pane",
81+ move: func(f moveObservabilityFixture) error {
82+ return f.daemon.movePane(movePaneRequest{
83+ Source: moveSessionLocator{ID: f.source.id, Incarnation: f.source.incarnation},
84+ SourceTabID: domain.TabStableID(f.sourceTab.stableID),
85+ SourcePaneID: domain.PaneStableID(f.sourcePane.stableID),
86+ Destination: moveSessionLocator{ID: f.destination.id, Incarnation: f.destination.incarnation},
87+ DestinationTabID: domain.TabStableID(f.destinationTab.stableID),
88+ })
89+ },
90+ assertLog: func(t *testing.T, output string) {
91+ t.Helper()
92+ require.Contains(t, output, `"msg":"pane move requested"`)
93+ require.Contains(t, output, `"source_pane_id":"source-pane","destination_tab_id":"destination-tab"`)
94+ },
95+ },
96+ }
97+
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ fixture := newMoveObservabilityFixture(t)
101+ require.NoError(t, tt.move(fixture))
102+ fixture.daemon.waitNotifies()
103+
104+ output := fixture.logs.String()
105+ tt.assertLog(t, output)
106+ require.Contains(t, output, `"msg":"move committed"`)
107+ require.Contains(t, output, `"operation":"`+tt.operation+`"`)
108+ require.Contains(t, output, `"source_retired":true`)
109+ require.Contains(t, output, `"msg":"move detaching source attachments"`)
110+ require.Contains(t, output, `"reason":"session-killed"`)
111+ require.Contains(t, output, `"msg":"client detached"`)
112+ require.Contains(t, output, `"reason":1`)
113+ })
114+ }
115+}
116+
117+func TestMoveLogsNormalizedRejection(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ operation string
121+ move func(*Daemon) error
122+ fields []string
123+ }{
124+ {
125+ name: "tab",
126+ operation: "tab",
127+ move: func(d *Daemon) error {
128+ return d.moveTab(moveTabRequest{
129+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
130+ SourceTabID: "tab",
131+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
132+ })
133+ },
134+ fields: []string{
135+ `"source_session_id":"missing"`,
136+ `"destination_session_id":"also-missing"`,
137+ },
138+ },
139+ {
140+ name: "pane",
141+ operation: "pane",
142+ move: func(d *Daemon) error {
143+ return d.movePane(movePaneRequest{
144+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
145+ SourceTabID: "source-tab",
146+ SourcePaneID: "source-pane",
147+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
148+ DestinationTabID: "destination-tab",
149+ })
150+ },
151+ fields: []string{
152+ `"source_pane_id":"source-pane"`,
153+ `"destination_tab_id":"destination-tab"`,
154+ },
155+ },
156+ }
157+
158+ for _, tt := range tests {
159+ t.Run(tt.name, func(t *testing.T) {
160+ var logs bytes.Buffer
161+ d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil)))
162+
163+ require.ErrorIs(t, tt.move(d), errMovePaneInvalid)
164+ output := logs.String()
165+ require.Contains(t, output, `"msg":"`+tt.operation+` move requested"`)
166+ require.Contains(t, output, `"msg":"`+tt.operation+` move rejected"`)
167+ for _, field := range tt.fields {
168+ require.Contains(t, output, field)
169+ }
170+ })
171+ }
172+}
@@ -0,0 +1,172 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "log/slog"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+)
13+
14+type moveObservabilityFixture struct {
15+ daemon *Daemon
16+ source *session
17+ sourceTab *tab
18+ sourcePane *pane
19+ destination *session
20+ destinationTab *tab
21+ logs *bytes.Buffer
22+}
23+
24+func newMoveObservabilityFixture(t *testing.T) moveObservabilityFixture {
25+ t.Helper()
26+ logs := &bytes.Buffer{}
27+ d, source, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
28+ d.log = slog.New(slog.NewJSONHandler(logs, nil))
29+
30+ sourceTab := source.tabs[0]
31+ sourceTab.stableID = "source-tab"
32+ sourcePane := sourceTab.focusedPane()
33+ sourcePane.stableID = "source-pane"
34+
35+ destinationCtx, destinationCancel := context.WithCancel(d.serveCtx)
36+ t.Cleanup(destinationCancel)
37+ destinationTab := newTabWithStableID("destination-tab", "destination-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
38+ destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
39+ destination := &session{
40+ sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}},
41+ ctx: destinationCtx,
42+ cancel: destinationCancel,
43+ tabs: []*tab{destinationTab},
44+ }
45+ publishTiledPaneOwners(destination, destinationTab)
46+ d.mu.Lock()
47+ d.sessions[destination.id] = destination
48+ d.mu.Unlock()
49+
50+ return moveObservabilityFixture{
51+ daemon: d, source: source, sourceTab: sourceTab, sourcePane: sourcePane,
52+ destination: destination, destinationTab: destinationTab, logs: logs,
53+ }
54+}
55+
56+func TestMoveLogsCommitAndSourceAttachmentTermination(t *testing.T) {
57+ tests := []struct {
58+ name string
59+ operation string
60+ move func(moveObservabilityFixture) error
61+ assertLog func(*testing.T, string)
62+ }{
63+ {
64+ name: "tab",
65+ operation: "tab",
66+ move: func(f moveObservabilityFixture) error {
67+ return f.daemon.moveTab(moveTabRequest{
68+ Source: moveSessionLocator{ID: f.source.id, Incarnation: f.source.incarnation},
69+ SourceTabID: domain.TabStableID(f.sourceTab.stableID),
70+ Destination: moveSessionLocator{ID: f.destination.id, Incarnation: f.destination.incarnation},
71+ })
72+ },
73+ assertLog: func(t *testing.T, output string) {
74+ t.Helper()
75+ require.Contains(t, output, `"msg":"tab move requested"`)
76+ },
77+ },
78+ {
79+ name: "pane",
80+ operation: "pane",
81+ move: func(f moveObservabilityFixture) error {
82+ return f.daemon.movePane(movePaneRequest{
83+ Source: moveSessionLocator{ID: f.source.id, Incarnation: f.source.incarnation},
84+ SourceTabID: domain.TabStableID(f.sourceTab.stableID),
85+ SourcePaneID: domain.PaneStableID(f.sourcePane.stableID),
86+ Destination: moveSessionLocator{ID: f.destination.id, Incarnation: f.destination.incarnation},
87+ DestinationTabID: domain.TabStableID(f.destinationTab.stableID),
88+ })
89+ },
90+ assertLog: func(t *testing.T, output string) {
91+ t.Helper()
92+ require.Contains(t, output, `"msg":"pane move requested"`)
93+ require.Contains(t, output, `"source_pane_id":"source-pane","destination_tab_id":"destination-tab"`)
94+ },
95+ },
96+ }
97+
98+ for _, tt := range tests {
99+ t.Run(tt.name, func(t *testing.T) {
100+ fixture := newMoveObservabilityFixture(t)
101+ require.NoError(t, tt.move(fixture))
102+ fixture.daemon.waitNotifies()
103+
104+ output := fixture.logs.String()
105+ tt.assertLog(t, output)
106+ require.Contains(t, output, `"msg":"move committed"`)
107+ require.Contains(t, output, `"operation":"`+tt.operation+`"`)
108+ require.Contains(t, output, `"source_retired":true`)
109+ require.Contains(t, output, `"msg":"move detaching source attachments"`)
110+ require.Contains(t, output, `"reason":"session-killed"`)
111+ require.Contains(t, output, `"msg":"client detached"`)
112+ require.Contains(t, output, `"reason":1`)
113+ })
114+ }
115+}
116+
117+func TestMoveLogsNormalizedRejection(t *testing.T) {
118+ tests := []struct {
119+ name string
120+ operation string
121+ move func(*Daemon) error
122+ fields []string
123+ }{
124+ {
125+ name: "tab",
126+ operation: "tab",
127+ move: func(d *Daemon) error {
128+ return d.moveTab(moveTabRequest{
129+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
130+ SourceTabID: "tab",
131+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
132+ })
133+ },
134+ fields: []string{
135+ `"source_session_id":"missing"`,
136+ `"destination_session_id":"also-missing"`,
137+ },
138+ },
139+ {
140+ name: "pane",
141+ operation: "pane",
142+ move: func(d *Daemon) error {
143+ return d.movePane(movePaneRequest{
144+ Source: moveSessionLocator{ID: "missing", Incarnation: domain.IncarnationID{1}},
145+ SourceTabID: "source-tab",
146+ SourcePaneID: "source-pane",
147+ Destination: moveSessionLocator{ID: "also-missing", Incarnation: domain.IncarnationID{2}},
148+ DestinationTabID: "destination-tab",
149+ })
150+ },
151+ fields: []string{
152+ `"source_pane_id":"source-pane"`,
153+ `"destination_tab_id":"destination-tab"`,
154+ },
155+ },
156+ }
157+
158+ for _, tt := range tests {
159+ t.Run(tt.name, func(t *testing.T) {
160+ var logs bytes.Buffer
161+ d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&logs, nil)))
162+
163+ require.ErrorIs(t, tt.move(d), errMovePaneInvalid)
164+ output := logs.String()
165+ require.Contains(t, output, `"msg":"`+tt.operation+` move requested"`)
166+ require.Contains(t, output, `"msg":"`+tt.operation+` move rejected"`)
167+ for _, field := range tt.fields {
168+ require.Contains(t, output, field)
169+ }
170+ })
171+ }
172+}
M internal/usecase/daemon/move_pane_postcommit.go +26 -0
@@ -26,6 +26,7 @@ type movePanePostcommitPlan struct {
2626 destinationTab *tab
2727 movedPane *pane
2828 movedPanes []*pane
29+ operation string
2930 sourceAttachments []*attachedClient
3031 syncCleanup syncTimerCleanup
3132 frozenEffects frozenAttachmentEffectGates
@@ -79,6 +80,22 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
7980 }
8081 p.unlockDispatch()
8182 p.reservation.Release()
83+ attrs := []any{
84+ "operation", p.operation,
85+ "source_session", p.source.name,
86+ "source_session_id", p.source.id,
87+ "source_tab_id", p.sourceTab.stableID,
88+ "destination_session", p.destination.name,
89+ "destination_session_id", p.destination.id,
90+ "source_retired", p.sourceEmpty,
91+ }
92+ if p.operation == "pane" {
93+ attrs = append(attrs,
94+ "source_pane_id", p.movedPane.stableID,
95+ "destination_tab_id", p.destinationTab.stableID,
96+ )
97+ }
98+ d.log.Info("move committed", attrs...)
8299
83100 // Repair attachment-local stable targets after shared topology publication
84101 // and after the move dispatch locks have been released.
@@ -114,6 +131,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114131 if p.sourceEmpty {
115132 retireEmptySessionAfterMove(d, p.source)
116133 }
134+ if len(p.retiredAttachments) != 0 {
135+ d.log.Info("move detaching source attachments",
136+ "operation", p.operation,
137+ "source_session", p.source.name,
138+ "source_session_id", p.source.id,
139+ "reason", "session-killed",
140+ "attachments", len(p.retiredAttachments),
141+ )
142+ }
117143 for _, attachment := range p.retiredAttachments {
118144 d.unregisterPreview(attachment.ac)
119145 attachment.ac.clearCaptureFrames()
@@ -26,6 +26,7 @@ type movePanePostcommitPlan struct {
26 destinationTab *tab26 destinationTab *tab
27 movedPane *pane27 movedPane *pane
28 movedPanes []*pane28 movedPanes []*pane
29+ operation string
29 sourceAttachments []*attachedClient30 sourceAttachments []*attachedClient
30 syncCleanup syncTimerCleanup31 syncCleanup syncTimerCleanup
31 frozenEffects frozenAttachmentEffectGates32 frozenEffects frozenAttachmentEffectGates
@@ -79,6 +80,22 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
79 }80 }
80 p.unlockDispatch()81 p.unlockDispatch()
81 p.reservation.Release()82 p.reservation.Release()
83+ attrs := []any{
84+ "operation", p.operation,
85+ "source_session", p.source.name,
86+ "source_session_id", p.source.id,
87+ "source_tab_id", p.sourceTab.stableID,
88+ "destination_session", p.destination.name,
89+ "destination_session_id", p.destination.id,
90+ "source_retired", p.sourceEmpty,
91+ }
92+ if p.operation == "pane" {
93+ attrs = append(attrs,
94+ "source_pane_id", p.movedPane.stableID,
95+ "destination_tab_id", p.destinationTab.stableID,
96+ )
97+ }
98+ d.log.Info("move committed", attrs...)
82 99
83 // Repair attachment-local stable targets after shared topology publication100 // Repair attachment-local stable targets after shared topology publication
84 // and after the move dispatch locks have been released.101 // and after the move dispatch locks have been released.
@@ -114,6 +131,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114 if p.sourceEmpty {131 if p.sourceEmpty {
115 retireEmptySessionAfterMove(d, p.source)132 retireEmptySessionAfterMove(d, p.source)
116 }133 }
134+ if len(p.retiredAttachments) != 0 {
135+ d.log.Info("move detaching source attachments",
136+ "operation", p.operation,
137+ "source_session", p.source.name,
138+ "source_session_id", p.source.id,
139+ "reason", "session-killed",
140+ "attachments", len(p.retiredAttachments),
141+ )
142+ }
117 for _, attachment := range p.retiredAttachments {143 for _, attachment := range p.retiredAttachments {
118 d.unregisterPreview(attachment.ac)144 d.unregisterPreview(attachment.ac)
119 attachment.ac.clearCaptureFrames()145 attachment.ac.clearCaptureFrames()
M internal/usecase/daemon/move_tab.go +21 -2
@@ -42,8 +42,26 @@ type moveTabCommit struct {
4242 }
4343
4444 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
45- defer func() { result = normalizeMoveRejection(result) }()
46- if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
45+ if d == nil {
46+ return errMovePaneInvalid
47+ }
48+ d.log.Info("tab move requested",
49+ "source_session_id", req.Source.ID,
50+ "source_tab_id", req.SourceTabID,
51+ "destination_session_id", req.Destination.ID,
52+ )
53+ defer func() {
54+ result = normalizeMoveRejection(result)
55+ if result != nil {
56+ d.log.Warn("tab move rejected",
57+ "err", result,
58+ "source_session_id", req.Source.ID,
59+ "source_tab_id", req.SourceTabID,
60+ "destination_session_id", req.Destination.ID,
61+ )
62+ }
63+ }()
64+ if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
4765 return errMovePaneInvalid
4866 }
4967 d.mu.Lock()
@@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
134152 destinationTab: admission.tab,
135153 movedPane: firstMovePane(admission.panes),
136154 movedPanes: admission.panes,
155+ operation: "tab",
137156 sourceAttachments: admission.sourceAttachments,
138157 syncCleanup: commit.syncCleanup,
139158 frozenEffects: frozen,
@@ -42,8 +42,26 @@ type moveTabCommit struct {
42 }42 }
43 43
44 func (d *Daemon) moveTab(req moveTabRequest) (result error) {44 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
45- defer func() { result = normalizeMoveRejection(result) }()45+ if d == nil {
46- if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {46+ return errMovePaneInvalid
47+ }
48+ d.log.Info("tab move requested",
49+ "source_session_id", req.Source.ID,
50+ "source_tab_id", req.SourceTabID,
51+ "destination_session_id", req.Destination.ID,
52+ )
53+ defer func() {
54+ result = normalizeMoveRejection(result)
55+ if result != nil {
56+ d.log.Warn("tab move rejected",
57+ "err", result,
58+ "source_session_id", req.Source.ID,
59+ "source_tab_id", req.SourceTabID,
60+ "destination_session_id", req.Destination.ID,
61+ )
62+ }
63+ }()
64+ if req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
47 return errMovePaneInvalid65 return errMovePaneInvalid
48 }66 }
49 d.mu.Lock()67 d.mu.Lock()
@@ -134,6 +152,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
134 destinationTab: admission.tab,152 destinationTab: admission.tab,
135 movedPane: firstMovePane(admission.panes),153 movedPane: firstMovePane(admission.panes),
136 movedPanes: admission.panes,154 movedPanes: admission.panes,
155+ operation: "tab",
137 sourceAttachments: admission.sourceAttachments,156 sourceAttachments: admission.sourceAttachments,
138 syncCleanup: commit.syncCleanup,157 syncCleanup: commit.syncCleanup,
139 frozenEffects: frozen,158 frozenEffects: frozen,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close