detached edb3828

feat(config): add floating pane settings

commit
edb3828a76f61560a30f1c70766898286a232f62
author
brice · · 2026-07-09 23:21 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
10e6267
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +151 -1
M internal/adapters/config/config.go +45 -1
@@ -18,7 +18,10 @@ import (
1818
1919 const pollInterval = 2 * time.Second
2020
21-var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+var (
22+ processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23+ percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24+)
2225
2326 // Parse reads vev's flat action = value config format. Duplicate action keys
2427 // are accepted with a warning; the last value wins while first-seen action order
@@ -27,6 +30,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
2730 cfg := domain.Defaults()
2831 var warnings []domain.Warning
2932 seenBindingKeys := make(map[string]bool)
33+ seenFloatingKeys := make(map[string]bool)
3034
3135 scanner := bufio.NewScanner(r)
3236 lineNo := 0
@@ -86,6 +90,30 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
8690 interval = domain.MinBarInterval
8791 }
8892 cfg.Bar.Interval = interval
93+ case key == "floating.command":
94+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
95+ command, ok := parseBarCommand(value)
96+ if !ok {
97+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
98+ continue
99+ }
100+ cfg.Floating.Command = command
101+ case key == "floating.width":
102+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
103+ width, ok := parsePercentage(value)
104+ if !ok {
105+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
106+ continue
107+ }
108+ cfg.Floating.Width = width
109+ case key == "floating.height":
110+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
111+ height, ok := parsePercentage(value)
112+ if !ok {
113+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
114+ continue
115+ }
116+ cfg.Floating.Height = height
89117 case strings.HasPrefix(key, "code."):
90118 codeKey := strings.TrimPrefix(key, "code.")
91119 if codeKey == "" {
@@ -189,6 +217,14 @@ func fileStamp(path string) (stamp, error) {
189217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190218 }
191219
220+func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221+ if seen[key] {
222+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223+ }
224+ seen[key] = true
225+ return warnings
226+}
227+
192228 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193229 for i := range entries {
194230 if entries[i].Key == key {
@@ -224,6 +260,14 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
224260 return out, warnings
225261 }
226262
263+func parsePercentage(value string) (int, bool) {
264+ if !percentagePattern.MatchString(value) {
265+ return 0, false
266+ }
267+ percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
268+ return percentage, err == nil && percentage >= 1 && percentage <= 100
269+}
270+
227271 func parseBarCommand(value string) (string, bool) {
228272 value = strings.TrimSpace(value)
229273 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
@@ -18,7 +18,10 @@ import (
18 18
19 const pollInterval = 2 * time.Second19 const pollInterval = 2 * time.Second
20 20
21-var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)21+var (
22+ processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23+ percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24+)
22 25
23 // Parse reads vev's flat action = value config format. Duplicate action keys26 // Parse reads vev's flat action = value config format. Duplicate action keys
24 // are accepted with a warning; the last value wins while first-seen action order27 // are accepted with a warning; the last value wins while first-seen action order
@@ -27,6 +30,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
27 cfg := domain.Defaults()30 cfg := domain.Defaults()
28 var warnings []domain.Warning31 var warnings []domain.Warning
29 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33+ seenFloatingKeys := make(map[string]bool)
30 34
31 scanner := bufio.NewScanner(r)35 scanner := bufio.NewScanner(r)
32 lineNo := 036 lineNo := 0
@@ -86,6 +90,30 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
86 interval = domain.MinBarInterval90 interval = domain.MinBarInterval
87 }91 }
88 cfg.Bar.Interval = interval92 cfg.Bar.Interval = interval
93+ case key == "floating.command":
94+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
95+ command, ok := parseBarCommand(value)
96+ if !ok {
97+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
98+ continue
99+ }
100+ cfg.Floating.Command = command
101+ case key == "floating.width":
102+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
103+ width, ok := parsePercentage(value)
104+ if !ok {
105+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
106+ continue
107+ }
108+ cfg.Floating.Width = width
109+ case key == "floating.height":
110+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
111+ height, ok := parsePercentage(value)
112+ if !ok {
113+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
114+ continue
115+ }
116+ cfg.Floating.Height = height
89 case strings.HasPrefix(key, "code."):117 case strings.HasPrefix(key, "code."):
90 codeKey := strings.TrimPrefix(key, "code.")118 codeKey := strings.TrimPrefix(key, "code.")
91 if codeKey == "" {119 if codeKey == "" {
@@ -189,6 +217,14 @@ func fileStamp(path string) (stamp, error) {
189 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190 }218 }
191 219
220+func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221+ if seen[key] {
222+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223+ }
224+ seen[key] = true
225+ return warnings
226+}
227+
192 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {228 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193 for i := range entries {229 for i := range entries {
194 if entries[i].Key == key {230 if entries[i].Key == key {
@@ -224,6 +260,14 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
224 return out, warnings260 return out, warnings
225 }261 }
226 262
263+func parsePercentage(value string) (int, bool) {
264+ if !percentagePattern.MatchString(value) {
265+ return 0, false
266+ }
267+ percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
268+ return percentage, err == nil && percentage >= 1 && percentage <= 100
269+}
270+
227 func parseBarCommand(value string) (string, bool) {271 func parseBarCommand(value string) (string, bool) {
228 value = strings.TrimSpace(value)272 value = strings.TrimSpace(value)
229 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {273 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
M internal/adapters/config/config_test.go +94 -0
@@ -91,6 +91,97 @@ func TestParse(t *testing.T) {
9191 {Line: 3, Msg: "missing key"},
9292 },
9393 },
94+ {
95+ name: "floating settings custom values",
96+ input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Floating: domain.FloatingConfig{
100+ Command: "btop --utf-force",
101+ Width: 75,
102+ Height: 60,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "floating command can be empty or quoted",
110+ input: "floating.command = echo ready\nfloating.command = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Floating: domain.FloatingConfig{
114+ Width: 80,
115+ Height: 80,
116+ },
117+ BindingEntries: []domain.ConfigEntry{},
118+ Codes: map[string]string{},
119+ },
120+ wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
121+ },
122+ {
123+ name: "floating percentage endpoints",
124+ input: "floating.width = 1%\nfloating.height = 100%\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Floating: domain.FloatingConfig{
128+ Width: 1,
129+ Height: 100,
130+ },
131+ BindingEntries: []domain.ConfigEntry{},
132+ Codes: map[string]string{},
133+ },
134+ },
135+ {
136+ name: "floating invalid percentages warn and preserve defaults",
137+ input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
138+ want: domain.Config{
139+ Theme: domain.ThemeAuto,
140+ Floating: domain.FloatingConfig{
141+ Width: 80,
142+ Height: 80,
143+ },
144+ BindingEntries: []domain.ConfigEntry{},
145+ Codes: map[string]string{},
146+ },
147+ wantWarnings: []domain.Warning{
148+ {Line: 1, Msg: "invalid floating.width \"80\""},
149+ {Line: 2, Msg: "invalid floating.height \"0%\""},
150+ {Line: 3, Msg: "duplicate key \"floating.width\""},
151+ {Line: 3, Msg: "invalid floating.width \"101%\""},
152+ {Line: 4, Msg: "duplicate key \"floating.height\""},
153+ {Line: 4, Msg: "invalid floating.height \"eighty%\""},
154+ },
155+ },
156+ {
157+ name: "floating duplicate keys warn and last valid value wins",
158+ input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
159+ want: domain.Config{
160+ Theme: domain.ThemeAuto,
161+ Floating: domain.FloatingConfig{
162+ Command: "lazygit",
163+ Width: 90,
164+ Height: 85,
165+ },
166+ BindingEntries: []domain.ConfigEntry{},
167+ Codes: map[string]string{},
168+ },
169+ wantWarnings: []domain.Warning{
170+ {Line: 2, Msg: "duplicate key \"floating.command\""},
171+ {Line: 4, Msg: "duplicate key \"floating.width\""},
172+ {Line: 6, Msg: "duplicate key \"floating.height\""},
173+ },
174+ },
175+ {
176+ name: "floating malformed quoted command warns and keeps default",
177+ input: "floating.command = \"bad\\q\"\n",
178+ want: domain.Config{
179+ Theme: domain.ThemeAuto,
180+ BindingEntries: []domain.ConfigEntry{},
181+ Codes: map[string]string{},
182+ },
183+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
184+ },
94185 {
95186 name: "bar settings custom values",
96187 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
@@ -244,6 +335,9 @@ func TestParse(t *testing.T) {
244335 if tt.want.Bar.Interval == 0 {
245336 tt.want.Bar = domain.Defaults().Bar
246337 }
338+ if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339+ tt.want.Floating = domain.Defaults().Floating
340+ }
247341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249343 }
@@ -91,6 +91,97 @@ func TestParse(t *testing.T) {
91 {Line: 3, Msg: "missing key"},91 {Line: 3, Msg: "missing key"},
92 },92 },
93 },93 },
94+ {
95+ name: "floating settings custom values",
96+ input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Floating: domain.FloatingConfig{
100+ Command: "btop --utf-force",
101+ Width: 75,
102+ Height: 60,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "floating command can be empty or quoted",
110+ input: "floating.command = echo ready\nfloating.command = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Floating: domain.FloatingConfig{
114+ Width: 80,
115+ Height: 80,
116+ },
117+ BindingEntries: []domain.ConfigEntry{},
118+ Codes: map[string]string{},
119+ },
120+ wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
121+ },
122+ {
123+ name: "floating percentage endpoints",
124+ input: "floating.width = 1%\nfloating.height = 100%\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Floating: domain.FloatingConfig{
128+ Width: 1,
129+ Height: 100,
130+ },
131+ BindingEntries: []domain.ConfigEntry{},
132+ Codes: map[string]string{},
133+ },
134+ },
135+ {
136+ name: "floating invalid percentages warn and preserve defaults",
137+ input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
138+ want: domain.Config{
139+ Theme: domain.ThemeAuto,
140+ Floating: domain.FloatingConfig{
141+ Width: 80,
142+ Height: 80,
143+ },
144+ BindingEntries: []domain.ConfigEntry{},
145+ Codes: map[string]string{},
146+ },
147+ wantWarnings: []domain.Warning{
148+ {Line: 1, Msg: "invalid floating.width \"80\""},
149+ {Line: 2, Msg: "invalid floating.height \"0%\""},
150+ {Line: 3, Msg: "duplicate key \"floating.width\""},
151+ {Line: 3, Msg: "invalid floating.width \"101%\""},
152+ {Line: 4, Msg: "duplicate key \"floating.height\""},
153+ {Line: 4, Msg: "invalid floating.height \"eighty%\""},
154+ },
155+ },
156+ {
157+ name: "floating duplicate keys warn and last valid value wins",
158+ input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
159+ want: domain.Config{
160+ Theme: domain.ThemeAuto,
161+ Floating: domain.FloatingConfig{
162+ Command: "lazygit",
163+ Width: 90,
164+ Height: 85,
165+ },
166+ BindingEntries: []domain.ConfigEntry{},
167+ Codes: map[string]string{},
168+ },
169+ wantWarnings: []domain.Warning{
170+ {Line: 2, Msg: "duplicate key \"floating.command\""},
171+ {Line: 4, Msg: "duplicate key \"floating.width\""},
172+ {Line: 6, Msg: "duplicate key \"floating.height\""},
173+ },
174+ },
175+ {
176+ name: "floating malformed quoted command warns and keeps default",
177+ input: "floating.command = \"bad\\q\"\n",
178+ want: domain.Config{
179+ Theme: domain.ThemeAuto,
180+ BindingEntries: []domain.ConfigEntry{},
181+ Codes: map[string]string{},
182+ },
183+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
184+ },
94 {185 {
95 name: "bar settings custom values",186 name: "bar settings custom values",
96 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",187 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
@@ -244,6 +335,9 @@ func TestParse(t *testing.T) {
244 if tt.want.Bar.Interval == 0 {335 if tt.want.Bar.Interval == 0 {
245 tt.want.Bar = domain.Defaults().Bar336 tt.want.Bar = domain.Defaults().Bar
246 }337 }
338+ if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339+ tt.want.Floating = domain.Defaults().Floating
340+ }
247 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249 }343 }
M internal/domain/config.go +12 -0
@@ -47,6 +47,13 @@ type BarConfig struct {
4747 Interval time.Duration
4848 }
4949
50+// FloatingConfig contains launch and sizing settings for per-tab floating panes.
51+type FloatingConfig struct {
52+ Command string
53+ Width int
54+ Height int
55+}
56+
5057 // Config is the user-editable vev configuration after parsing. Unknown binding
5158 // keys are preserved here (in BindingEntries, in file order) so the usecase
5259 // layer can decide which actions it understands.
@@ -56,6 +63,7 @@ type Config struct {
5663 BindingEntries []ConfigEntry
5764 Codes map[string]string
5865 Snapshot SnapshotConfig
66+ Floating FloatingConfig
5967 }
6068
6169 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -79,5 +87,9 @@ func Defaults() Config {
7987 Snapshot: SnapshotConfig{
8088 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
8189 },
90+ Floating: FloatingConfig{
91+ Width: 80,
92+ Height: 80,
93+ },
8294 }
8395 }
@@ -47,6 +47,13 @@ type BarConfig struct {
47 Interval time.Duration47 Interval time.Duration
48 }48 }
49 49
50+// FloatingConfig contains launch and sizing settings for per-tab floating panes.
51+type FloatingConfig struct {
52+ Command string
53+ Width int
54+ Height int
55+}
56+
50 // Config is the user-editable vev configuration after parsing. Unknown binding57 // Config is the user-editable vev configuration after parsing. Unknown binding
51 // keys are preserved here (in BindingEntries, in file order) so the usecase58 // keys are preserved here (in BindingEntries, in file order) so the usecase
52 // layer can decide which actions it understands.59 // layer can decide which actions it understands.
@@ -56,6 +63,7 @@ type Config struct {
56 BindingEntries []ConfigEntry63 BindingEntries []ConfigEntry
57 Codes map[string]string64 Codes map[string]string
58 Snapshot SnapshotConfig65 Snapshot SnapshotConfig
66+ Floating FloatingConfig
59 }67 }
60 68
61 // Warning describes a non-fatal config problem. Parsers and reloaders should69 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -79,5 +87,9 @@ func Defaults() Config {
79 Snapshot: SnapshotConfig{87 Snapshot: SnapshotConfig{
80 RestoreProcesses: DefaultSnapshotRestoreProcesses(),88 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
81 },89 },
90+ Floating: FloatingConfig{
91+ Width: 80,
92+ Height: 80,
93+ },
82 }94 }
83 }95 }

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