gemstar 1.2 → 1.3

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.
@@ -1,9 +1,14 @@
1
1
  require "cgi"
2
2
  require "erb"
3
+ require "json"
4
+ require "open3"
5
+ require "shellwords"
3
6
  require "uri"
4
7
  require "kramdown"
5
8
  require "nokogiri"
6
9
  require "roda"
10
+ require_relative "../crates_io_metadata"
11
+ require_relative "../pypi_metadata"
7
12
 
8
13
  begin
9
14
  require "kramdown-parser-gfm"
@@ -14,7 +19,7 @@ module Gemstar
14
19
  module Web
15
20
  class App < Roda
16
21
  MISSING_METADATA = Object.new
17
- CACHE_VERSION = "v7"
22
+ CACHE_VERSION = "v11"
18
23
 
19
24
  class << self
20
25
  def build(projects:, config_home:, cache_warmer: nil)
@@ -82,6 +87,23 @@ module Gemstar
82
87
  File.read(project.gemfile_path)
83
88
  end
84
89
 
90
+ r.post "actions", String do |action_id|
91
+ response["Content-Type"] = "application/json; charset=utf-8"
92
+
93
+ project_index = selected_project_index(r.params["project"])
94
+ project = @projects[project_index]
95
+ action = project_action(action_id, project)
96
+ unless project && action
97
+ response.status = 404
98
+ next JSON.generate({ ok: false, error: "Unknown project action." })
99
+ end
100
+
101
+ result = run_project_action(project, action)
102
+ clear_runtime_state!(project)
103
+ response.status = result[:ok] ? 200 : 500
104
+ JSON.generate(result)
105
+ end
106
+
85
107
  r.on "projects", String do |project_id|
86
108
  response.redirect "/?project=#{project_id}"
87
109
  end
@@ -104,6 +126,7 @@ module Gemstar
104
126
  importmap_stamp = File.file?(project.importmap_path) ? File.mtime(project.importmap_path).to_i : 0
105
127
  importmap_vendor_stamp = importmap_vendor_mtime(project)
106
128
  package_lock_stamp = File.file?(project.package_lock_path) ? File.mtime(project.package_lock_path).to_i : 0
129
+ cargo_lock_stamp = File.file?(project.cargo_lock_path) ? File.mtime(project.cargo_lock_path).to_i : 0
107
130
 
108
131
  [
109
132
  CACHE_VERSION,
@@ -113,10 +136,12 @@ module Gemstar
113
136
  params["filter"],
114
137
  params["scope"],
115
138
  package_param(params),
139
+ params["package_id"],
116
140
  lockfile_stamp,
117
141
  importmap_stamp,
118
142
  importmap_vendor_stamp,
119
- package_lock_stamp
143
+ package_lock_stamp,
144
+ cargo_lock_stamp
120
145
  ]
121
146
  end
122
147
 
@@ -156,10 +181,12 @@ module Gemstar
156
181
  @selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id)
157
182
  @gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : []
158
183
  requested_package_name = package_param(params)
184
+ requested_package_id = params["package_id"]
159
185
  @requested_gem_name = requested_package_name
160
- @selected_package_scope = selected_package_scope(params["scope"], requested_package_name)
161
- @selected_filter = selected_filter(params["filter"], requested_package_name)
162
- @selected_gem = selected_gem_state(requested_package_name)
186
+ @requested_package_id = requested_package_id
187
+ @selected_package_scope = selected_package_scope(params["scope"], requested_package_name, requested_package_id)
188
+ @selected_filter = selected_filter(params["filter"], requested_package_name, requested_package_id)
189
+ @selected_gem = selected_gem_state(requested_package_name, requested_package_id)
163
190
  end
164
191
 
165
192
  def package_param(params)
@@ -201,33 +228,36 @@ module Gemstar
201
228
  valid_ids.include?(candidate) ? candidate : valid_ids.first || "worktree"
202
229
  end
203
230
 
204
- def selected_filter(raw_filter, raw_gem_name)
231
+ def selected_filter(raw_filter, raw_gem_name, raw_package_id = nil)
205
232
  return "all" if @gem_states.empty?
233
+ return "all" unless @gem_states.any? { |gem| gem[:status] != :unchanged }
206
234
  return raw_filter if %w[updated all].include?(raw_filter)
207
235
 
208
- selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
236
+ selected_gem = requested_gem_state(raw_gem_name, raw_package_id)
209
237
  return "all" if selected_gem && selected_gem[:status] == :unchanged
210
238
 
211
239
  @gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all"
212
240
  end
213
241
 
214
- def selected_package_scope(raw_scope, raw_gem_name)
242
+ def selected_package_scope(raw_scope, raw_gem_name, raw_package_id = nil)
215
243
  return "all" if @gem_states.empty?
216
244
 
217
245
  available_scopes = available_package_scopes
218
246
  default_scope = available_scopes == ["gems"] ? "gems" : "all"
219
247
  return raw_scope if raw_scope == "all" || available_scopes.include?(raw_scope)
220
248
 
221
- selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name }
249
+ selected_gem = requested_gem_state(raw_gem_name, raw_package_id)
222
250
  return selected_gem[:package_scope] if selected_gem
223
251
 
224
252
  default_scope
225
253
  end
226
254
 
227
- def selected_gem_state(raw_gem_name)
255
+ def selected_gem_state(raw_gem_name, raw_package_id = nil)
228
256
  return nil if @gem_states.empty?
229
257
 
230
- exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
258
+ exact_match = @gem_states.find { |gem| package_identity(gem) == raw_package_id && gem_visible_in_selected_scope?(gem) } unless raw_package_id.to_s.empty?
259
+ exact_match ||= @gem_states.find { |gem| package_identity(gem) == raw_package_id } unless raw_package_id.to_s.empty?
260
+ exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name && gem_visible_in_selected_scope?(gem) }
231
261
  exact_match ||= @gem_states.find { |gem| gem[:name] == raw_gem_name }
232
262
  return exact_match if exact_match
233
263
 
@@ -237,6 +267,23 @@ module Gemstar
237
267
  @gem_states.first
238
268
  end
239
269
 
270
+ def requested_gem_state(raw_gem_name, raw_package_id)
271
+ unless raw_package_id.to_s.empty?
272
+ identity_match = @gem_states.find { |gem| package_identity(gem) == raw_package_id }
273
+ return identity_match if identity_match
274
+ end
275
+
276
+ @gem_states.find { |gem| gem[:name] == raw_gem_name }
277
+ end
278
+
279
+ def package_identity(gem_state)
280
+ [
281
+ gem_state[:package_scope],
282
+ gem_state[:package_source_file],
283
+ gem_state[:package_lock_key] || gem_state[:name]
284
+ ].join(":")
285
+ end
286
+
240
287
  def gem_visible_in_selected_filter?(gem_state)
241
288
  return false unless gem_visible_in_selected_scope?(gem_state)
242
289
  return true if @selected_filter != "updated"
@@ -404,13 +451,231 @@ module Gemstar
404
451
  <strong>#{@gem_states.count { |gem| gem[:status] != :unchanged }}</strong> changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)}
405
452
  </div>
406
453
  <div class="toolbar-actions">
407
- <button type="button" class="action" disabled="disabled">bundle install</button>
408
- <button type="button" class="action action-primary" disabled="disabled">bundle update</button>
454
+ #{render_project_action_buttons}
409
455
  </div>
410
456
  </section>
411
457
  HTML
412
458
  end
413
459
 
460
+ def render_project_action_buttons
461
+ actions = project_actions
462
+ actions.map do |action|
463
+ primary = action[:primary] ? " action-primary" : ""
464
+ command_label = project_action_display_command(@selected_project, action)
465
+ <<~HTML
466
+ <button
467
+ type="button"
468
+ class="action#{primary}"
469
+ data-project-action="#{h(action[:id])}"
470
+ data-project-action-command="#{h(command_label)}"
471
+ >#{h(action[:label])}</button>
472
+ HTML
473
+ end.join
474
+ end
475
+
476
+ def project_actions(project = @selected_project)
477
+ return [] unless project
478
+
479
+ actions = []
480
+ if project.gemfile? || project.lockfile?
481
+ actions << {
482
+ id: "bundle_install",
483
+ label: "#{bundle_command(project).join(" ")} install",
484
+ command: [*bundle_command(project), "install"]
485
+ }
486
+ actions << {
487
+ id: "bundle_update",
488
+ label: "#{bundle_command(project).join(" ")} update",
489
+ command: [*bundle_command(project), "update"],
490
+ primary: true
491
+ }
492
+ end
493
+
494
+ if project.uv_lock?
495
+ actions << {
496
+ id: "uv_sync",
497
+ label: "uv sync",
498
+ command: %w[uv sync]
499
+ }
500
+ actions << {
501
+ id: "uv_lock_upgrade",
502
+ label: "uv lock --upgrade",
503
+ command: %w[uv lock --upgrade],
504
+ primary: true
505
+ }
506
+ end
507
+
508
+
509
+ if project.cargo_toml? || project.cargo_lock?
510
+ actions << {
511
+ id: "cargo_fetch",
512
+ label: "cargo fetch",
513
+ command: %w[cargo fetch]
514
+ }
515
+ actions << {
516
+ id: "cargo_update",
517
+ label: "cargo update",
518
+ command: %w[cargo update],
519
+ primary: true
520
+ }
521
+ end
522
+
523
+ if project.package_lock?
524
+ actions << {
525
+ id: "npm_install",
526
+ label: "npm install",
527
+ command: %w[npm install]
528
+ }
529
+ actions << {
530
+ id: "npm_update",
531
+ label: "npm update",
532
+ command: %w[npm update],
533
+ primary: true
534
+ }
535
+ elsif project.package_json?
536
+ actions << {
537
+ id: "npm_install",
538
+ label: "npm install",
539
+ command: %w[npm install]
540
+ }
541
+ end
542
+
543
+ if project.importmap?
544
+ actions << {
545
+ id: "importmap_update",
546
+ label: "bin/importmap update",
547
+ command: %w[bin/importmap update]
548
+ }
549
+ end
550
+
551
+ actions
552
+ end
553
+
554
+ def project_action(action_id, project)
555
+ project_actions(project).find { |action| action[:id] == action_id }
556
+ end
557
+
558
+ def run_project_action(project, action)
559
+ command = project_action_command(project, action)
560
+ shell_command = project_action_shell_command(project, command)
561
+ stdout, stderr, status = Open3.capture3(project_action_environment, user_shell, "-lc", shell_command)
562
+ {
563
+ ok: status.success?,
564
+ action: action[:id],
565
+ command: shell_command,
566
+ output: truncated_action_output([stdout, stderr].reject(&:empty?).join("\n").strip)
567
+ }
568
+ rescue Errno::ENOENT => e
569
+ {
570
+ ok: false,
571
+ action: action[:id],
572
+ command: project_action_display_command(project, action),
573
+ output: e.message
574
+ }
575
+ end
576
+
577
+ def project_action_display_command(project, action)
578
+ project_action_shell_command(project, project_action_command(project, action))
579
+ end
580
+
581
+ def project_action_shell_command(project, command)
582
+ "cd #{Shellwords.escape(project.directory)} && #{Shellwords.join(command)}"
583
+ end
584
+
585
+ def project_action_command(project, action)
586
+ return action[:command] unless project_uses_mise?(project)
587
+
588
+ mise = mise_executable
589
+ return action[:command] unless mise
590
+
591
+ [mise, "exec", "--", *mise_command(action[:command])]
592
+ end
593
+
594
+ def mise_command(command)
595
+ executable, *arguments = command
596
+ case executable
597
+ when /\Abin\//
598
+ ["ruby", executable, *arguments]
599
+ when "bundle"
600
+ ["ruby", "-S", executable, *arguments]
601
+ else
602
+ command
603
+ end
604
+ end
605
+
606
+ def project_action_environment
607
+ {
608
+ "BUNDLE_BIN_PATH" => nil,
609
+ "BUNDLE_APP_CONFIG" => nil,
610
+ "BUNDLE_GEMFILE" => nil,
611
+ "BUNDLE_PATH" => nil,
612
+ "BUNDLE_WITHOUT" => nil,
613
+ "BUNDLER_VERSION" => nil,
614
+ "GEM_HOME" => nil,
615
+ "GEM_PATH" => nil,
616
+ "GEM_ROOT" => nil,
617
+ "MY_RUBY_HOME" => nil,
618
+ "RBENV_VERSION" => nil,
619
+ "RUBYLIB" => nil,
620
+ "RUBYGEMS_GEMDEPS" => nil,
621
+ "RUBYOPT" => nil,
622
+ "rvm_path" => nil,
623
+ "rvm_ruby_string" => nil
624
+ }
625
+ end
626
+
627
+ def user_shell
628
+ shell = ENV["SHELL"].to_s
629
+ shell.empty? ? "/bin/sh" : shell
630
+ end
631
+
632
+ def bundle_command(project)
633
+ File.file?(File.join(project.directory, "bin", "bundle")) ? %w[bin/bundle] : %w[bundle]
634
+ end
635
+
636
+ def project_uses_mise?(project)
637
+ directory = project.directory
638
+ loop do
639
+ return true if File.file?(File.join(directory, "mise.toml")) ||
640
+ File.file?(File.join(directory, ".mise.toml")) ||
641
+ File.file?(File.join(directory, ".ruby-version"))
642
+
643
+ parent = File.dirname(directory)
644
+ break if parent == directory
645
+
646
+ directory = parent
647
+ end
648
+
649
+ false
650
+ end
651
+
652
+ def mise_executable
653
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |directory|
654
+ path = File.join(directory, "mise")
655
+ return path if File.executable?(path)
656
+ end
657
+
658
+ [
659
+ "/opt/homebrew/bin/mise",
660
+ "/usr/local/bin/mise",
661
+ File.expand_path("~/.local/bin/mise")
662
+ ].find { |path| File.executable?(path) }
663
+ end
664
+
665
+ def truncated_action_output(output)
666
+ return output if output.length <= 8_000
667
+
668
+ "#{output[0, 8_000]}\n\n... output truncated ..."
669
+ end
670
+
671
+ def clear_runtime_state!(project)
672
+ project&.clear_cache! if project.respond_to?(:clear_cache!)
673
+ self.class.opts[:change_sections_cache].clear
674
+ self.class.opts[:detail_html_cache].clear
675
+ self.class.opts[:detail_request_cache].clear
676
+ self.class.opts[:metadata_cache].clear
677
+ end
678
+
414
679
  def selected_from_revision_label
415
680
  @revision_options.find { |option| option[:id] == @selected_from_revision_id }&.dig(:label) || "worktree"
416
681
  end
@@ -453,19 +718,22 @@ module Gemstar
453
718
  HTML
454
719
 
455
720
  items = @gem_states.map do |gem|
456
- selected = gem[:name] == @selected_gem[:name] ? " is-selected" : ""
721
+ identity = package_identity(gem)
722
+ selected = identity == package_identity(@selected_gem) ? " is-selected" : ""
457
723
  status_class = " status-#{gem[:status]}"
458
724
  updated = gem[:status] != :unchanged
459
- hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name)) ? ' hidden="hidden"' : ""
725
+ requested = identity == @requested_package_id || (@requested_package_id.to_s.empty? && gem[:name] == @requested_gem_name)
726
+ hidden = (!gem_visible_in_selected_scope?(gem) || (@selected_filter == "updated" && !updated && !requested)) ? ' hidden="hidden"' : ""
460
727
  <<~HTML
461
728
  <a
462
729
  class="gem-row#{selected}#{status_class}"
463
- href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name])}"
730
+ href="#{project_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name], package_id: identity)}"
464
731
  data-gem-link="true"
465
732
  data-gem-name="#{h(gem[:name])}"
733
+ data-package-id="#{h(identity)}"
466
734
  data-gem-updated="#{updated}"
467
735
  data-package-scope="#{h(gem[:package_scope])}"
468
- data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name]))}"
736
+ data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: gem[:name], package_id: identity))}"
469
737
  #{hidden}
470
738
  >
471
739
  <span class="gem-name-row">
@@ -518,8 +786,9 @@ module Gemstar
518
786
  detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
519
787
 
520
788
  detail_html = <<~HTML
521
- <section class="detail" data-detail-panel tabindex="0" data-detail-pending="#{detail_pending}" data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: @selected_gem[:name]))}">
789
+ <section class="detail" data-detail-panel tabindex="0" data-detail-pending="#{detail_pending}" data-detail-url="#{h(detail_query(project: @selected_project_index, from: @selected_from_revision_id, to: @selected_to_revision_id, filter: @selected_filter, scope: @selected_package_scope, package: @selected_gem[:name], package_id: package_identity(@selected_gem)))}">
522
790
  #{render_detail_hero(metadata)}
791
+ #{render_selected_dependency_details}
523
792
  #{render_detail_loading_notice if detail_pending}
524
793
  #{render_detail_revision_panel(groups)}
525
794
  </section>
@@ -538,7 +807,8 @@ module Gemstar
538
807
  to: @selected_to_revision_id,
539
808
  filter: @selected_filter,
540
809
  scope: @selected_package_scope,
541
- package: @selected_gem[:name]
810
+ package: @selected_gem[:name],
811
+ package_id: package_identity(@selected_gem)
542
812
  )
543
813
 
544
814
  <<~HTML
@@ -551,11 +821,13 @@ module Gemstar
551
821
  end
552
822
 
553
823
  def empty_detail_html(loading: false)
824
+ package_label = @selected_project&.package_item_label || "package"
825
+
554
826
  <<~HTML
555
827
  <section class="detail" data-detail-panel tabindex="0">
556
828
  <div class="empty-panel">
557
- <h2>#{loading ? "Loading details" : "No gem selected"}</h2>
558
- <p>#{loading ? "Preparing the selected gem details." : "Choose a gem from the list to inspect its current version and changelog revisions."}</p>
829
+ <h2>#{loading ? "Loading details" : "No #{h(package_label)} selected"}</h2>
830
+ <p>#{loading ? "Preparing the selected #{h(package_label)} details." : "Choose a #{h(package_label)} from the list to inspect its current version and changelog revisions."}</p>
559
831
  </div>
560
832
  </section>
561
833
  HTML
@@ -563,10 +835,7 @@ module Gemstar
563
835
 
564
836
  def render_detail_hero(metadata)
565
837
  description = metadata&.dig("info")
566
- bundle_origins = Array(@selected_gem[:bundle_origins])
567
- requirement_names = selected_gem_requirements
568
838
  bundled_version = detail_bundled_version(metadata)
569
- added_on = selected_gem_added_on
570
839
  title_url = metadata&.dig("homepage_uri")
571
840
  title_url = repo_url_for(@selected_gem, metadata: metadata) if title_url.to_s.empty?
572
841
  title_markup = if title_url.to_s.empty?
@@ -580,17 +849,50 @@ module Gemstar
580
849
  <div class="detail-hero-copy">
581
850
  <div class="detail-title-row">
582
851
  <div class="detail-title-lockup">
583
- <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
852
+ <div class="detail-heading">
853
+ <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
854
+ #{render_package_type_badge(@selected_gem)}
855
+ </div>
584
856
  </div>
585
857
  #{render_detail_links(metadata)}
586
858
  </div>
587
859
  <div class="detail-subtitle">#{render_detail_subtitle(description)}</div>
588
- #{render_dependency_details(bundle_origins, requirement_names, added_on)}
589
860
  </div>
590
861
  </section>
591
862
  HTML
592
863
  end
593
864
 
865
+ def render_package_type_badge(package_state)
866
+ scope = package_state[:package_scope].to_s
867
+ label, accessible_label, icon_type = case scope
868
+ when "gems"
869
+ ["Gem", "Ruby gem", :rubygems]
870
+ when "cargo"
871
+ ["Crate", "Rust crate", :cargo]
872
+ when "js"
873
+ ["JS", "JavaScript package", :javascript]
874
+ when "python"
875
+ ["Python", "Python package", :python]
876
+ else
877
+ [package_state[:package_type_label] || "Package", "Package", :package]
878
+ end
879
+
880
+ <<~HTML.chomp
881
+ <span class="detail-package-type detail-package-type-#{h(scope)}" aria-label="#{h(accessible_label)}" title="#{h(accessible_label)}">
882
+ <span class="detail-package-type-icon" aria-hidden="true">#{icon_svg(icon_type)}</span>
883
+ <span>#{h(label)}</span>
884
+ </span>
885
+ HTML
886
+ end
887
+
888
+ def render_selected_dependency_details
889
+ render_dependency_details(
890
+ Array(@selected_gem[:bundle_origins]),
891
+ selected_gem_requirements,
892
+ selected_gem_added_on
893
+ )
894
+ end
895
+
594
896
  def render_detail_subtitle(description)
595
897
  text = description.to_s.strip
596
898
  return "<p>Metadata will appear here when package information is available.</p>" if text.empty?
@@ -707,6 +1009,7 @@ module Gemstar
707
1009
  @selected_gem[:name],
708
1010
  package_scope: @selected_gem[:package_scope],
709
1011
  source_file: @selected_gem[:package_source_file],
1012
+ package_key: @selected_gem[:package_lock_key],
710
1013
  revision_id: revision_id
711
1014
  )
712
1015
  end
@@ -764,6 +1067,26 @@ module Gemstar
764
1067
  else
765
1068
  ["npm (#{h(remote)})"]
766
1069
  end
1070
+ when :pypi
1071
+ remote = source[:remote]
1072
+ registry_url = source[:registry_url]
1073
+ if remote.to_s.empty? && registry_url.to_s.empty?
1074
+ ["PyPI"]
1075
+ elsif remote.to_s.empty?
1076
+ ["PyPI (#{h(registry_url)})"]
1077
+ else
1078
+ ["PyPI (#{h(remote)})"]
1079
+ end
1080
+ when :cargo
1081
+ remote = source[:remote]
1082
+ registry_url = source[:registry_url]
1083
+ if remote.to_s.empty? && registry_url.to_s.empty?
1084
+ ["Cargo registry"]
1085
+ elsif remote.to_s.empty?
1086
+ ["Cargo registry (#{h(registry_url)})"]
1087
+ else
1088
+ ["Cargo registry (#{h(remote)})"]
1089
+ end
767
1090
  else
768
1091
  []
769
1092
  end
@@ -802,13 +1125,26 @@ module Gemstar
802
1125
  def render_detail_revision_panel(groups)
803
1126
  <<~HTML
804
1127
  <section class="revision-panel">
805
- #{render_revision_group("Latest", groups[:latest], empty_message: nil) if groups[:latest].any?}
806
- #{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")}
807
- #{render_revision_group("Earlier changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
1128
+ #{render_revision_group("Available", groups[:latest], empty_message: nil) if groups[:latest].any?}
1129
+ #{render_current_revision_group(groups)}
1130
+ #{render_previous_revision_group(groups)}
808
1131
  </section>
809
1132
  HTML
810
1133
  end
811
1134
 
1135
+ def render_current_revision_group(groups)
1136
+ return "" if unchanged_worktree_package? && groups[:previous].any?
1137
+
1138
+ render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")
1139
+ end
1140
+
1141
+ def render_previous_revision_group(groups)
1142
+ return "" if groups[:previous].empty?
1143
+
1144
+ title = unchanged_worktree_package? ? "Included in #{selected_from_revision_label}" : "Earlier changes"
1145
+ render_revision_group(title, groups[:previous], empty_message: nil)
1146
+ end
1147
+
812
1148
  def render_detail_loading_notice
813
1149
  <<~HTML
814
1150
  <section class="empty-panel">
@@ -932,7 +1268,7 @@ module Gemstar
932
1268
  }
933
1269
  end
934
1270
 
935
- change_sections_cache[cache_key] = rendered_sections.sort_by { |section| section_sort_key(section) }
1271
+ change_sections_cache[cache_key] = sort_change_sections(rendered_sections)
936
1272
  rescue StandardError
937
1273
  []
938
1274
  end
@@ -983,12 +1319,15 @@ module Gemstar
983
1319
  value = version.to_s.strip
984
1320
  return nil if value.empty?
985
1321
 
986
- value.sub(/\Av/i, "")
1322
+ value = value.sub(/\Av/i, "")
1323
+ Gem::Version.new(value.gsub(/-[\w\-]+$/, "")).canonical_segments.join(".")
1324
+ rescue ArgumentError
1325
+ value
987
1326
  end
988
1327
 
989
1328
  def relevant_package_versions(gem_state, metadata)
990
1329
  metadata_hash = metadata_for(gem_state) || {}
991
- [
1330
+ explicit_versions = [
992
1331
  gem_state[:old_version],
993
1332
  gem_state[:new_version],
994
1333
  gem_state[:raw_old_version],
@@ -996,6 +1335,27 @@ module Gemstar
996
1335
  gem_state.dig(:source, :package_version),
997
1336
  metadata_hash["version"]
998
1337
  ].compact
1338
+
1339
+ (explicit_versions + registry_versions_between(metadata, gem_state, metadata_hash)).uniq
1340
+ end
1341
+
1342
+ def registry_versions_between(metadata, gem_state, metadata_hash)
1343
+ return [] unless metadata.respond_to?(:registry_release_dates)
1344
+
1345
+ lower_bound = gem_state[:old_version] || gem_state[:raw_old_version] || gem_state.dig(:source, :package_version)
1346
+ current_version = effective_package_version(gem_state, metadata_hash)
1347
+ upper_bound = metadata_hash["version"] || current_version
1348
+ return [] if lower_bound.to_s.empty? || upper_bound.to_s.empty?
1349
+
1350
+ release_dates = metadata.registry_release_dates(cache_only: true)
1351
+ release_dates = metadata.registry_release_dates(cache_only: false) if release_dates.nil? || release_dates.empty?
1352
+
1353
+ release_dates.keys.select do |version|
1354
+ compare_versions(version, lower_bound) == 1 &&
1355
+ compare_versions(version, upper_bound) <= 0
1356
+ end
1357
+ rescue StandardError
1358
+ []
999
1359
  end
1000
1360
 
1001
1361
  def selected_gem_requires_refresh?(gem_state, cached_sections)
@@ -1042,17 +1402,14 @@ module Gemstar
1042
1402
  :previous if compare_versions(version, lower_bound) <= 0
1043
1403
  end
1044
1404
 
1045
- def section_sort_key(section)
1046
- kind_rank = { future: 0, current: 1, previous: 2 }.fetch(section[:kind], 9)
1047
- [kind_rank, -sortable_version_number(section[:version])]
1048
- end
1405
+ def sort_change_sections(sections)
1406
+ kind_ranks = { future: 0, current: 1, previous: 2 }
1407
+ sections.sort do |left, right|
1408
+ kind_comparison = kind_ranks.fetch(left[:kind], 9) <=> kind_ranks.fetch(right[:kind], 9)
1409
+ next kind_comparison unless kind_comparison.zero?
1049
1410
 
1050
- def sortable_version_number(version)
1051
- Gem::Version.new(version.to_s.gsub(/-[\w\-]+$/, "")).segments.take(6).each_with_index.sum do |segment, index|
1052
- segment.to_i * (10**(10 - index * 2))
1411
+ compare_versions(right[:version], left[:version])
1053
1412
  end
1054
- rescue ArgumentError
1055
- 0
1056
1413
  end
1057
1414
 
1058
1415
  def changelog_content(lines, heading_version: nil)
@@ -1088,7 +1445,7 @@ module Gemstar
1088
1445
  end
1089
1446
 
1090
1447
  def strip_leading_version_heading(text, heading_version)
1091
- stripped = text.sub(/\A\s*#+\s*(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b[^\n]*\n+/i, "")
1448
+ stripped = text.sub(/\A\s*#+\s*[\[(]?(?:Version\s+)?v?#{Regexp.escape(heading_version)}\b[^\n]*\n+/i, "")
1092
1449
  return strip_leading_heading_separator(stripped) unless stripped == text
1093
1450
 
1094
1451
  lines = text.lines
@@ -1107,7 +1464,7 @@ module Gemstar
1107
1464
  end
1108
1465
 
1109
1466
  def strip_leading_heading_separator(text)
1110
- text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=]{3,})\s*\n+/, "")
1467
+ text.sub(/\A\s*(?:#{Regexp.escape("#")}{4,}|[-=~`^"']{3,})\s*\n+/, "")
1111
1468
  end
1112
1469
 
1113
1470
  def compare_versions(left, right)
@@ -1154,6 +1511,34 @@ module Gemstar
1154
1511
  package_version = source[:package_version].to_s
1155
1512
  registry_url = source[:registry_url].to_s
1156
1513
  provider_gem = source[:provider_gem].to_s
1514
+ if package_state[:package_scope] == "python"
1515
+ package_name = package_state[:name].to_s if package_name.empty?
1516
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty?
1517
+ registry_url = "https://pypi.org/project/#{package_name}/" if registry_url.empty? && !package_name.empty?
1518
+
1519
+ return {
1520
+ "info" => "Python package `#{package_name}` locked to `#{package_version}` in uv.lock",
1521
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1522
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1523
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1524
+ }
1525
+ end
1526
+
1527
+ if package_state[:package_scope] == "cargo"
1528
+ package_name = package_state[:metadata_package_name].to_s
1529
+ package_name = source[:package_name].to_s if package_name.empty?
1530
+ package_name = package_state[:name].to_s if package_name.empty?
1531
+ package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty?
1532
+ registry_url = "https://crates.io/crates/#{package_name}" if registry_url.empty? && !package_name.empty?
1533
+
1534
+ return {
1535
+ "info" => "Rust crate `#{package_name}` locked to `#{package_version}` in Cargo.lock",
1536
+ "project_uri" => registry_url.empty? ? nil : registry_url,
1537
+ "homepage_uri" => absolute_url?(remote) ? remote : nil,
1538
+ "source_code_uri" => repo_url.empty? ? nil : repo_url
1539
+ }
1540
+ end
1541
+
1157
1542
  package_name = package_state[:name].to_s if package_name.empty? && source[:type] == :npm
1158
1543
  package_version = (package_state[:new_version] || package_state[:old_version]).to_s if package_version.empty? && source[:type] == :npm
1159
1544
  registry_url = "https://www.npmjs.com/package/#{package_name}" if registry_url.empty? && !package_name.empty?
@@ -1201,6 +1586,11 @@ module Gemstar
1201
1586
 
1202
1587
  def metadata_adapter_for(package_state)
1203
1588
  return Gemstar::RubyGemsMetadata.new(package_state[:name]) if package_state[:package_scope] == "gems"
1589
+ return Gemstar::PyPIMetadata.new(package_state[:name]) if package_state[:package_scope] == "python"
1590
+ if package_state[:package_scope] == "cargo"
1591
+ package_name = package_state[:metadata_package_name] || package_state.dig(:source, :package_name) || package_state[:name]
1592
+ return Gemstar::CratesIOMetadata.new(package_name)
1593
+ end
1204
1594
  return nil unless package_state[:package_scope] == "js"
1205
1595
 
1206
1596
  provider_gem = package_state.dig(:source, :provider_gem)
@@ -1235,7 +1625,15 @@ module Gemstar
1235
1625
  when :home
1236
1626
  '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 .8 1.2 6.3v8.9h4.3V10h5v5.2h4.3V6.3L8 .8Zm5.2 13.3h-1.8V8.9H4.6v5.2H2.8V6.8L8 2.6l5.2 4.2v7.3Z"/></svg>'
1237
1627
  when :rubygems
1238
- '<svg viewBox="0 0 16 16" aria-hidden="true"><rect width="16" height="16" rx="2.6" fill="#fff"/><path fill="#111" d="m8 2.35 4.55 2.63v5.24L8 12.85l-4.55-2.63V4.98L8 2.35Zm0 1.3L4.58 5.62v3.96L8 11.55l3.42-1.97V5.62L8 3.65Zm0 1.07 2.5 1.44v2.88L8 10.48 5.5 9.04V6.16L8 4.72Z"/></svg>'
1628
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="#cc342d" d="M4.2 2.2h7.6l2.7 4.1L8 14.2 1.5 6.3l2.7-4.1Z"/><path fill="#ef5b55" d="m4.2 2.2 1.5 4.1H1.5l2.7-4.1Zm7.6 0 2.7 4.1h-4.2l1.5-4.1Z"/><path fill="#a91f1a" d="M5.7 6.3h4.6L8 14.2 5.7 6.3Z"/><path fill="#fff" fill-opacity=".42" d="m5 3.15.7 2H3.3l1.3-2h.4Z"/></svg>'
1629
+ when :cargo
1630
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="m2.2 5 5.8-3 5.8 3v6L8 14l-5.8-3V5Z"/><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="M2.4 5 8 8l5.6-3M8 8v6M5.2 3.45 11 6.5"/></svg>'
1631
+ when :javascript
1632
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.2"/><text x="8" y="10.8" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, monospace" font-size="6.3" font-weight="700" fill="currentColor">JS</text></svg>'
1633
+ when :python
1634
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" d="M8 1.8H5.6c-1.7 0-2.4.8-2.4 2.3v2.1h5.6v1.6H4.5C2.8 7.8 2 8.7 2 10.2c0 1.7 1 2.5 2.6 2.5H7"/><path fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" d="M8 14.2h2.4c1.7 0 2.4-.8 2.4-2.3V9.8H7.2V8.2h4.3c1.7 0 2.5-.9 2.5-2.4 0-1.7-1-2.5-2.6-2.5H9"/><circle cx="6" cy="4.2" r=".7" fill="currentColor"/><circle cx="10" cy="11.8" r=".7" fill="currentColor"/></svg>'
1635
+ when :package
1636
+ '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" d="m2.2 5 5.8-3 5.8 3v6L8 14l-5.8-3V5ZM2.4 5 8 8l5.6-3M8 8v6"/></svg>'
1239
1637
  when :info
1240
1638
  '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="4.5" r="0.9" fill="currentColor"/><path d="M8 7v4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
1241
1639
  else
@@ -1251,6 +1649,10 @@ module Gemstar
1251
1649
  end
1252
1650
  end
1253
1651
 
1652
+ def unchanged_worktree_package?
1653
+ @selected_to_revision_id == "worktree" && @selected_gem && @selected_gem[:status] == :unchanged
1654
+ end
1655
+
1254
1656
  def range_label(gem_state)
1255
1657
  old_version = gem_state[:old_version]
1256
1658
  new_version = gem_state[:new_version]
@@ -1262,7 +1664,7 @@ module Gemstar
1262
1664
  end
1263
1665
 
1264
1666
  def previous_section_version(versions, current_version)
1265
- ordered_versions = versions.sort_by { |version| -sortable_version_number(version) }
1667
+ ordered_versions = versions.sort { |left, right| compare_versions(right, left) }
1266
1668
  current_index = ordered_versions.index(current_version)
1267
1669
  return nil if current_index.nil?
1268
1670
 
@@ -1383,18 +1785,19 @@ module Gemstar
1383
1785
  html
1384
1786
  end
1385
1787
 
1386
- def detail_query(project:, from:, to:, filter:, scope:, package:)
1387
- "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, scope: scope, package: package)}"
1788
+ def detail_query(project:, from:, to:, filter:, scope:, package:, package_id: nil)
1789
+ "/detail?#{URI.encode_www_form({project: project, from: from, to: to, filter: filter, scope: scope, package: package, package_id: package_id}.compact)}"
1388
1790
  end
1389
1791
 
1390
- def project_query(project:, from:, to:, filter:, scope:, package:)
1792
+ def project_query(project:, from:, to:, filter:, scope:, package:, package_id: nil)
1391
1793
  params = {
1392
1794
  project: project,
1393
1795
  from: from,
1394
1796
  to: to,
1395
1797
  filter: filter,
1396
1798
  scope: scope,
1397
- package: package
1799
+ package: package,
1800
+ package_id: package_id
1398
1801
  }.compact
1399
1802
 
1400
1803
  "/?#{URI.encode_www_form(params)}"