Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code.

### Changed

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ describe("config resolution", () => {
"wrap_lines = true",
"hunk_headers = false",
"agent_notes = true",
"copy_decorations = false",
].join("\n"),
);

Expand All @@ -280,6 +281,7 @@ describe("config resolution", () => {
expect(bootstrap.initialWrapLines).toBe(true);
expect(bootstrap.initialShowHunkHeaders).toBe(false);
expect(bootstrap.initialShowAgentNotes).toBe(true);
expect(bootstrap.initialCopyDecorations).toBe(false);
});

test("loadAppBootstrap exposes graphite when no theme is configured", async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
wrapLines: false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Default value contradicts documented behavior

The PR description and UI label both say "Copy decorations" is off by default, but DEFAULT_VIEW_PREFERENCES.copyDecorations: true initialises it as on. This flows through resolveConfiguredCliInputloadAppBootstrapApp, so new users without a config file will start with decorations enabled, copying diff rails, line numbers and gutters instead of bare code text. The value should be false to match the documented default.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.ts
Line: 15

Comment:
**Default value contradicts documented behavior**

The PR description and UI label both say "Copy decorations" is _off by default_, but `DEFAULT_VIEW_PREFERENCES.copyDecorations: true` initialises it as on. This flows through `resolveConfiguredCliInput``loadAppBootstrap``App`, so new users without a config file will start with decorations enabled, copying diff rails, line numbers and gutters instead of bare code text. The value should be `false` to match the documented default.

How can I resolve this? If you propose a fix, please make it concise.

showHunkHeaders: true,
showAgentNotes: false,
copyDecorations: false,
};
Comment on lines 17 to 19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Set copyDecorations to false so the out-of-the-box experience copies only code text, matching the PR description and menu label ("off by default").

Suggested change
showAgentNotes: false,
copyDecorations: true,
};
showAgentNotes: false,
copyDecorations: false,
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/config.ts
Line: 17-19

Comment:
Set `copyDecorations` to `false` so the out-of-the-box experience copies only code text, matching the PR description and menu label ("off by default").

```suggestion
  showAgentNotes: false,
  copyDecorations: false,
};
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


interface ConfigResolutionOptions {
Expand Down Expand Up @@ -63,6 +64,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
wrapLines: normalizeBoolean(source.wrap_lines),
hunkHeaders: normalizeBoolean(source.hunk_headers),
agentNotes: normalizeBoolean(source.agent_notes),
copyDecorations: normalizeBoolean(source.copy_decorations),
};
}

Expand All @@ -81,6 +83,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
wrapLines: overrides.wrapLines ?? base.wrapLines,
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
agentNotes: overrides.agentNotes ?? base.agentNotes,
copyDecorations: overrides.copyDecorations ?? base.copyDecorations,
};
}

Expand Down Expand Up @@ -165,6 +168,7 @@ export function resolveConfiguredCliInput(
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes,
copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations,
};

if (userConfigPath) {
Expand Down Expand Up @@ -194,6 +198,7 @@ export function resolveConfiguredCliInput(
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations,
};

return {
Expand Down
1 change: 1 addition & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,5 +1184,6 @@ export async function loadAppBootstrap(
initialWrapLines: input.options.wrapLines ?? false,
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
initialShowAgentNotes: input.options.agentNotes ?? false,
initialCopyDecorations: input.options.copyDecorations ?? false,
};
}
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export interface CommonOptions {
wrapLines?: boolean;
hunkHeaders?: boolean;
agentNotes?: boolean;
copyDecorations?: boolean;
}

export interface PersistedViewPreferences {
Expand All @@ -77,6 +78,7 @@ export interface PersistedViewPreferences {
wrapLines: boolean;
showHunkHeaders: boolean;
showAgentNotes: boolean;
copyDecorations: boolean;
}

export interface HelpCommandInput {
Expand Down Expand Up @@ -281,4 +283,5 @@ export interface AppBootstrap {
initialWrapLines?: boolean;
initialShowHunkHeaders?: boolean;
initialShowAgentNotes?: boolean;
initialCopyDecorations?: boolean;
}
59 changes: 56 additions & 3 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export function App({
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
const wrapToggleScrollTopRef = useRef<number | null>(null);
const layoutToggleScrollTopRef = useRef<number | null>(null);
const cancelCopySelectionRef = useRef<(() => void) | null>(null);
const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0);
const [transientNoticeText, setTransientNoticeText] = useState<string | null>(null);
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
const [themeId, setThemeId] = useState(() =>
bootstrap.initialTheme === "auto"
Expand All @@ -110,6 +112,7 @@ export function App({
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true);
const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false);
const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false);
const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0);
const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true);
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
Expand Down Expand Up @@ -294,6 +297,35 @@ export function App({
setShowLineNumbers((current) => !current);
};

/** Toggle whether mouse selection copies review decorations or only file content. */
const toggleCopyDecorations = () => {
setCopyDecorations((current) => !current);
};

// Show a short-lived status-bar message. Used to surface clipboard-copy outcomes that would
// otherwise be invisible to the user (OSC52 unsupported, etc.).
// Track the timer so we can clear it on unmount and avoid React state updates after unmount.
const transientTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showTransientNotice = useCallback((text: string, durationMs = 3000) => {
if (transientTimerRef.current !== null) {
clearTimeout(transientTimerRef.current);
}
setTransientNoticeText(text);
transientTimerRef.current = setTimeout(() => {
transientTimerRef.current = null;
setTransientNoticeText((current) => (current === text ? null : current));
}, durationMs);
}, []);

// Clear any pending transient-notice timer on unmount to avoid state updates after unmount.
useEffect(() => {
return () => {
if (transientTimerRef.current !== null) {
clearTimeout(transientTimerRef.current);
}
};
}, []);

/** Toggle whether diff code rows wrap instead of truncating to one terminal row. */
const toggleLineWrap = () => {
// Capture the pre-toggle viewport position synchronously so DiffPane can restore the same
Expand Down Expand Up @@ -483,11 +515,13 @@ export function App({
requestQuit,
selectLayoutMode,
selectThemeId: setThemeId,
copyDecorations,
showAgentNotes,
showHelp,
showHunkHeaders,
showLineNumbers,
renderSidebar,
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
toggleHelp,
Expand All @@ -500,6 +534,7 @@ export function App({
[
activeTheme.id,
canRefreshCurrentInput,
copyDecorations,
focusFilter,
layoutMode,
moveToAnnotatedFile,
Expand All @@ -508,6 +543,7 @@ export function App({
review.moveToHunk,
selectLayoutMode,
triggerRefreshCurrentInput,
toggleCopyDecorations,
showAgentNotes,
showHelp,
showHunkHeaders,
Expand Down Expand Up @@ -627,6 +663,11 @@ export function App({
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1);
const diffSeparatorWidth = Math.max(4, diffContentWidth - 2);
// Mirror the App layout: bodyPadding/2 left-padding, then sidebar + divider when visible. Keep
// this in lockstep with the body container's paddingLeft and the sidebar render branch below.
const diffPaneScreenLeft =
bodyPadding / 2 + (renderSidebar ? clampedSidebarWidth + DIVIDER_WIDTH : 0);
const diffPaneScreenTop = pagerMode ? 0 : 1;

return (
<box
Expand Down Expand Up @@ -665,10 +706,14 @@ export function App({
position: "relative",
}}
onMouseDrag={updateSidebarResize}
onMouseDragEnd={endSidebarResize}
onMouseDragEnd={(event) => {
endSidebarResize(event);
cancelCopySelectionRef.current?.();
}}
onMouseUp={(event) => {
endSidebarResize(event);
closeMenu();
cancelCopySelectionRef.current?.();
}}
>
{renderSidebar ? (
Expand Down Expand Up @@ -700,10 +745,14 @@ export function App({
) : null}

<DiffPane
cancelCopySelectionRef={cancelCopySelectionRef}
codeHorizontalOffset={codeHorizontalOffset}
copyDecorations={copyDecorations}
diffContentWidth={diffContentWidth}
files={filteredFiles}
pagerMode={pagerMode}
screenLeft={diffPaneScreenLeft}
screenTop={diffPaneScreenTop}
headerLabelWidth={diffHeaderLabelWidth}
headerStatsWidth={diffHeaderStatsWidth}
layout={resolvedLayout}
Expand All @@ -727,18 +776,22 @@ export function App({
onScrollCodeHorizontally={(delta) => {
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
}}
onCopyFeedback={showTransientNotice}
onSelectFile={jumpToFile}
onViewportCenteredHunkChange={(fileId, hunkIndex) =>
review.selectHunk(fileId, hunkIndex, { preserveViewport: true })
}
/>
</box>

{!pagerMode && (focusArea === "filter" || Boolean(review.filter) || Boolean(noticeText)) ? (
{!pagerMode &&
(focusArea === "filter" ||
Boolean(review.filter) ||
Boolean(transientNoticeText ?? noticeText)) ? (
<StatusBar
filter={review.filter}
filterFocused={focusArea === "filter"}
noticeText={noticeText ?? undefined}
noticeText={transientNoticeText ?? noticeText ?? undefined}
terminalWidth={terminal.width}
theme={activeTheme}
onCloseMenu={closeMenu}
Expand Down
2 changes: 1 addition & 1 deletion src/ui/components/panes/AgentInlineNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { annotationRangeLabel } from "../../lib/agentAnnotations";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";

function inlineNoteTitle(noteIndex: number, noteCount: number) {
export function inlineNoteTitle(noteIndex: number, noteCount: number) {
return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note";
}

Expand Down
Loading