v0.26.2 3cd9d2e

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

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