yildun 0.1.0
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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +32 -0
- data/docs/adr/001-cell-rendering.md +16 -0
- data/docs/adr/002-shell-integration.md +16 -0
- data/exe/yildun +43 -0
- data/lib/yildun/app.rb +44 -0
- data/lib/yildun/console.rb +49 -0
- data/lib/yildun/data_compat.rb +11 -0
- data/lib/yildun/keymap.rb +13 -0
- data/lib/yildun/links.rb +19 -0
- data/lib/yildun/pane.rb +27 -0
- data/lib/yildun/profile.rb +12 -0
- data/lib/yildun/search.rb +8 -0
- data/lib/yildun/session.rb +20 -0
- data/lib/yildun/settings.rb +134 -0
- data/lib/yildun/tab.rb +13 -0
- data/lib/yildun/terminal.rb +77 -0
- data/lib/yildun/theme.rb +13 -0
- data/lib/yildun/version.rb +5 -0
- data/lib/yildun.rb +16 -0
- data/sig/yildun.rbs +86 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: afdbd196693c266f7c4edf951aa39d8c2e750f15bccf1d500a0c00c7e94ea748
|
|
4
|
+
data.tar.gz: 5489e541a3cfbd946bef1441a03a7638b33f17ea02cef8669169c17ca6a25df3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bd3d9e22dfb587805f15b3e72149ad9513bf5090b22eee491ee25fa1cf2091140ade6936f1199adb2e997f103d4f787c16d4488f9aa8ca84f180f10fa96672ac
|
|
7
|
+
data.tar.gz: 4d6e49b08f6527baa7ee4caa78d66389219abf3d95477064776c2e4b84ead079dfa8b7ce8deb61957d5ef4e5c6f54280eedfb8e99900d0497517f71ce34c0686
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Yildun
|
|
2
|
+
|
|
3
|
+
Yildun (δ Ursae Minoris, from Turkish *yıldız*, “star”) is a pure Ruby
|
|
4
|
+
terminal application shell backed by the Tarazed terminal emulator.
|
|
5
|
+
|
|
6
|
+
It provides profiles, tabs, pane state, keymaps, scrollback search, OSC 8
|
|
7
|
+
links, and a deterministic headless mode for integration tests. It does not
|
|
8
|
+
embed an SSH client, multiplexer, JavaScript engine, or WebView.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
gem install yildun
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Headless sessions
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
yildun --headless --command 'printf "hello\\n"'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Configuration is read from `$XDG_CONFIG_HOME/yildun/settings.jsonc` (or
|
|
23
|
+
`~/.config/yildun/settings.jsonc`). Settings are fail-safe: malformed files
|
|
24
|
+
raise a clear error instead of silently changing the shell profile.
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bundle install
|
|
30
|
+
bundle exec rake test
|
|
31
|
+
bundle exec rbs -I sig validate
|
|
32
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 001: Cell rendering stays outside the element tree
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Decision
|
|
8
|
+
|
|
9
|
+
Yildun keeps terminal cells in Tarazed's grid and exposes them to a dedicated
|
|
10
|
+
view adapter. It does not turn every cell into a general UI element.
|
|
11
|
+
|
|
12
|
+
## Consequences
|
|
13
|
+
|
|
14
|
+
The terminal can apply damage updates without rebuilding an element tree. A
|
|
15
|
+
future Zaniah window may consume the same grid while the headless executable
|
|
16
|
+
remains usable without a graphics backend.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 002: Shell integration is a first-class feature
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Decision
|
|
8
|
+
|
|
9
|
+
Yildun exposes Tarazed command records, failed-command filtering, cwd
|
|
10
|
+
tracking, and previous/next command navigation instead of treating the PTY as
|
|
11
|
+
an opaque byte stream.
|
|
12
|
+
|
|
13
|
+
## Consequences
|
|
14
|
+
|
|
15
|
+
The application can attach command navigation and output actions to a future
|
|
16
|
+
window without duplicating OSC 133 parsing.
|
data/exe/yildun
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "yildun"
|
|
6
|
+
|
|
7
|
+
options = {columns: 80, rows: 24, timeout: 0.2}
|
|
8
|
+
OptionParser.new do |parser|
|
|
9
|
+
parser.banner = "Usage: yildun [options]"
|
|
10
|
+
parser.on("--command COMMAND", "shell command") { |value| options[:command] = value }
|
|
11
|
+
parser.on("--columns N", Integer) { |value| options[:columns] = value }
|
|
12
|
+
parser.on("--rows N", Integer) { |value| options[:rows] = value }
|
|
13
|
+
parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
|
|
14
|
+
parser.on("--headless", "print the terminal screen and exit") { options[:headless] = true }
|
|
15
|
+
parser.on("--script PATH", "send script contents to the shell") { |value| options[:script] = value }
|
|
16
|
+
parser.on("--expect PATH", "compare the headless screen with a file") { |value| options[:expect] = value }
|
|
17
|
+
parser.on("--version") { puts Yildun::VERSION; exit }
|
|
18
|
+
end.parse!
|
|
19
|
+
|
|
20
|
+
settings = Yildun::Settings.load
|
|
21
|
+
profile = options[:command] ? Yildun::Profile.new("command", [options[:command]], {}, Dir.pwd) : Yildun.profile(settings)
|
|
22
|
+
begin
|
|
23
|
+
app = Yildun::App.new(settings: settings, profile: profile, columns: options[:columns], rows: options[:rows])
|
|
24
|
+
terminal = app.active_terminal
|
|
25
|
+
terminal.input(File.binread(options[:script])) if options[:script]
|
|
26
|
+
|
|
27
|
+
if options[:headless]
|
|
28
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + options[:timeout]
|
|
29
|
+
terminal.pump(timeout: 0.05) while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline && terminal.session.alive?
|
|
30
|
+
output = terminal.screen.text
|
|
31
|
+
if options[:expect]
|
|
32
|
+
expected = File.read(options[:expect]).strip
|
|
33
|
+
abort "headless output did not match #{options[:expect]}" unless output.strip == expected
|
|
34
|
+
end
|
|
35
|
+
puts output
|
|
36
|
+
elsif STDIN.tty? && STDOUT.tty?
|
|
37
|
+
Yildun::Console.new(terminal).run
|
|
38
|
+
else
|
|
39
|
+
warn "Yildun needs a TTY for interactive mode; use --headless for scripted sessions."
|
|
40
|
+
end
|
|
41
|
+
ensure
|
|
42
|
+
app&.active_tab&.close
|
|
43
|
+
end
|
data/lib/yildun/app.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
class App
|
|
5
|
+
attr_reader :settings, :tabs, :active_index
|
|
6
|
+
|
|
7
|
+
def initialize(settings: Settings.new, profile: nil, columns: 80, rows: 24)
|
|
8
|
+
@settings, @tabs, @active_index = settings, [], 0
|
|
9
|
+
open_tab(profile: profile, columns: columns, rows: rows)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def active_tab = @tabs.fetch(@active_index)
|
|
13
|
+
def active_terminal = active_tab.terminal
|
|
14
|
+
|
|
15
|
+
def open_tab(profile: nil, cwd: Dir.pwd, columns: 80, rows: 24)
|
|
16
|
+
profile ||= Yildun.profile(@settings)
|
|
17
|
+
terminal = Terminal.new(command: profile.command, env: profile.env, cwd: profile.cwd || cwd,
|
|
18
|
+
columns: columns, rows: rows, scrollback_limit: @settings.fetch("scrollback_limit"))
|
|
19
|
+
@tabs << Tab.new(terminal, title: profile.name)
|
|
20
|
+
@active_index = @tabs.length - 1
|
|
21
|
+
active_tab
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def open_tab_here(profile: nil, columns: 80, rows: 24)
|
|
25
|
+
open_tab(profile: profile, cwd: active_terminal.cwd, columns: columns, rows: rows)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close_active
|
|
29
|
+
active_tab.close
|
|
30
|
+
@tabs.delete_at(@active_index)
|
|
31
|
+
return @active_index = 0 if @tabs.empty?
|
|
32
|
+
|
|
33
|
+
@active_index = [@active_index, @tabs.length - 1].min
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def next_tab
|
|
37
|
+
@active_index = (@active_index + 1) % @tabs.length if @tabs.any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def previous_tab
|
|
41
|
+
@active_index = (@active_index - 1) % @tabs.length if @tabs.any?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Yildun
|
|
6
|
+
class Console
|
|
7
|
+
def initialize(terminal, input: $stdin, output: $stdout)
|
|
8
|
+
@terminal, @input, @output = terminal, input, output
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
return render unless @input.tty? && @output.tty? && @input.respond_to?(:raw)
|
|
13
|
+
|
|
14
|
+
@input.raw do
|
|
15
|
+
@output.write("\e[?25l")
|
|
16
|
+
loop do
|
|
17
|
+
resize
|
|
18
|
+
@terminal.pump(timeout: 0.02)
|
|
19
|
+
render
|
|
20
|
+
break unless @terminal.session.alive?
|
|
21
|
+
next unless IO.select([@input], nil, nil, 0.02)
|
|
22
|
+
|
|
23
|
+
@terminal.input(@input.read_nonblock(4096))
|
|
24
|
+
rescue IO::WaitReadable
|
|
25
|
+
retry
|
|
26
|
+
rescue EOFError, Errno::EIO
|
|
27
|
+
break
|
|
28
|
+
end
|
|
29
|
+
ensure
|
|
30
|
+
@output.write("\e[0m\e[?25h\e[H\e[2J")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render
|
|
35
|
+
@output.write("\e[H")
|
|
36
|
+
@terminal.screen.lines.each { |line| @output.write("\e[2K#{line}\r\n") }
|
|
37
|
+
@output.flush
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def resize
|
|
43
|
+
rows, columns = @output.winsize
|
|
44
|
+
@terminal.resize(columns: columns, rows: rows)
|
|
45
|
+
rescue SystemCallError, NoMethodError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
unless defined?(Data)
|
|
4
|
+
Data = Struct
|
|
5
|
+
def Data.define(*members)
|
|
6
|
+
Struct.new(*members) do
|
|
7
|
+
members.each { |member| undef_method("#{member}=") }
|
|
8
|
+
define_method(:initialize) { |*values| super(*values).freeze }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
class Keymap
|
|
5
|
+
def initialize(bindings = {})
|
|
6
|
+
@bindings = bindings.to_h.transform_keys(&:to_s).freeze
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def command(key) = @bindings[key.to_s]
|
|
10
|
+
def include?(key) = @bindings.key?(key.to_s)
|
|
11
|
+
def to_h = @bindings
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/yildun/links.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
module Links
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def at(screen, row, column, cwd: Dir.pwd)
|
|
8
|
+
screen.links(row).find { |link| column.between?(link[:column], link[:end_column] - 1) } ||
|
|
9
|
+
screen.file_paths(row, cwd: cwd).find { |link| column == link[:column] }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def open(url, opener: nil)
|
|
13
|
+
raise ArgumentError, "only http(s) and mailto links are allowed" unless url.to_s.match?(/\A(?:https?|mailto):/i)
|
|
14
|
+
return opener.call(url) if opener
|
|
15
|
+
command = RUBY_PLATFORM.match?(/darwin/) ? "open" : "xdg-open"
|
|
16
|
+
Process.spawn(command, url.to_s, out: File::NULL, err: File::NULL)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/yildun/pane.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
class Pane
|
|
5
|
+
attr_reader :children, :orientation
|
|
6
|
+
|
|
7
|
+
def initialize(tab = nil, orientation: :leaf)
|
|
8
|
+
@children, @orientation = tab ? [tab] : [], orientation
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def leaf? = @orientation == :leaf
|
|
12
|
+
def split(tab, orientation: :horizontal)
|
|
13
|
+
if leaf? && @children.empty?
|
|
14
|
+
@children << (tab.is_a?(Pane) ? tab : Pane.new(tab))
|
|
15
|
+
return self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if leaf?
|
|
19
|
+
existing = @children.map { |child| child.is_a?(Pane) ? child : Pane.new(child) }
|
|
20
|
+
@children = existing
|
|
21
|
+
end
|
|
22
|
+
@orientation = orientation
|
|
23
|
+
@children << (tab.is_a?(Pane) ? tab : Pane.new(tab))
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
Profile = Data.define(:name, :command, :env, :cwd)
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def profile(settings, name = settings.fetch("default_profile"))
|
|
9
|
+
value = settings.profile(name)
|
|
10
|
+
Profile.new(name.to_s, Array(value.fetch("command")), value.fetch("env", {}), value["cwd"])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Yildun
|
|
7
|
+
class SessionState
|
|
8
|
+
def self.save(path, tabs:)
|
|
9
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
10
|
+
File.write(path, JSON.pretty_generate("tabs" => tabs.map { |tab| {"title" => tab.title, "cwd" => tab.terminal.cwd} }))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.load(path)
|
|
14
|
+
return [] unless File.file?(path)
|
|
15
|
+
JSON.parse(File.read(path)).fetch("tabs", [])
|
|
16
|
+
rescue JSON::ParserError
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Yildun
|
|
6
|
+
class Settings
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
"font" => {"family" => "JetBrains Mono", "size" => 13, "ligatures" => true, "line_height" => 1.2},
|
|
9
|
+
"theme" => "tokyo-night",
|
|
10
|
+
"cursor" => {"shape" => "block", "blink" => true},
|
|
11
|
+
"background_opacity" => 1.0,
|
|
12
|
+
"scrollback_limit" => 10_000,
|
|
13
|
+
"shell_integration" => true,
|
|
14
|
+
"profiles" => {"default" => {"command" => [ENV.fetch("SHELL", "sh"), "-l"], "env" => {}}},
|
|
15
|
+
"default_profile" => "default",
|
|
16
|
+
"links" => {"file_line" => {"pattern" => "([\\w./-]+):(\\d+)", "action" => "open_editor"}},
|
|
17
|
+
"keymap" => {"cmd+t" => "tab.new", "cmd+d" => "pane.split_right", "cmd+f" => "search.open"}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :values
|
|
21
|
+
|
|
22
|
+
def initialize(values = {})
|
|
23
|
+
@values = deep_merge(DEFAULTS, stringify_keys(values)).freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](key) = @values.fetch(key.to_s)
|
|
27
|
+
def fetch(key, default = nil) = @values.fetch(key.to_s, default)
|
|
28
|
+
def profile(name = @values.fetch("default_profile")) = @values.fetch("profiles").fetch(name.to_s)
|
|
29
|
+
|
|
30
|
+
def self.load(path = default_path)
|
|
31
|
+
return new unless File.file?(path)
|
|
32
|
+
new(parse_jsonc(File.read(path, encoding: "UTF-8")))
|
|
33
|
+
rescue JSON::ParserError, EncodingError => error
|
|
34
|
+
raise ArgumentError, "invalid settings: #{error.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.default_path
|
|
38
|
+
root = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
39
|
+
File.join(root, "yildun", "settings.jsonc")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.parse_jsonc(text)
|
|
43
|
+
chars = text.to_s.chars
|
|
44
|
+
output = +""
|
|
45
|
+
quote = false
|
|
46
|
+
escaped = false
|
|
47
|
+
line_comment = false
|
|
48
|
+
block_comment = false
|
|
49
|
+
index = 0
|
|
50
|
+
while index < chars.length
|
|
51
|
+
char = chars[index]
|
|
52
|
+
if line_comment
|
|
53
|
+
line_comment = false if char == "\n"
|
|
54
|
+
output << char if char == "\n"
|
|
55
|
+
elsif block_comment
|
|
56
|
+
if char == "*" && chars[index + 1] == "/"
|
|
57
|
+
block_comment = false
|
|
58
|
+
index += 1
|
|
59
|
+
else
|
|
60
|
+
output << "\n" if char == "\n"
|
|
61
|
+
end
|
|
62
|
+
elsif quote
|
|
63
|
+
output << char
|
|
64
|
+
if escaped
|
|
65
|
+
escaped = false
|
|
66
|
+
elsif char == "\\"
|
|
67
|
+
escaped = true
|
|
68
|
+
elsif char == '"'
|
|
69
|
+
quote = false
|
|
70
|
+
end
|
|
71
|
+
elsif char == '"'
|
|
72
|
+
quote = true
|
|
73
|
+
output << char
|
|
74
|
+
elsif char == "/" && chars[index + 1] == "/"
|
|
75
|
+
line_comment = true
|
|
76
|
+
index += 1
|
|
77
|
+
elsif char == "/" && chars[index + 1] == "*"
|
|
78
|
+
block_comment = true
|
|
79
|
+
index += 1
|
|
80
|
+
else
|
|
81
|
+
output << char
|
|
82
|
+
end
|
|
83
|
+
index += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
JSON.parse(remove_trailing_commas(output))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.remove_trailing_commas(text)
|
|
90
|
+
chars = text.chars
|
|
91
|
+
output = +""
|
|
92
|
+
quote = false
|
|
93
|
+
escaped = false
|
|
94
|
+
chars.each_with_index do |char, index|
|
|
95
|
+
if quote
|
|
96
|
+
output << char
|
|
97
|
+
if escaped
|
|
98
|
+
escaped = false
|
|
99
|
+
elsif char == "\\"
|
|
100
|
+
escaped = true
|
|
101
|
+
elsif char == '"'
|
|
102
|
+
quote = false
|
|
103
|
+
end
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
if char == '"'
|
|
107
|
+
quote = true
|
|
108
|
+
output << char
|
|
109
|
+
elsif char == "," && chars[(index + 1)..].drop_while { |item| item.match?(/\s/) }.first.to_s.match?(/[}\]]/)
|
|
110
|
+
next
|
|
111
|
+
else
|
|
112
|
+
output << char
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
output
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def stringify_keys(value)
|
|
121
|
+
case value
|
|
122
|
+
when Hash then value.to_h { |key, item| [key.to_s, stringify_keys(item)] }
|
|
123
|
+
when Array then value.map { |item| stringify_keys(item) }
|
|
124
|
+
else value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def deep_merge(left, right)
|
|
129
|
+
left.merge(right) do |_key, old, value|
|
|
130
|
+
old.is_a?(Hash) && value.is_a?(Hash) ? deep_merge(old, value) : value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/yildun/tab.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
tarazed_path = ENV["TARAZED_PATH"]
|
|
4
|
+
tarazed_root = File.expand_path("..", __dir__)
|
|
5
|
+
tarazed_path ? require(File.expand_path("lib/tarazed", File.expand_path(tarazed_path, tarazed_root))) : require("tarazed")
|
|
6
|
+
|
|
7
|
+
module Yildun
|
|
8
|
+
class Terminal
|
|
9
|
+
attr_reader :session
|
|
10
|
+
|
|
11
|
+
def initialize(command:, env: {}, cwd: Dir.pwd, columns: 80, rows: 24, scrollback_limit: 10_000, session: nil)
|
|
12
|
+
@session = session || Tarazed::Session.new(command: command, env: env, cwd: cwd, columns: columns, rows: rows,
|
|
13
|
+
scrollback_limit: scrollback_limit)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def screen = @session.screen
|
|
17
|
+
def cwd = @session.cwd
|
|
18
|
+
def commands = @session.commands
|
|
19
|
+
def failed_commands = commands.select { |command| command.exit_status != 0 }
|
|
20
|
+
def input(bytes) = @session.input(bytes)
|
|
21
|
+
def paste(text) = @session.paste(text)
|
|
22
|
+
def key(name, **modifiers) = @session.key(name, **modifiers)
|
|
23
|
+
def mouse(**event) = @session.mouse(**event)
|
|
24
|
+
def pump(timeout: 0) = @session.pump(timeout: timeout)
|
|
25
|
+
def resize(columns:, rows:) = @session.resize(columns: columns, rows: rows)
|
|
26
|
+
def focus(active:) = @session.vt.focus(active: active)
|
|
27
|
+
def search(query, regex: false)
|
|
28
|
+
return screen.search(query, regex: regex) if screen.respond_to?(:search)
|
|
29
|
+
|
|
30
|
+
matcher = regex ? Regexp.new(query.to_s) : query.to_s
|
|
31
|
+
screen.lines.each_with_index.filter_map do |text, index|
|
|
32
|
+
match = regex ? matcher.match(text) : text.index(matcher)
|
|
33
|
+
next unless match
|
|
34
|
+
|
|
35
|
+
range = match.is_a?(MatchData) ? match.begin(0)...match.end(0) : match...(match + matcher.length)
|
|
36
|
+
{index: index, text: text, range: range}.freeze
|
|
37
|
+
end.freeze
|
|
38
|
+
rescue RegexpError => error
|
|
39
|
+
raise ArgumentError, "invalid search pattern: #{error.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def previous_command
|
|
43
|
+
@command_index = [(@command_index || commands.length) - 1, 0].max
|
|
44
|
+
commands[@command_index]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def next_command
|
|
48
|
+
@command_index = [(@command_index || -1) + 1, commands.length - 1].min
|
|
49
|
+
commands[@command_index]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def last_command = commands.last
|
|
53
|
+
|
|
54
|
+
def command_output(command)
|
|
55
|
+
range = command&.output_range
|
|
56
|
+
return "" unless range
|
|
57
|
+
|
|
58
|
+
screen.selection([0, range.begin], [screen.columns, range.end], history: true)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
alias copy_command_output command_output
|
|
62
|
+
|
|
63
|
+
def selection(start, finish, history: false) = screen.selection(start, finish, history: history)
|
|
64
|
+
|
|
65
|
+
def search_commands(query, regex: false)
|
|
66
|
+
matcher = regex ? Regexp.new(query.to_s) : query.to_s.downcase
|
|
67
|
+
commands.select do |command|
|
|
68
|
+
input = command.input.to_s
|
|
69
|
+
regex ? matcher.match?(input) : input.downcase.include?(matcher)
|
|
70
|
+
end
|
|
71
|
+
rescue RegexpError => error
|
|
72
|
+
raise ArgumentError, "invalid search pattern: #{error.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def close = @session.close
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/yildun/theme.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yildun
|
|
4
|
+
Theme = Data.define(:name, :background, :foreground, :cursor, :selection)
|
|
5
|
+
THEMES = {
|
|
6
|
+
"tokyo-night" => Theme.new("tokyo-night", "#1a1b26", "#c0caf5", "#c0caf5", "#33467c"),
|
|
7
|
+
"default" => Theme.new("default", "#111318", "#dddddd", "#ffffff", "#334455")
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def theme(name) = THEMES.fetch(name.to_s, THEMES.fetch("default"))
|
|
13
|
+
end
|
data/lib/yildun.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "yildun/version"
|
|
4
|
+
require_relative "yildun/data_compat"
|
|
5
|
+
require_relative "yildun/settings"
|
|
6
|
+
require_relative "yildun/keymap"
|
|
7
|
+
require_relative "yildun/theme"
|
|
8
|
+
require_relative "yildun/profile"
|
|
9
|
+
require_relative "yildun/search"
|
|
10
|
+
require_relative "yildun/links"
|
|
11
|
+
require_relative "yildun/terminal"
|
|
12
|
+
require_relative "yildun/console"
|
|
13
|
+
require_relative "yildun/tab"
|
|
14
|
+
require_relative "yildun/pane"
|
|
15
|
+
require_relative "yildun/session"
|
|
16
|
+
require_relative "yildun/app"
|
data/sig/yildun.rbs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Yildun
|
|
2
|
+
VERSION: String
|
|
3
|
+
class Settings
|
|
4
|
+
attr_reader values: Hash[String, untyped]
|
|
5
|
+
def initialize: (?Hash[untyped, untyped] values) -> void
|
|
6
|
+
def []: (String key) -> untyped
|
|
7
|
+
def fetch: (String key, ?untyped default) -> untyped
|
|
8
|
+
def profile: (?String name) -> Hash[String, untyped]
|
|
9
|
+
def self.load: (?String path) -> Settings
|
|
10
|
+
def self.default_path: () -> String
|
|
11
|
+
def self.parse_jsonc: (String text) -> Hash[untyped, untyped]
|
|
12
|
+
end
|
|
13
|
+
class Keymap
|
|
14
|
+
def initialize: (?Hash[untyped, untyped] bindings) -> void
|
|
15
|
+
def command: (String key) -> String?
|
|
16
|
+
def include?: (String key) -> bool
|
|
17
|
+
end
|
|
18
|
+
class Terminal
|
|
19
|
+
attr_reader session: untyped
|
|
20
|
+
def initialize: (command: String | Array[String], ?env: Hash[String, String], ?cwd: String, ?columns: Integer, ?rows: Integer, ?scrollback_limit: Integer, ?session: untyped) -> void
|
|
21
|
+
def screen: () -> untyped
|
|
22
|
+
def cwd: () -> String
|
|
23
|
+
def commands: () -> Array[untyped]
|
|
24
|
+
def failed_commands: () -> Array[untyped]
|
|
25
|
+
def input: (String bytes) -> untyped
|
|
26
|
+
def paste: (String text) -> untyped
|
|
27
|
+
def key: (String name, **bool modifiers) -> untyped
|
|
28
|
+
def mouse: (**untyped event) -> untyped
|
|
29
|
+
def pump: (?timeout: Numeric) -> bool
|
|
30
|
+
def resize: (columns: Integer, rows: Integer) -> untyped
|
|
31
|
+
def focus: (active: bool) -> String
|
|
32
|
+
def search: (String query, ?regex: bool) -> Array[Hash[Symbol, untyped]]
|
|
33
|
+
def previous_command: () -> untyped
|
|
34
|
+
def next_command: () -> untyped
|
|
35
|
+
def last_command: () -> untyped
|
|
36
|
+
def command_output: (untyped command) -> String
|
|
37
|
+
alias copy_command_output command_output
|
|
38
|
+
def selection: (Array[Integer] start, Array[Integer] finish, ?history: bool) -> String
|
|
39
|
+
def search_commands: (String query, ?regex: bool) -> Array[untyped]
|
|
40
|
+
def close: () -> untyped
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class Console
|
|
44
|
+
def initialize: (untyped terminal, ?input: untyped, ?output: untyped) -> void
|
|
45
|
+
def run: () -> untyped
|
|
46
|
+
def render: () -> untyped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class Pane
|
|
50
|
+
attr_reader children: Array[untyped]
|
|
51
|
+
attr_reader orientation: Symbol
|
|
52
|
+
def initialize: (?untyped tab, ?orientation: Symbol) -> void
|
|
53
|
+
def leaf?: () -> bool
|
|
54
|
+
def split: (untyped tab, ?orientation: Symbol) -> Pane
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Search
|
|
58
|
+
def initialize: (untyped terminal) -> void
|
|
59
|
+
def call: (String query, ?regex: bool) -> untyped
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module Links
|
|
63
|
+
def self.at: (untyped screen, Integer row, Integer column, ?cwd: String) -> Hash[Symbol, untyped]?
|
|
64
|
+
def self.open: (String url, ?opener: ^(String) -> untyped) -> untyped
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class SessionState
|
|
68
|
+
def self.save: (String path, tabs: Array[untyped]) -> untyped
|
|
69
|
+
def self.load: (String path) -> Array[Hash[String, untyped]]
|
|
70
|
+
end
|
|
71
|
+
class Tab
|
|
72
|
+
attr_reader title: String
|
|
73
|
+
attr_reader terminal: Terminal
|
|
74
|
+
end
|
|
75
|
+
class App
|
|
76
|
+
attr_reader settings: Settings
|
|
77
|
+
attr_reader tabs: Array[Tab]
|
|
78
|
+
attr_reader active_index: Integer
|
|
79
|
+
def initialize: (?settings: Settings, ?profile: untyped, ?columns: Integer, ?rows: Integer) -> void
|
|
80
|
+
def active_tab: () -> Tab
|
|
81
|
+
def active_terminal: () -> Terminal
|
|
82
|
+
def open_tab: (?profile: untyped, ?cwd: String, ?columns: Integer, ?rows: Integer) -> Tab
|
|
83
|
+
def open_tab_here: (?profile: untyped, ?columns: Integer, ?rows: Integer) -> Tab
|
|
84
|
+
def close_active: () -> untyped
|
|
85
|
+
end
|
|
86
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yildun
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
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: tarazed
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.6
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '0.3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.2.6
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '0.3'
|
|
32
|
+
description: A terminal application shell with tabs, profiles, search, links, and
|
|
33
|
+
Tarazed-backed sessions.
|
|
34
|
+
email:
|
|
35
|
+
- t.yudai92@gmail.com
|
|
36
|
+
executables:
|
|
37
|
+
- yildun
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- CHANGELOG.md
|
|
42
|
+
- LICENSE.txt
|
|
43
|
+
- README.md
|
|
44
|
+
- docs/adr/001-cell-rendering.md
|
|
45
|
+
- docs/adr/002-shell-integration.md
|
|
46
|
+
- exe/yildun
|
|
47
|
+
- lib/yildun.rb
|
|
48
|
+
- lib/yildun/app.rb
|
|
49
|
+
- lib/yildun/console.rb
|
|
50
|
+
- lib/yildun/data_compat.rb
|
|
51
|
+
- lib/yildun/keymap.rb
|
|
52
|
+
- lib/yildun/links.rb
|
|
53
|
+
- lib/yildun/pane.rb
|
|
54
|
+
- lib/yildun/profile.rb
|
|
55
|
+
- lib/yildun/search.rb
|
|
56
|
+
- lib/yildun/session.rb
|
|
57
|
+
- lib/yildun/settings.rb
|
|
58
|
+
- lib/yildun/tab.rb
|
|
59
|
+
- lib/yildun/terminal.rb
|
|
60
|
+
- lib/yildun/theme.rb
|
|
61
|
+
- lib/yildun/version.rb
|
|
62
|
+
- sig/yildun.rbs
|
|
63
|
+
homepage: https://github.com/noxdea/yildun
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
source_code_uri: https://github.com/noxdea/yildun
|
|
68
|
+
changelog_uri: https://github.com/noxdea/yildun/blob/main/CHANGELOG.md
|
|
69
|
+
allowed_push_host: https://rubygems.org
|
|
70
|
+
rubygems_mfa_required: 'true'
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '3.1'
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 4.0.16
|
|
86
|
+
specification_version: 4
|
|
87
|
+
summary: A pure Ruby terminal application built on Tarazed
|
|
88
|
+
test_files: []
|