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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/libtmux-workspace +6 -0
- data/lib/libtmux/workspace/apply.rb +235 -0
- data/lib/libtmux/workspace/cli.rb +185 -0
- data/lib/libtmux/workspace/document.rb +195 -0
- data/lib/libtmux/workspace/normalizer.rb +281 -0
- data/lib/libtmux/workspace/plan.rb +123 -0
- data/lib/libtmux/workspace/version.rb +7 -0
- data/lib/libtmux/workspace.rb +83 -0
- data/sig/libtmux-workspace.rbs +79 -0
- metadata +94 -0
|
@@ -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,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: []
|