Skip to content

Commit 02c4a4a

Browse files
raulkbenvinegar
andauthored
feat(ui): add mouse-drag text selection and clipboard copy (#306)
Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent 77d4c8a commit 02c4a4a

23 files changed

Lines changed: 2689 additions & 128 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file.
66

77
### Added
88

9+
- 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.
10+
911
### Changed
1012

1113
### Fixed

src/core/config.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ describe("config resolution", () => {
308308
"wrap_lines = true",
309309
"hunk_headers = false",
310310
"agent_notes = true",
311+
"copy_decorations = false",
311312
].join("\n"),
312313
);
313314

@@ -333,6 +334,7 @@ describe("config resolution", () => {
333334
expect(bootstrap.initialWrapLines).toBe(true);
334335
expect(bootstrap.initialShowHunkHeaders).toBe(false);
335336
expect(bootstrap.initialShowAgentNotes).toBe(true);
337+
expect(bootstrap.initialCopyDecorations).toBe(false);
336338
});
337339

338340
test("loadAppBootstrap exposes graphite when no theme is configured", async () => {

src/core/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = {
1616
wrapLines: false,
1717
showHunkHeaders: true,
1818
showAgentNotes: false,
19+
copyDecorations: false,
1920
};
2021

2122
interface ConfigResolutionOptions {
@@ -65,6 +66,7 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
6566
wrapLines: normalizeBoolean(source.wrap_lines),
6667
hunkHeaders: normalizeBoolean(source.hunk_headers),
6768
agentNotes: normalizeBoolean(source.agent_notes),
69+
copyDecorations: normalizeBoolean(source.copy_decorations),
6870
};
6971
}
7072

@@ -83,6 +85,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
8385
wrapLines: overrides.wrapLines ?? base.wrapLines,
8486
hunkHeaders: overrides.hunkHeaders ?? base.hunkHeaders,
8587
agentNotes: overrides.agentNotes ?? base.agentNotes,
88+
copyDecorations: overrides.copyDecorations ?? base.copyDecorations,
8689
};
8790
}
8891

@@ -145,6 +148,7 @@ export function resolveConfiguredCliInput(
145148
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
146149
hunkHeaders: DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
147150
agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes,
151+
copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations,
148152
};
149153

150154
if (userConfigPath) {
@@ -174,6 +178,7 @@ export function resolveConfiguredCliInput(
174178
wrapLines: resolvedOptions.wrapLines ?? DEFAULT_VIEW_PREFERENCES.wrapLines,
175179
hunkHeaders: resolvedOptions.hunkHeaders ?? DEFAULT_VIEW_PREFERENCES.showHunkHeaders,
176180
agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes,
181+
copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations,
177182
};
178183

179184
return {

src/core/liveComments.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
import type { Hunk } from "@pierre/diffs";
2-
import type { DiffFile } from "./types";
3-
import type { CommentTargetInput, DiffSide, LiveComment } from "../hunk-session/types";
2+
import type { AgentAnnotation, DiffFile } from "./types";
3+
4+
export type DiffSide = "old" | "new";
5+
6+
export interface CommentTargetInput {
7+
filePath: string;
8+
hunkIndex?: number;
9+
side?: DiffSide;
10+
line?: number;
11+
summary: string;
12+
rationale?: string;
13+
author?: string;
14+
}
15+
16+
export interface LiveComment extends AgentAnnotation {
17+
id: string;
18+
source: "mcp";
19+
author?: string;
20+
createdAt: string;
21+
filePath: string;
22+
hunkIndex: number;
23+
side: DiffSide;
24+
line: number;
25+
}
426

527
export interface ResolvedCommentTarget {
628
hunkIndex: number;

src/core/loaders.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,5 +489,6 @@ export async function loadAppBootstrap(
489489
initialWrapLines: input.options.wrapLines ?? false,
490490
initialShowHunkHeaders: input.options.hunkHeaders ?? true,
491491
initialShowAgentNotes: input.options.agentNotes ?? false,
492+
initialCopyDecorations: input.options.copyDecorations ?? false,
492493
};
493494
}

src/core/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export interface CommonOptions {
7979
wrapLines?: boolean;
8080
hunkHeaders?: boolean;
8181
agentNotes?: boolean;
82+
copyDecorations?: boolean;
8283
}
8384

8485
export interface PersistedViewPreferences {
@@ -88,6 +89,7 @@ export interface PersistedViewPreferences {
8889
wrapLines: boolean;
8990
showHunkHeaders: boolean;
9091
showAgentNotes: boolean;
92+
copyDecorations: boolean;
9193
}
9294

9395
export interface HelpCommandInput {
@@ -294,4 +296,5 @@ export interface AppBootstrap {
294296
initialWrapLines?: boolean;
295297
initialShowHunkHeaders?: boolean;
296298
initialShowAgentNotes?: boolean;
299+
initialCopyDecorations?: boolean;
297300
}

src/hunk-session/types.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { AgentAnnotation, CliInput, ReviewNoteSource } from "../core/types";
1+
import type { CommentTargetInput, DiffSide } from "../core/liveComments";
2+
import type { CliInput, ReviewNoteSource } from "../core/types";
23
import type { SessionBrokerClient } from "../session-broker/brokerClient";
34
import type {
45
SessionClientMessage,
@@ -9,7 +10,7 @@ import type {
910
SessionTerminalMetadata,
1011
} from "@hunk/session-broker-core";
1112

12-
export type DiffSide = "old" | "new";
13+
export type { CommentTargetInput, DiffSide, LiveComment } from "../core/liveComments";
1314

1415
export interface SessionFileSummary {
1516
id: string;
@@ -63,16 +64,6 @@ export interface HunkSessionState {
6364
export type HunkSessionRegistration = SessionRegistration<HunkSessionInfo>;
6465
export type HunkSessionSnapshot = SessionSnapshot<HunkSessionState>;
6566

66-
export interface CommentTargetInput {
67-
filePath: string;
68-
hunkIndex?: number;
69-
side?: DiffSide;
70-
line?: number;
71-
summary: string;
72-
rationale?: string;
73-
author?: string;
74-
}
75-
7667
export interface CommentToolInput extends SessionTargetInput, CommentTargetInput {
7768
reveal?: boolean;
7869
}
@@ -109,17 +100,6 @@ export interface ClearCommentsToolInput extends SessionTargetInput {
109100
filePath?: string;
110101
}
111102

112-
export interface LiveComment extends AgentAnnotation {
113-
id: string;
114-
source: "mcp";
115-
author?: string;
116-
createdAt: string;
117-
filePath: string;
118-
hunkIndex: number;
119-
side: DiffSide;
120-
line: number;
121-
}
122-
123103
export interface SessionLiveCommentSummary {
124104
commentId: string;
125105
filePath: string;

src/ui/App.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ export function App({
103103
const diffScrollRef = useRef<ScrollBoxRenderable | null>(null);
104104
const wrapToggleScrollTopRef = useRef<number | null>(null);
105105
const layoutToggleScrollTopRef = useRef<number | null>(null);
106+
const cancelCopySelectionRef = useRef<(() => void) | null>(null);
106107
const [layoutToggleRequestId, setLayoutToggleRequestId] = useState(0);
108+
const [transientNoticeText, setTransientNoticeText] = useState<string | null>(null);
107109
const [layoutMode, setLayoutMode] = useState<LayoutMode>(bootstrap.initialMode);
108110
const [themeId, setThemeId] = useState(() =>
109111
bootstrap.initialTheme === "auto"
@@ -115,6 +117,7 @@ export function App({
115117
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
116118
const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true);
117119
const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false);
120+
const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false);
118121
const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0);
119122
const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true);
120123
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
@@ -325,6 +328,35 @@ export function App({
325328
setShowLineNumbers((current) => !current);
326329
};
327330

331+
/** Toggle whether mouse selection copies review decorations or only file content. */
332+
const toggleCopyDecorations = () => {
333+
setCopyDecorations((current) => !current);
334+
};
335+
336+
// Show a short-lived status-bar message. Used to surface clipboard-copy outcomes that would
337+
// otherwise be invisible to the user (OSC52 unsupported, etc.).
338+
// Track the timer so we can clear it on unmount and avoid React state updates after unmount.
339+
const transientTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
340+
const showTransientNotice = useCallback((text: string, durationMs = 3000) => {
341+
if (transientTimerRef.current !== null) {
342+
clearTimeout(transientTimerRef.current);
343+
}
344+
setTransientNoticeText(text);
345+
transientTimerRef.current = setTimeout(() => {
346+
transientTimerRef.current = null;
347+
setTransientNoticeText((current) => (current === text ? null : current));
348+
}, durationMs);
349+
}, []);
350+
351+
// Clear any pending transient-notice timer on unmount to avoid state updates after unmount.
352+
useEffect(() => {
353+
return () => {
354+
if (transientTimerRef.current !== null) {
355+
clearTimeout(transientTimerRef.current);
356+
}
357+
};
358+
}, []);
359+
328360
/** Toggle whether diff code rows wrap instead of truncating to one terminal row. */
329361
const toggleLineWrap = () => {
330362
// Capture the pre-toggle viewport position synchronously so DiffPane can restore the same
@@ -569,11 +601,13 @@ export function App({
569601
requestQuit,
570602
selectLayoutMode,
571603
selectThemeId: setThemeId,
604+
copyDecorations,
572605
showAgentNotes,
573606
showHelp,
574607
showHunkHeaders,
575608
showLineNumbers,
576609
renderSidebar,
610+
toggleCopyDecorations,
577611
toggleAgentNotes,
578612
toggleFocusArea,
579613
toggleHelp,
@@ -587,6 +621,7 @@ export function App({
587621
[
588622
activeTheme.id,
589623
canRefreshCurrentInput,
624+
copyDecorations,
590625
focusFilter,
591626
layoutMode,
592627
moveToAnnotatedFile,
@@ -595,6 +630,7 @@ export function App({
595630
review.moveToHunk,
596631
selectLayoutMode,
597632
triggerRefreshCurrentInput,
633+
toggleCopyDecorations,
598634
showAgentNotes,
599635
showHelp,
600636
showHunkHeaders,
@@ -720,6 +756,11 @@ export function App({
720756
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
721757
const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1);
722758
const diffSeparatorWidth = Math.max(4, diffContentWidth - 2);
759+
// Mirror the App layout: bodyPadding/2 left-padding, then sidebar + divider when visible. Keep
760+
// this in lockstep with the body container's paddingLeft and the sidebar render branch below.
761+
const diffPaneScreenLeft =
762+
bodyPadding / 2 + (renderSidebar ? clampedSidebarWidth + DIVIDER_WIDTH : 0);
763+
const diffPaneScreenTop = pagerMode ? 0 : 1;
723764

724765
return (
725766
<box
@@ -758,10 +799,14 @@ export function App({
758799
position: "relative",
759800
}}
760801
onMouseDrag={updateSidebarResize}
761-
onMouseDragEnd={endSidebarResize}
802+
onMouseDragEnd={(event) => {
803+
endSidebarResize(event);
804+
cancelCopySelectionRef.current?.();
805+
}}
762806
onMouseUp={(event) => {
763807
endSidebarResize(event);
764808
closeMenu();
809+
cancelCopySelectionRef.current?.();
765810
}}
766811
>
767812
{renderSidebar ? (
@@ -793,10 +838,14 @@ export function App({
793838
) : null}
794839

795840
<DiffPane
841+
cancelCopySelectionRef={cancelCopySelectionRef}
796842
codeHorizontalOffset={codeHorizontalOffset}
843+
copyDecorations={copyDecorations}
797844
diffContentWidth={diffContentWidth}
798845
files={filteredFiles}
799846
pagerMode={pagerMode}
847+
screenLeft={diffPaneScreenLeft}
848+
screenTop={diffPaneScreenTop}
800849
headerLabelWidth={diffHeaderLabelWidth}
801850
headerStatsWidth={diffHeaderStatsWidth}
802851
layout={resolvedLayout}
@@ -829,6 +878,7 @@ export function App({
829878
onScrollCodeHorizontally={(delta) => {
830879
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
831880
}}
881+
onCopyFeedback={showTransientNotice}
832882
onSelectFile={jumpToFile}
833883
onViewportCenteredHunkChange={(fileId, hunkIndex) =>
834884
review.selectHunk(fileId, hunkIndex, { preserveViewport: true })
@@ -839,12 +889,11 @@ export function App({
839889
{!pagerMode &&
840890
(focusArea === "filter" ||
841891
Boolean(review.filter) ||
842-
Boolean(sessionNoticeText) ||
843-
Boolean(noticeText)) ? (
892+
Boolean(sessionNoticeText ?? transientNoticeText ?? noticeText)) ? (
844893
<StatusBar
845894
filter={review.filter}
846895
filterFocused={focusArea === "filter"}
847-
noticeText={sessionNoticeText ?? noticeText ?? undefined}
896+
noticeText={sessionNoticeText ?? transientNoticeText ?? noticeText ?? undefined}
848897
terminalWidth={terminal.width}
849898
theme={activeTheme}
850899
onCloseMenu={closeMenu}

src/ui/components/panes/AgentInlineNote.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotatio
88
import { fitText, padText } from "../../lib/text";
99
import type { AppTheme } from "../../themes";
1010

11-
function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) {
11+
export function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) {
1212
if (annotation.source === "user-draft") {
1313
return "Draft note";
1414
}

0 commit comments

Comments
 (0)