diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e4637..dbfcc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,13 @@ # 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 - ## 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/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/TODO.md b/TODO.md new file mode 100644 index 0000000..2c62190 --- /dev/null +++ b/TODO.md @@ -0,0 +1,23 @@ +## Gemstar 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) + +### Server +- Add... project in web ui +- bundle install, bundle update, etc. +- possibly add gem in web ui +- upgrade/downgrade/pin specific versions diff --git a/gemstar.gemspec b/gemstar.gemspec index c539692..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" @@ -37,4 +38,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..f7bb4bb 100644 --- a/lib/gemstar.rb +++ b/lib/gemstar.rb @@ -1,15 +1,21 @@ # 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" 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/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..5199d5e 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? && !cache_only - pp @@candidates_found if Gemstar.debug? + s + end - s - end + @sections = result unless cache_only + result end def extract_relevant_sections(old_version, new_version) @@ -52,26 +61,30 @@ def extract_relevant_sections(old_version, new_version) def extract_version_from_heading(line) return nil unless line heading = line.to_s + version_token = /(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)/ # 1) Prefer version inside parentheses after a date: "### 2025-11-07 (2.16.0)" # Ensure we ONLY treat it as a version if it actually looks like a version (has a dot), # so we don't capture dates like (2025-11-21). - return $1 if heading[/\(\s*v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\s*\)/] + return $1 if heading[/\(\s*v?#{version_token}(?![A-Za-z0-9])\s*\)/] # 2) Version-first with optional leading markers/labels: "## v1.2.6 - 2025-10-21" # Require a dot in the numeric token to avoid capturing dates like 2025-11-21. - return $1 if heading[/^\s*(?:#+|=+)?\s*(?:Version\s+)?\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?/i] + return $1 if heading[/^\s*(?:[-*]\s+)?(?:#+|=+)?\s*(?:Version\s+)?\[*v?#{version_token}(?![A-Za-z0-9])\]*/i] # 3) Anywhere: first semver-like token with a dot - return $1 if heading[/\bv?(\d+\.\d+(?:\.\d+)?(?:[A-Za-z0-9.\-])*)\b/] + return $1 if heading[/\bv?#{version_token}(?![A-Za-z0-9])\b/] 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 +107,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 +117,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? @@ -127,18 +145,18 @@ def fetch_changelog_content end VERSION_PATTERNS = [ - /^\s*(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d[\w.\-]+)\s*\)/, # prefer this - /^\s*(?:#+|=+)\s*\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/, - /^\s*(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?(?:\s*[-(].*)?/i, - /^\s*(?:#+|=+)\s*(?:Version\s+)?\[?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)\]?(?:\s*[-(].*)?/i, - /^\s*(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?[A-Za-z0-9.\-]*)(?:\s*[-(].*)?/i + /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\d{4}-\d{2}-\d{2}\s*\(\s*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\s*\)/, # prefer this + /^\s*(?:[-*]\s+)?(?:#+|=+)\s*\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*\s*(?:—|–|-)\s*\d{4}-\d{2}-\d{2}\b/, + /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?(?:(?:[^\s\d][^\s]*\s+)+)\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i, + /^\s*(?:[-*]\s+)?(?:#+|=+)\s*(?:Version\s+)?\[*v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![A-Za-z0-9])\]*(?:\s*[-(].*)?/i, + /^\s*(?:[-*]\s+)?(?:Version\s+)?v?(\d+\.\d+(?:\.\d+)?(?:[-.][A-Za-z0-9]+)*)(?![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 + + if (html.nil? || html.strip.empty?) && cache_only + cached_content = content(cache_only: true) + if cached_content&.include?(" "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/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..727e5d4 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,50 @@ 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 origin_repo_url + remote = try_git_command(["remote", "get-url", "origin"]) + return nil if remote.nil? || remote.empty? + + normalize_remote_url(remote) + end + + def log_for_paths(paths, limit: 20, reverse: false) + return "" if tree_root_directory.nil? || tree_root_directory.empty? || paths.empty? + + format = "%H%x1f%h%x1f%aI%x1f%s" + command = ["log"] + command += ["-n", limit.to_s] if limit + command << "--reverse" if reverse + command += ["--pretty=format:#{format}", "--", *paths] + + run_git_command(command, in_directory: tree_root_directory) + end + + private + + def normalize_remote_url(remote) + normalized = remote.strip.sub(%r{\.git\z}, "") + + if normalized.start_with?("git@github.com:") + path = normalized.delete_prefix("git@github.com:") + return "https://github.com/#{path}" + end + + if normalized.start_with?("ssh://git@github.com/") + path = normalized.delete_prefix("ssh://git@github.com/") + return "https://github.com/#{path}" + end + + normalized.sub(%r{\Ahttp://}, "https://") + end end end 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 new file mode 100644 index 0000000..1e2beb9 --- /dev/null +++ b/lib/gemstar/project.rb @@ -0,0 +1,245 @@ +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 current_lockfile + return nil unless lockfile? + + @current_lockfile ||= Gemstar::LockFile.new(path: lockfile_path) + end + + def revision_history(limit: 20) + history_for_paths(tracked_git_paths, limit: limit) + end + + def lockfile_revision_history(limit: 20) + return [] unless lockfile? + + 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 + default_changed_lockfile_revision_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_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), + bundle_origins: bundle_origins, + bundle_origin_labels: bundle_origin_labels(bundle_origins) + } + end.sort_by { |gem| gem[:name] } + end + + def gem_added_on(gem_name, revision_id: "worktree") + return nil unless lockfile? + + target_lockfile = lockfile_for_revision(revision_id) + return nil unless target_lockfile&.specs&.key?(gem_name) + + relative_path = git_repo.relative_path(lockfile_path) + return nil if relative_path.nil? + + first_seen_revision = history_for_paths([relative_path], limit: nil, reverse: true).find do |revision| + lockfile = lockfile_for_revision(revision[:id]) + lockfile&.specs&.key?(gem_name) + end + + return worktree_added_on_info if first_seen_revision.nil? && revision_id == "worktree" + return nil unless first_seen_revision + + { + project_name: name, + date: first_seen_revision[:authored_at].strftime("%Y-%m-%d"), + revision: first_seen_revision[:short_sha], + revision_url: revision_url(first_seen_revision[:id]), + worktree: false + } + 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, reverse: false) + return [] if git_root.nil? || git_root.empty? + return [] if paths.empty? + + output = git_repo.log_for_paths(paths, limit: limit, reverse: reverse) + 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? + + { + id: full_sha, + full_sha: full_sha, + short_sha: short_sha, + authored_at: Time.iso8601(authored_at), + subject: subject + } + end + 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 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 bundle_origin_labels(origins) + Array(origins).map do |origin| + next "Gemfile" if origin[:type] == :direct + + ["Gemfile", *origin[:path]].join(" → ") + end.compact.uniq + end + + def worktree_added_on_info + return nil unless File.file?(lockfile_path) + + { + project_name: name, + date: File.mtime(lockfile_path).strftime("%Y-%m-%d"), + revision: "Worktree", + revision_url: nil, + worktree: true + } + end + + def revision_url(full_sha) + repo_url = git_repo.origin_repo_url + return nil unless repo_url&.include?("github.com") + + "#{repo_url}/commit/#{full_sha}" + 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..633100d 100644 --- a/lib/gemstar/ruby_gems_metadata.rb +++ b/lib/gemstar/ruby_gems_metadata.rb @@ -10,49 +10,65 @@ 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"] + + if uri.nil? + uri = resolved_meta["homepage_uri"] + if uri.include?("github.com") + uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}] + end + end - @repo_uri ||= begin - uri = meta["source_code_uri"] + uri ||= "" - if uri.nil? - uri = meta["homepage_uri"] - if uri.include?("github.com") - uri = uri[%r{http[s?]://github\.com/[^/]+/[^/]+}] - end - end + uri = uri.sub("http://", "https://") - uri ||= "" + uri = uri.gsub(/\.git$/, "") - uri = uri.sub("http://", "https://") + if uri.include?("github.io") + uri = uri.sub(%r{\Ahttps?://([\w-]+)\.github\.io/([^/]+)}) do + "https://github.com/#{$1}/#{$2}" + end + end - uri = uri.gsub(/\.git$/, "") + if uri.include?("github.com") + uri = uri[%r{\Ahttps?://github\.com/[^/]+/[^/]+}] || uri + 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 new file mode 100644 index 0000000..b4453b0 --- /dev/null +++ b/lib/gemstar/web/app.rb @@ -0,0 +1,936 @@ +require "cgi" +require "erb" +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:, 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 + + 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 = {} + apply_no_cache_headers! + + r.root do + 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.get "gemfile" do + project_index = selected_project_index(r.params["project"]) + project = @projects[project_index] + response.status = 404 + next "Gemfile not found" unless project && File.file?(project.gemfile_path) + + response["Content-Type"] = "text/plain; charset=utf-8" + File.read(project.gemfile_path) + end + + r.on "projects", String do |project_id| + response.redirect "/?project=#{project_id}" + end + end + + 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 + + "#{@selected_project.name}: Gemstar" + end + + 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_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) : [] + @requested_gem_name = params["gem"] + @selected_filter = selected_filter(params["filter"], params["gem"]) + @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 + 0 + end + + def selected_from_revision_id(raw_revision_id) + return "worktree" unless @selected_project + + 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 + end + + def selected_to_revision_id(raw_revision_id) + return "worktree" unless @selected_project + + valid_ids = valid_to_revision_ids + candidate = raw_revision_id.nil? || raw_revision_id.empty? ? "worktree" : raw_revision_id + + valid_ids.include?(candidate) ? candidate : valid_ids.first || "worktree" + end + + def selected_filter(raw_filter, raw_gem_name) + return "all" if @gem_states.empty? + return raw_filter if %w[updated all].include?(raw_filter) + + selected_gem = @gem_states.find { |gem| gem[:name] == raw_gem_name } + return "all" if selected_gem && selected_gem[:status] == :unchanged + + @gem_states.any? { |gem| gem[:status] != :unchanged } ? "updated" : "all" + end + + def selected_gem_state(raw_gem_name) + return nil if @gem_states.empty? + + exact_match = @gem_states.find { |gem| gem[:name] == raw_gem_name } + return exact_match if exact_match + + @gem_states.find { |gem| gem_visible_in_selected_filter?(gem) && gem[:status] != :unchanged } || + @gem_states.find { |gem| gem_visible_in_selected_filter?(gem) } || + @gem_states.find { |gem| gem[:status] != :unchanged } || + @gem_states.first + end + + def gem_visible_in_selected_filter?(gem_state) + return true if @selected_filter != "updated" + + gem_state[:status] != :unchanged + 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

+

Gemstar

+
+
+
+
+

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
+

Gemstar

+
+
+ + + +
+
+ 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"' : "" + disabled = valid_from_revision_ids.include?(option[:id]) ? "" : ' disabled="disabled"' + <<~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"' : "" + disabled = valid_to_revision_ids.include?(option[:id]) ? "" : ' disabled="disabled"' + <<~HTML + + HTML + end.join + end + + def revision_option_index(revision_id) + @revision_options.index { |option| option[:id] == revision_id } + end + + def valid_from_revision_ids + return [] unless @selected_project + + to_index = revision_option_index(@selected_to_revision_id) || 0 + @revision_options.filter_map.with_index do |option, index| + option[:id] if index > to_index + end + end + + def valid_to_revision_ids + return [] unless @selected_project + return @revision_options.map { |option| option[:id] } unless @selected_from_revision_id + + from_index = revision_option_index(@selected_from_revision_id) + return @revision_options.map { |option| option[:id] } if from_index.nil? + + @revision_options.filter_map.with_index do |option, index| + option[:id] if index < from_index + end + end + + def default_from_revision_id_for(to_revision_id) + default_id = @selected_project.default_from_revision_id + return default_id if valid_from_revision_ids.include?(default_id) + + valid_from_revision_ids.first || default_id + 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)} +
+
+ + +
+
+ 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_gem_list + return <<~HTML if @gem_states.empty? +
+

No gems found in the current lockfile.

+
+ HTML + + items = @gem_states.map do |gem| + selected = gem[:name] == @selected_gem[:name] ? " is-selected" : "" + status_class = " status-#{gem[:status]}" + updated = gem[:status] != :unchanged + hidden = @selected_filter == "updated" && !updated && gem[:name] != @requested_gem_name ? ' hidden="hidden"' : "" + <<~HTML + + + #{h(gem[:name])} + #{updated ? '' : ""} + + #{h(gem[:version_label])} + + HTML + end.join + + <<~HTML + + + HTML + end + + def render_detail + return empty_detail_html unless @selected_gem + + metadata = metadata_for(@selected_gem[:name]) + detail_pending = detail_pending?(@selected_gem[:name], metadata) + + <<~HTML +
+ #{render_detail_hero(metadata)} + #{render_detail_loading_notice if detail_pending} + #{render_detail_revision_panel} +
+ HTML + end + + def empty_detail_html + <<~HTML +
+
+

No gem selected

+

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

+
+
+ HTML + end + + def render_detail_hero(metadata) + description = metadata&.dig("info") + bundle_origins = Array(@selected_gem[:bundle_origins]) + requirement_names = selected_gem_requirements + bundled_version = @selected_gem[:new_version] + added_on = selected_gem_added_on + title_url = metadata&.dig("homepage_uri") + title_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) if title_url.to_s.empty? + title_markup = if title_url.to_s.empty? + h(@selected_gem[:name]) + else + %(#{h(@selected_gem[:name])}) + end + + <<~HTML +
+
+
+

#{title_markup}#{bundled_version ? %( #{h(bundled_version)}) : ""}

+ #{render_detail_links(metadata)} +
+

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

+ #{render_added_on(added_on)} + #{render_dependency_origins(bundle_origins)} + #{render_requirements(requirement_names)} +
+
+ 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 + 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", *display_path]) + origin[:type] == :direct ? gemfile_link("Gemfile") : linked_path + end.uniq + return "" if origins.empty? + + items = origins.map { |origin| "
  • #{origin}
  • " }.join + <<~HTML +
    + Required by +
      + #{items} +
    +
    + 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 << icon_button("RubyGems", rubygems_url, icon_type: :rubygems) + buttons << icon_button("GitHub", repo_url, icon_type: :github) if repo_url && !repo_url.empty? + buttons << icon_button("Homepage", homepage_url, icon_type: :home) if homepage_url && !homepage_url.empty? + + <<~HTML + + HTML + end + + def render_requirements(requirement_names) + names = Array(requirement_names).compact.uniq + return "" if names.empty? + + items = names.map { |name| "
  • #{internal_gem_link(name)}
  • " }.join + <<~HTML +
    + Requires +
      + #{items} +
    +
    + HTML + end + + def selected_gem_requirements + lockfile = if @selected_gem[:new_version] + @selected_project&.lockfile_for_revision(@selected_to_revision_id) + else + @selected_project&.lockfile_for_revision(@selected_from_revision_id) + end + + 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| + 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, + from: @selected_from_revision_id, + to: @selected_to_revision_id, + filter: @selected_filter, + gem: name + ) + + %(#{h(name)}) + end + + def render_detail_revision_panel + groups = grouped_change_sections(@selected_gem) + + <<~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 in this revision range.")} + #{render_revision_group("Previous changes", groups[:previous], empty_message: nil) if groups[:previous].any?} +
    + HTML + end + + 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? + return "" unless empty_message + + <<~HTML +
    +

    #{h(empty_message)}

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

    #{h(title)}

    +
    + #{cards} +
    + HTML + end + + def render_revision_card(section) + title_links = revision_card_links(section) + status_class = @selected_gem ? " status-#{@selected_gem[:status]}" : "" + + <<~HTML +
    +
    +
    +
    #{h(section[:title] || section[:version])}
    +
    + #{title_links.join} +
    +
    +
    +
    + #{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 } + + if current.empty? + fallback = fallback_current_section(gem_state, previous, latest) + current = [fallback] if fallback + end + + { + latest: latest, + current: current, + previous: 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 + content = changelog_content(sections[version], heading_version: version) + + { + version: version, + title: content[:title], + kind: kind, + previous_version: previous_section_version(sections.keys, version), + html: content[:html] + } + 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 + return :current if status == :added && compare_versions(version, current_version) <= 0 + + 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 + + :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_content(lines, heading_version: nil) + text = Array(lines).flatten.join + return { title: heading_version.to_s, html: "

    No changelog text available.

    " } if text.strip.empty? + + if heading_version + text = text.sub(/\A\s*#+\s*v?#{Regexp.escape(heading_version)}\s*\n+/i, "") + end + + options = { hard_wrap: false } + options[:input] = "GFM" if defined?(Kramdown::Parser::GFM) + html = Kramdown::Document.new(text, options).to_html + extract_card_title(with_external_links(html), fallback_title: heading_version.to_s, version: heading_version.to_s) + rescue Kramdown::Error + { title: heading_version.to_s, html: "
    #{h(text)}
    " } + end + + def extract_card_title(html, fallback_title:, version:) + fragment = Nokogiri::HTML::DocumentFragment.parse(html) + first_heading = fragment.at_css("h1, h2, h3, h4, h5, h6") + title = fallback_title + + if first_heading + heading_text = first_heading.text.to_s.strip + if heading_text.include?(version.to_s) + title = heading_text + first_heading.remove + end + end + + { title: title, html: fragment.to_html } + 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 icon_button(label, url, icon_type:) + <<~HTML + + #{icon_svg(icon_type)} + + HTML + end + + def icon_svg(icon_type) + case icon_type + when :github + '' + when :home + '' + when :rubygems + '' + else + '' + end + end + + def current_section_title + if @selected_to_revision_id == "worktree" + "Worktree changes since #{selected_from_revision_label}" + else + "Changes from #{selected_from_revision_label} to #{selected_to_revision_label}" + end + end + + def range_label(gem_state) + old_version = gem_state[:old_version] + new_version = gem_state[:new_version] + return new_version.to_s if old_version == new_version + return "new-#{new_version}" if old_version.nil? && new_version + return "#{old_version}-removed" if old_version && new_version.nil? + + "#{old_version}-#{new_version}" + end + + def previous_section_version(versions, current_version) + ordered_versions = versions.sort_by { |version| -sortable_version_number(version) } + current_index = ordered_versions.index(current_version) + return nil if current_index.nil? + + ordered_versions[current_index + 1] + end + + def revision_card_links(section) + repo_url = Gemstar::RubyGemsMetadata.new(@selected_gem[:name]).repo_uri(cache_only: true) + return [] if repo_url.to_s.empty? + + links = [] + compare_url = github_compare_url(repo_url, section[:previous_version], section[:version]) + links << icon_button("Git diff", compare_url, icon_type: :github) if compare_url + + release_url = github_release_url(repo_url, section[:version]) + links << icon_button("Release", release_url, icon_type: :github) if release_url && compare_url.nil? + 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? + + "#{repo_url}/compare/#{github_tag_name(previous_version)}...#{github_tag_name(current_version)}" + end + + def github_release_url(repo_url, version) + return nil unless repo_url.include?("github.com") + return nil if version.nil? + + "#{repo_url}/releases/tag/#{github_tag_name(version)}" + end + + def github_tag_name(version) + version.to_s.start_with?("v") ? version.to_s : "v#{version}" + 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:, filter:, gem:) + "/detail?#{URI.encode_www_form(project: project, from: from, to: to, filter: filter, gem: gem)}" + end + + def project_query(project:, from:, to:, filter:, gem:) + params = { + project: project, + from: from, + to: to, + filter: filter, + gem: gem + }.compact + + "/?#{URI.encode_www_form(params)}" + end + + def render_page(title) + render_template( + "page.html.erb", + title: h(title), + favicon_data_uri: favicon_data_uri, + styles_css: template_source("app.css"), + body_html: yield + ) + end + + def favicon_data_uri + svg = <<~SVG + + + G + + SVG + + "data:image/svg+xml,#{URI.encode_www_form_component(svg)}" + end + + def render_behavior_script + script = render_template( + "app.js.erb", + empty_detail_html_json: empty_detail_html.dump, + selected_filter_json: @selected_filter.dump, + selected_project_index: @selected_project_index || 0 + ) + + <<~HTML + + HTML + end + + def template_source(name) + File.read(template_path(name)) + end + + def render_template(name, locals = {}) + ERB.new(template_source(name), trim_mode: "-").result_with_hash(locals) + end + + def template_path(name) + File.expand_path(File.join("templates", name), __dir__) + end + + def h(value) + CGI.escapeHTML(value.to_s) + end + end + end +end diff --git a/lib/gemstar/web/templates/app.css b/lib/gemstar/web/templates/app.css new file mode 100644 index 0000000..9012503 --- /dev/null +++ b/lib/gemstar/web/templates/app.css @@ -0,0 +1,523 @@ + :root { + color-scheme: light; + --canvas: #f1ecdf; + --panel: #fff9ef; + --panel-strong: #f7efe0; + --ink: #1f1b17; + --muted: #6b6057; + --line: #ddcfbf; + --accent: #b44d25; + --accent-soft: rgba(180, 77, 37, 0.14); + --green: #2f8f5b; + --green-soft: rgba(47, 143, 91, 0.12); + --red: #a9473c; + --red-soft: rgba(169, 71, 60, 0.12); + --grey: #7c7c85; + --grey-soft: rgba(124, 124, 133, 0.1); + --shadow: 0 1.1rem 2.4rem rgba(66, 44, 28, 0.08); + } + + * { box-sizing: border-box; } + body { + margin: 0; + color: var(--ink); + font-family: "Avenir Next", "Helvetica Neue", "Segoe UI", sans-serif; + background: #fff; + } + a { + color: inherit; + text-decoration: none; + } + a[data-gem-link-inline="true"] { + color: #2563c9; + } + a[data-gem-link-inline="true"]:hover { + text-decoration: underline; + } + button, + select { + font: inherit; + } + code, + pre { + font-family: "SFMono-Regular", "Cascadia Code", monospace; + } + .app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + } + .topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + padding: 0.4rem 0.65rem; + border-bottom: 1px solid #ece8df; + background: #fff; + position: sticky; + top: 0; + z-index: 2; + flex-wrap: nowrap; + } + .brand-lockup { + display: flex; + align-items: center; + gap: 0.65rem; + flex: 0 1 auto; + min-width: 0; + } + .brand-mark { + width: 1.55rem; + height: 1.55rem; + border-radius: 0.35rem; + display: grid; + place-items: center; + font-weight: 700; + font-size: 0.82rem; + color: white; + background: linear-gradient(145deg, #c05a2b, #8f3517); + } + .brand-kicker, + .detail-kicker { + margin: 0; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 0.65rem; + } + .brand-lockup h1, + .sidebar-header h2, + .detail h2, + .detail h3, + .empty-state h2 { + margin: 0.05rem 0 0; + line-height: 1; + } + .brand-lockup h1 { + white-space: nowrap; + font-size: 0.96rem; + } + .picker-row { + display: flex; + gap: 0.45rem; + flex-wrap: nowrap; + align-items: center; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 0; + } + .picker { + display: inline-flex; + align-items: center; + gap: 0.28rem; + color: var(--muted); + font-size: 0.78rem; + min-width: 0; + } + .picker-prefix { + white-space: nowrap; + color: var(--muted); + } + .picker-prefix[data-text-label="true"] { + color: var(--ink); + font-weight: 700; + } + .picker select { + min-width: 10rem; + max-width: 17rem; + border: 1px solid var(--line); + border-radius: 0.35rem; + padding: 0.24rem 0.45rem; + background: #fff; + color: var(--ink); + box-shadow: none; + font-size: 0.8rem; + } + .workspace { + display: grid; + gap: 0.45rem; + padding: 0.45rem 0.55rem; + } + .sidebar, + .detail, + .empty-state { + background: #fff; + border: 1px solid #ece8df; + border-radius: 0.35rem; + box-shadow: none; + } + .toolbar { + display: flex; + justify-content: space-between; + gap: 0.45rem; + align-items: center; + padding: 0.35rem 0.5rem; + flex-wrap: wrap; + background: transparent; + } + .toolbar-actions { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + margin-left: auto; + } + .action, + .add-gem, + .link-button { + border: 1px solid var(--line); + border-radius: 0.3rem; + padding: 0.22rem 0.5rem; + background: #fff; + color: var(--ink); + font-size: 0.78rem; + } + .action-primary { + background: linear-gradient(145deg, #c55a28, #984119); + color: white; + border-color: rgba(152, 65, 25, 0.65); + } + .action[disabled], + .add-gem[disabled] { + opacity: 0.55; + cursor: not-allowed; + } + .toolbar-meta { + color: var(--muted); + display: flex; + gap: 0.28rem; + flex-wrap: wrap; + align-items: center; + font-size: 0.88rem; + justify-content: flex-start; + } + .workspace-body { + display: grid; + grid-template-columns: minmax(16rem, 22rem) minmax(0, 1fr); + gap: 0.45rem; + min-height: calc(100vh - 8rem); + height: calc(100vh - 8rem); + } + .sidebar { + padding: 0.45rem; + display: grid; + gap: 0.3rem; + align-content: start; + min-height: 0; + overflow: auto; + } + .detail-subtitle { + margin: 0.3rem 0 0; + color: var(--muted); + } + .detail-origin { + margin: 0; + color: var(--ink); + font-size: 0.9rem; + } + .sidebar-header { + display: grid; + gap: 0.35rem; + position: sticky; + top: -0.45rem; + z-index: 1; + background: #fff; + 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; + flex-wrap: wrap; + } + .list-filter-button { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.18rem 0.5rem; + background: #fff; + color: var(--muted); + font-size: 0.74rem; + line-height: 1; + min-height: 2rem; + } + .list-filter-button.is-active { + border-color: rgba(47, 143, 91, 0.4); + 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; + } + .gem-row { + display: grid; + gap: 0.08rem; + padding: 0.38rem 0.2rem; + border-radius: 0; + border: 0; + border-bottom: 1px solid #f0ede6; + background: transparent; + transition: background 120ms ease, color 120ms ease; + } + .gem-row[hidden] { + display: none; + } + .gem-row:hover, + .gem-row.is-selected { + background: #faf8f3; + } + .gem-name { + font-weight: 700; + font-size: 0.9rem; + } + .gem-name-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + .gem-updated-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 999px; + background: var(--green); + flex: 0 0 auto; + margin-right: 0.2rem; + } + .gem-row.status-removed .gem-updated-dot { + background: var(--red); + } + .gem-version { + color: var(--muted); + font-size: 0.76rem; + } + .gem-row.status-added .gem-version { + color: var(--green); + font-weight: 600; + } + .detail { + padding: 0.5rem 0.8rem 0.5rem 0.5rem; + display: grid; + gap: 0.45rem; + align-content: start; + min-height: 0; + overflow: auto; + } + .detail-hero, + .panel-heading { + display: flex; + justify-content: space-between; + gap: 0.65rem; + align-items: start; + flex-wrap: wrap; + } + .detail-hero-copy { + display: grid; + gap: 0.2rem; + min-width: 0; + width: 100%; + } + .detail-title-row { + display: flex; + align-items: start; + justify-content: space-between; + gap: 0.65rem; + min-width: 0; + width: 100%; + } + .detail-hero-copy h2 { + margin: 0; + font-size: 2.35rem; + line-height: 1.02; + min-width: 0; + flex: 1 1 auto; + } + .detail-title-version { + font-size: 0.62em; + font-weight: 600; + color: var(--muted); + } + .detail-title-row .link-strip { + margin-left: auto; + align-self: start; + flex: 0 0 auto; + } + .panel-heading-meta { + color: var(--muted); + font-size: 0.76rem; + } + .empty-panel { + border: 1px solid #f0ede6; + border-radius: 0.25rem; + background: #fff; + padding: 0.5rem; + } + .link-strip { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + justify-content: flex-end; + } + .icon-button { + width: 2rem; + height: 2rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + .icon-button svg { + width: 1rem; + height: 1rem; + } + .revision-panel { + display: grid; + gap: 0.4rem; + } + .revision-group { + display: grid; + gap: 0.3rem; + } + .revision-group-header h4 { + margin: 0.8rem 0 0.3rem; + font-size: 1.35rem; + line-height: 1.15; + } + .detail-origin { + margin-top: 0.2rem; + } + .detail-origin-list { + margin: 0.2rem 0 0 1.1rem; + padding: 0; + } + .detail-origin-list li + li { + margin-top: 0.12rem; + } + .revision-card { + border-radius: 0.2rem; + border: 1px solid #f0ede6; + background: #fff; + overflow: hidden; + } + .revision-future { + border-style: dashed; + border-color: rgba(124, 124, 133, 0.55); + background: linear-gradient(180deg, rgba(124, 124, 133, 0.07), rgba(255,255,255,0.45)); + } + .revision-current { + border-color: rgba(47, 143, 91, 0.25); + } + .revision-previous { + border-color: rgba(124, 124, 133, 0.2); + background: #f4f4f1; + } + .revision-card-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 0.4rem; + margin-bottom: 0; + } + .revision-card-titlebar { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.45rem; + padding: 0.42rem 0.5rem 0.46rem; + margin: 0; + background: rgba(47, 143, 91, 0.09); + border-bottom: 1px solid #f0ede6; + min-width: 0; + } + .revision-previous .revision-card-titlebar { + background: rgba(124, 124, 133, 0.09); + } + .revision-future .revision-card-titlebar { + background: rgba(124, 124, 133, 0.07); + } + .revision-card-actions { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; + margin-left: auto; + } + .revision-card h5 { + margin: 0; + font-size: 1.55rem; + line-height: 1.1; + color: var(--ink); + min-width: 0; + } + .revision-markup { + padding: 0.7rem; + } + .revision-markup a[href] { + color: #2563c9; + } + .revision-markup a[href]:hover { + text-decoration: underline; + } + .revision-markup > :first-child { + margin-top: 0; + } + .revision-markup > :last-child { + margin-bottom: 0; + } + .empty-state, + .empty-panel { + padding: 0.6rem; + } + @media (max-width: 980px) { + .topbar { + align-items: start; + flex-direction: column; + flex-wrap: wrap; + } + .brand-lockup h1 { + white-space: normal; + } + .picker-row { + width: 100%; + justify-content: stretch; + flex-wrap: wrap; + } + .picker { + width: 100%; + justify-content: space-between; + } + .picker select { + min-width: 0; + width: 100%; + max-width: none; + } + .workspace-body { + grid-template-columns: 1fr; + min-height: 0; + height: auto; + } + .sidebar, + .detail { + overflow: visible; + } + } diff --git a/lib/gemstar/web/templates/app.js.erb b/lib/gemstar/web/templates/app.js.erb new file mode 100644 index 0000000..fe55d39 --- /dev/null +++ b/lib/gemstar/web/templates/app.js.erb @@ -0,0 +1,226 @@ +(() => { + const projectSelect = document.querySelector("[data-project-select]"); + const fromSelect = document.querySelector("[data-from-select]"); + 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); + const currentSelectedIndex = () => visibleGemLinks().findIndex((link) => link.classList.contains("is-selected")); + const clearSidebarSelection = () => { + gemLinks.forEach((link) => { + link.classList.remove("is-selected"); + }); + }; + const requestedGemName = () => new URL(window.location.href).searchParams.get("gem"); + + 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); + }); + + gemLinks.forEach((link) => { + const updated = link.dataset.gemUpdated === "true"; + const pinned = pinnedGemName && link.dataset.gemName === pinnedGemName; + const matchesSearch = searchTerm === "" || link.dataset.gemName.toLowerCase().includes(searchTerm); + link.hidden = ((filter === "updated" && !updated && !pinned) || !matchesSearch); + }); + + if (emptyGemList) { + 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) { + clearSidebarSelection(); + replaceDetail(emptyDetailHtml); + } + }; + + const syncSidebarSelection = (gemName = null, keepVisible = false) => { + const effectiveGemName = gemName || new URL(window.location.href).searchParams.get("gem"); + if (!effectiveGemName) { + clearSidebarSelection(); + return; + } + + let selectedLink = null; + gemLinks.forEach((link) => { + const matches = link.dataset.gemName === effectiveGemName; + link.classList.toggle("is-selected", matches); + if (matches) { + selectedLink = link; + } + }); + + if (keepVisible && sidebarPanel && selectedLink) { + selectedLink.scrollIntoView({ block: "nearest" }); + } + }; + + if ("scrollRestoration" in history) { + history.scrollRestoration = "manual"; + } + + const replaceDetail = (html) => { + if (!detailPanel) return; + detailPanel.outerHTML = html; + detailPanel = document.querySelector("[data-detail-panel]"); + if (detailPanel) detailPanel.scrollTop = 0; + scheduleDetailPoll(); + }; + + const stopDetailPoll = () => { + if (detailPollTimer) { + clearTimeout(detailPollTimer); + detailPollTimer = null; + } + }; + + const fetchDetail = (url, pushHistory = true) => { + fetch(url, { headers: { "X-Requested-With": "gemstar-detail" } }) + .then((response) => response.text()) + .then((html) => { + replaceDetail(html); + if (pushHistory) { + const pageUrl = new URL(window.location.href); + const detailUrl = new URL(url, window.location.origin); + pageUrl.search = detailUrl.search; + pageUrl.searchParams.set("filter", currentFilter); + window.history.pushState({}, "", pageUrl); + } + const detailUrl = new URL(url, window.location.origin); + syncSidebarSelection(detailUrl.searchParams.get("gem")); + }); + }; + + const activateGemLink = (link, pushHistory = true, keepVisible = false) => { + if (!link) return; + + syncSidebarSelection(link.dataset.gemName, keepVisible); + fetchDetail(link.dataset.detailUrl || link.href, pushHistory); + + if (sidebarPanel) { + sidebarPanel.focus({ preventScroll: true }); + } + }; + + const scheduleDetailPoll = () => { + stopDetailPoll(); + if (!detailPanel || detailPanel.dataset.detailPending !== "true") return; + detailPollTimer = setTimeout(() => { + fetchDetail(detailPanel.dataset.detailUrl, false); + }, 1000); + }; + + if (detailPanel) { + detailPanel.scrollTop = 0; + scheduleDetailPoll(); + } + + syncSidebarSelection(null, true); + applyGemFilter(currentFilter); + + gemLinks.forEach((link) => { + link.addEventListener("click", (event) => { + event.preventDefault(); + activateGemLink(link); + }); + }); + + filterButtons.forEach((button) => { + button.addEventListener("click", () => { + applyGemFilter(button.dataset.filterButton); + if (sidebarPanel) { + sidebarPanel.scrollTop = 0; + } + const url = new URL(window.location.href); + url.searchParams.set("filter", currentFilter); + window.history.replaceState({}, "", url); + syncSidebarSelection(null, true); + }); + }); + + 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]) => { + if (value === null || value === undefined || value === "") { + url.searchParams.delete(key); + } else { + url.searchParams.set(key, value); + } + }); + window.location.href = url.toString(); + }; + + if (projectSelect) { + projectSelect.addEventListener("change", (event) => { + if (event.target.value === "__add__") { + window.alert("Add project UI is next. For now, restart gemstar server with another --project path."); + event.target.value = "<%= selected_project_index %>"; + return; + } + navigate({ project: event.target.value, from: null, to: "worktree", filter: currentFilter, gem: null }); + }); + } + + if (fromSelect) { + fromSelect.addEventListener("change", (event) => { + navigate({ from: event.target.value, filter: currentFilter, gem: null }); + }); + } + + if (toSelect) { + toSelect.addEventListener("change", (event) => { + navigate({ to: event.target.value, filter: currentFilter, gem: null }); + }); + } + + document.addEventListener("keydown", (event) => { + const tagName = document.activeElement && document.activeElement.tagName; + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") return; + const links = visibleGemLinks(); + if (!links.length) return; + + const selectedGem = currentSelectedIndex(); + const currentIndex = selectedGem >= 0 ? selectedGem : 0; + let nextIndex = null; + + if (event.key === "ArrowDown") nextIndex = Math.min(currentIndex + 1, links.length - 1); + if (event.key === "ArrowUp") nextIndex = Math.max(currentIndex - 1, 0); + + if (nextIndex !== null && nextIndex !== currentIndex) { + event.preventDefault(); + activateGemLink(links[nextIndex], true, true); + } + }); + + window.addEventListener("popstate", () => { + window.location.reload(); + }); +})(); diff --git a/lib/gemstar/web/templates/page.html.erb b/lib/gemstar/web/templates/page.html.erb new file mode 100644 index 0000000..a66988b --- /dev/null +++ b/lib/gemstar/web/templates/page.html.erb @@ -0,0 +1,15 @@ + + + + + + <%= title %> + + + + +<%= body_html %> + + 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