diff --git a/denops/gin/action/show.ts b/denops/gin/action/show.ts index f36f6fcc..a78eeaa8 100644 --- a/denops/gin/action/show.ts +++ b/denops/gin/action/show.ts @@ -60,6 +60,8 @@ async function doShow( await denops.dispatch("gin", "buffer:command", "", "", [ `++opener=${opener}`, ...(emojify ? [`++emojify`] : []), + `++diffjump=${x.commit}`, + `++difffold`, "show", x.commit, ]); diff --git a/denops/gin/command/buffer/command.ts b/denops/gin/command/buffer/command.ts index b8ea00e0..e2d8b852 100644 --- a/denops/gin/command/buffer/command.ts +++ b/denops/gin/command/buffer/command.ts @@ -11,6 +11,9 @@ export type ExecOptions = { monochrome?: boolean; opener?: string; emojify?: boolean; + diffjump?: string; + difffold?: boolean; + filetype?: string; cmdarg?: string; mods?: string; bang?: boolean; @@ -33,6 +36,9 @@ export async function exec( processor: unnullish(options.processor, (v) => v.join(" ")), monochrome: unnullish(options.monochrome, (v) => v ? "" : undefined), emojify: unnullish(options.emojify, (v) => v ? "" : undefined), + diffjump: options.diffjump, + difffold: unnullish(options.difffold, (v) => v ? "" : undefined), + filetype: options.filetype ?? "gin-buffer", }, fragment: `${args.join(" ")}$`, }); diff --git a/denops/gin/command/buffer/edit.ts b/denops/gin/command/buffer/edit.ts index ae51f472..b7cba4a6 100644 --- a/denops/gin/command/buffer/edit.ts +++ b/denops/gin/command/buffer/edit.ts @@ -16,6 +16,8 @@ import { buildDecorationsFromAnsiEscapeCode, } from "../../util/ansi_escape_code.ts"; import { execute } from "../../git/executor.ts"; +import { init as initDiffJump } from "../../feat/diffjump/jump.ts"; +import { init as initDiffFold } from "../../feat/difffold/fold.ts"; export async function edit( denops: Denops, @@ -42,8 +44,23 @@ export async function edit( monochrome: "monochrome" in (params ?? {}), encoding: opts.enc ?? opts.encoding, fileformat: opts.ff ?? opts.fileformat, + filetype: unnullish( + params?.filetype, + (v) => ensure(v, is.String, { message: "filetype must be string" }), + ), emojify: "emojify" in (params ?? {}), }); + + // Initialize diff jump functionality if ++diffjump option is present + const jumpCommitish = params?.diffjump; + if (jumpCommitish !== undefined) { + await initDiffJump(denops, bufnr, "buffer"); + } + + // Initialize diff fold functionality if ++difffold option is present + if ("difffold" in (params ?? {})) { + await initDiffFold(denops, bufnr); + } } export type ExecOptions = { @@ -52,6 +69,7 @@ export type ExecOptions = { monochrome?: boolean; encoding?: string; fileformat?: string; + filetype?: string; emojify?: boolean; stdoutIndicator?: string; stderrIndicator?: string; @@ -110,7 +128,9 @@ export async function exec( await buffer.concrete(denops, bufnr); await buffer.ensure(denops, bufnr, async () => { await batch.batch(denops, async (denops) => { - await option.filetype.setLocal(denops, "gin"); + if (options.filetype !== undefined) { + await option.filetype.setLocal(denops, options.filetype); + } await option.bufhidden.setLocal(denops, "unload"); await option.buftype.setLocal(denops, "nofile"); await option.swapfile.setLocal(denops, false); diff --git a/denops/gin/command/buffer/main.ts b/denops/gin/command/buffer/main.ts index 5acdba91..8c74c4c2 100644 --- a/denops/gin/command/buffer/main.ts +++ b/denops/gin/command/buffer/main.ts @@ -1,8 +1,10 @@ import type { Denops } from "jsr:@denops/std@^7.0.0"; import { unnullish } from "jsr:@lambdalisue/unnullish@^1.0.0"; -import { assert, is } from "jsr:@core/unknownutil@^4.0.0"; +import { assert, ensure, is } from "jsr:@core/unknownutil@^4.0.0"; import * as helper from "jsr:@denops/std@^7.0.0/helper"; import * as buffer from "jsr:@denops/std@^7.0.0/buffer"; +import * as fn from "jsr:@denops/std@^7.0.0/function"; +import { parse as parseBufname } from "jsr:@denops/std@^7.0.0/bufname"; import { builtinOpts, formatOpts, @@ -14,6 +16,7 @@ import { normCmdArgs, parseSilent } from "../../util/cmd.ts"; import { exec } from "./command.ts"; import { edit } from "./edit.ts"; import { read } from "./read.ts"; +import { main as mainDiffJump } from "../../feat/diffjump/main.ts"; export function main(denops: Denops): void { denops.dispatcher = { @@ -47,6 +50,38 @@ export function main(denops: Denops): void { ); }, }; + mainDiffJump(denops, "buffer", { + commitishMap: { + old: async ({ bufnr }) => { + const bufname = await fn.bufname(denops, bufnr); + const { params } = parseBufname(bufname); + const jumpCommitish = params?.diffjump; + if (jumpCommitish === undefined) { + return "HEAD^"; + } + const commitish = ensure( + jumpCommitish || "HEAD", + is.String, + { message: "jump must be string" }, + ); + return `${commitish}^`; + }, + new: async ({ bufnr }) => { + const bufname = await fn.bufname(denops, bufnr); + const { params } = parseBufname(bufname); + const jumpCommitish = params?.diffjump; + if (jumpCommitish === undefined) { + return "HEAD"; + } + const commitish = ensure( + jumpCommitish || "HEAD", + is.String, + { message: "jump must be string" }, + ); + return commitish; + }, + }, + }); } async function command( @@ -62,6 +97,9 @@ async function command( "monochrome", "opener", "emojify", + "diffjump", + "difffold", + "filetype", ...builtinOpts, ]); return exec(denops, residue, { @@ -70,6 +108,9 @@ async function command( monochrome: unnullish(opts.monochrome, () => true), opener: opts.opener, emojify: unnullish(opts.emojify, () => true), + diffjump: opts.diffjump, + difffold: unnullish(opts.difffold, () => true), + filetype: opts.filetype, cmdarg: formatOpts(opts, builtinOpts).join(" "), mods, bang: bang === "!", diff --git a/denops/gin/command/diff/commitish.ts b/denops/gin/command/diff/commitish.ts index 4634187a..875c72d3 100644 --- a/denops/gin/command/diff/commitish.ts +++ b/denops/gin/command/diff/commitish.ts @@ -1,7 +1,4 @@ -export const INDEX = Symbol("INDEX"); -export const WORKTREE = Symbol("WORKTREE"); - -export type Commitish = string | typeof INDEX | typeof WORKTREE; +import { type Commitish, INDEX, WORKTREE } from "../../feat/diffjump/jump.ts"; // git diff // INDEX -> WORKTREE diff --git a/denops/gin/command/diff/commitish_test.ts b/denops/gin/command/diff/commitish_test.ts index 7176f156..56096966 100644 --- a/denops/gin/command/diff/commitish_test.ts +++ b/denops/gin/command/diff/commitish_test.ts @@ -1,5 +1,6 @@ import { assertEquals } from "jsr:@std/assert@^1.0.0"; -import { Commitish, INDEX, parseCommitish, WORKTREE } from "./commitish.ts"; +import { type Commitish, INDEX, WORKTREE } from "../../feat/diffjump/jump.ts"; +import { parseCommitish } from "./commitish.ts"; Deno.test("parseCommitish", () => { const testcases: [[string, boolean], [Commitish, Commitish]][] = [ diff --git a/denops/gin/command/diff/edit.ts b/denops/gin/command/diff/edit.ts index 05b493aa..b0d1584d 100644 --- a/denops/gin/command/diff/edit.ts +++ b/denops/gin/command/diff/edit.ts @@ -2,7 +2,6 @@ import type { Denops } from "jsr:@denops/std@^7.0.0"; import { unnullish } from "jsr:@lambdalisue/unnullish@^1.0.0"; import { ensure, is } from "jsr:@core/unknownutil@^4.0.0"; import * as batch from "jsr:@denops/std@^7.0.0/batch"; -import * as mapping from "jsr:@denops/std@^7.0.0/mapping"; import * as option from "jsr:@denops/std@^7.0.0/option"; import * as vars from "jsr:@denops/std@^7.0.0/variable"; import { parse as parseBufname } from "jsr:@denops/std@^7.0.0/bufname"; @@ -15,6 +14,8 @@ import { } from "jsr:@denops/std@^7.0.0/argument"; import * as buffer from "jsr:@denops/std@^7.0.0/buffer"; import { exec as execBuffer } from "../../command/buffer/edit.ts"; +import { init as initDiffJump } from "../../feat/diffjump/jump.ts"; +import { init as initDiffFold } from "../../feat/difffold/fold.ts"; export async function edit( denops: Denops, @@ -85,33 +86,12 @@ export async function exec( await option.buftype.setLocal(denops, "nofile"); await option.swapfile.setLocal(denops, false); await option.modifiable.setLocal(denops, false); - await mapping.map( - denops, - "(gin-diffjump-old)", - `call denops#request('gin', 'diff:jump:old', [])`, - { - buffer: true, - noremap: true, - }, - ); - await mapping.map( - denops, - "(gin-diffjump-new)", - `call denops#request('gin', 'diff:jump:new', [])`, - { - buffer: true, - noremap: true, - }, - ); - await mapping.map( - denops, - "(gin-diffjump-smart)", - `call denops#request('gin', 'diff:jump:smart', [])`, - { - buffer: true, - noremap: true, - }, - ); }); }); + + // Initialize diff jump functionality + await initDiffJump(denops, bufnr, "diff"); + + // Initialize diff fold functionality + await initDiffFold(denops, bufnr); } diff --git a/denops/gin/command/diff/jump.ts b/denops/gin/command/diff/jump.ts deleted file mode 100644 index 717b314a..00000000 --- a/denops/gin/command/diff/jump.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Denops } from "jsr:@denops/std@^7.0.0"; -import { ensure, is } from "jsr:@core/unknownutil@^4.0.0"; -import * as path from "jsr:@std/path@^1.0.0"; -import * as batch from "jsr:@denops/std@^7.0.0/batch"; -import * as buffer from "jsr:@denops/std@^7.0.0/buffer"; -import * as fn from "jsr:@denops/std@^7.0.0/function"; -import { parse as parseBufname } from "jsr:@denops/std@^7.0.0/bufname"; -import { exec as execEdit } from "../edit/command.ts"; -import { Commitish, INDEX, parseCommitish, WORKTREE } from "./commitish.ts"; - -const patternSpc = /^(?:@@|\-\-\-|\+\+\+) /; -const patternRng = /^@@ \-(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*$/; -const patternOld = /^\-\-\- (.*?)(?:\t.*)?$/; -const patternNew = /^\+\+\+ (.*?)(?:\t.*)?$/; - -type Finder = (index: number, content: string[]) => Jump | undefined; - -type Parser = (commitish: string, cached: boolean) => Commitish; - -export type Jump = { - path: string; - lnum: number; -}; - -export function findJumpOld( - index: number, - content: string[], -): Jump | undefined { - if (patternSpc.test(content[index])) { - // We cannot find jump for special lines - return undefined; - } - let path = ""; - let lnum = -1; - let offset = 0; - for (let i = index; i >= 0; i--) { - const line = content[i]; - if (lnum === -1) { - const m1 = line.match(patternRng); - if (m1) { - lnum = Number(m1[1]); - continue; - } - if (!line.startsWith("+")) { - offset += 1; - } - } - const m2 = line.match(patternOld); - if (m2) { - path = m2[1]; - break; - } - } - if (lnum === -1) { - throw new Error(`No range pattern found in ${content}`); - } - if (path === "") { - throw new Error(`No old pattern found in ${content}`); - } - lnum += offset - 1; - return { - path, - lnum, - }; -} - -export function findJumpNew( - index: number, - content: string[], -): Jump | undefined { - if (patternSpc.test(content[index])) { - // We cannot find jump for special lines - return undefined; - } - let path = ""; - let lnum = -1; - let offset = 0; - for (let i = index; i >= 0; i--) { - const line = content[i]; - if (lnum === -1) { - const m1 = line.match(patternRng); - if (m1) { - lnum = Number(m1[2]); - continue; - } - if (!line.startsWith("-")) { - offset += 1; - } - } - const m2 = line.match(patternNew); - if (m2) { - path = m2[1]; - break; - } - } - if (lnum === -1) { - throw new Error(`No range pattern found in ${content}`); - } - if (path === "") { - throw new Error(`No new pattern found in ${content}`); - } - lnum += Math.max(offset - 1, 0); - return { - path, - lnum, - }; -} - -async function jump( - denops: Denops, - mods: string, - finder: Finder, - parser: Parser, -): Promise { - const [lnum, column, content, bufname] = await batch.collect( - denops, - (denops) => [ - fn.line(denops, "."), - fn.col(denops, "."), - fn.getline(denops, 1, "$"), - fn.bufname(denops, "%"), - ], - ); - const { expr, params } = parseBufname(bufname); - const jump = finder(lnum - 1, content); - if (!jump) { - // Do nothing - return; - } - const filename = path.join(expr, jump.path.replace(/^[ab]\//, "")); - const cached = "cached" in (params ?? {}); - const commitish = ensure(params?.commitish ?? "", is.String, { - message: "commitish must be string", - }); - const target = parser(commitish, cached); - if (target === INDEX) { - await execEdit(denops, filename, { - worktree: expr, - mods, - }); - } else if (target === WORKTREE) { - await buffer.open(denops, filename, { - mods, - }); - } else { - await execEdit(denops, filename, { - worktree: expr, - commitish: commitish || "HEAD", - mods, - }); - } - await fn.cursor(denops, jump.lnum, column - 1); -} - -export async function jumpOld(denops: Denops, mods: string): Promise { - await jump( - denops, - mods, - findJumpOld, - (commitish: string, cached: boolean) => { - const [target, _] = parseCommitish(commitish, cached); - return target; - }, - ); -} - -export async function jumpNew(denops: Denops, mods: string): Promise { - await jump( - denops, - mods, - findJumpNew, - (commitish: string, cached: boolean) => { - const [_, target] = parseCommitish(commitish, cached); - return target; - }, - ); -} - -export async function jumpSmart(denops: Denops, mods: string): Promise { - const line = await fn.getline(denops, "."); - if (line.startsWith("-")) { - await jumpOld(denops, mods); - } else { - await jumpNew(denops, mods); - } -} diff --git a/denops/gin/command/diff/jump_test.ts b/denops/gin/command/diff/jump_test.ts deleted file mode 100644 index d8c45f88..00000000 --- a/denops/gin/command/diff/jump_test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { assertEquals } from "jsr:@std/assert@^1.0.0"; -import { findJumpNew, findJumpOld, Jump } from "./jump.ts"; - -const example = (await Deno.readTextFile( - new URL("./jump_test.diff", import.meta.url), -)).split("\n"); - -Deno.test("findOldJump", () => { - const testcases: [number, Jump | undefined][] = [ - [0, undefined], - [1, undefined], - [2, undefined], - [3, { path: "a/path/to/lao", lnum: 1 }], - [4, { path: "a/path/to/lao", lnum: 2 }], - [5, { path: "a/path/to/lao", lnum: 3 }], - [6, { path: "a/path/to/lao", lnum: 4 }], - [7, { path: "a/path/to/lao", lnum: 4 }], - [8, { path: "a/path/to/lao", lnum: 4 }], - [9, { path: "a/path/to/lao", lnum: 5 }], - [10, { path: "a/path/to/lao", lnum: 6 }], - [11, { path: "a/path/to/lao", lnum: 7 }], - [12, undefined], - [13, { path: "a/path/to/lao", lnum: 9 }], - [14, { path: "a/path/to/lao", lnum: 10 }], - [15, { path: "a/path/to/lao", lnum: 11 }], - [16, { path: "a/path/to/lao", lnum: 11 }], - [17, { path: "a/path/to/lao", lnum: 11 }], - [18, { path: "a/path/to/lao", lnum: 11 }], - ]; - for (const [idx, exp] of testcases) { - assertEquals(exp, findJumpOld(idx, example)); - } -}); - -Deno.test("findNewJump", () => { - const testcases: [number, Jump | undefined][] = [ - [0, undefined], - [1, undefined], - [2, undefined], - [3, { path: "b/path/to/tzu", lnum: 1 }], - [4, { path: "b/path/to/tzu", lnum: 1 }], - [5, { path: "b/path/to/tzu", lnum: 1 }], - [6, { path: "b/path/to/tzu", lnum: 1 }], - [7, { path: "b/path/to/tzu", lnum: 2 }], - [8, { path: "b/path/to/tzu", lnum: 3 }], - [9, { path: "b/path/to/tzu", lnum: 4 }], - [10, { path: "b/path/to/tzu", lnum: 5 }], - [11, { path: "b/path/to/tzu", lnum: 6 }], - [12, undefined], - [13, { path: "b/path/to/tzu", lnum: 8 }], - [14, { path: "b/path/to/tzu", lnum: 9 }], - [15, { path: "b/path/to/tzu", lnum: 10 }], - [16, { path: "b/path/to/tzu", lnum: 11 }], - [17, { path: "b/path/to/tzu", lnum: 12 }], - [18, { path: "b/path/to/tzu", lnum: 13 }], - ]; - for (const [idx, exp] of testcases) { - assertEquals(exp, findJumpNew(idx, example)); - } -}); diff --git a/denops/gin/command/diff/main.ts b/denops/gin/command/diff/main.ts index 787492ef..61caa43b 100644 --- a/denops/gin/command/diff/main.ts +++ b/denops/gin/command/diff/main.ts @@ -1,6 +1,8 @@ import type { Denops } from "jsr:@denops/std@^7.0.0"; -import { as, assert, is } from "jsr:@core/unknownutil@^4.0.0"; +import { assert, ensure, is } from "jsr:@core/unknownutil@^4.0.0"; import * as helper from "jsr:@denops/std@^7.0.0/helper"; +import * as fn from "jsr:@denops/std@^7.0.0/function"; +import { parse as parseBufname } from "jsr:@denops/std@^7.0.0/bufname"; import { builtinOpts, formatOpts, @@ -11,7 +13,8 @@ import { fillCmdArgs, normCmdArgs, parseSilent } from "../../util/cmd.ts"; import { exec } from "./command.ts"; import { edit } from "./edit.ts"; import { read } from "./read.ts"; -import { jumpNew, jumpOld, jumpSmart } from "./jump.ts"; +import { main as mainDiffJump } from "../../feat/diffjump/main.ts"; +import { parseCommitish } from "./commitish.ts"; export function main(denops: Denops): void { denops.dispatcher = { @@ -38,19 +41,35 @@ export function main(denops: Denops): void { assert(bufname, is.String, { name: "bufname" }); return helper.friendlyCall(denops, () => read(denops, bufnr, bufname)); }, - "diff:jump:new": (mods) => { - assert(mods, as.Optional(is.String), { name: "mods" }); - return helper.friendlyCall(denops, () => jumpNew(denops, mods ?? "")); - }, - "diff:jump:old": (mods) => { - assert(mods, as.Optional(is.String), { name: "mods" }); - return helper.friendlyCall(denops, () => jumpOld(denops, mods ?? "")); - }, - "diff:jump:smart": (mods) => { - assert(mods, as.Optional(is.String), { name: "mods" }); - return helper.friendlyCall(denops, () => jumpSmart(denops, mods ?? "")); - }, }; + mainDiffJump(denops, "diff", { + commitishMap: { + old: async ({ bufnr }) => { + const bufname = await fn.bufname(denops, bufnr); + const { params } = parseBufname(bufname); + const cached = "cached" in (params ?? {}); + const commitish = ensure( + params?.commitish ?? "", + is.String, + { message: "commitish must be string" }, + ); + const [oldCommitish, _] = parseCommitish(commitish, cached); + return oldCommitish; + }, + new: async ({ bufnr }) => { + const bufname = await fn.bufname(denops, bufnr); + const { params } = parseBufname(bufname); + const cached = "cached" in (params ?? {}); + const commitish = ensure( + params?.commitish ?? "", + is.String, + { message: "commitish must be string" }, + ); + const [_, newCommitish] = parseCommitish(commitish, cached); + return newCommitish; + }, + }, + }); } async function command( diff --git a/denops/gin/feat/difffold/fold.ts b/denops/gin/feat/difffold/fold.ts new file mode 100644 index 00000000..df5a9bd9 --- /dev/null +++ b/denops/gin/feat/difffold/fold.ts @@ -0,0 +1,42 @@ +import type { Denops } from "jsr:@denops/std@^7.0.0"; +import * as batch from "jsr:@denops/std@^7.0.0/batch"; +import * as fn from "jsr:@denops/std@^7.0.0/function"; +import * as option from "jsr:@denops/std@^7.0.0/option"; +import { parse } from "./parser.ts"; + +/** + * Initialize fold functionality for a diff buffer + * + * Creates folds for each file section in the unified diff output. + * + * @param denops - Denops instance + * @param bufnr - Buffer number + * + * @example + * ```typescript,ignore + * // In a buffer with diff content + * await init(denops, bufnr); + * // Folds are created for each file section + * ``` + */ +export async function init(denops: Denops, bufnr: number): Promise { + const content = await fn.getbufline(denops, bufnr, 1, "$"); + const sections = parse(content); + + if (sections.length === 0) { + return; + } + + await batch.batch(denops, async (denops) => { + // Set fold method to manual + await option.foldmethod.setLocal(denops, "manual"); + + // Create folds for each file section + for (const section of sections) { + await fn.execute( + denops, + `${section.start},${section.end}fold`, + ); + } + }); +} diff --git a/denops/gin/feat/difffold/parser.ts b/denops/gin/feat/difffold/parser.ts new file mode 100644 index 00000000..fb067880 --- /dev/null +++ b/denops/gin/feat/difffold/parser.ts @@ -0,0 +1,89 @@ +/** + * Represents a file section in a unified diff + */ +export type FileSection = { + /** Starting line number (1-based) */ + start: number; + /** Ending line number (1-based) */ + end: number; + /** Old file path */ + oldPath: string; + /** New file path */ + newPath: string; +}; + +const patternOld = /^\-\-\- (.*?)(?:\t.*)?$/; +const patternNew = /^\+\+\+ (.*?)(?:\t.*)?$/; + +/** + * Parse unified diff content and extract file sections + * + * @param content - diff content as array of lines + * @returns Array of file sections for folding + * + * @example + * ```typescript + * const content = [ + * "--- a/file1.txt", + * "+++ b/file1.txt", + * "@@ -1,3 +1,4 @@", + * " line1", + * "--- a/file2.txt", + * "+++ b/file2.txt", + * "@@ -1,2 +1,2 @@", + * " line2", + * ]; + * const sections = parse(content); + * // sections[0] = { start: 1, end: 4, oldPath: "a/file1.txt", newPath: "b/file1.txt" } + * // sections[1] = { start: 5, end: 8, oldPath: "a/file2.txt", newPath: "b/file2.txt" } + * ``` + */ +export function parse(content: string[]): FileSection[] { + const sections: FileSection[] = []; + let currentSection: Partial | null = null; + + for (let i = 0; i < content.length; i++) { + const line = content[i]; + const lnum = i + 1; // 1-based line number + + // Start of a new file section + const oldMatch = line.match(patternOld); + if (oldMatch) { + // Save previous section if exists + if (currentSection?.start !== undefined) { + sections.push({ + start: currentSection.start, + end: i, // Previous line (0-based i = 1-based i) + oldPath: currentSection.oldPath!, + newPath: currentSection.newPath!, + }); + } + + // Start new section + currentSection = { + start: lnum, + oldPath: oldMatch[1], + }; + continue; + } + + // New file path (should immediately follow old file path) + const newMatch = line.match(patternNew); + if (newMatch && currentSection) { + currentSection.newPath = newMatch[1]; + continue; + } + } + + // Save the last section + if (currentSection?.start !== undefined) { + sections.push({ + start: currentSection.start, + end: content.length, + oldPath: currentSection.oldPath!, + newPath: currentSection.newPath!, + }); + } + + return sections; +} diff --git a/denops/gin/feat/difffold/parser_test.ts b/denops/gin/feat/difffold/parser_test.ts new file mode 100644 index 00000000..d3689abe --- /dev/null +++ b/denops/gin/feat/difffold/parser_test.ts @@ -0,0 +1,84 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.0"; +import { type FileSection, parse } from "./parser.ts"; + +Deno.test("parse() - single file", () => { + const content = [ + "--- a/file1.txt", + "+++ b/file1.txt", + "@@ -1,3 +1,4 @@", + " line1", + "-line2", + "+line2 modified", + ]; + + const result = parse(content); + const expected: FileSection[] = [ + { + start: 1, + end: 6, + oldPath: "a/file1.txt", + newPath: "b/file1.txt", + }, + ]; + + assertEquals(result, expected); +}); + +Deno.test("parse() - multiple files", () => { + const content = [ + "--- a/file1.txt", + "+++ b/file1.txt", + "@@ -1,3 +1,4 @@", + " line1", + "--- a/file2.txt", + "+++ b/file2.txt", + "@@ -1,2 +1,2 @@", + " line2", + "--- a/file3.txt", + "+++ b/file3.txt", + "@@ -5,6 +5,7 @@", + " line3", + ]; + + const result = parse(content); + const expected: FileSection[] = [ + { + start: 1, + end: 4, + oldPath: "a/file1.txt", + newPath: "b/file1.txt", + }, + { + start: 5, + end: 8, + oldPath: "a/file2.txt", + newPath: "b/file2.txt", + }, + { + start: 9, + end: 12, + oldPath: "a/file3.txt", + newPath: "b/file3.txt", + }, + ]; + + assertEquals(result, expected); +}); + +Deno.test("parse() - empty content", () => { + const content: string[] = []; + const result = parse(content); + assertEquals(result, []); +}); + +Deno.test("parse() - no file headers", () => { + const content = [ + "@@ -1,3 +1,4 @@", + " line1", + "-line2", + "+line2 modified", + ]; + + const result = parse(content); + assertEquals(result, []); +}); diff --git a/denops/gin/feat/diffjump/jump.ts b/denops/gin/feat/diffjump/jump.ts new file mode 100644 index 00000000..ac8ecff6 --- /dev/null +++ b/denops/gin/feat/diffjump/jump.ts @@ -0,0 +1,203 @@ +import type { Denops } from "jsr:@denops/std@^7.0.0"; +import * as batch from "jsr:@denops/std@^7.0.0/batch"; +import * as buffer from "jsr:@denops/std@^7.0.0/buffer"; +import * as fn from "jsr:@denops/std@^7.0.0/function"; +import * as mapping from "jsr:@denops/std@^7.0.0/mapping"; +import * as path from "jsr:@std/path@^1.0.0"; +import { type DiffLocation, parse } from "./parser.ts"; +import { parse as parseBufname } from "jsr:@denops/std@^7.0.0/bufname"; +import { exec as execEdit } from "../../command/edit/command.ts"; + +export const WORKTREE = Symbol("WORKTREE"); +export const INDEX = Symbol("INDEX"); + +export type Commitish = typeof WORKTREE | typeof INDEX | string; + +export type CommitishMap = { + old: + | Commitish + | ((_args: { bufnr: number }) => Commitish | Promise); + new: + | Commitish + | ((_args: { bufnr: number }) => Commitish | Promise); +}; + +type JumpTarget = { + location: DiffLocation; + commitish: Commitish; + worktree: string; +}; + +/** + * Initialize diff jump functionality for a buffer + * + * @param denops - Denops instance + * @param bufnr - Buffer number + * @param namespace - Namespace for dispatcher (e.g., "diff", "buffer") + * @param options - Configuration options + */ +export async function init( + denops: Denops, + bufnr: number, + namespace: string, +): Promise { + await buffer.ensure(denops, bufnr, async () => { + await batch.batch(denops, async (denops) => { + // Define mappings + await mapping.map( + denops, + "(gin-diffjump-old)", + `call denops#request('gin', '${namespace}:diffjump:old', [])`, + { + buffer: true, + noremap: true, + }, + ); + await mapping.map( + denops, + "(gin-diffjump-new)", + `call denops#request('gin', '${namespace}:diffjump:new', [])`, + { + buffer: true, + noremap: true, + }, + ); + await mapping.map( + denops, + "(gin-diffjump-smart)", + `call denops#request('gin', '${namespace}:diffjump:smart', [])`, + { + buffer: true, + noremap: true, + }, + ); + }); + }); +} + +export async function jumpOld( + denops: Denops, + commitishOrFn: + | Commitish + | ((_args: { bufnr: number }) => Commitish | Promise), + mods: string, +): Promise { + const bufnr = await fn.bufnr(denops, "%"); + const commitish = typeof commitishOrFn === "function" + ? await commitishOrFn({ bufnr }) + : commitishOrFn; + const target = await buildJumpTarget(denops, bufnr, "old", commitish); + if (target) { + await jumpTo(denops, target, mods); + } +} + +export async function jumpNew( + denops: Denops, + commitishOrFn: + | Commitish + | ((_args: { bufnr: number }) => Commitish | Promise), + mods: string, +): Promise { + const bufnr = await fn.bufnr(denops, "%"); + const commitish = typeof commitishOrFn === "function" + ? await commitishOrFn({ bufnr }) + : commitishOrFn; + const target = await buildJumpTarget(denops, bufnr, "new", commitish); + if (target) { + await jumpTo(denops, target, mods); + } +} + +export async function jumpSmart( + denops: Denops, + commitishMap: CommitishMap, + mods: string, +): Promise { + const bufnr = await fn.bufnr(denops, "%"); + const line = await fn.getline(denops, "."); + const side = line.startsWith("-") ? "old" : "new"; + const commitish = commitishMap[side]; + const resolved = typeof commitish === "function" + ? await commitish({ bufnr }) + : commitish; + + const target = await buildJumpTarget(denops, bufnr, side, resolved); + if (target) { + await jumpTo(denops, target, mods); + } +} + +async function buildJumpTarget( + denops: Denops, + bufnr: number, + side: "old" | "new", + commitish: Commitish, +): Promise { + const [lnum, content, bufname] = await batch.collect(denops, (denops) => [ + fn.line(denops, "."), + fn.getbufline(denops, bufnr, 1, "$"), + fn.bufname(denops, bufnr), + ]); + + const result = parse(lnum - 1, content); + if (!result) { + return undefined; + } + + // Select location based on jump type and requested side + let location: DiffLocation | undefined; + if (result.type === "old") { + location = result.old; + } else if (result.type === "new") { + location = result.new; + } else { + // jump.type === "both" + location = side === "old" ? result.old : result.new; + } + if (!location) { + return undefined; + } + + const { expr: worktree } = parseBufname(bufname); + if (!worktree) { + return undefined; + } + + return { + location, + commitish, + worktree, + }; +} + +async function jumpTo( + denops: Denops, + target: JumpTarget, + mods: string, +): Promise { + const filename = path.join( + target.worktree, + target.location.path.replace(/^[ab]\//, ""), + ); + + const column = await fn.col(denops, "."); + const cmdarg = `+call\\ cursor(${target.location.lnum},${column})`; + + if (target.commitish === WORKTREE) { + await buffer.open(denops, filename, { mods, cmdarg }); + } else if (target.commitish === INDEX) { + await execEdit(denops, filename, { + worktree: target.worktree, + mods, + cmdarg, + }); + } else { + await execEdit(denops, filename, { + worktree: target.worktree, + commitish: target.commitish, + mods, + cmdarg, + }); + } +} diff --git a/denops/gin/feat/diffjump/main.ts b/denops/gin/feat/diffjump/main.ts new file mode 100644 index 00000000..755cfdd7 --- /dev/null +++ b/denops/gin/feat/diffjump/main.ts @@ -0,0 +1,46 @@ +import type { Denops } from "jsr:@denops/std@^7.0.0"; +import * as helper from "jsr:@denops/std@^7.0.0/helper"; +import { assert, is } from "jsr:@core/unknownutil@^4.0.0"; +import { type CommitishMap, jumpNew, jumpOld, jumpSmart } from "./jump.ts"; + +export type InitOptions = { + commitishMap?: CommitishMap; +}; + +const isStringOrUndefined = is.UnionOf([is.String, is.Undefined]); + +export function main( + denops: Denops, + namespace: string, + options: InitOptions = {}, +): void { + const commitishMap = options.commitishMap ?? { + old: "HEAD^", + new: "HEAD", + }; + + denops.dispatcher = { + ...denops.dispatcher, + [`${namespace}:diffjump:old`]: (mods) => { + assert(mods, isStringOrUndefined); + return helper.friendlyCall( + denops, + () => jumpOld(denops, commitishMap.old, mods ?? ""), + ); + }, + [`${namespace}:diffjump:new`]: (mods) => { + assert(mods, isStringOrUndefined); + return helper.friendlyCall( + denops, + () => jumpNew(denops, commitishMap.new, mods ?? ""), + ); + }, + [`${namespace}:diffjump:smart`]: (mods) => { + assert(mods, isStringOrUndefined); + return helper.friendlyCall( + denops, + () => jumpSmart(denops, commitishMap, mods ?? ""), + ); + }, + }; +} diff --git a/denops/gin/feat/diffjump/parser.ts b/denops/gin/feat/diffjump/parser.ts new file mode 100644 index 00000000..411bc4dd --- /dev/null +++ b/denops/gin/feat/diffjump/parser.ts @@ -0,0 +1,131 @@ +/** + * Represents a location in a file within a diff + */ +export type DiffLocation = { + path: string; + lnum: number; +}; + +/** + * Represents the result of parsing a diff line for jump information + */ +export type Jump = + | { type: "old"; old: DiffLocation } + | { type: "new"; new: DiffLocation } + | { type: "both"; old: DiffLocation; new: DiffLocation }; + +// Pattern for special lines (headers) +const patternSpc = /^(?:@@|\-\-\-|\+\+\+) /; +// Pattern for hunk header: @@ -oldStart,oldCount +newStart,newCount @@ +const patternRng = /^@@ \-(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@.*$/; +// Pattern for old file header: --- a/path/to/file +const patternOld = /^\-\-\- (.*?)(?:\t.*)?$/; +// Pattern for new file header: +++ b/path/to/file +const patternNew = /^\+\+\+ (.*?)(?:\t.*)?$/; + +/** + * Parse jump target from diff content for a specific side + */ +function parseTarget( + index: number, + content: string[], + side: "old" | "new", +): DiffLocation | undefined { + const isOld = side === "old"; + const rangeIndex = isOld ? 1 : 2; // m1[1] for old, m1[2] for new + const pathPattern = isOld ? patternOld : patternNew; + const excludePrefix = isOld ? "+" : "-"; + const errorSide = isOld ? "old" : "new"; + + let path = ""; + let lnum = -1; + let offset = 0; + + for (let i = index; i >= 0; i--) { + const line = content[i]; + if (lnum === -1) { + const m1 = line.match(patternRng); + if (m1) { + lnum = Number(m1[rangeIndex]); + continue; + } + // Count lines that exist in this side (exclude the opposite side's changes) + if (!line.startsWith(excludePrefix)) { + offset += 1; + } + } + const m2 = line.match(pathPattern); + if (m2) { + path = m2[1]; + break; + } + } + + if (lnum === -1) { + throw new Error(`No range pattern found in diff content`); + } + if (path === "") { + throw new Error( + `No ${errorSide} file header pattern found in diff content`, + ); + } + + lnum += isOld ? offset - 1 : Math.max(offset - 1, 0); + return { path, lnum }; +} + +/** + * Parse diff content and find jump target at the specified line + * + * @param index - 0-based line index (e.g., lnum - 1 where lnum is from fn.line()) + * @param content - diff content as array of lines + * @returns Jump information, or undefined if not jumpable (e.g., on header lines) + * + * @example + * ```typescript,ignore + * // With Vim line number + * const lnum = await fn.line(denops, "."); + * const content = await fn.getline(denops, 1, "$"); + * const jump = parse(lnum - 1, content); + * + * // Direct array index usage + * const content = [ + * "--- a/old.txt", + * "+++ b/new.txt", + * "@@ -1,3 +1,3 @@", + * " context line", + * "-removed line", + * "+added line", + * ]; + * parse(3, content); // { type: "both", old: {...}, new: {...} } + * parse(4, content); // { type: "old", old: {...} } + * parse(5, content); // { type: "new", new: {...} } + * parse(0, content); // undefined (header line) + * ``` + */ +export function parse(index: number, content: string[]): Jump | undefined { + const line = content[index]; + + // Cannot jump from special lines (headers) + if (patternSpc.test(line)) { + return undefined; + } + + if (line.startsWith("-")) { + // Deleted line: only exists in old file + const oldTarget = parseTarget(index, content, "old"); + return oldTarget ? { type: "old", old: oldTarget } : undefined; + } else if (line.startsWith("+")) { + // Added line: only exists in new file + const newTarget = parseTarget(index, content, "new"); + return newTarget ? { type: "new", new: newTarget } : undefined; + } else { + // Context line: exists in both files + const oldTarget = parseTarget(index, content, "old"); + const newTarget = parseTarget(index, content, "new"); + if (oldTarget && newTarget) { + return { type: "both", old: oldTarget, new: newTarget }; + } + return undefined; + } +} diff --git a/denops/gin/command/diff/jump_test.diff b/denops/gin/feat/diffjump/parser_test.diff similarity index 100% rename from denops/gin/command/diff/jump_test.diff rename to denops/gin/feat/diffjump/parser_test.diff diff --git a/denops/gin/feat/diffjump/parser_test.ts b/denops/gin/feat/diffjump/parser_test.ts new file mode 100644 index 00000000..40f9f681 --- /dev/null +++ b/denops/gin/feat/diffjump/parser_test.ts @@ -0,0 +1,138 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.0"; +import { type Jump, parse } from "./parser.ts"; + +const example = (await Deno.readTextFile( + new URL("./parser_test.diff", import.meta.url), +)).split("\n"); + +Deno.test("parse() - old side (deleted lines)", () => { + const testcases: [number, Jump | undefined][] = [ + [0, undefined], // --- header + [1, undefined], // +++ header + [2, undefined], // @@ header + [3, { type: "old", old: { path: "a/path/to/lao", lnum: 1 } }], // -The Way... + [4, { type: "old", old: { path: "a/path/to/lao", lnum: 2 } }], // -The name... + [6, { type: "old", old: { path: "a/path/to/lao", lnum: 4 } }], // -The Named... + ]; + for (const [idx, exp] of testcases) { + assertEquals(parse(idx, example), exp, `Failed at line ${idx}`); + } +}); + +Deno.test("parse() - new side (added lines)", () => { + const testcases: [number, Jump | undefined][] = [ + [0, undefined], // --- header + [1, undefined], // +++ header + [2, undefined], // @@ header + [7, { type: "new", new: { path: "b/path/to/tzu", lnum: 2 } }], // +The named... + [8, { type: "new", new: { path: "b/path/to/tzu", lnum: 3 } }], // + + [16, { type: "new", new: { path: "b/path/to/tzu", lnum: 11 } }], // +They both... + [17, { type: "new", new: { path: "b/path/to/tzu", lnum: 12 } }], // +Deeper... + [18, { type: "new", new: { path: "b/path/to/tzu", lnum: 13 } }], // +The door... + ]; + for (const [idx, exp] of testcases) { + assertEquals(parse(idx, example), exp, `Failed at line ${idx}`); + } +}); + +Deno.test("parse() - both sides (context lines)", () => { + const testcases: [number, Jump | undefined][] = [ + [5, { + type: "both", + old: { path: "a/path/to/lao", lnum: 3 }, + new: { path: "b/path/to/tzu", lnum: 1 }, + }], // The Nameless... + [9, { + type: "both", + old: { path: "a/path/to/lao", lnum: 5 }, + new: { path: "b/path/to/tzu", lnum: 4 }, + }], // Therefore... + [10, { + type: "both", + old: { path: "a/path/to/lao", lnum: 6 }, + new: { path: "b/path/to/tzu", lnum: 5 }, + }], // so we may... + [11, { + type: "both", + old: { path: "a/path/to/lao", lnum: 7 }, + new: { path: "b/path/to/tzu", lnum: 6 }, + }], // And let... + [13, { + type: "both", + old: { path: "a/path/to/lao", lnum: 9 }, + new: { path: "b/path/to/tzu", lnum: 8 }, + }], // The two... + [14, { + type: "both", + old: { path: "a/path/to/lao", lnum: 10 }, + new: { path: "b/path/to/tzu", lnum: 9 }, + }], // But after... + [15, { + type: "both", + old: { path: "a/path/to/lao", lnum: 11 }, + new: { path: "b/path/to/tzu", lnum: 10 }, + }], // they have... + ]; + for (const [idx, exp] of testcases) { + assertEquals(parse(idx, example), exp, `Failed at line ${idx}`); + } +}); + +Deno.test("parse() - comprehensive test of all lines", () => { + const testcases: [number, Jump | undefined][] = [ + [0, undefined], // --- a/path/to/lao + [1, undefined], // +++ b/path/to/tzu + [2, undefined], // @@ -1,7 +1,6 @@ + [3, { type: "old", old: { path: "a/path/to/lao", lnum: 1 } }], // -The Way... + [4, { type: "old", old: { path: "a/path/to/lao", lnum: 2 } }], // -The name... + [5, { + type: "both", + old: { path: "a/path/to/lao", lnum: 3 }, + new: { path: "b/path/to/tzu", lnum: 1 }, + }], // The Nameless... + [6, { type: "old", old: { path: "a/path/to/lao", lnum: 4 } }], // -The Named... + [7, { type: "new", new: { path: "b/path/to/tzu", lnum: 2 } }], // +The named... + [8, { type: "new", new: { path: "b/path/to/tzu", lnum: 3 } }], // + + [9, { + type: "both", + old: { path: "a/path/to/lao", lnum: 5 }, + new: { path: "b/path/to/tzu", lnum: 4 }, + }], // Therefore... + [10, { + type: "both", + old: { path: "a/path/to/lao", lnum: 6 }, + new: { path: "b/path/to/tzu", lnum: 5 }, + }], // so we may... + [11, { + type: "both", + old: { path: "a/path/to/lao", lnum: 7 }, + new: { path: "b/path/to/tzu", lnum: 6 }, + }], // And let... + [12, undefined], // @@ -9,3 +8,6 @@ + [13, { + type: "both", + old: { path: "a/path/to/lao", lnum: 9 }, + new: { path: "b/path/to/tzu", lnum: 8 }, + }], // The two... + [14, { + type: "both", + old: { path: "a/path/to/lao", lnum: 10 }, + new: { path: "b/path/to/tzu", lnum: 9 }, + }], // But after... + [15, { + type: "both", + old: { path: "a/path/to/lao", lnum: 11 }, + new: { path: "b/path/to/tzu", lnum: 10 }, + }], // they have... + [16, { type: "new", new: { path: "b/path/to/tzu", lnum: 11 } }], // +They both... + [17, { type: "new", new: { path: "b/path/to/tzu", lnum: 12 } }], // +Deeper... + [18, { type: "new", new: { path: "b/path/to/tzu", lnum: 13 } }], // +The door... + ]; + for (const [idx, exp] of testcases) { + assertEquals( + parse(idx, example), + exp, + `Failed at line ${idx}: "${example[idx]}"`, + ); + } +}); diff --git a/doc/gin.txt b/doc/gin.txt index c6ca22d4..2f2f4dec 100644 --- a/doc/gin.txt +++ b/doc/gin.txt @@ -139,6 +139,33 @@ COMMANDS *gin-commands* ++emojify Replace all ":emoji:" with emoji characters. + ++diffjump[={commitish}] + Enable diff jump functionality for buffers that contain git + diff output (e.g., "git show", "git log -p"). When enabled, + diff jump mappings become available in the buffer. + If {commitish} is specified, it will be used as the target + commit. If omitted, defaults to "HEAD". + See |(gin-diffjump-smart)| for available mappings. + For example: +> + :GinBuffer ++diffjump show HEAD + :GinBuffer ++diffjump=abc123 show abc123 + :GinBuffer ++diffjump log -p -1 +< + ++difffold + Enable automatic folding for file sections in unified diff + output. Each file in the diff will be folded separately, + making it easier to navigate large diffs with multiple files. + For example: +> + :GinBuffer ++difffold show HEAD + :GinBuffer ++diffjump ++difffold log -p -1 +< + ++filetype={filetype} + Specifies the filetype to set for the buffer. Defaults to + "gin-buffer". This is useful when you want specific ftplugin + behavior for certain commands. + ++processor={processor} Specifies the processor program that will process the result. The result is passed to the processor via stdin and the @@ -649,6 +676,12 @@ VARIABLES *gin-variables* Default: 0 +*g:gin_buffer_disable_default_mappings* + Disable default mappings on buffers shown by |:GinBuffer| with + |++diffjump| option (filetype "gin-buffer"). + + Default: 0 + *g:gin_edit_default_args* Specify default arguments of |:GinEdit|. @@ -807,12 +840,16 @@ MAPPINGS *gin-mappings* *(gin-diffjump-smart)* Jump to the corresponding line of the comparison or comparison source. + Available in buffers with diff jump enabled (|:GinDiff|, |:GinBuffer| + with |++diffjump| option). *(gin-diffjump-old)* Jump to the corresponding line of the comparison source. + Available in buffers with diff jump enabled. *(gin-diffjump-new)* Jump to the corresponding line of the comparison. + Available in buffers with diff jump enabled. ----------------------------------------------------------------------------- ACTIONS *gin-actions* diff --git a/ftplugin/gin-buffer.vim b/ftplugin/gin-buffer.vim new file mode 100644 index 00000000..9cdbb998 --- /dev/null +++ b/ftplugin/gin-buffer.vim @@ -0,0 +1,11 @@ +if exists('b:did_ftplugin') + finish +endif + +if !get(g:, 'gin_buffer_disable_default_mappings') + nmap (gin-diffjump-smart)zv + nmap g (gin-diffjump-old)zv + nmap (gin-diffjump-new)zv +endif + +let b:did_ftplugin = 1 diff --git a/ftplugin/gin-diff.vim b/ftplugin/gin-diff.vim index d2f377b3..585752af 100644 --- a/ftplugin/gin-diff.vim +++ b/ftplugin/gin-diff.vim @@ -3,6 +3,8 @@ if exists('b:did_ftplugin') endif runtime ftplugin/diff.vim +setlocal foldlevel=1 + if !get(g:, 'gin_diff_disable_default_mappings') nmap (gin-diffjump-smart)zv nmap g (gin-diffjump-old)zv