v0.21.0 73bf644

modal.go RawHistoryFolder

1package input
2
3import (
4	"context"
5	"sync"
6	"time"
7
8	"github.com/bnema/dumber/internal/logging"
9)
10
11// Mode represents the current input mode.
12type Mode int
13
14const (
15	// ModeNormal is the default mode where keys pass through to WebView.
16	ModeNormal Mode = iota
17	// ModeTab is the modal tab management mode.
18	ModeTab
19	// ModePane is the modal pane management mode.
20	ModePane
21	// ModeSession is the modal session management mode.
22	ModeSession
23	// ModeResize is the modal pane resizing mode.
24	ModeResize
25)
26
27// String returns a human-readable mode name.
28func (m Mode) String() string {
29	switch m {
30	case ModeNormal:
31		return "normal"
32	case ModeTab:
33		return "tab"
34	case ModePane:
35		return "pane"
36	case ModeSession:
37		return "session"
38	case ModeResize:
39		return "resize"
40	default:
41		return "unknown"
42	}
43}
44
45// ModalState manages the current input mode with optional timeout.
46type ModalState struct {
47	mode    Mode
48	timeout time.Duration
49	timer   *time.Timer
50
51	// Callback for mode changes (called synchronously under lock).
52	onModeChange func(from, to Mode)
53
54	mu sync.RWMutex
55}
56
57// NewModalState creates a new modal state manager.
58func NewModalState(ctx context.Context) *ModalState {
59	log := logging.FromContext(ctx)
60	log.Debug().Msg("creating modal state")
61	return &ModalState{
62		mode: ModeNormal,
63	}
64}
65
66// Mode returns the current mode (thread-safe).
67func (m *ModalState) Mode() Mode {
68	m.mu.RLock()
69	defer m.mu.RUnlock()
70	return m.mode
71}
72
73// EnterTabMode switches to tab mode with an optional timeout.
74// If timeout is 0, the mode stays until explicitly exited.
75func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
76	log := logging.FromContext(ctx)
77	m.mu.Lock()
78	defer m.mu.Unlock()
79
80	if m.mode == ModeTab {
81		// Already in tab mode, just reset timeout
82		m.resetTimeoutLocked(ctx, timeout)
83		return
84	}
85
86	m.cancelTimerLocked()
87	oldMode := m.mode
88	m.mode = ModeTab
89	m.timeout = timeout
90
91	if timeout > 0 {
92		m.timer = time.AfterFunc(timeout, func() {
93			m.ExitMode(ctx)
94		})
95	}
96
97	log.Debug().
98		Str("from", oldMode.String()).
99		Str("to", m.mode.String()).
100		Dur("timeout", timeout).
101		Msg("entered tab mode")
102
103	if m.onModeChange != nil {
104		m.onModeChange(oldMode, m.mode)
105	}
106}
107
108// EnterPaneMode switches to pane mode with an optional timeout.
109// If timeout is 0, the mode stays until explicitly exited.
110func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
111	log := logging.FromContext(ctx)
112	m.mu.Lock()
113	defer m.mu.Unlock()
114
115	if m.mode == ModePane {
116		// Already in pane mode, just reset timeout
117		m.resetTimeoutLocked(ctx, timeout)
118		return
119	}
120
121	m.cancelTimerLocked()
122	oldMode := m.mode
123	m.mode = ModePane
124	m.timeout = timeout
125
126	if timeout > 0 {
127		m.timer = time.AfterFunc(timeout, func() {
128			m.ExitMode(ctx)
129		})
130	}
131
132	log.Debug().
133		Str("from", oldMode.String()).
134		Str("to", m.mode.String()).
135		Dur("timeout", timeout).
136		Msg("entered pane mode")
137
138	if m.onModeChange != nil {
139		m.onModeChange(oldMode, m.mode)
140	}
141}
142
143// EnterSessionMode switches to session mode with an optional timeout.
144// If timeout is 0, the mode stays until explicitly exited.
145func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
146	log := logging.FromContext(ctx)
147	m.mu.Lock()
148	defer m.mu.Unlock()
149
150	if m.mode == ModeSession {
151		// Already in session mode, just reset timeout
152		m.resetTimeoutLocked(ctx, timeout)
153		return
154	}
155
156	m.cancelTimerLocked()
157	oldMode := m.mode
158	m.mode = ModeSession
159	m.timeout = timeout
160
161	if timeout > 0 {
162		m.timer = time.AfterFunc(timeout, func() {
163			m.ExitMode(ctx)
164		})
165	}
166
167	log.Debug().
168		Str("from", oldMode.String()).
169		Str("to", m.mode.String()).
170		Dur("timeout", timeout).
171		Msg("entered session mode")
172
173	if m.onModeChange != nil {
174		m.onModeChange(oldMode, m.mode)
175	}
176}
177
178// EnterResizeMode switches to resize mode with an optional timeout.
179// If timeout is 0, the mode stays until explicitly exited.
180func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
181	log := logging.FromContext(ctx)
182	m.mu.Lock()
183	defer m.mu.Unlock()
184
185	if m.mode == ModeResize {
186		// Already in resize mode, just reset timeout
187		m.resetTimeoutLocked(ctx, timeout)
188		return
189	}
190
191	m.cancelTimerLocked()
192	oldMode := m.mode
193	m.mode = ModeResize
194	m.timeout = timeout
195
196	if timeout > 0 {
197		m.timer = time.AfterFunc(timeout, func() {
198			m.ExitMode(ctx)
199		})
200	}
201
202	log.Debug().
203		Str("from", oldMode.String()).
204		Str("to", m.mode.String()).
205		Dur("timeout", timeout).
206		Msg("entered resize mode")
207
208	if m.onModeChange != nil {
209		m.onModeChange(oldMode, m.mode)
210	}
211}
212
213// ExitMode returns to normal mode.
214func (m *ModalState) ExitMode(ctx context.Context) {
215	log := logging.FromContext(ctx)
216	m.mu.Lock()
217	defer m.mu.Unlock()
218
219	if m.mode == ModeNormal {
220		return
221	}
222
223	m.cancelTimerLocked()
224	oldMode := m.mode
225	m.mode = ModeNormal
226	m.timeout = 0
227
228	log.Debug().
229		Str("from", oldMode.String()).
230		Str("to", "normal").
231		Msg("exited modal mode")
232
233	if m.onModeChange != nil {
234		m.onModeChange(oldMode, ModeNormal)
235	}
236}
237
238// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
239// Does nothing if not in a modal mode or if no timeout was set.
240func (m *ModalState) ResetTimeout(ctx context.Context) {
241	m.mu.Lock()
242	defer m.mu.Unlock()
243
244	if m.mode == ModeNormal || m.timeout == 0 {
245		return
246	}
247
248	m.resetTimeoutLocked(ctx, m.timeout)
249}
250
251// SetOnModeChange sets the callback for mode changes.
252// The callback is invoked synchronously under the lock.
253func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
254	m.mu.Lock()
255	defer m.mu.Unlock()
256	m.onModeChange = fn
257}
258
259// cancelTimerLocked stops any active timeout timer.
260// Must be called with m.mu held.
261func (m *ModalState) cancelTimerLocked() {
262	if m.timer != nil {
263		m.timer.Stop()
264		m.timer = nil
265	}
266}
267
268// resetTimeoutLocked resets the timeout timer.
269// Must be called with m.mu held.
270func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
271	m.cancelTimerLocked()
272	m.timeout = timeout
273	if timeout > 0 {
274		m.timer = time.AfterFunc(timeout, func() {
275			m.ExitMode(ctx)
276		})
277	}
278}

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 8

Tags 42

↑↓ moveenter openesc close