gemstar 1.0.2 → 1.0.4

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: b290f73ca813d6ef39789e23d883f3bd382fbdcc17f0803cd5fdc296d7f60582
4
- data.tar.gz: '058a2d17b98461a62d463ba673d170b621471e8f9693ea294da403382da5a759'
3
+ metadata.gz: 2bea143c3f9b89aca665d0b74402cb7f16ec65b6994139a187f406c3de800eca
4
+ data.tar.gz: f9bc3eeba8f76decc7dbbe215e377a3130e1ffbbaa5d22f405604974ab99741a
5
5
  SHA512:
6
- metadata.gz: fe408b9d9259ab0771e41522238b35c259e624759ac27ffe013d689c76ebe0164905c5ee67bb7868e8cc43ff361568f854f8dccf2e513468176685a395bb9786
7
- data.tar.gz: 149884982e1e2d124f7ade0bf565604106a455db2bab04f6b3811b316fb0386f72ed3fdaa0969369c310e3811e7735d97d29c147f9263f32f62c6c2fcb0d7487
6
+ metadata.gz: b857d1cc340eab567266177d927710b13d9f569d07bea5d59332a679c3f57e827c26d9ce6d8154968cd624ac1e1b41723778d9bbc3c6ed6fede5af147fdffbba
7
+ data.tar.gz: d2da645e58549938abf27c2c10a53c1eca1b2a8d842ef7f41ce85298e0a47e4b4d8a0bf152843ad080c0cf2b112f6882a5628a93cd617f1933e727d2e2992d39
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Change Log
2
2
 
3
+ ## 1.0.4
4
+
5
+ - Server: Improve layout, detail panel state management, and initial gem selection
6
+ - Server: Gem details now in collapsible panel
7
+ - Server: Arrow left/right navigation improved
8
+ - Enhance changelog parsing with GitHub tag and release support
9
+ - Extend `LockFile` with dependency requirements, spec sources, and platform parsing
10
+ - Refactor dependency processing to include platform and source details
11
+ - Refactor changelog parsing to merge GitHub release and changelog sections
12
+
13
+ ## 1.0.3
14
+
15
+ - Load our own WEBrick to avoid conflicts with hosting puma.rb etc.
16
+
3
17
  ## 1.0.2
4
18
 
5
19
  - General server performance improvements.
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "cgi"
2
3
 
3
4
  module Gemstar
4
5
  class ChangeLog
@@ -22,10 +23,10 @@ module Gemstar
22
23
  return @sections if !cache_only && defined?(@sections)
23
24
 
24
25
  result = begin
25
- s = parse_changelog_sections(cache_only: cache_only, force_refresh: force_refresh)
26
- if s.nil? || s.empty?
27
- s = parse_github_release_sections(cache_only: cache_only, force_refresh: force_refresh)
28
- end
26
+ changelog_sections = parse_changelog_sections(cache_only: cache_only, force_refresh: force_refresh) || {}
27
+ github_sections = parse_github_release_sections(cache_only: cache_only, force_refresh: force_refresh) || {}
28
+
29
+ s = merge_section_sources(changelog_sections, github_sections)
29
30
 
30
31
  pp @@candidates_found if Gemstar.debug? && !cache_only
31
32
 
@@ -36,6 +37,13 @@ module Gemstar
36
37
  result
37
38
  end
38
39
 
40
+ def merge_section_sources(changelog_sections, github_sections)
41
+ return github_sections if changelog_sections.nil? || changelog_sections.empty?
42
+ return changelog_sections if github_sections.nil? || github_sections.empty?
43
+
44
+ github_sections.merge(changelog_sections)
45
+ end
46
+
39
47
  def extract_relevant_sections(old_version, new_version)
40
48
  from = Gem::Version.new(old_version.gsub(/-[\w\-]+$/, "")) rescue nil if old_version
41
49
  from ||= Gem::Version.new("0.0.0")
@@ -294,6 +302,22 @@ module Gemstar
294
302
  end
295
303
  end
296
304
 
305
+ if sections.empty?
306
+ current_version = @metadata&.meta(cache_only: cache_only, force_refresh: force_refresh)&.dig("version")
307
+ current_release_sections = parse_specific_github_release_pages(
308
+ repo_uri,
309
+ current_version,
310
+ cache_only: cache_only,
311
+ force_refresh: force_refresh
312
+ )
313
+ tag_sections = parse_github_tag_sections(
314
+ repo_uri,
315
+ cache_only: cache_only,
316
+ force_refresh: force_refresh
317
+ )
318
+ sections = tag_sections.merge(current_release_sections)
319
+ end
320
+
297
321
  if Gemstar.debug?
298
322
  puts "parse_github_release_sections #{@metadata.gem_name}:"
299
323
  pp sections.keys
@@ -308,5 +332,197 @@ module Gemstar
308
332
  return nil if repo.empty?
309
333
  "#{repo}/releases"
310
334
  end
335
+
336
+ def github_tags_url(repo_uri = @metadata&.repo_uri)
337
+ return nil unless repo_uri
338
+ repo = repo_uri.chomp("/")
339
+ return nil if repo.empty?
340
+ "#{repo}/tags"
341
+ end
342
+
343
+ def parse_specific_github_release_pages(repo_uri, version, cache_only:, force_refresh:)
344
+ return {} unless repo_uri&.include?("github.com")
345
+ return {} if version.to_s.empty?
346
+
347
+ github_release_tag_urls(repo_uri, version).each do |url|
348
+ html = if cache_only
349
+ Cache.peek("releases-#{url}")
350
+ else
351
+ Cache.fetch("releases-#{url}", force: force_refresh) do
352
+ begin
353
+ URI.open(url, read_timeout: 8)&.read
354
+ rescue => e
355
+ puts "#{url}: #{e}" if Gemstar.debug?
356
+ nil
357
+ end
358
+ end
359
+ end
360
+
361
+ next if html.nil? || html.strip.empty?
362
+
363
+ section = parse_single_github_release_page(html, version)
364
+ return { version => section } if section
365
+ end
366
+
367
+ {}
368
+ end
369
+
370
+ def parse_github_tag_sections(repo_uri, cache_only:, force_refresh:)
371
+ return {} unless repo_uri&.include?("github.com")
372
+
373
+ url = github_tags_url(repo_uri)
374
+ return {} unless url
375
+
376
+ sections = {}
377
+ seen_urls = {}
378
+
379
+ while url && !seen_urls[url]
380
+ seen_urls[url] = true
381
+ html = if cache_only
382
+ Cache.peek("tags-#{url}")
383
+ else
384
+ Cache.fetch("tags-#{url}", force: force_refresh) do
385
+ begin
386
+ URI.open(url, read_timeout: 8)&.read
387
+ rescue => e
388
+ puts "#{url}: #{e}" if Gemstar.debug?
389
+ nil
390
+ end
391
+ end
392
+ end
393
+
394
+ break if html.nil? || html.strip.empty?
395
+
396
+ page_sections, next_url = parse_single_github_tags_page(html, repo_uri)
397
+ sections.merge!(page_sections) { |_version, existing, _new| existing }
398
+ url = next_url
399
+ end
400
+
401
+ sections.keys.each do |version|
402
+ specific_release = parse_specific_github_release_pages(
403
+ repo_uri,
404
+ version,
405
+ cache_only: cache_only,
406
+ force_refresh: force_refresh
407
+ )
408
+ next if specific_release.nil? || specific_release.empty?
409
+
410
+ sections[version] = specific_release[version] if specific_release[version]
411
+ end
412
+
413
+ sections
414
+ end
415
+
416
+ def parse_single_github_tags_page(html, repo_uri)
417
+ require "nokogiri"
418
+
419
+ doc = begin
420
+ Nokogiri::HTML5(html)
421
+ rescue => _
422
+ Nokogiri::HTML(html)
423
+ end
424
+
425
+ sections = {}
426
+ repo_path = URI(repo_uri).path
427
+ release_prefix = "#{repo_path}/releases/tag/"
428
+ tree_prefix = "#{repo_path}/tree/"
429
+
430
+ doc.css("a[href]").each do |link|
431
+ href = link["href"].to_s
432
+ tag_name =
433
+ if href.start_with?(release_prefix)
434
+ href.delete_prefix(release_prefix)
435
+ elsif href.start_with?(tree_prefix)
436
+ href.delete_prefix(tree_prefix)
437
+ end
438
+ next if tag_name.to_s.empty?
439
+
440
+ version = normalize_github_tag_version(tag_name)
441
+ next if version.to_s.empty?
442
+
443
+ sections[version] ||= [
444
+ "## #{version}\n",
445
+ "<p>No release information available. Check the release page for more information.</p>"
446
+ ]
447
+ end
448
+
449
+ next_href =
450
+ doc.at_css('a[rel="next"], a.next_page')&.[]("href") ||
451
+ doc.css("a[href]").find do |link|
452
+ href = link["href"].to_s
453
+ text = link.text.to_s.gsub(/\s+/, " ").strip
454
+ href.include?("/tags?after=") && text == "Next"
455
+ end&.[]("href")
456
+ next_url = if next_href && !next_href.empty?
457
+ URI.join(repo_uri, next_href).to_s
458
+ end
459
+
460
+ [sections, next_url]
461
+ rescue LoadError
462
+ [{}, nil]
463
+ end
464
+
465
+ def parse_single_github_release_page(html, version)
466
+ require "nokogiri"
467
+
468
+ doc = begin
469
+ Nokogiri::HTML5(html)
470
+ rescue => _
471
+ Nokogiri::HTML(html)
472
+ end
473
+
474
+ body = doc.at_css('[data-test-selector="body-content"] .markdown-body') ||
475
+ doc.at_css('[data-test-selector="body-content"]') ||
476
+ doc.at_css('.markdown-body')
477
+ if body
478
+ html_chunk = body.inner_html.to_s.strip
479
+ return ["## #{version}\n", html_chunk] unless html_chunk.empty?
480
+ end
481
+
482
+ title = doc.at_css("title")&.text.to_s.strip
483
+ synthetic_title = normalize_github_release_title(title, version)
484
+ return nil if synthetic_title.nil? || synthetic_title.empty?
485
+
486
+ ["## #{version}\n", "<p>#{CGI.escapeHTML(synthetic_title)}</p>"]
487
+ rescue LoadError
488
+ nil
489
+ end
490
+
491
+ def github_release_tag_urls(repo_url, version)
492
+ github_tag_candidates(version).map do |tag|
493
+ "#{repo_url}/releases/tag/#{tag}"
494
+ end.uniq
495
+ end
496
+
497
+ def github_tag_candidates(version)
498
+ raw = version.to_s
499
+ [raw, (raw.start_with?("v") ? raw : "v#{raw}")].uniq
500
+ end
501
+
502
+ def normalize_github_tag_version(tag_name)
503
+ decoded = URI.decode_www_form_component(tag_name.to_s.split("?").first.to_s)
504
+ match = decoded.match(/\Av?(\d[\w.\-]*)\z/i)
505
+ match && match[1]
506
+ end
507
+
508
+ def prefer_github_releases_first?(cache_only:, force_refresh:)
509
+ meta = @metadata.meta(cache_only: cache_only, force_refresh: force_refresh)
510
+ repo_uri = @metadata.repo_uri(cache_only: cache_only, force_refresh: force_refresh)
511
+
512
+ repo_uri.to_s.include?("github.com") && meta["changelog_uri"].to_s.empty?
513
+ rescue StandardError
514
+ false
515
+ end
516
+
517
+ def normalize_github_release_title(title, version)
518
+ return nil if title.to_s.empty?
519
+
520
+ cleaned = title.sub(/\s*·\s*[^·]+\s*·\s*GitHub\z/, "")
521
+ cleaned = cleaned.sub(/\ARelease\s+/, "")
522
+ cleaned = cleaned.strip
523
+ return nil if cleaned.empty? || cleaned == version.to_s
524
+
525
+ cleaned
526
+ end
311
527
  end
312
528
  end
@@ -45,6 +45,7 @@ module Gemstar
45
45
  puts "Config home: #{Gemstar::Config.home_directory}"
46
46
  Rackup::Server.start(
47
47
  app: app,
48
+ server: "webrick",
48
49
  Host: bind,
49
50
  Port: port,
50
51
  AccessLog: [],
@@ -5,26 +5,72 @@ module Gemstar
5
5
  parsed = content ? parse_content(content) : parse_lockfile(path)
6
6
  @specs = parsed[:specs]
7
7
  @dependency_graph = parsed[:dependency_graph]
8
+ @dependency_requirements = parsed[:dependency_requirements]
8
9
  @direct_dependencies = parsed[:direct_dependencies]
10
+ @direct_dependency_requirements = parsed[:direct_dependency_requirements]
11
+ @spec_sources = parsed[:spec_sources]
9
12
  end
10
13
 
11
14
  attr_reader :specs
12
15
  attr_reader :dependency_graph
16
+ attr_reader :dependency_requirements
13
17
  attr_reader :direct_dependencies
18
+ attr_reader :direct_dependency_requirements
19
+ attr_reader :spec_sources
14
20
 
15
21
  def origins_for(gem_name)
16
- return [{ type: :direct, path: [gem_name] }] if direct_dependencies.include?(gem_name)
22
+ if direct_dependencies.include?(gem_name)
23
+ return [{
24
+ type: :direct,
25
+ path: [gem_name],
26
+ requirement: direct_dependency_requirements[gem_name]
27
+ }]
28
+ end
17
29
 
18
30
  direct_dependencies.filter_map do |root_dependency|
19
31
  path = shortest_path_from(root_dependency, gem_name)
20
32
  next if path.nil?
21
33
 
22
- { type: :transitive, path: path }
34
+ parent_name = path[-2]
35
+ {
36
+ type: :transitive,
37
+ path: path,
38
+ requirement: dependency_requirements.dig(parent_name, gem_name)
39
+ }
23
40
  end
24
41
  end
25
42
 
43
+ def source_for(gem_name)
44
+ spec_sources[gem_name]
45
+ end
46
+
47
+ def platform_for(gem_name)
48
+ version = specs[gem_name].to_s
49
+ parts = version.split("-")
50
+ return nil if parts.length < 2
51
+
52
+ 1.upto(parts.length - 1) do |index|
53
+ candidate_version = parts[0...index].join("-")
54
+ candidate_platform = parts[index..].join("-")
55
+ next unless plausible_platform_suffix?(candidate_platform)
56
+
57
+ begin
58
+ Gem::Version.new(candidate_version)
59
+ return candidate_platform
60
+ rescue ArgumentError
61
+ next
62
+ end
63
+ end
64
+
65
+ nil
66
+ end
67
+
26
68
  private
27
69
 
70
+ def plausible_platform_suffix?(suffix)
71
+ suffix.match?(/darwin|linux|mingw|mswin|musl|java|x86|arm|universal/i)
72
+ end
73
+
28
74
  def shortest_path_from(root_dependency, target_gem)
29
75
  queue = [[root_dependency, [root_dependency]]]
30
76
  visited = {}
@@ -53,9 +99,13 @@ module Gemstar
53
99
  def parse_content(content)
54
100
  specs = {}
55
101
  dependency_graph = Hash.new { |hash, key| hash[key] = [] }
102
+ dependency_requirements = Hash.new { |hash, key| hash[key] = {} }
56
103
  direct_dependencies = []
104
+ direct_dependency_requirements = {}
105
+ spec_sources = {}
57
106
  current_section = nil
58
107
  current_spec = nil
108
+ current_source = nil
59
109
 
60
110
  content.each_line do |line|
61
111
  stripped = line.strip
@@ -68,12 +118,28 @@ module Gemstar
68
118
  if stripped == "GEM"
69
119
  current_section = :gem
70
120
  current_spec = nil
121
+ current_source = { type: :rubygems }
122
+ next
123
+ end
124
+
125
+ if stripped == "PATH"
126
+ current_section = :path
127
+ current_spec = nil
128
+ current_source = { type: :path }
129
+ next
130
+ end
131
+
132
+ if stripped == "GIT"
133
+ current_section = :git
134
+ current_spec = nil
135
+ current_source = { type: :git }
71
136
  next
72
137
  end
73
138
 
74
139
  if stripped == "DEPENDENCIES"
75
140
  current_section = :dependencies
76
141
  current_spec = nil
142
+ current_source = nil
77
143
  next
78
144
  end
79
145
 
@@ -83,17 +149,30 @@ module Gemstar
83
149
  end
84
150
 
85
151
  case current_section
86
- when :gem
87
- if line =~ /^\s{4}(\S+) \((.+)\)/
152
+ when :gem, :path, :git
153
+ if line =~ /^\s{2}remote:\s+(.+)$/
154
+ current_source = (current_source || {}).merge(remote: Regexp.last_match(1))
155
+ elsif line =~ /^\s{2}(revision|branch|tag|ref|glob|submodules):\s+(.+)$/
156
+ current_source = (current_source || {}).merge(Regexp.last_match(1).to_sym => Regexp.last_match(2))
157
+ elsif line =~ /^\s{2}path:\s+(.+)$/
158
+ current_source = (current_source || {}).merge(path: Regexp.last_match(1))
159
+ elsif line =~ /^\s{4}(\S+) \((.+)\)/
88
160
  name, version = Regexp.last_match(1), Regexp.last_match(2)
89
161
  specs[name] = version
162
+ spec_sources[name] = (current_source || {}).dup
90
163
  current_spec = name
91
- elsif current_spec && line =~ /^\s{6}([^\s(]+)/
92
- dependency_graph[current_spec] << Regexp.last_match(1)
164
+ elsif current_spec && line =~ /^\s{6}([^\s(]+)(?: \(([^)]+)\))?/
165
+ dependency_name = Regexp.last_match(1)
166
+ requirement = Regexp.last_match(2)
167
+ dependency_graph[current_spec] << dependency_name
168
+ dependency_requirements[current_spec][dependency_name] = requirement if requirement && !requirement.empty?
93
169
  end
94
170
  when :dependencies
95
- if line =~ /^\s{2}([^\s!(]+)/
96
- direct_dependencies << Regexp.last_match(1)
171
+ if line =~ /^\s{2}([^\s!(]+)(?: \(([^)]+)\))?/
172
+ dependency_name = Regexp.last_match(1)
173
+ requirement = Regexp.last_match(2)
174
+ direct_dependencies << dependency_name
175
+ direct_dependency_requirements[dependency_name] = requirement if requirement && !requirement.empty?
97
176
  end
98
177
  end
99
178
  end
@@ -101,7 +180,10 @@ module Gemstar
101
180
  {
102
181
  specs: specs,
103
182
  dependency_graph: dependency_graph.transform_values(&:uniq),
104
- direct_dependencies: direct_dependencies.uniq
183
+ dependency_requirements: dependency_requirements,
184
+ direct_dependencies: direct_dependencies.uniq,
185
+ direct_dependency_requirements: direct_dependency_requirements,
186
+ spec_sources: spec_sources
105
187
  }
106
188
  end
107
189
  end
@@ -114,7 +114,8 @@ module Gemstar
114
114
  @gem_states_cache[cache_key] = (from_specs.keys | to_specs.keys).map do |gem_name|
115
115
  old_version = from_specs[gem_name]
116
116
  new_version = to_specs[gem_name]
117
- bundle_origins = to_lockfile&.origins_for(gem_name) || []
117
+ effective_lockfile = new_version ? to_lockfile : from_lockfile
118
+ bundle_origins = effective_lockfile&.origins_for(gem_name) || []
118
119
 
119
120
  {
120
121
  name: gem_name,
@@ -122,6 +123,8 @@ module Gemstar
122
123
  new_version: new_version,
123
124
  status: gem_status(old_version, new_version),
124
125
  version_label: version_label(old_version, new_version),
126
+ platform: effective_lockfile&.platform_for(gem_name),
127
+ source: effective_lockfile&.source_for(gem_name),
125
128
  bundle_origins: bundle_origins,
126
129
  bundle_origin_labels: bundle_origin_labels(bundle_origins)
127
130
  }
@@ -233,7 +236,8 @@ module Gemstar
233
236
  Array(origins).map do |origin|
234
237
  next "Gemfile" if origin[:type] == :direct
235
238
 
236
- ["Gemfile", *origin[:path]].join(" → ")
239
+ label = ["Gemfile", *origin[:path]].join(" → ")
240
+ origin[:requirement] ? "#{label} (#{origin[:requirement]})" : label
237
241
  end.compact.uniq
238
242
  end
239
243
 
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemstar # :nodoc:
4
- VERSION = "1.0.2"
5
-
4
+ VERSION = "1.0.4"
6
5
  def self.debug?
7
6
  return @debug if defined?(@debug)
8
7
  @debug = ENV["GEMSTAR_DEBUG"] == "true"
@@ -227,7 +227,7 @@ module Gemstar
227
227
  </div>
228
228
  <div class="picker-row">
229
229
  <label class="picker picker-project">
230
- <span class="picker-prefix">📁</span>
230
+ <span class="picker-prefix" data-text-label="true">Project:</span>
231
231
  <select data-project-select>
232
232
  #{project_options_html}
233
233
  <option value="" disabled>────────</option>
@@ -516,7 +516,8 @@ module Gemstar
516
516
  next if origin[:type] != :direct && display_path.empty?
517
517
 
518
518
  linked_path = linked_gem_chain(["Gemfile", *display_path])
519
- origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
519
+ label = origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path
520
+ origin[:requirement] ? "#{label} (#{h(origin[:requirement])})" : label
520
521
  end.uniq
521
522
  end
522
523
 
@@ -540,14 +541,18 @@ module Gemstar
540
541
  def render_dependency_details(bundle_origins, requirement_names, added_on)
541
542
  required_by = dependency_origin_items(bundle_origins)
542
543
  requires = Array(requirement_names).compact.uniq.map { |name| internal_gem_link(name) }
544
+ platforms = selected_gem_platform_items
545
+ source_items = selected_gem_source_items
543
546
  added_markup = render_added_on(added_on)
544
- return "" if required_by.empty? && requires.empty? && added_markup.empty?
547
+ return "" if required_by.empty? && requires.empty? && platforms.empty? && source_items.empty? && added_markup.empty?
545
548
 
546
549
  <<~HTML
547
550
  <details class="detail-disclosure">
548
551
  <summary><span class="detail-disclosure-caret" aria-hidden="true"></span><h3>Details</h3></summary>
549
552
  <div class="detail-disclosure-panel">
550
553
  #{added_markup}
554
+ #{render_dependency_popover_section("Platforms", platforms)}
555
+ #{render_dependency_popover_section("Source", source_items)}
551
556
  #{render_dependency_popover_section("Required by", required_by)}
552
557
  #{render_dependency_popover_section("Requires", requires)}
553
558
  </div>
@@ -584,6 +589,39 @@ module Gemstar
584
589
  @selected_project&.gem_added_on(@selected_gem[:name], revision_id: revision_id)
585
590
  end
586
591
 
592
+ def selected_gem_platform_items
593
+ platform = @selected_gem[:platform]
594
+ return [] if platform.to_s.empty?
595
+
596
+ [h(platform)]
597
+ end
598
+
599
+ def selected_gem_source_items
600
+ source = @selected_gem[:source] || {}
601
+ source_type = source[:type]
602
+
603
+ case source_type
604
+ when :path
605
+ location = source[:path] || source[:remote]
606
+ return [] if location.to_s.empty?
607
+
608
+ ["Path (#{h(location)})"]
609
+ when :git
610
+ remote = source[:remote]
611
+ pieces = ["Git"]
612
+ pieces << h(remote) unless remote.to_s.empty?
613
+ pieces << "@#{h(source[:branch])}" if source[:branch]
614
+ pieces << "##{h(source[:tag])}" if source[:tag]
615
+ pieces << h(source[:revision].to_s[0, 8]) if source[:revision]
616
+ [pieces.join(" ")]
617
+ when :rubygems
618
+ remote = source[:remote]
619
+ [remote.to_s.empty? ? "RubyGems" : "RubyGems (#{h(remote)})"]
620
+ else
621
+ []
622
+ end
623
+ end
624
+
587
625
  def linked_gem_chain(names)
588
626
  Array(names).map.with_index do |name, index|
589
627
  if index.zero?
@@ -52,9 +52,9 @@
52
52
  align-items: center;
53
53
  justify-content: space-between;
54
54
  gap: 0.6rem;
55
- padding: 0.4rem 0.65rem;
56
- border-bottom: 1px solid #ece8df;
57
- background: #fff;
55
+ padding: 0.55rem 0.75rem;
56
+ border-bottom: 1px solid #e6d5c2;
57
+ background: #fbf1e4;
58
58
  position: sticky;
59
59
  top: 0;
60
60
  z-index: 2;
@@ -316,7 +316,7 @@
316
316
  font-weight: 600;
317
317
  }
318
318
  .detail {
319
- padding: 0.5rem 0.8rem 0.5rem 0.5rem;
319
+ padding: 0.62rem 0.8rem 0.5rem 0.5rem;
320
320
  display: grid;
321
321
  gap: 0.45rem;
322
322
  align-content: start;
@@ -367,7 +367,7 @@
367
367
  flex: 0 0 auto;
368
368
  }
369
369
  .detail-disclosure {
370
- margin-top: 0.2rem;
370
+ margin-top: 0.38rem;
371
371
  border: 1px solid #ece8df;
372
372
  border-radius: 0.3rem;
373
373
  background: #fff;
@@ -15,6 +15,7 @@
15
15
  let currentFilter = <%= selected_filter_json %>;
16
16
  let currentSearch = "";
17
17
  const emptyDetailHtml = <%= empty_detail_html_json %>;
18
+ const detailDisclosureStorageKey = "gemstar.detailDisclosureOpen";
18
19
 
19
20
  const visibleGemLinks = () => gemLinks.filter((link) => !link.hidden);
20
21
  const currentSelectedIndex = () => visibleGemLinks().findIndex((link) => link.classList.contains("is-selected"));
@@ -24,6 +25,15 @@
24
25
  });
25
26
  };
26
27
  const requestedGemName = () => new URL(window.location.href).searchParams.get("gem");
28
+ const currentDetailGemName = () => {
29
+ if (!detailPanel || !detailPanel.dataset.detailUrl) return null;
30
+
31
+ try {
32
+ return new URL(detailPanel.dataset.detailUrl, window.location.origin).searchParams.get("gem");
33
+ } catch (_error) {
34
+ return null;
35
+ }
36
+ };
27
37
  const isSidebarFocused = () => document.activeElement === sidebarPanel;
28
38
  const isDetailFocused = () => detailPanel && document.activeElement === detailPanel;
29
39
  const focusSidebar = () => {
@@ -32,6 +42,31 @@
32
42
  const focusDetail = () => {
33
43
  if (detailPanel) detailPanel.focus({ preventScroll: true });
34
44
  };
45
+ const storedDetailDisclosureOpen = () => {
46
+ try {
47
+ return window.localStorage.getItem(detailDisclosureStorageKey) === "true";
48
+ } catch (_error) {
49
+ return false;
50
+ }
51
+ };
52
+ const persistDetailDisclosureOpen = (open) => {
53
+ try {
54
+ window.localStorage.setItem(detailDisclosureStorageKey, open ? "true" : "false");
55
+ } catch (_error) {
56
+ // ignore storage failures
57
+ }
58
+ };
59
+ const bindDetailDisclosure = () => {
60
+ if (!detailPanel) return;
61
+
62
+ const disclosure = detailPanel.querySelector(".detail-disclosure");
63
+ if (!disclosure) return;
64
+
65
+ disclosure.open = storedDetailDisclosureOpen();
66
+ disclosure.addEventListener("toggle", () => {
67
+ persistDetailDisclosureOpen(disclosure.open);
68
+ });
69
+ };
35
70
 
36
71
  const applyGemFilter = (filter) => {
37
72
  currentFilter = filter;
@@ -94,6 +129,7 @@
94
129
  detailPanel = document.querySelector("[data-detail-panel]");
95
130
  if (detailPanel) detailPanel.scrollTop = 0;
96
131
  activeDetailUrl = detailPanel ? detailPanel.dataset.detailUrl : null;
132
+ bindDetailDisclosure();
97
133
  if (shouldRestoreDetailFocus) {
98
134
  focusDetail();
99
135
  }
@@ -122,7 +158,7 @@
122
158
  </section>
123
159
  `;
124
160
 
125
- const fetchDetail = (url, pushHistory = true) => {
161
+ const fetchDetail = (url, historyMode = "push") => {
126
162
  const normalizedUrl = new URL(url, window.location.origin).toString();
127
163
  const requestToken = ++detailRequestToken;
128
164
  activeDetailUrl = normalizedUrl;
@@ -139,12 +175,13 @@
139
175
  if (requestToken !== detailRequestToken || normalizedUrl !== activeDetailUrl) return;
140
176
 
141
177
  replaceDetail(html);
142
- if (pushHistory) {
178
+ if (historyMode !== "none") {
143
179
  const pageUrl = new URL(window.location.href);
144
180
  const detailUrl = new URL(url, window.location.origin);
145
181
  pageUrl.search = detailUrl.search;
146
182
  pageUrl.searchParams.set("filter", currentFilter);
147
- window.history.pushState({}, "", pageUrl);
183
+ const historyMethod = historyMode === "replace" ? "replaceState" : "pushState";
184
+ window.history[historyMethod]({}, "", pageUrl);
148
185
  }
149
186
  const detailUrl = new URL(url, window.location.origin);
150
187
  syncSidebarSelection(detailUrl.searchParams.get("gem"));
@@ -154,17 +191,39 @@
154
191
  });
155
192
  };
156
193
 
157
- const activateGemLink = (link, pushHistory = true, keepVisible = false) => {
194
+ const activateGemLink = (link, historyMode = "push", keepVisible = false) => {
158
195
  if (!link) return;
159
196
 
160
197
  stopDetailPoll();
161
198
  stopDetailLoading();
162
199
  syncSidebarSelection(link.dataset.gemName, keepVisible);
163
- fetchDetail(link.dataset.detailUrl || link.href, pushHistory);
200
+ fetchDetail(link.dataset.detailUrl || link.href, historyMode);
164
201
 
165
202
  focusSidebar();
166
203
  };
167
204
 
205
+ const ensureInitialGemSelection = () => {
206
+ if (requestedGemName()) return;
207
+
208
+ const detailGemName = currentDetailGemName();
209
+ const detailLink = detailGemName ? visibleGemLinks().find((link) => link.dataset.gemName === detailGemName) : null;
210
+
211
+ if (detailLink) {
212
+ syncSidebarSelection(detailGemName, true);
213
+ const pageUrl = new URL(window.location.href);
214
+ const detailUrl = new URL(detailLink.dataset.detailUrl || detailLink.href, window.location.origin);
215
+ pageUrl.search = detailUrl.search;
216
+ pageUrl.searchParams.set("filter", currentFilter);
217
+ window.history.replaceState({}, "", pageUrl);
218
+ return;
219
+ }
220
+
221
+ const firstVisibleLink = visibleGemLinks()[0];
222
+ if (firstVisibleLink) {
223
+ activateGemLink(firstVisibleLink, "replace", true);
224
+ }
225
+ };
226
+
168
227
  const scheduleDetailPoll = () => {
169
228
  stopDetailPoll();
170
229
  if (!detailPanel || detailPanel.dataset.detailPending !== "true") return;
@@ -176,11 +235,13 @@
176
235
 
177
236
  if (detailPanel) {
178
237
  detailPanel.scrollTop = 0;
238
+ bindDetailDisclosure();
179
239
  scheduleDetailPoll();
180
240
  }
181
241
 
182
242
  syncSidebarSelection(null, true);
183
243
  applyGemFilter(currentFilter);
244
+ ensureInitialGemSelection();
184
245
 
185
246
  gemLinks.forEach((link) => {
186
247
  link.addEventListener("click", (event) => {
@@ -277,6 +338,14 @@
277
338
  }
278
339
 
279
340
  if (!isSidebarFocused()) {
341
+ if (event.key === "ArrowLeft") {
342
+ event.preventDefault();
343
+ focusSidebar();
344
+ }
345
+ if (event.key === "ArrowRight") {
346
+ event.preventDefault();
347
+ focusDetail();
348
+ }
280
349
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
281
350
  event.preventDefault();
282
351
  focusSidebar();
@@ -296,7 +365,7 @@
296
365
 
297
366
  if (nextIndex !== null && nextIndex !== currentIndex) {
298
367
  event.preventDefault();
299
- activateGemLink(links[nextIndex], true, true);
368
+ activateGemLink(links[nextIndex], "push", true);
300
369
  }
301
370
  });
302
371
 
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.2
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Dejako
@@ -281,7 +281,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
281
281
  - !ruby/object:Gem::Version
282
282
  version: '0'
283
283
  requirements: []
284
- rubygems_version: 4.0.6
284
+ rubygems_version: 3.6.9
285
285
  specification_version: 4
286
286
  summary: Making sense of gems.
287
287
  test_files: []