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
50 changes: 48 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ import { useResizablePanels } from "./hooks/useResizablePanels";
import { useLayoutMode } from "./hooks/useLayoutMode";
import { useAppSettings } from "./hooks/useAppSettings";
import { useUpdater } from "./hooks/useUpdater";
import type { AccessMode, QueuedMessage, WorkspaceInfo } from "./types";
import type {
AccessMode,
DiffLineReference,
QueuedMessage,
WorkspaceInfo,
} from "./types";

function useWindowLabel() {
const [label, setLabel] = useState("main");
Expand Down Expand Up @@ -100,6 +105,7 @@ function MainApp() {
Record<string, QueuedMessage[]>
>({});
const [prefillDraft, setPrefillDraft] = useState<QueuedMessage | null>(null);
const [composerInsert, setComposerInsert] = useState<QueuedMessage | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [reduceTransparency, setReduceTransparency] = useState(() => {
const stored = localStorage.getItem("reduceTransparency");
Expand Down Expand Up @@ -422,6 +428,36 @@ function MainApp() {
}
}

function handleDiffLineReference(reference: DiffLineReference) {
const startLine = reference.newLine ?? reference.oldLine;
const endLine =
reference.endNewLine ?? reference.endOldLine ?? startLine ?? null;
const lineRange =
startLine && endLine && endLine !== startLine
? `${startLine}-${endLine}`
: startLine
? `${startLine}`
: null;
const lineLabel = lineRange ? `${reference.path}:${lineRange}` : reference.path;
const changeLabel =
reference.type === "add"
? "added"
: reference.type === "del"
? "removed"
: reference.type === "mixed"
? "mixed"
: "context";
const snippet = reference.lines.join("\n").trimEnd();
const snippetBlock = snippet ? `\n\`\`\`\n${snippet}\n\`\`\`` : "";
const label = reference.lines.length > 1 ? "Line range" : "Line reference";
const text = `${label} (${changeLabel}): ${lineLabel}${snippetBlock}`;
setComposerInsert({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
text,
createdAt: Date.now(),
});
}

async function handleSend(text: string) {
const trimmed = text.trim();
if (!trimmed) {
Expand Down Expand Up @@ -543,7 +579,7 @@ function MainApp() {
};

const showComposer = !isCompact
? centerMode === "chat"
? centerMode === "chat" || centerMode === "diff"
: (isTablet ? tabletTab : activeTab) === "codex";
const showGitDetail = Boolean(selectedDiffPath) && isPhone;
const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${
Expand Down Expand Up @@ -635,6 +671,12 @@ function MainApp() {
setPrefillDraft(null);
}
}}
insertText={composerInsert}
onInsertHandled={(id) => {
if (composerInsert?.id === id) {
setComposerInsert(null);
}
}}
onEditQueued={(item) => {
if (!activeThreadId) {
return;
Expand Down Expand Up @@ -741,6 +783,7 @@ function MainApp() {
selectedPath={selectedDiffPath}
isLoading={isDiffLoading}
error={diffError}
onLineReference={handleDiffLineReference}
/>
) : (
messagesNode
Expand Down Expand Up @@ -899,6 +942,7 @@ function MainApp() {
selectedPath={selectedDiffPath}
isLoading={isDiffLoading}
error={diffError}
onLineReference={handleDiffLineReference}
/>
</div>
</div>
Expand Down Expand Up @@ -998,6 +1042,7 @@ function MainApp() {
selectedPath={selectedDiffPath}
isLoading={isDiffLoading}
error={diffError}
onLineReference={handleDiffLineReference}
/>
</div>
</>
Expand Down Expand Up @@ -1056,6 +1101,7 @@ function MainApp() {
selectedPath={selectedDiffPath}
isLoading={isDiffLoading}
error={diffError}
onLineReference={handleDiffLineReference}
/>
</div>
)}
Expand Down
12 changes: 12 additions & 0 deletions src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type ComposerProps = {
sendLabel?: string;
prefillDraft?: QueuedMessage | null;
onPrefillHandled?: (id: string) => void;
insertText?: QueuedMessage | null;
onInsertHandled?: (id: string) => void;
};

export function Composer({
Expand All @@ -51,6 +53,8 @@ export function Composer({
sendLabel = "Send",
prefillDraft = null,
onPrefillHandled,
insertText = null,
onInsertHandled,
}: ComposerProps) {
const [text, setText] = useState("");
const [selectionStart, setSelectionStart] = useState<number | null>(null);
Expand Down Expand Up @@ -96,6 +100,14 @@ export function Composer({
onPrefillHandled?.(prefillDraft.id);
}, [prefillDraft, onPrefillHandled]);

useEffect(() => {
if (!insertText) {
return;
}
setText(insertText.text);
onInsertHandled?.(insertText.id);
}, [insertText, onInsertHandled]);

return (
<footer className={`composer${disabled ? " is-disabled" : ""}`}>
<ComposerQueue
Expand Down
13 changes: 13 additions & 0 deletions src/components/ComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function ComposerInput({
}: ComposerInputProps) {
const suggestionListRef = useRef<HTMLDivElement | null>(null);
const suggestionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const maxTextareaHeight = 120;
const isFileSuggestion = (item: AutocompleteItem) =>
item.label.includes("/") || item.label.includes("\\");
const fileTitle = (path: string) => {
Expand Down Expand Up @@ -67,6 +68,18 @@ export function ComposerInput({
}
}, [highlightIndex, suggestionsOpen, suggestions.length]);

useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = "auto";
const nextHeight = Math.min(textarea.scrollHeight, maxTextareaHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxTextareaHeight ? "auto" : "hidden";
}, [text, textareaRef]);

return (
<div className="composer-input">
<div className="composer-input-area">
Expand Down
56 changes: 53 additions & 3 deletions src/components/DiffBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,77 @@
import type { KeyboardEvent, MouseEvent } from "react";
import { useMemo } from "react";
import { parseDiff } from "../utils/diff";
import { parseDiff, type ParsedDiffLine } from "../utils/diff";
import { highlightLine } from "../utils/syntax";

type DiffBlockProps = {
diff: string;
language?: string | null;
showLineNumbers?: boolean;
onLineSelect?: (
line: ParsedDiffLine,
index: number,
event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>,
) => void;
selectedRange?: { start: number; end: number } | null;
parsedLines?: ParsedDiffLine[] | null;
};

export function DiffBlock({
diff,
language,
showLineNumbers = true,
onLineSelect,
selectedRange = null,
parsedLines = null,
}: DiffBlockProps) {
const parsed = useMemo(() => parseDiff(diff), [diff]);
const parsed = useMemo(
() => parsedLines ?? parseDiff(diff),
[diff, parsedLines],
);
return (
<div>
{parsed.map((line, index) => {
const shouldHighlight =
line.type === "add" || line.type === "del" || line.type === "context";
const html = highlightLine(line.text, shouldHighlight ? language : null);
const isSelectable = Boolean(onLineSelect) && shouldHighlight;
const isSelected = Boolean(
isSelectable &&
selectedRange &&
index >= selectedRange.start &&
index <= selectedRange.end,
);
const isRangeStart = isSelected && selectedRange?.start === index;
const isRangeEnd = isSelected && selectedRange?.end === index;
return (
<div key={index} className={`diff-line diff-line-${line.type}`}>
<div
key={index}
className={`diff-line diff-line-${line.type}${
isSelectable ? " is-selectable" : ""
}${isSelected ? " is-selected" : ""}${
isRangeStart ? " is-range-start" : ""
}${isRangeEnd ? " is-range-end" : ""}`}
role={isSelectable ? "button" : undefined}
tabIndex={isSelectable ? 0 : undefined}
aria-pressed={isSelectable ? isSelected : undefined}
onClick={
isSelectable
? (event) => {
onLineSelect?.(line, index, event);
}
: undefined
}
onKeyDown={
isSelectable
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onLineSelect?.(line, index, event);
}
}
: undefined
}
>
{showLineNumbers && (
<div className="diff-gutter">
<span className="diff-line-number">{line.oldLine ?? ""}</span>
Expand Down
100 changes: 98 additions & 2 deletions src/components/GitDiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { DiffBlock } from "./DiffBlock";
import { parseDiff } from "../utils/diff";
import { languageFromPath } from "../utils/syntax";
import type { DiffLineReference } from "../types";
import type { ParsedDiffLine } from "../utils/diff";

type GitDiffViewerItem = {
path: string;
status: string;
Expand All @@ -12,16 +16,34 @@ type GitDiffViewerProps = {
selectedPath: string | null;
isLoading: boolean;
error: string | null;
onLineReference?: (reference: DiffLineReference) => void;
};

type SelectedRange = {
path: string;
start: number;
end: number;
anchor: number;
};

type SelectableDiffLine = ParsedDiffLine & {
type: "add" | "del" | "context";
};

function isSelectableLine(line: ParsedDiffLine): line is SelectableDiffLine {
return line.type === "add" || line.type === "del" || line.type === "context";
}

export function GitDiffViewer({
diffs,
selectedPath,
isLoading,
error,
onLineReference,
}: GitDiffViewerProps) {
const itemRefs = useRef(new Map<string, HTMLDivElement>());
const lastScrolledPath = useRef<string | null>(null);
const [selectedRange, setSelectedRange] = useState<SelectedRange | null>(null);

useEffect(() => {
if (!selectedPath) {
Expand All @@ -37,6 +59,61 @@ export function GitDiffViewer({
}
}, [selectedPath, diffs.length]);

useEffect(() => {
if (!selectedRange) {
return;
}
const stillExists = diffs.some((entry) => entry.path === selectedRange.path);
if (!stillExists) {
setSelectedRange(null);
}
}, [diffs, selectedRange]);

const handleLineSelect = (
entry: GitDiffViewerItem,
parsedLines: ParsedDiffLine[],
line: ParsedDiffLine,
index: number,
isRangeSelect: boolean,
) => {
if (!isSelectableLine(line)) {
return;
}
const hasAnchor = selectedRange?.path === entry.path;
const anchor = isRangeSelect && hasAnchor ? selectedRange.anchor : index;
const start = isRangeSelect ? Math.min(anchor, index) : index;
const end = isRangeSelect ? Math.max(anchor, index) : index;
setSelectedRange({ path: entry.path, start, end, anchor });

const selectedLines = parsedLines
.slice(start, end + 1)
.filter(isSelectableLine);
if (selectedLines.length === 0) {
return;
}

const typeSet = new Set(selectedLines.map((item) => item.type));
const selectionType = typeSet.size === 1 ? selectedLines[0].type : "mixed";
const firstOldLine = selectedLines.find((item) => item.oldLine !== null)?.oldLine ?? null;
const firstNewLine = selectedLines.find((item) => item.newLine !== null)?.newLine ?? null;
const lastOldLine =
[...selectedLines].reverse().find((item) => item.oldLine !== null)?.oldLine ??
null;
const lastNewLine =
[...selectedLines].reverse().find((item) => item.newLine !== null)?.newLine ??
null;

onLineReference?.({
path: entry.path,
type: selectionType,
oldLine: firstOldLine,
newLine: firstNewLine,
endOldLine: lastOldLine,
endNewLine: lastNewLine,
lines: selectedLines.map((item) => item.text),
});
};

return (
<div className="diff-viewer">
{error && <div className="diff-viewer-empty">{error}</div>}
Expand All @@ -51,6 +128,11 @@ export function GitDiffViewer({
const isSelected = entry.path === selectedPath;
const hasDiff = entry.diff.trim().length > 0;
const language = languageFromPath(entry.path);
const parsedLines = parseDiff(entry.diff);
const selectedRangeForEntry =
selectedRange?.path === entry.path
? { start: selectedRange.start, end: selectedRange.end }
: null;
return (
<div
key={entry.path}
Expand All @@ -69,7 +151,21 @@ export function GitDiffViewer({
</div>
{hasDiff ? (
<div className="diff-viewer-output">
<DiffBlock diff={entry.diff} language={language} />
<DiffBlock
diff={entry.diff}
language={language}
parsedLines={parsedLines}
onLineSelect={(line, index, event) =>
handleLineSelect(
entry,
parsedLines,
line,
index,
"shiftKey" in event && event.shiftKey,
)
}
selectedRange={selectedRangeForEntry}
/>
</div>
) : (
<div className="diff-viewer-placeholder">Diff unavailable.</div>
Expand Down
Loading