[Refactor] Centralizes autocompletion logic within useCompletion (#4740)

This commit is contained in:
Sandy Tao
2025-07-24 21:41:35 -07:00
committed by GitHub
parent 273e74c09d
commit 1d7eb0d250
5 changed files with 786 additions and 533 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
@@ -22,6 +22,9 @@ import {
Suggestion,
} from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { TextBuffer } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
export interface UseCompletionReturn {
suggestions: Suggestion[];
@@ -35,12 +38,12 @@ export interface UseCompletionReturn {
resetCompletionState: () => void;
navigateUp: () => void;
navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void;
}
export function useCompletion(
query: string,
buffer: TextBuffer,
cwd: string,
isActive: boolean,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
config?: Config,
@@ -122,13 +125,45 @@ export function useCompletion(
});
}, [suggestions.length]);
// Check if cursor is after @ or / without unescaped spaces
const isActive = useMemo(() => {
if (isSlashCommand(buffer.text.trim())) {
return true;
}
// For other completions like '@', we search backwards from the cursor.
const [row, col] = buffer.cursor;
const currentLine = buffer.lines[row] || '';
const codePoints = toCodePoints(currentLine);
for (let i = col - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
// Check for unescaped spaces.
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
return false; // Inactive on unescaped space.
}
} else if (char === '@') {
// Active if we find an '@' before any unescaped space.
return true;
}
}
return false;
}, [buffer.text, buffer.cursor, buffer.lines]);
useEffect(() => {
if (!isActive) {
resetCompletionState();
return;
}
const trimmedQuery = query.trimStart();
const trimmedQuery = buffer.text.trimStart();
if (trimmedQuery.startsWith('/')) {
// Always reset perfect match at the beginning of processing.
@@ -275,13 +310,13 @@ export function useCompletion(
}
// Handle At Command Completion
const atIndex = query.lastIndexOf('@');
const atIndex = buffer.text.lastIndexOf('@');
if (atIndex === -1) {
resetCompletionState();
return;
}
const partialPath = query.substring(atIndex + 1);
const partialPath = buffer.text.substring(atIndex + 1);
const lastSlashIndex = partialPath.lastIndexOf('/');
const baseDirRelative =
lastSlashIndex === -1
@@ -545,7 +580,7 @@ export function useCompletion(
clearTimeout(debounceTimeout);
};
}, [
query,
buffer.text,
cwd,
isActive,
resetCompletionState,
@@ -554,6 +589,77 @@ export function useCompletion(
config,
]);
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
return;
}
const query = buffer.text;
const suggestion = suggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const hasTrailingSpace = query.endsWith(' ');
const parts = query
.trimStart()
.substring(1)
.split(/\s+/)
.filter(Boolean);
let isParentPath = false;
// If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command.
if (!hasTrailingSpace) {
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
if (i === parts.length - 1 && found.subCommands) {
isParentPath = true;
}
currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else {
// Path is invalid, so it can't be a parent path.
currentLevel = undefined;
break;
}
}
}
// Determine the base path of the command.
// - If there's a trailing space, the whole command is the base.
// - If it's a known parent path, the whole command is the base.
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
const newValue = `/${[...basePath, suggestion].join(' ')}`;
buffer.setText(newValue);
} else {
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) return;
const pathPart = query.substring(atIndex + 1);
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
let autoCompleteStartIndex = atIndex + 1;
if (lastSlashIndexInPath !== -1) {
autoCompleteStartIndex += lastSlashIndexInPath + 1;
}
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
suggestion,
);
}
resetCompletionState();
},
[resetCompletionState, buffer, suggestions, slashCommands],
);
return {
suggestions,
activeSuggestionIndex,
@@ -566,5 +672,6 @@ export function useCompletion(
resetCompletionState,
navigateUp,
navigateDown,
handleAutocomplete,
};
}