From eb2f5335c762b211cdbbb760e01a3c7b4bdea4bd Mon Sep 17 00:00:00 2001 From: Florian Dejako Date: Thu, 19 Mar 2026 10:40:59 +0100 Subject: [PATCH 1/5] Add interactive web server with `gemstar server` command - Introduced `gemstar server` command for exploring Gemfile and Git history via a web interface. - Implemented `Gemstar::Web::App` for rendering project information. - Added support for managing configuration and loading projects. - Updated gem dependencies (`roda`, `rackup`, `webrick`) to support the web server functionality. --- CHANGELOG.md | 10 ++ gemstar.gemspec | 3 + lib/gemstar.rb | 3 + lib/gemstar/cli.rb | 8 ++ lib/gemstar/commands/server.rb | 43 ++++++ lib/gemstar/config.rb | 15 ++ lib/gemstar/git_repo.rb | 41 +++++- lib/gemstar/project.rb | 72 ++++++++++ lib/gemstar/web/app.rb | 254 +++++++++++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 lib/gemstar/commands/server.rb create mode 100644 lib/gemstar/config.rb create mode 100644 lib/gemstar/project.rb create mode 100644 lib/gemstar/web/app.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e4637..5993ff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,19 @@ - playwright-ruby-client uses release tags? - bundler itself - use changelog files from installed gems where present + - use 'gh' tool to fetch GitHub releases + - support downgrading pinned gems, i.e. minitest 6.0 -> 5.x + - read release notes from locally installed gems + - for each gem, show why it's included (Gemfile or 2nd dependency) ## Unreleased +- Added `gemstar server`, your interactive Gemfile.lock explorer and more. +- Default location for `diff` is now a tmp file. +- Removed Railtie from this gem. +- Improve how git root dir is determined. + + ## 0.0.2 - Diff: Fix regex warnings shown in terminal. diff --git a/gemstar.gemspec b/gemstar.gemspec index c539692..312ef5c 100644 --- a/gemstar.gemspec +++ b/gemstar.gemspec @@ -37,4 +37,7 @@ Gem::Specification.new do |s| s.add_dependency "concurrent-ruby", "~> 1.0" s.add_dependency "thor", "~> 1.4" s.add_dependency "nokogiri", "~> 1.18" + s.add_dependency "roda", "~> 3.90" + s.add_dependency "rackup", "~> 2.2" + s.add_dependency "webrick", "~> 1.9" end diff --git a/lib/gemstar.rb b/lib/gemstar.rb index 501157f..c86ed26 100644 --- a/lib/gemstar.rb +++ b/lib/gemstar.rb @@ -4,12 +4,15 @@ require "gemstar/cli" require "gemstar/commands/command" require "gemstar/commands/diff" +require "gemstar/commands/server" +require "gemstar/config" require "gemstar/outputs/basic" require "gemstar/outputs/html" require "gemstar/cache" require "gemstar/change_log" require "gemstar/git_hub" require "gemstar/lock_file" +require "gemstar/project" require "gemstar/remote_repository" require "gemstar/utils" require "gemstar/ruby_gems_metadata" diff --git a/lib/gemstar/cli.rb b/lib/gemstar/cli.rb index 6064824..07f9579 100644 --- a/lib/gemstar/cli.rb +++ b/lib/gemstar/cli.rb @@ -19,6 +19,14 @@ def diff Gemstar::Commands::Diff.new(options).run end + desc "server", "Start the interactive web server" + method_option :bind, type: :string, default: "127.0.0.1", desc: "Bind address" + method_option :port, type: :numeric, default: 2112, desc: "Port" + method_option :project, type: :array, default: [], desc: "Project directories or Gemfile paths" + def server + Gemstar::Commands::Server.new(options).run + end + # desc "pick", "Interactively cherry-pick and upgrade gems" # option :gem, type: :string, desc: "Gem name to cherry-pick" # def pick diff --git a/lib/gemstar/commands/server.rb b/lib/gemstar/commands/server.rb new file mode 100644 index 0000000..c9bff2c --- /dev/null +++ b/lib/gemstar/commands/server.rb @@ -0,0 +1,43 @@ +require_relative "command" + +module Gemstar + module Commands + class Server < Command + DEFAULT_BIND = "127.0.0.1" + DEFAULT_PORT = 2112 + + attr_reader :bind + attr_reader :port + attr_reader :project_inputs + + def initialize(options) + super + + @bind = options[:bind] || DEFAULT_BIND + @port = (options[:port] || DEFAULT_PORT).to_i + @project_inputs = Array(options[:project]).compact + end + + def run + require "rackup" + require "webrick" + require "gemstar/web/app" + + Gemstar::Config.ensure_home_directory! + + projects = load_projects + app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory) + + puts "Gemstar server listening on http://#{bind}:#{port}" + puts "Config home: #{Gemstar::Config.home_directory}" + Rackup::Server.start(app: app, Host: bind, Port: port) + end + + private + + def load_projects + project_inputs.map { |input| Gemstar::Project.from_cli_argument(input) } + end + end + end +end diff --git a/lib/gemstar/config.rb b/lib/gemstar/config.rb new file mode 100644 index 0000000..853dc51 --- /dev/null +++ b/lib/gemstar/config.rb @@ -0,0 +1,15 @@ +require "fileutils" + +module Gemstar + module Config + module_function + + def home_directory + File.expand_path("~/.config/gemstar") + end + + def ensure_home_directory! + FileUtils.mkdir_p(home_directory) + end + end +end diff --git a/lib/gemstar/git_repo.rb b/lib/gemstar/git_repo.rb index 17043f5..c533d8f 100644 --- a/lib/gemstar/git_repo.rb +++ b/lib/gemstar/git_repo.rb @@ -1,5 +1,10 @@ +require "open3" +require "pathname" + module Gemstar class GitRepo + attr_reader :tree_root_directory + def initialize(specified_directory) @specified_directory = specified_directory || Dir.pwd search_directory = if File.directory?(@specified_directory) @@ -11,17 +16,21 @@ def initialize(specified_directory) end def find_git_root(directory) - run_git_command(%W[rev-parse --show-toplevel], in_directory: directory) + try_git_command(%W[rev-parse --show-toplevel], in_directory: directory) end def git_client "git" end - def run_git_command(command, in_directory: @specified_directory, strip: true) + def build_git_command(command, in_directory: @specified_directory) git_command = [git_client] git_command += ["-C", in_directory] if in_directory - git_command += command + git_command + command + end + + def run_git_command(command, in_directory: @specified_directory, strip: true) + git_command = build_git_command(command, in_directory:) puts %[run_git_command (joined): #{git_command.join(" ")}] if Gemstar.debug? @@ -30,6 +39,17 @@ def run_git_command(command, in_directory: @specified_directory, strip: true) strip ? output.strip : output end + def try_git_command(command, in_directory: @specified_directory, strip: true) + git_command = build_git_command(command, in_directory:) + + puts %[try_git_command (joined): #{git_command.join(" ")}] if Gemstar.debug? + + output, status = Open3.capture2e(*git_command) + return nil unless status.success? + + strip ? output.strip : output + end + def resolve_commit(revish, default_branch: "HEAD") # If it looks like a pure date (or you want to support "date only"), # map it to "latest commit before date on default_branch". @@ -53,5 +73,20 @@ def show_blob_at(revish, path) def get_full_path(path) run_git_command(["ls-files", "--full-name", "--", path]) end + + def relative_path(path) + return nil if tree_root_directory.nil? || tree_root_directory.empty? + + Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(tree_root_directory)).to_s + rescue ArgumentError + nil + end + + def log_for_paths(paths, limit: 20) + return "" if tree_root_directory.nil? || tree_root_directory.empty? || paths.empty? + + format = "%H%x1f%h%x1f%aI%x1f%s" + run_git_command(["log", "-n", limit.to_s, "--pretty=format:#{format}", "--", *paths], in_directory: tree_root_directory) + end end end diff --git a/lib/gemstar/project.rb b/lib/gemstar/project.rb new file mode 100644 index 0000000..09b30b5 --- /dev/null +++ b/lib/gemstar/project.rb @@ -0,0 +1,72 @@ +require "time" + +module Gemstar + class Project + attr_reader :directory + attr_reader :gemfile_path + attr_reader :lockfile_path + attr_reader :name + + def self.from_cli_argument(input) + expanded_input = File.expand_path(input) + gemfile_path = if File.directory?(expanded_input) + File.join(expanded_input, "Gemfile") + else + expanded_input + end + + raise ArgumentError, "No Gemfile found for #{input}" unless File.file?(gemfile_path) + raise ArgumentError, "#{gemfile_path} is not a Gemfile" unless File.basename(gemfile_path) == "Gemfile" + + new(gemfile_path) + end + + def initialize(gemfile_path) + @gemfile_path = File.expand_path(gemfile_path) + @directory = File.dirname(@gemfile_path) + @lockfile_path = File.join(@directory, "Gemfile.lock") + @name = File.basename(@directory) + end + + def git_repo + @git_repo ||= Gemstar::GitRepo.new(directory) + end + + def git_root + git_repo.tree_root_directory + end + + def lockfile? + File.file?(lockfile_path) + end + + def revision_history(limit: 20) + return [] if git_root.nil? || git_root.empty? + + tracked_paths = [gemfile_path, lockfile_path].filter_map do |path| + next unless File.file?(path) + + git_repo.relative_path(path) + end + + return [] if tracked_paths.empty? + + output = git_repo.log_for_paths(tracked_paths.uniq, limit:) + return [] if output.nil? || output.empty? + + output.lines.filter_map do |line| + full_sha, short_sha, authored_at, subject = line.strip.split("\u001f", 4) + next if full_sha.nil? + + { + full_sha: full_sha, + short_sha: short_sha, + authored_at: Time.iso8601(authored_at), + subject: subject + } + end + rescue ArgumentError + [] + end + end +end diff --git a/lib/gemstar/web/app.rb b/lib/gemstar/web/app.rb new file mode 100644 index 0000000..e366658 --- /dev/null +++ b/lib/gemstar/web/app.rb @@ -0,0 +1,254 @@ +require "cgi" +require "roda" + +module Gemstar + module Web + class App < Roda + class << self + def build(projects:, config_home:) + Class.new(self) do + opts[:projects] = projects + opts[:config_home] = config_home + end.freeze.app + end + end + + route do |r| + @projects = self.class.opts.fetch(:projects) + @config_home = self.class.opts.fetch(:config_home) + + r.root do + render_page("Projects") do + <<~HTML +
+

Gemstar

+

Current projects

+

Config home: #{h(@config_home)}

+
+ #{render_projects} + HTML + end + end + + r.on "projects", String do |project_id| + project = project_for(project_id) + r.is do + next not_found_page unless project + + render_page(project.name) do + <<~HTML +

All projects

+
+

Project

+

#{h(project.name)}

+

#{h(project.directory)}

+
+ #{render_project_summary(project)} + #{render_revision_history(project)} + HTML + end + end + end + end + + private + + def project_for(project_id) + index = Integer(project_id, 10) + return nil if index.negative? + + @projects[index] + rescue ArgumentError + nil + end + + def render_projects + return <<~HTML if @projects.empty? +
+

No projects yet

+

Start the server with one or more --project paths.

+
+ HTML + + items = @projects.each_with_index.map do |project, index| + <<~HTML +
  • + #{h(project.name)} +
    #{h(project.directory)}
    +
  • + HTML + end.join + + <<~HTML +
    +

    Projects

    + +
    + HTML + end + + def render_project_summary(project) + <<~HTML +
    +

    Paths

    +
    +
    Gemfile
    +
    #{h(project.gemfile_path)}
    +
    Gemfile.lock
    +
    #{h(project.lockfile_path)}#{project.lockfile? ? "" : " (missing)"}
    +
    Git root
    +
    #{project.git_root ? h(project.git_root) : "Not in a git repository"}
    +
    +
    + HTML + end + + def render_revision_history(project) + revisions = project.revision_history + + return <<~HTML if revisions.empty? +
    +

    Gemfile revisions

    +

    No git revisions found for this Gemfile or Gemfile.lock.

    +
    + HTML + + items = revisions.map do |revision| + <<~HTML +
  • + #{h(revision[:short_sha])} + #{h(revision[:subject])} +
    #{h(revision[:authored_at].iso8601)}
    +
  • + HTML + end.join + + <<~HTML +
    +

    Gemfile revisions

    +
      + #{items} +
    +
    + HTML + end + + def render_page(title) + <<~HTML + + + + + + Gemstar: #{h(title)} + + + +
    + #{yield} +
    + + + HTML + end + + def not_found_page + response.status = 404 + render_page("Not found") do + <<~HTML +
    +

    Project not found

    +

    Back to projects

    +
    + HTML + end + end + + def h(value) + CGI.escapeHTML(value.to_s) + end + end + end +end From 545f7a718b9b8afd57ac74c0a3b70338a4447220 Mon Sep 17 00:00:00 2001 From: Florian Dejako Date: Thu, 19 Mar 2026 13:37:41 +0100 Subject: [PATCH 2/5] Gemstar Server work in progress - Introduced `Gemstar::CacheWarmer` for background cache warming with threaded workers. - Added `Gemstar::RequestLogger` to log requests with response times. - Implemented `Gemstar::WEBrickLogger` for suppressing expected disconnect errors in server logs. - Enhanced `Gemstar::Web::App` with caching and metadata preloading capabilities. - Added `cache` command for flushing cache entries. - Updated gem dependencies to include `rerun` for development. --- CHANGELOG.md | 58 +- README.md | 10 + gemstar.gemspec | 1 + lib/gemstar.rb | 3 + lib/gemstar/cache.rb | 57 +- lib/gemstar/cache_cli.rb | 12 + lib/gemstar/cache_warmer.rb | 120 +++ lib/gemstar/change_log.rb | 94 ++- lib/gemstar/cli.rb | 6 +- lib/gemstar/commands/cache.rb | 12 + lib/gemstar/commands/server.rb | 99 ++- lib/gemstar/project.rb | 129 +++- lib/gemstar/request_logger.rb | 31 + lib/gemstar/ruby_gems_metadata.rb | 78 +- lib/gemstar/web/app.rb | 1156 +++++++++++++++++++++++++---- lib/gemstar/webrick_logger.rb | 22 + 16 files changed, 1647 insertions(+), 241 deletions(-) create mode 100644 lib/gemstar/cache_cli.rb create mode 100644 lib/gemstar/cache_warmer.rb create mode 100644 lib/gemstar/commands/cache.rb create mode 100644 lib/gemstar/request_logger.rb create mode 100644 lib/gemstar/webrick_logger.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 5993ff2..df96b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,48 @@ # Change Log ## TODO -- Diff: - - Gems: - - benchmark - - brakeman - - json (not using CHANGES.md?) - - nio4r not using releases.md? - - parser not using CHANGELOG.md? - - actioncable-next uses release tag names? - - paper_trail not using CHANGELOG.md? - - playwright-ruby-client uses release tags? - - bundler itself - - use changelog files from installed gems where present - - use 'gh' tool to fetch GitHub releases - - support downgrading pinned gems, i.e. minitest 6.0 -> 5.x - - read release notes from locally installed gems - - for each gem, show why it's included (Gemfile or 2nd dependency) +### Diff +- Gems: + - benchmark + - brakeman + - json (not using CHANGES.md?) + - nio4r not using releases.md? + - parser not using CHANGELOG.md? + - actioncable-next uses release tag names? + - paper_trail not using CHANGELOG.md? + - playwright-ruby-client uses release tags? +- bundler itself +- use changelog files from installed gems where present +- use 'gh' tool to fetch GitHub releases +- support downgrading pinned gems, i.e. minitest 6.0 -> 5.x +- read release notes from locally installed gems +- for each gem, show why it's included (Gemfile or 2nd dependency) + +### Server + - main UI as a single page app + - title nav bar + - has "Gemstar" logo in the top left + - then a project popup menu with an Add... option at the bottom + - then from/to git revision popups that can show worktree or git revisions of the Gemfile.lock + - the main area as a master/detail view + - toolbar with buttons and information at the top + - bundle install, bundle update, etc. + - left side nav is the list of used gems + - gem name, version e.g. "2.2.0 -> 2.3.1" + - add gem option at bottom + - for each gem, in the detail area: + - at the top, basic information about the gem + - links to various sites: rubygems, github, home page, in buttons, opening to new tabs + - you can see the revisions, color coded to "what's new" in green + - if a version downgrade was applied, that change would be red + - for future versions (i.e. already released, but not yet in the repo), the change would be greyish with a dashed line outline + - for each revision: + - at the top right next to the revision number, + - buttons to upgrade/downgrade specific gems, + - for now, don't edit Gemfiles, so no specific updates/downgrades of gems yet, + but `bundle update` could already be possible + - keyboard navigation for master/detail view + ## Unreleased diff --git a/README.md b/README.md index 1ae5eab..bae3817 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,16 @@ To examine a specific Gemfile.lock, pass it like this: gemstar diff --lockfile=~/MyProject/Gemfile.lock ``` +### gemstar server + +Start the interactive web UI: + +```shell +gemstar server +``` + +By default, the server listens to http://127.0.0.1:2112/ + ## Contributing Bug reports and pull requests are welcome on GitHub at [https://github.com/FDj/gemstar](https://github.com/FDj/gemstar). diff --git a/gemstar.gemspec b/gemstar.gemspec index 312ef5c..66d7f62 100644 --- a/gemstar.gemspec +++ b/gemstar.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |s| s.add_development_dependency "combustion", "~> 1.5" s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "minitest", "~> 5.0" + s.add_development_dependency "rerun", "~> 0.14" s.add_dependency "kramdown", "~> 2.0" s.add_dependency "kramdown-parser-gfm", "~> 1.0" diff --git a/lib/gemstar.rb b/lib/gemstar.rb index c86ed26..f7bb4bb 100644 --- a/lib/gemstar.rb +++ b/lib/gemstar.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true require "gemstar/version" +require "gemstar/cache_cli" require "gemstar/cli" +require "gemstar/cache_warmer" require "gemstar/commands/command" +require "gemstar/commands/cache" require "gemstar/commands/diff" require "gemstar/commands/server" require "gemstar/config" diff --git a/lib/gemstar/cache.rb b/lib/gemstar/cache.rb index 6d2d86e..1ff33ee 100644 --- a/lib/gemstar/cache.rb +++ b/lib/gemstar/cache.rb @@ -1,10 +1,11 @@ +require_relative "config" require "fileutils" require "digest" module Gemstar class Cache MAX_CACHE_AGE = 60 * 60 * 24 * 7 # 1 week - CACHE_DIR = ".gem_changelog_cache" + CACHE_DIR = File.join(Gemstar::Config.home_directory, "cache") @@initialized = false @@ -18,15 +19,12 @@ def self.init def self.fetch(key, &block) init - path = File.join(CACHE_DIR, Digest::SHA256.hexdigest(key)) + path = path_for(key) - if File.exist?(path) - age = Time.now - File.mtime(path) - if age <= MAX_CACHE_AGE - content = File.read(path) - return nil if content == "__404__" - return content - end + if fresh?(path) + content = File.read(path) + return nil if content == "__404__" + return content end begin @@ -39,11 +37,50 @@ def self.fetch(key, &block) end end + def self.peek(key) + init + + path = path_for(key) + return nil unless fresh?(path) + + content = File.read(path) + return nil if content == "__404__" + + content + end + + def self.path_for(key) + File.join(CACHE_DIR, Digest::SHA256.hexdigest(key)) + end + + def self.fresh?(path) + return false unless File.exist?(path) + + (Time.now - File.mtime(path)) <= MAX_CACHE_AGE + end + + def self.flush! + init + + flush_directory(CACHE_DIR) + end + + def self.flush_directory(directory) + return 0 unless Dir.exist?(directory) + + entries = Dir.children(directory) + entries.each do |entry| + FileUtils.rm_rf(File.join(directory, entry)) + end + + entries.count + end + end def edit_gitignore gitignore_path = ".gitignore" - ignore_entries = %w[.gem_changelog_cache/ gem_update_changelog.html] + ignore_entries = %w[gem_update_changelog.html] existing_lines = File.exist?(gitignore_path) ? File.read(gitignore_path).lines.map(&:chomp) : [] diff --git a/lib/gemstar/cache_cli.rb b/lib/gemstar/cache_cli.rb new file mode 100644 index 0000000..d95fc5a --- /dev/null +++ b/lib/gemstar/cache_cli.rb @@ -0,0 +1,12 @@ +require "thor" + +module Gemstar + class CacheCLI < Thor + package_name "gemstar cache" + + desc "flush", "Clear all gemstar cache entries" + def flush + Gemstar::Commands::Cache.new({}).flush + end + end +end diff --git a/lib/gemstar/cache_warmer.rb b/lib/gemstar/cache_warmer.rb new file mode 100644 index 0000000..9a7b611 --- /dev/null +++ b/lib/gemstar/cache_warmer.rb @@ -0,0 +1,120 @@ +require "set" +require "thread" + +module Gemstar + class CacheWarmer + DEFAULT_THREADS = 10 + + def initialize(io: $stderr, debug: false, thread_count: DEFAULT_THREADS) + @io = io + @debug = debug + @thread_count = thread_count + @mutex = Mutex.new + @condition = ConditionVariable.new + @queue = [] + @queued = Set.new + @in_progress = Set.new + @completed = Set.new + @workers = [] + @started = false + @total = 0 + @completed_count = 0 + end + + def enqueue_many(gem_names) + names = gem_names.uniq + + @mutex.synchronize do + names.each do |gem_name| + next if @completed.include?(gem_name) || @queued.include?(gem_name) || @in_progress.include?(gem_name) + + @queue << gem_name + @queued << gem_name + end + @total += names.count + start_workers_unlocked unless @started + end + + log "Background cache refresh queued for #{names.count} gems." + @condition.broadcast + self + end + + def prioritize(gem_name) + @mutex.synchronize do + return if @completed.include?(gem_name) || @in_progress.include?(gem_name) + + if @queued.include?(gem_name) + @queue.delete(gem_name) + else + @queued << gem_name + @total += 1 + end + + @queue.unshift(gem_name) + start_workers_unlocked unless @started + end + + log "Prioritized #{gem_name}" + @condition.broadcast + end + + private + + def start_workers_unlocked + return if @started + + @started = true + @thread_count.times do + @workers << Thread.new { worker_loop } + end + end + + def worker_loop + Thread.current.name = "gemstar-cache-worker" if Thread.current.respond_to?(:name=) + + loop do + gem_name = @mutex.synchronize do + while @queue.empty? + @condition.wait(@mutex) + end + + next_gem = @queue.shift + @queued.delete(next_gem) + @in_progress << next_gem + next_gem + end + + warm_cache_for_gem(gem_name) + + current = @mutex.synchronize do + @in_progress.delete(gem_name) + @completed << gem_name + @completed_count += 1 + end + + log_progress(gem_name, current) + end + end + + def warm_cache_for_gem(gem_name) + metadata = Gemstar::RubyGemsMetadata.new(gem_name) + metadata.meta + metadata.repo_uri + Gemstar::ChangeLog.new(metadata).sections + rescue StandardError => e + log "Cache refresh failed for #{gem_name}: #{e.class}: #{e.message}" + end + + def log_progress(gem_name, current) + return unless @debug + return unless current <= 5 || (current % 25).zero? + + log "Background cache refresh #{current}/#{@total}: #{gem_name}" + end + + def log(message) + @io.puts(message) + end + end +end diff --git a/lib/gemstar/change_log.rb b/lib/gemstar/change_log.rb index d12c375..286c074 100644 --- a/lib/gemstar/change_log.rb +++ b/lib/gemstar/change_log.rb @@ -10,21 +10,30 @@ def initialize(metadata) attr_reader :metadata - def content - @content ||= fetch_changelog_content + def content(cache_only: false) + return @content if !cache_only && defined?(@content) + + result = fetch_changelog_content(cache_only: cache_only) + @content = result unless cache_only + result end - def sections - @sections ||= begin - s = parse_changelog_sections - if s.nil? || s.empty? - s = parse_github_release_sections - end + def sections(cache_only: false) + return @sections if !cache_only && defined?(@sections) + + result = begin + s = parse_changelog_sections(cache_only: cache_only) + if s.nil? || s.empty? + s = parse_github_release_sections(cache_only: cache_only) + end - pp @@candidates_found if Gemstar.debug? + pp @@candidates_found if Gemstar.debug? && !cache_only + + s + end - s - end + @sections = result unless cache_only + result end def extract_relevant_sections(old_version, new_version) @@ -64,14 +73,17 @@ def extract_version_from_heading(line) nil end - def changelog_uri_candidates + def changelog_uri_candidates(cache_only: false) candidates = [] - if @metadata.repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby} + repo_uri = @metadata.repo_uri(cache_only: cache_only) + return [] if repo_uri.nil? || repo_uri.empty? + + if repo_uri =~ %r{https://github\.com/aws/aws-sdk-ruby} base = "https://raw.githubusercontent.com/aws/aws-sdk-ruby/refs/heads/version-3/gems/#{@metadata.gem_name}" aws_style = true else - base = @metadata.repo_uri.sub("https://github.com", "https://raw.githubusercontent.com") + base = repo_uri.sub("https://github.com", "https://raw.githubusercontent.com") aws_style = false end @@ -94,7 +106,8 @@ def changelog_uri_candidates end # Add the gem's changelog_uri last as it's usually not the most parsable: - candidates += [Gemstar::GitHub::github_blob_to_raw(@metadata.meta["changelog_uri"])] + meta = @metadata.meta(cache_only: cache_only) + candidates += [Gemstar::GitHub::github_blob_to_raw(meta["changelog_uri"])] if meta candidates.flatten! candidates.uniq! @@ -103,15 +116,19 @@ def changelog_uri_candidates candidates end - def fetch_changelog_content + def fetch_changelog_content(cache_only: false) content = nil - changelog_uri_candidates.find do |candidate| - content = Cache.fetch("changelog-#{candidate}") do - URI.open(candidate, read_timeout: 8)&.read - rescue => e - puts "#{candidate}: #{e}" if Gemstar.debug? - nil + changelog_uri_candidates(cache_only: cache_only).find do |candidate| + content = if cache_only + Cache.peek("changelog-#{candidate}") + else + Cache.fetch("changelog-#{candidate}") do + URI.open(candidate, read_timeout: 8)&.read + rescue => e + puts "#{candidate}: #{e}" if Gemstar.debug? + nil + end end # puts "fetch_changelog_content #{candidate}:\n#{content}" if Gemstar.debug? @@ -134,11 +151,11 @@ def fetch_changelog_content /^\s*(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)(?:\s*[-(].*)?/i ] - def parse_changelog_sections + def parse_changelog_sections(cache_only: false) # If the fetched content looks like a GitHub Releases HTML page, return {} # so that the GitHub releases scraper can handle it. This avoids # accidentally parsing HTML from /releases pages as a markdown changelog. - c = content + c = content(cache_only: cache_only) return {} if c.nil? || c.strip.empty? if (c.include?(" e - puts "#{url}: #{e}" if Gemstar.debug? - nil + html = if cache_only + Cache.peek("releases-#{url}") + else + Cache.fetch("releases-#{url}") do + begin + URI.open(url, read_timeout: 8)&.read + rescue => e + puts "#{url}: #{e}" if Gemstar.debug? + nil + end end end @@ -271,9 +293,9 @@ def parse_github_release_sections sections end - def github_releases_url - return nil unless @metadata&.repo_uri - repo = @metadata.repo_uri.chomp("/") + def github_releases_url(repo_uri = @metadata&.repo_uri) + return nil unless repo_uri + repo = repo_uri.chomp("/") return nil if repo.empty? "#{repo}/releases" end diff --git a/lib/gemstar/cli.rb b/lib/gemstar/cli.rb index 07f9579..9e17ac1 100644 --- a/lib/gemstar/cli.rb +++ b/lib/gemstar/cli.rb @@ -22,11 +22,15 @@ def diff desc "server", "Start the interactive web server" method_option :bind, type: :string, default: "127.0.0.1", desc: "Bind address" method_option :port, type: :numeric, default: 2112, desc: "Port" - method_option :project, type: :array, default: [], desc: "Project directories or Gemfile paths" + method_option :project, type: :string, repeatable: true, desc: "Project directories or Gemfile paths" + method_option :reload, type: :boolean, default: false, desc: "Restart automatically when files change" def server Gemstar::Commands::Server.new(options).run end + desc "cache SUBCOMMAND ...ARGS", "Manage gemstar caches" + subcommand "cache", Gemstar::CacheCLI + # desc "pick", "Interactively cherry-pick and upgrade gems" # option :gem, type: :string, desc: "Gem name to cherry-pick" # def pick diff --git a/lib/gemstar/commands/cache.rb b/lib/gemstar/commands/cache.rb new file mode 100644 index 0000000..38b0ce3 --- /dev/null +++ b/lib/gemstar/commands/cache.rb @@ -0,0 +1,12 @@ +require_relative "command" + +module Gemstar + module Commands + class Cache < Command + def flush + removed_entries = Gemstar::Cache.flush! + puts "Flushed #{removed_entries} cache entr#{removed_entries == 1 ? 'y' : 'ies'} from #{Gemstar::Cache::CACHE_DIR}" + end + end + end +end diff --git a/lib/gemstar/commands/server.rb b/lib/gemstar/commands/server.rb index c9bff2c..a711b51 100644 --- a/lib/gemstar/commands/server.rb +++ b/lib/gemstar/commands/server.rb @@ -1,43 +1,136 @@ require_relative "command" +require "shellwords" module Gemstar module Commands class Server < Command DEFAULT_BIND = "127.0.0.1" DEFAULT_PORT = 2112 + RELOAD_ENV_VAR = "GEMSTAR_RELOAD_ACTIVE" + RELOAD_GLOB = "{lib/**/*.rb,bin/gemstar,README.md}" attr_reader :bind attr_reader :port attr_reader :project_inputs + attr_reader :reload def initialize(options) super @bind = options[:bind] || DEFAULT_BIND @port = (options[:port] || DEFAULT_PORT).to_i - @project_inputs = Array(options[:project]).compact + @project_inputs = normalize_project_inputs(options[:project]) + @reload = options[:reload] end def run + restart_with_rerun if reload_requested? + require "rackup" require "webrick" + require "gemstar/request_logger" + require "gemstar/webrick_logger" require "gemstar/web/app" Gemstar::Config.ensure_home_directory! projects = load_projects - app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory) + log_loaded_projects(projects) + cache_warmer = start_background_cache_refresh(projects) + app = Gemstar::Web::App.build(projects: projects, config_home: Gemstar::Config.home_directory, cache_warmer: cache_warmer) + app = Gemstar::RequestLogger.new(app, io: $stderr) if debug_request_logging? puts "Gemstar server listening on http://#{bind}:#{port}" puts "Config home: #{Gemstar::Config.home_directory}" - Rackup::Server.start(app: app, Host: bind, Port: port) + Rackup::Server.start( + app: app, + Host: bind, + Port: port, + AccessLog: [], + Logger: Gemstar::WEBrickLogger.new($stderr, WEBrick::BasicLog::INFO) + ) end private + def normalize_project_inputs(project_option) + inputs = Array(project_option).compact.map(&:to_s) + return ["."] if inputs.empty? + + inputs.uniq + end + + def reload_requested? + reload && ENV[RELOAD_ENV_VAR] != "1" + end + + def restart_with_rerun + rerun_executable = find_rerun_executable + unless rerun_executable + raise Thor::Error, "The `rerun` gem is not installed. Run `bundle install` and try `gemstar server --reload` again." + end + + puts "Starting gemstar server in reload mode..." + puts "Watching changes matching #{RELOAD_GLOB.inspect}" + + env = ENV.to_h.merge(RELOAD_ENV_VAR => "1") + exec env, *rerun_command(rerun_executable) + end + + def find_rerun_executable + Gem.bin_path("rerun", "rerun") + rescue Gem::Exception + nil + end + + def rerun_command(rerun_executable) + [ + rerun_executable, + "--pattern", + RELOAD_GLOB, + "--", + Gem.ruby, + File.expand_path($PROGRAM_NAME) + ] + server_arguments_without_reload + end + + def server_arguments_without_reload + args = [ + "server", + "--bind", bind, + "--port", port.to_s + ] + project_inputs.each do |project| + args << "--project" + args << project + end + args + end + def load_projects project_inputs.map { |input| Gemstar::Project.from_cli_argument(input) } end + + def log_loaded_projects(projects) + return unless debug_request_logging? + + $stderr.puts "[gemstar] project inputs: #{project_inputs.inspect}" + $stderr.puts "[gemstar] loaded projects (#{projects.count}): #{projects.map(&:directory).inspect}" + end + + def debug_request_logging? + ENV["DEBUG"] == "1" + end + + def start_background_cache_refresh(projects) + gem_names = projects.flat_map do |project| + project.current_lockfile&.specs&.keys || [] + end.uniq.sort + + return nil if gem_names.empty? + + Gemstar::CacheWarmer.new(io: $stderr, debug: debug_request_logging? || Gemstar.debug?, thread_count: 10).enqueue_many(gem_names) + end end end end diff --git a/lib/gemstar/project.rb b/lib/gemstar/project.rb index 09b30b5..d8e8565 100644 --- a/lib/gemstar/project.rb +++ b/lib/gemstar/project.rb @@ -40,18 +40,89 @@ def lockfile? File.file?(lockfile_path) end + def current_lockfile + return nil unless lockfile? + + @current_lockfile ||= Gemstar::LockFile.new(path: lockfile_path) + end + def revision_history(limit: 20) - return [] if git_root.nil? || git_root.empty? + history_for_paths(tracked_git_paths, limit: limit) + end - tracked_paths = [gemfile_path, lockfile_path].filter_map do |path| - next unless File.file?(path) + def lockfile_revision_history(limit: 20) + return [] unless lockfile? - git_repo.relative_path(path) + relative_path = git_repo.relative_path(lockfile_path) + return [] if relative_path.nil? + + history_for_paths([relative_path], limit: limit) + end + + def gemfile_revision_history(limit: 20) + relative_path = git_repo.relative_path(gemfile_path) + return [] if relative_path.nil? + + history_for_paths([relative_path], limit: limit) + end + + def default_from_revision_id + lockfile_revision_history(limit: 1).first&.dig(:id) || + gemfile_revision_history(limit: 1).first&.dig(:id) || + "worktree" + end + + def revision_options(limit: 20) + [{ id: "worktree", label: "Worktree", description: "Current Gemfile.lock in the working tree" }] + + revision_history(limit: limit).map do |revision| + { + id: revision[:id], + label: revision[:short_sha], + description: "#{revision[:subject]} (#{revision[:authored_at].strftime("%Y-%m-%d %H:%M")})" + } + end + end + + def lockfile_for_revision(revision_id) + return current_lockfile if revision_id.nil? || revision_id == "worktree" + return nil unless lockfile? + + relative_lockfile_path = git_repo.relative_path(lockfile_path) + return nil if relative_lockfile_path.nil? + + content = git_repo.try_git_command(["show", "#{revision_id}:#{relative_lockfile_path}"]) + return nil if content.nil? || content.empty? + + Gemstar::LockFile.new(content: content) + end + + def gem_states(from_revision_id: default_from_revision_id, to_revision_id: "worktree") + from_specs = lockfile_for_revision(from_revision_id)&.specs || {} + to_specs = lockfile_for_revision(to_revision_id)&.specs || {} + + (from_specs.keys | to_specs.keys).map do |gem_name| + old_version = from_specs[gem_name] + new_version = to_specs[gem_name] + + { + name: gem_name, + old_version: old_version, + new_version: new_version, + status: gem_status(old_version, new_version), + version_label: version_label(old_version, new_version) + } + end.sort_by do |gem| + [status_rank(gem[:status]), gem[:name]] end + end + + private - return [] if tracked_paths.empty? + def history_for_paths(paths, limit: 20) + return [] if git_root.nil? || git_root.empty? + return [] if paths.empty? - output = git_repo.log_for_paths(tracked_paths.uniq, limit:) + output = git_repo.log_for_paths(paths, limit: limit) return [] if output.nil? || output.empty? output.lines.filter_map do |line| @@ -59,6 +130,7 @@ def revision_history(limit: 20) next if full_sha.nil? { + id: full_sha, full_sha: full_sha, short_sha: short_sha, authored_at: Time.iso8601(authored_at), @@ -68,5 +140,50 @@ def revision_history(limit: 20) rescue ArgumentError [] end + + def tracked_git_paths + [gemfile_path, lockfile_path].filter_map do |path| + next unless File.file?(path) + + git_repo.relative_path(path) + end.uniq + end + + def gem_status(old_version, new_version) + return :added if old_version.nil? && !new_version.nil? + return :removed if !old_version.nil? && new_version.nil? + return :unchanged if old_version == new_version + + comparison = compare_versions(new_version, old_version) + return :upgrade if comparison.positive? + return :downgrade if comparison.negative? + + :changed + end + + def version_label(old_version, new_version) + return "new -> #{new_version}" if old_version.nil? && !new_version.nil? + return "#{old_version} -> removed" if !old_version.nil? && new_version.nil? + return new_version.to_s if old_version == new_version + + "#{old_version} -> #{new_version}" + end + + def status_rank(status) + { + upgrade: 0, + added: 1, + downgrade: 2, + removed: 3, + changed: 4, + unchanged: 5 + }.fetch(status, 9) + end + + def compare_versions(left, right) + Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, "")) + rescue ArgumentError + left.to_s <=> right.to_s + end end end diff --git a/lib/gemstar/request_logger.rb b/lib/gemstar/request_logger.rb new file mode 100644 index 0000000..b870709 --- /dev/null +++ b/lib/gemstar/request_logger.rb @@ -0,0 +1,31 @@ +module Gemstar + class RequestLogger + def initialize(app, io: $stderr) + @app = app + @io = io + end + + def call(env) + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + status, headers, body = @app.call(env) + log_request(env, status, started_at) + [status, headers, body] + rescue StandardError => e + log_request(env, 500, started_at, error: e) + raise + end + + private + + def log_request(env, status, started_at, error: nil) + duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1) + path = env["PATH_INFO"].to_s + query = env["QUERY_STRING"].to_s + full_path = query.empty? ? path : "#{path}?#{query}" + method = env["REQUEST_METHOD"].to_s + suffix = error ? " #{error.class}: #{error.message}" : "" + + @io.puts "[gemstar] #{method} #{full_path} -> #{status} in #{duration_ms}ms#{suffix}" + end + end +end diff --git a/lib/gemstar/ruby_gems_metadata.rb b/lib/gemstar/ruby_gems_metadata.rb index ecd0749..2da2437 100644 --- a/lib/gemstar/ruby_gems_metadata.rb +++ b/lib/gemstar/ruby_gems_metadata.rb @@ -10,49 +10,61 @@ def initialize(gem_name) attr_reader :gem_name - def meta - @meta ||= - begin - url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json" - Cache.fetch("rubygems-#{gem_name}") do - URI.open(url).read - end.then { |json| - begin - JSON.parse(json) if json - rescue - nil - end } + def meta(cache_only: false) + return @meta if !cache_only && defined?(@meta) + + json = if cache_only + Cache.peek("rubygems-#{gem_name}") + else + url = "https://rubygems.org/api/v1/gems/#{URI.encode_www_form_component(gem_name)}.json" + Cache.fetch("rubygems-#{gem_name}") do + URI.open(url).read end + end + + parsed = begin + JSON.parse(json) if json + rescue + nil + end + + @meta = parsed unless cache_only + parsed end - def repo_uri - return nil unless meta + def repo_uri(cache_only: false) + resolved_meta = meta(cache_only: cache_only) + return nil unless resolved_meta + + return @repo_uri if !cache_only && defined?(@repo_uri) + + repo = begin + uri = resolved_meta["source_code_uri"] - @repo_uri ||= begin - uri = meta["source_code_uri"] + if uri.nil? + uri = resolved_meta["homepage_uri"] + if uri.include?("github.com") + uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}] + end + end - if uri.nil? - uri = meta["homepage_uri"] - if uri.include?("github.com") - uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}] - end - end + uri ||= "" - uri ||= "" + uri = uri.sub("http://", "https://") - uri = uri.sub("http://", "https://") + uri = uri.gsub(/\.git$/, "") - uri = uri.gsub(/\.git$/, "") + if uri.include?("github.io") + uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do + "https://github.com/#{$1}/#{$2}" + end + end - if uri.include?("github.io") - # Convert e.g. https://socketry.github.io/console/ to https://github.com/socketry/console/ - uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do - "https://github.com/#{$1}/#{$2}" - end - end + uri + end - uri - end + @repo_uri = repo unless cache_only + repo end end diff --git a/lib/gemstar/web/app.rb b/lib/gemstar/web/app.rb index e366658..5661da1 100644 --- a/lib/gemstar/web/app.rb +++ b/lib/gemstar/web/app.rb @@ -1,14 +1,22 @@ require "cgi" +require "uri" +require "kramdown" require "roda" +begin + require "kramdown-parser-gfm" +rescue LoadError +end + module Gemstar module Web class App < Roda class << self - def build(projects:, config_home:) + def build(projects:, config_home:, cache_warmer: nil) Class.new(self) do opts[:projects] = projects opts[:config_home] = config_home + opts[:cache_warmer] = cache_warmer end.freeze.app end end @@ -16,125 +24,530 @@ def build(projects:, config_home:) route do |r| @projects = self.class.opts.fetch(:projects) @config_home = self.class.opts.fetch(:config_home) + @cache_warmer = self.class.opts[:cache_warmer] + @metadata_cache = {} r.root do - render_page("Projects") do - <<~HTML -
    -

    Gemstar

    -

    Current projects

    -

    Config home: #{h(@config_home)}

    -
    - #{render_projects} - HTML + load_state(r.params) + prioritize_selected_gem + + render_page(page_title) do + render_shell end end + r.get "detail" do + load_state(r.params) + prioritize_selected_gem + render_detail + end + r.on "projects", String do |project_id| - project = project_for(project_id) - r.is do - next not_found_page unless project - - render_page(project.name) do - <<~HTML -

    All projects

    -
    -

    Project

    -

    #{h(project.name)}

    -

    #{h(project.directory)}

    -
    - #{render_project_summary(project)} - #{render_revision_history(project)} - HTML - end - end + response.redirect "/?project=#{project_id}" end end private - def project_for(project_id) - index = Integer(project_id, 10) - return nil if index.negative? + def page_title + return "Gemstar" unless @selected_project + + "#{@selected_project.name}: Gemstar" + end - @projects[index] + def load_state(params) + @selected_project_index = selected_project_index(params["project"]) + @selected_project = @projects[@selected_project_index] + @revision_options = @selected_project ? @selected_project.revision_options : [] + @selected_from_revision_id = selected_from_revision_id(params["from"]) + @selected_to_revision_id = selected_to_revision_id(params["to"]) + @gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : [] + @selected_gem = selected_gem_state(params["gem"]) + end + + def prioritize_selected_gem + @cache_warmer&.prioritize(@selected_gem[:name]) if @selected_gem + end + + def selected_project_index(raw_index) + return nil if @projects.empty? + return 0 if raw_index.nil? || raw_index.empty? + + index = Integer(raw_index, 10) + return 0 if index.negative? || @projects[index].nil? + + index rescue ArgumentError - nil + 0 + end + + def selected_from_revision_id(raw_revision_id) + return "worktree" unless @selected_project + + valid_ids = @revision_options.map { |option| option[:id] } + default_id = @selected_project.default_from_revision_id + candidate = raw_revision_id.nil? || raw_revision_id.empty? ? default_id : raw_revision_id + + valid_ids.include?(candidate) ? candidate : default_id + end + + def selected_to_revision_id(raw_revision_id) + return "worktree" unless @selected_project + + valid_ids = @revision_options.map { |option| option[:id] } + candidate = raw_revision_id.nil? || raw_revision_id.empty? ? "worktree" : raw_revision_id + + valid_ids.include?(candidate) ? candidate : "worktree" + end + + def selected_gem_state(raw_gem_name) + return nil if @gem_states.empty? + + @gem_states.find { |gem| gem[:name] == raw_gem_name } || + @gem_states.find { |gem| gem[:status] != :unchanged } || + @gem_states.first + end + + def render_shell + return render_empty_workspace if @projects.empty? + + <<~HTML +
    + #{render_topbar} + #{render_workspace} +
    + #{render_behavior_script} + HTML + end + + def render_empty_workspace + <<~HTML +
    +
    +
    +
    G
    +
    +

    Gemstar

    +

    Gemfile.lock explorer

    +
    +
    +
    +
    +

    No projects loaded

    +

    Gemstar loads the current directory by default. Use --project to add other project paths.

    +

    Config home: #{h(@config_home)}

    +
    +
    + HTML + end + + def render_topbar + <<~HTML +
    +
    +
    G
    +

    Gemfile.lock explorer

    +
    +
    + + + +
    +
    + HTML + end + + def project_options_html + @projects.each_with_index.map do |project, index| + selected = index == @selected_project_index ? ' selected="selected"' : "" + <<~HTML + + HTML + end.join + end + + def from_revision_options_html + return '' unless @selected_project + + @revision_options.map do |option| + selected = option[:id] == @selected_from_revision_id ? ' selected="selected"' : "" + <<~HTML + + HTML + end.join + end + + def to_revision_options_html + return '' unless @selected_project + + @revision_options.map do |option| + selected = option[:id] == @selected_to_revision_id ? ' selected="selected"' : "" + <<~HTML + + HTML + end.join + end + + def render_workspace + <<~HTML +
    + #{render_toolbar} +
    + #{render_sidebar} + #{render_detail} +
    +
    + HTML + end + + def render_toolbar + <<~HTML +
    +
    + + +
    +
    + #{@gem_states.count} gems + · + #{@gem_states.count { |gem| gem[:status] != :unchanged }} changes from #{h(selected_from_revision_label)} to #{h(selected_to_revision_label)} + · + keyboard: arrows +
    +
    + HTML + end + + def selected_from_revision_label + @revision_options.find { |option| option[:id] == @selected_from_revision_id }&.dig(:label) || "worktree" + end + + def selected_to_revision_label + @revision_options.find { |option| option[:id] == @selected_to_revision_id }&.dig(:label) || "worktree" + end + + def render_sidebar + <<~HTML + + HTML end - def render_projects - return <<~HTML if @projects.empty? -
    -

    No projects yet

    -

    Start the server with one or more --project paths.

    + def render_gem_list + return <<~HTML if @gem_states.empty? +
    +

    No gems found in the current lockfile.

    HTML - items = @projects.each_with_index.map do |project, index| + items = @gem_states.map do |gem| + selected = gem[:name] == @selected_gem[:name] ? " is-selected" : "" + updated = gem[:status] != :unchanged <<~HTML -
  • - #{h(project.name)} -
    #{h(project.directory)}
    -
  • + + + #{h(gem[:name])} + #{updated ? '' : ""} + + #{h(gem[:version_label])} + HTML end.join <<~HTML -
    -

    Projects

    -
      - #{items} -
    + + HTML + end + + def render_detail + return <<~HTML unless @selected_gem +
    +
    +

    No gem selected

    +

    Choose a gem from the list to inspect its current version and changelog revisions.

    +
    +
    + HTML + + metadata = metadata_for(@selected_gem[:name]) + detail_pending = detail_pending?(@selected_gem[:name], metadata) + + <<~HTML +
    + #{render_detail_hero(metadata)} + #{render_detail_links(metadata)} + #{render_detail_loading_notice if detail_pending} + #{render_detail_revision_panel}
    HTML end - def render_project_summary(project) + def render_detail_hero(metadata) + summary = if @selected_gem[:old_version] == @selected_gem[:new_version] + @selected_gem[:new_version].to_s + else + @selected_gem[:version_label] + end + + description = metadata&.dig("info") + + <<~HTML +
    +
    +

    #{h(@selected_gem[:name])}

    +

    #{h(summary)}

    +
    +
    +
    +

    #{description ? h(description) : "Metadata will appear here when RubyGems information is available."}

    +
    + HTML + end + + def render_detail_links(metadata) + repo_url = metadata ? Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) : nil + homepage_url = metadata&.dig("homepage_uri") + rubygems_url = "https://rubygems.org/gems/#{URI.encode_www_form_component(@selected_gem[:name])}" + + buttons = [] + buttons << external_button("RubyGems", rubygems_url) + buttons << external_button("GitHub", repo_url) if repo_url && !repo_url.empty? + buttons << external_button("Homepage", homepage_url) if homepage_url && !homepage_url.empty? + <<~HTML -
    -

    Paths

    -
    -
    Gemfile
    -
    #{h(project.gemfile_path)}
    -
    Gemfile.lock
    -
    #{h(project.lockfile_path)}#{project.lockfile? ? "" : " (missing)"}
    -
    Git root
    -
    #{project.git_root ? h(project.git_root) : "Not in a git repository"}
    -
    + HTML end - def render_revision_history(project) - revisions = project.revision_history + def render_detail_revision_panel + groups = grouped_change_sections(@selected_gem) - return <<~HTML if revisions.empty? -
    -

    Gemfile revisions

    -

    No git revisions found for this Gemfile or Gemfile.lock.

    + <<~HTML +
    +
    +
    +

    Revisions for #{@selected_gem[:name]}

    +
    +
    #{h(selected_from_revision_label)} -> #{h(selected_to_revision_label)}
    +
    + #{render_revision_group("Latest", groups[:latest], empty_message: "No newer changelog entries found yet.")} + #{render_revision_group("New in this revision", groups[:current], empty_message: "No changelog entries matched this revision range.")} + #{render_revision_group("Previous updates", groups[:previous], empty_message: "No older changelog entries found.")}
    HTML + end - items = revisions.map do |revision| + def render_detail_loading_notice + <<~HTML +
    +

    Loading gem metadata and changelog in the background...

    +
    + HTML + end + + def render_revision_group(title, sections, empty_message:) + cards = if sections.empty? <<~HTML -
  • - #{h(revision[:short_sha])} - #{h(revision[:subject])} -
    #{h(revision[:authored_at].iso8601)}
    -
  • +
    +

    #{h(empty_message)}

    +
    HTML - end.join + else + sections.map { |section| render_revision_card(section) }.join + end <<~HTML -
    -

    Gemfile revisions

    -
      - #{items} -
    +
    +
    +

    #{h(title)}

    +
    + #{cards}
    HTML end + def render_revision_card(section) + <<~HTML +
    +
    +
    +
    #{h(section[:version])}
    +
    +
    +
    + #{section[:html]} +
    +
    + HTML + end + + def grouped_change_sections(gem_state) + sections = change_sections(gem_state) + + { + latest: sections.select { |section| section[:kind] == :future }, + current: sections.select { |section| section[:kind] == :current }, + previous: sections.select { |section| section[:kind] == :previous } + } + end + + def change_sections(gem_state) + return [] if gem_state[:new_version].nil? && gem_state[:old_version].nil? + + metadata = Gemstar::RubyGemsMetadata.new(gem_state[:name]) + sections = Gemstar::ChangeLog.new(metadata).sections(cache_only: true) + return [] if sections.nil? || sections.empty? + + current_version = gem_state[:new_version] || gem_state[:old_version] + previous_version = gem_state[:old_version] + + rendered_sections = sections.keys.filter_map do |version| + kind = section_kind(version, previous_version, current_version, gem_state[:status]) + next unless kind + + { + version: version, + kind: kind, + html: changelog_markup(sections[version]) + } + end + + rendered_sections.sort_by { |section| section_sort_key(section) } + rescue StandardError + [] + end + + def section_kind(version, previous_version, current_version, status) + return :future if compare_versions(version, current_version) == 1 + + lower_bound = previous_version || current_version + if compare_versions(version, lower_bound) == 1 && compare_versions(version, current_version) <= 0 + return :current + end + + if [:downgrade, :removed].include?(status) + upper_bound = previous_version || current_version + lower_bound = current_version || "0.0.0" + + return :current if compare_versions(version, lower_bound) == 1 && + compare_versions(version, upper_bound) <= 0 + end + + return :current if previous_version.nil? && compare_versions(version, current_version) <= 0 && compare_versions(version, current_version) >= 0 + + :previous if compare_versions(version, lower_bound) <= 0 + end + + def section_sort_key(section) + kind_rank = { future: 0, current: 1, previous: 2 }.fetch(section[:kind], 9) + [kind_rank, -sortable_version_number(section[:version])] + end + + def sortable_version_number(version) + Gem::Version.new(version.to_s.gsub(/-[\w\-]+$/, "")).segments.take(6).each_with_index.sum do |segment, index| + segment.to_i * (10**(10 - index * 2)) + end + rescue ArgumentError + 0 + end + + def changelog_markup(lines) + text = Array(lines).flatten.join + return "

    No changelog text available.

    " if text.strip.empty? + + options = { hard_wrap: false } + options[:input] = "GFM" if defined?(Kramdown::Parser::GFM) + html = Kramdown::Document.new(text, options).to_html + with_external_links(html) + rescue Kramdown::Error + "
    #{h(text)}
    " + end + + def compare_versions(left, right) + Gem::Version.new(left.to_s.gsub(/-[\w\-]+$/, "")) <=> Gem::Version.new(right.to_s.gsub(/-[\w\-]+$/, "")) + rescue ArgumentError + left.to_s <=> right.to_s + end + + def metadata_for(gem_name) + @metadata_cache[gem_name] ||= Gemstar::RubyGemsMetadata.new(gem_name).meta(cache_only: true) + rescue StandardError + nil + end + + def detail_pending?(gem_name, metadata) + metadata.nil? && change_sections({ name: gem_name, old_version: @selected_gem[:old_version], new_version: @selected_gem[:new_version], status: @selected_gem[:status] }).empty? + end + + def external_button(label, url) + <<~HTML + #{h(label)} + HTML + end + + def with_external_links(html) + fragment = Nokogiri::HTML::DocumentFragment.parse(html) + fragment.css("a[href]").each do |link| + link["target"] = "_blank" + link["rel"] = "noreferrer" + end + fragment.to_html + rescue StandardError + html + end + + def detail_query(project:, from:, to:, gem:) + "/detail?#{URI.encode_www_form(project: project, from: from, to: to, gem: gem)}" + end + + def project_query(project:, from:, to:, gem:) + params = { + project: project, + from: from, + to: to, + gem: gem + }.compact + + "/?#{URI.encode_www_form(params)}" + end + def render_page(title) <<~HTML @@ -142,108 +555,579 @@ def render_page(title) - Gemstar: #{h(title)} + #{h(title)} -
    - #{yield} -
    + #{yield} HTML end - def not_found_page - response.status = 404 - render_page("Not found") do - <<~HTML -
    -

    Project not found

    -

    Back to projects

    -
    - HTML - end + def render_behavior_script + <<~HTML + + HTML end def h(value) diff --git a/lib/gemstar/webrick_logger.rb b/lib/gemstar/webrick_logger.rb new file mode 100644 index 0000000..a8d479f --- /dev/null +++ b/lib/gemstar/webrick_logger.rb @@ -0,0 +1,22 @@ +module Gemstar + class WEBrickLogger < WEBrick::Log + EXPECTED_DISCONNECT_ERRORS = [ + Errno::ECONNRESET, + Errno::ECONNABORTED, + Errno::EPIPE + ].freeze + + def error(message) + return if expected_disconnect?(message) + + super + end + + private + + def expected_disconnect?(message) + EXPECTED_DISCONNECT_ERRORS.any? { |error_class| message.is_a?(error_class) } || + message.to_s.start_with?("Errno::ECONNRESET:", "Errno::ECONNABORTED:", "Errno::EPIPE:") + end + end +end From 7bc44c349a8e8ad973947d28c356edc196acdb8f Mon Sep 17 00:00:00 2001 From: Florian Dejako Date: Thu, 19 Mar 2026 15:42:20 +0100 Subject: [PATCH 3/5] Add initial web UI for gem exploration - Introduced HTML templates (CSS, JavaScript) for interactive gem management. - Enhanced `Gemstar::Web::App` with revised revision handling and no-cache headers. - Added gem filtering, navigation, and detail fetching functionality. - Updated toolbar and detail views for improved interactivity. --- lib/gemstar/lock_file.rb | 94 ++- lib/gemstar/project.rb | 53 +- lib/gemstar/web/app.rb | 907 +++++++----------------- lib/gemstar/web/templates/app.css | 490 +++++++++++++ lib/gemstar/web/templates/app.js.erb | 201 ++++++ lib/gemstar/web/templates/page.html.erb | 15 + 6 files changed, 1095 insertions(+), 665 deletions(-) create mode 100644 lib/gemstar/web/templates/app.css create mode 100644 lib/gemstar/web/templates/app.js.erb create mode 100644 lib/gemstar/web/templates/page.html.erb diff --git a/lib/gemstar/lock_file.rb b/lib/gemstar/lock_file.rb index a834c0a..29b201d 100644 --- a/lib/gemstar/lock_file.rb +++ b/lib/gemstar/lock_file.rb @@ -2,29 +2,107 @@ module Gemstar class LockFile def initialize(path: nil, content: nil) @path = path - @specs = content ? parse_content(content) : parse_lockfile(path) + parsed = content ? parse_content(content) : parse_lockfile(path) + @specs = parsed[:specs] + @dependency_graph = parsed[:dependency_graph] + @direct_dependencies = parsed[:direct_dependencies] end attr_reader :specs + attr_reader :dependency_graph + attr_reader :direct_dependencies + + def origins_for(gem_name) + return [{ type: :direct, path: [gem_name] }] if direct_dependencies.include?(gem_name) + + direct_dependencies.filter_map do |root_dependency| + path = shortest_path_from(root_dependency, gem_name) + next if path.nil? + + { type: :transitive, path: path } + end + end private + def shortest_path_from(root_dependency, target_gem) + queue = [[root_dependency, [root_dependency]]] + visited = {} + + until queue.empty? + current_name, path = queue.shift + next if visited[current_name] + + visited[current_name] = true + + Array(dependency_graph[current_name]).each do |dependency_name| + next_path = path + [dependency_name] + return next_path if dependency_name == target_gem + + queue << [dependency_name, next_path] + end + end + + nil + end + def parse_lockfile(path) parse_content(File.read(path)) end def parse_content(content) specs = {} - in_specs = false + dependency_graph = Hash.new { |hash, key| hash[key] = [] } + direct_dependencies = [] + current_section = nil + current_spec = nil + content.each_line do |line| - in_specs = true if line.strip == "GEM" - next unless in_specs - if line =~ /^\s{4}(\S+) \((.+)\)/ - name, version = $1, $2 - specs[name] = version + stripped = line.strip + + if stripped.match?(/\A[A-Z][A-Z0-9 ]*\z/) + current_section = nil + current_spec = nil + end + + if stripped == "GEM" + current_section = :gem + current_spec = nil + next + end + + if stripped == "DEPENDENCIES" + current_section = :dependencies + current_spec = nil + next + end + + if stripped.empty? + current_spec = nil if current_section == :dependencies + next + end + + case current_section + when :gem + if line =~ /^\s{4}(\S+) \((.+)\)/ + name, version = Regexp.last_match(1), Regexp.last_match(2) + specs[name] = version + current_spec = name + elsif current_spec && line =~ /^\s{6}([^\s(]+)/ + dependency_graph[current_spec] << Regexp.last_match(1) + end + when :dependencies + if line =~ /^\s{2}([^\s!(]+)/ + direct_dependencies << Regexp.last_match(1) + end end end - specs + + { + specs: specs, + dependency_graph: dependency_graph.transform_values(&:uniq), + direct_dependencies: direct_dependencies.uniq + } end end end diff --git a/lib/gemstar/project.rb b/lib/gemstar/project.rb index d8e8565..6eb5522 100644 --- a/lib/gemstar/project.rb +++ b/lib/gemstar/project.rb @@ -67,7 +67,7 @@ def gemfile_revision_history(limit: 20) end def default_from_revision_id - lockfile_revision_history(limit: 1).first&.dig(:id) || + default_changed_lockfile_revision_id || gemfile_revision_history(limit: 1).first&.dig(:id) || "worktree" end @@ -97,27 +97,41 @@ def lockfile_for_revision(revision_id) end def gem_states(from_revision_id: default_from_revision_id, to_revision_id: "worktree") - from_specs = lockfile_for_revision(from_revision_id)&.specs || {} - to_specs = lockfile_for_revision(to_revision_id)&.specs || {} + from_lockfile = lockfile_for_revision(from_revision_id) + to_lockfile = lockfile_for_revision(to_revision_id) + from_specs = from_lockfile&.specs || {} + to_specs = to_lockfile&.specs || {} (from_specs.keys | to_specs.keys).map do |gem_name| old_version = from_specs[gem_name] new_version = to_specs[gem_name] + bundle_origins = to_lockfile&.origins_for(gem_name) || [] { name: gem_name, old_version: old_version, new_version: new_version, status: gem_status(old_version, new_version), - version_label: version_label(old_version, new_version) + version_label: version_label(old_version, new_version), + bundle_origins: bundle_origins, + bundle_origin_labels: bundle_origin_labels(bundle_origins) } - end.sort_by do |gem| - [status_rank(gem[:status]), gem[:name]] - end + end.sort_by { |gem| gem[:name] } end private + def default_changed_lockfile_revision_id + return nil unless lockfile? + + current_specs = current_lockfile&.specs || {} + + lockfile_revision_history(limit: 20).find do |revision| + revision_lockfile = lockfile_for_revision(revision[:id]) + revision_lockfile && revision_lockfile.specs != current_specs + end&.dig(:id) + end + def history_for_paths(paths, limit: 20) return [] if git_root.nil? || git_root.empty? return [] if paths.empty? @@ -162,22 +176,11 @@ def gem_status(old_version, new_version) end def version_label(old_version, new_version) - return "new -> #{new_version}" if old_version.nil? && !new_version.nil? - return "#{old_version} -> removed" if !old_version.nil? && new_version.nil? + return "new → #{new_version}" if old_version.nil? && !new_version.nil? + return "#{old_version} → removed" if !old_version.nil? && new_version.nil? return new_version.to_s if old_version == new_version - "#{old_version} -> #{new_version}" - end - - def status_rank(status) - { - upgrade: 0, - added: 1, - downgrade: 2, - removed: 3, - changed: 4, - unchanged: 5 - }.fetch(status, 9) + "#{old_version} → #{new_version}" end def compare_versions(left, right) @@ -185,5 +188,13 @@ def compare_versions(left, right) rescue ArgumentError left.to_s <=> right.to_s end + + def bundle_origin_labels(origins) + Array(origins).map do |origin| + next "Gemfile" if origin[:type] == :direct + + ["Gemfile", *origin[:path]].join(" → ") + end.compact.uniq + end end end diff --git a/lib/gemstar/web/app.rb b/lib/gemstar/web/app.rb index 5661da1..2f8445e 100644 --- a/lib/gemstar/web/app.rb +++ b/lib/gemstar/web/app.rb @@ -1,4 +1,5 @@ require "cgi" +require "erb" require "uri" require "kramdown" require "roda" @@ -26,6 +27,7 @@ def build(projects:, config_home:, cache_warmer: nil) @config_home = self.class.opts.fetch(:config_home) @cache_warmer = self.class.opts[:cache_warmer] @metadata_cache = {} + apply_no_cache_headers! r.root do load_state(r.params) @@ -49,6 +51,12 @@ def build(projects:, config_home:, cache_warmer: nil) private + def apply_no_cache_headers! + response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response["Pragma"] = "no-cache" + response["Expires"] = "0" + end + def page_title return "Gemstar" unless @selected_project @@ -59,8 +67,9 @@ def load_state(params) @selected_project_index = selected_project_index(params["project"]) @selected_project = @projects[@selected_project_index] @revision_options = @selected_project ? @selected_project.revision_options : [] - @selected_from_revision_id = selected_from_revision_id(params["from"]) @selected_to_revision_id = selected_to_revision_id(params["to"]) + @selected_from_revision_id = selected_from_revision_id(params["from"]) + @selected_to_revision_id = selected_to_revision_id(@selected_to_revision_id) @gem_states = @selected_project ? @selected_project.gem_states(from_revision_id: @selected_from_revision_id, to_revision_id: @selected_to_revision_id) : [] @selected_gem = selected_gem_state(params["gem"]) end @@ -84,8 +93,8 @@ def selected_project_index(raw_index) def selected_from_revision_id(raw_revision_id) return "worktree" unless @selected_project - valid_ids = @revision_options.map { |option| option[:id] } - default_id = @selected_project.default_from_revision_id + valid_ids = valid_from_revision_ids + default_id = default_from_revision_id_for(@selected_to_revision_id) candidate = raw_revision_id.nil? || raw_revision_id.empty? ? default_id : raw_revision_id valid_ids.include?(candidate) ? candidate : default_id @@ -94,10 +103,10 @@ def selected_from_revision_id(raw_revision_id) def selected_to_revision_id(raw_revision_id) return "worktree" unless @selected_project - valid_ids = @revision_options.map { |option| option[:id] } + valid_ids = valid_to_revision_ids candidate = raw_revision_id.nil? || raw_revision_id.empty? ? "worktree" : raw_revision_id - valid_ids.include?(candidate) ? candidate : "worktree" + valid_ids.include?(candidate) ? candidate : valid_ids.first || "worktree" end def selected_gem_state(raw_gem_name) @@ -128,7 +137,7 @@ def render_empty_workspace
    G

    Gemstar

    -

    Gemfile.lock explorer

    +

    Gemstar

    @@ -146,7 +155,7 @@ def render_topbar
    G
    -

    Gemfile.lock explorer

    +

    Gemstar

    @@ -416,13 +438,32 @@ def render_detail_hero(metadata) HTML end + def render_added_on(added_on) + return "" unless added_on + + revision_markup = if added_on[:revision_url] + %(#{h(added_on[:revision])}) + else + h(added_on[:revision]) + end + + <<~HTML +
    +

    Added to #{h(added_on[:project_name])} on #{h(added_on[:date])} (#{revision_markup}).

    +
    + HTML + end + def render_dependency_origins(bundle_origins) origins = Array(bundle_origins).filter_map do |origin| path = Array(origin[:path]).compact - next if path.empty? + display_path = path.dup + display_path.pop if display_path.last == @selected_gem[:name] + + next if origin[:type] != :direct && display_path.empty? - linked_path = linked_gem_chain(["Gemfile", *path]) - origin[:type] == :direct ? "Gemfile" : linked_path + linked_path = linked_gem_chain(["Gemfile", *display_path]) + origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path end.uniq return "" if origins.empty? @@ -479,12 +520,28 @@ def selected_gem_requirements Array(lockfile&.dependency_graph&.fetch(@selected_gem[:name], nil)) end + def selected_gem_added_on + revision_id = @selected_gem[:new_version] ? @selected_to_revision_id : @selected_from_revision_id + @selected_project&.gem_added_on(@selected_gem[:name], revision_id: revision_id) + end + def linked_gem_chain(names) Array(names).map.with_index do |name, index| - index.zero? ? h(name) : internal_gem_link(name) + if index.zero? + gemfile_link(name) + else + internal_gem_link(name) + end end.join(" → ") end + def gemfile_link(label = "Gemfile") + return h(label) unless @selected_project + + href = "/gemfile?#{URI.encode_www_form(project: @selected_project_index)}" + %(#{h(label)}) + end + def internal_gem_link(name) href = project_query( project: @selected_project_index, @@ -503,7 +560,7 @@ def render_detail_revision_panel <<~HTML
    #{render_revision_group("Latest", groups[:latest], empty_message: nil) if groups[:latest].any?} - #{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries matched this revision range.")} + #{render_revision_group(current_section_title, groups[:current], empty_message: "No changelog entries in this revision range.")} #{render_revision_group("Previous changes", groups[:previous], empty_message: nil) if groups[:previous].any?}
    HTML @@ -563,11 +620,19 @@ def render_revision_card(section) def grouped_change_sections(gem_state) sections = change_sections(gem_state) + latest = sections.select { |section| section[:kind] == :future } + current = sections.select { |section| section[:kind] == :current } + previous = sections.select { |section| section[:kind] == :previous } + + if current.empty? + fallback = fallback_current_section(gem_state, previous, latest) + current = [fallback] if fallback + end { - latest: sections.select { |section| section[:kind] == :future }, - current: sections.select { |section| section[:kind] == :current }, - previous: sections.select { |section| section[:kind] == :previous } + latest: latest, + current: current, + previous: previous } end @@ -741,6 +806,35 @@ def revision_card_links(section) links end + def fallback_current_section(gem_state, previous_sections, latest_sections) + version = gem_state[:new_version] || gem_state[:old_version] + return nil if version.nil? + return nil if previous_sections.any? { |section| section[:version] == version } + return nil if latest_sections.any? { |section| section[:version] == version } + + repo_url = Gemstar::RubyGemsMetadata.new(gem_state[:name]).repo_uri(cache_only: true) + repo_link = if repo_url.to_s.empty? + "the gem repository" + else + %(the gem repository) + end + + { + version: version, + title: version, + kind: :current, + previous_version: fallback_previous_version_for(gem_state, previous_sections), + html: "

    No release information available. Check #{repo_link} for more information.

    " + } + end + + def fallback_previous_version_for(gem_state, previous_sections) + return gem_state[:old_version] if gem_state[:new_version] + return previous_sections.first[:version] if previous_sections.any? + + nil + end + def github_compare_url(repo_url, previous_version, current_version) return nil unless repo_url.include?("github.com") return nil if previous_version.nil? || current_version.nil? diff --git a/lib/gemstar/web/templates/app.css b/lib/gemstar/web/templates/app.css index f63e2cd..9012503 100644 --- a/lib/gemstar/web/templates/app.css +++ b/lib/gemstar/web/templates/app.css @@ -216,10 +216,8 @@ font-size: 0.9rem; } .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; + display: grid; + gap: 0.35rem; position: sticky; top: -0.45rem; z-index: 1; @@ -227,6 +225,12 @@ padding: 0.45rem 0 0.35rem; border-bottom: 1px solid #f0ede6; } + .sidebar-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } .list-filters { display: flex; gap: 0.28rem; @@ -247,6 +251,18 @@ background: var(--green-soft); color: var(--green); } + .gem-search { + width: 100%; + border: 1px solid #e2ddd2; + border-radius: 0.35rem; + padding: 0.32rem 0.45rem; + background: #fff; + color: var(--ink); + font-size: 0.78rem; + } + .gem-search::placeholder { + color: #93877b; + } .gem-list { display: grid; gap: 0; diff --git a/lib/gemstar/web/templates/app.js.erb b/lib/gemstar/web/templates/app.js.erb index a326fdc..fe55d39 100644 --- a/lib/gemstar/web/templates/app.js.erb +++ b/lib/gemstar/web/templates/app.js.erb @@ -4,11 +4,13 @@ const toSelect = document.querySelector("[data-to-select]"); const sidebarPanel = document.querySelector("[data-sidebar-panel]"); const filterButtons = Array.from(document.querySelectorAll("[data-filter-button]")); + const gemSearch = document.querySelector("[data-gem-search]"); const emptyGemList = document.querySelector("[data-gem-list-empty]"); let detailPanel = document.querySelector("[data-detail-panel]"); const gemLinks = Array.from(document.querySelectorAll("[data-gem-link]")); let detailPollTimer = null; let currentFilter = <%= selected_filter_json %>; + let currentSearch = ""; const emptyDetailHtml = <%= empty_detail_html_json %>; const visibleGemLinks = () => gemLinks.filter((link) => !link.hidden); @@ -23,6 +25,7 @@ const applyGemFilter = (filter) => { currentFilter = filter; const pinnedGemName = requestedGemName(); + const searchTerm = currentSearch.trim().toLowerCase(); filterButtons.forEach((button) => { button.classList.toggle("is-active", button.dataset.filterButton === filter); @@ -31,11 +34,15 @@ gemLinks.forEach((link) => { const updated = link.dataset.gemUpdated === "true"; const pinned = pinnedGemName && link.dataset.gemName === pinnedGemName; - link.hidden = filter === "updated" && !updated && !pinned; + const matchesSearch = searchTerm === "" || link.dataset.gemName.toLowerCase().includes(searchTerm); + link.hidden = ((filter === "updated" && !updated && !pinned) || !matchesSearch); }); if (emptyGemList) { - emptyGemList.hidden = !(filter === "updated" && visibleGemLinks().length === 0); + const emptyMessage = searchTerm === "" ? "No updated gems in this revision range." : "No gems match this filter."; + emptyGemList.hidden = visibleGemLinks().length !== 0; + const text = emptyGemList.querySelector("p"); + if (text) text.textContent = emptyMessage; } if (currentSelectedIndex() === -1) { @@ -148,6 +155,17 @@ }); }); + if (gemSearch) { + gemSearch.addEventListener("input", (event) => { + currentSearch = event.target.value || ""; + applyGemFilter(currentFilter); + if (sidebarPanel) { + sidebarPanel.scrollTop = 0; + } + syncSidebarSelection(null, true); + }); + } + const navigate = (params) => { const url = new URL(window.location.href); Object.entries(params).forEach(([key, value]) => {