Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/github-style-history-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Redesign interactive `hunk log` with themed GitHub-style day groups, account-like author handles, relative timestamps, and an optional commit graph view.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ records automatically, and `--static` forces static output that pages only when
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.
`--theme` uses the same palette as Hunk review. Interactive history groups commits by local-calendar
day with account-like author handles and relative times, while keeping commit ids right-aligned and
clickable. Enable **Graph view** from the View menu to replace day groups with commit-topology lanes.
After opening a commit, quit its normal Hunk review to return to the same selection.

### Working with Jujutsu and Sapling

Expand Down
5 changes: 3 additions & 2 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ The built-in commands and the keys they ship with:

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
View includes Hunk's shared theme selector and an optional **Graph view** that replaces the default
day-grouped timeline with commit-topology lanes. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`,
`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `t` to choose a theme, `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 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.
Expand Down
7 changes: 4 additions & 3 deletions packages/hunk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ records automatically, and `--static` forces static output that pages only when
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.
`--theme` uses the same palette as Hunk review. Interactive history groups commits by local-calendar
day with account-like author handles and relative times, while keeping commit ids right-aligned and
clickable. Enable **Graph view** from the View menu to replace day groups with commit-topology lanes.
After opening a commit, quit its normal Hunk review to return to the same selection.

### Working with Jujutsu and Sapling

Expand Down
235 changes: 161 additions & 74 deletions packages/hunk/src/ui/log/LogApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,24 @@ import {
type LogCommandId,
} from "./commands";
import { ParentSelectorDialog } from "./ParentSelectorDialog";
import { monochromeLogTheme } from "./colorPolicy";
import { monochromeLogTheme, resolveInteractiveLogPalette } from "./colorPolicy";
import { formatHistoryDay } from "./formatting";
import { LOG_DAY_HEADER_HEIGHT, planLogViewportGeometry } from "./geometry";
import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiveLayout";

/** Render graph cells with stable semantic colors from the active Hunk theme. */
function HistoryGraphLine({ text, colors }: { text: string; colors: readonly string[] }) {
return (
<text>
{Array.from({ length: Math.ceil(text.length / 2) }, (_, lane) => (
<span key={lane} fg={colors[lane % colors.length]!}>
{text.slice(lane * 2, lane * 2 + 2)}
</span>
))}
</text>
);
}

export type LogAppOutcome =
| { kind: "quit"; exitCode?: number }
| {
Expand Down Expand Up @@ -54,6 +69,7 @@ export function LogApp({
const [showHelp, setShowHelp] = useState(false);
const [parentSelectorIndex, setParentSelectorIndex] = useState<number | null>(null);
const [transientNotice, setTransientNotice] = useState("");
const [relativeTimeNow, setRelativeTimeNow] = useState(() => Date.now());
const [openingCommit, setOpeningCommit] = useState<{ id: string; subject: string } | null>(null);
const lastClick = useRef({ index: -1, at: 0 });
// Lock synchronously before requesting review preparation so coalesced input cannot
Expand All @@ -75,9 +91,11 @@ export function LogApp({
const chromeTheme = useColor
? themeController.baseTheme
: monochromeLogTheme(themeController.baseTheme, terminalThemeMode);
const logPalette = resolveInteractiveLogPalette(theme);
const graphColors = snapshot.presentation.graph ? logPalette.graphLanes : [logPalette.timeline];
const selectedRow = snapshot.rows[snapshot.selected];
const responsiveLayout = resolveLogResponsiveLayout(terminal.width, terminal.height);
const viewportHeight = responsiveLayout.visibleRows;
const viewportBodyHeight = responsiveLayout.bodyHeight;

const copySelected = (row = controller.getSelectedRow()) => {
const currentRow = row;
Expand Down Expand Up @@ -161,31 +179,31 @@ export function LogApp({
controller.togglePresentation("decorations");
break;
case "previous":
void controller.move(-1, viewportHeight);
void controller.move(-1, viewportBodyHeight);
break;
case "next":
void controller.move(1, viewportHeight);
void controller.move(1, viewportBodyHeight);
break;
case "page-up":
void controller.page(-1, viewportHeight);
void controller.page(-1, viewportBodyHeight);
break;
case "page-down":
void controller.page(1, viewportHeight);
void controller.page(1, viewportBodyHeight);
break;
case "first":
void controller.first(viewportHeight);
void controller.first(viewportBodyHeight);
break;
case "last":
void controller.last(viewportHeight);
void controller.last(viewportBodyHeight);
break;
case "search":
controller.beginSearch();
break;
case "next-match":
void controller.findMatch(1, viewportHeight);
void controller.findMatch(1, viewportBodyHeight);
break;
case "previous-match":
void controller.findMatch(-1, viewportHeight);
void controller.findMatch(-1, viewportBodyHeight);
break;
case "open-first-parent": {
const parent = controller.getSelectedRow()?.commit.parentRevisionIds[0];
Expand Down Expand Up @@ -262,11 +280,35 @@ export function LogApp({
void controller.loadMore();
}, [controller]);
useEffect(() => {
controller.clampViewport(viewportHeight);
if (snapshot.top + viewportHeight + 8 >= snapshot.rows.length && !snapshot.historyDone) {
const timer = setInterval(() => setRelativeTimeNow(Date.now()), 60_000);
timer.unref?.();
return () => clearInterval(timer);
}, []);
useEffect(() => {
controller.clampViewport(viewportBodyHeight);
const geometry = planLogViewportGeometry({
rows: snapshot.rows,
selected: snapshot.selected,
requestedTop: snapshot.top,
bodyHeight: viewportBodyHeight,
groupByDay: !snapshot.presentation.graph,
});
if (
geometry.entries.at(-1)?.index !== undefined &&
geometry.entries.at(-1)!.index + 8 >= snapshot.rows.length &&
!snapshot.historyDone
) {
void controller.loadMore();
}
}, [controller, snapshot.historyDone, snapshot.rows.length, snapshot.top, viewportHeight]);
}, [
controller,
snapshot.historyDone,
snapshot.presentation.graph,
snapshot.rows,
snapshot.selected,
snapshot.top,
viewportBodyHeight,
]);

useKeyboard((key: KeyEvent) => {
clearTransientNotice();
Expand Down Expand Up @@ -360,7 +402,7 @@ export function LogApp({
if (snapshot.searchEditing) {
if ((key.ctrl && name === "c") || name === "escape") controller.cancelSearch();
else if (name === "return" || name === "enter")
void controller.finishSearch(1, viewportHeight);
void controller.finishSearch(1, viewportBodyHeight);
else if (name === "backspace") controller.backspaceSearch();
else if (/^[^\x00-\x1f\x7f]+$/u.test(sequence)) controller.appendSearch(sequence);
else return;
Expand All @@ -378,7 +420,14 @@ export function LogApp({
consume();
});

const visible = snapshot.rows.slice(snapshot.top, snapshot.top + viewportHeight);
const viewportGeometry = planLogViewportGeometry({
rows: snapshot.rows,
selected: snapshot.selected,
requestedTop: snapshot.top,
bodyHeight: viewportBodyHeight,
groupByDay: !snapshot.presentation.graph,
});
const visible = viewportGeometry.entries;
const statusHint = terminal.width >= 60 ? "↑↓ move · Enter open · / search · F10 menu" : "";
const statusTextWidth = Math.max(
1,
Expand Down Expand Up @@ -417,8 +466,8 @@ export function LogApp({
onMouseScroll={(event: TuiMouseEvent) => {
menu.closeMenu();
const direction = event.scroll?.direction;
if (direction === "up") controller.move(-3, viewportHeight);
else if (direction === "down") controller.move(3, viewportHeight);
if (direction === "up") controller.move(-3, viewportBodyHeight);
else if (direction === "down") controller.move(3, viewportBodyHeight);
}}
>
{openingCommit ? (
Expand All @@ -441,87 +490,125 @@ export function LogApp({
<text fg={theme.muted}>Preparing review…</text>
</box>
) : (
visible.map((row, offset) => {
const index = snapshot.top + offset;
visible.map(({ index, row, showDayHeader }) => {
const selected = index === snapshot.selected;
const projected = projectResponsiveLogRow({
row,
presentation: snapshot.presentation,
layout: responsiveLayout,
width: terminal.width,
now: relativeTimeNow,
});
return (
<box
key={row.commit.revisionId}
style={{
height: responsiveLayout.rowHeight,
width: "100%",
flexDirection: "row",
backgroundColor: selected ? theme.selectedHunk : theme.background,
}}
onMouseUp={() => {
clearTransientNotice();
const now = Date.now();
const shouldOpen =
lastClick.current.index === index && now - lastClick.current.at < 400;
void controller.select(index, viewportHeight).then(() => {
if (shouldOpen) void openSelected();
});
lastClick.current = { index, at: now };
height: responsiveLayout.rowHeight + (showDayHeader ? LOG_DAY_HEADER_HEIGHT : 0),
flexDirection: "column",
}}
>
{projected.graphWidth ? (
<box
style={{
width: projected.graphWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
}}
>
<text fg={theme.muted}>{projected.graph}</text>
{Array.from({ length: responsiveLayout.rowHeight - 1 }, (_, line) => (
<text key={line} fg={theme.muted}>
{projected.continuation}
</text>
))}
</box>
{showDayHeader ? (
<text>
<span fg={logPalette.timeline}>
{snapshot.presentation.unicode ? "○─" : "o-"}
</span>
<span fg={logPalette.separator}> </span>
<span fg={logPalette.dayHeading}>
{fitText(
formatHistoryDay(row.commit.authoredAt),
Math.max(1, terminal.width - 5),
)}
</span>
</text>
) : null}
{showDayHeader ? (
<text fg={logPalette.timeline}>{snapshot.presentation.unicode ? "│" : "|"}</text>
) : null}
<box
style={{
width: projected.leftWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
}}
>
<text fg={theme.text}>{projected.title}</text>
{responsiveLayout.showDescription ? (
<text fg={theme.muted}>{projected.description}</text>
) : null}
<text fg={theme.muted}>{projected.metadata}</text>
</box>
{projected.columnGap ? <box style={{ width: projected.columnGap }} /> : null}
<box
style={{
width: projected.rightWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
alignItems: "flex-end",
width: "100%",
flexDirection: "row",
backgroundColor: selected ? theme.selectedHunk : theme.background,
}}
onMouseUp={(event: TuiMouseEvent) => {
event.stopPropagation();
onMouseUp={() => {
clearTransientNotice();
const copyIconStart = terminal.width - 1 - measureTextWidth(projected.copyIcon);
void controller.select(index, viewportHeight).then(() => {
if (event.x >= copyIconStart) copySelected(row);
else void openSelected();
const now = Date.now();
const shouldOpen =
lastClick.current.index === index && now - lastClick.current.at < 400;
void controller.select(index, viewportBodyHeight).then(() => {
if (shouldOpen) void openSelected();
});
lastClick.current = { index, at: now };
}}
>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.accent}>{projected.displayId}</text>
<text fg={theme.muted}>{projected.copyIcon}</text>
{projected.graphWidth ? (
<box
style={{
width: projected.graphWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
}}
>
<HistoryGraphLine text={projected.graph} colors={graphColors} />
<HistoryGraphLine text={projected.continuation} colors={graphColors} />
<HistoryGraphLine text={projected.convergence} colors={graphColors} />
</box>
) : null}
<box
style={{
width: projected.leftWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
}}
>
<text fg={theme.text}>{projected.title}</text>
<text>
{projected.author ? (
<span fg={logPalette.author}>{projected.author}</span>
) : null}
{projected.author && projected.relativeTime ? (
<span fg={logPalette.separator}> · </span>
) : null}
{projected.relativeTime ? (
<span fg={logPalette.relativeTime}>{projected.relativeTime}</span>
) : null}
</text>
<text> </text>
</box>
{projected.columnGap ? <box style={{ width: projected.columnGap }} /> : null}
<box
style={{
width: projected.rightWidth,
height: responsiveLayout.rowHeight,
flexDirection: "column",
alignItems: "flex-end",
}}
onMouseUp={(event: TuiMouseEvent) => {
event.stopPropagation();
clearTransientNotice();
const copyIconStart =
1 +
projected.graphWidth +
projected.leftWidth +
projected.columnGap +
projected.rightWidth -
measureTextWidth(projected.copyIcon);
void controller.select(index, viewportBodyHeight).then(() => {
if (event.x >= copyIconStart) copySelected(row);
else void openSelected();
});
}}
>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={logPalette.commitId}>{projected.displayId}</text>
<text fg={logPalette.copyAction}>{projected.copyIcon}</text>
</box>
{projected.secondary ? (
<text fg={logPalette.decoration}>{projected.secondary}</text>
) : null}
</box>
{projected.secondary ? <text fg={theme.muted}>{projected.secondary}</text> : null}
</box>
</box>
);
Expand Down
Loading
Loading