diff --git a/denops/gin/command/browse/command.ts b/denops/gin/command/browse/command.ts new file mode 100644 index 00000000..67514712 --- /dev/null +++ b/denops/gin/command/browse/command.ts @@ -0,0 +1,101 @@ +import type { Denops } from "https://deno.land/x/denops_std@v5.0.1/mod.ts"; +import { unnullish } from "https://deno.land/x/unnullish@v1.0.1/mod.ts"; +import * as path from "https://deno.land/std@0.192.0/path/mod.ts"; +import * as option from "https://deno.land/x/denops_std@v5.0.1/option/mod.ts"; +import { systemopen } from "https://deno.land/x/systemopen@v0.2.0/mod.ts"; +import { findWorktreeFromDenops } from "../../git/worktree.ts"; +import { getRemoteURL } from "../../git/remote.ts"; +import { execute } from "../../git/executor.ts"; +import { getHostingService } from "../../git/hosting_service.ts"; +import { yank } from "../../util/yank.ts"; +import { decodeUtf8 } from "../../util/text.ts"; + +export type ExecOptions = { + worktree?: string; + commitish?: string; + filename?: string; + remote?: string; + permanent?: boolean; + yank?: boolean; + echo?: boolean; +}; + +export async function exec( + denops: Denops, + options: ExecOptions = {}, +): Promise { + const url = await getURL(denops, options); + if (options.yank) { + await yank(denops, url.href); + } + if (options.echo) { + await denops.cmd(`echomsg url`, { url: url.href }); + } else { + await systemopen(url.href); + } +} + +async function getURL( + denops: Denops, + options: ExecOptions, +) { + const verbose = await option.verbose.get(denops); + const worktree = await findWorktreeFromDenops(denops, { + worktree: options.worktree, + verbose: !!verbose, + }); + + const remoteURL = await getRemoteURL(denops, options.remote ?? "origin", { + worktree: options.worktree, + }); + const svc = await getHostingService(remoteURL); + if (options.filename) { + const [filename, lineStart, lineEnd] = parseFilename( + worktree, + options.filename, + ); + const commitish = options.permanent + ? await getCommitishHash( + denops, + worktree, + options.commitish ?? "HEAD", + ) + : options.commitish ?? "HEAD"; + return svc.getBlobURL(commitish, filename, { lineStart, lineEnd }); + } else { + return svc.getRootURL(); + } +} + +function parseFilename( + worktree: string, + filename: string, +): [string, number?, number?] { + const relpath = path.isAbsolute(filename) + ? path.relative(worktree, filename) + : filename; + const m = relpath?.match(/^(.*?):(\d+)(?::(\d+))?$/); + if (!m) { + return [relpath, undefined, undefined]; + } + return [m[1], unnullish(m[2], Number), unnullish(m[3], Number)]; +} + +async function getCommitishHash( + denops: Denops, + worktree: string, + commitish: string | typeof HEAD, +): Promise { + const { success, stdout, stderr } = await execute(denops, [ + "rev-parse", + commitish.toString(), + ], { + worktree: worktree, + stdoutIndicator: "null", + stderrIndicator: "null", + }); + if (!success) { + throw new Error(decodeUtf8(stderr)); + } + return decodeUtf8(stdout).trim(); +} diff --git a/denops/gin/command/browse/main.ts b/denops/gin/command/browse/main.ts new file mode 100644 index 00000000..e61362ed --- /dev/null +++ b/denops/gin/command/browse/main.ts @@ -0,0 +1,67 @@ +import type { Denops } from "https://deno.land/x/denops_std@v5.0.1/mod.ts"; +import { assert, is } from "https://deno.land/x/unknownutil@v3.2.0/mod.ts#^"; +import * as helper from "https://deno.land/x/denops_std@v5.0.1/helper/mod.ts"; +import { + parseOpts, + validateOpts, +} from "https://deno.land/x/denops_std@v5.0.1/argument/opts.ts"; +import { normCmdArgs, parseSilent } from "../../util/cmd.ts"; +import { exec } from "./command.ts"; + +export function main(denops: Denops): void { + denops.dispatcher = { + ...denops.dispatcher, + "browse:command": (mods, args, range) => { + assert(mods, is.String, { message: "mods must be string" }); + assert(args, is.ArrayOf(is.String), { message: "args must be string[]" }); + assert(range, is.TupleOf([is.Number, is.Number] as const), { + message: "range must be [number, number]", + }); + const silent = parseSilent(mods); + return helper.ensureSilent(denops, silent, () => { + return helper.friendlyCall(denops, () => command(denops, args, range)); + }); + }, + }; +} + +async function command( + denops: Denops, + args: string[], + _range: readonly [number, number], +): Promise { + const [opts, residue] = parseOpts(await normCmdArgs(denops, args)); + validateOpts(opts, [ + "worktree", + "remote", + "permanent", + "yank", + "echo", + ]); + const [commitish, filename] = parseResidue(residue); + await exec(denops, { + worktree: opts.worktree, + remote: opts.remote, + commitish, + filename, + permanent: "permanent" in opts, + yank: "yank" in opts, + echo: "echo" in opts, + }); +} + +function parseResidue(residue: string[]): [string?, string?] { + // GinBrowse [{options}] + // GinBrowse [{options}] {path} + // GinBrowse [{options}] {commitish} {path} + switch (residue.length) { + case 0: + return [undefined, undefined]; + case 1: + return [undefined, residue[0]]; + case 2: + return [residue[0], residue[1]]; + default: + throw new Error("Invalid number of arguments"); + } +} diff --git a/denops/gin/main.ts b/denops/gin/main.ts index ba507b09..8a74127f 100644 --- a/denops/gin/main.ts +++ b/denops/gin/main.ts @@ -6,6 +6,7 @@ import { main as mainProxy } from "./proxy/main.ts"; import { main as mainUtil } from "./util/main.ts"; import { main as mainBranch } from "./command/branch/main.ts"; +import { main as mainBrowse } from "./command/browse/main.ts"; import { main as mainChaperon } from "./command/chaperon/main.ts"; import { main as mainDiff } from "./command/diff/main.ts"; import { main as mainEdit } from "./command/edit/main.ts"; @@ -24,6 +25,7 @@ export function main(denops: Denops): void { mainUtil(denops); mainBranch(denops); + mainBrowse(denops); mainChaperon(denops); mainDiff(denops); mainEdit(denops); diff --git a/doc/gin.txt b/doc/gin.txt index edd25675..cfffd2c0 100644 --- a/doc/gin.txt +++ b/doc/gin.txt @@ -160,6 +160,37 @@ COMMANDS *gin-commands* Users can specify default arguments by |g:gin_branch_default_args|. Use a bang (!) to forcibly open a buffer. + Use an ampersand (&) to temporary disable default arguments. + + *:GinBrowse* +:GinBrowse[&] [{++option}...] +:GinBrowse[&] [{++option}...] [{commitish}] {path}[:{lineStart}[:{lineEnd}] +:[range]GinBrowse[&] [{++option}...] [{commitish}] + Open a system default browser to visit a web page of a hosting service + of the {worktree} (current) repository (e.g. GitHub). + If no {path} or {range} is specified, it opens the index page of the + hosting service. + If {path} is specified, it opens the page of the {path} in the hosting + service with specified {commitish} (default is "HEAD"). + If {range} is specified, it opens the page of the current buffer in + the hosting service with specified {commitish} (default is "HEAD"). + + The following options are valid as {++option}: + + ++remote={remote} + Use {remote} as a remote to generate a URL. + Default is "origin". + + ++permanent + Use an exact revision to generate a permanent URL. + + ++yank + Copy the URL to the clipboard. + + ++echo + Echo URL instead of opening a system browser. + + Users can specify default arguments by |g:gin_browse_default_args|. Use an ampersand (&) to temporary disable default arguments. *:GinCd* diff --git a/plugin/gin-browse.vim b/plugin/gin-browse.vim new file mode 100644 index 00000000..e01c3b3a --- /dev/null +++ b/plugin/gin-browse.vim @@ -0,0 +1,13 @@ +if exists('g:loaded_gin_browse') + finish +endif +let g:loaded_gin_browse = 1 + +function! s:command(mods, args, range) abort + if denops#plugin#wait('gin') + return + endif + call denops#request('gin', 'browse:command', [a:mods, a:args, a:range]) +endfunction + +command! -bar -range=% -nargs=* GinBrowse call s:command(, [], [, ])