diff --git a/.changeset/responsive-log.md b/.changeset/responsive-log.md new file mode 100644 index 000000000..d9e685794 --- /dev/null +++ b/.changeset/responsive-log.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Open `hunk log` as an auto-responsive terminal browser with GitHub-inspired rows, right-aligned commit links and copy actions, while preserving static output for pipes and `--static`. diff --git a/README.md b/README.md index 5257ebf5d..c0275f47d 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,19 @@ hunk --fast # experimentally offload eligible syntax highligh hunk diff --watch # auto-reload as the working tree changes hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit -hunk log # print the selected provider's repository history -hunk log --interactive # browse history; Enter opens a commit in Hunk +hunk log # browse history on a terminal; print when redirected +hunk log --static # force static output, paging when needed ``` -`hunk log` is a static-first, read-only history surface, not a repository manager. The selected VCS -adapter owns traversal, filtering, refs, and how a history item opens for review; the bundled Git -and Jujutsu adapters both implement that public capability. Default output keeps full commit, -author, date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact rows, and `--theme` -uses the same palette as Hunk review. Static output remains safe for pipes, redirects, and normal -terminal scrollback. The explicit interactive mode stays a single history list; after opening a -commit, quit its normal Hunk review to return to the same selection. +`hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a +terminal it opens the desktop history browser; pipes and redirects receive shell-native static +records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter +owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and +Jujutsu adapters both implement that public capability. Static output keeps full commit, author, +date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and +`--theme` uses the same palette as Hunk review. Interactive rows adapt their information density to +the available width and keep commit ids right-aligned and clickable. After opening a commit, quit +its normal Hunk review to return to the same selection. ### Working with Jujutsu and Sapling diff --git a/docs/keybindings.md b/docs/keybindings.md index 39e8e57e8..2a131cead 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -45,12 +45,13 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: -`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of -the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus; +On a terminal, `hunk log` opens its read-only history browser automatically. Its controls are +separate from the configurable review command table. `F10` opens File, View, Navigate, Commit, and Help menus; View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, `g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit -id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. +id to open it immediately, click the adjacent copy icon to copy its full immutable id, click elsewhere +on a row to select it, or double-click a row to open it. Quitting the opened review returns to the retained history selection and viewport. The Commit menu's **Compare with first parent** and **Compare with parent…** actions compare the selected commit against an ordered provider-owned parent; they do not navigate the history selection to that parent. diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index 3c27a99ed..dc26b117a 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -491,7 +491,7 @@ describe("parseCli", () => { }); }); - test("parses static and interactive log options without review flags", async () => { + test("parses automatic and forced-static log options without review flags", async () => { expect( await parseCli([ "bun", @@ -520,10 +520,14 @@ describe("parseCli", () => { color: "never", format: "medium", ascii: true, - interactive: true, + static: false, extensionsEnabled: true, extensionPaths: [], }); + expect(await parseCli(["bun", "hunk", "log", "--static"])).toMatchObject({ + kind: "history", + static: true, + }); }); test("parses compact aliases, themes, and command-local extension disabling", async () => { @@ -1757,6 +1761,10 @@ describe("parseCli command help text", () => { expect(await expectHelp(["patch", "--help"])).toContain("review a patch file"); expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper"); expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs"); + const logHelp = await expectHelp(["log", "--help"]); + expect(logHelp).toContain("browse an attractive repository history"); + expect(logHelp).toContain("--static"); + expect(logHelp).not.toContain("--interactive"); }); test("renders the stash command overview and the stash show command help", async () => { diff --git a/src/app/cli.ts b/src/app/cli.ts index 53c0d8bb0..d5edf34c6 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -207,10 +207,11 @@ export const CLI_REFERENCE_COMMANDS = { path: "log", // Release preparation enables this when an installable build contains history browsing. publicDocs: false, - summary: "print an attractive repository history", + summary: "browse an attractive repository history", synopsis: ["hunk log [revision-expression] [-- ]"], details: [ - "Static output is the default. Use --interactive for the experimental history browser.", + "A terminal opens the responsive browser; pipes and redirects receive static output.", + "Use --static to force static output, paging when it exceeds the terminal.", "The selected VCS provider defines revision, filtering, and review semantics.", ], options: [ @@ -232,13 +233,13 @@ export const CLI_REFERENCE_COMMANDS = { }, { flag: "--format ", - description: "record format: medium or compact", + description: "static record format: medium or compact", commanderDefault: "medium", }, - { flag: "--oneline", description: "alias for --format compact" }, + { flag: "--oneline", description: "alias for static --format compact" }, { flag: "--theme ", description: "use the same theme as Hunk review" }, { flag: "--ascii", description: "use an ASCII graph" }, - { flag: "--interactive", description: "browse history and open commits in Hunk" }, + { flag: "--static", description: "print static output, paging when needed" }, { flag: "--vcs ", description: "select a VCS history provider" }, { flag: "--extension ", @@ -576,7 +577,7 @@ function renderCliHelp() { " hunk diff --staged [-- ] review staged changes", " hunk diff --files compare two concrete files", " hunk show [target] [-- ] review the last commit or a given target", - " hunk log [target] [-- ] print an attractive repository history", + " hunk log [target] [-- ] browse an attractive repository history", " hunk stash show [ref] review a stash entry (git only)", " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", @@ -1003,13 +1004,16 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("log").argument("[revision]"); + const command = createCliReferenceCommand("log") + .argument("[revision]") + // Accept the former opt-in spelling during migration without presenting two experiences. + .addOption(new Option("--interactive").hideHelp()); let revision: string | undefined; let options: Record = {}; @@ -1052,7 +1056,7 @@ async function parseHistoryCommand( color, format, ascii: Boolean(options.ascii), - interactive: Boolean(options.interactive), + static: Boolean(options.static), ...(typeof options.theme === "string" ? { theme: options.theme } : {}), ...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}), extensionsEnabled: extensionsEnabled && options.extensions !== false, diff --git a/src/app/historyBootstrap.test.ts b/src/app/historyBootstrap.test.ts index 976168daa..b8f937161 100644 --- a/src/app/historyBootstrap.test.ts +++ b/src/app/historyBootstrap.test.ts @@ -11,7 +11,7 @@ const input: HistoryCommandInput = { color: "never", format: "compact", ascii: false, - interactive: true, + static: false, vcs: "test", extensionsEnabled: false, extensionPaths: [], diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index df653c998..647101aec 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -3,7 +3,7 @@ import { createEmptyExtensionLoadResult } from "../extensions/types"; import { resolveExtensionCliCommands } from "../extensions/cliCommands"; import type { HunkConfigResolution } from "../core/run/config"; import { HunkUserError } from "../core/run/errors"; -import { prepareStartupPlan } from "./startup"; +import { prepareStartupPlan, shouldUseInteractiveHistory } from "./startup"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput, ParsedCliInput } from "../core/run/commandInputs"; import type { NamedCustomThemeConfig } from "../extension-api/types"; @@ -41,6 +41,23 @@ function createBootstrap(input: CliInput): AppBootstrap { }; } +describe("history surface selection", () => { + test("opens one automatic browser only when both standard streams are terminals", () => { + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(true); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: false, stdoutIsTTY: true }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: false }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: true, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(false); + }); +}); + describe("startup planning", () => { test("runs an extension CLI command and retires its registry before returning", async () => { const invocation = { diff --git a/src/app/startup.ts b/src/app/startup.ts index 253c3d352..a88daea9d 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -161,6 +161,19 @@ function applyDelegatedExtensionFlags( } as ParsedCliInput; } +/** Choose the history surface from terminal ownership unless static output was forced. */ +export function shouldUseInteractiveHistory({ + forceStatic, + stdinIsTTY, + stdoutIsTTY, +}: { + forceStatic: boolean; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; +}) { + return !forceStatic && stdinIsTTY && stdoutIsTTY; +} + /** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */ export async function prepareStartupPlan( argv: string[] = process.argv, @@ -399,8 +412,13 @@ export async function prepareStartupPlan( // The runner owns source/extension retirement; unlike ordinary headless plans, history must // retain its provider cursor until every page has been consumed. preloadedExtensions = undefined; + const useInteractiveHistory = shouldUseInteractiveHistory({ + forceStatic: parsedCliInput.static, + stdinIsTTY, + stdoutIsTTY, + }); return { - kind: parsedCliInput.interactive ? "history-interactive" : "history-static", + kind: useInteractiveHistory ? "history-interactive" : "history-static", bootstrap, input: parsedCliInput, }; diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index b8652415e..4fc50d751 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -113,7 +113,7 @@ export type SessionCommentListType = "live" | "all" | ReviewNoteSource; export type HistoryColorMode = "auto" | "always" | "never"; export type HistoryFormat = "medium" | "compact"; -/** Static-first VCS history invocation, deliberately separate from review view options. */ +/** Auto-responsive VCS history invocation, deliberately separate from review view options. */ export interface HistoryCommandInput { kind: "history"; revision?: string; @@ -128,7 +128,8 @@ export interface HistoryCommandInput { color: HistoryColorMode; format: HistoryFormat; ascii: boolean; - interactive: boolean; + /** Force scrollback output even when stdin and stdout are terminals. */ + static: boolean; theme?: string; vcs?: string; extensionsEnabled: boolean; diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts index cd2b53214..1ac5a2071 100644 --- a/src/ui/history/runStaticHistory.test.ts +++ b/src/ui/history/runStaticHistory.test.ts @@ -15,7 +15,7 @@ function runtime(commits: HistoryCommit[], maxCount?: number) { color: "never", format: "medium", ascii: false, - interactive: false, + static: true, extensionsEnabled: true, extensionPaths: [], ...(maxCount !== undefined ? { maxCount } : {}), diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx index 4b8d0a0dd..e6635ab48 100644 --- a/src/ui/log/LogApp.tsx +++ b/src/ui/log/LogApp.tsx @@ -11,8 +11,7 @@ import type { AppMenus, MenuEntry } from "../components/chrome/menu"; import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; import { useMenuController } from "../hooks/useMenuController"; import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; -import { fitText } from "../lib/text"; -import { formatHistoryDecorations, renderHistoryGraph } from "../history/staticProjection"; +import { fitText, measureTextWidth } from "../lib/text"; import type { HistoryRuntime } from "../history/types"; import type { LogController } from "./controller"; import { LOG_HELP_SECTIONS } from "./logHelp"; @@ -25,6 +24,7 @@ import { } from "./commands"; import { ParentSelectorDialog } from "./ParentSelectorDialog"; import { monochromeLogTheme } from "./colorPolicy"; +import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiveLayout"; export type LogAppOutcome = | { kind: "quit"; exitCode?: number } @@ -68,12 +68,11 @@ export function LogApp({ ? themeController.baseTheme : monochromeLogTheme(themeController.baseTheme, terminalThemeMode); const selectedRow = snapshot.rows[snapshot.selected]; - const detailHeight = - snapshot.presentation.format === "medium" && selectedRow && terminal.height >= 9 ? 5 : 0; - const viewportHeight = Math.max(1, terminal.height - 2 - detailHeight); + const responsiveLayout = resolveLogResponsiveLayout(terminal.width, terminal.height); + const viewportHeight = responsiveLayout.visibleRows; - const copySelected = () => { - const currentRow = controller.getSelectedRow(); + const copySelected = (row = controller.getSelectedRow()) => { + const currentRow = row; if (!currentRow) return; if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { renderer.copyToClipboardOSC52(currentRow.commit.revisionId); @@ -125,12 +124,6 @@ export function LogApp({ case "theme": themeController.openThemeSelector(); break; - case "format-medium": - controller.setFormat("medium"); - break; - case "format-compact": - controller.setFormat("compact"); - break; case "toggle-graph": controller.togglePresentation("graph"); break; @@ -215,9 +208,6 @@ export function LogApp({ view: [ commandItem("theme"), { kind: "separator" }, - commandItem("format-medium", { checked: snapshot.presentation.format === "medium" }), - commandItem("format-compact", { checked: snapshot.presentation.format === "compact" }), - { kind: "separator" }, commandItem("toggle-graph", { checked: snapshot.presentation.graph }), commandItem("toggle-unicode", { checked: snapshot.presentation.unicode }), commandItem("toggle-author", { checked: snapshot.presentation.author }), @@ -340,9 +330,11 @@ export function LogApp({ }); const visible = snapshot.rows.slice(snapshot.top, snapshot.top + viewportHeight); - const rowWidth = Math.max(1, terminal.width - 2); - const statusHint = terminal.width >= 48 ? "↑↓ move · Enter open · / search · F10 menu" : ""; - const statusTextWidth = Math.max(1, terminal.width - (statusHint ? 42 : 2)); + const statusHint = terminal.width >= 60 ? "↑↓ move · Enter open · / search · F10 menu" : ""; + const statusTextWidth = Math.max( + 1, + terminal.width - measureTextWidth(statusHint) - (statusHint ? 3 : 2), + ); return ( + { const index = snapshot.top + offset; const selected = index === snapshot.selected; - const graph = snapshot.presentation.graph - ? `${renderHistoryGraph(row, !snapshot.presentation.unicode)} ` - : ""; - const decorations = snapshot.presentation.decorations - ? formatHistoryDecorations(row) - : ""; - const author = snapshot.presentation.author - ? ` ${sanitizeTerminalLine(row.commit.authorName)}` - : ""; - const date = snapshot.presentation.date - ? ` ${sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10)}` - : ""; - const subject = fitText( - `${sanitizeTerminalLine(row.commit.subject)}${decorations}${author}${date}`, - Math.max(1, rowWidth - graph.length - row.commit.displayId.length - 2), - ); + const projected = projectResponsiveLogRow({ + row, + presentation: snapshot.presentation, + layout: responsiveLayout, + width: terminal.width, + }); return ( - {graph ? {graph} : null} + {projected.graphWidth ? ( + + {projected.graph} + {Array.from({ length: responsiveLayout.rowHeight - 1 }, (_, line) => ( + + {projected.continuation} + + ))} + + ) : null} + + {projected.title} + {responsiveLayout.showDescription ? ( + {projected.description} + ) : null} + {projected.metadata} + + {projected.columnGap ? : null} { event.stopPropagation(); clearTransientNotice(); - void controller.select(index, viewportHeight).then(() => openSelected()); + const copyIconStart = terminal.width - 1 - measureTextWidth(projected.copyIcon); + void controller.select(index, viewportHeight).then(() => { + if (event.x >= copyIconStart) copySelected(row); + else void openSelected(); + }); }} > - {row.commit.displayId} + + {projected.displayId} + {projected.copyIcon} + + {projected.secondary ? {projected.secondary} : null} - {` ${subject}`} ); })} - {detailHeight && selectedRow ? ( - - {fitText(selectedRow.commit.subject, rowWidth)} - - {fitText( - sanitizeTerminalLine( - `${selectedRow.commit.authorName}${selectedRow.commit.authorEmail ? ` <${selectedRow.commit.authorEmail}>` : ""}`, - ), - rowWidth, - )} - - - {fitText(sanitizeTerminalLine(selectedRow.commit.authoredAt), rowWidth)} - - - {fitText( - sanitizeTerminalLine((selectedRow.commit.body ?? "").replaceAll("\n", " ")), - rowWidth, - )} - - - {fitText(sanitizeTerminalLine(selectedRow.commit.revisionId), rowWidth)} - - - ) : null} ({ loading: false, notice: "", presentation: { - format: "compact", graph: true, unicode: true, author: true, diff --git a/src/ui/log/commands.ts b/src/ui/log/commands.ts index aeceb57b5..94debff4a 100644 --- a/src/ui/log/commands.ts +++ b/src/ui/log/commands.ts @@ -9,8 +9,6 @@ export type LogCommandId = | "refresh" | "quit" | "theme" - | "format-medium" - | "format-compact" | "toggle-graph" | "toggle-unicode" | "toggle-author" @@ -74,8 +72,6 @@ export const LOG_COMMANDS: readonly LogCommandDefinition[] = [ helpSection: "Application", }, { id: "theme", label: "Theme…", menu: "view" }, - { id: "format-medium", label: "Medium format", menu: "view" }, - { id: "format-compact", label: "Compact format", menu: "view" }, { id: "toggle-graph", label: "Graph", menu: "view" }, { id: "toggle-unicode", label: "Unicode graph", menu: "view" }, { id: "toggle-author", label: "Show author", menu: "view" }, diff --git a/src/ui/log/controller.test.ts b/src/ui/log/controller.test.ts index 1762ffce8..9125f979a 100644 --- a/src/ui/log/controller.test.ts +++ b/src/ui/log/controller.test.ts @@ -31,7 +31,7 @@ function createRuntime(subjects = ["first", "second", "third"]) { color: "never", format: "compact", ascii: false, - interactive: true, + static: false, extensionsEnabled: false, extensionPaths: [], }, @@ -93,11 +93,9 @@ describe("LogController", () => { } }); - test("initializes format from CLI input and loads enough pages for navigation", async () => { + test("loads enough bounded pages for responsive navigation", async () => { const { runtime } = createRuntime(["one", "two", "three", "four", "five"]); - runtime.input.format = "medium"; const controller = new LogController(runtime); - expect(controller.getSnapshot().presentation.format).toBe("medium"); await controller.loadMore(); await controller.page(1, 4); expect(controller.getSnapshot().selected).toBe(4); diff --git a/src/ui/log/controller.ts b/src/ui/log/controller.ts index 494cd6575..dbae4c3f7 100644 --- a/src/ui/log/controller.ts +++ b/src/ui/log/controller.ts @@ -5,7 +5,6 @@ import { sanitizeTerminalLine } from "../../lib/terminalText"; import type { HistoryRuntime } from "../history/types"; export interface LogPresentation { - format: "compact" | "medium"; graph: boolean; unicode: boolean; author: boolean; @@ -54,7 +53,6 @@ export class LogController { notice: runtime.notices[0] ?? "", themeId: runtime.input.theme, presentation: { - format: runtime.input.format, graph: true, unicode: !runtime.input.ascii && process.env.TERM !== "dumb", author: true, @@ -244,16 +242,12 @@ export class LogController { this.publish({ themeId }); } - togglePresentation(key: keyof Omit) { + togglePresentation(key: keyof LogPresentation) { this.publish({ presentation: { ...this.snapshot.presentation, [key]: !this.snapshot.presentation[key] }, }); } - setFormat(format: LogPresentation["format"]) { - this.publish({ presentation: { ...this.snapshot.presentation, format } }); - } - /** Refresh the provider cursor while reconciling selection by immutable revision id. */ async refresh() { if (this.refreshPromise) return this.refreshPromise; diff --git a/src/ui/log/responsiveLayout.test.ts b/src/ui/log/responsiveLayout.test.ts new file mode 100644 index 000000000..34ddf1610 --- /dev/null +++ b/src/ui/log/responsiveLayout.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryCommit } from "../../core/history/types"; +import { measureTextWidth } from "../lib/text"; +import type { LogPresentation } from "./controller"; +import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiveLayout"; + +const commit: HistoryCommit = { + revisionId: "a".repeat(40), + displayId: "日本語a1", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Responsive history title", + body: "A useful description of the selected change.\n\nMore detail.", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-09-05T12:00:00Z", + decorations: [ + { kind: "head", label: "HEAD", attachedLocalBranch: "main" }, + { kind: "local-branch", label: "main" }, + { kind: "tag", label: "v1.0.0" }, + ], +}; +const row = planHistoryPage([commit]).rows[0]!; +const presentation: LogPresentation = { + graph: true, + unicode: true, + author: true, + date: true, + decorations: true, +}; + +describe("responsive log layout", () => { + test("selects one automatic density from actual width", () => { + expect(resolveLogResponsiveLayout(120, 30)).toMatchObject({ + density: "wide", + rowHeight: 4, + visibleRows: 6, + }); + expect(resolveLogResponsiveLayout(80, 24)).toMatchObject({ + density: "medium", + rowHeight: 3, + visibleRows: 7, + }); + expect(resolveLogResponsiveLayout(42, 18)).toMatchObject({ + density: "narrow", + rowHeight: 3, + visibleRows: 5, + }); + }); + + test("keeps GitHub-style left metadata and a display-cell-correct right id", () => { + const wide = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(120, 30), + width: 120, + }); + expect(wide.title).toBe("Responsive history title"); + expect(wide.description).toContain("useful description"); + expect(wide.metadata).toContain("Ada Lovelace"); + expect(wide.metadata).toContain("2026-09-05"); + expect(wide.metadata).toContain("HEAD -> main"); + expect(wide.metadata).toContain("tag: v1.0.0"); + expect(wide.secondary).toBe("2 parents"); + expect(measureTextWidth(wide.displayId)).toBe(8); + expect(wide.copyIcon).toBe("⧉"); + expect(wide.rightWidth).toBeGreaterThan(measureTextWidth(wide.displayId)); + expect(wide.graphWidth + wide.leftWidth + wide.rightWidth + 2).toBeLessThanOrEqual(118); + }); + + test("bounds many graph lanes while reserving the title and right-aligned id", () => { + const manyLanes = Array.from({ length: 24 }, (_, index) => `lane-${index}`); + const crowdedRow = { + ...row, + cells: manyLanes.map((_, index) => ({ + kind: index === 0 ? ("node" as const) : ("vertical" as const), + })), + lanesBefore: manyLanes, + lanesAfter: manyLanes, + }; + const projected = projectResponsiveLogRow({ + row: crowdedRow, + presentation, + layout: resolveLogResponsiveLayout(42, 18), + width: 42, + }); + expect(projected.graph).toEndWith("…"); + expect(projected.leftWidth).toBeGreaterThanOrEqual(12); + expect( + projected.graphWidth + projected.leftWidth + projected.rightWidth + projected.columnGap, + ).toBeLessThanOrEqual(40); + expect(projected.displayId).not.toBe(""); + expect(projected.copyIcon).toBe("⧉"); + }); + + test("removes description and secondary state as room contracts", () => { + const medium = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(80, 24), + width: 80, + }); + expect(medium.description).toBe(""); + expect(medium.metadata).toContain("2026-09-05"); + expect(medium.secondary).toBe(""); + + const narrow = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(42, 18), + width: 42, + }); + expect(narrow.description).toBe(""); + expect(narrow.metadata).not.toContain("2026-09-05"); + expect(measureTextWidth(narrow.title)).toBeLessThanOrEqual(narrow.leftWidth); + expect(measureTextWidth(narrow.displayId)).toBeLessThanOrEqual(narrow.rightWidth); + }); +}); diff --git a/src/ui/log/responsiveLayout.ts b/src/ui/log/responsiveLayout.ts new file mode 100644 index 000000000..534f131ba --- /dev/null +++ b/src/ui/log/responsiveLayout.ts @@ -0,0 +1,136 @@ +import type { HistoryGraphRow } from "../../core/history/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../../lib/terminalText"; +import { + formatHistoryDecorations, + renderHistoryContinuation, + renderHistoryGraph, +} from "../history/staticProjection"; +import { fitText, measureTextWidth } from "../lib/text"; +import type { LogPresentation } from "./controller"; + +export type LogResponsiveDensity = "wide" | "medium" | "narrow"; + +export interface LogResponsiveLayout { + density: LogResponsiveDensity; + rowHeight: 3 | 4; + bodyHeight: number; + visibleRows: number; + showDescription: boolean; + showSecondary: boolean; +} + +export interface LogResponsiveRow { + graph: string; + continuation: string; + graphWidth: number; + leftWidth: number; + rightWidth: number; + columnGap: number; + title: string; + description: string; + metadata: string; + displayId: string; + copyIcon: string; + secondary: string; +} + +/** Derive one information-density policy from the actual terminal dimensions. */ +export function resolveLogResponsiveLayout(width: number, height: number): LogResponsiveLayout { + const safeWidth = Math.max(1, width); + // Reserve one row each for the menu, its breathing room, and the status bar. + const bodyHeight = Math.max(1, height - 3); + const density: LogResponsiveDensity = + safeWidth >= 96 ? "wide" : safeWidth >= 60 ? "medium" : "narrow"; + // Keep one graph-continuation row of breathing room between commit entries. + const rowHeight = density === "wide" ? 4 : 3; + return { + density, + rowHeight, + bodyHeight, + visibleRows: Math.max(1, Math.floor(bodyHeight / rowHeight)), + showDescription: density === "wide", + showSecondary: density === "wide", + }; +} + +/** Return the first safe one-line description after the commit subject. */ +function commitDescription(body: string | undefined) { + if (!body) return ""; + return ( + sanitizeTerminalText(body, { preserveNewlines: true, preserveTabs: false }) + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +/** Project one commit into left/right responsive columns using terminal display-cell widths. */ +export function projectResponsiveLogRow({ + row, + presentation, + layout, + width, +}: { + row: HistoryGraphRow; + presentation: LogPresentation; + layout: LogResponsiveLayout; + width: number; +}): LogResponsiveRow { + const contentWidth = Math.max(1, width - 2); + const rawGraph = presentation.graph ? renderHistoryGraph(row, !presentation.unicode) : ""; + const rawContinuation = presentation.graph + ? renderHistoryContinuation(row, !presentation.unicode) + : ""; + const safeId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const maxIdWidth = Math.max( + 1, + Math.min(measureTextWidth(safeId), Math.floor(contentWidth * 0.35)), + ); + const displayId = fitText(safeId, maxIdWidth, "…"); + const copyIcon = presentation.unicode ? "⧉" : "c"; + const secondary = + layout.showSecondary && row.commit.parentRevisionIds.length > 1 + ? `${row.commit.parentRevisionIds.length} parents` + : ""; + const idActionWidth = measureTextWidth(displayId) + 1 + measureTextWidth(copyIcon); + const rightWidth = Math.max(idActionWidth, measureTextWidth(secondary)); + const minimumLeftWidth = Math.min(12, Math.max(1, contentWidth - rightWidth)); + const desiredGap = contentWidth > rightWidth + minimumLeftWidth ? 2 : 0; + const maximumGraphWidth = Math.max(0, contentWidth - rightWidth - minimumLeftWidth - desiredGap); + const graphContentWidth = Math.max(0, maximumGraphWidth - 2); + const graph = graphContentWidth > 0 ? fitText(rawGraph, graphContentWidth, "…") : ""; + const continuation = + graphContentWidth > 0 ? fitText(rawContinuation, graphContentWidth, "…") : ""; + const graphWidth = graph + ? Math.min( + maximumGraphWidth, + Math.max(measureTextWidth(graph), measureTextWidth(continuation)) + 2, + ) + : 0; + const columnGap = contentWidth > graphWidth + rightWidth ? desiredGap : 0; + const leftWidth = Math.max(1, contentWidth - graphWidth - rightWidth - columnGap); + const decorations = presentation.decorations ? formatHistoryDecorations(row).trim() : ""; + const metadataParts = [ + presentation.author ? sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " ") : "", + presentation.date && layout.density !== "narrow" + ? sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10) + : "", + decorations, + ].filter(Boolean); + return { + graph, + continuation, + graphWidth, + leftWidth, + rightWidth, + columnGap, + title: fitText(sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "), leftWidth), + description: layout.showDescription + ? fitText(commitDescription(row.commit.body), leftWidth) + : "", + metadata: fitText(metadataParts.join(" · "), leftWidth), + displayId, + copyIcon, + secondary, + }; +} diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx index e4f1bc875..7de12522d 100644 --- a/src/ui/log/runInteractiveLog.tsx +++ b/src/ui/log/runInteractiveLog.tsx @@ -109,8 +109,8 @@ export async function runInteractiveLog( ) { if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { await runtime.close(); - throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ - "Use plain `hunk log` for pipes and redirected output.", + throw new HunkUserError("The `hunk log` browser requires a terminal.", [ + "Use `hunk log --static` to force scrollback output.", ]); } diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index b03dd34ef..be633c624 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createPtyHarness } from "./harness"; +import { createPtyHarness, rightmostColumnOf } from "./harness"; const harness = createPtyHarness(); const tempDirs: string[] = []; @@ -35,7 +35,7 @@ function createHistoryRepo() { git(cwd, ["add", "history.ts"]); git(cwd, ["commit", "-qm", "First history commit"]); writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'second';\n"); - git(cwd, ["commit", "-qam", "Second history commit"]); + git(cwd, ["commit", "-qam", "Second history commit", "-m", "Responsive description"]); return cwd; } @@ -74,7 +74,7 @@ describe("interactive hunk log", () => { test("opens the selected immutable commit and returns to the retained history", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 100, rows: 20, @@ -98,9 +98,16 @@ describe("interactive hunk log", () => { await session.press("enter"); await session.waitForText(/Second history commit/, { timeout: 5_000 }); - // The menu occupies row one; x=5 on the first history row lands inside - // its commit id and opens immediately without a double-click. - session.writeRaw("\x1b[<0;5;2M\x1b[<0;5;2m"); + // The first row's right-aligned commit id opens immediately without a double-click. + const firstRowIndex = history + .split("\n") + .findIndex((line) => line.includes("Second history commit")); + const firstRow = history.split("\n")[firstRowIndex] ?? ""; + const commitColumn = firstRow.search(/[0-9a-f]{8}\s+⧉\s*$/); + expect(commitColumn).toBeGreaterThan(0); + session.writeRaw( + `\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}m`, + ); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); @@ -112,6 +119,12 @@ describe("interactive hunk log", () => { }); expect(returned).toContain("Enter open"); + // The adjacent icon copies without opening the review. + session.writeRaw( + `\x1b[<0;${commitColumn + 10};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 10};${firstRowIndex + 1}m`, + ); + await session.waitForText(/Copied [0-9a-f]{8}/, { timeout: 5_000 }); + // Scrolling the history body dismisses an open dropdown before moving selection. await session.press("f10"); await session.waitForText(/Open selected commit/, { timeout: 5_000 }); @@ -123,7 +136,7 @@ describe("interactive hunk log", () => { ); // Clicking outside the id selects the second row without opening it. - session.writeRaw("\x1b[<0;50;3M\x1b[<0;50;3m"); + session.writeRaw("\x1b[<0;50;5M\x1b[<0;50;5m"); await session.press("enter"); const rootReview = await session.waitForText(/historyValue = 'first'/, { timeout: 15_000, @@ -152,10 +165,69 @@ describe("interactive hunk log", () => { } }); + test("adapts GitHub-style row density and right-aligned ids on resize", async () => { + const cwd = createHistoryRepo(); + const displayId = Bun.spawnSync(["git", "rev-parse", "--short=8", "HEAD"], { + cwd, + stdout: "pipe", + }) + .stdout.toString() + .trim(); + const session = await harness.launchHunk({ + args: ["log", "--color", "never", "--no-extensions"], + cwd, + cols: 110, + rows: 20, + }); + try { + const wide = await session.waitForText(/Responsive description/, { timeout: 15_000 }); + expect(rightmostColumnOf(wide, displayId)).toBeGreaterThan(95); + session.resize({ cols: 70, rows: 20 }); + await harness.waitForSnapshot( + session, + (text) => + text.includes("Second history commit") && !text.includes("Responsive description"), + 5_000, + ); + const medium = await session.text({ immediate: true }); + expect(medium).toContain("History Tester"); + expect(rightmostColumnOf(medium, displayId)).toBeGreaterThan(55); + session.resize({ cols: 42, rows: 18 }); + await harness.waitForSnapshot( + session, + (text) => text.includes("Second history commit") && !text.includes("2026-"), + 5_000, + ); + const narrow = await session.text({ immediate: true }); + expect(narrow).toContain("History Tester"); + expect(rightmostColumnOf(narrow, displayId)).toBeGreaterThan(30); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("forces static scrollback output on a terminal", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--static", "--color", "never", "--no-extensions"], + cwd, + cols: 80, + rows: 24, + }); + try { + const output = await session.waitForText(/Author: History Tester/, { timeout: 15_000 }); + expect(output).toContain("Second history commit"); + expect(output).not.toContain("File View Navigate"); + } finally { + session.close(); + } + }); + test("uses an ASCII graph in a dumb terminal", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--ascii", "--no-extensions"], + args: ["log", "--ascii", "--no-extensions"], cwd, cols: 80, rows: 16, @@ -174,7 +246,7 @@ describe("interactive hunk log", () => { test("refreshes from a new provider cursor and reveals a new commit", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 90, rows: 18, @@ -195,7 +267,7 @@ describe("interactive hunk log", () => { test("opens a merge against the provider-selected parent", async () => { const cwd = createMergeHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 100, rows: 20,