gemstar 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c795357616121ccda76004c6fefa92194688472dd45950396c24a1924cf70a6
4
- data.tar.gz: d4922547dc024c923d751164698da5b672c48fd4b2e61384345e0de24b1f5430
3
+ metadata.gz: b290f73ca813d6ef39789e23d883f3bd382fbdcc17f0803cd5fdc296d7f60582
4
+ data.tar.gz: '058a2d17b98461a62d463ba673d170b621471e8f9693ea294da403382da5a759'
5
5
  SHA512:
6
- metadata.gz: 6caf7c992dcccd533da3cbcf8ed1e98ee44538faffbdf43c55e1ecc1af899497c70100c62e1ba5bbe9331fba6c78d2a65a571c860f4035b99dc63f44d94cb682
7
- data.tar.gz: 88e7f1557e01332a6d5dbd69301b0fcfb30548a80333d835476994a9b9ae92a5fdb8559bfa268d242ad11eaad266b432947277d52b185e40dcc66f166ee01854
6
+ metadata.gz: fe408b9d9259ab0771e41522238b35c259e624759ac27ffe013d689c76ebe0164905c5ee67bb7868e8cc43ff361568f854f8dccf2e513468176685a395bb9786
7
+ data.tar.gz: 149884982e1e2d124f7ade0bf565604106a455db2bab04f6b3811b316fb0386f72ed3fdaa0969369c310e3811e7735d97d29c147f9263f32f62c6c2fcb0d7487
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Change Log
2
2
 
3
- ## Unreleased
3
+ ## 1.0.2
4
+
5
+ - General server performance improvements.
6
+ - Server: Required by / Requires / date added information now hidden in a "Details" section by default.
7
+ - Server: Improve color hilighting in gems list.
8
+ - Server: Improve up/down/left/right arrow key navigation.
9
+ - Fix problem not fetching new changelogs due to extraneous caching.
10
+ - Fix `nil.include?` error fetching changelogs for gems without either `homepage_uri` or `source_code_uri`.
11
+ - Add gem-release as a development dependency.
4
12
 
5
13
  ## 1.0.1
6
14
 
data/lib/gemstar/cache.rb CHANGED
@@ -4,7 +4,7 @@ require "digest"
4
4
 
5
5
  module Gemstar
6
6
  class Cache
7
- MAX_CACHE_AGE = 60 * 60 * 24 * 7 # 1 week
7
+ MAX_CACHE_AGE = 60 * 60 * 24 # 1 day
8
8
  CACHE_DIR = File.join(Gemstar::Config.home_directory, "cache")
9
9
 
10
10
  @@initialized = false
@@ -16,12 +16,12 @@ module Gemstar
16
16
  @@initialized = true
17
17
  end
18
18
 
19
- def self.fetch(key, &block)
19
+ def self.fetch(key, force: false, &block)
20
20
  init
21
21
 
22
22
  path = path_for(key)
23
23
 
24
- if fresh?(path)
24
+ if !force && fresh?(path)
25
25
  content = File.read(path)
26
26
  return nil if content == "__404__"
27
27
  return content
@@ -59,6 +59,12 @@ module Gemstar
59
59
  @condition.broadcast
60
60
  end
61
61
 
62
+ def pending?(gem_name)
63
+ @mutex.synchronize do
64
+ @queued.include?(gem_name) || @in_progress.include?(gem_name)
65
+ end
66
+ end
67
+
62
68
  private
63
69
 
64
70
  def start_workers_unlocked
@@ -10,21 +10,21 @@ module Gemstar
10
10
 
11
11
  attr_reader :metadata
12
12
 
13
- def content(cache_only: false)
13
+ def content(cache_only: false, force_refresh: false)
14
14
  return @content if !cache_only && defined?(@content)
15
15
 
16
- result = fetch_changelog_content(cache_only: cache_only)
16
+ result = fetch_changelog_content(cache_only: cache_only, force_refresh: force_refresh)
17
17
  @content = result unless cache_only
18
18
  result
19
19
  end
20
20
 
21
- def sections(cache_only: false)
21
+ def sections(cache_only: false, force_refresh: false)
22
22
  return @sections if !cache_only && defined?(@sections)
23
23
 
24
24
  result = begin
25
- s = parse_changelog_sections(cache_only: cache_only)
25
+ s = parse_changelog_sections(cache_only: cache_only, force_refresh: force_refresh)
26
26
  if s.nil? || s.empty?
27
- s = parse_github_release_sections(cache_only: cache_only)
27
+ s = parse_github_release_sections(cache_only: cache_only, force_refresh: force_refresh)
28
28
  end
29
29
 
30
30
  pp @@candidates_found if Gemstar.debug? && !cache_only
@@ -74,10 +74,10 @@ module Gemstar
74
74
  nil
75
75
  end
76
76
 
77
- def changelog_uri_candidates(cache_only: false)
77
+ def changelog_uri_candidates(cache_only: false, force_refresh: false)
78
78
  candidates = []
79
79
 
80
- repo_uri = @metadata.repo_uri(cache_only: cache_only)
80
+ repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
81
81
  return [] if repo_uri.nil? || repo_uri.empty?
82
82
 
83
83
  if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby}
@@ -100,14 +100,14 @@ module Gemstar
100
100
 
101
101
  remote_repository = RemoteRepository.new(base)
102
102
 
103
- branches = aws_style ? [""] : remote_repository.find_main_branch
103
+ branches = aws_style ? [""] : remote_repository.find_main_branch(cache_only: cache_only, force_refresh: force_refresh)
104
104
 
105
105
  candidates += paths.product(branches).map do |file, branch|
106
106
  uri = aws_style ? "#{base}/#{file}" : "#{base}/#{branch}/#{file}"
107
107
  end
108
108
 
109
109
  # Add the gem's changelog_uri last as it's usually not the most parsable:
110
- meta = @metadata.meta(cache_only: cache_only)
110
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
111
111
  candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta
112
112
 
113
113
  candidates.flatten!
@@ -117,14 +117,14 @@ module Gemstar
117
117
  candidates
118
118
  end
119
119
 
120
- def fetch_changelog_content(cache_only: false)
120
+ def fetch_changelog_content(cache_only: false, force_refresh: false)
121
121
  content = nil
122
122
 
123
- changelog_uri_candidates(cache_only: cache_only).find do |candidate|
123
+ changelog_uri_candidates(cache_only: cache_only, force_refresh: force_refresh).find do |candidate|
124
124
  content = if cache_only
125
125
  Cache.peek("changelog-#{candidate}")
126
126
  else
127
- Cache.fetch("changelog-#{candidate}") do
127
+ Cache.fetch("changelog-#{candidate}", force: force_refresh) do
128
128
  URI.open(candidate, read_timeout: 8)&.read
129
129
  rescue => e
130
130
  puts "#{candidate}: #{e}" if Gemstar.debug?
@@ -152,11 +152,11 @@ module Gemstar
152
152
  /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])(?:\s*[-(].*)?/i
153
153
  ]
154
154
 
155
- def parse_changelog_sections(cache_only: false)
155
+ def parse_changelog_sections(cache_only: false, force_refresh: false)
156
156
  # If the fetched content looks like a GitHub Releases HTML page, return {}
157
157
  # so that the GitHub releases scraper can handle it. This avoids
158
158
  # accidentally parsing HTML from /releases pages as a markdown changelog.
159
- c = content(cache_only: cache_only)
159
+ c = content(cache_only: cache_only, force_refresh: force_refresh)
160
160
  return {} if c.nil? || c.strip.empty?
161
161
  if (c.include?("<html") || c.include?("<!DOCTYPE html")) &&
162
162
  (c.include?('data-test-selector="body-content"') || c.include?("/releases/tag/"))
@@ -212,14 +212,14 @@ module Gemstar
212
212
  sections
213
213
  end
214
214
 
215
- def parse_github_release_sections(cache_only: false)
215
+ def parse_github_release_sections(cache_only: false, force_refresh: false)
216
216
  begin
217
217
  require "nokogiri"
218
218
  rescue LoadError
219
219
  return {}
220
220
  end
221
221
 
222
- repo_uri = @metadata&.repo_uri(cache_only: cache_only)
222
+ repo_uri = @metadata&.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
223
223
  return {} unless repo_uri&.include?("github.com")
224
224
 
225
225
  url = github_releases_url(repo_uri)
@@ -228,9 +228,9 @@ module Gemstar
228
228
  html = if cache_only
229
229
  Cache.peek("releases-#{url}")
230
230
  else
231
- Cache.fetch("releases-#{url}") do
232
- begin
233
- URI.open(url, read_timeout: 8)&.read
231
+ Cache.fetch("releases-#{url}", force: force_refresh) do
232
+ begin
233
+ URI.open(url, read_timeout: 8)&.read
234
234
  rescue => e
235
235
  puts "#{url}: #{e}" if Gemstar.debug?
236
236
  nil
@@ -8,6 +8,7 @@ module Gemstar
8
8
  DEFAULT_PORT = 2112
9
9
  RELOAD_ENV_VAR = "GEMSTAR_RELOAD_ACTIVE"
10
10
  RELOAD_GLOB = "{lib/**/*.rb,lib/gemstar/web/templates/**/*,bin/gemstar,README.md}"
11
+ RELOAD_DIRS = %w[lib bin].freeze
11
12
 
12
13
  attr_reader :bind
13
14
  attr_reader :port
@@ -71,7 +72,7 @@ module Gemstar
71
72
  end
72
73
 
73
74
  puts "Starting gemstar server in reload mode..."
74
- puts "Watching changes matching #{RELOAD_GLOB.inspect}"
75
+ puts "Watching directories #{RELOAD_DIRS.join(", ")} with glob #{RELOAD_GLOB.inspect}"
75
76
 
76
77
  env = ENV.to_h.merge(RELOAD_ENV_VAR => "1")
77
78
  exec env, *rerun_command(rerun_executable)
@@ -86,12 +87,35 @@ module Gemstar
86
87
  def rerun_command(rerun_executable)
87
88
  [
88
89
  rerun_executable,
90
+ "--dir",
91
+ RELOAD_DIRS.join(","),
89
92
  "--pattern",
90
93
  RELOAD_GLOB,
94
+ "--signal",
95
+ "INT,KILL",
96
+ "--wait",
97
+ "1",
98
+ "--name",
99
+ "Gemstar",
100
+ "--background",
91
101
  "--",
92
- Gem.ruby,
93
- File.expand_path($PROGRAM_NAME)
94
- ] + server_arguments_without_reload
102
+ *server_runner_command,
103
+ *server_arguments_without_reload
104
+ ]
105
+ end
106
+
107
+ def server_runner_command
108
+ return %w[bundle exec gemstar] if ENV["BUNDLE_GEMFILE"]
109
+
110
+ repo_executable = File.expand_path("../../../bin/gemstar", __dir__)
111
+ return [repo_executable] if File.exist?(repo_executable)
112
+
113
+ gem_executable = Gem.bin_path("gemstar", "gemstar")
114
+ return [gem_executable] if gem_executable
115
+
116
+ ["gemstar"]
117
+ rescue Gem::Exception
118
+ ["gemstar"]
95
119
  end
96
120
 
97
121
  def server_arguments_without_reload
@@ -26,6 +26,10 @@ module Gemstar
26
26
  @directory = File.dirname(@gemfile_path)
27
27
  @lockfile_path = File.join(@directory, "Gemfile.lock")
28
28
  @name = File.basename(@directory)
29
+ @lockfile_cache = {}
30
+ @gem_states_cache = {}
31
+ @gem_added_on_cache = {}
32
+ @history_cache = {}
29
33
  end
30
34
 
31
35
  def git_repo
@@ -84,7 +88,9 @@ module Gemstar
84
88
  end
85
89
 
86
90
  def lockfile_for_revision(revision_id)
87
- return current_lockfile if revision_id.nil? || revision_id == "worktree"
91
+ cache_key = revision_id || "worktree"
92
+ return @lockfile_cache[cache_key] if @lockfile_cache.key?(cache_key)
93
+ return @lockfile_cache[cache_key] = current_lockfile if revision_id.nil? || revision_id == "worktree"
88
94
  return nil unless lockfile?
89
95
 
90
96
  relative_lockfile_path = git_repo.relative_path(lockfile_path)
@@ -93,16 +99,19 @@ module Gemstar
93
99
  content = git_repo.try_git_command(["show", "#{revision_id}:#{relative_lockfile_path}"])
94
100
  return nil if content.nil? || content.empty?
95
101
 
96
- Gemstar::LockFile.new(content: content)
102
+ @lockfile_cache[cache_key] = Gemstar::LockFile.new(content: content)
97
103
  end
98
104
 
99
105
  def gem_states(from_revision_id: default_from_revision_id, to_revision_id: "worktree")
106
+ cache_key = [from_revision_id, to_revision_id]
107
+ return @gem_states_cache[cache_key] if @gem_states_cache.key?(cache_key)
108
+
100
109
  from_lockfile = lockfile_for_revision(from_revision_id)
101
110
  to_lockfile = lockfile_for_revision(to_revision_id)
102
111
  from_specs = from_lockfile&.specs || {}
103
112
  to_specs = to_lockfile&.specs || {}
104
113
 
105
- (from_specs.keys | to_specs.keys).map do |gem_name|
114
+ @gem_states_cache[cache_key] = (from_specs.keys | to_specs.keys).map do |gem_name|
106
115
  old_version = from_specs[gem_name]
107
116
  new_version = to_specs[gem_name]
108
117
  bundle_origins = to_lockfile&.origins_for(gem_name) || []
@@ -120,23 +129,25 @@ module Gemstar
120
129
  end
121
130
 
122
131
  def gem_added_on(gem_name, revision_id: "worktree")
132
+ cache_key = [gem_name, revision_id]
133
+ return @gem_added_on_cache[cache_key] if @gem_added_on_cache.key?(cache_key)
123
134
  return nil unless lockfile?
124
135
 
125
136
  target_lockfile = lockfile_for_revision(revision_id)
126
- return nil unless target_lockfile&.specs&.key?(gem_name)
137
+ return @gem_added_on_cache[cache_key] = nil unless target_lockfile&.specs&.key?(gem_name)
127
138
 
128
139
  relative_path = git_repo.relative_path(lockfile_path)
129
- return nil if relative_path.nil?
140
+ return @gem_added_on_cache[cache_key] = nil if relative_path.nil?
130
141
 
131
142
  first_seen_revision = history_for_paths([relative_path], limit: nil, reverse: true).find do |revision|
132
143
  lockfile = lockfile_for_revision(revision[:id])
133
144
  lockfile&.specs&.key?(gem_name)
134
145
  end
135
146
 
136
- return worktree_added_on_info if first_seen_revision.nil? && revision_id == "worktree"
137
- return nil unless first_seen_revision
147
+ return @gem_added_on_cache[cache_key] = worktree_added_on_info if first_seen_revision.nil? && revision_id == "worktree"
148
+ return @gem_added_on_cache[cache_key] = nil unless first_seen_revision
138
149
 
139
- {
150
+ @gem_added_on_cache[cache_key] = {
140
151
  project_name: name,
141
152
  date: first_seen_revision[:authored_at].strftime("%Y-%m-%d"),
142
153
  revision: first_seen_revision[:short_sha],
@@ -162,10 +173,13 @@ module Gemstar
162
173
  return [] if git_root.nil? || git_root.empty?
163
174
  return [] if paths.empty?
164
175
 
176
+ cache_key = [paths.sort, limit, reverse]
177
+ return @history_cache[cache_key] if @history_cache.key?(cache_key)
178
+
165
179
  output = git_repo.log_for_paths(paths, limit: limit, reverse: reverse)
166
180
  return [] if output.nil? || output.empty?
167
181
 
168
- output.lines.filter_map do |line|
182
+ @history_cache[cache_key] = output.lines.filter_map do |line|
169
183
  full_sha, short_sha, authored_at, subject = line.strip.split("\u001f", 4)
170
184
  next if full_sha.nil?
171
185
 
@@ -6,11 +6,20 @@ module Gemstar
6
6
  @repository_uri = repository_uri
7
7
  end
8
8
 
9
- def find_main_branch
9
+ def find_main_branch(cache_only: false, force_refresh: false)
10
10
  # Attempt loading .gitignore (assumed to be present in all repos) from either
11
11
  # main or master branch:
12
12
  %w[main master].each do |branch|
13
- Cache.fetch("gitignore-#{branch}") do
13
+ cache_key = "gitignore-#{@repository_uri}-#{branch}"
14
+
15
+ if cache_only
16
+ content = Cache.peek(cache_key)
17
+ return [branch] unless content.nil?
18
+
19
+ next
20
+ end
21
+
22
+ Cache.fetch(cache_key, force: force_refresh) do
14
23
  content = begin
15
24
  URI.open("#{@repository_uri}/#{branch}/.gitignore", read_timeout: 8)&.read
16
25
  rescue
@@ -10,14 +10,14 @@ module Gemstar
10
10
 
11
11
  attr_reader :gem_name
12
12
 
13
- def meta(cache_only: false)
13
+ def meta(cache_only: false, force_refresh: false)
14
14
  return @meta if !cache_only && defined?(@meta)
15
15
 
16
16
  json = if cache_only
17
17
  Cache.peek("rubygems-#{gem_name}")
18
18
  else
19
19
  url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json"
20
- Cache.fetch("rubygems-#{gem_name}") do
20
+ Cache.fetch("rubygems-#{gem_name}", force: force_refresh) do
21
21
  URI.open(url).read
22
22
  end
23
23
  end
@@ -32,8 +32,8 @@ module Gemstar
32
32
  parsed
33
33
  end
34
34
 
35
- def repo_uri(cache_only: false)
36
- resolved_meta = meta(cache_only: cache_only)
35
+ def repo_uri(cache_only: false, force_refresh: false)
36
+ resolved_meta = meta(cache_only: cache_only, force_refresh: force_refresh)
37
37
  return nil unless resolved_meta
38
38
 
39
39
  return @repo_uri if !cache_only && defined?(@repo_uri)
@@ -43,7 +43,7 @@ module Gemstar
43
43
 
44
44
  if uri.nil?
45
45
  uri = resolved_meta["homepage_uri"]
46
- if uri.include?("github.com")
46
+ if uri&.include?("github.com")
47
47
  uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}]
48
48
  end
49
49
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemstar # :nodoc:
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
 
6
6
  def self.debug?
7
7
  return @debug if defined?(@debug)
@@ -12,12 +12,18 @@ end
12
12
  module Gemstar
13
13
  module Web
14
14
  class App < Roda
15
+ MISSING_METADATA = Object.new
16
+
15
17
  class << self
16
18
  def build(projects:, config_home:, cache_warmer: nil)
17
19
  Class.new(self) do
18
20
  opts[:projects] = projects
19
21
  opts[:config_home] = config_home
20
22
  opts[:cache_warmer] = cache_warmer
23
+ opts[:change_sections_cache] = {}
24
+ opts[:detail_html_cache] = {}
25
+ opts[:detail_request_cache] = {}
26
+ opts[:metadata_cache] = {}
21
27
  end.freeze.app
22
28
  end
23
29
  end
@@ -26,7 +32,7 @@ module Gemstar
26
32
  @projects = self.class.opts.fetch(:projects)
27
33
  @config_home = self.class.opts.fetch(:config_home)
28
34
  @cache_warmer = self.class.opts[:cache_warmer]
29
- @metadata_cache = {}
35
+ @metadata_cache = self.class.opts[:metadata_cache]
30
36
  apply_no_cache_headers!
31
37
 
32
38
  r.root do
@@ -39,9 +45,17 @@ module Gemstar
39
45
  end
40
46
 
41
47
  r.get "detail" do
48
+ request_cache_key = detail_request_cache_key(r.params)
49
+ request_cache = self.class.opts[:detail_request_cache]
50
+ if request_cache_key && request_cache.key?(request_cache_key)
51
+ next request_cache[request_cache_key]
52
+ end
53
+
42
54
  load_state(r.params)
43
55
  prioritize_selected_gem
44
- render_detail
56
+ detail_html = render_detail
57
+ request_cache[request_cache_key] = detail_html if request_cache_key
58
+ detail_html
45
59
  end
46
60
 
47
61
  r.get "gemfile" do
@@ -67,6 +81,28 @@ module Gemstar
67
81
  response["Expires"] = "0"
68
82
  end
69
83
 
84
+ def detail_request_cache_key(params)
85
+ project_index = selected_project_index(params["project"])
86
+ project = @projects[project_index]
87
+ return nil unless project
88
+
89
+ lockfile_stamp =
90
+ if File.file?(project.lockfile_path)
91
+ File.mtime(project.lockfile_path).to_i
92
+ else
93
+ 0
94
+ end
95
+
96
+ [
97
+ project_index,
98
+ params["from"],
99
+ params["to"],
100
+ params["filter"],
101
+ params["gem"],
102
+ lockfile_stamp
103
+ ]
104
+ end
105
+
70
106
  def page_title
71
107
  return "Gemstar" unless @selected_project
72
108
 
@@ -385,21 +421,38 @@ module Gemstar
385
421
  def render_detail
386
422
  return empty_detail_html unless @selected_gem
387
423
 
388
- metadata = metadata_for(@selected_gem[:name])
389
- detail_pending = detail_pending?(@selected_gem[:name], metadata)
424
+ cache_key = [
425
+ @selected_project_index,
426
+ @selected_from_revision_id,
427
+ @selected_to_revision_id,
428
+ @selected_filter,
429
+ @selected_gem[:name],
430
+ @selected_gem[:old_version],
431
+ @selected_gem[:new_version],
432
+ @selected_gem[:status]
433
+ ]
434
+ detail_cache = self.class.opts[:detail_html_cache]
435
+ return detail_cache[cache_key] if detail_cache.key?(cache_key)
436
+
437
+ metadata = metadata_for(@selected_gem[:name], refresh_if_missing: true)
438
+ groups = grouped_change_sections(@selected_gem)
439
+ detail_pending = detail_pending?(@selected_gem[:name], metadata, groups)
390
440
 
391
- <<~HTML
392
- <section class="detail" data-detail-panel 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, gem: @selected_gem[:name]))}">
441
+ detail_html = <<~HTML
442
+ <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, gem: @selected_gem[:name]))}">
393
443
  #{render_detail_hero(metadata)}
394
444
  #{render_detail_loading_notice if detail_pending}
395
- #{render_detail_revision_panel}
445
+ #{render_detail_revision_panel(groups)}
396
446
  </section>
397
447
  HTML
448
+
449
+ detail_cache[cache_key] = detail_html unless detail_pending
450
+ detail_html
398
451
  end
399
452
 
400
453
  def empty_detail_html
401
454
  <<~HTML
402
- <section class="detail" data-detail-panel>
455
+ <section class="detail" data-detail-panel tabindex="0">
403
456
  <div class="empty-panel">
404
457
  <h2>No gem selected</h2>
405
458
  <p>Choose a gem from the list to inspect its current version and changelog revisions.</p>
@@ -426,13 +479,13 @@ module Gemstar
426
479
  <section class="detail-hero">
427
480
  <div class="detail-hero-copy">
428
481
  <div class="detail-title-row">
429
- <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
482
+ <div class="detail-title-lockup">
483
+ <h2>#{title_markup}#{bundled_version ? %(<span class="detail-title-version"> #{h(bundled_version)}</span>) : ""}</h2>
484
+ </div>
430
485
  #{render_detail_links(metadata)}
431
486
  </div>
432
487
  <p class="detail-subtitle">#{description ? h(description) : "Metadata will appear here when RubyGems information is available."}</p>
433
- #{render_added_on(added_on)}
434
- #{render_dependency_origins(bundle_origins)}
435
- #{render_requirements(requirement_names)}
488
+ #{render_dependency_details(bundle_origins, requirement_names, added_on)}
436
489
  </div>
437
490
  </section>
438
491
  HTML
@@ -449,12 +502,12 @@ module Gemstar
449
502
 
450
503
  <<~HTML
451
504
  <div class="detail-origin">
452
- <p>Added to #{h(added_on[:project_name])} on #{h(added_on[:date])} (#{revision_markup}).</p>
505
+ <p>Added to project <strong>#{h(added_on[:project_name])}</strong> on #{h(added_on[:date])} (#{revision_markup}).</p>
453
506
  </div>
454
507
  HTML
455
508
  end
456
509
 
457
- def render_dependency_origins(bundle_origins)
510
+ def dependency_origin_items(bundle_origins)
458
511
  origins = Array(bundle_origins).filter_map do |origin|
459
512
  path = Array(origin[:path]).compact
460
513
  display_path = path.dup
@@ -465,17 +518,6 @@ module Gemstar
465
518
  linked_path = linked_gem_chain(["Gemfile", *display_path])
466
519
  origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
467
520
  end.uniq
468
- return "" if origins.empty?
469
-
470
- items = origins.map { |origin| "<li>#{origin}</li>" }.join
471
- <<~HTML
472
- <div class="detail-origin">
473
- <strong>Required by</strong>
474
- <ul class="detail-origin-list">
475
- #{items}
476
- </ul>
477
- </div>
478
- HTML
479
521
  end
480
522
 
481
523
  def render_detail_links(metadata)
@@ -495,18 +537,35 @@ module Gemstar
495
537
  HTML
496
538
  end
497
539
 
498
- def render_requirements(requirement_names)
499
- names = Array(requirement_names).compact.uniq
500
- return "" if names.empty?
540
+ def render_dependency_details(bundle_origins, requirement_names, added_on)
541
+ required_by = dependency_origin_items(bundle_origins)
542
+ requires = Array(requirement_names).compact.uniq.map { |name| internal_gem_link(name) }
543
+ added_markup = render_added_on(added_on)
544
+ return "" if required_by.empty? && requires.empty? && added_markup.empty?
501
545
 
502
- items = names.map { |name| "<li>#{internal_gem_link(name)}</li>" }.join
503
546
  <<~HTML
504
- <div class="detail-origin">
505
- <strong>Requires</strong>
547
+ <details class="detail-disclosure">
548
+ <summary><span class="detail-disclosure-caret" aria-hidden="true"></span><h3>Details</h3></summary>
549
+ <div class="detail-disclosure-panel">
550
+ #{added_markup}
551
+ #{render_dependency_popover_section("Required by", required_by)}
552
+ #{render_dependency_popover_section("Requires", requires)}
553
+ </div>
554
+ </details>
555
+ HTML
556
+ end
557
+
558
+ def render_dependency_popover_section(title, items)
559
+ return "" if items.empty?
560
+
561
+ list_items = items.map { |item| "<li>#{item}</li>" }.join
562
+ <<~HTML
563
+ <section class="detail-info-section">
564
+ <strong>#{h(title)}</strong>
506
565
  <ul class="detail-origin-list">
507
- #{items}
566
+ #{list_items}
508
567
  </ul>
509
- </div>
568
+ </section>
510
569
  HTML
511
570
  end
512
571
 
@@ -554,14 +613,12 @@ module Gemstar
554
613
  %(<a href="#{h(href)}" data-gem-link-inline="true">#{h(name)}</a>)
555
614
  end
556
615
 
557
- def render_detail_revision_panel
558
- groups = grouped_change_sections(@selected_gem)
559
-
616
+ def render_detail_revision_panel(groups)
560
617
  <<~HTML
561
618
  <section class="revision-panel">
562
619
  #{render_revision_group("Latest", groups[:latest], empty_message: nil) if groups[:latest].any?}
563
620
  #{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")}
564
- #{render_revision_group("Previous changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
621
+ #{render_revision_group("Earlier changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
565
622
  </section>
566
623
  HTML
567
624
  end
@@ -637,11 +694,15 @@ module Gemstar
637
694
  end
638
695
 
639
696
  def change_sections(gem_state)
697
+ cache_key = [gem_state[:name], gem_state[:old_version], gem_state[:new_version], gem_state[:status]]
698
+ change_sections_cache = self.class.opts[:change_sections_cache]
699
+ return change_sections_cache[cache_key] if change_sections_cache.key?(cache_key)
700
+
640
701
  return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil?
641
702
 
642
703
  metadata = Gemstar::RubyGemsMetadata.new(gem_state[:name])
643
- sections = Gemstar::ChangeLog.new(metadata).sections(cache_only: true)
644
- return [] if sections.nil? || sections.empty?
704
+ sections = resolved_sections(metadata, gem_state)
705
+ return change_sections_cache[cache_key] = [] if sections.nil? || sections.empty?
645
706
 
646
707
  current_version = gem_state[:new_version] || gem_state[:old_version]
647
708
  previous_version = gem_state[:old_version]
@@ -660,11 +721,45 @@ module Gemstar
660
721
  }
661
722
  end
662
723
 
663
- rendered_sections.sort_by { |section| section_sort_key(section) }
724
+ change_sections_cache[cache_key] = rendered_sections.sort_by { |section| section_sort_key(section) }
664
725
  rescue StandardError
665
726
  []
666
727
  end
667
728
 
729
+ def resolved_sections(metadata, gem_state)
730
+ changelog = Gemstar::ChangeLog.new(metadata)
731
+ cached_sections = changelog.sections(cache_only: true) || {}
732
+ return cached_sections unless selected_gem_requires_refresh?(gem_state, cached_sections)
733
+
734
+ @metadata_cache.delete(gem_state[:name])
735
+ metadata.meta(cache_only: false, force_refresh: true)
736
+ metadata.repo_uri(cache_only: false, force_refresh: true)
737
+ Gemstar::ChangeLog.new(metadata).sections(cache_only: false, force_refresh: true) || cached_sections
738
+ end
739
+
740
+ def selected_gem_requires_refresh?(gem_state, cached_sections)
741
+ return false unless @selected_gem && gem_state[:name] == @selected_gem[:name]
742
+
743
+ bundled_version = gem_state[:new_version] || gem_state[:old_version]
744
+ return false if bundled_version.nil?
745
+ metadata = metadata_for(gem_state[:name]) || {}
746
+ has_upstream_release_source =
747
+ !metadata["changelog_uri"].to_s.empty? ||
748
+ !metadata["source_code_uri"].to_s.empty? ||
749
+ !metadata["homepage_uri"].to_s.empty?
750
+ return false unless has_upstream_release_source
751
+ return true if cached_sections.nil? || cached_sections.empty?
752
+
753
+ cached_versions = cached_sections.keys
754
+ return true unless cached_versions.include?(bundled_version)
755
+
756
+ compare_versions(bundled_version, newest_version(cached_versions)) == 1
757
+ end
758
+
759
+ def newest_version(versions)
760
+ Array(versions).max { |left, right| compare_versions(left, right) }
761
+ end
762
+
668
763
  def section_kind(version, previous_version, current_version, status)
669
764
  return :future if compare_versions(version, current_version) == 1
670
765
  return :current if status == :added && compare_versions(version, current_version) <= 0
@@ -736,14 +831,25 @@ module Gemstar
736
831
  left.to_s <=> right.to_s
737
832
  end
738
833
 
739
- def metadata_for(gem_name)
740
- @metadata_cache[gem_name] ||= Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
834
+ def metadata_for(gem_name, refresh_if_missing: false)
835
+ cached = @metadata_cache[gem_name]
836
+ return nil if cached.equal?(MISSING_METADATA)
837
+ return cached if cached
838
+
839
+ metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true)
840
+ if metadata.nil? && refresh_if_missing
841
+ metadata = Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: false, force_refresh: true)
842
+ end
843
+
844
+ @metadata_cache[gem_name] = metadata || MISSING_METADATA
845
+ metadata
741
846
  rescue StandardError
847
+ @metadata_cache[gem_name] = MISSING_METADATA
742
848
  nil
743
849
  end
744
850
 
745
- def detail_pending?(gem_name, metadata)
746
- metadata.nil? && change_sections({ name: gem_name, old_version: @selected_gem[:old_version], new_version: @selected_gem[:new_version], status: @selected_gem[:status] }).empty?
851
+ def detail_pending?(gem_name, metadata, groups)
852
+ false
747
853
  end
748
854
 
749
855
  def icon_button(label, url, icon_type:)
@@ -762,6 +868,8 @@ module Gemstar
762
868
  '<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>'
763
869
  when :rubygems
764
870
  '<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>'
871
+ when :info
872
+ '<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>'
765
873
  else
766
874
  '<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>'
767
875
  end
@@ -812,11 +920,25 @@ module Gemstar
812
920
  return nil if previous_sections.any? { |section| section[:version] == version }
813
921
  return nil if latest_sections.any? { |section| section[:version] == version }
814
922
 
923
+ metadata = metadata_for(gem_state[:name]) || {}
815
924
  repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true)
816
- repo_link = if repo_url.to_s.empty?
925
+ fallback_url =
926
+ if !repo_url.to_s.empty?
927
+ repo_url
928
+ elsif metadata["project_uri"]
929
+ metadata["project_uri"]
930
+ else
931
+ metadata["documentation_uri"]
932
+ end
933
+ fallback_label = if repo_url.to_s.empty?
934
+ metadata["project_uri"] ? "the RubyGems page" : "the gem documentation"
935
+ else
817
936
  "the gem repository"
937
+ end
938
+ fallback_link = if fallback_url.to_s.empty?
939
+ fallback_label
818
940
  else
819
- %(<a href="#{h(repo_url)}" target="_blank" rel="noreferrer">the gem repository</a>)
941
+ %(<a href="#{h(fallback_url)}" target="_blank" rel="noreferrer">#{h(fallback_label)}</a>)
820
942
  end
821
943
 
822
944
  {
@@ -824,7 +946,7 @@ module Gemstar
824
946
  title: version,
825
947
  kind: :current,
826
948
  previous_version: fallback_previous_version_for(gem_state, previous_sections),
827
- html: "<p>No release information available. Check #{repo_link} for more information.</p>"
949
+ html: "<p>No release information available. Check #{fallback_link} for more information.</p>"
828
950
  }
829
951
  end
830
952
 
@@ -280,10 +280,12 @@
280
280
  .gem-row[hidden] {
281
281
  display: none;
282
282
  }
283
- .gem-row:hover,
284
- .gem-row.is-selected {
283
+ .gem-row:hover {
285
284
  background: #faf8f3;
286
285
  }
286
+ .gem-row.is-selected {
287
+ background: #f2ece0;
288
+ }
287
289
  .gem-name {
288
290
  font-weight: 700;
289
291
  font-size: 0.9rem;
@@ -343,6 +345,10 @@
343
345
  min-width: 0;
344
346
  width: 100%;
345
347
  }
348
+ .detail-title-lockup {
349
+ min-width: 0;
350
+ flex: 1 1 auto;
351
+ }
346
352
  .detail-hero-copy h2 {
347
353
  margin: 0;
348
354
  font-size: 2.35rem;
@@ -360,6 +366,50 @@
360
366
  align-self: start;
361
367
  flex: 0 0 auto;
362
368
  }
369
+ .detail-disclosure {
370
+ margin-top: 0.2rem;
371
+ border: 1px solid #ece8df;
372
+ border-radius: 0.3rem;
373
+ background: #fff;
374
+ padding: 0.45rem 0.55rem;
375
+ }
376
+ .detail-disclosure summary {
377
+ cursor: pointer;
378
+ user-select: none;
379
+ list-style: none;
380
+ display: flex;
381
+ align-items: center;
382
+ gap: 0.35rem;
383
+ }
384
+ .detail-disclosure summary::-webkit-details-marker {
385
+ display: none;
386
+ }
387
+ .detail-disclosure-caret {
388
+ width: 0;
389
+ height: 0;
390
+ border-top: 0.3rem solid transparent;
391
+ border-bottom: 0.3rem solid transparent;
392
+ border-left: 0.42rem solid var(--muted);
393
+ transition: transform 120ms ease;
394
+ flex: 0 0 auto;
395
+ }
396
+ .detail-disclosure[open] .detail-disclosure-caret {
397
+ transform: rotate(90deg);
398
+ }
399
+ .detail-disclosure summary h3 {
400
+ margin: 0;
401
+ font-size: 1.1rem;
402
+ line-height: 1.2;
403
+ color: var(--ink);
404
+ }
405
+ .detail-disclosure-panel {
406
+ margin-top: 0.45rem;
407
+ }
408
+ .detail-info-section + .detail-info-section {
409
+ margin-top: 0.45rem;
410
+ padding-top: 0.45rem;
411
+ border-top: 1px solid #f0ede6;
412
+ }
363
413
  .panel-heading-meta {
364
414
  color: var(--muted);
365
415
  font-size: 0.76rem;
@@ -370,6 +420,24 @@
370
420
  background: #fff;
371
421
  padding: 0.5rem;
372
422
  }
423
+ .detail-loading-shell {
424
+ min-height: 14rem;
425
+ display: grid;
426
+ place-items: center;
427
+ }
428
+ .detail-loading-spinner {
429
+ width: 2rem;
430
+ height: 2rem;
431
+ border-radius: 999px;
432
+ border: 0.18rem solid rgba(31, 27, 23, 0.12);
433
+ border-top-color: rgba(31, 27, 23, 0.62);
434
+ animation: detail-loading-spin 0.85s linear infinite;
435
+ }
436
+ @keyframes detail-loading-spin {
437
+ to {
438
+ transform: rotate(360deg);
439
+ }
440
+ }
373
441
  .link-strip {
374
442
  display: flex;
375
443
  gap: 0.3rem;
@@ -407,6 +475,7 @@
407
475
  .detail-origin-list {
408
476
  margin: 0.2rem 0 0 1.1rem;
409
477
  padding: 0;
478
+ font-size: 0.88rem;
410
479
  }
411
480
  .detail-origin-list li + li {
412
481
  margin-top: 0.12rem;
@@ -9,6 +9,9 @@
9
9
  let detailPanel = document.querySelector("[data-detail-panel]");
10
10
  const gemLinks = Array.from(document.querySelectorAll("[data-gem-link]"));
11
11
  let detailPollTimer = null;
12
+ let detailLoadingTimer = null;
13
+ let detailRequestToken = 0;
14
+ let activeDetailUrl = detailPanel ? detailPanel.dataset.detailUrl : null;
12
15
  let currentFilter = <%= selected_filter_json %>;
13
16
  let currentSearch = "";
14
17
  const emptyDetailHtml = <%= empty_detail_html_json %>;
@@ -21,6 +24,14 @@
21
24
  });
22
25
  };
23
26
  const requestedGemName = () => new URL(window.location.href).searchParams.get("gem");
27
+ const isSidebarFocused = () => document.activeElement === sidebarPanel;
28
+ const isDetailFocused = () => detailPanel && document.activeElement === detailPanel;
29
+ const focusSidebar = () => {
30
+ if (sidebarPanel) sidebarPanel.focus({ preventScroll: true });
31
+ };
32
+ const focusDetail = () => {
33
+ if (detailPanel) detailPanel.focus({ preventScroll: true });
34
+ };
24
35
 
25
36
  const applyGemFilter = (filter) => {
26
37
  currentFilter = filter;
@@ -78,9 +89,14 @@
78
89
 
79
90
  const replaceDetail = (html) => {
80
91
  if (!detailPanel) return;
92
+ const shouldRestoreDetailFocus = isDetailFocused();
81
93
  detailPanel.outerHTML = html;
82
94
  detailPanel = document.querySelector("[data-detail-panel]");
83
95
  if (detailPanel) detailPanel.scrollTop = 0;
96
+ activeDetailUrl = detailPanel ? detailPanel.dataset.detailUrl : null;
97
+ if (shouldRestoreDetailFocus) {
98
+ focusDetail();
99
+ }
84
100
  scheduleDetailPoll();
85
101
  };
86
102
 
@@ -91,10 +107,37 @@
91
107
  }
92
108
  };
93
109
 
110
+ const stopDetailLoading = () => {
111
+ if (detailLoadingTimer) {
112
+ clearTimeout(detailLoadingTimer);
113
+ detailLoadingTimer = null;
114
+ }
115
+ };
116
+
117
+ const loadingDetailHtml = (url) => `
118
+ <section class="detail" data-detail-panel tabindex="0" data-detail-pending="false" data-detail-url="${url}">
119
+ <div class="detail-loading-shell" aria-hidden="true">
120
+ <div class="detail-loading-spinner"></div>
121
+ </div>
122
+ </section>
123
+ `;
124
+
94
125
  const fetchDetail = (url, pushHistory = true) => {
126
+ const normalizedUrl = new URL(url, window.location.origin).toString();
127
+ const requestToken = ++detailRequestToken;
128
+ activeDetailUrl = normalizedUrl;
129
+ stopDetailLoading();
130
+ detailLoadingTimer = setTimeout(() => {
131
+ if (requestToken !== detailRequestToken || normalizedUrl !== activeDetailUrl) return;
132
+ replaceDetail(loadingDetailHtml(normalizedUrl));
133
+ }, 1000);
134
+
95
135
  fetch(url, { headers: { "X-Requested-With": "gemstar-detail" } })
96
136
  .then((response) => response.text())
97
137
  .then((html) => {
138
+ stopDetailLoading();
139
+ if (requestToken !== detailRequestToken || normalizedUrl !== activeDetailUrl) return;
140
+
98
141
  replaceDetail(html);
99
142
  if (pushHistory) {
100
143
  const pageUrl = new URL(window.location.href);
@@ -105,24 +148,28 @@
105
148
  }
106
149
  const detailUrl = new URL(url, window.location.origin);
107
150
  syncSidebarSelection(detailUrl.searchParams.get("gem"));
151
+ })
152
+ .catch(() => {
153
+ stopDetailLoading();
108
154
  });
109
155
  };
110
156
 
111
157
  const activateGemLink = (link, pushHistory = true, keepVisible = false) => {
112
158
  if (!link) return;
113
159
 
160
+ stopDetailPoll();
161
+ stopDetailLoading();
114
162
  syncSidebarSelection(link.dataset.gemName, keepVisible);
115
163
  fetchDetail(link.dataset.detailUrl || link.href, pushHistory);
116
164
 
117
- if (sidebarPanel) {
118
- sidebarPanel.focus({ preventScroll: true });
119
- }
165
+ focusSidebar();
120
166
  };
121
167
 
122
168
  const scheduleDetailPoll = () => {
123
169
  stopDetailPoll();
124
170
  if (!detailPanel || detailPanel.dataset.detailPending !== "true") return;
125
171
  detailPollTimer = setTimeout(() => {
172
+ if (!detailPanel || !detailPanel.dataset.detailUrl) return;
126
173
  fetchDetail(detailPanel.dataset.detailUrl, false);
127
174
  }, 1000);
128
175
  };
@@ -204,6 +251,39 @@
204
251
  document.addEventListener("keydown", (event) => {
205
252
  const tagName = document.activeElement && document.activeElement.tagName;
206
253
  if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") return;
254
+
255
+ if (event.key === "ArrowRight" && isSidebarFocused()) {
256
+ event.preventDefault();
257
+ focusDetail();
258
+ return;
259
+ }
260
+
261
+ if (event.key === "ArrowLeft" && isDetailFocused()) {
262
+ event.preventDefault();
263
+ focusSidebar();
264
+ return;
265
+ }
266
+
267
+ if (isDetailFocused()) {
268
+ if (event.key === "ArrowDown") {
269
+ event.preventDefault();
270
+ detailPanel.scrollBy({ top: 80, behavior: "auto" });
271
+ }
272
+ if (event.key === "ArrowUp") {
273
+ event.preventDefault();
274
+ detailPanel.scrollBy({ top: -80, behavior: "auto" });
275
+ }
276
+ return;
277
+ }
278
+
279
+ if (!isSidebarFocused()) {
280
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
281
+ event.preventDefault();
282
+ focusSidebar();
283
+ }
284
+ return;
285
+ }
286
+
207
287
  const links = visibleGemLinks();
208
288
  if (!links.length) return;
209
289
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemstar
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Dejako
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.14'
82
+ - !ruby/object:Gem::Dependency
83
+ name: gem-release
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: 2.2.4
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 2.2.4
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: kramdown
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -267,7 +281,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
267
281
  - !ruby/object:Gem::Version
268
282
  version: '0'
269
283
  requirements: []
270
- rubygems_version: 3.6.9
284
+ rubygems_version: 4.0.6
271
285
  specification_version: 4
272
286
  summary: Making sense of gems.
273
287
  test_files: []