704 lines
22 KiB
TypeScript
704 lines
22 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { glob } from 'glob';
|
|
import {
|
|
isNodeError,
|
|
escapePath,
|
|
unescapePath,
|
|
getErrorMessage,
|
|
Config,
|
|
FileDiscoveryService,
|
|
DEFAULT_FILE_FILTERING_OPTIONS,
|
|
} from '@google/gemini-cli-core';
|
|
import {
|
|
MAX_SUGGESTIONS_TO_SHOW,
|
|
Suggestion,
|
|
} from '../components/SuggestionsDisplay.js';
|
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
|
import {
|
|
logicalPosToOffset,
|
|
TextBuffer,
|
|
} from '../components/shared/text-buffer.js';
|
|
import { isSlashCommand } from '../utils/commandUtils.js';
|
|
import { toCodePoints } from '../utils/textUtils.js';
|
|
|
|
export interface UseCompletionReturn {
|
|
suggestions: Suggestion[];
|
|
activeSuggestionIndex: number;
|
|
visibleStartIndex: number;
|
|
showSuggestions: boolean;
|
|
isLoadingSuggestions: boolean;
|
|
isPerfectMatch: boolean;
|
|
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
|
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
|
resetCompletionState: () => void;
|
|
navigateUp: () => void;
|
|
navigateDown: () => void;
|
|
handleAutocomplete: (indexToUse: number) => void;
|
|
}
|
|
|
|
export function useCompletion(
|
|
buffer: TextBuffer,
|
|
dirs: readonly string[],
|
|
cwd: string,
|
|
slashCommands: readonly SlashCommand[],
|
|
commandContext: CommandContext,
|
|
config?: Config,
|
|
): UseCompletionReturn {
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
|
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
|
useState<number>(-1);
|
|
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0);
|
|
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
|
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
|
useState<boolean>(false);
|
|
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
|
const completionStart = useRef(-1);
|
|
const completionEnd = useRef(-1);
|
|
|
|
const cursorRow = buffer.cursor[0];
|
|
const cursorCol = buffer.cursor[1];
|
|
|
|
const resetCompletionState = useCallback(() => {
|
|
setSuggestions([]);
|
|
setActiveSuggestionIndex(-1);
|
|
setVisibleStartIndex(0);
|
|
setShowSuggestions(false);
|
|
setIsLoadingSuggestions(false);
|
|
setIsPerfectMatch(false);
|
|
}, []);
|
|
|
|
const navigateUp = useCallback(() => {
|
|
if (suggestions.length === 0) return;
|
|
|
|
setActiveSuggestionIndex((prevActiveIndex) => {
|
|
// Calculate new active index, handling wrap-around
|
|
const newActiveIndex =
|
|
prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1;
|
|
|
|
// Adjust scroll position based on the new active index
|
|
setVisibleStartIndex((prevVisibleStart) => {
|
|
// Case 1: Wrapped around to the last item
|
|
if (
|
|
newActiveIndex === suggestions.length - 1 &&
|
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
|
) {
|
|
return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW);
|
|
}
|
|
// Case 2: Scrolled above the current visible window
|
|
if (newActiveIndex < prevVisibleStart) {
|
|
return newActiveIndex;
|
|
}
|
|
// Otherwise, keep the current scroll position
|
|
return prevVisibleStart;
|
|
});
|
|
|
|
return newActiveIndex;
|
|
});
|
|
}, [suggestions.length]);
|
|
|
|
const navigateDown = useCallback(() => {
|
|
if (suggestions.length === 0) return;
|
|
|
|
setActiveSuggestionIndex((prevActiveIndex) => {
|
|
// Calculate new active index, handling wrap-around
|
|
const newActiveIndex =
|
|
prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1;
|
|
|
|
// Adjust scroll position based on the new active index
|
|
setVisibleStartIndex((prevVisibleStart) => {
|
|
// Case 1: Wrapped around to the first item
|
|
if (
|
|
newActiveIndex === 0 &&
|
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
|
) {
|
|
return 0;
|
|
}
|
|
// Case 2: Scrolled below the current visible window
|
|
const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW;
|
|
if (newActiveIndex >= visibleEndIndex) {
|
|
return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1;
|
|
}
|
|
// Otherwise, keep the current scroll position
|
|
return prevVisibleStart;
|
|
});
|
|
|
|
return newActiveIndex;
|
|
});
|
|
}, [suggestions.length]);
|
|
|
|
// Check if cursor is after @ or / without unescaped spaces
|
|
const commandIndex = useMemo(() => {
|
|
const currentLine = buffer.lines[cursorRow] || '';
|
|
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
|
return currentLine.indexOf('/');
|
|
}
|
|
|
|
// For other completions like '@', we search backwards from the cursor.
|
|
|
|
const codePoints = toCodePoints(currentLine);
|
|
for (let i = cursorCol - 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 -1; // Inactive on unescaped space.
|
|
}
|
|
} else if (char === '@') {
|
|
// Active if we find an '@' before any unescaped space.
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}, [cursorRow, cursorCol, buffer.lines]);
|
|
|
|
useEffect(() => {
|
|
if (commandIndex === -1) {
|
|
resetCompletionState();
|
|
return;
|
|
}
|
|
|
|
const currentLine = buffer.lines[cursorRow] || '';
|
|
const codePoints = toCodePoints(currentLine);
|
|
|
|
if (codePoints[commandIndex] === '/') {
|
|
// Always reset perfect match at the beginning of processing.
|
|
setIsPerfectMatch(false);
|
|
|
|
const fullPath = currentLine.substring(commandIndex + 1);
|
|
const hasTrailingSpace = currentLine.endsWith(' ');
|
|
|
|
// Get all non-empty parts of the command.
|
|
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
|
|
|
let commandPathParts = rawParts;
|
|
let partial = '';
|
|
|
|
// If there's no trailing space, the last part is potentially a partial segment.
|
|
// We tentatively separate it.
|
|
if (!hasTrailingSpace && rawParts.length > 0) {
|
|
partial = rawParts[rawParts.length - 1];
|
|
commandPathParts = rawParts.slice(0, -1);
|
|
}
|
|
|
|
// Traverse the Command Tree using the tentative completed path
|
|
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
|
let leafCommand: SlashCommand | null = null;
|
|
|
|
for (const part of commandPathParts) {
|
|
if (!currentLevel) {
|
|
leafCommand = null;
|
|
currentLevel = [];
|
|
break;
|
|
}
|
|
const found: SlashCommand | undefined = currentLevel.find(
|
|
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
|
);
|
|
if (found) {
|
|
leafCommand = found;
|
|
currentLevel = found.subCommands as
|
|
| readonly SlashCommand[]
|
|
| undefined;
|
|
} else {
|
|
leafCommand = null;
|
|
currentLevel = [];
|
|
break;
|
|
}
|
|
}
|
|
|
|
let exactMatchAsParent: SlashCommand | undefined;
|
|
// Handle the Ambiguous Case
|
|
if (!hasTrailingSpace && currentLevel) {
|
|
exactMatchAsParent = currentLevel.find(
|
|
(cmd) =>
|
|
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
|
cmd.subCommands,
|
|
);
|
|
|
|
if (exactMatchAsParent) {
|
|
// It's a perfect match for a parent command. Override our initial guess.
|
|
// Treat it as a completed command path.
|
|
leafCommand = exactMatchAsParent;
|
|
currentLevel = exactMatchAsParent.subCommands;
|
|
partial = ''; // We now want to suggest ALL of its sub-commands.
|
|
}
|
|
}
|
|
|
|
// Check for perfect, executable match
|
|
if (!hasTrailingSpace) {
|
|
if (leafCommand && partial === '' && leafCommand.action) {
|
|
// Case: /command<enter> - command has action, no sub-commands were suggested
|
|
setIsPerfectMatch(true);
|
|
} else if (currentLevel) {
|
|
// Case: /command subcommand<enter>
|
|
const perfectMatch = currentLevel.find(
|
|
(cmd) =>
|
|
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
|
cmd.action,
|
|
);
|
|
if (perfectMatch) {
|
|
setIsPerfectMatch(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
const depth = commandPathParts.length;
|
|
const isArgumentCompletion =
|
|
leafCommand?.completion &&
|
|
(hasTrailingSpace ||
|
|
(rawParts.length > depth && depth > 0 && partial !== ''));
|
|
|
|
// Set completion range
|
|
if (hasTrailingSpace || exactMatchAsParent) {
|
|
completionStart.current = currentLine.length;
|
|
completionEnd.current = currentLine.length;
|
|
} else if (partial) {
|
|
if (isArgumentCompletion) {
|
|
const commandSoFar = `/${commandPathParts.join(' ')}`;
|
|
const argStartIndex =
|
|
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
|
|
completionStart.current = argStartIndex;
|
|
} else {
|
|
completionStart.current = currentLine.length - partial.length;
|
|
}
|
|
completionEnd.current = currentLine.length;
|
|
} else {
|
|
// e.g. /
|
|
completionStart.current = commandIndex + 1;
|
|
completionEnd.current = currentLine.length;
|
|
}
|
|
|
|
// Provide Suggestions based on the now-corrected context
|
|
if (isArgumentCompletion) {
|
|
const fetchAndSetSuggestions = async () => {
|
|
setIsLoadingSuggestions(true);
|
|
const argString = rawParts.slice(depth).join(' ');
|
|
const results =
|
|
(await leafCommand!.completion!(commandContext, argString)) || [];
|
|
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
|
setSuggestions(finalSuggestions);
|
|
setShowSuggestions(finalSuggestions.length > 0);
|
|
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
|
setIsLoadingSuggestions(false);
|
|
};
|
|
fetchAndSetSuggestions();
|
|
return;
|
|
}
|
|
|
|
// Command/Sub-command Completion
|
|
const commandsToSearch = currentLevel || [];
|
|
if (commandsToSearch.length > 0) {
|
|
let potentialSuggestions = commandsToSearch.filter(
|
|
(cmd) =>
|
|
cmd.description &&
|
|
(cmd.name.startsWith(partial) ||
|
|
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
|
);
|
|
|
|
// If a user's input is an exact match and it is a leaf command,
|
|
// enter should submit immediately.
|
|
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
|
const perfectMatch = potentialSuggestions.find(
|
|
(s) => s.name === partial || s.altNames?.includes(partial),
|
|
);
|
|
if (perfectMatch && perfectMatch.action) {
|
|
potentialSuggestions = [];
|
|
}
|
|
}
|
|
|
|
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
|
label: cmd.name,
|
|
value: cmd.name,
|
|
description: cmd.description,
|
|
}));
|
|
|
|
setSuggestions(finalSuggestions);
|
|
setShowSuggestions(finalSuggestions.length > 0);
|
|
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
|
setIsLoadingSuggestions(false);
|
|
return;
|
|
}
|
|
|
|
// If we fall through, no suggestions are available.
|
|
resetCompletionState();
|
|
return;
|
|
}
|
|
|
|
// Handle At Command Completion
|
|
completionEnd.current = codePoints.length;
|
|
for (let i = cursorCol; i < codePoints.length; i++) {
|
|
if (codePoints[i] === ' ') {
|
|
let backslashCount = 0;
|
|
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
|
backslashCount++;
|
|
}
|
|
|
|
if (backslashCount % 2 === 0) {
|
|
completionEnd.current = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const pathStart = commandIndex + 1;
|
|
const partialPath = currentLine.substring(pathStart, completionEnd.current);
|
|
const lastSlashIndex = partialPath.lastIndexOf('/');
|
|
completionStart.current =
|
|
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
|
|
const baseDirRelative =
|
|
lastSlashIndex === -1
|
|
? '.'
|
|
: partialPath.substring(0, lastSlashIndex + 1);
|
|
const prefix = unescapePath(
|
|
lastSlashIndex === -1
|
|
? partialPath
|
|
: partialPath.substring(lastSlashIndex + 1),
|
|
);
|
|
|
|
let isMounted = true;
|
|
|
|
const findFilesRecursively = async (
|
|
startDir: string,
|
|
searchPrefix: string,
|
|
fileDiscovery: FileDiscoveryService | null,
|
|
filterOptions: {
|
|
respectGitIgnore?: boolean;
|
|
respectGeminiIgnore?: boolean;
|
|
},
|
|
currentRelativePath = '',
|
|
depth = 0,
|
|
maxDepth = 10, // Limit recursion depth
|
|
maxResults = 50, // Limit number of results
|
|
): Promise<Suggestion[]> => {
|
|
if (depth > maxDepth) {
|
|
return [];
|
|
}
|
|
|
|
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
|
let foundSuggestions: Suggestion[] = [];
|
|
try {
|
|
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (foundSuggestions.length >= maxResults) break;
|
|
|
|
const entryPathRelative = path.join(currentRelativePath, entry.name);
|
|
const entryPathFromRoot = path.relative(
|
|
startDir,
|
|
path.join(startDir, entry.name),
|
|
);
|
|
|
|
// Conditionally ignore dotfiles
|
|
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
|
|
continue;
|
|
}
|
|
|
|
// Check if this entry should be ignored by filtering options
|
|
if (
|
|
fileDiscovery &&
|
|
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
|
|
foundSuggestions.push({
|
|
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
|
value: escapePath(
|
|
entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
|
),
|
|
});
|
|
}
|
|
if (
|
|
entry.isDirectory() &&
|
|
entry.name !== 'node_modules' &&
|
|
!entry.name.startsWith('.')
|
|
) {
|
|
if (foundSuggestions.length < maxResults) {
|
|
foundSuggestions = foundSuggestions.concat(
|
|
await findFilesRecursively(
|
|
path.join(startDir, entry.name),
|
|
searchPrefix, // Pass original searchPrefix for recursive calls
|
|
fileDiscovery,
|
|
filterOptions,
|
|
entryPathRelative,
|
|
depth + 1,
|
|
maxDepth,
|
|
maxResults - foundSuggestions.length,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
// Ignore errors like permission denied or ENOENT during recursive search
|
|
}
|
|
return foundSuggestions.slice(0, maxResults);
|
|
};
|
|
|
|
const findFilesWithGlob = async (
|
|
searchPrefix: string,
|
|
fileDiscoveryService: FileDiscoveryService,
|
|
filterOptions: {
|
|
respectGitIgnore?: boolean;
|
|
respectGeminiIgnore?: boolean;
|
|
},
|
|
searchDir: string,
|
|
maxResults = 50,
|
|
): Promise<Suggestion[]> => {
|
|
const globPattern = `**/${searchPrefix}*`;
|
|
const files = await glob(globPattern, {
|
|
cwd: searchDir,
|
|
dot: searchPrefix.startsWith('.'),
|
|
nocase: true,
|
|
});
|
|
|
|
const suggestions: Suggestion[] = files
|
|
.filter((file) => {
|
|
if (fileDiscoveryService) {
|
|
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
|
|
}
|
|
return true;
|
|
})
|
|
.map((file: string) => {
|
|
const absolutePath = path.resolve(searchDir, file);
|
|
const label = path.relative(cwd, absolutePath);
|
|
return {
|
|
label,
|
|
value: escapePath(label),
|
|
};
|
|
})
|
|
.slice(0, maxResults);
|
|
|
|
return suggestions;
|
|
};
|
|
|
|
const fetchSuggestions = async () => {
|
|
setIsLoadingSuggestions(true);
|
|
let fetchedSuggestions: Suggestion[] = [];
|
|
|
|
const fileDiscoveryService = config ? config.getFileService() : null;
|
|
const enableRecursiveSearch =
|
|
config?.getEnableRecursiveFileSearch() ?? true;
|
|
const filterOptions =
|
|
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
|
|
|
try {
|
|
// If there's no slash, or it's the root, do a recursive search from workspace directories
|
|
for (const dir of dirs) {
|
|
let fetchedSuggestionsPerDir: Suggestion[] = [];
|
|
if (
|
|
partialPath.indexOf('/') === -1 &&
|
|
prefix &&
|
|
enableRecursiveSearch
|
|
) {
|
|
if (fileDiscoveryService) {
|
|
fetchedSuggestionsPerDir = await findFilesWithGlob(
|
|
prefix,
|
|
fileDiscoveryService,
|
|
filterOptions,
|
|
dir,
|
|
);
|
|
} else {
|
|
fetchedSuggestionsPerDir = await findFilesRecursively(
|
|
dir,
|
|
prefix,
|
|
null,
|
|
filterOptions,
|
|
);
|
|
}
|
|
} else {
|
|
// Original behavior: list files in the specific directory
|
|
const lowerPrefix = prefix.toLowerCase();
|
|
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
|
|
const entries = await fs.readdir(baseDirAbsolute, {
|
|
withFileTypes: true,
|
|
});
|
|
|
|
// Filter entries using git-aware filtering
|
|
const filteredEntries = [];
|
|
for (const entry of entries) {
|
|
// Conditionally ignore dotfiles
|
|
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
|
|
continue;
|
|
}
|
|
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
|
|
|
|
const relativePath = path.relative(
|
|
dir,
|
|
path.join(baseDirAbsolute, entry.name),
|
|
);
|
|
if (
|
|
fileDiscoveryService &&
|
|
fileDiscoveryService.shouldIgnoreFile(
|
|
relativePath,
|
|
filterOptions,
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
filteredEntries.push(entry);
|
|
}
|
|
|
|
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
|
|
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
|
|
const label =
|
|
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
|
|
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
|
|
return {
|
|
label: suggestionLabel,
|
|
value: escapePath(suggestionLabel),
|
|
};
|
|
});
|
|
}
|
|
fetchedSuggestions = [
|
|
...fetchedSuggestions,
|
|
...fetchedSuggestionsPerDir,
|
|
];
|
|
}
|
|
|
|
// Like glob, we always return forwardslashes, even in windows.
|
|
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
|
|
...suggestion,
|
|
label: suggestion.label.replace(/\\/g, '/'),
|
|
value: suggestion.value.replace(/\\/g, '/'),
|
|
}));
|
|
|
|
// Sort by depth, then directories first, then alphabetically
|
|
fetchedSuggestions.sort((a, b) => {
|
|
const depthA = (a.label.match(/\//g) || []).length;
|
|
const depthB = (b.label.match(/\//g) || []).length;
|
|
|
|
if (depthA !== depthB) {
|
|
return depthA - depthB;
|
|
}
|
|
|
|
const aIsDir = a.label.endsWith('/');
|
|
const bIsDir = b.label.endsWith('/');
|
|
if (aIsDir && !bIsDir) return -1;
|
|
if (!aIsDir && bIsDir) return 1;
|
|
|
|
// exclude extension when comparing
|
|
const filenameA = a.label.substring(
|
|
0,
|
|
a.label.length - path.extname(a.label).length,
|
|
);
|
|
const filenameB = b.label.substring(
|
|
0,
|
|
b.label.length - path.extname(b.label).length,
|
|
);
|
|
|
|
return (
|
|
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
|
|
);
|
|
});
|
|
|
|
if (isMounted) {
|
|
setSuggestions(fetchedSuggestions);
|
|
setShowSuggestions(fetchedSuggestions.length > 0);
|
|
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
|
setVisibleStartIndex(0);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
if (isMounted) {
|
|
setSuggestions([]);
|
|
setShowSuggestions(false);
|
|
}
|
|
} else {
|
|
console.error(
|
|
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
|
);
|
|
if (isMounted) {
|
|
resetCompletionState();
|
|
}
|
|
}
|
|
}
|
|
if (isMounted) {
|
|
setIsLoadingSuggestions(false);
|
|
}
|
|
};
|
|
|
|
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
clearTimeout(debounceTimeout);
|
|
};
|
|
}, [
|
|
buffer.text,
|
|
cursorRow,
|
|
cursorCol,
|
|
buffer.lines,
|
|
dirs,
|
|
cwd,
|
|
commandIndex,
|
|
resetCompletionState,
|
|
slashCommands,
|
|
commandContext,
|
|
config,
|
|
]);
|
|
|
|
const handleAutocomplete = useCallback(
|
|
(indexToUse: number) => {
|
|
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
|
return;
|
|
}
|
|
const suggestion = suggestions[indexToUse].value;
|
|
|
|
if (completionStart.current === -1 || completionEnd.current === -1) {
|
|
return;
|
|
}
|
|
|
|
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
|
|
let suggestionText = suggestion;
|
|
if (isSlash) {
|
|
// If we are inserting (not replacing), and the preceding character is not a space, add one.
|
|
if (
|
|
completionStart.current === completionEnd.current &&
|
|
completionStart.current > commandIndex + 1 &&
|
|
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
|
|
) {
|
|
suggestionText = ' ' + suggestionText;
|
|
}
|
|
suggestionText += ' ';
|
|
}
|
|
|
|
buffer.replaceRangeByOffset(
|
|
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
|
|
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
|
|
suggestionText,
|
|
);
|
|
resetCompletionState();
|
|
},
|
|
[cursorRow, resetCompletionState, buffer, suggestions, commandIndex],
|
|
);
|
|
|
|
return {
|
|
suggestions,
|
|
activeSuggestionIndex,
|
|
visibleStartIndex,
|
|
showSuggestions,
|
|
isLoadingSuggestions,
|
|
isPerfectMatch,
|
|
setActiveSuggestionIndex,
|
|
setShowSuggestions,
|
|
resetCompletionState,
|
|
navigateUp,
|
|
navigateDown,
|
|
handleAutocomplete,
|
|
};
|
|
}
|