diff --git a/README.md b/README.md index 9b03c6a..50e7cd3 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - [Split changes from the log buffer](#split-changes-from-the-log-buffer) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) - [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer) + - [Browse current file on remote](#browse-current-file-on-remote) - [Open a changed file](#open-a-changed-file) - [Restore a changed file](#restore-a-changed-file) - [Installation](#installation) @@ -69,6 +70,7 @@ - `undo` - Undo the last operation - `redo` - Redo the last undone operation - `open_pr` - Open a PR/MR on your remote (GitHub, GitLab, Gitea, Forgejo, etc.) + - `browse` - Open the current file on your remote at the current line (or selected range) - `annotate` / `annotate_line` - View file blame and line history with change ID, author, and timestamp - `commit` - Describe the current change and create a new one after - Diff commands @@ -265,6 +267,32 @@ The plugin automatically: **This is a jj.nvim exclusive feature** - the ability to seamlessly bridge from your Neovim jj workflow directly to your remote platform's PR/MR interface. +### Browse current file on remote + +Open the current buffer's file in your browser on the hosted remote (GitHub/GitLab/Gitea/Forgejo, etc.) at the current cursor line or a visually selected range. + +**Usage:** + +```sh +:Jbrowse " Use @ (best-effort chooses a remote-reachable ref) +:Jbrowse main " Use an explicit revset (no walkback) +``` + +**How it works:** + +- Takes the current buffer path and makes it repo-relative (must be inside a jj repo) +- Collects git remotes; if there's more than one, prompts you to pick one +- Normalizes the remote URL to an HTTPS base repo URL +- Picks a ref that is expected to exist on the remote: + - With default `@`: walks back first-parent up to 20 parents to find a commit reachable from that remote's bookmarks; falls back to `trunk()` if needed + - With an explicit revset (e.g. `main`, `@-2`): uses that revset directly (no walkback) + - If there's a single unambiguous remote bookmark pointing at the chosen commit, uses that bookmark name; otherwise uses the commit SHA +- Builds a provider-specific URL and adds a line anchor: + - GitHub-style: `#L` or `#L-L` + - GitLab-style: `#L` or `#L-` + +In Visual mode, select lines and run `:Jbrowse` to open a range. + ### Open a changed file Just press enter to open a file from the `status` output in your current window. @@ -308,6 +336,8 @@ The plugin provides a `:J` command that accepts jj subcommands: :J fetch " Fetch from remote :J open_pr " Open PR for current change's bookmark :J open_pr --list " Select bookmark from all and open PR +:Jbrowse " Open current file on remote at cursor line +:Jbrowse main " Open current file on remote at the given revset :J split " Split a change interactively :J bookmark create/move/delete :J tag set " Set a tag (prompts for revision and tag name) diff --git a/lua/jj/browse.lua b/lua/jj/browse.lua new file mode 100644 index 0000000..2efbda9 --- /dev/null +++ b/lua/jj/browse.lua @@ -0,0 +1,168 @@ +--- @class jj.cmd.browse +local M = {} + +local utils = require("jj.utils") + +--- Build a line anchor for a hosted-file URL. +--- GitHub uses `#L-L` for ranges; GitLab uses `#L-`. +--- @param host string|nil Remote host name (e.g. github.com) +--- @param line1 integer|nil 1-indexed start line +--- @param line2 integer|nil 1-indexed end line +--- @return string anchor URL fragment (including leading `#`), or empty string +local function build_anchor(host, line1, line2) + if not line1 then + return "" + end + + -- GitHub: #L1-L2, GitLab: #L1-2 + local is_gitlab = host and host:match("gitlab") + if line2 and line2 ~= line1 then + if is_gitlab then + return string.format("#L%d-%d", line1, line2) + end + return string.format("#L%d-L%d", line1, line2) + end + + return string.format("#L%d", line1) +end + +--- Build a browser URL for a file in a repo. +--- Falls back to GitHub-style routing unless host is recognized. +--- @param base_repo_url string HTTPS base repo URL (e.g. https://host/owner/repo) +--- @param host string Remote hostname +--- @param ref string Commit SHA (preferred) or branch-like reference +--- @param path string Repo-relative file path +--- @param line1 integer|nil 1-indexed start line +--- @param line2 integer|nil 1-indexed end line +--- @return string url +local function build_browse_url(base_repo_url, host, ref, path, line1, line2) + local encoded_path = utils.url_encode_path(path) + local anchor = build_anchor(host, line1, line2) + + if host and host:match("gitlab") then + return string.format("%s/-/blob/%s/%s%s", base_repo_url, ref, encoded_path, anchor) + end + + if host and (host:match("gitea") or host:match("forgejo")) then + -- Use commit URLs only when ref looks like a SHA; otherwise use branch URLs. + local is_sha = type(ref) == "string" and ref:match("^[0-9a-fA-F]+$") and #ref >= 7 + local kind = is_sha and "commit" or "branch" + return string.format("%s/src/%s/%s/%s%s", base_repo_url, kind, ref, encoded_path, anchor) + end + + -- GitHub-style default + return string.format("%s/blob/%s/%s%s", base_repo_url, ref, encoded_path, anchor) +end + +--- Open current file on remote at current line / selected range +--- @param opts? {line1?: number, line2?: number, range?: number, args?: string, fargs?: string[]} +function M.browse(opts) + if not utils.ensure_jj() then + return + end + + local abs_path = vim.api.nvim_buf_get_name(0) + if not utils.is_file(abs_path) then + utils.notify("Current buffer is not a file", vim.log.levels.ERROR) + return + end + + local root = utils.get_jj_root() + if not root then + utils.notify("Not in a jj repository", vim.log.levels.ERROR) + return + end + + local repo_rel = utils.relpath(root, abs_path) + if not repo_rel then + utils.notify("File is not within jj repository root", vim.log.levels.ERROR) + return + end + repo_rel = repo_rel:gsub("\\", "/") + + local line1, line2 + if opts and opts.range and opts.range > 0 and opts.line1 and opts.line2 then + line1, line2 = opts.line1, opts.line2 + else + line1 = vim.api.nvim_win_get_cursor(0)[1] + line2 = line1 + end + + local revset = "@" + if opts then + if type(opts.args) == "string" and opts.args ~= "" then + revset = vim.trim(opts.args) + elseif type(opts.fargs) == "table" and #opts.fargs > 0 then + revset = vim.trim(opts.fargs[1] or "") + end + end + + local remotes = utils.get_remotes() + if remotes == nil then + utils.notify("Failed to get git remotes", vim.log.levels.ERROR) + return + end + if #remotes == 0 then + utils.notify("No git remotes found", vim.log.levels.ERROR) + return + end + + local function browse_with_remote(remote) + local base_repo_url, host = utils.normalize_remote_url(remote.url) + if not base_repo_url or not host then + utils.notify("Unsupported remote URL: " .. (remote.url or ""), vim.log.levels.ERROR) + return + end + + -- If a revset has been given the walkback is none since the user doesn't expect any walkback + local max_walkback = 20 + if revset ~= "@" then + max_walkback = 0 + end + + local commit_id = utils.get_pushed_commit_id(revset, remote.name, max_walkback) + if not commit_id then + utils.notify( + string.format("Could not determine a remote reachable commit for %s", revset), + vim.log.levels.ERROR + ) + return + end + + -- Prefer a single remote bookmark name (more readable). If there are multiple, + -- stick to commit SHA to avoid ambiguity. + local ref = utils.get_unique_remote_bookmark_name(commit_id, remote.name) or commit_id + + local url = build_browse_url(base_repo_url, host, ref, repo_rel, line1, line2) + utils.open_url(url) + utils.notify("Opening in browser", vim.log.levels.INFO, 1000) + end + + if #remotes == 1 then + browse_with_remote(remotes[1]) + return + end + + vim.ui.select(remotes, { + prompt = "Select remote to browse: ", + format_item = function(item) + return string.format("%s (%s)", item.name, item.url) + end, + }, function(choice) + if choice then + browse_with_remote(choice) + end + end) +end + +function M.register_command() + vim.api.nvim_create_user_command("Jbrowse", function(cmdopts) + M.browse(cmdopts) + end, { + nargs = "?", + range = true, + desc = "Open current file on remote (optionally pass revset; supports visual line range)", + }) +end + +return M diff --git a/lua/jj/cmd/describe.lua b/lua/jj/cmd/describe.lua index c180bdd..9b5ccf7 100644 --- a/lua/jj/cmd/describe.lua +++ b/lua/jj/cmd/describe.lua @@ -44,6 +44,7 @@ local function describe_editor_keymaps() close = { desc = "Close describe editor without saving", handler = "close!", + modes = { "n" }, }, }) end diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 9dd9b87..28e6f4f 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -7,7 +7,6 @@ local terminal = require("jj.ui.terminal") local editor = require("jj.ui.editor") local parser = require("jj.core.parser") -local diff = require("jj.diff") local log_module = require("jj.cmd.log") local describe_module = require("jj.cmd.describe") local status_module = require("jj.cmd.status") @@ -805,6 +804,7 @@ function M.commit(description) close = { desc = "Close commit editor without saving", handler = "close!", + modes = { "n" }, }, }) @@ -1223,21 +1223,6 @@ function M.register_command() end, desc = "Execute jj commands with subcommand support", }) - - local function create_diff_command(name, fn, desc) - vim.api.nvim_create_user_command(name, function(opts) - local rev = opts.fargs[1] - if rev then - fn({ rev = rev }) - else - fn() - end - end, { nargs = "?", desc = desc .. " (optionally pass jj revision)" }) - end - - create_diff_command("Jdiff", diff.open_vdiff, "Vertical diff against jj revision") - create_diff_command("Jhdiff", diff.open_hdiff, "Horizontal diff against jj revision") - create_diff_command("Jvdiff", diff.open_vdiff, "Vertical diff against jj revision") end return M diff --git a/lua/jj/diff/init.lua b/lua/jj/diff/init.lua index 261db1f..b01cd7f 100644 --- a/lua/jj/diff/init.lua +++ b/lua/jj/diff/init.lua @@ -160,5 +160,23 @@ function M.open_hdiff(opts) M.diff_current(vim.tbl_extend("force", { layout = "horizontal" }, { rev = opts and opts.rev })) end +-- Register the different diff commands +function M.register_command() + local function create_diff_command(name, fn, desc) + vim.api.nvim_create_user_command(name, function(opts) + local rev = opts.fargs[1] + if rev then + fn({ rev = rev }) + else + fn() + end + end, { nargs = "?", desc = desc .. " (optionally pass jj revision)" }) + end + + create_diff_command("Jdiff", M.open_vdiff, "Vertical diff against jj revision") + create_diff_command("Jhdiff", M.open_hdiff, "Horizontal diff against jj revision") + create_diff_command("Jvdiff", M.open_vdiff, "Vertical diff against jj revision") +end + ---@return jj.diff return M diff --git a/lua/jj/init.lua b/lua/jj/init.lua index e38870f..d2efd69 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -4,6 +4,7 @@ local picker = require("jj.picker") local editor = require("jj.ui.editor") local terminal = require("jj.ui.terminal") local diff = require("jj.diff") +local browse = require("jj.browse") --- Jujutsu plugin configuration --- @class jj.Config @@ -49,7 +50,10 @@ function M.setup(opts) terminal.setup(opts and opts.terminal or {}) diff.setup(M.config.diff) + -- Register the commands form the different modules cmd.register_command() + browse.register_command() + diff.register_command() end return M diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 551e261..0eb8fb4 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -124,6 +124,153 @@ function M.url_encode(str) end)) end +--- URL-encode a file path for use in URLs. +--- Encodes each path segment and preserves `/` separators. +--- Also normalizes Windows path separators (`\` -> `/`). +--- @param path string +--- @return string +function M.url_encode_path(path) + path = (path or ""):gsub("\\", "/") + local parts = vim.split(path, "/", { plain = true }) + for i, seg in ipairs(parts) do + parts[i] = M.url_encode(seg) + end + return table.concat(parts, "/") +end + +--- Check whether a path points to an existing file. +--- @param path string|nil +--- @return boolean +function M.is_file(path) + if not path or path == "" then + return false + end + local st = vim.uv.fs_stat(path) + return st ~= nil and st.type == "file" +end + +--- Compute a repository-relative path. +--- Returns nil if `path` is outside of `root`. +--- @param root string +--- @param path string +--- @return string|nil +function M.relpath(root, path) + if not root or root == "" or not path or path == "" then + return nil + end + + local ok_norm_root, norm_root = pcall(vim.fs.normalize, root) + local ok_norm_path, norm_path = pcall(vim.fs.normalize, path) + if not ok_norm_root or not ok_norm_path then + norm_root, norm_path = root, path + end + + local ok_rel, rel = pcall(vim.fs.relpath, norm_root, norm_path) + if ok_rel and rel and rel ~= "" and not rel:match("^%.%.") then + return rel + end + + local prefix = norm_root + if not prefix:match("/$") then + prefix = prefix .. "/" + end + if vim.startswith(norm_path, prefix) then + return norm_path:sub(#prefix + 1) + end + + return nil +end + +--- Normalize a git remote URL to an HTTPS repo URL. +--- Supports: +--- - `git@host:owner/repo(.git)` +--- - `ssh://git@host/owner/repo(.git)` +--- - `https://host/owner/repo(.git)` +--- @param raw_url string +--- @return string|nil repo_url HTTPS base repo URL (no trailing .git) +--- @return string|nil host Hostname extracted from the remote +function M.normalize_remote_url(raw_url) + if not raw_url or raw_url == "" then + return nil, nil + end + + raw_url = raw_url:gsub("%.git$", "") + + -- scp-like SSH: git@host:owner/repo + if raw_url:match("^git@") then + local host = raw_url:match("^git@([^:]+):") + local repo_path = raw_url:match("^git@[^:]+:(.+)$") + if host and repo_path then + return "https://" .. host .. "/" .. repo_path, host + end + end + + -- ssh://git@host/owner/repo + if raw_url:match("^ssh://") then + local host = raw_url:match("^ssh://[^@]+@([^/]+)/") or raw_url:match("^ssh://([^/]+)/") + local rest = raw_url:gsub("^ssh://[^@]+@", ""):gsub("^ssh://", "") + local repo_path = rest:match("^[^/]+/(.+)$") + if host and repo_path then + repo_path = repo_path:gsub("%.git$", "") + return "https://" .. host .. "/" .. repo_path, host + end + end + + -- HTTPS + local host = raw_url:match("^https?://([^/]+)") + if host then + return raw_url, host + end + + return nil, nil +end + +--- Open a URL using the system default handler. +--- Prefers `vim.ui.open()` when available. +--- @param url string +function M.open_url(url) + if not url or url == "" then + return + end + + if vim.ui and type(vim.ui.open) == "function" then + vim.ui.open(url) + return + end + + if vim.fn.has("win32") == 1 then + -- `start` is a cmd.exe builtin + vim.fn.jobstart({ "cmd.exe", "/c", "start", "", url }, { detach = true }) + return + end + + local open_cmd = vim.fn.has("mac") == 1 and "open" or "xdg-open" + vim.fn.jobstart({ open_cmd, url }, { detach = true }) +end + +--- Best-effort unquoting of a jj `RefSymbol` rendering. +--- +--- `RefSymbol` values are displayed as revset symbols, which may be quoted and +--- escaped if necessary. For browser URLs, we generally want the raw name. +--- +--- This handles the common cases of surrounding single/double quotes. +--- @param s string|nil +--- @return string +function M.unquote_refsymbol(s) + s = vim.trim(s or "") + if s == "" then + return "" + end + local first = s:sub(1, 1) + local last = s:sub(-1) + if (first == '"' and last == '"') or (first == "'" and last == "'") then + s = s:sub(2, -2) + -- Minimal unescaping (covers typical `\"` and `\\` sequences) + s = s:gsub("\\\\", "\\"):gsub('\\"', '"'):gsub("\\'", "'") + end + return s +end + --- Get all bookmarks in the repository, filters out deleted bookmarks --- @return string[] List of bookmarks, or empty list if none found function M.get_all_bookmarks() @@ -264,27 +411,17 @@ function M.open_pr_for_bookmark(bookmark) -- Get all git remotes local remotes = M.get_remotes() - if #remotes == 0 then + if not remotes or #remotes == 0 then M.notify("No git remotes found", vim.log.levels.ERROR) return end -- Helper function to open PR for a given remote URL local function open_pr_with_url(raw_url) - -- Remove .git suffix if present - raw_url = raw_url:gsub("%.git$", "") - - -- Convert SSH URL to HTTPS and detect platform - local repo_url, host - if raw_url:match("^git@") then - -- Extract host and path from git@host:path - host = raw_url:match("^git@([^:]+):") - local repo_path = raw_url:match("^git@[^:]+:(.+)$") - repo_url = "https://" .. host .. "/" .. repo_path - else - -- Extract host from https://host/path - host = raw_url:match("https?://([^/]+)") - repo_url = raw_url + local repo_url, host = M.normalize_remote_url(raw_url) + if not repo_url or not host then + M.notify("Unsupported remote URL: " .. (raw_url or ""), vim.log.levels.ERROR) + return end -- Construct the appropriate PR/MR URL based on the platform @@ -302,17 +439,7 @@ function M.open_pr_for_bookmark(bookmark) pr_url = repo_url .. "/compare/" .. encoded_bookmark .. "?expand=1" end - -- Open the URL using xdg-open or the system's default browser - local open_cmd - if vim.fn.has("mac") == 1 then - open_cmd = "open" - elseif vim.fn.has("win32") == 1 then - open_cmd = "start" - else - open_cmd = "xdg-open" - end - - vim.fn.jobstart({ open_cmd, pr_url }, { detach = true }) + M.open_url(pr_url) M.notify(string.format("Opening PR for bookmark `%s`", bookmark), vim.log.levels.INFO) end @@ -452,6 +579,98 @@ function M.get_current_commit_id() return vim.trim(output) end +--- Return a commit id that is expected to exist on a given remote. +--- +--- This is a best-effort heuristic used for browser URLs: if a commit isn't +--- reachable from the remote's bookmarks, most web UIs will 404. +--- +--- Strategy: +--- - Starting from `start_revset`, walk back first-parent (`revset-`) up to +--- `max_walkback` times until the commit is contained in +--- `::remote_bookmarks(remote="")`. +--- - If still not found, return the commit id of `trunk()`. +--- +--- @param start_revset string Starting point revset (e.g. "@") +--- @param remote_name string Remote name (e.g. "origin") +--- @param max_walkback? integer Maximum number of parents to traverse (default: 20) +--- @return string|nil commit_id +function M.get_pushed_commit_id(start_revset, remote_name, max_walkback) + max_walkback = max_walkback or 20 + start_revset = (start_revset and start_revset ~= "") and start_revset or "@" + remote_name = (remote_name and remote_name ~= "") and remote_name or "origin" + + -- Build a revset expression string we can embed in a template StringLiteral. + local remote_literal = remote_name:gsub("\\", "\\\\"):gsub('"', '\\"') + local templ = [[if(self.contained_in("::remote_bookmarks(remote='%s')"), commit_id)]] + templ = string.format(templ, remote_literal) + + -- If `start_revset` is already a commit id (or change id), we still let jj + -- resolve it; otherwise, treat it as a revset expression. + local current = start_revset + for _ = 0, max_walkback do + -- Use a single-quoted StringLiteral for the revset, so we can keep the + -- remote name quoted inside the revset expression. + local cmd = string.format( + "jj log -r %s --no-graph --quiet -T %s", + vim.fn.shellescape(current), + vim.fn.shellescape(templ) + ) + + local out, ok = runner.execute_command(cmd, "Error determining remote-reachable commit", nil, true) + if ok and out and not out:match("^%s*$") then + return vim.trim(out) + end + current = current .. "-" + end + + return M.get_commit_id("trunk()") +end + +--- Return a remote bookmark name pointing at `revset` on `remote_name`. +--- +--- If there are 0 or multiple matching bookmarks and none of those are neither `main` nor `master`, returns nil. +--- This avoids choosing an arbitrary name when multiple bookmarks point to the +--- same commit. +--- +--- @param revset string +--- @param remote_name string +--- @return string|nil bookmark_name +function M.get_unique_remote_bookmark_name(revset, remote_name) + revset = (revset and revset ~= "") and revset or "@" + remote_name = (remote_name and remote_name ~= "") and remote_name or "origin" + + -- Render remote bookmarks as tab-separated pairs: \t\n + local tmpl = [[self.remote_bookmarks().map(|b| b.remote() ++ "\t" ++ b.name() ++ "\n").join("")]] + local cmd = + string.format("jj log -r %s --no-graph --quiet -T %s", vim.fn.shellescape(revset), vim.fn.shellescape(tmpl)) + local out, ok = runner.execute_command(cmd, "Error getting remote bookmarks", nil, true) + if not ok or not out or out:match("^%s*$") then + return nil + end + + local matches = {} + for line in out:gmatch("[^\r\n]+") do + local remote_sym, name_sym = line:match("^(.-)\t(.*)$") + local r = M.unquote_refsymbol(remote_sym) + local n = M.unquote_refsymbol(name_sym) + if r == remote_name and n ~= "" then + table.insert(matches, n) + end + end + + if #matches == 1 then + return matches[1] + elseif #matches > 1 then + for _, name in ipairs(matches) do + -- Unless it's main or master which takes precedence + if name == "main" or name == "master" then + return name + end + end + end + return nil +end + --- Extract the description from the describe text --- @param lines string[] The lines of a described change --- @return string|nil