Skip to content

Commit dcf13aa

Browse files
jongioCopilot
andauthored
feat(config): persist view-state settings and add config versioning (#128)
Retain user preferences (sort, pivot, time range, preview visibility) across restarts and version updates by saving them to config.json when changed in the TUI. Changes: - Add DefaultSortOrder field to Config for sort direction persistence - Add ConfigVersion field with migration system (v0 -> v1 migrates deprecated LaunchInPlace to LaunchMode) - Persist sort field, sort order, pivot, time range, and preview toggle to config on every change (matches existing PreviewPosition and ConversationNewestFirst behavior) - Add saveConfig() helper for centralized error handling - Add sortFieldToConfig/sortOrderFromConfig/sortOrderToConfig helpers - Load sort order from config on startup via EffectiveSortOrder() - 10 new tests covering migration, round-trip, and effective methods Co-authored-by: Copilot <[email protected]>
1 parent 39129d7 commit dcf13aa

3 files changed

Lines changed: 283 additions & 1 deletion

File tree

internal/config/config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,20 @@ const (
2727
// maxMaxSessions is the hard upper limit for MaxSessions to prevent
2828
// resource exhaustion from a maliciously large config value.
2929
maxMaxSessions = 10_000
30+
31+
// currentConfigVersion is the schema version written to new and migrated
32+
// config files. Increment this when making breaking schema changes and
33+
// add a corresponding migration case in [migrate].
34+
currentConfigVersion = 1
3035
)
3136

3237
// Config holds the user's preferences.
3338
type Config struct {
39+
// ConfigVersion tracks the schema version of this config file. Used to
40+
// detect and apply migrations when the config schema changes across
41+
// dispatch releases. The current version is set by [currentConfigVersion].
42+
ConfigVersion int `json:"config_version,omitempty"`
43+
3444
// DefaultShell is the preferred shell name (e.g. "pwsh", "bash", "zsh").
3545
DefaultShell string `json:"default_shell"`
3646

@@ -46,6 +56,10 @@ type Config struct {
4656
// Valid values: "updated", "created", "turns", "name", "folder".
4757
DefaultSort string `json:"default_sort"`
4858

59+
// DefaultSortOrder is the direction used to order session lists.
60+
// Valid values: "asc", "desc".
61+
DefaultSortOrder string `json:"default_sort_order,omitempty"`
62+
4963
// DefaultPivot is the default grouping applied to session lists.
5064
// Valid values: "none", "folder", "repo", "branch", "date".
5165
DefaultPivot string `json:"default_pivot"`
@@ -193,6 +207,14 @@ const (
193207
PreviewPositionTop = "top"
194208
)
195209

210+
// Sort order constants for DefaultSortOrder.
211+
const (
212+
// SortOrderAsc sorts results in ascending order.
213+
SortOrderAsc = "asc"
214+
// SortOrderDesc sorts results in descending order.
215+
SortOrderDesc = "desc"
216+
)
217+
196218
// EffectivePaneDirection returns the configured pane direction, defaulting
197219
// to "auto" when unset.
198220
func (c *Config) EffectivePaneDirection() string {
@@ -213,6 +235,17 @@ func (c *Config) EffectivePreviewPosition() string {
213235
}
214236
}
215237

238+
// EffectiveSortOrder returns the configured sort order, defaulting to "desc"
239+
// when unset or invalid.
240+
func (c *Config) EffectiveSortOrder() string {
241+
switch c.DefaultSortOrder {
242+
case SortOrderAsc, SortOrderDesc:
243+
return c.DefaultSortOrder
244+
default:
245+
return SortOrderDesc
246+
}
247+
}
248+
216249
// defaultAttentionThreshold is used when AttentionThreshold is empty or unparseable.
217250
const defaultAttentionThreshold = 15 * time.Minute
218251

@@ -244,6 +277,7 @@ func (c *Config) EffectiveLaunchMode() string {
244277
// Default returns a Config populated with sensible default values.
245278
func Default() *Config {
246279
return &Config{
280+
ConfigVersion: currentConfigVersion,
247281
DefaultShell: "",
248282
DefaultTerminal: "",
249283
DefaultTimeRange: "1d",
@@ -282,6 +316,9 @@ func Load() (*Config, error) {
282316
}
283317

284318
cfg := Default() // start from defaults so missing keys keep their default
319+
// Reset ConfigVersion to 0 before unmarshal so that old configs lacking
320+
// the field are detected as version 0 (needing migration).
321+
cfg.ConfigVersion = 0
285322
if err := json.Unmarshal(data, cfg); err != nil {
286323
return nil, fmt.Errorf("parsing config: %w", err)
287324
}
@@ -294,6 +331,14 @@ func Load() (*Config, error) {
294331
}
295332
cfg.sanitize()
296333

334+
// Migrate old config schemas forward. If the version changed, persist
335+
// the upgraded config so future loads skip migration.
336+
if cfg.ConfigVersion < currentConfigVersion {
337+
migrate(cfg)
338+
cfg.ConfigVersion = currentConfigVersion
339+
_ = Save(cfg) // best-effort; don't fail Load on write errors
340+
}
341+
297342
// Allow env var override for workspace recovery (used by --demo).
298343
if os.Getenv("DISPATCH_WORKSPACE_RECOVERY") == "1" {
299344
cfg.WorkspaceRecovery = true
@@ -302,6 +347,19 @@ func Load() (*Config, error) {
302347
return cfg, nil
303348
}
304349

350+
// migrate applies all necessary transformations to bring an old config up to
351+
// the current schema version. Each version bump should have a case here that
352+
// transforms fields from the old layout to the new one, preserving user intent.
353+
func migrate(cfg *Config) {
354+
// v0 → v1: LaunchInPlace (bool) was replaced by LaunchMode (string).
355+
// If the user had LaunchInPlace=true but LaunchMode is unset, populate it.
356+
if cfg.ConfigVersion < 1 {
357+
if cfg.LaunchInPlace && cfg.LaunchMode == "" {
358+
cfg.LaunchMode = LaunchModeInPlace
359+
}
360+
}
361+
}
362+
305363
// shellUnsafe contains characters that must never appear in shell or terminal
306364
// names because they could be interpreted by command interpreters (cmd.exe,
307365
// bash, PowerShell, AppleScript). Values containing any of these are cleared

internal/config/config_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"os"
67
"path/filepath"
78
"runtime"
@@ -16,6 +17,9 @@ func TestDefaultValues(t *testing.T) {
1617
t.Parallel()
1718
cfg := Default()
1819

20+
if cfg.ConfigVersion != currentConfigVersion {
21+
t.Errorf("ConfigVersion = %d, want %d", cfg.ConfigVersion, currentConfigVersion)
22+
}
1923
if cfg.DefaultShell != "" {
2024
t.Errorf("DefaultShell = %q, want empty", cfg.DefaultShell)
2125
}
@@ -77,6 +81,7 @@ func TestConfigJSONRoundTrip(t *testing.T) {
7781
DefaultTerminal: "alacritty",
7882
DefaultTimeRange: "7d",
7983
DefaultSort: "created",
84+
DefaultSortOrder: "asc",
8085
DefaultPivot: "repo",
8186
ShowPreview: false,
8287
MaxSessions: 50,
@@ -112,6 +117,9 @@ func TestConfigJSONRoundTrip(t *testing.T) {
112117
if restored.DefaultSort != original.DefaultSort {
113118
t.Errorf("DefaultSort = %q, want %q", restored.DefaultSort, original.DefaultSort)
114119
}
120+
if restored.DefaultSortOrder != original.DefaultSortOrder {
121+
t.Errorf("DefaultSortOrder = %q, want %q", restored.DefaultSortOrder, original.DefaultSortOrder)
122+
}
115123
if restored.DefaultPivot != original.DefaultPivot {
116124
t.Errorf("DefaultPivot = %q, want %q", restored.DefaultPivot, original.DefaultPivot)
117125
}
@@ -1171,3 +1179,164 @@ func TestPreviewPositionConstants(t *testing.T) {
11711179
t.Errorf("PreviewPositionTop = %q, want 'top'", PreviewPositionTop)
11721180
}
11731181
}
1182+
1183+
func TestEffectiveSortOrder_DefaultsToDesc(t *testing.T) {
1184+
t.Parallel()
1185+
cfg := Default()
1186+
if got := cfg.EffectiveSortOrder(); got != SortOrderDesc {
1187+
t.Errorf("EffectiveSortOrder() = %q, want %q", got, SortOrderDesc)
1188+
}
1189+
}
1190+
1191+
func TestEffectiveSortOrder_RespectsAsc(t *testing.T) {
1192+
t.Parallel()
1193+
cfg := Default()
1194+
cfg.DefaultSortOrder = SortOrderAsc
1195+
if got := cfg.EffectiveSortOrder(); got != SortOrderAsc {
1196+
t.Errorf("EffectiveSortOrder() = %q, want %q", got, SortOrderAsc)
1197+
}
1198+
}
1199+
1200+
func TestEffectiveSortOrder_InvalidFallsBackToDesc(t *testing.T) {
1201+
t.Parallel()
1202+
cfg := Default()
1203+
cfg.DefaultSortOrder = "invalid"
1204+
if got := cfg.EffectiveSortOrder(); got != SortOrderDesc {
1205+
t.Errorf("EffectiveSortOrder() = %q, want %q", got, SortOrderDesc)
1206+
}
1207+
}
1208+
1209+
func TestDefaultSortOrderOmittedFromJSON(t *testing.T) {
1210+
t.Parallel()
1211+
cfg := Default()
1212+
data, err := json.Marshal(cfg)
1213+
if err != nil {
1214+
t.Fatalf("Marshal: %v", err)
1215+
}
1216+
var raw map[string]any
1217+
if err := json.Unmarshal(data, &raw); err != nil {
1218+
t.Fatalf("Unmarshal: %v", err)
1219+
}
1220+
if _, ok := raw["default_sort_order"]; ok {
1221+
t.Error("default_sort_order should be omitted from JSON when empty")
1222+
}
1223+
}
1224+
1225+
func TestDefaultSortOrderPreservedOnLoad(t *testing.T) {
1226+
t.Parallel()
1227+
jsonData := `{"default_sort_order": "asc"}`
1228+
cfg := Default()
1229+
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
1230+
t.Fatalf("Unmarshal: %v", err)
1231+
}
1232+
if cfg.DefaultSortOrder != SortOrderAsc {
1233+
t.Errorf("DefaultSortOrder = %q, want %q", cfg.DefaultSortOrder, SortOrderAsc)
1234+
}
1235+
}
1236+
1237+
// ---------------------------------------------------------------------------
1238+
// Config version and migration tests
1239+
// ---------------------------------------------------------------------------
1240+
1241+
func TestDefaultConfigVersion(t *testing.T) {
1242+
t.Parallel()
1243+
cfg := Default()
1244+
if cfg.ConfigVersion != currentConfigVersion {
1245+
t.Errorf("ConfigVersion = %d, want %d", cfg.ConfigVersion, currentConfigVersion)
1246+
}
1247+
}
1248+
1249+
func TestMigrate_V0LaunchInPlaceToLaunchMode(t *testing.T) {
1250+
t.Parallel()
1251+
cfg := &Config{
1252+
ConfigVersion: 0,
1253+
LaunchInPlace: true,
1254+
}
1255+
migrate(cfg)
1256+
if cfg.LaunchMode != LaunchModeInPlace {
1257+
t.Errorf("LaunchMode = %q, want %q", cfg.LaunchMode, LaunchModeInPlace)
1258+
}
1259+
}
1260+
1261+
func TestMigrate_V0LaunchInPlaceSkippedWhenLaunchModeSet(t *testing.T) {
1262+
t.Parallel()
1263+
cfg := &Config{
1264+
ConfigVersion: 0,
1265+
LaunchInPlace: true,
1266+
LaunchMode: LaunchModeTab,
1267+
}
1268+
migrate(cfg)
1269+
if cfg.LaunchMode != LaunchModeTab {
1270+
t.Errorf("LaunchMode = %q, want %q (should not be overwritten)", cfg.LaunchMode, LaunchModeTab)
1271+
}
1272+
}
1273+
1274+
func TestLoad_MigratesAndPersistsVersion(t *testing.T) {
1275+
dir := withTempConfigDir(t)
1276+
1277+
// Write a v0 config (no config_version field).
1278+
path := filepath.Join(dir, "dispatch", configFileName)
1279+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1280+
t.Fatalf("MkdirAll: %v", err)
1281+
}
1282+
content := `{"launchInPlace": true, "default_shell": "fish"}`
1283+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1284+
t.Fatalf("WriteFile: %v", err)
1285+
}
1286+
1287+
cfg, err := Load()
1288+
if err != nil {
1289+
t.Fatalf("Load: %v", err)
1290+
}
1291+
1292+
// Migration should have run.
1293+
if cfg.LaunchMode != LaunchModeInPlace {
1294+
t.Errorf("LaunchMode = %q, want %q after migration", cfg.LaunchMode, LaunchModeInPlace)
1295+
}
1296+
// User settings should be preserved.
1297+
if cfg.DefaultShell != "fish" {
1298+
t.Errorf("DefaultShell = %q, want 'fish' (should survive migration)", cfg.DefaultShell)
1299+
}
1300+
// Version should be updated.
1301+
if cfg.ConfigVersion != currentConfigVersion {
1302+
t.Errorf("ConfigVersion = %d, want %d", cfg.ConfigVersion, currentConfigVersion)
1303+
}
1304+
1305+
// File should have been re-written with the new version.
1306+
reloaded, err := os.ReadFile(path)
1307+
if err != nil {
1308+
t.Fatalf("ReadFile after migration: %v", err)
1309+
}
1310+
var raw map[string]any
1311+
if err := json.Unmarshal(reloaded, &raw); err != nil {
1312+
t.Fatalf("Unmarshal re-written config: %v", err)
1313+
}
1314+
if v, ok := raw["config_version"]; !ok || int(v.(float64)) != currentConfigVersion {
1315+
t.Errorf("Persisted config_version = %v, want %d", v, currentConfigVersion)
1316+
}
1317+
}
1318+
1319+
func TestLoad_SkipsMigrationWhenCurrent(t *testing.T) {
1320+
dir := withTempConfigDir(t)
1321+
1322+
// Write a current-version config.
1323+
path := filepath.Join(dir, "dispatch", configFileName)
1324+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1325+
t.Fatalf("MkdirAll: %v", err)
1326+
}
1327+
content := fmt.Sprintf(`{"config_version": %d, "default_shell": "zsh"}`, currentConfigVersion)
1328+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
1329+
t.Fatalf("WriteFile: %v", err)
1330+
}
1331+
1332+
cfg, err := Load()
1333+
if err != nil {
1334+
t.Fatalf("Load: %v", err)
1335+
}
1336+
if cfg.DefaultShell != "zsh" {
1337+
t.Errorf("DefaultShell = %q, want 'zsh'", cfg.DefaultShell)
1338+
}
1339+
if cfg.ConfigVersion != currentConfigVersion {
1340+
t.Errorf("ConfigVersion = %d, want %d", cfg.ConfigVersion, currentConfigVersion)
1341+
}
1342+
}

0 commit comments

Comments
 (0)