libtmux-workspace 0.1.0.alpha.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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Workspace
5
+ class Plan
6
+ Step = Data.define(:id, :operation, :target, :arguments, :produces, :effect) do
7
+ def inspect
8
+ "#<#{self.class} id=#{id} operation=#{operation} effect=#{effect}>"
9
+ end
10
+ end
11
+ attr_reader :mode, :preconditions, :steps
12
+
13
+ def initialize(workspace, snapshot: nil)
14
+ unless snapshot.nil? || snapshot.is_a?(LibTmux::Snapshot)
15
+ raise ArgumentError, "workspace planning requires a captured Snapshot"
16
+ end
17
+ @configuration = workspace.to_h
18
+ name = @configuration.fetch("session_name")
19
+ if snapshot && snapshot.sessions.where(name: name).exists?
20
+ raise ConflictError.new("workspace creation requires an absent session name", phase: :plan)
21
+ end
22
+ @mode = snapshot ? :captured_create : :offline_create
23
+ @preconditions = immutable({"session_name_absent" => name,
24
+ "binding_key" => snapshot&.binding_key, "capture_id" => snapshot&.capture_id})
25
+ @steps = []
26
+ build
27
+ @steps.freeze
28
+ freeze
29
+ end
30
+
31
+ def to_h
32
+ {"profile" => PROFILE, "version" => CONFIG_VERSION, "mode" => mode.to_s,
33
+ "preconditions" => preconditions,
34
+ "steps" => steps.map do |step|
35
+ {"id" => step.id, "operation" => step.operation.to_s, "target" => step.target,
36
+ "arguments" => step.arguments, "produces" => step.produces, "effect" => step.effect.to_s}
37
+ end}
38
+ end
39
+
40
+ def inspect
41
+ "#<#{self.class} mode=#{mode} steps=#{steps.length}>"
42
+ end
43
+
44
+ def apply(server:, timeout: 5.0, cancel: nil, compensate: false)
45
+ ApplyExecution.new(self, server, timeout, cancel, compensate).call
46
+ end
47
+
48
+ private
49
+
50
+ def build
51
+ windows = @configuration.fetch("windows")
52
+ initial = windows.first.fetch("panes").first
53
+ add(:create_session, "session", {"name" => @configuration.fetch("session_name"),
54
+ "window_name" => windows.first.fetch("window_name"), "cwd" => initial.fetch("start_directory"),
55
+ "session_cwd" => @configuration.fetch("start_directory"),
56
+ "environment" => @configuration.fetch("environment"), "pane_environment" => initial.fetch("environment")},
57
+ produces: ["session", "window:0", "pane:0:0"], effect: :creation)
58
+ add(:set_session_option, "session", {"name" => "renumber-windows", "value" => false})
59
+ add(:set_window_option, "window:0", {"name" => "synchronize-panes", "value" => false})
60
+ add(:move_initial_window, "window:0", {"session" => "session", "index" => windows.first.fetch("window_index")})
61
+ @configuration.fetch("options").sort.each do |name, value|
62
+ add(:set_session_option, "session", {"name" => name, "value" => value}) unless name == "renumber-windows"
63
+ end
64
+ windows.each_with_index do |window, position|
65
+ unless position.zero?
66
+ pane = window.fetch("panes").first
67
+ add(:create_window, "session", {"name" => window.fetch("window_name"), "index" => window.fetch("window_index"),
68
+ "cwd" => pane.fetch("start_directory"), "environment" => pane.fetch("environment"), "focus" => false},
69
+ produces: ["window:#{position}", "pane:#{position}:0"], effect: :creation)
70
+ add(:set_window_option, "window:#{position}", {"name" => "synchronize-panes", "value" => false})
71
+ end
72
+ window.fetch("panes").each_with_index do |pane, pane_position|
73
+ next if pane_position.zero?
74
+ add(:split_pane, "pane:#{position}:#{pane_position - 1}",
75
+ {"direction" => pane.fetch("split"), "size" => pane["size"], "cwd" => pane.fetch("start_directory"),
76
+ "environment" => pane.fetch("environment"), "focus" => false},
77
+ produces: ["pane:#{position}:#{pane_position}"], effect: :creation)
78
+ end
79
+ end
80
+ windows.each_with_index do |window, position|
81
+ window.fetch("options").sort.each do |name, value|
82
+ next if name == "synchronize-panes"
83
+ add(:set_window_option, "window:#{position}", {"name" => name, "value" => value})
84
+ end
85
+ add(:select_layout, "window:#{position}", {"layout" => window.fetch("layout")}) if window["layout"]
86
+ window.fetch("panes").each_with_index do |pane, pane_position|
87
+ (pane.fetch("shell_command_before") + pane.fetch("shell_command")).each do |command|
88
+ add(:send_command, "pane:#{position}:#{pane_position}", {"command" => command}, effect: :dispatch_only)
89
+ end
90
+ end
91
+ if window.fetch("options").key?("synchronize-panes")
92
+ add(:set_window_option, "window:#{position}", {"name" => "synchronize-panes", "value" => window.fetch("options").fetch("synchronize-panes")})
93
+ else
94
+ add(:unset_window_option, "window:#{position}", {"name" => "synchronize-panes"})
95
+ end
96
+ pane_focus = window.fetch("panes").index { |pane| pane.fetch("focus") }
97
+ add(:select_pane, "pane:#{position}:#{pane_focus}", {})
98
+ end
99
+ selected = windows.index { |window| window.fetch("focus") }
100
+ add(:select_window, "window:#{selected}", {"session" => "session"})
101
+ if @configuration.fetch("options").key?("renumber-windows")
102
+ add(:set_session_option, "session", {"name" => "renumber-windows", "value" => @configuration.fetch("options").fetch("renumber-windows")})
103
+ else
104
+ add(:unset_session_option, "session", {"name" => "renumber-windows"})
105
+ end
106
+ end
107
+
108
+ def add(operation, target, arguments, produces: [], effect: :configuration)
109
+ @steps << Step.new(id: @steps.length + 1, operation: operation, target: target.freeze,
110
+ arguments: immutable(arguments), produces: immutable(produces), effect: effect)
111
+ end
112
+
113
+ def immutable(value)
114
+ case value
115
+ when Hash then value.to_h { |key, child| [key.dup.freeze, immutable(child)] }.freeze
116
+ when Array then value.map { |child| immutable(child) }.freeze
117
+ when String then value.dup.freeze
118
+ else value
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Workspace
5
+ VERSION = "0.1.0.alpha.1"
6
+ end
7
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux"
4
+ require "json"
5
+ require "psych"
6
+ require "libtmux/workspace/version"
7
+
8
+ module LibTmux
9
+ class Workspace
10
+ PROFILE = "libtmux-ruby.workspace"
11
+ CONFIG_VERSION = 1
12
+ LIMITS = {max_bytes: 1 << 20, max_depth: 32, max_nodes: 10_000,
13
+ max_string_bytes: 1 << 16, max_windows: 128, max_panes: 1024}.freeze
14
+ private_constant :LIMITS
15
+
16
+ class ConfigError < LibTmux::Error
17
+ def initialize(message = "invalid workspace configuration", path: "$", expected: nil)
18
+ super("#{message} at #{path}#{expected ? "; expected #{expected}" : ''}", path: path, expected: expected, phase: :workspace)
19
+ end
20
+ end
21
+ class ConflictError < LibTmux::Error; end
22
+
23
+ def self.load(path, format: nil, **options)
24
+ unless path.is_a?(String) && !path.empty? && !path.include?("\0")
25
+ raise ArgumentError, "workspace path must be a nonempty String without NUL"
26
+ end
27
+ filename = File.expand_path(path)
28
+ format ||= case File.extname(filename).downcase
29
+ when ".json" then :json
30
+ when ".yaml", ".yml" then :yaml
31
+ else raise ConfigError.new("workspace file format is unknown", expected: "JSON/YAML extension or explicit format")
32
+ end
33
+ limit = options.fetch(:max_bytes, LIMITS.fetch(:max_bytes))
34
+ raise ArgumentError, "workspace byte limit must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
35
+
36
+ bytes = File.open(filename, File::RDONLY | File::NONBLOCK) do |file|
37
+ raise ConfigError.new("invalid workspace file", expected: "regular configuration file") unless file.stat.file?
38
+ file.binmode
39
+ file.read(limit + 1) || ""
40
+ end
41
+ parse(bytes, format: format, base_directory: File.dirname(filename), **options)
42
+ rescue SystemCallError
43
+ raise ConfigError.new("workspace file could not be read", expected: "readable configuration file"), cause: nil
44
+ end
45
+
46
+ def self.parse(bytes, format:, base_directory:, expand_environment: false, environment: {}, **limits)
47
+ unless (limits.keys - LIMITS.keys).empty? && limits.values.all? { |value| value.is_a?(Integer) && value.positive? }
48
+ raise ArgumentError, "workspace limits must be known positive integers"
49
+ end
50
+ bounds = LIMITS.merge(limits).freeze
51
+ document = Document.new(bounds).parse(bytes, format)
52
+ config = Normalizer.new(base_directory, expand_environment, environment, bounds).normalize(document)
53
+ if JSON.generate(config).bytesize > bounds.fetch(:max_bytes)
54
+ raise ConfigError.new("normalized workspace exceeds its wire byte limit", expected: "bounded canonical JSON")
55
+ end
56
+ Document.new(bounds).validate_tree(config)
57
+ new(config)
58
+ end
59
+
60
+ def initialize(config)
61
+ @config = config
62
+ freeze
63
+ end
64
+ private_class_method :new
65
+
66
+ def to_h
67
+ @config
68
+ end
69
+
70
+ def plan(snapshot: nil)
71
+ Plan.new(self, snapshot: snapshot)
72
+ end
73
+
74
+ def inspect
75
+ "#<#{self.class} profile=#{PROFILE} version=#{CONFIG_VERSION} windows=#{@config.fetch('windows').length}>"
76
+ end
77
+ end
78
+ end
79
+
80
+ require "libtmux/workspace/document"
81
+ require "libtmux/workspace/normalizer"
82
+ require "libtmux/workspace/plan"
83
+ require "libtmux/workspace/apply"
@@ -0,0 +1,79 @@
1
+ module LibTmux
2
+ class Workspace
3
+ private def self.new: (*untyped, **untyped) -> instance
4
+ VERSION: String
5
+ PROFILE: String
6
+ CONFIG_VERSION: Integer
7
+
8
+ class ConfigError < LibTmux::Error
9
+ def initialize: (?String, ?path: String, ?expected: String?) -> void
10
+ end
11
+ class ConflictError < LibTmux::Error
12
+ end
13
+ class ApplyError < LibTmux::Error
14
+ attr_reader result: ApplyResult
15
+ attr_reader failure_class: String
16
+ def initialize: (ApplyResult, Exception) -> void
17
+ end
18
+
19
+ class ApplyResult
20
+ private def self.new: (*untyped, **untyped) -> instance
21
+ class Effect < Data
22
+ def self.new: (Integer?, Symbol, :observed | :dispatch_only) -> instance
23
+ | (step_id: Integer?, action: Symbol, outcome: :observed | :dispatch_only) -> instance
24
+ alias self.[] self.new
25
+ def self.members: () -> Array[Symbol]
26
+ attr_reader step_id: Integer?
27
+ attr_reader action: Symbol
28
+ attr_reader outcome: :observed | :dispatch_only
29
+ end
30
+ attr_reader completed_steps: Array[Integer]
31
+ attr_reader created_refs: Hash[String, LibTmux::EntityRef]
32
+ attr_reader effects: Array[Effect]
33
+ attr_reader failed_step: Integer?
34
+ attr_reader failed_action: Symbol?
35
+ attr_reader compensation: :not_requested | :nothing_owned | :completed | :failed
36
+ attr_reader cleanup_errors: Array[String]
37
+ def success?: () -> bool
38
+ def uncertain?: () -> bool
39
+ def to_h: () -> Hash[String, untyped]
40
+ def inspect: () -> String
41
+ end
42
+
43
+ class CLI
44
+ private def self.new: (*untyped, **untyped) -> instance
45
+ def run: () -> Integer
46
+ def self.run: (Array[String], ?out: untyped, ?err: untyped, ?directory: String, ?environment: untyped) -> Integer
47
+ end
48
+
49
+ def self.load: (String, ?format: :yaml | :json | nil, ?expand_environment: bool, ?environment: Hash[String, String], **Integer) -> Workspace
50
+ def self.parse: (String, format: :yaml | :json, base_directory: String, ?expand_environment: bool, ?environment: Hash[String, String], **Integer) -> Workspace
51
+ def to_h: () -> Hash[String, untyped]
52
+ def plan: (?snapshot: LibTmux::Snapshot?) -> Plan
53
+ def inspect: () -> String
54
+
55
+ class Plan
56
+ class Step < Data
57
+ def self.new: (Integer, Symbol, String, Hash[String, untyped], Array[String], Symbol) -> instance
58
+ | (id: Integer, operation: Symbol, target: String, arguments: Hash[String, untyped], produces: Array[String], effect: Symbol) -> instance
59
+ alias self.[] self.new
60
+ def self.members: () -> Array[Symbol]
61
+ attr_reader id: Integer
62
+ attr_reader operation: Symbol
63
+ attr_reader target: String
64
+ attr_reader arguments: Hash[String, untyped]
65
+ attr_reader produces: Array[String]
66
+ attr_reader effect: Symbol
67
+ def inspect: () -> String
68
+ end
69
+
70
+ attr_reader mode: :offline_create | :captured_create
71
+ attr_reader preconditions: Hash[String, String?]
72
+ attr_reader steps: Array[Step]
73
+ def initialize: (Workspace, ?snapshot: LibTmux::Snapshot?) -> void
74
+ def to_h: () -> Hash[String, untyped]
75
+ def apply: (server: LibTmux::Server, ?timeout: Numeric, ?cancel: untyped, ?compensate: bool) -> ApplyResult
76
+ def inspect: () -> String
77
+ end
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libtmux-workspace
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha.1
5
+ platform: ruby
6
+ authors:
7
+ - libtmux contributors
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: libtmux
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0.alpha.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0.alpha.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 3.0.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 3.0.2
40
+ - !ruby/object:Gem::Dependency
41
+ name: psych
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 5.5.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 5.5.0
54
+ description: Validate YAML or JSON workspace configurations, inspect creation plans
55
+ and apply them to tmux.
56
+ executables:
57
+ - libtmux-workspace
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - exe/libtmux-workspace
64
+ - lib/libtmux/workspace.rb
65
+ - lib/libtmux/workspace/apply.rb
66
+ - lib/libtmux/workspace/cli.rb
67
+ - lib/libtmux/workspace/document.rb
68
+ - lib/libtmux/workspace/normalizer.rb
69
+ - lib/libtmux/workspace/plan.rb
70
+ - lib/libtmux/workspace/version.rb
71
+ - sig/libtmux-workspace.rbs
72
+ homepage: https://github.com/libtmux/libtmux-ruby
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ source_code_uri: https://github.com/libtmux/libtmux-ruby
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '3.3'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.20
92
+ specification_version: 4
93
+ summary: Data-only workspace consumer package for libtmux
94
+ test_files: []