mxup 0.3.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/mxup/config.rb CHANGED
@@ -7,26 +7,57 @@ require_relative 'env_file'
7
7
  module Mxup
8
8
  # Parsed mxup YAML config. Pure data; no tmux or filesystem side effects.
9
9
  #
10
- # Profiles (optional): a config may declare a `profiles:` map where each
11
- # entry is a partial override on top of the base `setup`, `windows`, and
12
- # `layouts`. A single active profile is resolved at parse time and its
13
- # overrides are merged in before the rest of the Config is built — so the
14
- # rest of the system (Launcher, Reconciler, StatusView…) never has to know
15
- # about profiles.
10
+ # Config format generations: `mxup_version` names the generation a config was
11
+ # written for, and mxup reads every generation up to its own so upgrading
12
+ # mxup never forces a config rewrite. The declared major selects how
13
+ # `profiles:` is read; nothing else in the format differs between them.
14
+ #
15
+ # Profiles (optional): a config may declare a `profiles:` map. What selecting
16
+ # one means depends on the generation:
17
+ #
18
+ # * Format 1 (`mxup_version: '1'`) — *additive*, and any number of profiles
19
+ # may be selected at once. The base `windows:` block is a catalog, and a
20
+ # window only runs when at least one selected profile opts in to it.
21
+ # Several profiles opting in to the same window is fine; two of them
22
+ # overriding the same setting differently is a collision and aborts with an
23
+ # explanation.
24
+ # * Format 0 (no `mxup_version` — every config written for 0.3.1 or earlier)
25
+ # — *subtractive*, one profile at a time. Every window in `windows:` runs;
26
+ # the active profile overrides some of them and drops others with `~`.
27
+ #
28
+ # Either way the merged result is applied before the rest of the Config is
29
+ # built — so the rest of the system (Launcher, Reconciler, StatusView…) never
30
+ # has to know about profiles or generations.
16
31
  class Config
17
- KNOWN_TOP_LEVEL_KEYS = %w[session setup root live_env required_env windows layouts profiles default_profile].freeze
32
+ # Config format generations this mxup can read. Generation N ships in mxup
33
+ # N.0.0 and every older one keeps working, so this range only ever grows.
34
+ CURRENT_FORMAT_MAJOR = 1
35
+ SUPPORTED_FORMAT_MAJORS = (0..CURRENT_FORMAT_MAJOR).freeze
36
+
37
+ KNOWN_TOP_LEVEL_KEYS = %w[mxup_version session setup root live_env required_env windows layouts profiles default_profile].freeze
18
38
  KNOWN_WINDOW_KEYS = %w[root command env wait_for live_env commands].freeze
19
39
  KNOWN_LAYOUT_GROUP_KEYS = %w[panes split].freeze
20
40
  KNOWN_PROFILE_KEYS = %w[setup root layouts live_env windows].freeze
21
41
 
42
+ # Profile keys that replace their base value wholesale, so two profiles
43
+ # declaring them differently cannot be reconciled.
44
+ PROFILE_SCALAR_KEYS = %w[setup root layouts].freeze
45
+
46
+ # Window keys that are themselves maps, compared entry-by-entry so two
47
+ # profiles can each contribute a different env var to the same window.
48
+ NESTED_WINDOW_KEYS = %w[env commands].freeze
49
+
22
50
  attr_reader :session, :setup, :root, :windows, :layouts, :layout_names,
23
- :profile, :profile_names, :default_profile, :live_env,
24
- :live_env_dir, :required_env, :env_file_path
51
+ :profiles, :profile_names, :default_profiles, :live_env,
52
+ :live_env_dir, :required_env, :env_file_path, :format_major
25
53
 
26
- def initialize(path, profile: nil)
54
+ # `profiles` accepts nil, a single name, or a list; `-p a,b` and
55
+ # `-p a -p b` both arrive here as the same selection.
56
+ def initialize(path, profiles: nil)
27
57
  raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
58
+ @format_major = resolve_format_major!(raw)
28
59
  validate_keys!(raw, KNOWN_TOP_LEVEL_KEYS, 'top level')
29
- resolve_profile!(raw, profile)
60
+ resolve_profiles!(raw, profiles)
30
61
  @session = raw.fetch('session')
31
62
  @setup = raw['setup']&.strip
32
63
  @live_env_dir = config_base_dir(path)
@@ -36,10 +67,17 @@ module Mxup
36
67
  load_env_file_into_env!
37
68
  @root = resolve_root(raw['root'], path)
38
69
  @windows = parse_windows(raw.fetch('windows'))
70
+ validate_windows_present!
39
71
  validate_window_live_env!
40
72
  @layouts, @layout_names = parse_layouts(raw['layouts'])
41
73
  end
42
74
 
75
+ # The selection as stored in the session's tmux environment (MXUP_PROFILE),
76
+ # so `status` can display it and `up` can detect a switch.
77
+ def profiles_label
78
+ @profiles.join(',')
79
+ end
80
+
43
81
  def default_layout
44
82
  @layout_names.first
45
83
  end
@@ -78,98 +116,407 @@ module Mxup
78
116
 
79
117
  private
80
118
 
81
- # Pick the active profile (if any) and merge its overrides into `raw`.
82
- # Sets @profile / @profile_names / @default_profile.
83
- def resolve_profile!(raw, requested)
84
- profiles = raw['profiles'] || {}
85
- @profile_names = profiles.keys
86
- @default_profile = raw['default_profile'] || @profile_names.first
119
+ # A config declares the format generation it was written for, and mxup
120
+ # reads every generation up to its own. The declared major therefore
121
+ # *selects* the profile semantics rather than gating the config out, so an
122
+ # mxup upgrade never forces a config rewrite; only a config aimed at a
123
+ # newer mxup is refused. Resolved before any other validation, so such a
124
+ # config explains itself instead of tripping over a key it doesn't know.
125
+ def resolve_format_major!(raw)
126
+ @format_pinned = false
127
+ return CURRENT_FORMAT_MAJOR unless raw.is_a?(Hash)
128
+
129
+ declared = raw['mxup_version']
130
+ # No `mxup_version` means the config predates the key, which only existed
131
+ # from major 1 on — so it is read as the original format, major 0.
132
+ @format_pinned = !declared.nil?
133
+ wanted = declared.nil? ? 0 : parse_mxup_major(declared)
134
+ return wanted if SUPPORTED_FORMAT_MAJORS.include?(wanted)
135
+
136
+ raise ArgumentError, format_from_the_future(wanted)
137
+ end
138
+
139
+ def legacy_format?
140
+ @format_major.zero?
141
+ end
142
+
143
+ def parse_mxup_major(declared)
144
+ # Guarded explicitly: Gem::Version reads a blank string as 0, which would
145
+ # quietly pass as "major 0" instead of flagging the empty value.
146
+ if declared.to_s.strip.empty?
147
+ raise ArgumentError, "mxup_version must name a major version, e.g. '1'"
148
+ end
149
+ unless Gem::Version.correct?(declared.to_s)
150
+ raise ArgumentError, "mxup_version '#{declared}' is not a valid version"
151
+ end
152
+
153
+ # Only the major is compared, so anything after it is decoration: '2',
154
+ # '2.0' and '2.7.3' all pin the same format generation.
155
+ Gem::Version.new(declared.to_s).segments.first
156
+ end
87
157
 
88
- if profiles.empty?
89
- if requested
158
+ # Only reachable for a generation newer than this mxup knows: older ones
159
+ # are read, not refused.
160
+ def format_from_the_future(wanted)
161
+ <<~MSG.chomp
162
+ This config targets the mxup #{wanted}.x config format, but mxup #{VERSION}
163
+ reads up to #{CURRENT_FORMAT_MAJOR}.x. Update mxup, then try again:
164
+ brew upgrade mxup # if installed via Homebrew
165
+ gem update mxup # if installed via RubyGems
166
+ MSG
167
+ end
168
+
169
+ # Resolve the selected profiles and merge them into `raw`.
170
+ # Sets @profiles / @profile_names / @default_profiles.
171
+ def resolve_profiles!(raw, requested)
172
+ declared = raw['profiles'] || {}
173
+ @profile_names = declared.keys
174
+ @default_profiles = Array(raw['default_profile']).map(&:to_s)
175
+ selected = normalize_profile_selection(requested)
176
+
177
+ if declared.empty?
178
+ unless selected.empty?
90
179
  raise ArgumentError,
91
- "--profile '#{requested}' was given, but this config declares no profiles"
180
+ "--profile '#{selected.join(', ')}' was given, but this config " \
181
+ 'declares no profiles'
92
182
  end
93
- @profile = nil
183
+ @profiles = []
94
184
  return
95
185
  end
96
186
 
97
- @profile = requested || @default_profile
98
- unless profiles.key?(@profile)
187
+ validate_legacy_selection!(selected) if legacy_format?
188
+ @profiles = selected.empty? ? default_profile_selection : selected
189
+
190
+ unknown = @profiles - @profile_names
191
+ unless unknown.empty?
99
192
  raise ArgumentError,
100
- "Unknown profile '#{@profile}' (available: #{@profile_names.join(', ')})"
193
+ "Unknown profile(s) #{unknown.map { |n| "'#{n}'" }.join(', ')} " \
194
+ "(available: #{@profile_names.join(', ')})"
101
195
  end
102
196
 
103
- apply_profile_overrides!(raw, profiles.fetch(@profile))
104
- end
105
-
106
- def apply_profile_overrides!(raw, override)
107
- return unless override.is_a?(Hash)
197
+ overrides = @profiles.map do |n|
198
+ override = declared.fetch(n)
199
+ [n, override.is_a?(Hash) ? override : {}]
200
+ end
108
201
 
109
- validate_keys!(override, KNOWN_PROFILE_KEYS, "profile '#{@profile}'")
202
+ if legacy_format?
203
+ warn_legacy_profiles unless @format_pinned
204
+ apply_legacy_profile!(raw, *overrides.first)
205
+ else
206
+ apply_profiles!(raw, overrides)
207
+ end
208
+ end
110
209
 
111
- if override.key?('session')
210
+ # Selecting several profiles at once, and pointing `default_profile` at
211
+ # several, both arrived with format 1. Under format 0 exactly one profile is
212
+ # active, so say what to change instead of quietly using just the first.
213
+ def validate_legacy_selection!(selected)
214
+ if selected.size > 1
112
215
  raise ArgumentError,
113
- "Profile '#{@profile}' cannot override 'session'; profiles of the " \
114
- 'same group must share one tmux session name'
216
+ legacy_single_profile_message(
217
+ "--profile '#{selected.join(', ')}' selects #{selected.size} profiles"
218
+ )
115
219
  end
220
+ return unless @default_profiles.size > 1
116
221
 
117
- raw['setup'] = override['setup'] if override.key?('setup')
118
- raw['root'] = override['root'] if override.key?('root')
119
- raw['layouts'] = override['layouts'] if override.key?('layouts')
222
+ raise ArgumentError,
223
+ legacy_single_profile_message(
224
+ "default_profile lists #{@default_profiles.size} profiles"
225
+ )
226
+ end
227
+
228
+ def legacy_single_profile_message(subject)
229
+ <<~MSG.chomp
230
+ #{subject}, which the mxup 1.x config format added.
231
+ This config is read as the 0.x format, where one profile is active at a
232
+ time. Run `mxup migrations` to see what 1.x changed, then set
233
+ `mxup_version: '1'` to select several at once.
234
+ MSG
235
+ end
236
+
237
+ # Format 0 and format 1 read the same `profiles:` text differently, and this
238
+ # is the only place they diverge — so a config that hasn't opted in is told
239
+ # which meaning it is getting. Two cases stay quiet: a config without
240
+ # `profiles:` (the generations are identical for it, so there is nothing to
241
+ # do) and one that pins `mxup_version: '0'`, which is an informed choice
242
+ # rather than a config that never heard about the key.
243
+ def warn_legacy_profiles
244
+ warn <<~MSG.chomp
245
+ mxup: reading `profiles:` with the 0.x meaning (every window in `windows:` runs;
246
+ the profile overrides some and drops others with `~`). Run `mxup migrations`,
247
+ then set `mxup_version: '1'` to switch to additive profiles.
248
+ MSG
249
+ end
250
+
251
+ # Format 0 profile semantics, kept so configs written for mxup 0.3.1 and
252
+ # earlier keep running unchanged: the profile is a partial override on the
253
+ # base config, and every window it does not mention still runs.
254
+ def apply_legacy_profile!(raw, name, override)
255
+ validate_profile!(name, override)
256
+
257
+ PROFILE_SCALAR_KEYS.each { |key| raw[key] = override[key] if override.key?(key) }
120
258
 
121
259
  if override.key?('live_env')
122
- base_le = raw['live_env'] || {}
123
- prof_le = override['live_env'] || {}
124
- raw['live_env'] = base_le.merge(prof_le)
260
+ raw['live_env'] = (raw['live_env'] || {}).merge(override['live_env'] || {})
125
261
  end
126
262
 
263
+ apply_legacy_windows!(raw, override['windows'] || {})
264
+ end
265
+
266
+ # `~` drops a window for this profile; any other value is merged onto the
267
+ # base entry, adding the window when the base declares none.
268
+ def apply_legacy_windows!(raw, windows)
269
+ return if windows.empty?
270
+
271
+ raw['windows'] ||= {}
127
272
  removed = []
128
- (override['windows'] || {}).each do |wname, woverride|
129
- raw['windows'] ||= {}
273
+
274
+ windows.each do |wname, woverride|
130
275
  if woverride.nil?
131
- # Explicit null ("dev-kit: ~") drops this window for this profile.
132
- # `parse_windows` skips absent keys; `prune_layouts!` keeps layouts
133
- # from referencing the now-missing name.
134
276
  raw['windows'].delete(wname)
135
277
  removed << wname
136
278
  else
137
- base = raw['windows'][wname] || {}
138
- raw['windows'][wname] = merge_window(base, woverride)
279
+ raw['windows'][wname] = merge_legacy_window(raw['windows'][wname] || {}, woverride)
139
280
  end
140
281
  end
141
282
 
142
- prune_layouts!(raw, removed) if removed.any?
283
+ prune_removed_from_layouts!(raw, removed)
284
+ end
285
+
286
+ # Shallow merge, except that `env` and `commands` are themselves maps and
287
+ # merge entry by entry — so a profile can tweak one of their keys without
288
+ # redeclaring the rest.
289
+ def merge_legacy_window(base, override)
290
+ base.merge(override) do |key, base_val, prof_val|
291
+ if NESTED_WINDOW_KEYS.include?(key) && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
292
+ base_val.merge(prof_val)
293
+ else
294
+ prof_val
295
+ end
296
+ end
143
297
  end
144
298
 
145
- # Strip any removed window names from layout groups so parse_layouts
146
- # doesn't raise "window not found". A group whose panes list becomes
147
- # empty is dropped from the layout entirely.
148
- def prune_layouts!(raw, removed)
299
+ # Strip only the windows this profile dropped from layout groups, so
300
+ # parse_layouts still reports a pane naming a window that was never in
301
+ # `windows:` as the typo it is as 0.3.1 did. (The format 1 path prunes by
302
+ # what was *kept*, because there a window is absent until a profile opts in.)
303
+ def prune_removed_from_layouts!(raw, removed)
304
+ return if removed.empty?
149
305
  return unless raw['layouts'].is_a?(Hash)
150
- removed_set = removed.to_set
306
+ dropped = removed.to_set
151
307
 
152
308
  raw['layouts'].each_value do |layout_def|
153
309
  next unless layout_def.is_a?(Hash)
154
310
  layout_def.reject! do |_group_name, group_def|
155
311
  next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
156
- group_def['panes'] -= removed_set.to_a
312
+ group_def['panes'].reject! { |pn| dropped.include?(pn) }
157
313
  group_def['panes'].empty?
158
314
  end
159
315
  end
160
316
  end
161
317
 
162
- # Shallow merge with a special case: `env` and `commands` are themselves
163
- # hashes that should be merged (so a profile can tweak one key without
164
- # redeclaring the rest).
165
- def merge_window(base, override)
166
- base.merge(override) do |key, base_val, prof_val|
167
- if %w[env commands].include?(key) && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
168
- base_val.merge(prof_val)
169
- else
170
- prof_val
318
+ # Duplicates collapse naming the same profile twice is not a collision
319
+ # with itself.
320
+ def normalize_profile_selection(requested)
321
+ Array(requested)
322
+ .flat_map { |p| p.to_s.split(',') }
323
+ .map(&:strip).reject(&:empty?).uniq
324
+ end
325
+
326
+ def default_profile_selection
327
+ @default_profiles.empty? ? [@profile_names.first] : @default_profiles
328
+ end
329
+
330
+ # Merge every selected profile into `raw`, collecting disagreements as we
331
+ # go so a config with several conflicts reports all of them at once rather
332
+ # than one per run.
333
+ def apply_profiles!(raw, selected)
334
+ selected.each { |name, override| validate_profile!(name, override) }
335
+
336
+ collisions = []
337
+ claims = {}
338
+
339
+ apply_profile_scalars!(raw, selected, claims, collisions)
340
+ apply_profile_live_env!(raw, selected, claims, collisions)
341
+ windows = resolve_profile_windows(raw, selected, claims, collisions)
342
+
343
+ raise ArgumentError, collision_report(collisions) if collisions.any?
344
+
345
+ raw['windows'] = windows
346
+ prune_layouts!(raw, windows.keys)
347
+ end
348
+
349
+ def validate_profile!(name, override)
350
+ validate_keys!(override, KNOWN_PROFILE_KEYS, "profile '#{name}'")
351
+
352
+ return unless override.key?('session')
353
+ raise ArgumentError,
354
+ "Profile '#{name}' cannot override 'session'; profiles of the " \
355
+ 'same group must share one tmux session name'
356
+ end
357
+
358
+ def apply_profile_scalars!(raw, selected, claims, collisions)
359
+ PROFILE_SCALAR_KEYS.each do |key|
360
+ selected.each do |name, override|
361
+ next unless override.key?(key)
362
+ claim!(claims, collisions, "top-level '#{key}'", name, override[key])
363
+ raw[key] = override[key]
364
+ end
365
+ end
366
+ end
367
+
368
+ # Profile live_env entries override the base per variable, so two profiles
369
+ # collide only when they declare the *same* variable differently.
370
+ def apply_profile_live_env!(raw, selected, claims, collisions)
371
+ merged = (raw['live_env'] || {}).dup
372
+
373
+ selected.each do |name, override|
374
+ (override['live_env'] || {}).each do |var, command|
375
+ claim!(claims, collisions, "live_env '#{var}'", name, command)
376
+ merged[var] = command
171
377
  end
172
378
  end
379
+
380
+ raw['live_env'] = merged unless merged.empty?
381
+ end
382
+
383
+ # Build the effective `windows:` map from the selected profiles. A window
384
+ # is included when at least one profile lists it with a (possibly empty)
385
+ # override; mapping it to null ("db: ~") is an explicit veto, which
386
+ # collides with any profile that includes the same window.
387
+ def resolve_profile_windows(raw, selected, claims, collisions)
388
+ catalog = raw['windows'] || {}
389
+ vetoes = {}
390
+ includes = {}
391
+
392
+ selected.each do |name, override|
393
+ (override['windows'] || {}).each do |wname, woverride|
394
+ if woverride.nil?
395
+ (vetoes[wname] ||= []) << name
396
+ else
397
+ (includes[wname] ||= []) << [name, woverride]
398
+ end
399
+ end
400
+ end
401
+
402
+ flag_veto_collisions(vetoes, includes, collisions)
403
+
404
+ order_included_windows(catalog, includes).each_with_object({}) do |wname, acc|
405
+ acc[wname] = merge_profile_window(
406
+ wname, catalog[wname] || {}, includes[wname], claims, collisions
407
+ )
408
+ end
409
+ end
410
+
411
+ def flag_veto_collisions(vetoes, includes, collisions)
412
+ includes.each_key do |wname|
413
+ vetoed_by = vetoes[wname]
414
+ next unless vetoed_by
415
+
416
+ collisions << {
417
+ label: "window '#{wname}'",
418
+ text: "vetoed by #{vetoed_by.join(', ')}, but included by " \
419
+ "#{includes[wname].map(&:first).join(', ')}"
420
+ }
421
+ end
422
+ end
423
+
424
+ # Catalog order first, so window and pane ordering stays stable no matter
425
+ # which profile happened to mention a window first. Windows a profile
426
+ # declares from scratch follow, in the order they were encountered.
427
+ def order_included_windows(catalog, includes)
428
+ catalog.keys.select { |n| includes.key?(n) } + (includes.keys - catalog.keys)
429
+ end
430
+
431
+ # Layer every including profile's override onto the catalog entry. Keys are
432
+ # claimed one at a time (and `env`/`commands` entry by entry) so profiles
433
+ # touching different settings of the same window merge cleanly.
434
+ def merge_profile_window(wname, base, entries, claims, collisions)
435
+ entries.each_with_object(base.dup) do |(pname, override), merged|
436
+ override.each do |key, value|
437
+ if NESTED_WINDOW_KEYS.include?(key) && value.is_a?(Hash)
438
+ nested = (merged[key] || {}).dup
439
+ value.each do |nkey, nvalue|
440
+ claim!(claims, collisions, "window '#{wname}' → #{key}.#{nkey}", pname, nvalue)
441
+ nested[nkey] = nvalue
442
+ end
443
+ merged[key] = nested
444
+ else
445
+ claim!(claims, collisions, "window '#{wname}' → #{key}", pname, value)
446
+ merged[key] = value
447
+ end
448
+ end
449
+ end
450
+ end
451
+
452
+ # Record "profile P set LABEL to VALUE". A later profile setting the same
453
+ # label to a *different* value is the collision we report; setting it to
454
+ # the same value silently agrees.
455
+ def claim!(claims, collisions, label, profile, value)
456
+ previous = claims[label]
457
+ if previous && previous.last != value
458
+ collisions << { label: label, first: previous, second: [profile, value] }
459
+ end
460
+ claims[label] = [profile, value]
461
+ end
462
+
463
+ def collision_report(collisions)
464
+ lines = ["Profile collision: the selected profiles " \
465
+ "(#{@profiles.join(', ')}) disagree.", '']
466
+
467
+ collisions.each do |c|
468
+ lines.concat(c[:text] ? [" #{c[:label]}: #{c[:text]}"] : disagreement_lines(c))
469
+ lines << ''
470
+ end
471
+
472
+ lines << 'Select fewer profiles, or make the conflicting values agree.'
473
+ lines.join("\n")
474
+ end
475
+
476
+ def disagreement_lines(collision)
477
+ first, second = collision.values_at(:first, :second)
478
+ width = [first.first.length, second.first.length].max
479
+
480
+ [" #{collision[:label]}"] + [first, second].map do |profile, value|
481
+ " #{profile.ljust(width)} sets #{format_collision_value(value)}"
482
+ end
483
+ end
484
+
485
+ def format_collision_value(value)
486
+ text = value.inspect
487
+ text.length > 70 ? "#{text[0, 67]}..." : text
488
+ end
489
+
490
+ # Strip window names that no selected profile included from layout groups
491
+ # so parse_layouts doesn't raise "window not found". A group whose panes
492
+ # list becomes empty is dropped from the layout entirely.
493
+ def prune_layouts!(raw, kept_names)
494
+ return unless raw['layouts'].is_a?(Hash)
495
+ kept = kept_names.to_set
496
+
497
+ raw['layouts'].each_value do |layout_def|
498
+ next unless layout_def.is_a?(Hash)
499
+ layout_def.reject! do |_group_name, group_def|
500
+ next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
501
+ group_def['panes'].select! { |pn| kept.include?(pn) }
502
+ group_def['panes'].empty?
503
+ end
504
+ end
505
+ end
506
+
507
+ # Under additive profiles an empty selection is a realistic mistake (a
508
+ # profile that declares no `windows:` runs nothing), so say so plainly
509
+ # instead of letting the reconciler fail on a session with zero windows.
510
+ def validate_windows_present!
511
+ return unless @windows.empty?
512
+
513
+ if @profiles.any? && !legacy_format?
514
+ raise ArgumentError,
515
+ "Profile(s) #{@profiles.join(', ')} include no windows — nothing " \
516
+ 'would run. Each profile lists the windows it runs under `windows:`.'
517
+ end
518
+
519
+ raise ArgumentError, 'Config declares no windows'
173
520
  end
174
521
 
175
522
  def parse_windows(hash)
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # The config format's history, one entry per major version, plus the renderer
5
+ # behind `mxup migrations`.
6
+ #
7
+ # A new config format generation ships with a new mxup major, and mxup goes on
8
+ # reading every older generation (see Config#resolve_format_major!) — so every
9
+ # major above the first has exactly one entry here describing what changed and
10
+ # how to move a config written for the previous one. When cutting a new major,
11
+ # add an entry and bump Config::CURRENT_FORMAT_MAJOR to match its `to`.
12
+ module Migrations
13
+ # `from`/`to` are majors. `changes` each have a title and, for readability
14
+ # at the terminal, pre-wrapped `before` / `now` / `fix` prose.
15
+ ENTRIES = [
16
+ {
17
+ from: 0,
18
+ to: 1,
19
+ changes: [
20
+ {
21
+ title: 'Profiles select windows additively instead of subtracting them',
22
+ before: 'Every window in `windows:` ran. A profile overrode some of ' \
23
+ 'them and dropped others with `~`, so `staging: {}` meant ' \
24
+ '"run everything, unchanged".',
25
+ now: '`windows:` is a catalog and nothing in it runs on its own. ' \
26
+ 'Each profile lists the windows it runs, and several profiles ' \
27
+ 'can be selected at once (`-p a,b`) — the live set is their ' \
28
+ 'union. A window no selected profile lists stays down.',
29
+ fix: 'In every profile, list the windows it should run: `name: {}` ' \
30
+ 'to include one as declared in the catalog, or a non-empty ' \
31
+ 'block to include it with overrides. A profile that listed ' \
32
+ 'nothing (`staging: {}`) must now name every window it wants, ' \
33
+ 'and a profile that dropped windows with `name: ~` should ' \
34
+ 'instead list the ones it keeps. `~` still parses, but it now ' \
35
+ 'means "veto": it collides if another selected profile ' \
36
+ 'includes that window.'
37
+ },
38
+ {
39
+ title: '`default_profile` may name several profiles',
40
+ before: 'A single profile name.',
41
+ now: 'A name or a list of names, selected together when ' \
42
+ '`--profile` is omitted.',
43
+ fix: 'Nothing to change — a single name still works.'
44
+ },
45
+ {
46
+ title: '`mxup_version` selects which format a config is read as',
47
+ before: 'The key did not exist.',
48
+ now: 'A config with no `mxup_version` is read as the 0.x format — ' \
49
+ 'profiles included — so it goes on running under mxup 1.x ' \
50
+ 'exactly as it did under 0.3.1. The key is what opts a ' \
51
+ 'config in to the format described above.',
52
+ fix: 'Nothing, to keep a 0.x config working. Set ' \
53
+ "`mxup_version: '1'` once you have migrated its profiles; " \
54
+ 'that is also what unlocks selecting several at once.'
55
+ }
56
+ ]
57
+ }
58
+ ].freeze
59
+
60
+ class << self
61
+ def render(out: $stdout)
62
+ out.puts "mxup #{VERSION} — config format changes by major version."
63
+ out.puts
64
+
65
+ ENTRIES.each { |entry| render_entry(entry, out) }
66
+
67
+ out.puts '`mxup_version` names the generation a config is written for, and'
68
+ out.puts 'mxup reads every generation up to its own — so a config with no'
69
+ out.puts '`mxup_version` keeps running as 0.x. Set it to the major you have'
70
+ out.puts 'migrated to.'
71
+ end
72
+
73
+ private
74
+
75
+ def render_entry(entry, out)
76
+ out.puts "#{entry[:from]}.x -> #{entry[:to]}.x"
77
+ out.puts
78
+
79
+ entry[:changes].each_with_index do |change, i|
80
+ out.puts " #{i + 1}. #{change[:title]}"
81
+ %i[before now fix].each do |key|
82
+ out.puts wrap(change[key], label: "#{key.to_s.capitalize}:")
83
+ end
84
+ out.puts
85
+ end
86
+ end
87
+
88
+ # Hanging-indent wrap so each paragraph's continuation lines line up under
89
+ # its text rather than under the label.
90
+ def wrap(text, label:, width: 76, indent: 5)
91
+ prefix = "#{' ' * indent}#{label.ljust(8)}"
92
+ continue = ' ' * prefix.length
93
+ lines = []
94
+
95
+ text.split(' ').each do |word|
96
+ if lines.empty?
97
+ lines << prefix + word
98
+ elsif (lines.last.length + 1 + word.length) > width
99
+ lines << continue + word
100
+ else
101
+ lines[-1] = "#{lines.last} #{word}"
102
+ end
103
+ end
104
+
105
+ lines.join("\n")
106
+ end
107
+ end
108
+ end
109
+ end
@@ -64,7 +64,7 @@ module Mxup
64
64
  order.drop(1).each { |entry| create_entry(entry) }
65
65
 
66
66
  Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout
67
- Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profile) if @config.profile
67
+ Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profiles_label) if @config.profiles.any?
68
68
  out.puts "Session #{@session} is up (#{@config.windows.size} windows)."
69
69
  end
70
70
 
@@ -113,7 +113,9 @@ module Mxup
113
113
 
114
114
  @layout_manager.reorder(layout) unless @dry_run
115
115
  Tmux.set_environment(@session, 'MXUP_LAYOUT', layout) if layout && !@dry_run
116
- Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profile) if @config.profile && !@dry_run
116
+ if @config.profiles.any? && !@dry_run
117
+ Tmux.set_environment(@session, 'MXUP_PROFILE', @config.profiles_label)
118
+ end
117
119
  out.puts 'Reconciliation complete.'
118
120
  end
119
121