izar 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: adbd281a78d48b4fae7001785c8df64cd4f933e081640edf72b41b1415d86b69
4
+ data.tar.gz: 42ff12350941b4c5667cac792f575bb640e11a4ab3daaa2fa60a34deed7bd7a9
5
+ SHA512:
6
+ metadata.gz: 9213ccc523c8b98d3ef4364e63303ee3c3f6c297e52828992d4a9fb6b5909e688a30650ec3cdfb7571c24d823392451636ebe33b52dc925c5165fcb19dd6f3dc
7
+ data.tar.gz: b34709d071003f1fb7bb9dcbc1677a69ab13ae0042f99b0145b7dd296ddc26b4eaabb619247223864432b7ba0e3d177ed6dcc263a365d6983677522f25d3258b
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-20
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Izar
2
+
3
+ Pure Ruby Git staging workspace built on Thuban and Porrima. It shows staged,
4
+ unstaged, and untracked files, computes safe hunks, and keeps destructive
5
+ discard operations behind an explicit confirmation flag.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ gem install izar
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Run inside a Git repository:
16
+
17
+ ```sh
18
+ izar status
19
+ izar stage lib/example.rb
20
+ izar unstage lib/example.rb
21
+ izar commit -m "Describe the change"
22
+ izar discard tmp.txt --yes
23
+ ```
24
+
25
+ The Ruby API is intentionally small:
26
+
27
+ ```ruby
28
+ repo = Izar::Repository.new
29
+ repo.status
30
+ repo.diff("README.md").staged_to_worktree
31
+ ```
32
+
33
+ Izar does not invoke the `git` executable.
34
+
35
+ ## Development
36
+
37
+ Run `rake spec` and `gem build --strict izar.gemspec`.
38
+
39
+ ## Contributing
40
+
41
+ Bug reports and pull requests are welcome at https://github.com/noxdea/izar.
42
+
43
+ ## License
44
+
45
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/exe/izar ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+ require "izar"
6
+ exit Izar::CLI.run(ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Izar
4
+ VERSION = "0.1.0"
5
+ end
data/lib/izar.rb ADDED
@@ -0,0 +1,666 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+ require "thuban"
6
+ require "porrima"
7
+ begin
8
+ require "kochab"
9
+ rescue LoadError
10
+ end
11
+ begin
12
+ require "spica"
13
+ rescue LoadError
14
+ end
15
+ begin
16
+ require "zaniah"
17
+ require "zaniah/ui"
18
+ rescue LoadError
19
+ end
20
+ begin
21
+ require "auva"
22
+ rescue LoadError
23
+ end
24
+ require_relative "izar/version"
25
+
26
+ module Izar
27
+ class Error < StandardError; end
28
+
29
+ class Operation
30
+ attr_reader :error
31
+
32
+ def initialize(&block)
33
+ @status = :running
34
+ @thread = Thread.new do
35
+ block.call
36
+ rescue StandardError => error
37
+ @error = error
38
+ ensure
39
+ @status = :done
40
+ end
41
+ end
42
+
43
+ def running? = @status == :running
44
+ def done? = @status == :done
45
+ end
46
+
47
+ Change = Data.define(:path, :code, :index, :worktree) do
48
+ def staged? = index != " " && index != "?"
49
+ def untracked? = index == "?"
50
+ def unstaged? = worktree != " "
51
+ end
52
+
53
+ class Repository
54
+ attr_reader :repo
55
+
56
+ def initialize(dir = Dir.pwd)
57
+ @repo = Thuban::Repository.new(dir)
58
+ rescue ArgumentError => error
59
+ raise Error, error.message
60
+ end
61
+
62
+ def root = repo.root
63
+ def branch = repo.branch || "(detached)"
64
+ def head = repo.head
65
+
66
+ def ahead_behind
67
+ return {ahead: 0, behind: 0} unless repo.branch && head
68
+ upstream = repo.resolve("refs/remotes/origin/#{repo.branch}")
69
+ return {ahead: 0, behind: 0} unless upstream
70
+ base = repo.merge_base(head, upstream)
71
+ {ahead: count_until(head, base), behind: count_until(upstream, base)}
72
+ rescue StandardError
73
+ {ahead: 0, behind: 0}
74
+ end
75
+
76
+ def status
77
+ repo.status.map { |entry| Change.new(path: entry.path, code: entry.code, index: entry.index, worktree: entry.worktree) }
78
+ rescue StandardError => error
79
+ raise Error, error.message
80
+ end
81
+
82
+ def grouped(filter: nil)
83
+ changes = status
84
+ if filter && !filter.empty?
85
+ candidates = changes.map(&:path)
86
+ matches = defined?(Spica) ? Spica.filter(filter, candidates).map(&:candidate) : candidates.select { |path| path.include?(filter) }
87
+ changes = changes.select { |change| matches.include?(change.path) }
88
+ end
89
+ {
90
+ staged: changes.select(&:staged?),
91
+ unstaged: changes.select { |change| change.unstaged? && !change.untracked? },
92
+ untracked: changes.select(&:untracked?)
93
+ }
94
+ end
95
+
96
+ def stage(path)
97
+ content = repo.worktree_content(path)
98
+ if content
99
+ stage_content(path, content)
100
+ else
101
+ index = repo.index
102
+ index.remove(path)
103
+ index.write
104
+ end
105
+ path
106
+ rescue StandardError => error
107
+ raise Error, error.message
108
+ end
109
+
110
+ def stage_content(path, content)
111
+ index = repo.index
112
+ entry = index[path]
113
+ stat = File.stat(repo.worktree_path(path)) rescue nil
114
+ mode = entry&.mode || (stat && (stat.mode & 0o100).positive? ? 0o100755 : 0o100644)
115
+ index.stage(path, repo.write_blob(content), mode, stat: stat)
116
+ index.write
117
+ path
118
+ end
119
+
120
+ def unstage(path)
121
+ index = repo.index
122
+ head_content = repo.blob(path)
123
+ if head_content
124
+ entry = repo.tree.fetch(path)
125
+ stat = File.stat(repo.worktree_path(path)) rescue nil
126
+ index.stage(path, entry.oid, entry.mode, stat: stat)
127
+ else
128
+ index.remove(path)
129
+ end
130
+ index.write
131
+ path
132
+ rescue StandardError => error
133
+ raise Error, error.message
134
+ end
135
+
136
+ def discard(path, confirm: false)
137
+ raise Error, "discard requires confirmation" unless confirm
138
+ backup = repo.stash_push(message: "izar discard #{path}", include_untracked: true)
139
+ repo.stash_pop if backup
140
+ head_content = repo.blob(path)
141
+ absolute = repo.worktree_path(path)
142
+ if head_content
143
+ FileUtils.mkdir_p(File.dirname(absolute))
144
+ File.binwrite(absolute, head_content)
145
+ entry = repo.tree.fetch(path)
146
+ File.chmod(entry.mode & 0o777, absolute) unless entry.mode == 0o120000
147
+ index = repo.index
148
+ index.stage(path, entry.oid, entry.mode, stat: File.stat(absolute))
149
+ index.write
150
+ else
151
+ File.unlink(absolute) if File.file?(absolute) || File.symlink?(absolute)
152
+ index = repo.index
153
+ index.remove(path)
154
+ index.write
155
+ end
156
+ {path: path, backup: backup, message: backup ? "temporary stash #{backup} was restored before discarding #{path}" : "discarded #{path}"}
157
+ rescue StandardError => error
158
+ raise Error, error.message
159
+ end
160
+
161
+ def commit(message)
162
+ message = message.to_s.strip
163
+ raise Error, "commit message cannot be empty" if message.empty?
164
+ raise Error, "nothing staged" unless status.any?(&:staged?)
165
+ repo.commit!(message: message, author: repo.signature(role: :author), committer: repo.signature(role: :committer))
166
+ rescue StandardError => error
167
+ raise Error, error.message
168
+ end
169
+
170
+ def diff(path, context: 3) = Diff.new(self, path, context: context)
171
+
172
+ def stage_all
173
+ status.each { |change| stage(change.path) unless change.staged? && !change.unstaged? }
174
+ self
175
+ end
176
+
177
+ private
178
+
179
+ def count_until(reference, stop)
180
+ repo.each_commit(reference, limit: 100_000).take_while { |commit| commit.oid != stop }.length
181
+ end
182
+ end
183
+
184
+ class Diff
185
+ attr_reader :repository, :path, :context
186
+
187
+ def initialize(repository, path, context: 3)
188
+ @repository, @path, @context = repository, path, context
189
+ end
190
+
191
+ def staged_to_worktree
192
+ Porrima.diff(repository.repo.staged_blob(path).to_s, repository.repo.worktree_content(path).to_s, context: context).hunks
193
+ end
194
+
195
+ def head_to_staged
196
+ Porrima.diff(repository.repo.blob(path).to_s, repository.repo.staged_blob(path).to_s, context: context).hunks
197
+ end
198
+
199
+ def stage_hunk(hunk)
200
+ before = repository.repo.staged_blob(path).to_s
201
+ after = Porrima.apply(before, hunk)
202
+ repository.stage_content(path, after)
203
+ after
204
+ rescue StandardError => error
205
+ raise Error, "cannot stage hunk: #{error.message}"
206
+ end
207
+
208
+ def unstage_hunk(hunk)
209
+ before = repository.repo.staged_blob(path).to_s
210
+ after = Porrima.revert(before, hunk)
211
+ if after.empty? && repository.repo.blob(path).nil?
212
+ index = repository.repo.index
213
+ index.remove(path)
214
+ index.write
215
+ else
216
+ repository.stage_content(path, after)
217
+ end
218
+ after
219
+ rescue StandardError => error
220
+ raise Error, "cannot unstage hunk: #{error.message}"
221
+ end
222
+
223
+ def lines(limit: 50_000)
224
+ staged_to_worktree.flat_map { |hunk| hunk.edits.map { |edit| [edit.kind, edit.text] } }.first(limit)
225
+ end
226
+
227
+ def highlight(lines: nil, limit: 50_000)
228
+ source = Array(lines || repository.repo.worktree_content(path).to_s.lines).map do |line|
229
+ line.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
230
+ end
231
+ require "antares"
232
+ require "rouge"
233
+ lexer = Rouge::Lexer.guess(filename: path)
234
+ highlighter = Antares::Highlighter.new(lexer: lexer, lines: ->(index) { source[index] }, line_count: -> { source.length })
235
+ highlighter.tokens_in(0...[source.length, limit].min)
236
+ rescue LoadError, StandardError
237
+ source.to_a.first(limit).map { |line| [[nil, line]] }
238
+ end
239
+ end
240
+
241
+ class Model
242
+ attr_reader :repository, :selected_path, :query, :config, :message, :operation
243
+
244
+ def initialize(dir = Dir.pwd, config: Config.load(File.join(dir, ".izar.jsonc")))
245
+ @repository = Repository.new(dir)
246
+ @config = config
247
+ @selected_path = nil
248
+ @query = ""
249
+ @message = nil
250
+ @operation = nil
251
+ reload
252
+ end
253
+
254
+ def reload
255
+ @groups = repository.grouped(filter: query)
256
+ paths = @groups.values.flatten.map(&:path)
257
+ @selected_path = paths.include?(selected_path) ? selected_path : paths.first
258
+ self
259
+ end
260
+
261
+ def groups = @groups
262
+ def select(path) = (@selected_path = path)
263
+ def filter(value)
264
+ @query = value.to_s
265
+ reload
266
+ end
267
+ def selected = groups.values.flatten.find { |change| change.path == selected_path }
268
+
269
+ def paths = groups.values.flatten.map(&:path)
270
+
271
+ def move(delta)
272
+ return nil if paths.empty?
273
+ index = [paths.index(selected_path).to_i + delta, 0].max
274
+ select(paths[[index, paths.length - 1].min])
275
+ end
276
+
277
+ def diff
278
+ return nil unless selected_path
279
+ context = config.dig("diff", "context").to_i
280
+ repository.diff(selected_path, context: context.positive? ? context : 3)
281
+ end
282
+
283
+ def stage_selected(async: false)
284
+ return reload unless selected_path
285
+ perform(async) { repository.stage(selected_path); reload }
286
+ end
287
+
288
+ def unstage_selected(async: false)
289
+ return reload unless selected_path
290
+ perform(async) { repository.unstage(selected_path); reload }
291
+ end
292
+
293
+ def toggle_selected(async: false)
294
+ return reload unless selected
295
+ selected.staged? && !selected.unstaged? ? unstage_selected(async: async) : stage_selected(async: async)
296
+ end
297
+
298
+ def stage_selected_hunk(async: false)
299
+ change = selected
300
+ return reload unless change
301
+ perform(async) do
302
+ if change.staged? && !change.unstaged?
303
+ hunks = repository.diff(change.path).head_to_staged
304
+ repository.diff(change.path).unstage_hunk(hunks.first) if hunks.first
305
+ else
306
+ hunks = repository.diff(change.path).staged_to_worktree
307
+ repository.diff(change.path).stage_hunk(hunks.first) if hunks.first
308
+ end
309
+ reload
310
+ end
311
+ end
312
+
313
+ def stage_all(async: false)
314
+ perform(async) { repository.stage_all; reload }
315
+ end
316
+
317
+ def discard_selected(async: false)
318
+ return reload unless selected_path
319
+ perform(async) { repository.discard(selected_path, confirm: true); reload }
320
+ end
321
+
322
+ def commit(message, async: false)
323
+ perform(async) { repository.commit(message); reload }
324
+ end
325
+
326
+ def busy?
327
+ operation&.running? || false
328
+ end
329
+
330
+ def poll_operation
331
+ return false unless operation&.done?
332
+ @message = operation.error.message if operation.error
333
+ @operation = nil
334
+ true
335
+ end
336
+
337
+ def message=(value)
338
+ @message = value
339
+ end
340
+
341
+ private
342
+
343
+ def perform(async)
344
+ return yield unless async
345
+ return false if busy?
346
+ @operation = Operation.new { yield }
347
+ true
348
+ end
349
+ end
350
+
351
+ module Config
352
+ DEFAULTS = {"theme" => "dark", "split_ratio" => 0.35,
353
+ "keymap" => {"stage" => "space", "commit" => "c"},
354
+ "diff" => {"context" => 3, "highlight" => true}}.freeze
355
+ module_function
356
+
357
+ def load(path = File.join(Dir.pwd, ".izar.jsonc"))
358
+ return DEFAULTS unless File.file?(path)
359
+ value = Kochab.parse(File.read(path, encoding: "UTF-8")).value
360
+ raise Error, "Izar config must be an object" unless value.is_a?(Hash)
361
+ config = DEFAULTS.merge(value) do |_key, defaults, override|
362
+ defaults.is_a?(Hash) && override.is_a?(Hash) ? defaults.merge(override) : override
363
+ end
364
+ validate(config)
365
+ config
366
+ rescue Kochab::ParseError => error
367
+ raise Error, "invalid config: #{error.message}"
368
+ end
369
+
370
+ def validate(config)
371
+ unknown = config.keys.map(&:to_s) - DEFAULTS.keys
372
+ raise Error, "unknown config key: #{unknown.first}" unless unknown.empty?
373
+ raise Error, "theme must be a string" unless config["theme"].is_a?(String)
374
+ ratio = config["split_ratio"]
375
+ raise Error, "split_ratio must be between 0 and 1" unless ratio.is_a?(Numeric) && ratio.finite? && ratio.between?(0, 1)
376
+ keymap = config["keymap"]
377
+ raise Error, "keymap must be an object" unless keymap.is_a?(Hash)
378
+ %w[stage commit].each do |key|
379
+ value = keymap[key]
380
+ raise Error, "keymap.#{key} must be a non-empty string" unless value.is_a?(String) && !value.empty?
381
+ end
382
+ unknown_keymap = keymap.keys.map(&:to_s) - DEFAULTS["keymap"].keys
383
+ raise Error, "unknown keymap key: #{unknown_keymap.first}" unless unknown_keymap.empty?
384
+ diff = config["diff"]
385
+ raise Error, "diff must be an object" unless diff.is_a?(Hash)
386
+ context = diff["context"]
387
+ raise Error, "diff.context must be a non-negative integer" unless context.is_a?(Integer) && context >= 0
388
+ raise Error, "diff.highlight must be boolean" unless diff["highlight"] == true || diff["highlight"] == false
389
+ unknown_diff = diff.keys.map(&:to_s) - DEFAULTS["diff"].keys
390
+ raise Error, "unknown diff key: #{unknown_diff.first}" unless unknown_diff.empty?
391
+ config
392
+ end
393
+ end
394
+
395
+ module Theme
396
+ module_function
397
+
398
+ def resolve(value)
399
+ return value if value.respond_to?(:colors)
400
+ return Auva.load(value) if defined?(Auva) && File.file?(value.to_s)
401
+ return Auva.builtin(value) if defined?(Auva)
402
+ Zaniah::Theme.public_send(value.to_s)
403
+ rescue StandardError
404
+ raise Error, "unknown theme: #{value}"
405
+ end
406
+ end
407
+
408
+ module TUI
409
+ module_function
410
+
411
+ def keymap(config = Config::DEFAULTS)
412
+ return nil unless defined?(Zaniah::Input::Keymap)
413
+ bindings = config.fetch("keymap", {})
414
+ Zaniah::Input::Keymap.new
415
+ .bind("j", :next)
416
+ .bind("k", :previous)
417
+ .bind(bindings.fetch("stage", "space"), :stage)
418
+ .bind("a", :stage_all)
419
+ .bind("s", :hunk)
420
+ .bind(bindings.fetch("commit", "c"), :commit)
421
+ .bind("X", :discard)
422
+ .bind("/", :filter)
423
+ .bind("r", :reload)
424
+ .bind("?", :help)
425
+ .bind("q", :quit)
426
+ end
427
+
428
+ class Session
429
+ attr_reader :model, :message
430
+
431
+ def initialize(model, async: false)
432
+ @model = model
433
+ @async = async
434
+ @keymap = TUI.keymap(model.config)
435
+ @fallback = {"j" => :next, "k" => :previous,
436
+ model.config.dig("keymap", "stage").to_s => :stage,
437
+ "a" => :stage_all, "s" => :hunk, "X" => :discard,
438
+ model.config.dig("keymap", "commit").to_s => :commit,
439
+ "/" => :filter, "r" => :reload, "?" => :help, "q" => :quit}
440
+ @fallback[" "] ||= :stage
441
+ @message = nil
442
+ end
443
+
444
+ def dispatch(key, commit_message: nil, confirm: false, filter_text: nil)
445
+ action = @keymap&.dispatch(key) || @fallback[key]
446
+ case action
447
+ when :next then model.move(1)
448
+ when :previous then model.move(-1)
449
+ when :stage then model.toggle_selected(async: @async)
450
+ when :stage_all then model.stage_all(async: @async)
451
+ when :hunk then model.stage_selected_hunk(async: @async)
452
+ when :discard
453
+ confirm ? model.discard_selected(async: @async) : (@message = "discard cancelled")
454
+ when :filter
455
+ model.filter(filter_text.to_s)
456
+ when :commit
457
+ begin
458
+ model.commit(commit_message, async: @async)
459
+ rescue Error => error
460
+ @message = error.message
461
+ end
462
+ when :reload then model.reload
463
+ when :help then @message = "j/k move · space stage · a all · s hunk · X discard · c commit · q quit"
464
+ when :quit then return :quit
465
+ end
466
+ model.message = @message
467
+ action
468
+ rescue Error => error
469
+ @message = error.message
470
+ model.message = @message
471
+ :error
472
+ end
473
+ end
474
+
475
+ def run(model, input: $stdin, output: $stdout)
476
+ unless input.tty? && output.tty? && defined?(Zaniah::Platform)
477
+ View.render(model, out: output)
478
+ return 0
479
+ end
480
+ require "io/console"
481
+ session = Session.new(model)
482
+ input.raw do
483
+ loop do
484
+ output.write("\e[2J\e[H")
485
+ View.render(model, out: output)
486
+ key = input.getch
487
+ if key == "c"
488
+ output.write("commit message: ")
489
+ message = input.cooked { input.gets.to_s.chomp }
490
+ result = session.dispatch(key, commit_message: message)
491
+ elsif key == "X"
492
+ output.write("discard #{model.selected_path}? [y/N] ")
493
+ confirm = input.cooked { input.getch.to_s.downcase == "y" }
494
+ result = session.dispatch(key, confirm: confirm)
495
+ elsif key == "/"
496
+ output.write("filter: ")
497
+ query = input.cooked { input.gets.to_s.chomp }
498
+ result = session.dispatch(key, filter_text: query)
499
+ else
500
+ result = session.dispatch(key)
501
+ end
502
+ break if result == :quit
503
+ end
504
+ end
505
+ 0
506
+ end
507
+
508
+ def snapshot(model)
509
+ model.groups.map { |name, entries| "#{name}: #{entries.map(&:path).join(", ")}" }.join("\n")
510
+ end
511
+ end
512
+
513
+ module View
514
+ module_function
515
+
516
+ def element(model)
517
+ theme = defined?(Zaniah::Theme) ? Theme.resolve(model.config.fetch("theme", "dark")) : nil
518
+ return Zaniah::UI::EmptyState.new("No changes", message: "Working tree is clean") unless theme
519
+
520
+ sidebar = Zaniah::UI::Sidebar.new(width: 280)
521
+ model.groups.each do |name, entries|
522
+ sidebar.child(Zaniah::UI::Label.new("#{name.to_s.capitalize} (#{entries.length})", tone: :muted, size: :xs))
523
+ entries.each do |change|
524
+ variant = change.path == model.selected_path ? :secondary : :ghost
525
+ sidebar.child(Zaniah::UI::Button.new("#{change.code} #{change.path}", size: :sm, variant: variant).w_full
526
+ .on_click { |_event, context| model.select(change.path); context.window.request_frame })
527
+ end
528
+ end
529
+
530
+ main = Zaniah::Div.new.flex_col.gap(12).p(24).bg(theme.colors.background)
531
+ status_children = [Zaniah::UI::Label.new("#{model.repository.branch} · #{model.groups.values.sum(&:length)} changes", size: :sm)]
532
+ status_children << Zaniah::UI::Spinner.new(size: 14) if model.busy?
533
+ status_children << Zaniah::UI::Spacer.new
534
+ status_children << Zaniah::UI::Label.new(model.message.to_s, tone: :muted, size: :xs)
535
+ main.child(Zaniah::UI::StatusBar.new(*status_children))
536
+ if model.selected_path && model.diff
537
+ lines = model.diff.lines(limit: 50_000)
538
+ main.child(Zaniah::UI::Label.new("Diff: #{model.selected_path}", size: :lg))
539
+ main.child(Zaniah::List.new(count: lines.length, estimated_height: 20) do |index|
540
+ kind, text = lines[index]
541
+ Zaniah::Div.new.h(20).child(Zaniah::UI::RichText.new(diff_runs(model, kind, text, theme), selectable: false))
542
+ end.h(680))
543
+ else
544
+ main.child(Zaniah::UI::EmptyState.new("Select a change", message: "Choose a file from the sidebar"))
545
+ end
546
+ Zaniah::UI::SplitPane.new(sidebar, main, ratio: model.config.fetch("split_ratio", 0.35))
547
+ end
548
+
549
+ def render(model, out: $stdout)
550
+ ahead = model.repository.ahead_behind
551
+ out.puts "#{model.repository.branch} ↑#{ahead[:ahead]} ↓#{ahead[:behind]} · #{model.groups.values.sum(&:length)} changes"
552
+ {staged: "Staged", unstaged: "Unstaged", untracked: "Untracked"}.each do |key, title|
553
+ entries = model.groups.fetch(key)
554
+ out.puts "#{title} (#{entries.length})"
555
+ entries.each { |change| out.puts " #{change.code} #{change.path}" }
556
+ end
557
+ if model.selected_path
558
+ out.puts "\nDiff: #{model.selected_path}"
559
+ model.diff.lines.first(500).each { |kind, text| out.write(kind == :insert ? "+" : kind == :delete ? "-" : " "); out.write(text) }
560
+ end
561
+ out.puts "\n#{model.message}" if model.respond_to?(:message) && model.message
562
+ out.puts "\n[space] stage [a] all [s] hunk [c] commit [X] discard [/] filter [r] reload [q] quit"
563
+ end
564
+
565
+ def diff_runs(model, kind, text, theme)
566
+ prefix = kind == :insert ? "+" : kind == :delete ? "-" : " "
567
+ plain = "#{prefix}#{text}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
568
+ return [{text: plain, color: diff_color(kind, theme)}] unless model.config.dig("diff", "highlight")
569
+
570
+ tokens = model.diff.highlight(lines: [text], limit: 1).first || [[nil, text]]
571
+ [{text: prefix, color: diff_color(kind, theme)}] + tokens.map do |token, value|
572
+ {text: value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace), color: token_color(token, kind, theme)}
573
+ end
574
+ rescue StandardError
575
+ [{text: "#{prefix}#{text}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace), color: diff_color(kind, theme)}]
576
+ end
577
+
578
+ def diff_color(kind, theme)
579
+ case kind
580
+ when :insert then theme.colors.success
581
+ when :delete then theme.colors.danger
582
+ else theme.colors.text
583
+ end
584
+ end
585
+
586
+ def token_color(token, kind, theme)
587
+ return diff_color(kind, theme) unless token.respond_to?(:shortname)
588
+ case token.shortname.to_s
589
+ when /k|c/ then theme.colors.text_muted
590
+ when /nb|nc|nf|n/ then theme.colors.accent
591
+ when /s|dl/ then theme.colors.success
592
+ when /m|mi|mf/ then theme.colors.warning
593
+ else theme.colors.text
594
+ end
595
+ end
596
+ end
597
+
598
+ module GUI
599
+ module_function
600
+
601
+ def run(model, output: $stdout)
602
+ raise Error, "GUI backend unavailable" unless defined?(Zaniah::Platform)
603
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux
604
+ app = Zaniah::App.new
605
+ window = app.open_window(backend: backend, width: 1200, height: 800, title: "Izar")
606
+ app.global(:theme, Theme.resolve(model.config.fetch("theme", "dark")))
607
+ session = TUI::Session.new(model, async: true)
608
+ window.draw { View.element(model) }
609
+ window.on_tick do
610
+ window.request_frame if model.busy?
611
+ window.request_frame if model.poll_operation
612
+ end
613
+ window.on_input do |event|
614
+ next unless event.is_a?(Zaniah::Input::KeyDown)
615
+ key = event.keystroke
616
+ next if %w[c X].include?(key)
617
+ result = session.dispatch(key)
618
+ window.request_frame unless result == :quit
619
+ window.close if result == :quit
620
+ end
621
+ window.run
622
+ rescue StandardError => error
623
+ output.puts "izar: GUI backend unavailable; using TUI (#{error.message})"
624
+ TUI.run(model, input: $stdin, output: output)
625
+ ensure
626
+ window&.close
627
+ end
628
+ end
629
+
630
+ class CLI
631
+ def self.run(argv, out: $stdout, err: $stderr, input: $stdin)
632
+ options = {dir: Dir.pwd, filter: nil, action: :status, yes: false, tui: false, gui: false}
633
+ OptionParser.new do |opts|
634
+ opts.banner = "Usage: izar [status|stage|unstage|discard|commit] [PATH]"
635
+ opts.on("-C PATH") { |v| options[:dir] = v }
636
+ opts.on("--filter TEXT") { |v| options[:filter] = v }
637
+ opts.on("--yes") { options[:yes] = true }
638
+ opts.on("--tui") { options[:tui] = true }
639
+ opts.on("--gui") { options[:gui] = true }
640
+ opts.on("-m MESSAGE") { |v| options[:message] = v }
641
+ end.parse!(argv)
642
+ options[:action] = argv.shift&.to_sym || :status
643
+ repository = Repository.new(options[:dir])
644
+ case options[:action]
645
+ when :status
646
+ model = Model.new(options[:dir]).filter(options[:filter]) if options[:filter]
647
+ model ||= Model.new(options[:dir])
648
+ if options[:gui] && input.tty? && out.tty?
649
+ GUI.run(model, output: out)
650
+ else
651
+ out.puts "izar: GUI backend unavailable; using TUI" if options[:gui]
652
+ options[:tui] ? TUI.run(model, input: input, output: out) : View.render(model, out: out)
653
+ end
654
+ when :stage then repository.stage(argv.fetch(0)); out.puts "staged #{argv.fetch(0)}"
655
+ when :unstage then repository.unstage(argv.fetch(0)); out.puts "unstaged #{argv.fetch(0)}"
656
+ when :discard then out.puts repository.discard(argv.fetch(0), confirm: options[:yes]).fetch(:message)
657
+ when :commit then out.puts "committed #{repository.commit(options[:message] || argv.fetch(0))}"
658
+ else raise Error, "unknown command: #{options[:action]}"
659
+ end
660
+ 0
661
+ rescue OptionParser::ParseError, KeyError, Error => error
662
+ err.puts "izar: #{error.message}"
663
+ 1
664
+ end
665
+ end
666
+ end
data/sig/izar.rbs ADDED
@@ -0,0 +1,16 @@
1
+ module Izar
2
+ VERSION: String
3
+ class Error < StandardError
4
+ end
5
+ class Repository
6
+ def initialize: (?String dir) -> void
7
+ def status: () -> Array[untyped]
8
+ def stage: (String path) -> String
9
+ def unstage: (String path) -> String
10
+ def commit: (String message) -> String
11
+ end
12
+ class Diff
13
+ def staged_to_worktree: () -> Array[untyped]
14
+ def head_to_staged: () -> Array[untyped]
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,157 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: izar
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: antares
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: fiddle
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: kochab
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: porrima
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: spica
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: thuban
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.6'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.6'
96
+ - !ruby/object:Gem::Dependency
97
+ name: zaniah
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 0.6.0
103
+ - - "<"
104
+ - !ruby/object:Gem::Version
105
+ version: '0.7'
106
+ type: :runtime
107
+ prerelease: false
108
+ version_requirements: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 0.6.0
113
+ - - "<"
114
+ - !ruby/object:Gem::Version
115
+ version: '0.7'
116
+ description: A safe, thin TUI and CLI layer over Thuban and Porrima for reviewing
117
+ and staging Git changes.
118
+ email:
119
+ - t.yudai92@gmail.com
120
+ executables:
121
+ - izar
122
+ extensions: []
123
+ extra_rdoc_files: []
124
+ files:
125
+ - CHANGELOG.md
126
+ - LICENSE.txt
127
+ - README.md
128
+ - Rakefile
129
+ - exe/izar
130
+ - lib/izar.rb
131
+ - lib/izar/version.rb
132
+ - sig/izar.rbs
133
+ homepage: https://github.com/noxdea/izar
134
+ licenses:
135
+ - MIT
136
+ metadata:
137
+ allowed_push_host: https://rubygems.org
138
+ source_code_uri: https://github.com/noxdea/izar
139
+ rubygems_mfa_required: 'true'
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: 3.2.0
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubygems_version: 4.0.16
155
+ specification_version: 4
156
+ summary: Pure Ruby Git staging workspace
157
+ test_files: []