@mariozechner/pi-tui 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autocomplete.d.ts +2 -0
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +179 -109
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +41 -6
- package/dist/components/editor.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +34 -8
- package/dist/utils.js.map +1 -1
- package/package.json +2 -1
package/dist/autocomplete.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export declare class CombinedAutocompleteProvider implements AutocompleteProvide
|
|
|
35
35
|
private extractPathPrefix;
|
|
36
36
|
private expandHomePath;
|
|
37
37
|
private getFileSuggestions;
|
|
38
|
+
private scoreEntry;
|
|
39
|
+
private getFuzzyFileSuggestions;
|
|
38
40
|
getForceFileSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
|
|
39
41
|
items: AutocompleteItem[];
|
|
40
42
|
prefix: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AAuFA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IAGrB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,IAAI,CAAC;CAC3E;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QACF,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAC1B,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,IAAI,CAAC;IAIT,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QACF,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KAClB,CAAC;CACF;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,QAAQ,GAAE,CAAC,YAAY,GAAG,gBAAgB,CAAC,EAAO,EAAE,QAAQ,GAAE,MAAsB,EAG/F;IAED,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAmEtD;IAED,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CA0D5D;IAGD,OAAO,CAAC,iBAAiB;IAwCzB,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,kBAAkB;IAuJ1B,uBAAuB,CACtB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAsBtD;IAGD,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAU3F;CACD","sourcesContent":["import { readdirSync, statSync } from \"fs\";\nimport mimeTypes from \"mime-types\";\nimport { homedir } from \"os\";\nimport { basename, dirname, extname, join } from \"path\";\n\nfunction isAttachableFile(filePath: string): boolean {\n\tconst mimeType = mimeTypes.lookup(filePath);\n\n\t// Check file extension for common text files that might be misidentified\n\tconst textExtensions = [\n\t\t\".txt\",\n\t\t\".md\",\n\t\t\".markdown\",\n\t\t\".js\",\n\t\t\".ts\",\n\t\t\".tsx\",\n\t\t\".jsx\",\n\t\t\".py\",\n\t\t\".java\",\n\t\t\".c\",\n\t\t\".cpp\",\n\t\t\".h\",\n\t\t\".hpp\",\n\t\t\".cs\",\n\t\t\".php\",\n\t\t\".rb\",\n\t\t\".go\",\n\t\t\".rs\",\n\t\t\".swift\",\n\t\t\".kt\",\n\t\t\".scala\",\n\t\t\".sh\",\n\t\t\".bash\",\n\t\t\".zsh\",\n\t\t\".fish\",\n\t\t\".html\",\n\t\t\".htm\",\n\t\t\".css\",\n\t\t\".scss\",\n\t\t\".sass\",\n\t\t\".less\",\n\t\t\".xml\",\n\t\t\".json\",\n\t\t\".yaml\",\n\t\t\".yml\",\n\t\t\".toml\",\n\t\t\".ini\",\n\t\t\".cfg\",\n\t\t\".conf\",\n\t\t\".log\",\n\t\t\".sql\",\n\t\t\".r\",\n\t\t\".R\",\n\t\t\".m\",\n\t\t\".pl\",\n\t\t\".lua\",\n\t\t\".vim\",\n\t\t\".dockerfile\",\n\t\t\".makefile\",\n\t\t\".cmake\",\n\t\t\".gradle\",\n\t\t\".maven\",\n\t\t\".properties\",\n\t\t\".env\",\n\t];\n\n\tconst ext = extname(filePath).toLowerCase();\n\tif (textExtensions.includes(ext)) return true;\n\n\tif (!mimeType) return false;\n\n\tif (mimeType.startsWith(\"image/\")) return true;\n\tif (mimeType.startsWith(\"text/\")) return true;\n\n\t// Special cases for common text files that might not be detected as text/\n\tconst commonTextTypes = [\n\t\t\"application/json\",\n\t\t\"application/javascript\",\n\t\t\"application/typescript\",\n\t\t\"application/xml\",\n\t\t\"application/yaml\",\n\t\t\"application/x-yaml\",\n\t];\n\n\treturn commonTextTypes.includes(mimeType);\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\t// Function to get argument completions for this command\n\t// Returns null if no argument completion is available\n\tgetArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;\n}\n\nexport interface AutocompleteProvider {\n\t// Get autocomplete suggestions for current text/cursor position\n\t// Returns null if no suggestions available\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): {\n\t\titems: AutocompleteItem[];\n\t\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\n\t} | null;\n\n\t// Apply the selected item\n\t// Returns the new text and cursor position\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): {\n\t\tlines: string[];\n\t\tcursorLine: number;\n\t\tcursorCol: number;\n\t};\n}\n\n// Combined provider that handles both slash commands and file paths\nexport class CombinedAutocompleteProvider implements AutocompleteProvider {\n\tprivate commands: (SlashCommand | AutocompleteItem)[];\n\tprivate basePath: string;\n\n\tconstructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t}\n\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Check for slash commands\n\t\tif (textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\t// No space yet - complete command names\n\t\t\t\tconst prefix = textBeforeCursor.slice(1); // Remove the \"/\"\n\t\t\t\tconst filtered = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem\n\t\t\t\t\t\treturn name?.toLowerCase().startsWith(prefix.toLowerCase());\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => ({\n\t\t\t\t\t\tvalue: \"name\" in cmd ? cmd.name : cmd.value,\n\t\t\t\t\t\tlabel: \"name\" in cmd ? cmd.name : cmd.label,\n\t\t\t\t\t\t...(cmd.description && { description: cmd.description }),\n\t\t\t\t\t}));\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: textBeforeCursor,\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\t// Space found - complete command arguments\n\t\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex); // Command without \"/\"\n\t\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space\n\n\t\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\treturn name === commandName;\n\t\t\t\t});\n\t\t\t\tif (!command || !(\"getArgumentCompletions\" in command) || !command.getArgumentCompletions) {\n\t\t\t\t\treturn null; // No argument completion for this command\n\t\t\t\t}\n\n\t\t\t\tconst argumentSuggestions = command.getArgumentCompletions(argumentText);\n\t\t\t\tif (!argumentSuggestions || argumentSuggestions.length === 0) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\titems: argumentSuggestions,\n\t\t\t\t\tprefix: argumentText,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for file paths - triggered by Tab or if we detect a path pattern\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, false);\n\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): { lines: string[]; cursorLine: number; cursorCol: number } {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst beforePrefix = currentLine.slice(0, cursorCol - prefix.length);\n\t\tconst afterCursor = currentLine.slice(cursorCol);\n\n\t\t// Check if we're completing a slash command (prefix starts with \"/\")\n\t\tif (prefix.startsWith(\"/\")) {\n\t\t\t// This is a command name completion\n\t\t\tconst newLine = beforePrefix + \"/\" + item.value + \" \" + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 2, // +2 for \"/\" and space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're completing a file attachment (prefix starts with \"@\")\n\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t// This is a file attachment completion\n\t\t\tconst newLine = beforePrefix + item.value + \" \" + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 1, // +1 for space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're in a slash command context (beforePrefix contains \"/command \")\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\t\tif (textBeforeCursor.includes(\"/\") && textBeforeCursor.includes(\" \")) {\n\t\t\t// This is likely a command argument completion\n\t\t\tconst newLine = beforePrefix + item.value + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length,\n\t\t\t};\n\t\t}\n\n\t\t// For file paths, complete the path\n\t\tconst newLine = beforePrefix + item.value + afterCursor;\n\t\tconst newLines = [...lines];\n\t\tnewLines[cursorLine] = newLine;\n\n\t\treturn {\n\t\t\tlines: newLines,\n\t\t\tcursorLine,\n\t\t\tcursorCol: beforePrefix.length + item.value.length,\n\t\t};\n\t}\n\n\t// Extract a path-like prefix from the text before cursor\n\tprivate extractPathPrefix(text: string, forceExtract: boolean = false): string | null {\n\t\t// Check for @ file attachment syntax first\n\t\tconst atMatch = text.match(/@([^\\s]*)$/);\n\t\tif (atMatch) {\n\t\t\treturn atMatch[0]; // Return the full @path pattern\n\t\t}\n\n\t\t// Simple approach: find the last whitespace/delimiter and extract the word after it\n\t\t// This avoids catastrophic backtracking from nested quantifiers\n\t\tconst lastDelimiterIndex = Math.max(\n\t\t\ttext.lastIndexOf(\" \"),\n\t\t\ttext.lastIndexOf(\"\\t\"),\n\t\t\ttext.lastIndexOf('\"'),\n\t\t\ttext.lastIndexOf(\"'\"),\n\t\t\ttext.lastIndexOf(\"=\"),\n\t\t);\n\n\t\tconst pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);\n\n\t\t// For forced extraction (Tab key), always return something\n\t\tif (forceExtract) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .\n\t\t// Only return empty string if the text looks like it's starting a path context\n\t\tif (pathPrefix.includes(\"/\") || pathPrefix.startsWith(\".\") || pathPrefix.startsWith(\"~/\")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// Return empty string only if we're at the beginning of the line or after a space\n\t\t// (not after quotes or other delimiters that don't suggest file paths)\n\t\tif (pathPrefix === \"\" && (text === \"\" || text.endsWith(\" \"))) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Expand home directory (~/) to actual home path\n\tprivate expandHomePath(path: string): string {\n\t\tif (path.startsWith(\"~/\")) {\n\t\t\tconst expandedPath = join(homedir(), path.slice(2));\n\t\t\t// Preserve trailing slash if original path had one\n\t\t\treturn path.endsWith(\"/\") && !expandedPath.endsWith(\"/\") ? expandedPath + \"/\" : expandedPath;\n\t\t} else if (path === \"~\") {\n\t\t\treturn homedir();\n\t\t}\n\t\treturn path;\n\t}\n\n\t// Get file/directory suggestions for a given path prefix\n\tprivate getFileSuggestions(prefix: string): AutocompleteItem[] {\n\t\ttry {\n\t\t\tlet searchDir: string;\n\t\t\tlet searchPrefix: string;\n\t\t\tlet expandedPrefix = prefix;\n\t\t\tlet isAtPrefix = false;\n\n\t\t\t// Handle @ file attachment prefix\n\t\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t\tisAtPrefix = true;\n\t\t\t\texpandedPrefix = prefix.slice(1); // Remove the @\n\t\t\t}\n\n\t\t\t// Handle home directory expansion\n\t\t\tif (expandedPrefix.startsWith(\"~\")) {\n\t\t\t\texpandedPrefix = this.expandHomePath(expandedPrefix);\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\texpandedPrefix === \"\" ||\n\t\t\t\texpandedPrefix === \"./\" ||\n\t\t\t\texpandedPrefix === \"../\" ||\n\t\t\t\texpandedPrefix === \"~\" ||\n\t\t\t\texpandedPrefix === \"~/\" ||\n\t\t\t\texpandedPrefix === \"/\" ||\n\t\t\t\tprefix === \"@\"\n\t\t\t) {\n\t\t\t\t// Complete from specified position\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix === \"/\") {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else if (expandedPrefix.endsWith(\"/\")) {\n\t\t\t\t// If prefix ends with /, show contents of that directory\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else {\n\t\t\t\t// Split into directory and file prefix\n\t\t\t\tconst dir = dirname(expandedPrefix);\n\t\t\t\tconst file = basename(expandedPrefix);\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = dir;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, dir);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = file;\n\t\t\t}\n\n\t\t\tconst entries = readdirSync(searchDir);\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst fullPath = join(searchDir, entry);\n\t\t\t\tlet isDirectory: boolean;\n\t\t\t\ttry {\n\t\t\t\t\tisDirectory = statSync(fullPath).isDirectory();\n\t\t\t\t} catch (e) {\n\t\t\t\t\t// Skip files we can't stat (permission issues, broken symlinks, etc.)\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// For @ prefix, filter to only show directories and attachable files\n\t\t\t\tif (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tlet relativePath: string;\n\n\t\t\t\t// Handle @ prefix path construction\n\t\t\t\tif (isAtPrefix) {\n\t\t\t\t\tconst pathWithoutAt = expandedPrefix;\n\t\t\t\t\tif (pathWithoutAt.endsWith(\"/\")) {\n\t\t\t\t\t\trelativePath = \"@\" + pathWithoutAt + entry;\n\t\t\t\t\t} else if (pathWithoutAt.includes(\"/\")) {\n\t\t\t\t\t\tif (pathWithoutAt.startsWith(\"~/\")) {\n\t\t\t\t\t\t\tconst homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/\n\t\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\t\trelativePath = \"@~/\" + (dir === \".\" ? entry : join(dir, entry));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = \"@\" + join(dirname(pathWithoutAt), entry);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (pathWithoutAt.startsWith(\"~\")) {\n\t\t\t\t\t\t\trelativePath = \"@~/\" + entry;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = \"@\" + entry;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if (prefix.endsWith(\"/\")) {\n\t\t\t\t\t// If prefix ends with /, append entry to the prefix\n\t\t\t\t\trelativePath = prefix + entry;\n\t\t\t\t} else if (prefix.includes(\"/\")) {\n\t\t\t\t\t// Preserve ~/ format for home directory paths\n\t\t\t\t\tif (prefix.startsWith(\"~/\")) {\n\t\t\t\t\t\tconst homeRelativeDir = prefix.slice(2); // Remove ~/\n\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\trelativePath = \"~/\" + (dir === \".\" ? entry : join(dir, entry));\n\t\t\t\t\t} else if (prefix.startsWith(\"/\")) {\n\t\t\t\t\t\t// Absolute path - construct properly\n\t\t\t\t\t\tconst dir = dirname(prefix);\n\t\t\t\t\t\tif (dir === \"/\") {\n\t\t\t\t\t\t\trelativePath = \"/\" + entry;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = dir + \"/\" + entry;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = join(dirname(prefix), entry);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// For standalone entries, preserve ~/ if original prefix was ~/\n\t\t\t\t\tif (prefix.startsWith(\"~\")) {\n\t\t\t\t\t\trelativePath = \"~/\" + entry;\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = entry;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue: isDirectory ? relativePath + \"/\" : relativePath,\n\t\t\t\t\tlabel: entry,\n\t\t\t\t\tdescription: isDirectory ? \"directory\" : \"file\",\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Sort directories first, then alphabetically\n\t\t\tsuggestions.sort((a, b) => {\n\t\t\t\tconst aIsDir = a.description === \"directory\";\n\t\t\t\tconst bIsDir = b.description === \"directory\";\n\t\t\t\tif (aIsDir && !bIsDir) return -1;\n\t\t\t\tif (!aIsDir && bIsDir) return 1;\n\t\t\t\treturn a.label.localeCompare(b.label);\n\t\t\t});\n\n\t\t\treturn suggestions;\n\t\t} catch (e) {\n\t\t\t// Directory doesn't exist or not accessible\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Force file completion (called on Tab key) - always returns suggestions\n\tgetForceFileSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn null;\n\t\t}\n\n\t\t// Force extract path prefix - this will always return something\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, true);\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Check if we should trigger file completion (called on Tab key)\n\tshouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AA8GA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IAGrB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,IAAI,CAAC;CAC3E;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QACF,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAC1B,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,IAAI,CAAC;IAIT,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QACF,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KAClB,CAAC;CACF;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,QAAQ,GAAE,CAAC,YAAY,GAAG,gBAAgB,CAAC,EAAO,EAAE,QAAQ,GAAE,MAAsB,EAG/F;IAED,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAiFtD;IAED,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CA0D5D;IAGD,OAAO,CAAC,iBAAiB;IAwCzB,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,kBAAkB;IA6I1B,OAAO,CAAC,UAAU;IAuBlB,OAAO,CAAC,uBAAuB;IAqC/B,uBAAuB,CACtB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAsBtD;IAGD,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAU3F;CACD","sourcesContent":["import { type Dirent, readdirSync, readFileSync } from \"fs\";\nimport { minimatch } from \"minimatch\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join, relative } from \"path\";\n\n// Parse gitignore-style file into patterns\nfunction parseIgnoreFile(filePath: string): string[] {\n\ttry {\n\t\tconst content = readFileSync(filePath, \"utf-8\");\n\t\treturn content\n\t\t\t.split(\"\\n\")\n\t\t\t.map((line) => line.trim())\n\t\t\t.filter((line) => line && !line.startsWith(\"#\"));\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n// Check if a path matches gitignore patterns\nfunction isIgnored(filePath: string, patterns: string[]): boolean {\n\tconst pathWithoutSlash = filePath.endsWith(\"/\") ? filePath.slice(0, -1) : filePath;\n\tconst isDir = filePath.endsWith(\"/\");\n\n\tlet ignored = false;\n\n\tfor (const pattern of patterns) {\n\t\tlet p = pattern;\n\t\tconst negated = p.startsWith(\"!\");\n\t\tif (negated) p = p.slice(1);\n\n\t\t// Directory-only pattern\n\t\tconst dirOnly = p.endsWith(\"/\");\n\t\tif (dirOnly) {\n\t\t\tif (!isDir) continue;\n\t\t\tp = p.slice(0, -1);\n\t\t}\n\n\t\t// Remove leading slash (means anchored to root)\n\t\tconst anchored = p.startsWith(\"/\");\n\t\tif (anchored) p = p.slice(1);\n\n\t\t// Match - either at any level or anchored\n\t\tconst matchPattern = anchored ? p : \"**/\" + p;\n\t\tconst matches = minimatch(pathWithoutSlash, matchPattern, { dot: true });\n\n\t\tif (matches) {\n\t\t\tignored = !negated;\n\t\t}\n\t}\n\n\treturn ignored;\n}\n\n// Walk directory tree respecting .gitignore, similar to fd\nfunction walkDirectory(\n\tbaseDir: string,\n\tquery: string,\n\tmaxResults: number,\n): Array<{ path: string; isDirectory: boolean }> {\n\tconst results: Array<{ path: string; isDirectory: boolean }> = [];\n\tconst rootIgnorePatterns = parseIgnoreFile(join(baseDir, \".gitignore\"));\n\n\tfunction walk(currentDir: string, ignorePatterns: string[]): void {\n\t\tif (results.length >= maxResults) return;\n\n\t\t// Load local .gitignore if exists\n\t\tconst localPatterns = parseIgnoreFile(join(currentDir, \".gitignore\"));\n\t\tconst combinedPatterns = [...ignorePatterns, ...localPatterns];\n\n\t\tlet entries: Dirent[];\n\t\ttry {\n\t\t\tentries = readdirSync(currentDir, { withFileTypes: true });\n\t\t} catch {\n\t\t\treturn; // Can't read directory, skip\n\t\t}\n\n\t\tfor (const entry of entries) {\n\t\t\tif (results.length >= maxResults) return;\n\n\t\t\t// Skip hidden files/dirs\n\t\t\tif (entry.name.startsWith(\".\")) continue;\n\n\t\t\tconst fullPath = join(currentDir, entry.name);\n\t\t\tconst relativePath = relative(baseDir, fullPath);\n\n\t\t\t// Check if ignored\n\t\t\tconst pathToCheck = entry.isDirectory() ? relativePath + \"/\" : relativePath;\n\t\t\tif (isIgnored(pathToCheck, combinedPatterns)) continue;\n\n\t\t\tif (entry.isDirectory()) {\n\t\t\t\t// Check if dir matches query\n\t\t\t\tif (!query || entry.name.toLowerCase().includes(query.toLowerCase())) {\n\t\t\t\t\tresults.push({ path: relativePath + \"/\", isDirectory: true });\n\t\t\t\t}\n\n\t\t\t\t// Recurse\n\t\t\t\twalk(fullPath, combinedPatterns);\n\t\t\t} else {\n\t\t\t\t// Check if file matches query\n\t\t\t\tif (!query || entry.name.toLowerCase().includes(query.toLowerCase())) {\n\t\t\t\t\tresults.push({ path: relativePath, isDirectory: false });\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\twalk(baseDir, rootIgnorePatterns);\n\treturn results;\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\t// Function to get argument completions for this command\n\t// Returns null if no argument completion is available\n\tgetArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;\n}\n\nexport interface AutocompleteProvider {\n\t// Get autocomplete suggestions for current text/cursor position\n\t// Returns null if no suggestions available\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): {\n\t\titems: AutocompleteItem[];\n\t\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\n\t} | null;\n\n\t// Apply the selected item\n\t// Returns the new text and cursor position\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): {\n\t\tlines: string[];\n\t\tcursorLine: number;\n\t\tcursorCol: number;\n\t};\n}\n\n// Combined provider that handles both slash commands and file paths\nexport class CombinedAutocompleteProvider implements AutocompleteProvider {\n\tprivate commands: (SlashCommand | AutocompleteItem)[];\n\tprivate basePath: string;\n\n\tconstructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t}\n\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Check for @ file reference (fuzzy search) - must be after a space or at start\n\t\tconst atMatch = textBeforeCursor.match(/(?:^|[\\s])(@[^\\s]*)$/);\n\t\tif (atMatch) {\n\t\t\tconst prefix = atMatch[1] ?? \"@\"; // The @... part\n\t\t\tconst query = prefix.slice(1); // Remove the @\n\t\t\tconst suggestions = this.getFuzzyFileSuggestions(query);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: prefix,\n\t\t\t};\n\t\t}\n\n\t\t// Check for slash commands\n\t\tif (textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\t// No space yet - complete command names\n\t\t\t\tconst prefix = textBeforeCursor.slice(1); // Remove the \"/\"\n\t\t\t\tconst filtered = this.commands\n\t\t\t\t\t.filter((cmd) => {\n\t\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value; // Check if SlashCommand or AutocompleteItem\n\t\t\t\t\t\treturn name?.toLowerCase().startsWith(prefix.toLowerCase());\n\t\t\t\t\t})\n\t\t\t\t\t.map((cmd) => ({\n\t\t\t\t\t\tvalue: \"name\" in cmd ? cmd.name : cmd.value,\n\t\t\t\t\t\tlabel: \"name\" in cmd ? cmd.name : cmd.label,\n\t\t\t\t\t\t...(cmd.description && { description: cmd.description }),\n\t\t\t\t\t}));\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: textBeforeCursor,\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\t// Space found - complete command arguments\n\t\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex); // Command without \"/\"\n\t\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space\n\n\t\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\treturn name === commandName;\n\t\t\t\t});\n\t\t\t\tif (!command || !(\"getArgumentCompletions\" in command) || !command.getArgumentCompletions) {\n\t\t\t\t\treturn null; // No argument completion for this command\n\t\t\t\t}\n\n\t\t\t\tconst argumentSuggestions = command.getArgumentCompletions(argumentText);\n\t\t\t\tif (!argumentSuggestions || argumentSuggestions.length === 0) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\treturn {\n\t\t\t\t\titems: argumentSuggestions,\n\t\t\t\t\tprefix: argumentText,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for file paths - triggered by Tab or if we detect a path pattern\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, false);\n\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): { lines: string[]; cursorLine: number; cursorCol: number } {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst beforePrefix = currentLine.slice(0, cursorCol - prefix.length);\n\t\tconst afterCursor = currentLine.slice(cursorCol);\n\n\t\t// Check if we're completing a slash command (prefix starts with \"/\")\n\t\tif (prefix.startsWith(\"/\")) {\n\t\t\t// This is a command name completion\n\t\t\tconst newLine = beforePrefix + \"/\" + item.value + \" \" + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 2, // +2 for \"/\" and space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're completing a file attachment (prefix starts with \"@\")\n\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t// This is a file attachment completion\n\t\t\tconst newLine = beforePrefix + item.value + \" \" + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 1, // +1 for space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're in a slash command context (beforePrefix contains \"/command \")\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\t\tif (textBeforeCursor.includes(\"/\") && textBeforeCursor.includes(\" \")) {\n\t\t\t// This is likely a command argument completion\n\t\t\tconst newLine = beforePrefix + item.value + afterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length,\n\t\t\t};\n\t\t}\n\n\t\t// For file paths, complete the path\n\t\tconst newLine = beforePrefix + item.value + afterCursor;\n\t\tconst newLines = [...lines];\n\t\tnewLines[cursorLine] = newLine;\n\n\t\treturn {\n\t\t\tlines: newLines,\n\t\t\tcursorLine,\n\t\t\tcursorCol: beforePrefix.length + item.value.length,\n\t\t};\n\t}\n\n\t// Extract a path-like prefix from the text before cursor\n\tprivate extractPathPrefix(text: string, forceExtract: boolean = false): string | null {\n\t\t// Check for @ file attachment syntax first\n\t\tconst atMatch = text.match(/@([^\\s]*)$/);\n\t\tif (atMatch) {\n\t\t\treturn atMatch[0]; // Return the full @path pattern\n\t\t}\n\n\t\t// Simple approach: find the last whitespace/delimiter and extract the word after it\n\t\t// This avoids catastrophic backtracking from nested quantifiers\n\t\tconst lastDelimiterIndex = Math.max(\n\t\t\ttext.lastIndexOf(\" \"),\n\t\t\ttext.lastIndexOf(\"\\t\"),\n\t\t\ttext.lastIndexOf('\"'),\n\t\t\ttext.lastIndexOf(\"'\"),\n\t\t\ttext.lastIndexOf(\"=\"),\n\t\t);\n\n\t\tconst pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);\n\n\t\t// For forced extraction (Tab key), always return something\n\t\tif (forceExtract) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .\n\t\t// Only return empty string if the text looks like it's starting a path context\n\t\tif (pathPrefix.includes(\"/\") || pathPrefix.startsWith(\".\") || pathPrefix.startsWith(\"~/\")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// Return empty string only if we're at the beginning of the line or after a space\n\t\t// (not after quotes or other delimiters that don't suggest file paths)\n\t\tif (pathPrefix === \"\" && (text === \"\" || text.endsWith(\" \"))) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Expand home directory (~/) to actual home path\n\tprivate expandHomePath(path: string): string {\n\t\tif (path.startsWith(\"~/\")) {\n\t\t\tconst expandedPath = join(homedir(), path.slice(2));\n\t\t\t// Preserve trailing slash if original path had one\n\t\t\treturn path.endsWith(\"/\") && !expandedPath.endsWith(\"/\") ? expandedPath + \"/\" : expandedPath;\n\t\t} else if (path === \"~\") {\n\t\t\treturn homedir();\n\t\t}\n\t\treturn path;\n\t}\n\n\t// Get file/directory suggestions for a given path prefix\n\tprivate getFileSuggestions(prefix: string): AutocompleteItem[] {\n\t\ttry {\n\t\t\tlet searchDir: string;\n\t\t\tlet searchPrefix: string;\n\t\t\tlet expandedPrefix = prefix;\n\t\t\tlet isAtPrefix = false;\n\n\t\t\t// Handle @ file attachment prefix\n\t\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t\tisAtPrefix = true;\n\t\t\t\texpandedPrefix = prefix.slice(1); // Remove the @\n\t\t\t}\n\n\t\t\t// Handle home directory expansion\n\t\t\tif (expandedPrefix.startsWith(\"~\")) {\n\t\t\t\texpandedPrefix = this.expandHomePath(expandedPrefix);\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\texpandedPrefix === \"\" ||\n\t\t\t\texpandedPrefix === \"./\" ||\n\t\t\t\texpandedPrefix === \"../\" ||\n\t\t\t\texpandedPrefix === \"~\" ||\n\t\t\t\texpandedPrefix === \"~/\" ||\n\t\t\t\texpandedPrefix === \"/\" ||\n\t\t\t\tprefix === \"@\"\n\t\t\t) {\n\t\t\t\t// Complete from specified position\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix === \"/\") {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else if (expandedPrefix.endsWith(\"/\")) {\n\t\t\t\t// If prefix ends with /, show contents of that directory\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else {\n\t\t\t\t// Split into directory and file prefix\n\t\t\t\tconst dir = dirname(expandedPrefix);\n\t\t\t\tconst file = basename(expandedPrefix);\n\t\t\t\tif (prefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = dir;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, dir);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = file;\n\t\t\t}\n\n\t\t\tconst entries = readdirSync(searchDir, { withFileTypes: true });\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tconst isDirectory = entry.isDirectory();\n\n\t\t\t\tlet relativePath: string;\n\t\t\t\tconst name = entry.name;\n\n\t\t\t\t// Handle @ prefix path construction\n\t\t\t\tif (isAtPrefix) {\n\t\t\t\t\tconst pathWithoutAt = expandedPrefix;\n\t\t\t\t\tif (pathWithoutAt.endsWith(\"/\")) {\n\t\t\t\t\t\trelativePath = \"@\" + pathWithoutAt + name;\n\t\t\t\t\t} else if (pathWithoutAt.includes(\"/\")) {\n\t\t\t\t\t\tif (pathWithoutAt.startsWith(\"~/\")) {\n\t\t\t\t\t\t\tconst homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/\n\t\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\t\trelativePath = \"@~/\" + (dir === \".\" ? name : join(dir, name));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = \"@\" + join(dirname(pathWithoutAt), name);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tif (pathWithoutAt.startsWith(\"~\")) {\n\t\t\t\t\t\t\trelativePath = \"@~/\" + name;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = \"@\" + name;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if (prefix.endsWith(\"/\")) {\n\t\t\t\t\t// If prefix ends with /, append entry to the prefix\n\t\t\t\t\trelativePath = prefix + name;\n\t\t\t\t} else if (prefix.includes(\"/\")) {\n\t\t\t\t\t// Preserve ~/ format for home directory paths\n\t\t\t\t\tif (prefix.startsWith(\"~/\")) {\n\t\t\t\t\t\tconst homeRelativeDir = prefix.slice(2); // Remove ~/\n\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\trelativePath = \"~/\" + (dir === \".\" ? name : join(dir, name));\n\t\t\t\t\t} else if (prefix.startsWith(\"/\")) {\n\t\t\t\t\t\t// Absolute path - construct properly\n\t\t\t\t\t\tconst dir = dirname(prefix);\n\t\t\t\t\t\tif (dir === \"/\") {\n\t\t\t\t\t\t\trelativePath = \"/\" + name;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = dir + \"/\" + name;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = join(dirname(prefix), name);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// For standalone entries, preserve ~/ if original prefix was ~/\n\t\t\t\t\tif (prefix.startsWith(\"~\")) {\n\t\t\t\t\t\trelativePath = \"~/\" + name;\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = name;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue: isDirectory ? relativePath + \"/\" : relativePath,\n\t\t\t\t\tlabel: name,\n\t\t\t\t\tdescription: isDirectory ? \"directory\" : \"file\",\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Sort directories first, then alphabetically\n\t\t\tsuggestions.sort((a, b) => {\n\t\t\t\tconst aIsDir = a.description === \"directory\";\n\t\t\t\tconst bIsDir = b.description === \"directory\";\n\t\t\t\tif (aIsDir && !bIsDir) return -1;\n\t\t\t\tif (!aIsDir && bIsDir) return 1;\n\t\t\t\treturn a.label.localeCompare(b.label);\n\t\t\t});\n\n\t\t\treturn suggestions;\n\t\t} catch (e) {\n\t\t\t// Directory doesn't exist or not accessible\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Score an entry against the query (higher = better match)\n\t// isDirectory adds bonus to prioritize folders\n\tprivate scoreEntry(filePath: string, query: string, isDirectory: boolean): number {\n\t\tconst fileName = basename(filePath);\n\t\tconst lowerFileName = fileName.toLowerCase();\n\t\tconst lowerQuery = query.toLowerCase();\n\n\t\tlet score = 0;\n\n\t\t// Exact filename match (highest)\n\t\tif (lowerFileName === lowerQuery) score = 100;\n\t\t// Filename starts with query\n\t\telse if (lowerFileName.startsWith(lowerQuery)) score = 80;\n\t\t// Substring match in filename\n\t\telse if (lowerFileName.includes(lowerQuery)) score = 50;\n\t\t// Substring match in full path\n\t\telse if (filePath.toLowerCase().includes(lowerQuery)) score = 30;\n\n\t\t// Directories get a bonus to appear first\n\t\tif (isDirectory && score > 0) score += 10;\n\n\t\treturn score;\n\t}\n\n\t// Fuzzy file search using pure Node.js directory walking (respects .gitignore)\n\tprivate getFuzzyFileSuggestions(query: string): AutocompleteItem[] {\n\t\ttry {\n\t\t\tconst entries = walkDirectory(this.basePath, query, 100);\n\n\t\t\t// Score entries\n\t\t\tconst scoredEntries = entries\n\t\t\t\t.map((entry) => ({\n\t\t\t\t\t...entry,\n\t\t\t\t\tscore: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1,\n\t\t\t\t}))\n\t\t\t\t.filter((entry) => entry.score > 0);\n\n\t\t\t// Sort by score (descending) and take top 20\n\t\t\tscoredEntries.sort((a, b) => b.score - a.score);\n\t\t\tconst topEntries = scoredEntries.slice(0, 20);\n\n\t\t\t// Build suggestions\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\t\t\tfor (const { path: entryPath, isDirectory } of topEntries) {\n\t\t\t\tconst entryName = basename(entryPath.endsWith(\"/\") ? entryPath.slice(0, -1) : entryPath);\n\t\t\t\tconst normalizedPath = entryPath.endsWith(\"/\") ? entryPath.slice(0, -1) : entryPath;\n\t\t\t\tconst valuePath = isDirectory ? normalizedPath + \"/\" : normalizedPath;\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue: \"@\" + valuePath,\n\t\t\t\t\tlabel: entryName + (isDirectory ? \"/\" : \"\"),\n\t\t\t\t\tdescription: normalizedPath,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn suggestions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Force file completion (called on Tab key) - always returns suggestions\n\tgetForceFileSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t): { items: AutocompleteItem[]; prefix: string } | null {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn null;\n\t\t}\n\n\t\t// Force extract path prefix - this will always return something\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, true);\n\t\tif (pathMatch !== null) {\n\t\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: pathMatch,\n\t\t\t};\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Check if we should trigger file completion (called on Tab key)\n\tshouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
|
package/dist/autocomplete.js
CHANGED
|
@@ -1,85 +1,97 @@
|
|
|
1
|
-
import { readdirSync,
|
|
2
|
-
import
|
|
1
|
+
import { readdirSync, readFileSync } from "fs";
|
|
2
|
+
import { minimatch } from "minimatch";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
-
import { basename, dirname,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
".
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
4
|
+
import { basename, dirname, join, relative } from "path";
|
|
5
|
+
// Parse gitignore-style file into patterns
|
|
6
|
+
function parseIgnoreFile(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
const content = readFileSync(filePath, "utf-8");
|
|
9
|
+
return content
|
|
10
|
+
.split("\n")
|
|
11
|
+
.map((line) => line.trim())
|
|
12
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Check if a path matches gitignore patterns
|
|
19
|
+
function isIgnored(filePath, patterns) {
|
|
20
|
+
const pathWithoutSlash = filePath.endsWith("/") ? filePath.slice(0, -1) : filePath;
|
|
21
|
+
const isDir = filePath.endsWith("/");
|
|
22
|
+
let ignored = false;
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
let p = pattern;
|
|
25
|
+
const negated = p.startsWith("!");
|
|
26
|
+
if (negated)
|
|
27
|
+
p = p.slice(1);
|
|
28
|
+
// Directory-only pattern
|
|
29
|
+
const dirOnly = p.endsWith("/");
|
|
30
|
+
if (dirOnly) {
|
|
31
|
+
if (!isDir)
|
|
32
|
+
continue;
|
|
33
|
+
p = p.slice(0, -1);
|
|
34
|
+
}
|
|
35
|
+
// Remove leading slash (means anchored to root)
|
|
36
|
+
const anchored = p.startsWith("/");
|
|
37
|
+
if (anchored)
|
|
38
|
+
p = p.slice(1);
|
|
39
|
+
// Match - either at any level or anchored
|
|
40
|
+
const matchPattern = anchored ? p : "**/" + p;
|
|
41
|
+
const matches = minimatch(pathWithoutSlash, matchPattern, { dot: true });
|
|
42
|
+
if (matches) {
|
|
43
|
+
ignored = !negated;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return ignored;
|
|
47
|
+
}
|
|
48
|
+
// Walk directory tree respecting .gitignore, similar to fd
|
|
49
|
+
function walkDirectory(baseDir, query, maxResults) {
|
|
50
|
+
const results = [];
|
|
51
|
+
const rootIgnorePatterns = parseIgnoreFile(join(baseDir, ".gitignore"));
|
|
52
|
+
function walk(currentDir, ignorePatterns) {
|
|
53
|
+
if (results.length >= maxResults)
|
|
54
|
+
return;
|
|
55
|
+
// Load local .gitignore if exists
|
|
56
|
+
const localPatterns = parseIgnoreFile(join(currentDir, ".gitignore"));
|
|
57
|
+
const combinedPatterns = [...ignorePatterns, ...localPatterns];
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(currentDir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return; // Can't read directory, skip
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (results.length >= maxResults)
|
|
67
|
+
return;
|
|
68
|
+
// Skip hidden files/dirs
|
|
69
|
+
if (entry.name.startsWith("."))
|
|
70
|
+
continue;
|
|
71
|
+
const fullPath = join(currentDir, entry.name);
|
|
72
|
+
const relativePath = relative(baseDir, fullPath);
|
|
73
|
+
// Check if ignored
|
|
74
|
+
const pathToCheck = entry.isDirectory() ? relativePath + "/" : relativePath;
|
|
75
|
+
if (isIgnored(pathToCheck, combinedPatterns))
|
|
76
|
+
continue;
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
// Check if dir matches query
|
|
79
|
+
if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) {
|
|
80
|
+
results.push({ path: relativePath + "/", isDirectory: true });
|
|
81
|
+
}
|
|
82
|
+
// Recurse
|
|
83
|
+
walk(fullPath, combinedPatterns);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Check if file matches query
|
|
87
|
+
if (!query || entry.name.toLowerCase().includes(query.toLowerCase())) {
|
|
88
|
+
results.push({ path: relativePath, isDirectory: false });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
walk(baseDir, rootIgnorePatterns);
|
|
94
|
+
return results;
|
|
83
95
|
}
|
|
84
96
|
// Combined provider that handles both slash commands and file paths
|
|
85
97
|
export class CombinedAutocompleteProvider {
|
|
@@ -92,6 +104,19 @@ export class CombinedAutocompleteProvider {
|
|
|
92
104
|
getSuggestions(lines, cursorLine, cursorCol) {
|
|
93
105
|
const currentLine = lines[cursorLine] || "";
|
|
94
106
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
107
|
+
// Check for @ file reference (fuzzy search) - must be after a space or at start
|
|
108
|
+
const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/);
|
|
109
|
+
if (atMatch) {
|
|
110
|
+
const prefix = atMatch[1] ?? "@"; // The @... part
|
|
111
|
+
const query = prefix.slice(1); // Remove the @
|
|
112
|
+
const suggestions = this.getFuzzyFileSuggestions(query);
|
|
113
|
+
if (suggestions.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
return {
|
|
116
|
+
items: suggestions,
|
|
117
|
+
prefix: prefix,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
95
120
|
// Check for slash commands
|
|
96
121
|
if (textBeforeCursor.startsWith("/")) {
|
|
97
122
|
const spaceIndex = textBeforeCursor.indexOf(" ");
|
|
@@ -293,88 +318,77 @@ export class CombinedAutocompleteProvider {
|
|
|
293
318
|
}
|
|
294
319
|
searchPrefix = file;
|
|
295
320
|
}
|
|
296
|
-
const entries = readdirSync(searchDir);
|
|
321
|
+
const entries = readdirSync(searchDir, { withFileTypes: true });
|
|
297
322
|
const suggestions = [];
|
|
298
323
|
for (const entry of entries) {
|
|
299
|
-
if (!entry.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
const fullPath = join(searchDir, entry);
|
|
303
|
-
let isDirectory;
|
|
304
|
-
try {
|
|
305
|
-
isDirectory = statSync(fullPath).isDirectory();
|
|
306
|
-
}
|
|
307
|
-
catch (e) {
|
|
308
|
-
// Skip files we can't stat (permission issues, broken symlinks, etc.)
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
// For @ prefix, filter to only show directories and attachable files
|
|
312
|
-
if (isAtPrefix && !isDirectory && !isAttachableFile(fullPath)) {
|
|
324
|
+
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
|
|
313
325
|
continue;
|
|
314
326
|
}
|
|
327
|
+
const isDirectory = entry.isDirectory();
|
|
315
328
|
let relativePath;
|
|
329
|
+
const name = entry.name;
|
|
316
330
|
// Handle @ prefix path construction
|
|
317
331
|
if (isAtPrefix) {
|
|
318
332
|
const pathWithoutAt = expandedPrefix;
|
|
319
333
|
if (pathWithoutAt.endsWith("/")) {
|
|
320
|
-
relativePath = "@" + pathWithoutAt +
|
|
334
|
+
relativePath = "@" + pathWithoutAt + name;
|
|
321
335
|
}
|
|
322
336
|
else if (pathWithoutAt.includes("/")) {
|
|
323
337
|
if (pathWithoutAt.startsWith("~/")) {
|
|
324
338
|
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
|
|
325
339
|
const dir = dirname(homeRelativeDir);
|
|
326
|
-
relativePath = "@~/" + (dir === "." ?
|
|
340
|
+
relativePath = "@~/" + (dir === "." ? name : join(dir, name));
|
|
327
341
|
}
|
|
328
342
|
else {
|
|
329
|
-
relativePath = "@" + join(dirname(pathWithoutAt),
|
|
343
|
+
relativePath = "@" + join(dirname(pathWithoutAt), name);
|
|
330
344
|
}
|
|
331
345
|
}
|
|
332
346
|
else {
|
|
333
347
|
if (pathWithoutAt.startsWith("~")) {
|
|
334
|
-
relativePath = "@~/" +
|
|
348
|
+
relativePath = "@~/" + name;
|
|
335
349
|
}
|
|
336
350
|
else {
|
|
337
|
-
relativePath = "@" +
|
|
351
|
+
relativePath = "@" + name;
|
|
338
352
|
}
|
|
339
353
|
}
|
|
340
354
|
}
|
|
341
355
|
else if (prefix.endsWith("/")) {
|
|
342
356
|
// If prefix ends with /, append entry to the prefix
|
|
343
|
-
relativePath = prefix +
|
|
357
|
+
relativePath = prefix + name;
|
|
344
358
|
}
|
|
345
359
|
else if (prefix.includes("/")) {
|
|
346
360
|
// Preserve ~/ format for home directory paths
|
|
347
361
|
if (prefix.startsWith("~/")) {
|
|
348
362
|
const homeRelativeDir = prefix.slice(2); // Remove ~/
|
|
349
363
|
const dir = dirname(homeRelativeDir);
|
|
350
|
-
relativePath = "~/" + (dir === "." ?
|
|
364
|
+
relativePath = "~/" + (dir === "." ? name : join(dir, name));
|
|
351
365
|
}
|
|
352
366
|
else if (prefix.startsWith("/")) {
|
|
353
367
|
// Absolute path - construct properly
|
|
354
368
|
const dir = dirname(prefix);
|
|
355
369
|
if (dir === "/") {
|
|
356
|
-
relativePath = "/" +
|
|
370
|
+
relativePath = "/" + name;
|
|
357
371
|
}
|
|
358
372
|
else {
|
|
359
|
-
relativePath = dir + "/" +
|
|
373
|
+
relativePath = dir + "/" + name;
|
|
360
374
|
}
|
|
361
375
|
}
|
|
362
376
|
else {
|
|
363
|
-
relativePath = join(dirname(prefix),
|
|
377
|
+
relativePath = join(dirname(prefix), name);
|
|
364
378
|
}
|
|
365
379
|
}
|
|
366
380
|
else {
|
|
367
381
|
// For standalone entries, preserve ~/ if original prefix was ~/
|
|
368
382
|
if (prefix.startsWith("~")) {
|
|
369
|
-
relativePath = "~/" +
|
|
383
|
+
relativePath = "~/" + name;
|
|
370
384
|
}
|
|
371
385
|
else {
|
|
372
|
-
relativePath =
|
|
386
|
+
relativePath = name;
|
|
373
387
|
}
|
|
374
388
|
}
|
|
375
389
|
suggestions.push({
|
|
376
390
|
value: isDirectory ? relativePath + "/" : relativePath,
|
|
377
|
-
label:
|
|
391
|
+
label: name,
|
|
378
392
|
description: isDirectory ? "directory" : "file",
|
|
379
393
|
});
|
|
380
394
|
}
|
|
@@ -395,6 +409,62 @@ export class CombinedAutocompleteProvider {
|
|
|
395
409
|
return [];
|
|
396
410
|
}
|
|
397
411
|
}
|
|
412
|
+
// Score an entry against the query (higher = better match)
|
|
413
|
+
// isDirectory adds bonus to prioritize folders
|
|
414
|
+
scoreEntry(filePath, query, isDirectory) {
|
|
415
|
+
const fileName = basename(filePath);
|
|
416
|
+
const lowerFileName = fileName.toLowerCase();
|
|
417
|
+
const lowerQuery = query.toLowerCase();
|
|
418
|
+
let score = 0;
|
|
419
|
+
// Exact filename match (highest)
|
|
420
|
+
if (lowerFileName === lowerQuery)
|
|
421
|
+
score = 100;
|
|
422
|
+
// Filename starts with query
|
|
423
|
+
else if (lowerFileName.startsWith(lowerQuery))
|
|
424
|
+
score = 80;
|
|
425
|
+
// Substring match in filename
|
|
426
|
+
else if (lowerFileName.includes(lowerQuery))
|
|
427
|
+
score = 50;
|
|
428
|
+
// Substring match in full path
|
|
429
|
+
else if (filePath.toLowerCase().includes(lowerQuery))
|
|
430
|
+
score = 30;
|
|
431
|
+
// Directories get a bonus to appear first
|
|
432
|
+
if (isDirectory && score > 0)
|
|
433
|
+
score += 10;
|
|
434
|
+
return score;
|
|
435
|
+
}
|
|
436
|
+
// Fuzzy file search using pure Node.js directory walking (respects .gitignore)
|
|
437
|
+
getFuzzyFileSuggestions(query) {
|
|
438
|
+
try {
|
|
439
|
+
const entries = walkDirectory(this.basePath, query, 100);
|
|
440
|
+
// Score entries
|
|
441
|
+
const scoredEntries = entries
|
|
442
|
+
.map((entry) => ({
|
|
443
|
+
...entry,
|
|
444
|
+
score: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1,
|
|
445
|
+
}))
|
|
446
|
+
.filter((entry) => entry.score > 0);
|
|
447
|
+
// Sort by score (descending) and take top 20
|
|
448
|
+
scoredEntries.sort((a, b) => b.score - a.score);
|
|
449
|
+
const topEntries = scoredEntries.slice(0, 20);
|
|
450
|
+
// Build suggestions
|
|
451
|
+
const suggestions = [];
|
|
452
|
+
for (const { path: entryPath, isDirectory } of topEntries) {
|
|
453
|
+
const entryName = basename(entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath);
|
|
454
|
+
const normalizedPath = entryPath.endsWith("/") ? entryPath.slice(0, -1) : entryPath;
|
|
455
|
+
const valuePath = isDirectory ? normalizedPath + "/" : normalizedPath;
|
|
456
|
+
suggestions.push({
|
|
457
|
+
value: "@" + valuePath,
|
|
458
|
+
label: entryName + (isDirectory ? "/" : ""),
|
|
459
|
+
description: normalizedPath,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return suggestions;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
398
468
|
// Force file completion (called on Tab key) - always returns suggestions
|
|
399
469
|
getForceFileSuggestions(lines, cursorLine, cursorCol) {
|
|
400
470
|
const currentLine = lines[cursorLine] || "";
|