detached 1e1b557

feat(config): add copy word separators

commit
1e1b557e01834c43d0da8e42bed537c30a8bf10a
author
brice · · 2026-07-15 06:22 UTC
committer
brice · · 2026-07-15 09:45 UTC
parent
a3000d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +109 -0
M internal/adapters/config/config.go +29 -0
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3131 var warnings []domain.Warning
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
34+ seenCopyKeys := make(map[string]bool)
3435 seenPaletteKeys := make(map[string]bool)
3536 seenTabsKeys := make(map[string]bool)
3637
@@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
112113 continue
113114 }
114115 cfg.Tabs.TerminalTitle = on
116+ case key == "copy.word-separators":
117+ warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
118+ separators, ok := parseConfigString(value)
119+ if !ok {
120+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
121+ continue
122+ }
123+ cfg.Copy.WordSeparators = separators
115124 case key == "floating.command":
116125 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
117126 command, ok := parseBarCommand(value)
@@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) {
313322 return value, true
314323 }
315324
325+func parseConfigString(value string) (string, bool) {
326+ value = strings.TrimSpace(value)
327+ if value == "" {
328+ return "", true
329+ }
330+ startsQuoted := strings.HasPrefix(value, `"`)
331+ endsQuoted := strings.HasSuffix(value, `"`)
332+ if startsQuoted != endsQuoted {
333+ return "", false
334+ }
335+ if !startsQuoted {
336+ return value, true
337+ }
338+ unquoted, err := strconv.Unquote(value)
339+ if err != nil {
340+ return "", false
341+ }
342+ return unquoted, true
343+}
344+
316345 func parseTheme(value string) (domain.ThemeMode, bool) {
317346 switch strings.ToLower(strings.TrimSpace(value)) {
318347 case "auto", "":
@@ -31,6 +31,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
31 var warnings []domain.Warning31 var warnings []domain.Warning
32 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34+ seenCopyKeys := make(map[string]bool)
34 seenPaletteKeys := make(map[string]bool)35 seenPaletteKeys := make(map[string]bool)
35 seenTabsKeys := make(map[string]bool)36 seenTabsKeys := make(map[string]bool)
36 37
@@ -112,6 +113,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
112 continue113 continue
113 }114 }
114 cfg.Tabs.TerminalTitle = on115 cfg.Tabs.TerminalTitle = on
116+ case key == "copy.word-separators":
117+ warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
118+ separators, ok := parseConfigString(value)
119+ if !ok {
120+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.word-separators %q", value)})
121+ continue
122+ }
123+ cfg.Copy.WordSeparators = separators
115 case key == "floating.command":124 case key == "floating.command":
116 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)125 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
117 command, ok := parseBarCommand(value)126 command, ok := parseBarCommand(value)
@@ -313,6 +322,26 @@ func parseBarCommand(value string) (string, bool) {
313 return value, true322 return value, true
314 }323 }
315 324
325+func parseConfigString(value string) (string, bool) {
326+ value = strings.TrimSpace(value)
327+ if value == "" {
328+ return "", true
329+ }
330+ startsQuoted := strings.HasPrefix(value, `"`)
331+ endsQuoted := strings.HasSuffix(value, `"`)
332+ if startsQuoted != endsQuoted {
333+ return "", false
334+ }
335+ if !startsQuoted {
336+ return value, true
337+ }
338+ unquoted, err := strconv.Unquote(value)
339+ if err != nil {
340+ return "", false
341+ }
342+ return unquoted, true
343+}
344+
316 func parseTheme(value string) (domain.ThemeMode, bool) {345 func parseTheme(value string) (domain.ThemeMode, bool) {
317 switch strings.ToLower(strings.TrimSpace(value)) {346 switch strings.ToLower(strings.TrimSpace(value)) {
318 case "auto", "":347 case "auto", "":
M internal/adapters/config/config_test.go +68 -0
@@ -344,6 +344,9 @@ func TestParse(t *testing.T) {
344344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
345345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
346346 }
347+ if tt.want.Copy.WordSeparators == "" {
348+ tt.want.Copy = domain.Defaults().Copy
349+ }
347350 got, warnings, err := Parse(strings.NewReader(tt.input))
348351 if err != nil {
349352 t.Fatalf("Parse() error = %v", err)
@@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
419422 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
420423 }
421424
425+func TestParseCopyWordSeparators(t *testing.T) {
426+ t.Parallel()
427+
428+ tests := []struct {
429+ name string
430+ input string
431+ want string
432+ wantWarnings []domain.Warning
433+ }{
434+ {
435+ name: "default",
436+ input: "",
437+ want: domain.DefaultWordSeparators,
438+ },
439+ {
440+ name: "quoted preserves leading separator space",
441+ input: `copy.word-separators = " -_@/"`,
442+ want: " -_@/",
443+ },
444+ {
445+ name: "quoted empty is valid",
446+ input: `copy.word-separators = ""`,
447+ want: "",
448+ },
449+ {
450+ name: "unquoted empty is valid",
451+ input: "copy.word-separators =",
452+ want: "",
453+ },
454+ {
455+ name: "malformed quote keeps default",
456+ input: `copy.word-separators = "unterminated`,
457+ want: domain.DefaultWordSeparators,
458+ wantWarnings: []domain.Warning{
459+ {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
460+ },
461+ },
462+ {
463+ name: "duplicate last value wins",
464+ input: "copy.word-separators = /\ncopy.word-separators = _",
465+ want: "_",
466+ wantWarnings: []domain.Warning{
467+ {Line: 2, Msg: `duplicate key "copy.word-separators"`},
468+ },
469+ },
470+ }
471+
472+ for _, tt := range tests {
473+ t.Run(tt.name, func(t *testing.T) {
474+ t.Parallel()
475+
476+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
477+ require.NoError(t, err)
478+ require.Equal(t, tt.want, cfg.Copy.WordSeparators)
479+ require.Equal(t, tt.wantWarnings, warnings)
480+ })
481+ }
482+}
483+
484+func TestDefaultsCopyWordSeparators(t *testing.T) {
485+ t.Parallel()
486+
487+ require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
488+}
489+
422490 func TestParsePreservesBindingFileOrder(t *testing.T) {
423491 t.Parallel()
424492
@@ -344,6 +344,9 @@ func TestParse(t *testing.T) {
344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
346 }346 }
347+ if tt.want.Copy.WordSeparators == "" {
348+ tt.want.Copy = domain.Defaults().Copy
349+ }
347 got, warnings, err := Parse(strings.NewReader(tt.input))350 got, warnings, err := Parse(strings.NewReader(tt.input))
348 if err != nil {351 if err != nil {
349 t.Fatalf("Parse() error = %v", err)352 t.Fatalf("Parse() error = %v", err)
@@ -419,6 +422,71 @@ func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
419 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])422 require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
420 }423 }
421 424
425+func TestParseCopyWordSeparators(t *testing.T) {
426+ t.Parallel()
427+
428+ tests := []struct {
429+ name string
430+ input string
431+ want string
432+ wantWarnings []domain.Warning
433+ }{
434+ {
435+ name: "default",
436+ input: "",
437+ want: domain.DefaultWordSeparators,
438+ },
439+ {
440+ name: "quoted preserves leading separator space",
441+ input: `copy.word-separators = " -_@/"`,
442+ want: " -_@/",
443+ },
444+ {
445+ name: "quoted empty is valid",
446+ input: `copy.word-separators = ""`,
447+ want: "",
448+ },
449+ {
450+ name: "unquoted empty is valid",
451+ input: "copy.word-separators =",
452+ want: "",
453+ },
454+ {
455+ name: "malformed quote keeps default",
456+ input: `copy.word-separators = "unterminated`,
457+ want: domain.DefaultWordSeparators,
458+ wantWarnings: []domain.Warning{
459+ {Line: 1, Msg: `invalid copy.word-separators "\"unterminated"`},
460+ },
461+ },
462+ {
463+ name: "duplicate last value wins",
464+ input: "copy.word-separators = /\ncopy.word-separators = _",
465+ want: "_",
466+ wantWarnings: []domain.Warning{
467+ {Line: 2, Msg: `duplicate key "copy.word-separators"`},
468+ },
469+ },
470+ }
471+
472+ for _, tt := range tests {
473+ t.Run(tt.name, func(t *testing.T) {
474+ t.Parallel()
475+
476+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
477+ require.NoError(t, err)
478+ require.Equal(t, tt.want, cfg.Copy.WordSeparators)
479+ require.Equal(t, tt.wantWarnings, warnings)
480+ })
481+ }
482+}
483+
484+func TestDefaultsCopyWordSeparators(t *testing.T) {
485+ t.Parallel()
486+
487+ require.Equal(t, " -_@", domain.Defaults().Copy.WordSeparators)
488+}
489+
422 func TestParsePreservesBindingFileOrder(t *testing.T) {490 func TestParsePreservesBindingFileOrder(t *testing.T) {
423 t.Parallel()491 t.Parallel()
424 492
M internal/domain/config.go +12 -0
@@ -5,6 +5,9 @@ import "time"
55 // MinBarInterval is the minimum supported bar refresh interval.
66 const MinBarInterval = time.Second
77
8+// DefaultWordSeparators matches tmux's practical shell-oriented word boundaries.
9+const DefaultWordSeparators = " -_@"
10+
811 // ThemeMode selects how vev derives its color scheme.
912 type ThemeMode int
1013
@@ -47,6 +50,11 @@ type BarConfig struct {
4750 Interval time.Duration
4851 }
4952
53+// CopyConfig contains copy-mode text selection settings.
54+type CopyConfig struct {
55+ WordSeparators string
56+}
57+
5058 // PaletteConfig contains palette placement settings. A false AnchorSet uses the
5159 // adaptive automatic placement.
5260 type PaletteConfig struct {
@@ -77,6 +85,7 @@ type Config struct {
7785 BindingEntries []ConfigEntry
7886 Codes map[string]string
7987 Snapshot SnapshotConfig
88+ Copy CopyConfig
8089 Palette PaletteConfig
8190 Floating FloatingConfig
8291 Tabs TabsConfig
@@ -103,6 +112,9 @@ func Defaults() Config {
103112 Snapshot: SnapshotConfig{
104113 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
105114 },
115+ Copy: CopyConfig{
116+ WordSeparators: DefaultWordSeparators,
117+ },
106118 Floating: FloatingConfig{
107119 Width: 80,
108120 Height: 80,
@@ -5,6 +5,9 @@ import "time"
5 // MinBarInterval is the minimum supported bar refresh interval.5 // MinBarInterval is the minimum supported bar refresh interval.
6 const MinBarInterval = time.Second6 const MinBarInterval = time.Second
7 7
8+// DefaultWordSeparators matches tmux's practical shell-oriented word boundaries.
9+const DefaultWordSeparators = " -_@"
10+
8 // ThemeMode selects how vev derives its color scheme.11 // ThemeMode selects how vev derives its color scheme.
9 type ThemeMode int12 type ThemeMode int
10 13
@@ -47,6 +50,11 @@ type BarConfig struct {
47 Interval time.Duration50 Interval time.Duration
48 }51 }
49 52
53+// CopyConfig contains copy-mode text selection settings.
54+type CopyConfig struct {
55+ WordSeparators string
56+}
57+
50 // PaletteConfig contains palette placement settings. A false AnchorSet uses the58 // PaletteConfig contains palette placement settings. A false AnchorSet uses the
51 // adaptive automatic placement.59 // adaptive automatic placement.
52 type PaletteConfig struct {60 type PaletteConfig struct {
@@ -77,6 +85,7 @@ type Config struct {
77 BindingEntries []ConfigEntry85 BindingEntries []ConfigEntry
78 Codes map[string]string86 Codes map[string]string
79 Snapshot SnapshotConfig87 Snapshot SnapshotConfig
88+ Copy CopyConfig
80 Palette PaletteConfig89 Palette PaletteConfig
81 Floating FloatingConfig90 Floating FloatingConfig
82 Tabs TabsConfig91 Tabs TabsConfig
@@ -103,6 +112,9 @@ func Defaults() Config {
103 Snapshot: SnapshotConfig{112 Snapshot: SnapshotConfig{
104 RestoreProcesses: DefaultSnapshotRestoreProcesses(),113 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
105 },114 },
115+ Copy: CopyConfig{
116+ WordSeparators: DefaultWordSeparators,
117+ },
106 Floating: FloatingConfig{118 Floating: FloatingConfig{
107 Width: 80,119 Width: 80,
108 Height: 80,120 Height: 80,

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