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
13 changes: 13 additions & 0 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,18 @@ export default function DiffPanel({
return { scopeKey: collapseScopeKey, fileKeys: next };
});
}, []);
// Find can ask again before the unfolded file reaches the viewer, so this must never fold.
const unfoldDiffFile = useCallback((fileKey: string) => {
const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current;
setCollapsedDiffFiles((current) => {
const fileKeys =
current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys;
if (!fileKeys.has(fileKey)) return current;
const next = new Set(fileKeys);
next.delete(fileKey);
return { scopeKey: collapseScopeKey, fileKeys: next };
});
}, []);

const toggleDiffFileCollapse = useCallback(() => {
setCodeViewRevision((current) => current + 1);
Expand Down Expand Up @@ -1197,6 +1209,7 @@ export default function DiffPanel({
},
}
: {})}
onRevealSearchMatch={unfoldDiffFile}
renderHeaderPrefix={(fileDiff, fileKey) => {
const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
return (
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,8 @@ function RightPanelEmptyState(props: {
const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
if (!action) return;
if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
const target = event.target;
// The composed path starts at the real target, which may sit inside a shadow root.
const target = event.composedPath()[0] ?? event.target;
if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;
event.preventDefault();
event.stopPropagation();
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/diffs/AnnotatableCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps {
fileKey: string,
collapsed: boolean,
) => ReactNode;
/** Unfold a collapsed file that holds the find match being navigated to. */
onRevealSearchMatch: (fileKey: string) => void;
}

interface DiffSelectionContext {
Expand All @@ -115,6 +117,7 @@ export function AnnotatableCodeView({
renderHeaderMetadata,
renderHeaderFilenameSuffix,
renderHeaderPrefix,
onRevealSearchMatch,
}: AnnotatableCodeViewProps) {
const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
Expand Down Expand Up @@ -262,6 +265,7 @@ export function AnnotatableCodeView({
items={items}
selectedLines={selectedLines}
onSelectedLinesChange={setSelectedLines}
onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)}
options={{
...options,
enableGutterUtility: !hasOpenComment,
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/components/diffs/codeViewSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { CodeViewItem } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
import { describe, expect, it } from "vite-plus/test";

// Find-in-diff lives in our @pierre/diffs patch and is not a package export.
const searchUrl = new URL("./components/CodeViewSearch.js", import.meta.resolve("@pierre/diffs"));
const { collectMatches } = (await import(/* @vite-ignore */ searchUrl.href)) as {
collectMatches(
items: ReadonlyArray<CodeViewItem<undefined>>,
params: { text: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean },
): Array<{
id: string;
side: string;
isContext: boolean;
lineNumber: number;
start: number;
end: number;
}>;
};

const PATCH = `diff --git a/src/a.ts b/src/a.ts
index 1111111..2222222 100644
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,4 +1,5 @@
const keep = 1;
-const oldName = 2;
+const newName = 2;
+const added = oldName;
const tail = 4;
@@ -20,3 +21,3 @@ function x() {
ctx20 name
-del21 Name
+add22 names
ctx22
diff --git a/src/b.ts b/src/b.ts
index 3333333..4444444 100644
--- a/src/b.ts
+++ b/src/b.ts
@@ -1,1 +1,1 @@
-nil
+: nil
`;

const items: CodeViewItem<undefined>[] = parsePatchFiles(PATCH)[0]!.files.map(
(fileDiff, index) => ({ id: `file-${index}`, type: "diff", fileDiff, collapsed: index === 1 }),
);

function find(
text: string,
flags: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean } = {},
) {
return collectMatches(items, {
text,
caseSensitive: flags.caseSensitive ?? false,
wholeWord: flags.wholeWord ?? false,
regex: flags.regex ?? false,
}).map(({ id, side, lineNumber, start, end }) => `${id}:${side}:${lineNumber}:${start}-${end}`);
}

describe("collectMatches", () => {
it("finds removed, added, and context lines in render order, folded files included", () => {
expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]);
expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]);
});

it("honours case, whole-word, and regex toggles", () => {
expect(find("Name", { caseSensitive: true })).toEqual([
"file-0:deletions:2:9-13",
"file-0:additions:2:9-13",
"file-0:additions:3:17-21",
"file-0:deletions:21:6-10",
]);
expect(find("name", { wholeWord: true })).toEqual([
"file-0:additions:21:6-10",
"file-0:deletions:21:6-10",
]);
expect(find("n[a-z]+e", { regex: true })).toHaveLength(6);
});

it("marks unchanged lines, which split view draws in both columns", () => {
const flags = collectMatches(items, {
text: "ctx",
caseSensitive: false,
wholeWord: false,
regex: false,
}).map(({ lineNumber, isContext }) => [lineNumber, isContext]);
expect(flags).toEqual([
[21, true],
[23, true],
]);
expect(
collectMatches(items, {
text: "oldName",
caseSensitive: false,
wholeWord: false,
regex: false,
}).map(({ isContext }) => isContext),
).toEqual([false, false]);
});

it("returns nothing for an empty query or an invalid pattern", () => {
expect(find("")).toEqual([]);
expect(find("(", { regex: true })).toEqual([]);
});
});
6 changes: 6 additions & 0 deletions apps/web/src/components/pullRequest/PullRequestCodeTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,12 @@ function PullRequestCodeTab({
renderHeaderPrefix={renderHeaderPrefix}
renderHeaderMetadata={renderHeaderMetadata}
renderAnnotation={renderAnnotation}
// Find can ask again before the unfolded file arrives, so this unfolds and never folds.
onRevealSearchMatch={(item) =>
setToggledFiles((current) =>
toggleFileDiffFoldForViewed(item.id, false, effectiveFoldOverride, current),
)
}
unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
/>
</div>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2061,6 +2061,14 @@ code {
color: var(--code-foreground);
}

/* Pierre's find panel and match highlights read these. */
.diff-render-surface {
--diffs-search-panel-bg: var(--popover);
--diffs-search-panel-fg: var(--popover-foreground);
--diffs-search-panel-accent: var(--primary);
--diffs-search-panel-font: var(--font-sans);
}

/* Pierre's file renderer owns its own surface tokens. Keep the root of a file
preview on the same themeable code surface as the diff panel. */
.diff-render-surface diffs-container,
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/lib/editableFocus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it } from "vite-plus/test";

import { isEditableFocused } from "./editableFocus";

afterEach(() => {
document.body.replaceChildren();
});

describe("isEditableFocused", () => {
it("sees a text field focused inside an open shadow root", () => {
// A key event from a shadow input is retargeted to its host, so the host is
// what page-level shortcut handlers receive as the event target.
const host = document.createElement("div");
const input = document.createElement("input");
host.attachShadow({ mode: "open" }).append(input);
document.body.append(host);

expect(isEditableFocused(host)).toBe(false);
input.focus();
expect(isEditableFocused(host)).toBe(true);
expect(isEditableFocused()).toBe(true);
});
});
6 changes: 5 additions & 1 deletion apps/web/src/lib/editableFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [
/**
* Whether a text-editing element owns the keyboard. Shortcuts that share
* their chord with native editing (mod+z) must yield when this is true.
* Focus inside an open shadow root counts: a key event's target is retargeted
* to the shadow host, so the host's focused descendant is what is typed into.
*/
export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean {
return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null;
let element = target instanceof Element ? target : null;
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
return element !== null && element.closest(EDITABLE_SELECTOR) !== null;
}
9 changes: 9 additions & 0 deletions docs/user/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”
or “Copy Number”. They copy the selected PR and leave terminal input alone.

## Find in a diff

Click into a diff in the Diff panel or a pull request's Code tab, then press
`mod+f` to search every file in it, including folded files and unchanged lines
hidden between changes. Enter and `Shift+Enter` move between matches, and a
match in a folded file opens it. Escape closes the search. This shortcut is not
configurable. A very large uncommitted diff loads its files as you scroll, and
find only searches the files loaded so far.

## iPad

With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine
Expand Down
Loading
Loading