detached 5ebf319

feat(config): add snapshot restore process configuration

commit
5ebf319507fc393c5fae96b2c1535874b7d4f61c
author
brice · · 2026-07-06 07:37 UTC
parent
7b6607d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +195 -0
M internal/adapters/config/config.go +31 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "os"
10+ "regexp"
1011 "strings"
1112 "time"
1213
@@ -16,6 +17,8 @@ import (
1617
1718 const pollInterval = 2 * time.Second
1819
20+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+
1922 // Parse reads vev's flat action = value config format. Duplicate action keys
2023 // are accepted with a warning; the last value wins while first-seen action order
2124 // is preserved for binding conflict resolution.
@@ -67,6 +70,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6770 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
6871 }
6972 cfg.Codes[codeKey] = value
73+ case key == "snapshot.restore_processes":
74+ cfg.Snapshot.RestoreProcesses = parseProcessList(value, lineNo, &warnings)
75+ cfg.Snapshot.RestoreProcessesSet = true
7076 default:
7177 if seenBindingKeys[key] {
7278 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
@@ -164,6 +170,31 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
164170 }
165171 }
166172
173+func parseProcessList(value string, lineNo int, warnings *[]domain.Warning) []string {
174+ if strings.TrimSpace(value) == "" {
175+ return nil
176+ }
177+ parts := strings.Split(value, ",")
178+ out := make([]string, 0, len(parts))
179+ seen := make(map[string]struct{}, len(parts))
180+ for _, part := range parts {
181+ item := strings.TrimSpace(part)
182+ if item == "" {
183+ continue
184+ }
185+ if !processNamePattern.MatchString(item) {
186+ *warnings = append(*warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
187+ continue
188+ }
189+ if _, ok := seen[item]; ok {
190+ continue
191+ }
192+ seen[item] = struct{}{}
193+ out = append(out, item)
194+ }
195+ return out
196+}
197+
167198 func parseTheme(value string) (domain.ThemeMode, bool) {
168199 switch strings.ToLower(strings.TrimSpace(value)) {
169200 case "auto", "":
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "os"9 "os"
10+ "regexp"
10 "strings"11 "strings"
11 "time"12 "time"
12 13
@@ -16,6 +17,8 @@ import (
16 17
17 const pollInterval = 2 * time.Second18 const pollInterval = 2 * time.Second
18 19
20+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+
19 // Parse reads vev's flat action = value config format. Duplicate action keys22 // Parse reads vev's flat action = value config format. Duplicate action keys
20 // are accepted with a warning; the last value wins while first-seen action order23 // are accepted with a warning; the last value wins while first-seen action order
21 // is preserved for binding conflict resolution.24 // is preserved for binding conflict resolution.
@@ -67,6 +70,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
67 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})70 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
68 }71 }
69 cfg.Codes[codeKey] = value72 cfg.Codes[codeKey] = value
73+ case key == "snapshot.restore_processes":
74+ cfg.Snapshot.RestoreProcesses = parseProcessList(value, lineNo, &warnings)
75+ cfg.Snapshot.RestoreProcessesSet = true
70 default:76 default:
71 if seenBindingKeys[key] {77 if seenBindingKeys[key] {
72 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})78 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
@@ -164,6 +170,31 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
164 }170 }
165 }171 }
166 172
173+func parseProcessList(value string, lineNo int, warnings *[]domain.Warning) []string {
174+ if strings.TrimSpace(value) == "" {
175+ return nil
176+ }
177+ parts := strings.Split(value, ",")
178+ out := make([]string, 0, len(parts))
179+ seen := make(map[string]struct{}, len(parts))
180+ for _, part := range parts {
181+ item := strings.TrimSpace(part)
182+ if item == "" {
183+ continue
184+ }
185+ if !processNamePattern.MatchString(item) {
186+ *warnings = append(*warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
187+ continue
188+ }
189+ if _, ok := seen[item]; ok {
190+ continue
191+ }
192+ seen[item] = struct{}{}
193+ out = append(out, item)
194+ }
195+ return out
196+}
197+
167 func parseTheme(value string) (domain.ThemeMode, bool) {198 func parseTheme(value string) (domain.ThemeMode, bool) {
168 switch strings.ToLower(strings.TrimSpace(value)) {199 switch strings.ToLower(strings.TrimSpace(value)) {
169 case "auto", "":200 case "auto", "":
M internal/adapters/config/config_test.go +46 -0
@@ -97,6 +97,9 @@ func TestParse(t *testing.T) {
9797 t.Run(tt.name, func(t *testing.T) {
9898 t.Parallel()
9999
100+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
101+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses...)
102+ }
100103 got, warnings, err := Parse(strings.NewReader(tt.input))
101104 if err != nil {
102105 t.Fatalf("Parse() error = %v", err)
@@ -111,6 +114,49 @@ func TestParse(t *testing.T) {
111114 }
112115 }
113116
117+func TestParseSnapshotRestoreProcesses(t *testing.T) {
118+ t.Parallel()
119+
120+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, codex, pi, opencode, btop\n"))
121+ require.NoError(t, err)
122+ require.Empty(t, warnings)
123+ require.Equal(t, []string{"claude", "codex", "pi", "opencode", "btop"}, cfg.Snapshot.RestoreProcesses)
124+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
125+}
126+
127+func TestParseSnapshotRestoreProcessesEmptyDisables(t *testing.T) {
128+ t.Parallel()
129+
130+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes =\n"))
131+ require.NoError(t, err)
132+ require.Empty(t, warnings)
133+ require.Empty(t, cfg.Snapshot.RestoreProcesses)
134+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
135+}
136+
137+func TestParseSnapshotRestoreProcessesWarnsOnMalformedEntries(t *testing.T) {
138+ t.Parallel()
139+
140+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n"))
141+ require.NoError(t, err)
142+ require.Equal(t, []domain.Warning{
143+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
144+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
145+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
146+ }, warnings)
147+ require.Equal(t, []string{"claude", "custom-tool", "zsh"}, cfg.Snapshot.RestoreProcesses)
148+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
149+}
150+
151+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
152+ t.Parallel()
153+
154+ first := domain.Defaults()
155+ first.Snapshot.RestoreProcesses[0] = "mutated"
156+ second := domain.Defaults()
157+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
158+}
159+
114160 func TestParsePreservesBindingFileOrder(t *testing.T) {
115161 t.Parallel()
116162
@@ -97,6 +97,9 @@ func TestParse(t *testing.T) {
97 t.Run(tt.name, func(t *testing.T) {97 t.Run(tt.name, func(t *testing.T) {
98 t.Parallel()98 t.Parallel()
99 99
100+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
101+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses...)
102+ }
100 got, warnings, err := Parse(strings.NewReader(tt.input))103 got, warnings, err := Parse(strings.NewReader(tt.input))
101 if err != nil {104 if err != nil {
102 t.Fatalf("Parse() error = %v", err)105 t.Fatalf("Parse() error = %v", err)
@@ -111,6 +114,49 @@ func TestParse(t *testing.T) {
111 }114 }
112 }115 }
113 116
117+func TestParseSnapshotRestoreProcesses(t *testing.T) {
118+ t.Parallel()
119+
120+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, codex, pi, opencode, btop\n"))
121+ require.NoError(t, err)
122+ require.Empty(t, warnings)
123+ require.Equal(t, []string{"claude", "codex", "pi", "opencode", "btop"}, cfg.Snapshot.RestoreProcesses)
124+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
125+}
126+
127+func TestParseSnapshotRestoreProcessesEmptyDisables(t *testing.T) {
128+ t.Parallel()
129+
130+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes =\n"))
131+ require.NoError(t, err)
132+ require.Empty(t, warnings)
133+ require.Empty(t, cfg.Snapshot.RestoreProcesses)
134+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
135+}
136+
137+func TestParseSnapshotRestoreProcessesWarnsOnMalformedEntries(t *testing.T) {
138+ t.Parallel()
139+
140+ cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n"))
141+ require.NoError(t, err)
142+ require.Equal(t, []domain.Warning{
143+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
144+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
145+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
146+ }, warnings)
147+ require.Equal(t, []string{"claude", "custom-tool", "zsh"}, cfg.Snapshot.RestoreProcesses)
148+ require.True(t, cfg.Snapshot.RestoreProcessesSet)
149+}
150+
151+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
152+ t.Parallel()
153+
154+ first := domain.Defaults()
155+ first.Snapshot.RestoreProcesses[0] = "mutated"
156+ second := domain.Defaults()
157+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
158+}
159+
114 func TestParsePreservesBindingFileOrder(t *testing.T) {160 func TestParsePreservesBindingFileOrder(t *testing.T) {
115 t.Parallel()161 t.Parallel()
116 162
M internal/app/run.go +3 -0
@@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) {
142142 if len(args) > 2 {
143143 return command{}, usagef("`new` does not support command overrides")
144144 }
145+ if err := domain.ValidateSessionName(args[1]); err != nil {
146+ return command{}, err
147+ }
145148 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
146149 case "attach", "a":
147150 if len(args) < 2 || args[1] == "" {
@@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) {
142 if len(args) > 2 {142 if len(args) > 2 {
143 return command{}, usagef("`new` does not support command overrides")143 return command{}, usagef("`new` does not support command overrides")
144 }144 }
145+ if err := domain.ValidateSessionName(args[1]); err != nil {
146+ return command{}, err
147+ }
145 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil148 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
146 case "attach", "a":149 case "attach", "a":
147 if len(args) < 2 || args[1] == "" {150 if len(args) < 2 || args[1] == "" {
M internal/app/run_test.go +11 -0
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/adapters/snapshot"
16+ "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/persist"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/usecase/client"
@@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) {
3839 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
3940 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
4041 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
4143 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
4244 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
4345 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) {
4749 {name: "ls", args: []string{"ls"}, wantKind: kindList},
4850 {name: "list", args: []string{"list"}, wantKind: kindList},
4951 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
52+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
5053 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
5154 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
5255 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) {
5861 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
5962 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
6063 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
64+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
6165 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
6266 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
6367 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
@@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) {
103107 }
104108 }
105109
110+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
111+ _, err := parseArgs([]string{"new", "my work"})
112+ if !errors.Is(err, domain.ErrInvalidSessionName) {
113+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
114+ }
115+}
116+
106117 func TestPrintSessionsShowsStoppedState(t *testing.T) {
107118 var out bytes.Buffer
108119 printSessions(&out, []ports.SessionInfo{
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/vev/internal/adapters/snapshot"15 "github.com/bnema/vev/internal/adapters/snapshot"
16+ "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/persist"17 "github.com/bnema/vev/internal/persist"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/usecase/client"19 "github.com/bnema/vev/internal/usecase/client"
@@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) {
38 {name: "new empty name", args: []string{"new", ""}, wantErr: true},39 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
39 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},40 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
40 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},41 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
41 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},43 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},44 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
43 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},45 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) {
47 {name: "ls", args: []string{"ls"}, wantKind: kindList},49 {name: "ls", args: []string{"ls"}, wantKind: kindList},
48 {name: "list", args: []string{"list"}, wantKind: kindList},50 {name: "list", args: []string{"list"}, wantKind: kindList},
49 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},51 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
52+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
50 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},53 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
51 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},54 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
52 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},55 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) {
58 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},61 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
59 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},62 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
60 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},63 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
64+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
61 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},65 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
62 {name: "help", args: []string{"--help"}, wantKind: kindHelp},66 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
63 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},67 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
@@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) {
103 }107 }
104 }108 }
105 109
110+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
111+ _, err := parseArgs([]string{"new", "my work"})
112+ if !errors.Is(err, domain.ErrInvalidSessionName) {
113+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
114+ }
115+}
116+
106 func TestPrintSessionsShowsStoppedState(t *testing.T) {117 func TestPrintSessionsShowsStoppedState(t *testing.T) {
107 var out bytes.Buffer118 var out bytes.Buffer
108 printSessions(&out, []ports.SessionInfo{119 printSessions(&out, []ports.SessionInfo{
M internal/domain/config.go +16 -0
@@ -18,6 +18,18 @@ type ConfigEntry struct {
1818 Value string
1919 }
2020
21+// SnapshotConfig contains user-configurable snapshot restore settings.
22+type SnapshotConfig struct {
23+ RestoreProcesses []string
24+ RestoreProcessesSet bool
25+}
26+
27+// DefaultSnapshotRestoreProcesses is the default allowlist for process restore.
28+var DefaultSnapshotRestoreProcesses = []string{
29+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
30+ "claude", "codex", "pi", "opencode",
31+}
32+
2133 // Config is the user-editable vev configuration after parsing. Unknown binding
2234 // keys are preserved here (in BindingEntries, in file order) so the usecase
2335 // layer can decide which actions it understands.
@@ -25,6 +37,7 @@ type Config struct {
2537 Theme ThemeMode
2638 BindingEntries []ConfigEntry
2739 Codes map[string]string
40+ Snapshot SnapshotConfig
2841 }
2942
3043 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -40,5 +53,8 @@ func Defaults() Config {
4053 Theme: ThemeAuto,
4154 BindingEntries: []ConfigEntry{},
4255 Codes: map[string]string{},
56+ Snapshot: SnapshotConfig{
57+ RestoreProcesses: append([]string(nil), DefaultSnapshotRestoreProcesses...),
58+ },
4359 }
4460 }
@@ -18,6 +18,18 @@ type ConfigEntry struct {
18 Value string18 Value string
19 }19 }
20 20
21+// SnapshotConfig contains user-configurable snapshot restore settings.
22+type SnapshotConfig struct {
23+ RestoreProcesses []string
24+ RestoreProcessesSet bool
25+}
26+
27+// DefaultSnapshotRestoreProcesses is the default allowlist for process restore.
28+var DefaultSnapshotRestoreProcesses = []string{
29+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
30+ "claude", "codex", "pi", "opencode",
31+}
32+
21 // Config is the user-editable vev configuration after parsing. Unknown binding33 // Config is the user-editable vev configuration after parsing. Unknown binding
22 // keys are preserved here (in BindingEntries, in file order) so the usecase34 // keys are preserved here (in BindingEntries, in file order) so the usecase
23 // layer can decide which actions it understands.35 // layer can decide which actions it understands.
@@ -25,6 +37,7 @@ type Config struct {
25 Theme ThemeMode37 Theme ThemeMode
26 BindingEntries []ConfigEntry38 BindingEntries []ConfigEntry
27 Codes map[string]string39 Codes map[string]string
40+ Snapshot SnapshotConfig
28 }41 }
29 42
30 // Warning describes a non-fatal config problem. Parsers and reloaders should43 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -40,5 +53,8 @@ func Defaults() Config {
40 Theme: ThemeAuto,53 Theme: ThemeAuto,
41 BindingEntries: []ConfigEntry{},54 BindingEntries: []ConfigEntry{},
42 Codes: map[string]string{},55 Codes: map[string]string{},
56+ Snapshot: SnapshotConfig{
57+ RestoreProcesses: append([]string(nil), DefaultSnapshotRestoreProcesses...),
58+ },
43 }59 }
44 }60 }
A internal/domain/session_name.go +22 -0
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
A internal/domain/session_name_test.go +34 -0
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
M internal/usecase/daemon/daemon.go +4 -0
@@ -609,6 +609,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
609609 d.mu.Unlock()
610610 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
611611 }
612+ if err := domain.ValidateSessionName(h.Name); err != nil {
613+ d.mu.Unlock()
614+ return nil, nil, &protoErr{ports.ErrInternal, err.Error()}
615+ }
612616 if d.nameLiveOrStoppedLocked(h.Name) {
613617 d.mu.Unlock()
614618 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
@@ -609,6 +609,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
609 d.mu.Unlock()609 d.mu.Unlock()
610 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}610 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
611 }611 }
612+ if err := domain.ValidateSessionName(h.Name); err != nil {
613+ d.mu.Unlock()
614+ return nil, nil, &protoErr{ports.ErrInternal, err.Error()}
615+ }
612 if d.nameLiveOrStoppedLocked(h.Name) {616 if d.nameLiveOrStoppedLocked(h.Name) {
613 d.mu.Unlock()617 d.mu.Unlock()
614 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}618 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
M internal/usecase/daemon/session.go +6 -0
@@ -177,6 +177,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
177177 if name == "" {
178178 return errors.New("name required")
179179 }
180+ if err := domain.ValidateSessionName(name); err != nil {
181+ return err
182+ }
180183 sz := ac.size
181184 d.mu.Lock()
182185 if d.closing {
@@ -390,6 +393,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
390393 if name == "" {
391394 return errors.New("name required")
392395 }
396+ if err := domain.ValidateSessionName(name); err != nil {
397+ return err
398+ }
393399 d.mu.Lock()
394400 defer d.mu.Unlock()
395401 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
@@ -177,6 +177,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
177 if name == "" {177 if name == "" {
178 return errors.New("name required")178 return errors.New("name required")
179 }179 }
180+ if err := domain.ValidateSessionName(name); err != nil {
181+ return err
182+ }
180 sz := ac.size183 sz := ac.size
181 d.mu.Lock()184 d.mu.Lock()
182 if d.closing {185 if d.closing {
@@ -390,6 +393,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
390 if name == "" {393 if name == "" {
391 return errors.New("name required")394 return errors.New("name required")
392 }395 }
396+ if err := domain.ValidateSessionName(name); err != nil {
397+ return err
398+ }
393 d.mu.Lock()399 d.mu.Lock()
394 defer d.mu.Unlock()400 defer d.mu.Unlock()
395 if taken := d.findByNameLocked(name); taken != nil && taken != sess {401 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
M internal/usecase/daemon/session_test.go +22 -0
@@ -900,6 +900,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) {
900900 require.ErrorContains(t, err, "name already in use")
901901 }
902902
903+func TestIntentNewUnsafeNameRejected(t *testing.T) {
904+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
905+ tr := portsmocks.NewMockTransport(t)
906+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
907+ require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error())
908+}
909+
910+func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) {
911+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
912+ require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName)
913+}
914+
915+func TestRenameSessionUnsafeNameRejected(t *testing.T) {
916+ sz := domain.Size{Cols: 80, Rows: 24}
917+ p, release := newBlockingPTY(t)
918+ defer release()
919+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
921+ require.NoError(t, err)
922+ require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
923+}
924+
903925 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
904926 sz := domain.Size{Cols: 80, Rows: 24}
905927 p1, release1 := newBlockingPTY(t)
@@ -900,6 +900,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) {
900 require.ErrorContains(t, err, "name already in use")900 require.ErrorContains(t, err, "name already in use")
901 }901 }
902 902
903+func TestIntentNewUnsafeNameRejected(t *testing.T) {
904+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
905+ tr := portsmocks.NewMockTransport(t)
906+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
907+ require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error())
908+}
909+
910+func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) {
911+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
912+ require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName)
913+}
914+
915+func TestRenameSessionUnsafeNameRejected(t *testing.T) {
916+ sz := domain.Size{Cols: 80, Rows: 24}
917+ p, release := newBlockingPTY(t)
918+ defer release()
919+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
921+ require.NoError(t, err)
922+ require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
923+}
924+
903 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {925 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
904 sz := domain.Size{Cols: 80, Rows: 24}926 sz := domain.Size{Cols: 80, Rows: 24}
905 p1, release1 := newBlockingPTY(t)927 p1, release1 := newBlockingPTY(t)

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