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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,16 @@ Completion runs in the shell alone and never starts diffle, so it stays fast —
- `V`: select a block
- `v`: mark viewed
- `/`: search the current file
- `g/`: search the diff or codebase
- `g/`: search changed files
- `gf`: filter files
- `yy`: copy all comments
- `F`: open the full file
- `Ctrl+o`: go back

Press `?` in the app for the full list.

Both content searches toggle between diff hunks with context and full file contents, remembering that choice independently. Global search stays within changed files.

## GitHub reviews

Use the pull request icon to add one thread or all open threads to a pending GitHub review — a new one, or the pending review already waiting on the pull request. This requires a local, authenticated [`gh`](https://cli.github.com/).
Expand Down
12 changes: 10 additions & 2 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
ModeRequest,
ReplyCreate,
SearchScope,
SearchContent,
ServerMessage,
Side,
ThreadCreate,
Expand Down Expand Up @@ -111,11 +112,18 @@ export const api = {
/** `path` names the file a `scope: 'file'` search is confined to. */
search: (
query: string,
opts: { word?: boolean; ignoreCase?: boolean; regex?: boolean; scope?: SearchScope; path?: string } = {},
opts: {
word?: boolean;
ignoreCase?: boolean;
regex?: boolean;
scope?: SearchScope;
content?: SearchContent;
path?: string;
} = {},
) =>
json(
SearchResponseSchema,
`api/search?${q({ q: query, word: opts.word ? '1' : undefined, i: opts.ignoreCase ? '1' : undefined, re: opts.regex ? '1' : undefined, scope: opts.scope, path: opts.path })}`,
`api/search?${q({ q: query, word: opts.word ? '1' : undefined, i: opts.ignoreCase ? '1' : undefined, re: opts.regex ? '1' : undefined, scope: opts.scope, content: opts.content, path: opts.path })}`,
),
threads: (query: ThreadQuery = {}) =>
json(CommentThreadSchema.array(), `api/threads?${q({ state: query.state, path: query.path })}`),
Expand Down
2 changes: 1 addition & 1 deletion src/client/keyboard/HelpOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const COLUMNS: HelpSection[][] = [
title: 'Search',
rows: [
['/', 'search the current file, then n / N between matches'],
['g/', 'search the diff (scope button: file / diff / codebase)'],
['g/', 'search changed files (toggle: diff + context / full file)'],
['⌘/Ctrl+p or gf', 'search files by name'],
['w / b', 'focus the next / previous symbol on the line'],
['0 / $', 'focus the first / last symbol on the line'],
Expand Down
36 changes: 16 additions & 20 deletions src/client/keyboard/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
import { twMerge } from 'tailwind-merge';
import { Button } from '../ui/Button.js';
import { FileDiff, FileSearch, FolderSearch, Link2, Search, WholeWord, X } from 'lucide-react';
import { FileDiff, FileSearch, Link2, Search, WholeWord, X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import type { SearchScope } from '../../shared/protocol.js';
import { nextSearchScope } from '../model.js';
import { useStore } from '../store.js';

const SCOPE_LABEL: Record<SearchScope, string> = {
file: 'the current file',
diff: 'only the diff',
repo: 'the full codebase',
};
const SCOPE_ICON: Record<SearchScope, typeof FileSearch> = { file: FileSearch, diff: FileDiff, repo: FolderSearch };

/** Content search (/ in the current file, g/ across the diff or codebase), the references of a symbol (gA), or a word's occurrences (* / #). Enter runs a search; n / N step through matches. */
/** Content search (/ in the current file, g/ across changed files), the references of a symbol (gA), or a word's occurrences (* / #). Enter runs a search; n / N step through matches. */
export function SearchBar({ path }: { path?: string }) {
const visible = useStore((s) => {
const local = s.search.kind === 'text' && s.search.scope === 'file';
Expand Down Expand Up @@ -57,7 +48,8 @@ function SearchForm() {
</div>
);
}
const ScopeIcon = SCOPE_ICON[search.scope];
const fullFile = search.content[search.scope] === 'full';
const ScopeIcon = fullFile ? FileSearch : FileDiff;
return (
<form
className="flex items-center gap-2 border-b border-b-border bg-surface px-2.5 py-1.5"
Expand Down Expand Up @@ -113,12 +105,16 @@ function SearchForm() {
type="button"
variant="ghost"
className={twMerge(
`rounded-sm border border-transparent px-1.5 py-[2px] font-mono text-[0.6875rem] leading-[normal] text-muted ${search.scope === 'diff' ? '' : 'border-accent bg-[color-mix(in_srgb,_var(--accent)_12%,_transparent)] text-accent'}`,
`rounded-sm border border-transparent px-1.5 py-[2px] font-mono text-[0.6875rem] leading-[normal] text-muted ${!fullFile ? '' : 'border-accent bg-[color-mix(in_srgb,_var(--accent)_12%,_transparent)] text-accent'}`,
)}
aria-label={`Searching ${SCOPE_LABEL[search.scope]}`}
data-scope={search.scope}
onClick={() => setSearchOptions({ scope: nextSearchScope(search.scope) })}
title={`Searching ${SCOPE_LABEL[search.scope]}; click to search ${SCOPE_LABEL[nextSearchScope(search.scope)]}`}
aria-label="Search full file"
aria-pressed={fullFile}
onClick={() => setSearchOptions({ content: fullFile ? 'diff' : 'full' })}
title={
fullFile
? 'Searching full file; click to search diff + context'
: 'Searching diff + context; click to search full file'
}
>
<ScopeIcon size="0.75rem" />
</Button>
Expand All @@ -128,9 +124,9 @@ function SearchForm() {
: n === 0 && search.query
? search.scope === 'file'
? 'none in file'
: search.scope === 'diff'
? 'none in diff'
: 'no matches'
: fullFile
? 'no matches'
: 'none in diff'
: n
? `${search.index + 1} / ${n}${search.truncated ? '+' : ''}`
: ''}
Expand Down
4 changes: 2 additions & 2 deletions src/client/keyboard/useKeymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
import { copyText } from '../clipboard.js';
import { api } from '../api.js';
import { clearWordFocus, moveWord, moveWordToEdge } from '../lsp/wordNav.js';
import { currentPath, widenSearchScope } from '../model.js';
import { currentPath } from '../model.js';
import { remPx } from '../scale.js';
import { useStore, type ReviewState } from '../store.js';
import { nextTheme } from '../theme.js';
Expand Down Expand Up @@ -63,7 +63,7 @@ const KEYMAP: Record<string, Action> = {
yy: () => void copyComments(),
Y: () => void copyComments(),
'/': (s) => s.openSearch('file'),
'g/': (s) => s.openSearch(widenSearchScope(s.search.scope)),
'g/': (s) => s.openSearch('diff'),
gf: () => focusFileSearch(),
gd: (s) => s.goToDefinition(),
gy: (s) => s.goToTypeDefinition(),
Expand Down
13 changes: 0 additions & 13 deletions src/client/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type {
CommentThread,
LspSymbol,
ModeRequest,
SearchScope,
Snapshot,
UserConfig,
ViewedState,
Expand Down Expand Up @@ -110,18 +109,6 @@ export function currentPath(state: Pick<ReviewState, 'snapshot' | 'activePath' |
return state.activePath ?? state.fileView?.path ?? (state.snapshot && orderedPaths(state.snapshot)[0]) ?? null;
}

/** The search scopes in widening order; the search bar's scope button cycles through them. */
export const SEARCH_SCOPES: readonly SearchScope[] = ['file', 'diff', 'repo'];

export function nextSearchScope(scope: SearchScope): SearchScope {
return SEARCH_SCOPES[(SEARCH_SCOPES.indexOf(scope) + 1) % SEARCH_SCOPES.length]!;
}

/** `g/` widens a file-scoped search to the diff but keeps a codebase-wide choice. */
export function widenSearchScope(scope: SearchScope): Exclude<SearchScope, 'file'> {
return scope === 'file' ? 'diff' : scope;
}

/**
* The order `@pierre/trees` lists paths in (its path store's default sort), so the review pane and
* the tree agree file for file: at the first segment where two paths diverge, folders before
Expand Down
41 changes: 28 additions & 13 deletions src/client/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ModeRequest,
type SearchMatch,
type SearchScope,
type SearchContent,
type Side,
type Snapshot,
type UserConfig,
Expand Down Expand Up @@ -94,8 +95,10 @@ export interface SearchState {
/** Text-search options; `*` / `#` ignore them (whole word, case-sensitive, whole repository). */
ignoreCase: boolean;
regex: boolean;
/** 'file' searches the file the reader is in; 'diff' only the changed files; 'repo' the whole codebase. */
scope: SearchScope;
/** Local search stays pinned to one file; global search covers changed files. */
scope: Exclude<SearchScope, 'repo'>;
/** Each search location remembers its own content mode. */
content: Record<Exclude<SearchScope, 'repo'>, SearchContent>;
/** The file owning the local search bar and its results, pinned when search opens. */
path: string | null;
/** Bumped by `g/` so the box takes focus again after `n` / `N` blurred it. */
Expand Down Expand Up @@ -236,15 +239,15 @@ export interface ReviewState {
/** Toast for a failed fire-and-forget action: `${what} failed: <message>`. */
report(what: string, e: unknown): void;
search: SearchState;
/** Open the text search box; `scope` replaces the remembered scope (`/` → file, `g/` → widened). */
openSearch(scope?: SearchScope): void;
/** Open the text search box; `scope` replaces the remembered scope (`/` → file, `g/` → diff). */
openSearch(scope?: SearchState['scope']): void;
setSearchInput(value: string): void;
blurSearchInput(): void;
typeSearchInput(key: string): void;
closeSearch(): void;
runSearch(query: string): Promise<void>;
/** Flip a text-search option and rerun the current query. */
setSearchOptions(opts: Partial<Pick<SearchState, 'ignoreCase' | 'regex' | 'scope'>>): void;
setSearchOptions(opts: Partial<Pick<SearchState, 'ignoreCase' | 'regex'>> & { content?: SearchContent }): void;
moveMatch(delta: 1 | -1): void;
/** `*` / `#`: whole-word search for the focused word, landing on the next occurrence in `delta`'s direction. */
searchWord(delta: 1 | -1): Promise<void>;
Expand Down Expand Up @@ -1304,6 +1307,7 @@ export const useStore = create<ReviewState>((set, get) => {
ignoreCase: true,
regex: false,
scope: 'diff',
content: { file: 'diff', diff: 'diff' },
path: null,
focusNonce: 0,
input: '',
Expand All @@ -1315,16 +1319,22 @@ export const useStore = create<ReviewState>((set, get) => {
truncated: false,
},
openSearch(scope) {
searchSeq.start();
set((s) => {
const nextScope = scope ?? s.search.scope;
const path = nextScope === 'file' ? currentPath(s) : null;
const sameSearch = s.search.kind === 'text' && s.search.scope === nextScope && s.search.path === path;
return {
search: {
...s.search,
open: true,
kind: 'text',
direction: 1,
scope: nextScope,
matches: sameSearch ? s.search.matches : [],
index: sameSearch ? s.search.index : -1,
truncated: sameSearch && s.search.truncated,
loading: false,
input: s.search.open && s.search.kind === 'text' ? s.search.input : s.search.query,
editing: true,
path,
Expand Down Expand Up @@ -1370,11 +1380,12 @@ export const useStore = create<ReviewState>((set, get) => {
}));
},
setSearchOptions(opts) {
const { content, ...options } = opts;
set((s) => ({
search: {
...s.search,
...opts,
path: opts.scope === undefined ? s.search.path : opts.scope === 'file' ? currentPath(s) : null,
...options,
content: content ? { ...s.search.content, [s.search.scope]: content } : s.search.content,
},
}));
const { query, kind } = get().search;
Expand Down Expand Up @@ -1405,12 +1416,18 @@ export const useStore = create<ReviewState>((set, get) => {
search: { ...s.search, kind: 'text', direction: 1, query, input: query, editing: false, path, loading: true },
}));
try {
const { ignoreCase, regex, scope } = get().search;
const { ignoreCase, regex, scope, content } = get().search;
if (scope === 'file' && !path) {
set((s) => ({ search: { ...s.search, matches: [], index: -1, loading: false } }));
return get().flash('No file to search in');
}
const res = await api.search(query, { ignoreCase, regex, scope, ...(path ? { path } : {}) });
const res = await api.search(query, {
ignoreCase,
regex,
scope,
content: content[scope],
...(path ? { path } : {}),
});
if (!searchOwned(g, t)) return;
set((s) => ({
search: { ...s.search, matches: res.matches, truncated: res.truncated, index: -1, loading: false },
Expand All @@ -1420,9 +1437,7 @@ export const useStore = create<ReviewState>((set, get) => {
get().flash(
scope === 'file'
? `No matches for “${query}” in ${path}`
: scope === 'diff'
? `No matches for “${query}” in the diff`
: `No matches for “${query}”`,
: `No matches for “${query}” in ${content[scope] === 'diff' ? 'the diff + context' : 'changed files'}`,
);
} catch (e) {
if (!searchOwned(g, t)) return;
Expand Down Expand Up @@ -1459,7 +1474,7 @@ export const useStore = create<ReviewState>((set, get) => {
},
}));
try {
const res = await api.search(word, { word: true, scope: 'repo' });
const res = await api.search(word, { word: true, scope: 'repo', content: 'full' });
if (!searchOwned(g, t)) return;
const matches = res.matches;
// Start from the occurrence just past the cursor in the requested direction, wrapping like vim.
Expand Down
Loading
Loading