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
211 changes: 211 additions & 0 deletions ui/text/src/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import React, { useEffect, useMemo, useState } from "react";
import { Box, Text, useInput } from "ink";
import {
TEXT_DIM,
TEXT_PRIMARY,
GOLD,
TEAL,
CRANBERRY,
TEXT_SECONDARY,
} from "../colors.js";
import { SCROLL_FAST_MULTIPLIER } from "../constants.js";

const PAD_X = 2;
const PAD_Y = 1;
const HEADER_LINES = 1;
const FOOTER_LINES = 1;

type LineKind = "add" | "remove" | "hunk" | "meta" | "context";

function classifyLine(line: string): LineKind {
if (line.startsWith("+++") || line.startsWith("---")) return "meta";
if (
line.startsWith("diff ") ||
line.startsWith("index ") ||
line.startsWith("new file") ||
line.startsWith("deleted file") ||
line.startsWith("rename ") ||
line.startsWith("similarity ") ||
line.startsWith("Binary ")
) {
return "meta";
}
if (line.startsWith("@@")) return "hunk";
if (line.startsWith("+")) return "add";
if (line.startsWith("-")) return "remove";
return "context";
}

function padLine(line: string, width: number): string {
if (line.length >= width) return line.slice(0, width);
return line + " ".repeat(width - line.length);
}

interface Props {
content: string;
truncated: boolean;
width: number;
height: number;
onClose: () => void;
}

export function DiffViewer({
content,
truncated,
width,
height,
onClose,
}: Props) {
const lines = useMemo(() => {
const split = content.split("\n");
if (split.length > 0 && split[split.length - 1] === "") split.pop();
return split;
}, [content]);

const innerWidth = Math.max(width - PAD_X * 2, 10);
const innerHeight = Math.max(height - PAD_Y * 2, 3);
const viewportHeight = Math.max(
innerHeight - HEADER_LINES - FOOTER_LINES,
1,
);
const maxScroll = Math.max(lines.length - viewportHeight, 0);

const [scroll, setScroll] = useState(0);

useEffect(() => {
setScroll((prev) => Math.min(prev, maxScroll));
}, [maxScroll]);

useInput((ch, key) => {
if (ch === "q" || ch === "Q" || key.escape) {
onClose();
return;
}
if (key.ctrl && (ch === "c" || ch === "C")) {
onClose();
return;
}

if (key.downArrow || ch === "j") {
const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
setScroll((s) => Math.min(s + step, maxScroll));
return;
}
if (key.upArrow || ch === "k") {
const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
setScroll((s) => Math.max(s - step, 0));
return;
}
if (key.pageDown || ch === " " || (key.ctrl && ch === "d")) {
setScroll((s) => Math.min(s + viewportHeight, maxScroll));
return;
}
if (key.pageUp || ch === "b" || (key.ctrl && ch === "u")) {
setScroll((s) => Math.max(s - viewportHeight, 0));
return;
}
if (ch === "g") {
setScroll(0);
return;
}
if (ch === "G") {
setScroll(maxScroll);
return;
}
});

const visible = lines.slice(scroll, scroll + viewportHeight);

const atEnd = scroll >= maxScroll;
const atStart = scroll === 0;
const position = maxScroll === 0
? "ALL"
: atEnd
? "END"
: `${Math.round((scroll / maxScroll) * 100)}%`;

return (
<Box
flexDirection="column"
width={width}
height={height}
paddingX={PAD_X}
paddingY={PAD_Y}
>
<Box width={innerWidth} justifyContent="space-between" flexShrink={0}>
<Text color={TEXT_PRIMARY} bold>
git diff{truncated ? " (truncated)" : ""}
</Text>
<Text color={TEXT_DIM}>
{atStart ? "" : "↑ "}lines {scroll + 1}–
{Math.min(scroll + viewportHeight, lines.length)} / {lines.length}
{" "}[{position}]
</Text>
</Box>
<Box flexDirection="column" width={innerWidth} height={viewportHeight}>
{visible.map((line, i) => {
const kind = classifyLine(line);
const padded = padLine(line, innerWidth);
switch (kind) {
case "add":
return (
<Text
key={i}
wrap="truncate-end"
color={TEXT_PRIMARY}
backgroundColor={TEAL}
>
{padded}
</Text>
);
case "remove":
return (
<Text
key={i}
wrap="truncate-end"
color={TEXT_PRIMARY}
backgroundColor={CRANBERRY}
>
{padded}
</Text>
);
case "hunk":
return (
<Text key={i} wrap="truncate-end" color={GOLD} bold>
{padded}
</Text>
);
case "meta":
return (
<Text key={i} wrap="truncate-end" color={TEXT_SECONDARY} bold>
{padded}
</Text>
);
default:
return (
<Text key={i} wrap="truncate-end" color={TEXT_PRIMARY}>
{padded}
</Text>
);
}
})}
</Box>
<Box width={innerWidth} flexShrink={0}>
<Text color={GOLD}>q</Text>
<Text color={TEXT_DIM}> close · </Text>
<Text color={GOLD}>↑↓</Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>j k</Text>
<Text color={TEXT_DIM}> scroll · </Text>
<Text color={GOLD}>space</Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>b</Text>
<Text color={TEXT_DIM}> page · </Text>
<Text color={GOLD}>g</Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>G</Text>
<Text color={TEXT_DIM}> top/bottom</Text>
</Box>
</Box>
);
}
93 changes: 93 additions & 0 deletions ui/text/src/slashCommands.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { spawnSync } from "node:child_process";

export interface SlashCommandContext {
cwd: string;
}

export type SlashCommandResult =
| { handled: true; message?: string }
| { handled: true; overlay: "diff"; content: string; truncated: boolean }
| { handled: false };

export interface SlashCommand {
name: string;
description: string;
run: (ctx: SlashCommandContext) => SlashCommandResult;
}

function isGitRepo(cwd: string): boolean {
const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd,
stdio: ["ignore", "ignore", "ignore"],
});
return result.status === 0;
}

const MAX_DIFF_BYTES = 2_000_000;

function readDiff(cwd: string): { text: string; truncated: boolean } | null {
const result = spawnSync(
"git",
["--no-pager", "diff", "--no-color"],
{
cwd,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
},
);
if (result.status !== 0 && result.status !== null) return null;
const stdout = result.stdout ?? "";
if (stdout.length > MAX_DIFF_BYTES) {
return { text: stdout.slice(0, MAX_DIFF_BYTES), truncated: true };
}
return { text: stdout, truncated: false };
}

const diffCommand: SlashCommand = {
name: "diff",
description: "show unstaged changes",
run: (ctx) => {
if (!isGitRepo(ctx.cwd)) {
return {
handled: true,
message: `not a git repository: ${ctx.cwd}`,
};
}

const diff = readDiff(ctx.cwd);
if (diff === null) {
return { handled: true, message: "failed to run `git diff`" };
}

if (diff.text.trim().length === 0) {
return { handled: true, message: "no unstaged changes" };
}

return {
handled: true,
overlay: "diff",
content: diff.text,
truncated: diff.truncated,
};
},
};

const COMMANDS: Record<string, SlashCommand> = {
diff: diffCommand,
};

export function tryRunSlashCommand(
input: string,
ctx: SlashCommandContext,
): SlashCommandResult {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) return { handled: false };
const name = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
const cmd = COMMANDS[name];
if (!cmd) return { handled: false };
return cmd.run(ctx);
}

export function listSlashCommands(): SlashCommand[] {
return Object.values(COMMANDS);
}
Loading