Skip to content

Commit 8934eb4

Browse files
committed
feat: add Tab/Shift+Tab to cycle through matches in content search
- Tab cycles to next match, Shift+Tab to previous - Wraps around at ends - Shows match indicator [1/5] in preview header - Help line updates to show Tab behavior per mode - Ctrl+E still works for edit in all modes
1 parent 55f8a95 commit 8934eb4

File tree

2 files changed

+63
-17
lines changed

2 files changed

+63
-17
lines changed

.beads/beads.db

0 Bytes
Binary file not shown.

src/file-selector.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ function formatPreviewContent(
235235
scrollOffset: number,
236236
previewWidth: number,
237237
searchTerm?: string
238-
): { lines: string[]; totalLines: number; firstMatchDisplayLine?: number } {
238+
): { lines: string[]; totalLines: number; matchDisplayLines: number[] } {
239239
const allLines = content.split("\n");
240240
const totalLines = allLines.length;
241241

@@ -245,17 +245,17 @@ function formatPreviewContent(
245245

246246
// Build display lines with wrapping
247247
const displayLines: string[] = [];
248-
let firstMatchDisplayLine: number | undefined;
248+
const matchDisplayLines: number[] = [];
249249
const lowerSearch = searchTerm?.toLowerCase();
250250

251251
for (let lineIdx = 0; lineIdx < allLines.length; lineIdx++) {
252252
const line = allLines[lineIdx]!;
253253
const lineNum = lineIdx + 1;
254254
const lineNumStr = String(lineNum).padStart(lineNumWidth, " ");
255255

256-
// Track display line of first match (before wrapping adds more lines)
257-
if (firstMatchDisplayLine === undefined && lowerSearch && line.toLowerCase().includes(lowerSearch)) {
258-
firstMatchDisplayLine = displayLines.length;
256+
// Track display line of each match (before wrapping adds more lines)
257+
if (lowerSearch && line.toLowerCase().includes(lowerSearch)) {
258+
matchDisplayLines.push(displayLines.length);
259259
}
260260

261261
// Apply syntax highlighting
@@ -287,7 +287,7 @@ function formatPreviewContent(
287287
visibleLines.push("");
288288
}
289289

290-
return { lines: visibleLines, totalLines: displayLines.length, firstMatchDisplayLine };
290+
return { lines: visibleLines, totalLines: displayLines.length, matchDisplayLines };
291291
}
292292

293293
// Cache for lowercase file content (avoid repeated toLowerCase calls)
@@ -434,6 +434,8 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
434434
mode: "name",
435435
});
436436
const { filter, mode: searchMode } = searchState;
437+
// Current match index for Tab/Shift+Tab cycling in content mode
438+
const [matchIndex, setMatchIndex] = useState(0);
437439

438440
// Filter and sort files by match score (best matches first)
439441
const scoreFn = searchMode === "content" ? getContentMatchScore : getMatchScore;
@@ -480,8 +482,29 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
480482
return;
481483
}
482484

483-
// Tab or Ctrl+E to edit the file in $EDITOR
484-
if (key.name === "tab" || (key.ctrl && key.name === "e")) {
485+
// Tab: In content mode, cycle to next match. Otherwise, edit file.
486+
if (key.name === "tab") {
487+
if (searchMode === "content" && filter) {
488+
// Cycle to next match (will wrap in render based on match count)
489+
setMatchIndex(matchIndex + 1);
490+
setPreviewScroll(0); // Reset scroll so auto-scroll takes effect
491+
} else if (currentFile) {
492+
done({ action: "edit", path: currentFile.path });
493+
}
494+
return;
495+
}
496+
497+
// Shift+Tab: In content mode, cycle to previous match
498+
if (extKey.shift && extKey.sequence === "\x1b[Z") {
499+
if (searchMode === "content" && filter) {
500+
setMatchIndex(matchIndex - 1);
501+
setPreviewScroll(0);
502+
}
503+
return;
504+
}
505+
506+
// Ctrl+E to edit the file in $EDITOR (works in all modes)
507+
if (key.ctrl && key.name === "e") {
485508
if (currentFile) {
486509
done({ action: "edit", path: currentFile.path });
487510
}
@@ -522,13 +545,15 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
522545
setSearchState({ ...searchState, filter: filter.slice(0, -1) });
523546
setCursor(0);
524547
setPreviewScroll(0);
548+
setMatchIndex(0);
525549
return;
526550
}
527551

528552
// Escape: clear filter and exit content mode (single atomic update)
529553
if (key.name === "escape") {
530554
if (searchMode === "content" || filter) {
531555
setSearchState({ filter: "", mode: "name" });
556+
setMatchIndex(0);
532557
if (filter) {
533558
setCursor(0);
534559
setPreviewScroll(0);
@@ -540,6 +565,7 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
540565
// "/" to toggle content search mode
541566
if (extKey.sequence === "/" && !filter) {
542567
setSearchState({ ...searchState, mode: searchMode === "content" ? "name" : "content" });
568+
setMatchIndex(0);
543569
return;
544570
}
545571

@@ -550,6 +576,7 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
550576
setSearchState({ ...searchState, filter: filter + char });
551577
setCursor(0);
552578
setPreviewScroll(0);
579+
setMatchIndex(0); // Reset to first match on filter change
553580
}
554581
}
555582
});
@@ -627,20 +654,26 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
627654
let previewHeader = "";
628655
let previewFooter = "";
629656
let totalLines = 0;
657+
let matchCount = 0;
658+
let currentMatchIdx = 0;
630659

631660
if (currentFile) {
632661
const content = readFileContentSync(currentFile.path);
633662
const searchTerm = searchMode === "content" && filter ? filter : undefined;
634663
const previewContentHeight = contentHeight - 2; // Leave room for header and footer
635664

636-
// Calculate effective scroll - auto-scroll to first match in content mode
665+
// Calculate effective scroll - auto-scroll to current match in content mode
637666
let effectiveScroll = previewScroll;
638667
if (searchTerm && previewScroll === 0) {
639-
// First pass: find first match display line
668+
// First pass: get all match positions
640669
const firstPass = formatPreviewContent(content, previewContentHeight, 0, previewWidth, searchTerm);
641-
if (firstPass.firstMatchDisplayLine !== undefined) {
670+
matchCount = firstPass.matchDisplayLines.length;
671+
if (matchCount > 0) {
672+
// Wrap matchIndex to valid range
673+
currentMatchIdx = ((matchIndex % matchCount) + matchCount) % matchCount;
674+
const targetLine = firstPass.matchDisplayLines[currentMatchIdx]!;
642675
// Scroll to show match with some context above (3 lines)
643-
effectiveScroll = Math.max(0, firstPass.firstMatchDisplayLine - 3);
676+
effectiveScroll = Math.max(0, targetLine - 3);
644677
}
645678
}
646679

@@ -653,10 +686,19 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
653686
);
654687
previewLines = formatted.lines;
655688
totalLines = formatted.totalLines;
689+
// Update match count from final pass (in case first pass was skipped)
690+
if (searchTerm && matchCount === 0) {
691+
matchCount = formatted.matchDisplayLines.length;
692+
}
656693

657-
// Header: shortened path
694+
// Header: shortened path + match indicator in content mode
658695
const shortPath = shortenPath(currentFile.path);
659-
previewHeader = `\x1b[1m\x1b[34m${shortPath}\x1b[0m`;
696+
const matchIndicator = searchTerm && matchCount > 0
697+
? ` \x1b[33m[${currentMatchIdx + 1}/${matchCount}]\x1b[0m`
698+
: searchTerm && matchCount === 0
699+
? ` \x1b[90m[no matches]\x1b[0m`
700+
: "";
701+
previewHeader = `\x1b[1m\x1b[34m${shortPath}\x1b[0m${matchIndicator}`;
660702

661703
// Footer: scroll position
662704
const scrollPct =
@@ -681,8 +723,8 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
681723
: searchMode === "content"
682724
? `${modeIndicator}\x1b[90mType to search file contents...\x1b[0m`
683725
: `\x1b[90mType to filter...\x1b[0m`;
684-
const matchCount = `\x1b[90m(${filteredFiles.length}/${files.length})\x1b[0m`;
685-
outputLines.push(`${prefix} ${config.message} ${matchCount} ${filterDisplay}`);
726+
const fileCountDisplay = `\x1b[90m(${filteredFiles.length}/${files.length})\x1b[0m`;
727+
outputLines.push(`${prefix} ${config.message} ${fileCountDisplay} ${filterDisplay}`);
686728
outputLines.push("");
687729

688730
for (let i = 0; i < contentHeight; i++) {
@@ -704,8 +746,12 @@ export const fileSelector = createPrompt<FileSelectorResult, FileSelectorConfig>
704746
// Help line with styled keys (inverse video for keys)
705747
const k = (t: string) => `\x1b[7m ${t} \x1b[27m`;
706748
outputLines.push("");
749+
// Show different Tab hint in content mode (cycles matches vs edit)
750+
const tabHint = searchMode === "content" && filter
751+
? `${k("Tab")} Next ${k("S-Tab")} Prev`
752+
: `${k("Tab")} Edit`;
707753
outputLines.push(
708-
`${k("↑↓")} Nav ${k("Enter")} Run ${k("^R")} Dry ${k("Tab")} Edit ${k("/")} Content ${k("Esc")} Clear`
754+
`${k("↑↓")} Nav ${k("Enter")} Run ${k("^R")} Dry ${tabHint} ${k("/")} Content ${k("Esc")} Clear`
709755
);
710756

711757
return outputLines.join("\n");

0 commit comments

Comments
 (0)