v0.20.0 c741a39

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)
22
23// String returns a human-readable mode name.
24func (m Mode) String() string {
25	switch m {
26	case ModeNormal:
27		return "normal"
28	case ModeTab:
29		return "tab"
30	case ModePane:
31		return "pane"
32	default:
33		return "unknown"
34	}
35}
36
37// ModalState manages the current input mode with optional timeout.
38type ModalState struct {
39	mode    Mode
40	timeout time.Duration
41	timer   *time.Timer
42
43	// Callback for mode changes (called synchronously under lock).
44	onModeChange func(from, to Mode)
45
46	mu sync.RWMutex
47}
48
49// NewModalState creates a new modal state manager.
50func NewModalState(ctx context.Context) *ModalState {
51	log := logging.FromContext(ctx)
52	log.Debug().Msg("creating modal state")
53	return &ModalState{
54		mode: ModeNormal,
55	}
56}
57
58// Mode returns the current mode (thread-safe).
59func (m *ModalState) Mode() Mode {
60	m.mu.RLock()
61	defer m.mu.RUnlock()
62	return m.mode
63}
64
65// EnterTabMode switches to tab mode with an optional timeout.
66// If timeout is 0, the mode stays until explicitly exited.
67func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
68	log := logging.FromContext(ctx)
69	m.mu.Lock()
70	defer m.mu.Unlock()
71
72	if m.mode == ModeTab {
73		// Already in tab mode, just reset timeout
74		m.resetTimeoutLocked(ctx, timeout)
75		return
76	}
77
78	m.cancelTimerLocked()
79	oldMode := m.mode
80	m.mode = ModeTab
81	m.timeout = timeout
82
83	if timeout > 0 {
84		m.timer = time.AfterFunc(timeout, func() {
85			m.ExitMode(ctx)
86		})
87	}
88
89	log.Debug().
90		Str("from", oldMode.String()).
91		Str("to", m.mode.String()).
92		Dur("timeout", timeout).
93		Msg("entered tab mode")
94
95	if m.onModeChange != nil {
96		m.onModeChange(oldMode, m.mode)
97	}
98}
99
100// EnterPaneMode switches to pane mode with an optional timeout.
101// If timeout is 0, the mode stays until explicitly exited.
102func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
103	log := logging.FromContext(ctx)
104	m.mu.Lock()
105	defer m.mu.Unlock()
106
107	if m.mode == ModePane {
108		// Already in pane mode, just reset timeout
109		m.resetTimeoutLocked(ctx, timeout)
110		return
111	}
112
113	m.cancelTimerLocked()
114	oldMode := m.mode
115	m.mode = ModePane
116	m.timeout = timeout
117
118	if timeout > 0 {
119		m.timer = time.AfterFunc(timeout, func() {
120			m.ExitMode(ctx)
121		})
122	}
123
124	log.Debug().
125		Str("from", oldMode.String()).
126		Str("to", m.mode.String()).
127		Dur("timeout", timeout).
128		Msg("entered pane mode")
129
130	if m.onModeChange != nil {
131		m.onModeChange(oldMode, m.mode)
132	}
133}
134
135// ExitMode returns to normal mode.
136func (m *ModalState) ExitMode(ctx context.Context) {
137	log := logging.FromContext(ctx)
138	m.mu.Lock()
139	defer m.mu.Unlock()
140
141	if m.mode == ModeNormal {
142		return
143	}
144
145	m.cancelTimerLocked()
146	oldMode := m.mode
147	m.mode = ModeNormal
148	m.timeout = 0
149
150	log.Debug().
151		Str("from", oldMode.String()).
152		Str("to", "normal").
153		Msg("exited modal mode")
154
155	if m.onModeChange != nil {
156		m.onModeChange(oldMode, ModeNormal)
157	}
158}
159
160// ResetTimeout restarts the mode timeout (e.g., after a valid keystroke).
161// Does nothing if not in a modal mode or if no timeout was set.
162func (m *ModalState) ResetTimeout(ctx context.Context) {
163	m.mu.Lock()
164	defer m.mu.Unlock()
165
166	if m.mode == ModeNormal || m.timeout == 0 {
167		return
168	}
169
170	m.resetTimeoutLocked(ctx, m.timeout)
171}
172
173// SetOnModeChange sets the callback for mode changes.
174// The callback is invoked synchronously under the lock.
175func (m *ModalState) SetOnModeChange(fn func(from, to Mode)) {
176	m.mu.Lock()
177	defer m.mu.Unlock()
178	m.onModeChange = fn
179}
180
181// cancelTimerLocked stops any active timeout timer.
182// Must be called with m.mu held.
183func (m *ModalState) cancelTimerLocked() {
184	if m.timer != nil {
185		m.timer.Stop()
186		m.timer = nil
187	}
188}
189
190// resetTimeoutLocked resets the timeout timer.
191// Must be called with m.mu held.
192func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
193	m.cancelTimerLocked()
194	m.timeout = timeout
195	if timeout > 0 {
196		m.timer = time.AfterFunc(timeout, func() {
197			m.ExitMode(ctx)
198		})
199	}
200}

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