mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
refactor(vscode): remove unused extension code
This commit is contained in:
@@ -53,7 +53,7 @@ import { getWorkspaceRoot } from "./review-utils"
|
||||
import { createMarketplaceRemover, removeAgent, removeMcp } from "./kilo-provider/remove-config-item"
|
||||
import type { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
import { resolveProjectDirectory } from "./project-directory"
|
||||
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
|
||||
import { seedSessionStatuses } from "./session-status"
|
||||
import { normalizeEnhancePromptErrorMessage } from "./enhance-prompt-error"
|
||||
import { retry } from "./services/cli-backend/retry"
|
||||
import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata"
|
||||
@@ -2372,11 +2372,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage(getWorkStylePayload())
|
||||
}
|
||||
|
||||
/** Returns the number of sessions currently in "busy" state. */
|
||||
private getBusySessionCount(): number {
|
||||
return getBusySessionCount(this.sessionStatusMap)
|
||||
}
|
||||
|
||||
private async handleUpdateConfig(
|
||||
partial: Partial<Config>,
|
||||
project: Partial<Config> = {},
|
||||
|
||||
@@ -399,7 +399,7 @@ export class KiloClawProvider implements vscode.Disposable {
|
||||
return false
|
||||
}
|
||||
|
||||
this.attachEventHandlers(events, chat)
|
||||
this.attachEventHandlers(events)
|
||||
this.subscribeSandboxContext()
|
||||
return true
|
||||
}
|
||||
@@ -511,7 +511,7 @@ export class KiloClawProvider implements vscode.Disposable {
|
||||
this.subscribedConversationContext = null
|
||||
}
|
||||
|
||||
private attachEventHandlers(events: EventServiceClient, _chat: KiloChatClient): void {
|
||||
private attachEventHandlers(events: EventServiceClient): void {
|
||||
// Reset on reconnect — the event stream may have missed events while
|
||||
// disconnected, so refetch authoritative state.
|
||||
const offReconnect = events.onReconnect(() => {
|
||||
|
||||
@@ -89,10 +89,6 @@ export class KiloChatClient {
|
||||
})
|
||||
}
|
||||
|
||||
getConversation(conversationId: string): Promise<ConversationDetail> {
|
||||
return this.request(`/v1/conversations/${conversationId}`)
|
||||
}
|
||||
|
||||
createConversation(req: {
|
||||
sandboxId: string
|
||||
title?: string
|
||||
|
||||
@@ -26,11 +26,6 @@ export class TokenManager {
|
||||
|
||||
constructor(private readonly getClient: () => KiloClient | null) {}
|
||||
|
||||
/** Latest resolved token info (may be stale). Used for URL extraction. */
|
||||
peek(): ChatToken | null {
|
||||
return this.cached
|
||||
}
|
||||
|
||||
/** Drop the cached token; next `get` will refetch. */
|
||||
clear(): void {
|
||||
this.cached = null
|
||||
|
||||
@@ -257,37 +257,6 @@ export class AutocompleteServiceManager {
|
||||
return Date.now() < snoozeUntil
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining snooze time in seconds
|
||||
*/
|
||||
public getSnoozeRemainingSeconds(): number {
|
||||
const snoozeUntil = this.settings?.snoozeUntil
|
||||
if (!snoozeUntil) {
|
||||
return 0
|
||||
}
|
||||
const remaining = Math.max(0, Math.ceil((snoozeUntil - Date.now()) / 1000))
|
||||
return remaining
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze autocomplete for a specified number of seconds
|
||||
*/
|
||||
public async snooze(seconds: number): Promise<void> {
|
||||
if (this.snoozeTimer) {
|
||||
clearTimeout(this.snoozeTimer)
|
||||
this.snoozeTimer = null
|
||||
}
|
||||
|
||||
const snoozeUntil = Date.now() + seconds * 1000
|
||||
await writeSettings({ snoozeUntil })
|
||||
|
||||
this.snoozeTimer = setTimeout(() => {
|
||||
void this.unsnooze()
|
||||
}, seconds * 1000)
|
||||
|
||||
await this.load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel snooze and re-enable autocomplete
|
||||
*/
|
||||
|
||||
-20
@@ -84,10 +84,6 @@ vi.mock("../classic-auto-complete/AutocompleteInlineCompletionProvider", () => {
|
||||
public setModel(id: string) {
|
||||
this.modelId = id
|
||||
}
|
||||
public getModelId(): string {
|
||||
return this.modelId
|
||||
}
|
||||
|
||||
constructor(..._args: any[]) {}
|
||||
}
|
||||
return { AutocompleteInlineCompletionProvider }
|
||||
@@ -317,21 +313,5 @@ describe("AutocompleteServiceManager (less mocked logic)", () => {
|
||||
|
||||
expect(manager.isSnoozed()).toBe(true)
|
||||
})
|
||||
|
||||
it("getSnoozeRemainingSeconds() returns 0 when not snoozed", async () => {
|
||||
const manager = await createManager()
|
||||
;(manager as any).settings = {}
|
||||
|
||||
expect(manager.getSnoozeRemainingSeconds()).toBe(0)
|
||||
})
|
||||
|
||||
it("getSnoozeRemainingSeconds() returns a positive number when snoozed", async () => {
|
||||
const manager = await createManager()
|
||||
;(manager as any).settings = { snoozeUntil: Date.now() + 30_000 }
|
||||
|
||||
const remaining = manager.getSnoozeRemainingSeconds()
|
||||
expect(remaining).toBeGreaterThan(0)
|
||||
expect(remaining).toBeLessThanOrEqual(30)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-4
@@ -233,10 +233,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
this.contextProvider.modelId = modelId
|
||||
}
|
||||
|
||||
public getModelId(): string {
|
||||
return this.contextProvider.modelId
|
||||
}
|
||||
|
||||
private processSuggestion(
|
||||
suggestionText: string,
|
||||
prefix: string,
|
||||
|
||||
@@ -133,13 +133,6 @@ export class ErrorBackoff {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a fatal (non-retriable) error is active — credits depleted, auth invalid, etc.
|
||||
*/
|
||||
isFatal(): boolean {
|
||||
return this.fatal !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP status code of the fatal error, or null.
|
||||
*/
|
||||
|
||||
+3
-23
@@ -55,9 +55,7 @@ vi.mock("../../continuedev/core/autocomplete/snippets/getAllSnippets", () => ({
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [],
|
||||
clipboardSnippets: [],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
}),
|
||||
}))
|
||||
@@ -133,9 +131,7 @@ describe("AutocompleteContextProvider", () => {
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [],
|
||||
clipboardSnippets: [],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
|
||||
@@ -178,9 +174,7 @@ describe("AutocompleteContextProvider", () => {
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [],
|
||||
clipboardSnippets: [],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
|
||||
@@ -275,9 +269,7 @@ describe("AutocompleteContextProvider", () => {
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [],
|
||||
clipboardSnippets: [],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
|
||||
@@ -318,12 +310,6 @@ describe("AutocompleteContextProvider", () => {
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [
|
||||
{
|
||||
content: "diff content",
|
||||
type: AutocompleteSnippetType.Diff,
|
||||
},
|
||||
],
|
||||
clipboardSnippets: [
|
||||
{
|
||||
content: "clipboard content",
|
||||
@@ -331,14 +317,12 @@ describe("AutocompleteContextProvider", () => {
|
||||
copiedAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
|
||||
const { getSnippets } = await import("../../continuedev/core/autocomplete/templating/filtering")
|
||||
;(getSnippets as any).mockImplementation((_helper: any, payload: any) => [
|
||||
...payload.recentlyOpenedFileSnippets,
|
||||
...payload.diffSnippets,
|
||||
...payload.clipboardSnippets,
|
||||
])
|
||||
|
||||
@@ -357,11 +341,9 @@ describe("AutocompleteContextProvider", () => {
|
||||
result.snippetsWithUris.some((s) => "filepath" in s && s.filepath && s.filepath.includes("blocked.ts")),
|
||||
).toBe(false)
|
||||
// But should contain snippets without file paths
|
||||
expect(result.snippetsWithUris).toHaveLength(2)
|
||||
expect(result.snippetsWithUris[0].content).toBe("diff content")
|
||||
expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Diff)
|
||||
expect(result.snippetsWithUris[1].content).toBe("clipboard content")
|
||||
expect(result.snippetsWithUris[1].type).toBe(AutocompleteSnippetType.Clipboard)
|
||||
expect(result.snippetsWithUris).toHaveLength(1)
|
||||
expect(result.snippetsWithUris[0].content).toBe("clipboard content")
|
||||
expect(result.snippetsWithUris[0].type).toBe(AutocompleteSnippetType.Clipboard)
|
||||
})
|
||||
|
||||
it("should allow all files when no ignore controller is provided", async () => {
|
||||
@@ -389,9 +371,7 @@ describe("AutocompleteContextProvider", () => {
|
||||
rootPathSnippets: [],
|
||||
recentlyEditedRangeSnippets: [],
|
||||
recentlyVisitedRangesSnippets: [],
|
||||
diffSnippets: [],
|
||||
clipboardSnippets: [],
|
||||
ideSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
|
||||
|
||||
-2
@@ -4,7 +4,6 @@ import { VsCodeIde } from "../continuedev/core/vscode-test-harness/src/VSCodeIde
|
||||
import { AutocompleteInput } from "../types"
|
||||
import { HelperVars } from "../continuedev/core/autocomplete/util/HelperVars"
|
||||
import { getAllSnippetsWithoutRace } from "../continuedev/core/autocomplete/snippets/getAllSnippets"
|
||||
import { getDefinitionsFromLsp } from "../continuedev/core/vscode-test-harness/src/autocomplete/lsp"
|
||||
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../continuedev/core/util/parameters"
|
||||
import { getSnippets } from "../continuedev/core/autocomplete/templating/filtering"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
@@ -97,7 +96,6 @@ export async function getProcessedSnippets(
|
||||
const snippetPayload = await getAllSnippetsWithoutRace({
|
||||
helper,
|
||||
ide,
|
||||
getDefinitionsFromLsp,
|
||||
contextRetrievalService: contextService,
|
||||
})
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils"
|
||||
const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"]
|
||||
|
||||
export class VisibleCodeTracker {
|
||||
private lastContext: VisibleCodeContext | null = null
|
||||
|
||||
constructor(
|
||||
private workspacePath: string,
|
||||
private ignoreController: FileIgnoreController | null = null,
|
||||
@@ -101,19 +99,10 @@ export class VisibleCodeTracker {
|
||||
})
|
||||
}
|
||||
|
||||
this.lastContext = {
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
editors: editorInfos,
|
||||
}
|
||||
|
||||
return this.lastContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last captured context, or null if never captured.
|
||||
*/
|
||||
public getLastContext(): VisibleCodeContext | null {
|
||||
return this.lastContext
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
import { getUriFileExtension } from "../../util/uri"
|
||||
import { BracketMatchingService } from "../filtering/BracketMatchingService"
|
||||
import { CharacterFilter, LineFilter } from "../filtering/streamTransforms/lineStream"
|
||||
|
||||
type LineStream = AsyncGenerator<string>
|
||||
type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream
|
||||
type CharacterFilter = (args: {
|
||||
chars: AsyncGenerator<string>
|
||||
prefix: string
|
||||
suffix: string
|
||||
filepath: string
|
||||
multiline: boolean
|
||||
}) => AsyncGenerator<string>
|
||||
|
||||
export interface AutocompleteLanguageInfo {
|
||||
/**
|
||||
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { rankAndOrderSnippets, fillPromptWithSnippets } from "./index"
|
||||
import { RankedSnippet } from "../../types"
|
||||
|
||||
// vibecoded
|
||||
describe("rankAndOrderSnippets", () => {
|
||||
it("should rank and order snippets by similarity to cursor context", () => {
|
||||
// Create a simple mock HelperVars with only the required properties
|
||||
const mockHelper = {
|
||||
fullPrefix: "function calculateTotal(items) {\n const total = items.reduce(",
|
||||
fullSuffix: ", 0);\n return total;\n}",
|
||||
options: {
|
||||
slidingWindowSize: 50,
|
||||
slidingWindowPrefixPercentage: 0.5,
|
||||
},
|
||||
} as any
|
||||
|
||||
// Create test snippets with different levels of similarity to the cursor context
|
||||
const snippets: RankedSnippet[] = [
|
||||
{
|
||||
filepath: "utils.ts",
|
||||
range: {
|
||||
start: { line: 10, character: 0 },
|
||||
end: { line: 12, character: 0 },
|
||||
},
|
||||
contents: "// Helper function for database queries\nfunction queryDb() {}",
|
||||
},
|
||||
{
|
||||
filepath: "math.ts",
|
||||
range: {
|
||||
start: { line: 5, character: 0 },
|
||||
end: { line: 7, character: 0 },
|
||||
},
|
||||
contents: "// Array reduce function\nconst sum = items.reduce((acc, item) => acc + item, 0);",
|
||||
},
|
||||
{
|
||||
filepath: "helpers.ts",
|
||||
range: {
|
||||
start: { line: 20, character: 0 },
|
||||
end: { line: 22, character: 0 },
|
||||
},
|
||||
contents:
|
||||
"// Calculate total with reduce\nfunction calculateSum(items) {\n return items.reduce((a, b) => a + b);\n}",
|
||||
},
|
||||
]
|
||||
|
||||
const result = rankAndOrderSnippets(snippets, mockHelper)
|
||||
|
||||
// Verify the result has the expected structure
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
// All snippets should have scores assigned
|
||||
expect(result.every((s) => typeof s.score === "number")).toBe(true)
|
||||
|
||||
// All snippets should have required properties
|
||||
result.forEach((snippet) => {
|
||||
expect(snippet.filepath).toBeDefined()
|
||||
expect(snippet.range).toBeDefined()
|
||||
expect(snippet.contents).toBeDefined()
|
||||
expect(snippet.score).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
// Scores should be in ascending order (lower scores = better matches)
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].score).toBeLessThanOrEqual(result[i + 1].score)
|
||||
}
|
||||
})
|
||||
})
|
||||
// vibecoded
|
||||
|
||||
describe("fillPromptWithSnippets", () => {
|
||||
it("should fill token budget with snippets until limit is reached", () => {
|
||||
// Create snippets with required properties (including score)
|
||||
const snippets: Required<RankedSnippet>[] = [
|
||||
{
|
||||
filepath: "math.ts",
|
||||
range: {
|
||||
start: { line: 1, character: 0 },
|
||||
end: { line: 3, character: 0 },
|
||||
},
|
||||
contents: "function add(a, b) { return a + b; }",
|
||||
score: 0.1,
|
||||
},
|
||||
{
|
||||
filepath: "utils.ts",
|
||||
range: {
|
||||
start: { line: 5, character: 0 },
|
||||
end: { line: 7, character: 0 },
|
||||
},
|
||||
contents: "function multiply(x, y) { return x * y; }",
|
||||
score: 0.2,
|
||||
},
|
||||
{
|
||||
filepath: "helpers.ts",
|
||||
range: {
|
||||
start: { line: 10, character: 0 },
|
||||
end: { line: 15, character: 0 },
|
||||
},
|
||||
contents: "function calculateSum(items) {\n return items.reduce((acc, item) => acc + item, 0);\n}",
|
||||
score: 0.3,
|
||||
},
|
||||
]
|
||||
|
||||
// Set token limit to include first 2 snippets but not the third
|
||||
// Using a model name and a reasonable token limit
|
||||
const maxSnippetTokens = 25
|
||||
const modelName = "gpt-3.5-turbo"
|
||||
|
||||
const result = fillPromptWithSnippets(snippets, maxSnippetTokens, modelName)
|
||||
|
||||
// Verify that we got fewer snippets than we started with
|
||||
expect(result.length).toBeLessThan(snippets.length)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify all returned snippets are from the original list
|
||||
result.forEach((snippet) => {
|
||||
expect(snippets).toContainEqual(snippet)
|
||||
})
|
||||
|
||||
// Verify snippets maintain their order (first snippets are kept)
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
expect(result[i]).toBe(snippets[i])
|
||||
}
|
||||
})
|
||||
})
|
||||
+3
-127
@@ -1,133 +1,9 @@
|
||||
import { RangeInFileWithContents } from "../../../"
|
||||
import { countTokens } from "../../../llm/countTokens"
|
||||
import { RankedSnippet } from "../../types"
|
||||
import { HelperVars } from "../../util/HelperVars"
|
||||
|
||||
const rx = /[\s.,/#!$%^&*;:{}=\-_`~()[\]]/g
|
||||
|
||||
export function getSymbolsForSnippet(snippet: string): Set<string> {
|
||||
const symbols = snippet
|
||||
.split(rx)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== "")
|
||||
.map((symbol) => symbol.trim())
|
||||
.filter((symbol) => symbol !== "")
|
||||
return new Set(symbols)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity as number of shared symbols divided by total number of unique symbols between both.
|
||||
*/
|
||||
function jaccardSimilarity(a: string, b: string): number {
|
||||
const aSet = getSymbolsForSnippet(a)
|
||||
const bSet = getSymbolsForSnippet(b)
|
||||
const union = new Set([...aSet, ...bSet]).size
|
||||
|
||||
// Avoid division by zero
|
||||
if (union === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let intersection = 0
|
||||
for (const symbol of aSet) {
|
||||
if (bSet.has(symbol)) {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
|
||||
return intersection / union
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank code snippets to be used in tab-autocomplete prompt. Returns a sorted version of the snippet array.
|
||||
*/
|
||||
export function rankAndOrderSnippets(ranges: RankedSnippet[], helper: HelperVars): Required<RankedSnippet>[] {
|
||||
//MINIMAL_REPO - this isn't actually used in continue
|
||||
const windowAroundCursor =
|
||||
helper.fullPrefix.slice(-helper.options.slidingWindowSize * helper.options.slidingWindowPrefixPercentage) +
|
||||
helper.fullSuffix.slice(helper.options.slidingWindowSize * (1 - helper.options.slidingWindowPrefixPercentage))
|
||||
|
||||
const snippets: Required<RankedSnippet>[] = ranges.map((snippet) => ({
|
||||
score: snippet.score ?? jaccardSimilarity(snippet.contents, windowAroundCursor),
|
||||
...snippet,
|
||||
}))
|
||||
const uniqueSnippets = deduplicateSnippets(snippets)
|
||||
return uniqueSnippets.sort((a, b) => a.score - b.score)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate code snippets by merging overlapping ranges into a single range.
|
||||
*/
|
||||
function deduplicateSnippets(snippets: Required<RankedSnippet>[]): Required<RankedSnippet>[] {
|
||||
// Group by file
|
||||
const fileGroups: {
|
||||
[key: string]: Required<RankedSnippet>[]
|
||||
} = {}
|
||||
for (const snippet of snippets) {
|
||||
if (!fileGroups[snippet.filepath]) {
|
||||
fileGroups[snippet.filepath] = []
|
||||
}
|
||||
fileGroups[snippet.filepath].push(snippet)
|
||||
}
|
||||
|
||||
// Merge overlapping ranges
|
||||
const allRanges = []
|
||||
for (const file of Object.keys(fileGroups)) {
|
||||
allRanges.push(...mergeSnippetsByRange(fileGroups[file]))
|
||||
}
|
||||
return allRanges
|
||||
}
|
||||
|
||||
function mergeSnippetsByRange(snippets: Required<RankedSnippet>[]): Required<RankedSnippet>[] {
|
||||
if (snippets.length <= 1) {
|
||||
return snippets
|
||||
}
|
||||
|
||||
const sorted = snippets.sort((a, b) => a.range.start.line - b.range.start.line)
|
||||
const merged: Required<RankedSnippet>[] = []
|
||||
|
||||
while (sorted.length > 0) {
|
||||
const next = sorted.shift()!
|
||||
const last = merged[merged.length - 1]
|
||||
if (merged.length > 0 && last.range.end.line >= next.range.start.line) {
|
||||
// Merge with previous snippet
|
||||
last.score = Math.max(last.score, next.score)
|
||||
try {
|
||||
last.range.end = next.range.end
|
||||
} catch (e) {
|
||||
console.log("Error merging ranges", e)
|
||||
}
|
||||
last.contents = mergeOverlappingRangeContents(last, next)
|
||||
} else {
|
||||
merged.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeOverlappingRangeContents(first: RangeInFileWithContents, second: RangeInFileWithContents): string {
|
||||
const firstLines = first.contents.split("\n")
|
||||
const numOverlapping = first.range.end.line - second.range.start.line
|
||||
return `${firstLines.slice(-numOverlapping).join("\n")}\n${second.contents}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the allowed space with snippets.
|
||||
* It is assumed that the snippets are sorted by score.
|
||||
*/
|
||||
export function fillPromptWithSnippets( //MINIMAL_REPO - this isn't actually used in continue
|
||||
snippets: Required<RankedSnippet>[],
|
||||
maxSnippetTokens: number,
|
||||
modelName: string,
|
||||
): Required<RankedSnippet>[] {
|
||||
let tokensRemaining = maxSnippetTokens
|
||||
const keptSnippets: Required<RankedSnippet>[] = []
|
||||
for (let i = 0; i < snippets.length; i++) {
|
||||
const snippet = snippets[i]
|
||||
const tokenCount = countTokens(snippet.contents, modelName)
|
||||
if (tokensRemaining - tokenCount >= 0) {
|
||||
tokensRemaining -= tokenCount
|
||||
keptSnippets.push(snippet)
|
||||
}
|
||||
}
|
||||
|
||||
return keptSnippets
|
||||
}
|
||||
|
||||
-25
@@ -48,31 +48,6 @@ export class StaticContextService {
|
||||
})
|
||||
}
|
||||
|
||||
public static formatAutocompleteStaticSnippet(ctx: StaticContext): string {
|
||||
let output = `AutocompleteStaticSnippet:\n`
|
||||
output += ` holeType: ${ctx.holeType}\n`
|
||||
|
||||
output += ` relevantTypes:\n`
|
||||
if (ctx.relevantTypes.size === 0) {
|
||||
output += ` (none)\n`
|
||||
} else {
|
||||
ctx.relevantTypes.forEach((types, filepath) => {
|
||||
output += ` ${filepath}: [${types.join(", ")}]\n`
|
||||
})
|
||||
}
|
||||
|
||||
output += ` relevantHeaders:\n`
|
||||
if (ctx.relevantHeaders.size === 0) {
|
||||
output += ` (none)\n`
|
||||
} else {
|
||||
ctx.relevantHeaders.forEach((headers, filepath) => {
|
||||
output += ` ${filepath}: [${headers.join(", ")}]\n`
|
||||
})
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
public async getContext(helper: HelperVars): Promise<AutocompleteStaticSnippet[]> {
|
||||
const tsFiles = await this.getTypeScriptFilesFromWorkspaces(helper.workspaceUris)
|
||||
// Get the three contexts holeContext, relevantTypes, relevantHeaders.
|
||||
|
||||
+77
-284
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { BracketMatchingService, BRACKETS, BRACKETS_REVERSE } from "./BracketMatchingService"
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { BRACKETS, BRACKETS_REVERSE, BracketMatchingService } from "./BracketMatchingService"
|
||||
|
||||
describe("BracketMatchingService", () => {
|
||||
let service: BracketMatchingService
|
||||
@@ -8,300 +8,93 @@ describe("BracketMatchingService", () => {
|
||||
service = new BracketMatchingService()
|
||||
})
|
||||
|
||||
describe("BRACKETS constants", () => {
|
||||
it("should have correct opening-to-closing bracket mappings", () => {
|
||||
expect(BRACKETS["("]).toBe(")")
|
||||
expect(BRACKETS["{"]).toBe("}")
|
||||
expect(BRACKETS["["]).toBe("]")
|
||||
})
|
||||
|
||||
it("should have correct closing-to-opening bracket mappings", () => {
|
||||
expect(BRACKETS_REVERSE[")"]).toBe("(")
|
||||
expect(BRACKETS_REVERSE["}"]).toBe("{")
|
||||
expect(BRACKETS_REVERSE["]"]).toBe("[")
|
||||
})
|
||||
it("defines matching bracket pairs", () => {
|
||||
expect(BRACKETS).toEqual({ "(": ")", "{": "}", "[": "]" })
|
||||
expect(BRACKETS_REVERSE).toEqual({ ")": "(", "}": "{", "]": "[" })
|
||||
})
|
||||
|
||||
describe("handleAcceptedCompletion", () => {
|
||||
it("should track unmatched opening brackets from completion", () => {
|
||||
service.handleAcceptedCompletion("function test() {", "test.ts")
|
||||
// Internal state should track the unmatched opening brackets
|
||||
// We can verify this by checking behavior in subsequent calls
|
||||
})
|
||||
async function* stream(chunks: string[]): AsyncGenerator<string> {
|
||||
for (const chunk of chunks) yield chunk
|
||||
}
|
||||
|
||||
it("should handle matched bracket pairs correctly", () => {
|
||||
service.handleAcceptedCompletion("function test() { return 1; }", "test.ts")
|
||||
// All brackets are matched, so stack should be empty
|
||||
})
|
||||
async function collect(gen: AsyncGenerator<string>): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
for await (const chunk of gen) chunks.push(chunk)
|
||||
return chunks.join("")
|
||||
}
|
||||
|
||||
it("should handle multiple unmatched opening brackets", () => {
|
||||
service.handleAcceptedCompletion("if (condition) { while (true) {", "test.ts")
|
||||
// Should track both unmatched { brackets
|
||||
})
|
||||
|
||||
it("should handle nested bracket structures", () => {
|
||||
service.handleAcceptedCompletion("arr[0] = { key: [1, 2]", "test.ts")
|
||||
// Should track unmatched { and [
|
||||
})
|
||||
|
||||
it("should stop tracking when encountering unmatched closing bracket", () => {
|
||||
service.handleAcceptedCompletion("function test() { } }", "test.ts")
|
||||
// Should stop when encountering the extra closing brace
|
||||
})
|
||||
|
||||
it("should handle different bracket types", () => {
|
||||
service.handleAcceptedCompletion("const obj = { arr: [1, (2", "test.ts")
|
||||
// Should track {, [, and (
|
||||
})
|
||||
|
||||
it("should reset state for each new completion", () => {
|
||||
service.handleAcceptedCompletion("function test() {", "test.ts")
|
||||
service.handleAcceptedCompletion("class MyClass {", "test.ts")
|
||||
// Second call should reset state from first call
|
||||
})
|
||||
|
||||
it("should update filepath tracking", () => {
|
||||
service.handleAcceptedCompletion("function a() {", "file1.ts")
|
||||
service.handleAcceptedCompletion("function b() {", "file2.ts")
|
||||
// Should track that we're now in file2.ts
|
||||
})
|
||||
|
||||
it("should handle empty completion string", () => {
|
||||
service.handleAcceptedCompletion("", "test.ts")
|
||||
// Should not throw and should have empty stack
|
||||
})
|
||||
|
||||
it("should handle completion with only text and no brackets", () => {
|
||||
service.handleAcceptedCompletion("const x = 5;", "test.ts")
|
||||
// Should complete successfully with empty bracket stack
|
||||
})
|
||||
|
||||
it("should handle complex nested structure", () => {
|
||||
service.handleAcceptedCompletion("obj = { a: [1, { b: (x", "test.ts")
|
||||
// Should track {, [, {, (
|
||||
})
|
||||
it("allows a matching single-line closing bracket", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(
|
||||
stream(["x + 1)"]),
|
||||
"const result = calculate(",
|
||||
");",
|
||||
"test.ts",
|
||||
false,
|
||||
)
|
||||
expect(await collect(result)).toBe("x + 1)")
|
||||
})
|
||||
|
||||
describe("stopOnUnmatchedClosingBracket", () => {
|
||||
// Helper function to create async generator from array
|
||||
async function* arrayToAsyncGen(arr: string[]): AsyncGenerator<string> {
|
||||
for (const item of arr) {
|
||||
yield item
|
||||
}
|
||||
}
|
||||
it("stops at an unmatched single-line closing bracket", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(
|
||||
stream(["x + 1))"]),
|
||||
"const result = calculate(",
|
||||
"",
|
||||
"test.ts",
|
||||
false,
|
||||
)
|
||||
expect(await collect(result)).toBe("x + 1)")
|
||||
})
|
||||
|
||||
// Helper to collect all values from async generator
|
||||
async function collectAll(gen: AsyncGenerator<string>): Promise<string[]> {
|
||||
const results: string[] = []
|
||||
for await (const item of gen) {
|
||||
results.push(item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
it("tracks brackets opened by the current stream", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(
|
||||
stream(["function test() {", "\n return 1;", "\n}"]),
|
||||
"",
|
||||
"",
|
||||
"test.ts",
|
||||
true,
|
||||
)
|
||||
expect(await collect(result)).toBe("function test() {\n return 1;\n}")
|
||||
})
|
||||
|
||||
describe("multiline mode", () => {
|
||||
it("should allow closing brackets that match previous completion", async () => {
|
||||
service.handleAcceptedCompletion("function test() {", "test.ts")
|
||||
const stream = arrayToAsyncGen(["\n return 1;\n}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() ", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("\n return 1;\n}")
|
||||
})
|
||||
it("stops at an unmatched multiline closing bracket", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(
|
||||
stream(["function test() {\n return 1;\n}\n}"]),
|
||||
"",
|
||||
"",
|
||||
"test.ts",
|
||||
true,
|
||||
)
|
||||
expect(await collect(result)).toBe("function test() {\n return 1;\n}\n")
|
||||
})
|
||||
|
||||
it("should not use previous completion state from different file", async () => {
|
||||
service.handleAcceptedCompletion("function test() {", "file1.ts")
|
||||
const stream = arrayToAsyncGen(["}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// Different file so stack is empty, but '}' is in whitespace section
|
||||
// Whitespace section (closing brackets before non-whitespace) yields without checking
|
||||
// Since '}' doesn't match /[^\s\)\}\]]/, it's all whitespace/closing brackets
|
||||
// So entire chunk is yielded and loop continues to end
|
||||
expect(result.join("")).toBe("}")
|
||||
})
|
||||
it("handles nested bracket types", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(stream(["arr[i][j]"]), "const val = ", ";", "test.ts", false)
|
||||
expect(await collect(result)).toBe("arr[i][j]")
|
||||
})
|
||||
|
||||
it("should stop on unmatched closing bracket in multiline", async () => {
|
||||
const stream = arrayToAsyncGen(["function test() {\n return 1;\n}\n}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// The chunk contains one complete function and one extra '}'
|
||||
// Processing char by char: '{' at position 16 opens, '}' at position 32 closes (stack empty)
|
||||
// '\n' at position 33, then '}' at position 34 is unmatched (stack empty)
|
||||
// Yields chunk.slice(0, 34) which includes the newline after the first }
|
||||
expect(result.join("")).toBe("function test() {\n return 1;\n}\n")
|
||||
})
|
||||
it("uses closing brackets from a whitespace-prefixed suffix", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(stream(["1, 2, 3)"]), "func(", " )", "test.ts", false)
|
||||
expect(await collect(result)).toBe("1, 2, 3)")
|
||||
})
|
||||
|
||||
it("should handle multiple chunks in stream", async () => {
|
||||
const stream = arrayToAsyncGen(["function", " test()", " {", "\n return", " 1;", "\n}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("function test() {\n return 1;\n}")
|
||||
})
|
||||
})
|
||||
it("stops suffix bracket parsing at other content", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(stream(["x)"]), "func(", ") {", "test.ts", false)
|
||||
expect(await collect(result)).toBe("x")
|
||||
})
|
||||
|
||||
describe("single-line mode", () => {
|
||||
it("should allow completing brackets from current line", async () => {
|
||||
const stream = arrayToAsyncGen(["x + 1)"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(
|
||||
stream,
|
||||
"const result = calculate(",
|
||||
");",
|
||||
"test.ts",
|
||||
false,
|
||||
)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("x + 1)")
|
||||
})
|
||||
it("handles a closing bracket at a chunk boundary", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(
|
||||
stream(["return 1", ";", "\n", "}", "extra"]),
|
||||
"function test() {",
|
||||
"",
|
||||
"test.ts",
|
||||
true,
|
||||
)
|
||||
expect(await collect(result)).toBe("return 1;\n")
|
||||
})
|
||||
|
||||
it("should handle bracket in suffix that gets overwritten", async () => {
|
||||
const stream = arrayToAsyncGen(["1, 2, 3)"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "array.push(", ");", "test.ts", false)
|
||||
const result = await collectAll(filtered)
|
||||
// Should allow the closing paren because suffix has one
|
||||
expect(result.join("")).toBe("1, 2, 3)")
|
||||
})
|
||||
|
||||
it("should stop on unmatched closing bracket in single-line", async () => {
|
||||
const stream = arrayToAsyncGen(["x + 1))"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(
|
||||
stream,
|
||||
"const result = calculate(",
|
||||
"",
|
||||
"test.ts",
|
||||
false,
|
||||
)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("x + 1)")
|
||||
})
|
||||
|
||||
it("should handle multiple bracket pairs on current line", async () => {
|
||||
const stream = arrayToAsyncGen(['" + y + ")])'])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(
|
||||
stream,
|
||||
'array.push({ key: getValue("x',
|
||||
"",
|
||||
"test.ts",
|
||||
false,
|
||||
)
|
||||
const result = await collectAll(filtered)
|
||||
// Current line has: { ( (
|
||||
// Stream closes: ) ] )
|
||||
// First ) matches third (, second ] doesn't match second ( (expects }), stops before ]
|
||||
expect(result.join("")).toBe('" + y + ")')
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty stream", async () => {
|
||||
const stream = arrayToAsyncGen([])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle stream with only whitespace before brackets", async () => {
|
||||
const stream = arrayToAsyncGen([" \n }"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe(" \n }")
|
||||
})
|
||||
|
||||
it("should allow closing brackets before non-whitespace content", async () => {
|
||||
const stream = arrayToAsyncGen([")\n const x = 1;"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test(", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// In multiline mode with no previous completion, stack starts empty but prefix has '('
|
||||
// Actually, prefix is NOT processed in multiline mode (only previous completion state)
|
||||
// Whitespace section: searches for /[^\s\)\}\]]/, finds 'c' at index 7
|
||||
// Yields ')\n ' (everything before 'c'), then processes 'const x = 1;'
|
||||
// 'const x = 1;' has no brackets, yields entire remaining chunk
|
||||
expect(result.join("")).toBe(")\n const x = 1;")
|
||||
})
|
||||
|
||||
it("should handle mixed bracket types correctly", async () => {
|
||||
const stream = arrayToAsyncGen(["]})"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "obj = { arr: [{ key: val", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("]})")
|
||||
})
|
||||
|
||||
it("should handle suffix with spaces before closing bracket", async () => {
|
||||
const stream = arrayToAsyncGen(["1, 2, 3)"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", " )", "test.ts", false)
|
||||
const result = await collectAll(filtered)
|
||||
// Spaces in suffix should be ignored, bracket should be added to stack
|
||||
expect(result.join("")).toBe("1, 2, 3)")
|
||||
})
|
||||
|
||||
it("should stop when suffix parsing ends at non-bracket", async () => {
|
||||
const stream = arrayToAsyncGen(["x)"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "func(", ") {", "test.ts", false)
|
||||
const result = await collectAll(filtered)
|
||||
// In single-line mode, current line is 'func() {'
|
||||
// Stack from current line: ( opens, ) closes (matches), { opens -> stack = ['{']
|
||||
// Suffix ') {': unshift adds '(' to FRONT of stack -> stack = ['(', '{']
|
||||
// Stream 'x)': ')' is closing bracket
|
||||
// stack.pop() removes from END, returns '{', BRACKETS['{'] = '}', char = ')'
|
||||
// '}' !== ')' so condition is true, stops and yields 'x'
|
||||
expect(result.join("")).toBe("x")
|
||||
})
|
||||
|
||||
it("should handle chunk boundary on closing bracket", async () => {
|
||||
const stream = arrayToAsyncGen(["return 1", ";", "\n", "}", "extra"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "function test() {", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// In multiline mode without previous completion state, stack starts empty
|
||||
// Prefix doesn't add to stack in multiline mode
|
||||
// First chunk 'return 1' has no brackets, yielded
|
||||
// Second chunk ';' has no brackets, yielded
|
||||
// Third chunk '\n' is whitespace with no brackets, still in whitespace section
|
||||
// Fourth chunk '}' is closing bracket in whitespace section, but stack is empty so stops immediately
|
||||
expect(result.join("")).toBe("return 1;\n")
|
||||
})
|
||||
|
||||
it("should handle nested brackets in stream", async () => {
|
||||
const stream = arrayToAsyncGen(["arr[i][j]"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "const val = ", ";", "test.ts", false)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("arr[i][j]")
|
||||
})
|
||||
|
||||
it("should handle unmatched opening brackets in stream", async () => {
|
||||
const stream = arrayToAsyncGen(["arr[index"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("arr[index")
|
||||
})
|
||||
})
|
||||
|
||||
describe("state persistence across completions", () => {
|
||||
it("should use state from previous completion in same file", async () => {
|
||||
service.handleAcceptedCompletion("if (cond) {\n while (true) {", "test.ts")
|
||||
const stream = arrayToAsyncGen(["\n doWork();\n }\n}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "test.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
expect(result.join("")).toBe("\n doWork();\n }\n}")
|
||||
})
|
||||
|
||||
it("should not use state from previous file", async () => {
|
||||
service.handleAcceptedCompletion("if (cond) {", "file1.ts")
|
||||
const stream = arrayToAsyncGen(["}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// Different file so stack is empty, but '}' is in whitespace section
|
||||
// Since '}' doesn't match /[^\s\)\}\]]/, entire chunk yielded without bracket checking
|
||||
expect(result.join("")).toBe("}")
|
||||
})
|
||||
|
||||
it("should clear state when switching files", async () => {
|
||||
service.handleAcceptedCompletion("function a() {", "file1.ts")
|
||||
service.handleAcceptedCompletion("function b() {", "file2.ts")
|
||||
const stream = arrayToAsyncGen(["\n return;\n}"])
|
||||
const filtered = service.stopOnUnmatchedClosingBracket(stream, "", "", "file2.ts", true)
|
||||
const result = await collectAll(filtered)
|
||||
// Should only allow one closing brace from file2's state
|
||||
expect(result.join("")).toBe("\n return;\n}")
|
||||
})
|
||||
})
|
||||
it("handles an empty stream", async () => {
|
||||
const result = service.stopOnUnmatchedClosingBracket(stream([]), "", "", "test.ts", true)
|
||||
expect(await collect(result)).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
+13
-47
@@ -13,61 +13,27 @@ export const BRACKETS_REVERSE: { [key: string]: string } = {
|
||||
* But sometimes we started the pair in a previous autocomplete suggestion
|
||||
*/
|
||||
export class BracketMatchingService {
|
||||
private openingBracketsFromLastCompletion: string[] = []
|
||||
private lastCompletionFile: string | undefined = undefined
|
||||
|
||||
handleAcceptedCompletion(completion: string, filepath: string) {
|
||||
this.openingBracketsFromLastCompletion = []
|
||||
const stack: string[] = []
|
||||
|
||||
for (let i = 0; i < completion.length; i++) {
|
||||
const char = completion[i]
|
||||
if (Object.keys(BRACKETS).includes(char)) {
|
||||
// It's an opening bracket
|
||||
stack.push(char)
|
||||
} else if (Object.values(BRACKETS).includes(char)) {
|
||||
// It's a closing bracket
|
||||
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining opening brackets in the stack are uncompleted
|
||||
this.openingBracketsFromLastCompletion = stack
|
||||
this.lastCompletionFile = filepath
|
||||
}
|
||||
|
||||
async *stopOnUnmatchedClosingBracket(
|
||||
stream: AsyncGenerator<string>,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
filepath: string,
|
||||
_filepath: string,
|
||||
multiline: boolean, // Whether this is a multiline completion or not
|
||||
): AsyncGenerator<string> {
|
||||
let stack: string[] = []
|
||||
if (multiline) {
|
||||
// Add opening brackets from the previous response
|
||||
if (this.lastCompletionFile === filepath) {
|
||||
stack = [...this.openingBracketsFromLastCompletion]
|
||||
} else {
|
||||
this.lastCompletionFile = undefined
|
||||
}
|
||||
} else {
|
||||
const stack: string[] = []
|
||||
if (!multiline) {
|
||||
// If single line completion, then allow completing bracket pairs that are
|
||||
// started on the current line but not finished on the current line
|
||||
if (!multiline) {
|
||||
const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "")
|
||||
for (let i = 0; i < currentLine.length; i++) {
|
||||
const char = currentLine[i]
|
||||
if (Object.keys(BRACKETS).includes(char)) {
|
||||
// It's an opening bracket
|
||||
stack.push(char)
|
||||
} else if (Object.values(BRACKETS).includes(char)) {
|
||||
// It's a closing bracket
|
||||
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
|
||||
break
|
||||
}
|
||||
const currentLine = (prefix.split("\n").pop() ?? "") + (suffix.split("\n")[0] ?? "")
|
||||
for (let i = 0; i < currentLine.length; i++) {
|
||||
const char = currentLine[i]
|
||||
if (Object.keys(BRACKETS).includes(char)) {
|
||||
// It's an opening bracket
|
||||
stack.push(char)
|
||||
} else if (Object.values(BRACKETS).includes(char)) {
|
||||
// It's a closing bracket
|
||||
if (stack.length === 0 || BRACKETS[stack.pop()!] !== char) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"
|
||||
|
||||
import {
|
||||
avoidPathLine,
|
||||
avoidEmptyComments,
|
||||
streamWithNewLines,
|
||||
lineIsRepeated,
|
||||
stopAtSimilarLine,
|
||||
stopAtLines,
|
||||
LINES_TO_STOP_AT,
|
||||
PREFIXES_TO_SKIP,
|
||||
skipPrefixes,
|
||||
stopAtRepeatingLines,
|
||||
} from "./lineStream"
|
||||
|
||||
describe("lineStream (production-used subset)", () => {
|
||||
let mockFullStop: Mock
|
||||
|
||||
async function getLineGenerator(lines: any[]) {
|
||||
return (async function* () {
|
||||
for (const line of lines) {
|
||||
yield line
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
async function getFilteredLines(results: AsyncGenerator<string>) {
|
||||
const output: string[] = []
|
||||
for await (const line of results) {
|
||||
output.push(line)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockFullStop = vi.fn()
|
||||
})
|
||||
|
||||
describe("avoidPathLine", () => {
|
||||
it("filters out '// Path: ...' lines", async () => {
|
||||
const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"])
|
||||
const result = avoidPathLine(linesGenerator, "//")
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
expect(filteredLines).toEqual(["const x = 5;", "//", "console.log(x);"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("avoidEmptyComments", () => {
|
||||
it("filters out empty comment-only lines", async () => {
|
||||
const linesGenerator = await getLineGenerator(["// Path: src/index.ts", "const x = 5;", "//", "console.log(x);"])
|
||||
const result = avoidEmptyComments(linesGenerator, "//")
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
expect(filteredLines).toEqual(["// Path: src/index.ts", "const x = 5;", "console.log(x);"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamWithNewLines", () => {
|
||||
it("adds newline separators between lines", async () => {
|
||||
const linesGenerator = await getLineGenerator(["line1", "line2", "line3"])
|
||||
const result = streamWithNewLines(linesGenerator)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
expect(filteredLines).toEqual(["line1", "\n", "line2", "\n", "line3"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("lineIsRepeated", () => {
|
||||
it("returns true for similar lines", () => {
|
||||
expect(lineIsRepeated("const x = 5;", "const x = 6;")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for different lines", () => {
|
||||
expect(lineIsRepeated("const x = 5;", "let y = 10;")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for short lines", () => {
|
||||
expect(lineIsRepeated("x=5", "x=6")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopAtSimilarLine", () => {
|
||||
it("stops at the exact same line", async () => {
|
||||
const lineToTest = "const x = 6"
|
||||
const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest])
|
||||
|
||||
const result = stopAtSimilarLine(linesGenerator, lineToTest, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual(["console.log();", "const y = () => {};"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("stops at a similar line", async () => {
|
||||
const lineToTest = "const x = 6;"
|
||||
const linesGenerator = await getLineGenerator(["console.log();", "const y = () => {};", lineToTest])
|
||||
|
||||
const result = stopAtSimilarLine(linesGenerator, "a" + lineToTest, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual(["console.log();", "const y = () => {};"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("continues on bracket-ending lines", async () => {
|
||||
const linesGenerator = await getLineGenerator([" if (x > 0) {", " console.log(x);", " }"])
|
||||
|
||||
const result = stopAtSimilarLine(linesGenerator, "}", mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual([" if (x > 0) {", " console.log(x);", " }"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopAtLines", () => {
|
||||
it("stops at specified lines", async () => {
|
||||
const linesGenerator = await getLineGenerator([
|
||||
"const x = 5;",
|
||||
"let y = 10;",
|
||||
LINES_TO_STOP_AT[0],
|
||||
"const z = 15;",
|
||||
])
|
||||
|
||||
const result = stopAtLines(linesGenerator, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("stops when stop phrase has leading whitespace", async () => {
|
||||
const linesGenerator = await getLineGenerator([
|
||||
"const x = 5;",
|
||||
"let y = 10;",
|
||||
` ${LINES_TO_STOP_AT[0]}`,
|
||||
"const z = 15;",
|
||||
])
|
||||
|
||||
const result = stopAtLines(linesGenerator, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("skipPrefixes", () => {
|
||||
it("skips configured prefixes on the first line", async () => {
|
||||
const linesGenerator = await getLineGenerator([`${PREFIXES_TO_SKIP[0]}const x = 5;`, "let y = 10;"])
|
||||
|
||||
const result = skipPrefixes(linesGenerator)
|
||||
const filteredLines = await getFilteredLines(result)
|
||||
|
||||
expect(filteredLines).toEqual(["const x = 5;", "let y = 10;"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopAtRepeatingLines", () => {
|
||||
it("yields non-repeating lines and does not stop prematurely", async () => {
|
||||
const linesGenerator = await getLineGenerator(["a", "b", "c", "d", "e"])
|
||||
|
||||
const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result as any)
|
||||
|
||||
expect(filteredLines).toEqual(["a", "b", "c", "d", "e"])
|
||||
expect(mockFullStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("stops when a line repeats 3 times consecutively", async () => {
|
||||
const linesGenerator = await getLineGenerator(["x", "x", "x", "x", "after"])
|
||||
|
||||
const result = stopAtRepeatingLines(linesGenerator as any, mockFullStop)
|
||||
const filteredLines = await getFilteredLines(result as any)
|
||||
|
||||
// Only the first of the repeating lines is yielded
|
||||
expect(filteredLines).toEqual(["x"])
|
||||
expect(mockFullStop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
import type { LineStream } from "../../../diff/util"
|
||||
import { lineIsRepeated } from "../../util/textSimilarity"
|
||||
|
||||
export { lineIsRepeated }
|
||||
|
||||
export type LineFilter = (args: { lines: LineStream; fullStop: () => void }) => LineStream
|
||||
|
||||
export type CharacterFilter = (args: {
|
||||
chars: AsyncGenerator<string>
|
||||
prefix: string
|
||||
suffix: string
|
||||
filepath: string
|
||||
multiline: boolean
|
||||
}) => AsyncGenerator<string>
|
||||
|
||||
const BRACKET_ENDING_CHARS = [")", "]", "}", ";"]
|
||||
export const PREFIXES_TO_SKIP = ["<COMPLETION>"]
|
||||
export const LINES_TO_STOP_AT = ["# End of file.", "<STOP EDITING HERE", "<|/updated_code|>", "```"]
|
||||
|
||||
function isBracketEnding(line: string): boolean {
|
||||
return line
|
||||
.trim()
|
||||
.split("")
|
||||
.some((char) => BRACKET_ENDING_CHARS.includes(char))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate whether a stop pattern in a line is in a valid context (not inside quotes or identifiers)
|
||||
* and capture the text before the pattern.
|
||||
* Internal helper for stopAtLines.
|
||||
*/
|
||||
function validatePatternInLine(
|
||||
line: string,
|
||||
pattern: string,
|
||||
): {
|
||||
isValid: boolean
|
||||
patternIndex: number
|
||||
beforePattern: string
|
||||
} {
|
||||
const patternIndex = line.indexOf(pattern)
|
||||
|
||||
if (patternIndex === -1) {
|
||||
return { isValid: false, patternIndex: -1, beforePattern: "" }
|
||||
}
|
||||
|
||||
// If preceded by a non-whitespace, treat as part of an identifier
|
||||
if (patternIndex > 0) {
|
||||
const charBefore = line[patternIndex - 1]
|
||||
if (charBefore && !charBefore.match(/\s/)) {
|
||||
return { isValid: false, patternIndex, beforePattern: "" }
|
||||
}
|
||||
}
|
||||
|
||||
const beforePattern = line.substring(0, patternIndex)
|
||||
const singleQuotes = (beforePattern.match(/'/g) || []).length
|
||||
const doubleQuotes = (beforePattern.match(/"/g) || []).length
|
||||
|
||||
// Odd number of quotes before the pattern - likely inside quotes
|
||||
if (singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0) {
|
||||
return { isValid: false, patternIndex, beforePattern }
|
||||
}
|
||||
|
||||
return { isValid: true, patternIndex, beforePattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out lines starting with "// Path: <PATH>" which models sometimes echo.
|
||||
*/
|
||||
export async function* avoidPathLine(stream: LineStream, comment?: string): LineStream {
|
||||
for await (const line of stream) {
|
||||
if (comment && line.startsWith(`${comment} Path: `)) {
|
||||
continue
|
||||
}
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out empty comment-only lines.
|
||||
*/
|
||||
export async function* avoidEmptyComments(stream: LineStream, comment?: string): LineStream {
|
||||
for await (const line of stream) {
|
||||
if (!comment || line.trim() !== comment) {
|
||||
yield line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert "\n" separators between streamed lines.
|
||||
*/
|
||||
export async function* streamWithNewLines(stream: LineStream): LineStream {
|
||||
let firstLine = true
|
||||
for await (const nextLine of stream) {
|
||||
if (!firstLine) {
|
||||
yield "\n"
|
||||
}
|
||||
firstLine = false
|
||||
yield nextLine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield until a line equals or is very similar to the provided line, then call fullStop.
|
||||
* If the provided line ends with a bracket/semicolon, allow exact trimmed matches to pass through.
|
||||
*/
|
||||
export async function* stopAtSimilarLine(
|
||||
stream: LineStream,
|
||||
line: string,
|
||||
fullStop: () => void,
|
||||
): AsyncGenerator<string> {
|
||||
const trimmedLine = line.trim()
|
||||
const lineIsBracketEnding = isBracketEnding(trimmedLine)
|
||||
|
||||
for await (const nextLine of stream) {
|
||||
if (trimmedLine === "") {
|
||||
yield nextLine
|
||||
continue
|
||||
}
|
||||
|
||||
if (lineIsBracketEnding && trimmedLine === nextLine.trim()) {
|
||||
yield nextLine
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextLine === line) {
|
||||
fullStop()
|
||||
break
|
||||
}
|
||||
|
||||
if (lineIsRepeated(nextLine, trimmedLine)) {
|
||||
fullStop()
|
||||
break
|
||||
}
|
||||
|
||||
yield nextLine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield until any of the stop phrases is encountered in a valid context, then call fullStop.
|
||||
*/
|
||||
export async function* stopAtLines(
|
||||
stream: LineStream,
|
||||
fullStop: () => void,
|
||||
linesToStopAt: string[] = LINES_TO_STOP_AT,
|
||||
): LineStream {
|
||||
for await (const line of stream) {
|
||||
let shouldStop = false
|
||||
|
||||
for (const stopAt of linesToStopAt) {
|
||||
if (line.includes(stopAt)) {
|
||||
const validation = validatePatternInLine(line, stopAt)
|
||||
if (!validation.isValid) {
|
||||
continue
|
||||
}
|
||||
|
||||
const trimmedLine = line.trimStart()
|
||||
if (trimmedLine.startsWith(stopAt)) {
|
||||
shouldStop = true
|
||||
break
|
||||
} else {
|
||||
const contentBeforeStopPhrase = validation.beforePattern.trimEnd()
|
||||
if (contentBeforeStopPhrase.length < validation.beforePattern.length) {
|
||||
shouldStop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStop) {
|
||||
fullStop()
|
||||
break
|
||||
}
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the first line only, strip any configured prefix (e.g. "<COMPLETION>").
|
||||
*/
|
||||
export async function* skipPrefixes(lines: LineStream): LineStream {
|
||||
let isFirstLine = true
|
||||
for await (const line of lines) {
|
||||
if (isFirstLine) {
|
||||
const match = PREFIXES_TO_SKIP.find((prefix) => line.startsWith(prefix))
|
||||
if (match) {
|
||||
yield line.slice(match.length)
|
||||
continue
|
||||
}
|
||||
isFirstLine = false
|
||||
}
|
||||
yield line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield lines until a line repeats 3 times consecutively. Only the first of the repeats is yielded.
|
||||
*/
|
||||
export async function* stopAtRepeatingLines(lines: LineStream, fullStop: () => void): LineStream {
|
||||
let previousLine: string | undefined
|
||||
let repeatCount = 0
|
||||
const MAX_REPEATS = 3
|
||||
|
||||
for await (const line of lines) {
|
||||
if (line === previousLine) {
|
||||
repeatCount++
|
||||
if (repeatCount === MAX_REPEATS) {
|
||||
fullStop()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
yield line
|
||||
repeatCount = 1
|
||||
}
|
||||
previousLine = line
|
||||
}
|
||||
}
|
||||
+76
-415
@@ -1,450 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { getAllSnippets, getAllSnippetsWithoutRace } from "./getAllSnippets"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type { ContextRetrievalService } from "../context/ContextRetrievalService"
|
||||
import { AutocompleteSnippetType } from "../types"
|
||||
import type { HelperVars } from "../util/HelperVars"
|
||||
import type { IDE } from "../../index"
|
||||
import type { GetLspDefinitionsFunction } from "../types"
|
||||
import type { ContextRetrievalService } from "../context/ContextRetrievalService"
|
||||
import { getAllSnippetsWithoutRace } from "./getAllSnippets"
|
||||
|
||||
describe("getAllSnippets", () => {
|
||||
let mockHelper: HelperVars
|
||||
let mockIde: IDE
|
||||
let mockGetDefinitionsFromLsp: GetLspDefinitionsFunction
|
||||
let mockContextRetrievalService: ContextRetrievalService
|
||||
describe("getAllSnippetsWithoutRace", () => {
|
||||
let helper: HelperVars
|
||||
let ide: IDE
|
||||
let context: ContextRetrievalService
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock helper with minimal required properties
|
||||
mockHelper = {
|
||||
helper = {
|
||||
input: {
|
||||
filepath: "/test/file.ts",
|
||||
recentlyEditedRanges: [
|
||||
{
|
||||
filepath: "/test/recent.ts",
|
||||
lines: ["const x = 1;", "const y = 2;"],
|
||||
},
|
||||
],
|
||||
recentlyEditedRanges: [{ filepath: "/test/recent.ts", lines: ["const x = 1;"] }],
|
||||
recentlyVisitedRanges: [
|
||||
{
|
||||
filepath: "/test/visited.ts",
|
||||
content: "visited content",
|
||||
type: AutocompleteSnippetType.Code,
|
||||
},
|
||||
{ filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code },
|
||||
],
|
||||
},
|
||||
filepath: "/test/file.ts",
|
||||
fullPrefix: "const result = ",
|
||||
fullSuffix: ";",
|
||||
lang: "typescript",
|
||||
options: {
|
||||
onlyMyCode: false,
|
||||
useRecentlyEdited: true,
|
||||
useRecentlyOpened: true,
|
||||
experimental_enableStaticContextualization: false,
|
||||
},
|
||||
} as any
|
||||
|
||||
// Create mock IDE
|
||||
mockIde = {
|
||||
getWorkspaceDirs: vi.fn().mockResolvedValue(["/test"]),
|
||||
getClipboardContent: vi.fn().mockResolvedValue({
|
||||
text: "clipboard content",
|
||||
copiedAt: "2024-01-01T00:00:00.000Z",
|
||||
}),
|
||||
} as HelperVars
|
||||
ide = {
|
||||
getClipboardContent: vi.fn().mockResolvedValue({ text: "clipboard", copiedAt: "2024-01-01" }),
|
||||
readFile: vi.fn().mockResolvedValue("file content"),
|
||||
} as any
|
||||
|
||||
// Create mock LSP function
|
||||
mockGetDefinitionsFromLsp = vi.fn().mockResolvedValue([])
|
||||
|
||||
// Create mock context retrieval service
|
||||
mockContextRetrievalService = {
|
||||
} as unknown as IDE
|
||||
context = {
|
||||
getRootPathSnippets: vi.fn().mockResolvedValue([]),
|
||||
getSnippetsFromImportDefinitions: vi.fn().mockResolvedValue([]),
|
||||
getStaticContextSnippets: vi.fn().mockResolvedValue([]),
|
||||
} as any
|
||||
} as unknown as ContextRetrievalService
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
it("collects every active snippet source", async () => {
|
||||
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
|
||||
|
||||
describe("getAllSnippets with race conditions", () => {
|
||||
it("should return all snippet types", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result).toHaveProperty("rootPathSnippets")
|
||||
expect(result).toHaveProperty("importDefinitionSnippets")
|
||||
expect(result).toHaveProperty("ideSnippets")
|
||||
expect(result).toHaveProperty("recentlyEditedRangeSnippets")
|
||||
expect(result).toHaveProperty("diffSnippets")
|
||||
expect(result).toHaveProperty("clipboardSnippets")
|
||||
expect(result).toHaveProperty("recentlyVisitedRangesSnippets")
|
||||
expect(result).toHaveProperty("recentlyOpenedFileSnippets")
|
||||
expect(result).toHaveProperty("staticSnippet")
|
||||
})
|
||||
|
||||
it("should collect recently edited snippets synchronously", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.recentlyEditedRangeSnippets).toHaveLength(1)
|
||||
expect(result.recentlyEditedRangeSnippets[0]).toEqual({
|
||||
filepath: "/test/recent.ts",
|
||||
content: "const x = 1;\nconst y = 2;",
|
||||
type: AutocompleteSnippetType.Code,
|
||||
})
|
||||
})
|
||||
|
||||
it("should pass through recently visited ranges", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.recentlyVisitedRangesSnippets).toEqual(mockHelper.input.recentlyVisitedRanges)
|
||||
})
|
||||
|
||||
it("should timeout slow snippet sources after default 100ms", async () => {
|
||||
// Mock a slow service that takes 200ms
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
filepath: "/slow.ts",
|
||||
content: "slow",
|
||||
type: AutocompleteSnippetType.Code,
|
||||
},
|
||||
]),
|
||||
200,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const startTime = Date.now()
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Should timeout and return empty array, not wait 200ms
|
||||
expect(result.rootPathSnippets).toEqual([])
|
||||
expect(duration).toBeLessThan(150) // Some buffer for timing
|
||||
})
|
||||
|
||||
it("should return results from fast sources even if other sources are slow", async () => {
|
||||
// Mock one fast and one slow source
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([
|
||||
{
|
||||
filepath: "/fast.ts",
|
||||
content: "fast",
|
||||
type: AutocompleteSnippetType.Code,
|
||||
},
|
||||
])
|
||||
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi
|
||||
.fn()
|
||||
.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve([]), 200)))
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
// Fast source should return results
|
||||
expect(result.rootPathSnippets).toHaveLength(1)
|
||||
expect(result.rootPathSnippets[0].content).toBe("fast")
|
||||
|
||||
// Slow source should timeout and return empty
|
||||
expect(result.importDefinitionSnippets).toEqual([])
|
||||
})
|
||||
|
||||
it("should collect clipboard snippets", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.clipboardSnippets).toHaveLength(1)
|
||||
expect(result.clipboardSnippets[0]).toEqual({
|
||||
content: "clipboard content",
|
||||
copiedAt: "2024-01-01T00:00:00.000Z",
|
||||
type: AutocompleteSnippetType.Clipboard,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty results from snippet sources", async () => {
|
||||
// All sources return empty
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue([])
|
||||
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockResolvedValue([])
|
||||
mockIde.getClipboardContent = vi.fn().mockResolvedValue({
|
||||
text: "",
|
||||
copiedAt: "2024-01-01T00:00:00.000Z",
|
||||
})
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.rootPathSnippets).toEqual([])
|
||||
expect(result.importDefinitionSnippets).toEqual([])
|
||||
expect(result.clipboardSnippets).toHaveLength(1) // Still returns clipboard snippet
|
||||
})
|
||||
|
||||
it("should return empty array for IDE snippets when disabled", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
// IDE_SNIPPETS_ENABLED is false in the implementation
|
||||
expect(result.ideSnippets).toEqual([])
|
||||
expect(mockGetDefinitionsFromLsp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return empty array for diff snippets (temporarily disabled)", async () => {
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.diffSnippets).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle option useRecentlyEdited = false", async () => {
|
||||
mockHelper.options.useRecentlyEdited = false
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.recentlyEditedRangeSnippets).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle option useRecentlyOpened = false", async () => {
|
||||
mockHelper.options.useRecentlyOpened = false
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.recentlyOpenedFileSnippets).toEqual([])
|
||||
})
|
||||
|
||||
it("should collect static context snippets when experimental flag is enabled", async () => {
|
||||
mockHelper.options.experimental_enableStaticContextualization = true
|
||||
mockContextRetrievalService.getStaticContextSnippets = vi.fn().mockResolvedValue([
|
||||
{
|
||||
filepath: "/static.ts",
|
||||
content: "static",
|
||||
type: AutocompleteSnippetType.Static,
|
||||
},
|
||||
])
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.staticSnippet).toHaveLength(1)
|
||||
expect(result.staticSnippet[0].content).toBe("static")
|
||||
})
|
||||
|
||||
it("should return empty static snippets when experimental flag is disabled", async () => {
|
||||
mockHelper.options.experimental_enableStaticContextualization = false
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result.staticSnippet).toEqual([])
|
||||
expect(mockContextRetrievalService.getStaticContextSnippets).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({
|
||||
rootPathSnippets: [],
|
||||
importDefinitionSnippets: [],
|
||||
recentlyEditedRangeSnippets: [
|
||||
{ filepath: "/test/recent.ts", content: "const x = 1;", type: AutocompleteSnippetType.Code },
|
||||
],
|
||||
recentlyVisitedRangesSnippets: [
|
||||
{ filepath: "/test/visited.ts", content: "visited", type: AutocompleteSnippetType.Code },
|
||||
],
|
||||
clipboardSnippets: [{ content: "clipboard", copiedAt: "2024-01-01", type: AutocompleteSnippetType.Clipboard }],
|
||||
recentlyOpenedFileSnippets: [],
|
||||
staticSnippet: [],
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should propagate errors from context retrieval service", async () => {
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
|
||||
it("honors disabled recent-context sources", async () => {
|
||||
helper.options.useRecentlyEdited = false
|
||||
helper.options.useRecentlyOpened = false
|
||||
|
||||
// Errors are not caught by racePromise - they propagate if they occur before timeout
|
||||
await expect(
|
||||
getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
|
||||
expect(result.recentlyEditedRangeSnippets).toEqual([])
|
||||
expect(result.recentlyOpenedFileSnippets).toEqual([])
|
||||
})
|
||||
|
||||
it("collects import definitions and enabled static context", async () => {
|
||||
helper.options.experimental_enableStaticContextualization = true
|
||||
context.getSnippetsFromImportDefinitions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ filepath: "/import.ts", content: "imported", type: AutocompleteSnippetType.Code }])
|
||||
context.getStaticContextSnippets = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ filepath: "/static.ts", content: "static", type: AutocompleteSnippetType.Static }])
|
||||
|
||||
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
|
||||
expect(result.importDefinitionSnippets[0]?.content).toBe("imported")
|
||||
expect(result.staticSnippet[0]?.content).toBe("static")
|
||||
})
|
||||
|
||||
it("propagates clipboard failures", async () => {
|
||||
ide.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error"))
|
||||
|
||||
await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow(
|
||||
"Clipboard error",
|
||||
)
|
||||
})
|
||||
|
||||
it("waits for active context sources without a collector timeout", async () => {
|
||||
context.getRootPathSnippets = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() => resolve([{ filepath: "/slow.ts", content: "slow", type: AutocompleteSnippetType.Code }]),
|
||||
120,
|
||||
)
|
||||
}),
|
||||
).rejects.toThrow("Service error")
|
||||
})
|
||||
)
|
||||
|
||||
it("should propagate errors from IDE clipboard", async () => {
|
||||
mockIde.getClipboardContent = vi.fn().mockRejectedValue(new Error("Clipboard error"))
|
||||
|
||||
// Errors are not caught by racePromise - they propagate if they occur before timeout
|
||||
await expect(
|
||||
getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
}),
|
||||
).rejects.toThrow("Clipboard error")
|
||||
})
|
||||
|
||||
it("should pass through null from snippet sources", async () => {
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockResolvedValue(null as any)
|
||||
|
||||
const result = await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
// racePromise returns null if the promise resolves to null (not converted to [])
|
||||
expect(result.rootPathSnippets).toBeNull()
|
||||
})
|
||||
const result = await getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })
|
||||
expect(result.rootPathSnippets[0]?.content).toBe("slow")
|
||||
})
|
||||
|
||||
describe("getAllSnippetsWithoutRace", () => {
|
||||
it("should wait for all promises without timeout", async () => {
|
||||
// Mock a slow service that takes 200ms
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
filepath: "/slow.ts",
|
||||
content: "slow",
|
||||
type: AutocompleteSnippetType.Code,
|
||||
},
|
||||
]),
|
||||
200,
|
||||
)
|
||||
}),
|
||||
)
|
||||
it("propagates context retrieval failures", async () => {
|
||||
context.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
|
||||
|
||||
const result = await getAllSnippetsWithoutRace({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
// Should wait and get results, not timeout
|
||||
expect(result.rootPathSnippets).toHaveLength(1)
|
||||
expect(result.rootPathSnippets[0].content).toBe("slow")
|
||||
})
|
||||
|
||||
it("should return all snippet types without racing", async () => {
|
||||
const result = await getAllSnippetsWithoutRace({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
expect(result).toHaveProperty("rootPathSnippets")
|
||||
expect(result).toHaveProperty("importDefinitionSnippets")
|
||||
expect(result).toHaveProperty("ideSnippets")
|
||||
expect(result).toHaveProperty("recentlyEditedRangeSnippets")
|
||||
expect(result).toHaveProperty("diffSnippets")
|
||||
expect(result).toHaveProperty("clipboardSnippets")
|
||||
expect(result).toHaveProperty("recentlyVisitedRangesSnippets")
|
||||
expect(result).toHaveProperty("recentlyOpenedFileSnippets")
|
||||
expect(result).toHaveProperty("staticSnippet")
|
||||
})
|
||||
|
||||
it("should handle errors without race timeout", async () => {
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockRejectedValue(new Error("Service error"))
|
||||
|
||||
// Should propagate error since no timeout
|
||||
await expect(
|
||||
getAllSnippetsWithoutRace({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
}),
|
||||
).rejects.toThrow("Service error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parallel execution", () => {
|
||||
it("should execute all snippet collections in parallel", async () => {
|
||||
const executionOrder: string[] = []
|
||||
|
||||
mockContextRetrievalService.getRootPathSnippets = vi.fn().mockImplementation(async () => {
|
||||
executionOrder.push("rootPath-start")
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
executionOrder.push("rootPath-end")
|
||||
return []
|
||||
})
|
||||
|
||||
mockContextRetrievalService.getSnippetsFromImportDefinitions = vi.fn().mockImplementation(async () => {
|
||||
executionOrder.push("import-start")
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
executionOrder.push("import-end")
|
||||
return []
|
||||
})
|
||||
|
||||
mockIde.getClipboardContent = vi.fn().mockImplementation(async () => {
|
||||
executionOrder.push("clipboard-start")
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
executionOrder.push("clipboard-end")
|
||||
return { text: "test", copiedAt: "2024-01-01T00:00:00.000Z" }
|
||||
})
|
||||
|
||||
await getAllSnippets({
|
||||
helper: mockHelper,
|
||||
ide: mockIde,
|
||||
getDefinitionsFromLsp: mockGetDefinitionsFromLsp,
|
||||
contextRetrievalService: mockContextRetrievalService,
|
||||
})
|
||||
|
||||
// All should start before any complete (parallel execution)
|
||||
expect(executionOrder[0]).toBe("rootPath-start")
|
||||
expect(executionOrder[1]).toBe("import-start")
|
||||
expect(executionOrder[2]).toBe("clipboard-start")
|
||||
})
|
||||
await expect(getAllSnippetsWithoutRace({ helper, ide, contextRetrievalService: context })).rejects.toThrow(
|
||||
"Service error",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+33
-161
@@ -1,215 +1,89 @@
|
||||
import { IDE } from "../../index"
|
||||
import { findUriInDirs } from "../../util/uri"
|
||||
import { ContextRetrievalService } from "../context/ContextRetrievalService"
|
||||
import { GetLspDefinitionsFunction } from "../types"
|
||||
import { HelperVars } from "../util/HelperVars"
|
||||
import { openedFilesLruCache } from "../util/openedFilesLruCache"
|
||||
|
||||
import {
|
||||
AutocompleteClipboardSnippet,
|
||||
AutocompleteCodeSnippet,
|
||||
AutocompleteDiffSnippet,
|
||||
AutocompleteSnippetType,
|
||||
AutocompleteStaticSnippet,
|
||||
} from "../types"
|
||||
|
||||
const IDE_SNIPPETS_ENABLED = false // ideSnippets is not used, so it's temporarily disabled
|
||||
|
||||
export interface SnippetPayload {
|
||||
rootPathSnippets: AutocompleteCodeSnippet[]
|
||||
importDefinitionSnippets: AutocompleteCodeSnippet[]
|
||||
ideSnippets: AutocompleteCodeSnippet[]
|
||||
recentlyEditedRangeSnippets: AutocompleteCodeSnippet[]
|
||||
recentlyVisitedRangesSnippets: AutocompleteCodeSnippet[]
|
||||
diffSnippets: AutocompleteDiffSnippet[]
|
||||
clipboardSnippets: AutocompleteClipboardSnippet[]
|
||||
recentlyOpenedFileSnippets: AutocompleteCodeSnippet[]
|
||||
staticSnippet: AutocompleteStaticSnippet[]
|
||||
}
|
||||
|
||||
function racePromise<T>(promise: Promise<T[]>, timeout = 100): Promise<T[]> {
|
||||
const timeoutPromise = new Promise<T[]>((resolve) => {
|
||||
setTimeout(() => resolve([]), timeout)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeoutPromise])
|
||||
}
|
||||
|
||||
// Some IDEs might have special ways of finding snippets (e.g. JetBrains and VS Code have different "LSP-equivalent" systems,
|
||||
// or they might separately track recently edited ranges)
|
||||
async function getIdeSnippets(
|
||||
helper: HelperVars,
|
||||
ide: IDE,
|
||||
getDefinitionsFromLsp: GetLspDefinitionsFunction,
|
||||
): Promise<AutocompleteCodeSnippet[]> {
|
||||
const ideSnippets = await getDefinitionsFromLsp(
|
||||
helper.input.filepath,
|
||||
helper.fullPrefix + helper.fullSuffix,
|
||||
helper.fullPrefix.length,
|
||||
ide,
|
||||
helper.lang,
|
||||
)
|
||||
|
||||
if (helper.options.onlyMyCode) {
|
||||
const workspaceDirs = await ide.getWorkspaceDirs()
|
||||
|
||||
return ideSnippets.filter((snippet) =>
|
||||
workspaceDirs.some((dir) => !!findUriInDirs(snippet.filepath, [dir]).foundInDir),
|
||||
)
|
||||
}
|
||||
|
||||
return ideSnippets
|
||||
}
|
||||
|
||||
function getSnippetsFromRecentlyEditedRanges(helper: HelperVars): AutocompleteCodeSnippet[] {
|
||||
if (helper.options.useRecentlyEdited === false) {
|
||||
return []
|
||||
}
|
||||
if (helper.options.useRecentlyEdited === false) return []
|
||||
|
||||
return helper.input.recentlyEditedRanges.map((range) => {
|
||||
return {
|
||||
filepath: range.filepath,
|
||||
content: range.lines.join("\n"),
|
||||
type: AutocompleteSnippetType.Code,
|
||||
}
|
||||
})
|
||||
return helper.input.recentlyEditedRanges.map((range) => ({
|
||||
filepath: range.filepath,
|
||||
content: range.lines.join("\n"),
|
||||
type: AutocompleteSnippetType.Code,
|
||||
}))
|
||||
}
|
||||
|
||||
const getClipboardSnippets = async (ide: IDE): Promise<AutocompleteClipboardSnippet[]> => {
|
||||
const content = await ide.getClipboardContent()
|
||||
|
||||
return [content].map((item) => {
|
||||
return {
|
||||
content: item.text,
|
||||
copiedAt: item.copiedAt,
|
||||
return [
|
||||
{
|
||||
content: content.text,
|
||||
copiedAt: content.copiedAt,
|
||||
type: AutocompleteSnippetType.Clipboard,
|
||||
}
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const getSnippetsFromRecentlyOpenedFiles = async (helper: HelperVars, ide: IDE): Promise<AutocompleteCodeSnippet[]> => {
|
||||
if (helper.options.useRecentlyOpened === false) {
|
||||
return []
|
||||
}
|
||||
if (helper.options.useRecentlyOpened === false) return []
|
||||
|
||||
try {
|
||||
const currentFileUri = `${helper.filepath}`
|
||||
|
||||
// Get all file URIs excluding the current file
|
||||
const fileUrisToRead = [...openedFilesLruCache.entriesDescending()]
|
||||
.filter(([fileUri, _]) => fileUri !== currentFileUri)
|
||||
.map(([fileUri, _]) => fileUri)
|
||||
|
||||
// Create an array of promises that each read a file with timeout
|
||||
const fileReadPromises = fileUrisToRead.map((fileUri) => {
|
||||
// Create a promise that resolves to a snippet or null
|
||||
const readPromise = new Promise<AutocompleteCodeSnippet | null>((resolve) => {
|
||||
const current = `${helper.filepath}`
|
||||
const uris = [...openedFilesLruCache.entriesDescending()].filter(([uri]) => uri !== current).map(([uri]) => uri)
|
||||
const reads = uris.map((uri) => {
|
||||
const read = new Promise<AutocompleteCodeSnippet | null>((resolve) => {
|
||||
ide
|
||||
.readFile(fileUri)
|
||||
.then((fileContent) => {
|
||||
if (!fileContent || fileContent.trim() === "") {
|
||||
.readFile(uri)
|
||||
.then((content) => {
|
||||
if (!content || content.trim() === "") {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
filepath: fileUri,
|
||||
content: fileContent,
|
||||
type: AutocompleteSnippetType.Code,
|
||||
})
|
||||
resolve({ filepath: uri, content, type: AutocompleteSnippetType.Code })
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Failed to read file ${fileUri}:`, e)
|
||||
.catch((err) => {
|
||||
console.error(`Failed to read file ${uri}:`, err)
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
// Cut off at 80ms via racing promises
|
||||
return Promise.race([readPromise, new Promise<null>((resolve) => setTimeout(() => resolve(null), 80))])
|
||||
return Promise.race([read, new Promise<null>((resolve) => setTimeout(() => resolve(null), 80))])
|
||||
})
|
||||
|
||||
// Execute all file reads in parallel
|
||||
const results = await Promise.all(fileReadPromises)
|
||||
|
||||
// Filter out null results
|
||||
const results = await Promise.all(reads)
|
||||
return results.filter(Boolean) as AutocompleteCodeSnippet[]
|
||||
} catch (e) {
|
||||
console.error("Error processing opened files cache:", e)
|
||||
} catch (err) {
|
||||
console.error("Error processing opened files cache:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getAllSnippets = async ({
|
||||
helper,
|
||||
ide,
|
||||
getDefinitionsFromLsp,
|
||||
contextRetrievalService,
|
||||
}: {
|
||||
helper: HelperVars
|
||||
ide: IDE
|
||||
getDefinitionsFromLsp: GetLspDefinitionsFunction
|
||||
contextRetrievalService: ContextRetrievalService
|
||||
}): Promise<SnippetPayload> => {
|
||||
const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper)
|
||||
|
||||
const [
|
||||
rootPathSnippets,
|
||||
importDefinitionSnippets,
|
||||
ideSnippets,
|
||||
diffSnippets,
|
||||
clipboardSnippets,
|
||||
recentlyOpenedFileSnippets,
|
||||
staticSnippet,
|
||||
] = await Promise.all([
|
||||
racePromise(contextRetrievalService.getRootPathSnippets(helper)),
|
||||
racePromise(contextRetrievalService.getSnippetsFromImportDefinitions(helper)),
|
||||
IDE_SNIPPETS_ENABLED ? racePromise(getIdeSnippets(helper, ide, getDefinitionsFromLsp)) : [],
|
||||
[], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882,
|
||||
racePromise(getClipboardSnippets(ide)),
|
||||
racePromise(getSnippetsFromRecentlyOpenedFiles(helper, ide)), // giving this one a little more time to complete
|
||||
helper.options.experimental_enableStaticContextualization
|
||||
? racePromise(contextRetrievalService.getStaticContextSnippets(helper))
|
||||
: [],
|
||||
])
|
||||
|
||||
return {
|
||||
rootPathSnippets,
|
||||
importDefinitionSnippets,
|
||||
ideSnippets,
|
||||
recentlyEditedRangeSnippets,
|
||||
diffSnippets,
|
||||
clipboardSnippets,
|
||||
recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges,
|
||||
recentlyOpenedFileSnippets,
|
||||
staticSnippet,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAllSnippetsWithoutRace = async ({
|
||||
helper,
|
||||
ide,
|
||||
getDefinitionsFromLsp,
|
||||
contextRetrievalService,
|
||||
}: {
|
||||
helper: HelperVars
|
||||
ide: IDE
|
||||
getDefinitionsFromLsp: GetLspDefinitionsFunction
|
||||
contextRetrievalService: ContextRetrievalService
|
||||
}): Promise<SnippetPayload> => {
|
||||
const recentlyEditedRangeSnippets = getSnippetsFromRecentlyEditedRanges(helper)
|
||||
|
||||
const [
|
||||
rootPathSnippets,
|
||||
importDefinitionSnippets,
|
||||
ideSnippets,
|
||||
diffSnippets,
|
||||
clipboardSnippets,
|
||||
recentlyOpenedFileSnippets,
|
||||
staticSnippet,
|
||||
] = await Promise.all([
|
||||
const [root, imports, clipboard, opened, staticSnippet] = await Promise.all([
|
||||
contextRetrievalService.getRootPathSnippets(helper),
|
||||
contextRetrievalService.getSnippetsFromImportDefinitions(helper),
|
||||
IDE_SNIPPETS_ENABLED ? getIdeSnippets(helper, ide, getDefinitionsFromLsp) : [],
|
||||
[], // racePromise(getDiffSnippets(ide)) // temporarily disabled, see https://github.com/continuedev/continue/pull/5882,
|
||||
getClipboardSnippets(ide),
|
||||
getSnippetsFromRecentlyOpenedFiles(helper, ide),
|
||||
helper.options.experimental_enableStaticContextualization
|
||||
@@ -218,14 +92,12 @@ export const getAllSnippetsWithoutRace = async ({
|
||||
])
|
||||
|
||||
return {
|
||||
rootPathSnippets,
|
||||
importDefinitionSnippets,
|
||||
ideSnippets,
|
||||
recentlyEditedRangeSnippets,
|
||||
diffSnippets,
|
||||
clipboardSnippets,
|
||||
rootPathSnippets: root,
|
||||
importDefinitionSnippets: imports,
|
||||
recentlyEditedRangeSnippets: getSnippetsFromRecentlyEditedRanges(helper),
|
||||
recentlyVisitedRangesSnippets: helper.input.recentlyVisitedRanges,
|
||||
recentlyOpenedFileSnippets,
|
||||
clipboardSnippets: clipboard,
|
||||
recentlyOpenedFileSnippets: opened,
|
||||
staticSnippet,
|
||||
}
|
||||
}
|
||||
|
||||
+3
-15
@@ -5,7 +5,7 @@
|
||||
|
||||
import { CompletionOptions } from "../../index.js"
|
||||
import { getLastNUriRelativePathParts, getShortestUniqueRelativeUriPaths } from "../../util/uri.js"
|
||||
import { AutocompleteSnippet, AutocompleteSnippetType } from "../types.js"
|
||||
import { AutocompleteSnippet } from "../types.js"
|
||||
|
||||
type TemplateRenderer = (
|
||||
prefix: string,
|
||||
@@ -49,13 +49,7 @@ const codestralMultifileFimTemplate: AutocompleteTemplate = {
|
||||
)
|
||||
|
||||
const otherFiles = snippets
|
||||
.map((snippet, i) => {
|
||||
if (snippet.type === AutocompleteSnippetType.Diff) {
|
||||
return snippet.content
|
||||
}
|
||||
|
||||
return `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`
|
||||
})
|
||||
.map((snippet, i) => `+++++ ${getFileName(relativePaths[i])} \n${snippet.content}`)
|
||||
.join("\n\n")
|
||||
|
||||
return [`${otherFiles}\n\n+++++ ${getFileName(relativePaths[relativePaths.length - 1])}\n${prefix}`, suffix]
|
||||
@@ -93,13 +87,7 @@ const mercuryMultifileFimTemplate: AutocompleteTemplate = {
|
||||
)
|
||||
|
||||
const otherFiles = snippets
|
||||
.map((snippet, i) => {
|
||||
if (snippet.type === AutocompleteSnippetType.Diff) {
|
||||
return snippet.content
|
||||
}
|
||||
|
||||
return `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`
|
||||
})
|
||||
.map((snippet, i) => `<|file_sep|>${getFileName(relativePaths[i])} \n${snippet.content}`)
|
||||
.join("\n\n")
|
||||
|
||||
return [
|
||||
|
||||
+2
-7
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest"
|
||||
import { AutocompleteCodeSnippet, AutocompleteDiffSnippet, AutocompleteSnippetType } from "../../types"
|
||||
import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../types"
|
||||
import { HelperVars } from "../../util/HelperVars"
|
||||
import { formatOpenedFilesContext } from "../formatOpenedFilesContext"
|
||||
|
||||
@@ -25,11 +25,6 @@ describe("formatOpenedFilesContext main function tests", () => {
|
||||
content,
|
||||
})
|
||||
|
||||
const createDiffSnippet = (content: string): AutocompleteDiffSnippet => ({
|
||||
type: AutocompleteSnippetType.Diff,
|
||||
content,
|
||||
})
|
||||
|
||||
test("should return empty array when no snippets are provided", () => {
|
||||
const result = formatOpenedFilesContext([], 1000, mockHelper, [], TOKEN_BUFFER)
|
||||
expect(result).toEqual([])
|
||||
@@ -99,7 +94,7 @@ describe("formatOpenedFilesContext main function tests", () => {
|
||||
createCodeSnippet("file2.ts", "content of file 2"),
|
||||
]
|
||||
|
||||
const alreadyAddedSnippets = [createDiffSnippet("diff content")]
|
||||
const alreadyAddedSnippets = [createCodeSnippet("added.ts", "added content")]
|
||||
|
||||
const result = formatOpenedFilesContext(snippets, 1000, mockHelper, alreadyAddedSnippets, TOKEN_BUFFER)
|
||||
|
||||
|
||||
-8
@@ -44,7 +44,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco
|
||||
clipboard: payload.clipboardSnippets,
|
||||
recentlyVisitedRanges: payload.recentlyVisitedRangesSnippets,
|
||||
recentlyEditedRanges: payload.recentlyEditedRangeSnippets,
|
||||
diff: payload.diffSnippets,
|
||||
recentlyOpenedFiles: payload.recentlyOpenedFileSnippets,
|
||||
base: shuffleArray(
|
||||
filterSnippetsAlreadyInCaretWindow(
|
||||
@@ -88,13 +87,6 @@ export const getSnippets = (helper: HelperVars, payload: SnippetPayload): Autoco
|
||||
defaultPriority: 4,
|
||||
snippets: payload.recentlyEditedRangeSnippets,
|
||||
},
|
||||
{
|
||||
key: "diff",
|
||||
enabledOrPriority: helper.options.experimental_includeDiff,
|
||||
defaultPriority: 5,
|
||||
snippets: payload.diffSnippets,
|
||||
// TODO: diff is commonly too large, thus anything lower in priority is not included.
|
||||
},
|
||||
{
|
||||
key: "base",
|
||||
enabledOrPriority: true,
|
||||
|
||||
+2
-20
@@ -1,9 +1,7 @@
|
||||
import { IDE, RangeInFileWithContents } from "../index"
|
||||
import { AutocompleteLanguageInfo } from "./constants/AutocompleteLanguageInfo"
|
||||
import { RangeInFileWithContents } from "../index"
|
||||
|
||||
export enum AutocompleteSnippetType {
|
||||
Code = "code",
|
||||
Diff = "diff",
|
||||
Clipboard = "clipboard",
|
||||
Static = "static",
|
||||
}
|
||||
@@ -18,10 +16,6 @@ export interface AutocompleteCodeSnippet extends BaseAutocompleteSnippet {
|
||||
type: AutocompleteSnippetType.Code
|
||||
}
|
||||
|
||||
export interface AutocompleteDiffSnippet extends BaseAutocompleteSnippet {
|
||||
type: AutocompleteSnippetType.Diff
|
||||
}
|
||||
|
||||
export interface AutocompleteClipboardSnippet extends BaseAutocompleteSnippet {
|
||||
type: AutocompleteSnippetType.Clipboard
|
||||
copiedAt: string
|
||||
@@ -32,20 +26,8 @@ export interface AutocompleteStaticSnippet extends BaseAutocompleteSnippet {
|
||||
filepath: string
|
||||
}
|
||||
|
||||
export type AutocompleteSnippet =
|
||||
| AutocompleteCodeSnippet
|
||||
| AutocompleteDiffSnippet
|
||||
| AutocompleteClipboardSnippet
|
||||
| AutocompleteStaticSnippet
|
||||
export type AutocompleteSnippet = AutocompleteCodeSnippet | AutocompleteClipboardSnippet | AutocompleteStaticSnippet
|
||||
|
||||
export type RankedSnippet = RangeInFileWithContents & {
|
||||
score?: number
|
||||
}
|
||||
|
||||
export type GetLspDefinitionsFunction = (
|
||||
filepath: string,
|
||||
contents: string,
|
||||
cursorIndex: number,
|
||||
ide: IDE,
|
||||
lang: AutocompleteLanguageInfo,
|
||||
) => Promise<AutocompleteCodeSnippet[]>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Generated by continue
|
||||
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ChatMessage } from "../index"
|
||||
import { generateLines, streamLines } from "./util"
|
||||
|
||||
describe("streamLines", () => {
|
||||
it("should split chunks into lines correctly", async () => {
|
||||
async function* streamCompletion(): AsyncGenerator<string> {
|
||||
yield "line1\nline"
|
||||
yield "2\nline3\n"
|
||||
yield "line4"
|
||||
}
|
||||
|
||||
const resultLines: string[] = []
|
||||
for await (const line of streamLines(streamCompletion())) {
|
||||
resultLines.push(line)
|
||||
}
|
||||
|
||||
expect(resultLines).toEqual(["line1", "line2", "line3", "line4"])
|
||||
})
|
||||
|
||||
it("should handle ChatMessage chunks", async () => {
|
||||
const messageChunk1: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "line1\nline",
|
||||
}
|
||||
const messageChunk2: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "2\nline3\n",
|
||||
}
|
||||
const messageChunk3: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: "line4",
|
||||
}
|
||||
|
||||
// const spy = vi.spyOn(messageContentModule, "renderChatMessage");
|
||||
|
||||
async function* streamCompletion(): AsyncGenerator<ChatMessage> {
|
||||
yield messageChunk1
|
||||
yield messageChunk2
|
||||
yield messageChunk3
|
||||
}
|
||||
|
||||
const resultLines: string[] = []
|
||||
for await (const line of streamLines(streamCompletion())) {
|
||||
resultLines.push(line)
|
||||
}
|
||||
|
||||
expect(resultLines).toEqual(["line1", "line2", "line3", "line4"])
|
||||
// expect(spy).toHaveBeenCalledTimes(3);
|
||||
})
|
||||
|
||||
it("should log lines if log parameter is true", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
|
||||
async function* streamCompletion(): AsyncGenerator<string> {
|
||||
yield "line1\nline2\n"
|
||||
yield "line3"
|
||||
}
|
||||
|
||||
const resultLines: string[] = []
|
||||
for await (const line of streamLines(streamCompletion(), true)) {
|
||||
resultLines.push(line)
|
||||
}
|
||||
|
||||
expect(resultLines).toEqual(["line1", "line2", "line3"])
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Streamed lines: ", "line1\nline2\nline3")
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateLines", () => {
|
||||
it("should yield the lines provided in the array", async () => {
|
||||
const lines = ["line1", "line2", "line3"]
|
||||
const resultLines: string[] = []
|
||||
|
||||
for await (const line of generateLines(lines)) {
|
||||
resultLines.push(line)
|
||||
}
|
||||
|
||||
expect(resultLines).toEqual(lines)
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ChatMessage } from "../index.js"
|
||||
import { renderChatMessage } from "../util/messageContent.js"
|
||||
|
||||
export type LineStream = AsyncGenerator<string>
|
||||
|
||||
/**
|
||||
* Convert a stream of arbitrary chunks to a stream of lines
|
||||
*/
|
||||
export async function* streamLines(
|
||||
streamCompletion: AsyncGenerator<string | ChatMessage>,
|
||||
log: boolean = false,
|
||||
): LineStream {
|
||||
const allLines = []
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
for await (const update of streamCompletion) {
|
||||
const chunk = typeof update === "string" ? update : renderChatMessage(update)
|
||||
buffer += chunk
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() ?? ""
|
||||
for (const line of lines) {
|
||||
yield line
|
||||
allLines.push(line)
|
||||
}
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
yield buffer
|
||||
allLines.push(buffer)
|
||||
}
|
||||
} finally {
|
||||
if (log) {
|
||||
console.log("Streamed lines: ", allLines.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function* generateLines<T>(lines: T[]): AsyncGenerator<T> {
|
||||
for (const line of lines) {
|
||||
yield line
|
||||
}
|
||||
}
|
||||
@@ -531,7 +531,6 @@ export interface TabAutocompleteOptions {
|
||||
experimental_includeClipboard: boolean | number
|
||||
experimental_includeRecentlyVisitedRanges: boolean | number
|
||||
experimental_includeRecentlyEditedRanges: boolean | number
|
||||
experimental_includeDiff: boolean | number
|
||||
experimental_enableStaticContextualization: boolean
|
||||
}
|
||||
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { ChatMessage, MessageContent, TextMessagePart } from "../index"
|
||||
|
||||
function stripImages(messageContent: MessageContent): string {
|
||||
if (typeof messageContent === "string") {
|
||||
return messageContent
|
||||
}
|
||||
|
||||
return messageContent
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part as TextMessagePart).text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function renderChatMessage(message: ChatMessage): string {
|
||||
switch (message?.role) {
|
||||
case "user":
|
||||
case "assistant":
|
||||
case "system":
|
||||
return stripImages(message.content)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,5 @@ export const DEFAULT_AUTOCOMPLETE_OPTS: TabAutocompleteOptions = {
|
||||
experimental_includeClipboard: false,
|
||||
experimental_includeRecentlyVisitedRanges: true,
|
||||
experimental_includeRecentlyEditedRanges: true,
|
||||
experimental_includeDiff: true,
|
||||
experimental_enableStaticContextualization: false,
|
||||
}
|
||||
|
||||
@@ -13,34 +13,3 @@ export function getRangeInString(content: string, range: Range): string {
|
||||
|
||||
return [firstLine, ...middleLines, lastLine].join("\n")
|
||||
}
|
||||
|
||||
export function intersection(a: Range, b: Range): Range | null {
|
||||
const startLine = Math.max(a.start.line, b.start.line)
|
||||
const endLine = Math.min(a.end.line, b.end.line)
|
||||
|
||||
if (startLine > endLine) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (startLine === endLine) {
|
||||
const startCharacter = Math.max(a.start.character, b.start.character)
|
||||
const endCharacter = Math.min(a.end.character, b.end.character)
|
||||
|
||||
if (startCharacter > endCharacter) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
start: { line: startLine, character: startCharacter },
|
||||
end: { line: endLine, character: endCharacter },
|
||||
}
|
||||
}
|
||||
|
||||
const startCharacter = startLine === a.start.line ? a.start.character : b.start.character
|
||||
const endCharacter = endLine === a.end.line ? a.end.character : b.end.character
|
||||
|
||||
return {
|
||||
start: { line: startLine, character: startCharacter },
|
||||
end: { line: endLine, character: endCharacter },
|
||||
}
|
||||
}
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getSymbolsForFile } from "./treeSitter"
|
||||
|
||||
// vibecoded
|
||||
describe("getSymbolsForFile", () => {
|
||||
it("should extract symbols from Python code", async () => {
|
||||
const filepath = "test.py"
|
||||
const contents = `def greet(name):
|
||||
return f"Hello, {name}!"
|
||||
|
||||
class Calculator:
|
||||
def add(self, a, b):
|
||||
return a + b
|
||||
`
|
||||
|
||||
const symbols = await getSymbolsForFile(filepath, contents)
|
||||
|
||||
// Verify we get symbols
|
||||
expect(symbols).toBeDefined()
|
||||
expect(symbols!.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify function symbol
|
||||
const greetSymbol = symbols?.find((s) => s.name === "greet")
|
||||
expect(greetSymbol).toBeDefined()
|
||||
expect(greetSymbol?.type).toBe("function_definition")
|
||||
expect(greetSymbol?.filepath).toBe(filepath)
|
||||
expect(greetSymbol?.range.start.line).toBe(0)
|
||||
expect(greetSymbol?.content).toContain("def greet")
|
||||
|
||||
// Verify class symbol
|
||||
const calculatorSymbol = symbols?.find((s) => s.name === "Calculator")
|
||||
expect(calculatorSymbol).toBeDefined()
|
||||
expect(calculatorSymbol?.type).toBe("class_definition")
|
||||
expect(calculatorSymbol?.content).toContain("class Calculator")
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,6 @@ type Language = Parser.Language
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
type Query = Parser.Query
|
||||
type Tree = Parser.Tree
|
||||
import { SymbolWithRange } from ".."
|
||||
import { getUriFileExtension } from "./uri"
|
||||
|
||||
export enum LanguageName {
|
||||
@@ -259,87 +258,3 @@ async function loadLanguageForFileExt(fileExtension: string): Promise<Language>
|
||||
|
||||
return await Language.load(wasmPath)
|
||||
}
|
||||
|
||||
// See https://tree-sitter.github.io/tree-sitter/using-parsers
|
||||
const GET_SYMBOLS_FOR_NODE_TYPES: SyntaxNode["type"][] = [
|
||||
"class_declaration",
|
||||
"class_definition",
|
||||
"function_item", // function name = first "identifier" child
|
||||
"function_definition",
|
||||
"method_declaration", // method name = first "identifier" child
|
||||
"method_definition",
|
||||
"generator_function_declaration",
|
||||
// property_identifier
|
||||
// field_declaration
|
||||
// "arrow_function",
|
||||
]
|
||||
|
||||
export async function getSymbolsForFile(filepath: string, contents: string): Promise<SymbolWithRange[] | undefined> {
|
||||
//MINIMAL_REPO - continue doesn't use this in autocomplete
|
||||
const parser = await getParserForFile(filepath)
|
||||
if (!parser) {
|
||||
return
|
||||
}
|
||||
|
||||
let tree: Tree | null
|
||||
try {
|
||||
tree = parser.parse(contents)
|
||||
} catch {
|
||||
console.log(`Error parsing file: ${filepath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!tree) {
|
||||
console.log(`Failed to parse file: ${filepath}`)
|
||||
return
|
||||
}
|
||||
// console.log(`file: ${filepath}`);
|
||||
|
||||
// Function to recursively find all named nodes (classes and functions)
|
||||
const symbols: SymbolWithRange[] = []
|
||||
function findNamedNodesRecursive(node: SyntaxNode) {
|
||||
// console.log(`node: ${node.type}, ${node.text}`);
|
||||
if (GET_SYMBOLS_FOR_NODE_TYPES.includes(node.type)) {
|
||||
// console.log(`parent: ${node.type}, ${node.text.substring(0, 200)}`);
|
||||
// node.children.forEach((child) => {
|
||||
// console.log(`child: ${child.type}, ${child.text}`);
|
||||
// });
|
||||
|
||||
// Empirically, the actual name is the last identifier in the node
|
||||
// Especially with languages where return type is declared before the name
|
||||
// TODO use findLast in newer version of node target
|
||||
let identifier: SyntaxNode | undefined = undefined
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
const child = node.children[i]
|
||||
if (child && (child.type === "identifier" || child.type === "property_identifier")) {
|
||||
identifier = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (identifier?.text) {
|
||||
symbols.push({
|
||||
filepath,
|
||||
type: node.type,
|
||||
name: identifier.text,
|
||||
range: {
|
||||
start: {
|
||||
character: node.startPosition.column,
|
||||
line: node.startPosition.row,
|
||||
},
|
||||
end: {
|
||||
character: node.endPosition.column + 1,
|
||||
line: node.endPosition.row + 1,
|
||||
},
|
||||
},
|
||||
content: node.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
node.children.forEach((child) => {
|
||||
if (child) findNamedNodesRecursive(child)
|
||||
})
|
||||
}
|
||||
findNamedNodesRecursive(tree.rootNode)
|
||||
return symbols
|
||||
}
|
||||
|
||||
+1
-294
@@ -1,22 +1,6 @@
|
||||
import { AutocompleteLanguageInfo } from "../../../autocomplete/constants/AutocompleteLanguageInfo"
|
||||
import { AutocompleteCodeSnippet, AutocompleteSnippetType } from "../../../autocomplete/types"
|
||||
import { GetLspDefinitionsFunction } from "../../../autocomplete/types"
|
||||
import { getAst, getTreePathAtCursor } from "../../../autocomplete/util/ast"
|
||||
import { intersection } from "../../../util/ranges"
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { DocumentSymbol, IDE, Range, RangeInFile, RangeInFileWithContents, SignatureHelp } from "../../../"
|
||||
import type Parser from "web-tree-sitter"
|
||||
type SyntaxNode = Parser.SyntaxNode
|
||||
const FUNCTION_BLOCK_NODE_TYPES = ["block", "statement_block"]
|
||||
const FUNCTION_DECLARATION_NODE_TYPEs = [
|
||||
"method_definition",
|
||||
"function_definition",
|
||||
"function_item",
|
||||
"function_declaration",
|
||||
"method_declaration",
|
||||
]
|
||||
import type { DocumentSymbol, RangeInFile, SignatureHelp } from "../../../"
|
||||
|
||||
type GotoProviderName =
|
||||
| "vscode.executeDefinitionProvider"
|
||||
@@ -120,283 +104,6 @@ export async function executeGotoProvider(input: GotoInput): Promise<RangeInFile
|
||||
}
|
||||
}
|
||||
|
||||
function isRifWithContents(rif: RangeInFile | RangeInFileWithContents): rif is RangeInFileWithContents {
|
||||
return typeof (rif as any).contents === "string"
|
||||
}
|
||||
|
||||
function findChildren(node: SyntaxNode, predicate: (n: SyntaxNode) => boolean, firstN?: number): SyntaxNode[] {
|
||||
let matchingNodes: SyntaxNode[] = []
|
||||
|
||||
if (firstN && firstN <= 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Check if the current node's type is in the list of types we're interested in
|
||||
if (predicate(node)) {
|
||||
matchingNodes.push(node)
|
||||
}
|
||||
|
||||
// Recursively search for matching types in all children of the current node
|
||||
for (const child of node.children) {
|
||||
if (!child) continue
|
||||
matchingNodes = matchingNodes.concat(
|
||||
findChildren(child, predicate, firstN ? firstN - matchingNodes.length : undefined),
|
||||
)
|
||||
}
|
||||
|
||||
return matchingNodes
|
||||
}
|
||||
|
||||
function findTypeIdentifiers(node: SyntaxNode): SyntaxNode[] {
|
||||
return findChildren(
|
||||
node,
|
||||
(childNode) =>
|
||||
childNode.type === "type_identifier" ||
|
||||
(["ERROR"].includes(childNode.parent?.type ?? "") &&
|
||||
childNode.type === "identifier" &&
|
||||
childNode.text[0].toUpperCase() === childNode.text[0]),
|
||||
)
|
||||
}
|
||||
|
||||
async function crawlTypes(
|
||||
rif: RangeInFile | RangeInFileWithContents,
|
||||
ide: IDE,
|
||||
depth: number = 1,
|
||||
results: RangeInFileWithContents[] = [],
|
||||
searchedLabels: Set<string> = new Set(),
|
||||
): Promise<RangeInFileWithContents[]> {
|
||||
// Get the file contents if not already attached
|
||||
const contents = isRifWithContents(rif) ? rif.contents : await ide.readFile(rif.filepath)
|
||||
|
||||
// Parse AST
|
||||
const ast = await getAst(rif.filepath, contents)
|
||||
if (!ast) {
|
||||
return results
|
||||
}
|
||||
const astLineCount = ast.rootNode.text.split("\n").length
|
||||
|
||||
// Find type identifiers
|
||||
const identifierNodes = findTypeIdentifiers(ast.rootNode).filter((node) => !searchedLabels.has(node.text))
|
||||
// Don't search for the same type definition more than once
|
||||
// We deduplicate below to be sure, but this saves calls to the LSP
|
||||
identifierNodes.forEach((node) => searchedLabels.add(node.text))
|
||||
|
||||
// Use LSP to get the definitions of those types
|
||||
const definitions = []
|
||||
|
||||
for (const node of identifierNodes) {
|
||||
const [typeDef] = await executeGotoProvider({
|
||||
uri: vscode.Uri.parse(rif.filepath),
|
||||
// TODO: tree-sitter is zero-indexed, but there seems to be an off-by-one
|
||||
// error at least with the .ts parser sometimes
|
||||
line: rif.range.start.line + Math.min(node.startPosition.row, astLineCount - 1),
|
||||
character: rif.range.start.character + node.startPosition.column,
|
||||
name: "vscode.executeDefinitionProvider",
|
||||
})
|
||||
|
||||
if (!typeDef) {
|
||||
definitions.push(undefined)
|
||||
continue
|
||||
}
|
||||
|
||||
const contents = await ide.readRangeInFile(typeDef.filepath, typeDef.range)
|
||||
|
||||
definitions.push({
|
||||
...typeDef,
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Filter out if not in our code?
|
||||
|
||||
// Filter out duplicates
|
||||
for (const definition of definitions) {
|
||||
if (
|
||||
!definition ||
|
||||
results.some(
|
||||
(result) => result.filepath === definition.filepath && intersection(result.range, definition.range) !== null,
|
||||
)
|
||||
) {
|
||||
continue // ;)
|
||||
}
|
||||
results.push(definition)
|
||||
}
|
||||
|
||||
// Recurse
|
||||
if (depth > 0) {
|
||||
for (const result of [...results]) {
|
||||
await crawlTypes(result, ide, depth - 1, results, searchedLabels)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function getDefinitionsForNode(
|
||||
uri: vscode.Uri,
|
||||
node: SyntaxNode,
|
||||
ide: IDE,
|
||||
lang: AutocompleteLanguageInfo,
|
||||
): Promise<RangeInFileWithContents[]> {
|
||||
const ranges: (RangeInFile | RangeInFileWithContents)[] = []
|
||||
switch (node.type) {
|
||||
case "call_expression": {
|
||||
// function call -> function definition
|
||||
const [funDef] = await executeGotoProvider({
|
||||
uri,
|
||||
line: node.startPosition.row,
|
||||
character: node.startPosition.column,
|
||||
name: "vscode.executeDefinitionProvider",
|
||||
})
|
||||
if (!funDef) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Don't display a function of more than 15 lines
|
||||
// We can of course do something smarter here eventually
|
||||
let funcText = await ide.readRangeInFile(funDef.filepath, funDef.range)
|
||||
if (funcText.split("\n").length > 15) {
|
||||
let truncated = false
|
||||
const funRootAst = await getAst(funDef.filepath, funcText)
|
||||
if (funRootAst) {
|
||||
const [funNode] = findChildren(
|
||||
funRootAst?.rootNode,
|
||||
(node) => FUNCTION_DECLARATION_NODE_TYPEs.includes(node.type),
|
||||
1,
|
||||
)
|
||||
if (funNode) {
|
||||
const [statementBlockNode] = findChildren(
|
||||
funNode,
|
||||
(node) => FUNCTION_BLOCK_NODE_TYPES.includes(node.type),
|
||||
1,
|
||||
)
|
||||
if (statementBlockNode) {
|
||||
funcText = funRootAst.rootNode.text.slice(0, statementBlockNode.startIndex).trim()
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!truncated) {
|
||||
funcText = funcText.split("\n")[0]
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push(funDef)
|
||||
|
||||
const typeDefs = await crawlTypes(
|
||||
{
|
||||
...funDef,
|
||||
contents: funcText,
|
||||
},
|
||||
ide,
|
||||
)
|
||||
ranges.push(...typeDefs)
|
||||
break
|
||||
}
|
||||
case "variable_declarator":
|
||||
// variable assignment -> variable definition/type
|
||||
// usages of the var that appear after the declaration
|
||||
break
|
||||
case "impl_item":
|
||||
// impl of trait -> trait definition
|
||||
break
|
||||
case "new_expression": {
|
||||
// In 'new MyClass(...)', "MyClass" is the classNameNode
|
||||
const classNameNode = node.children.find((child) => child && child.type === "identifier")
|
||||
const [classDef] = await executeGotoProvider({
|
||||
uri,
|
||||
line: (classNameNode ?? node).endPosition.row,
|
||||
character: (classNameNode ?? node).endPosition.column,
|
||||
name: "vscode.executeDefinitionProvider",
|
||||
})
|
||||
if (!classDef) {
|
||||
break
|
||||
}
|
||||
const contents = await ide.readRangeInFile(classDef.filepath, classDef.range)
|
||||
|
||||
ranges.push({
|
||||
...classDef,
|
||||
contents: `${
|
||||
classNameNode?.text ? `${lang.singleLineComment} ${classNameNode.text}:\n` : ""
|
||||
}${contents.trim()}`,
|
||||
})
|
||||
|
||||
const definitions = await crawlTypes({ ...classDef, contents }, ide)
|
||||
ranges.push(...definitions.filter(Boolean))
|
||||
|
||||
break
|
||||
}
|
||||
case "":
|
||||
// function definition -> implementations?
|
||||
break
|
||||
}
|
||||
return await Promise.all(
|
||||
ranges.map(async (rif) => {
|
||||
// Convert the VS Code Range type to ours
|
||||
const range: Range = {
|
||||
start: {
|
||||
line: rif.range.start.line,
|
||||
character: rif.range.start.character,
|
||||
},
|
||||
end: {
|
||||
line: rif.range.end.line,
|
||||
character: rif.range.end.character,
|
||||
},
|
||||
}
|
||||
rif.range = range
|
||||
|
||||
if (!isRifWithContents(rif)) {
|
||||
return {
|
||||
...rif,
|
||||
contents: await ide.readRangeInFile(rif.filepath, rif.range),
|
||||
}
|
||||
}
|
||||
return rif
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* and other stuff not directly on the path:
|
||||
* - variables defined on line above
|
||||
* ...etc...
|
||||
*/
|
||||
|
||||
export const getDefinitionsFromLsp: GetLspDefinitionsFunction = async (
|
||||
filepath: string,
|
||||
contents: string,
|
||||
cursorIndex: number,
|
||||
ide: IDE,
|
||||
lang: AutocompleteLanguageInfo,
|
||||
): Promise<AutocompleteCodeSnippet[]> => {
|
||||
try {
|
||||
const ast = await getAst(filepath, contents)
|
||||
if (!ast) {
|
||||
return []
|
||||
}
|
||||
|
||||
const treePath = await getTreePathAtCursor(ast, cursorIndex)
|
||||
if (!treePath) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: RangeInFileWithContents[] = []
|
||||
for (const node of treePath.reverse()) {
|
||||
const definitions = await getDefinitionsForNode(vscode.Uri.parse(filepath), node, ide, lang)
|
||||
results.push(...definitions)
|
||||
}
|
||||
|
||||
return results.map((result) => ({
|
||||
filepath: result.filepath,
|
||||
content: result.contents,
|
||||
type: AutocompleteSnippetType.Code,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn("Error getting definitions from LSP: ", e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
type SymbolProviderName = "vscode.executeDocumentSymbolProvider"
|
||||
|
||||
interface SymbolInput {
|
||||
|
||||
@@ -122,10 +122,6 @@ export class NextEditSuggestionManager implements vscode.Disposable {
|
||||
return this.pending !== null
|
||||
}
|
||||
|
||||
public getPending(): PendingNextEdit | null {
|
||||
return this.pending
|
||||
}
|
||||
|
||||
public setPending(p: PendingNextEdit): void {
|
||||
this.clearDecorations()
|
||||
this.pending = p
|
||||
|
||||
@@ -23,7 +23,6 @@ function toPosix(filePath: string): string {
|
||||
export class FileIgnoreController {
|
||||
private workspacePath: string
|
||||
private ignoreInstance: Ignore = ignore()
|
||||
private loadedContents: Array<{ file: string; content: string }> = []
|
||||
private readonly realpathCache = new Map<string, string>()
|
||||
|
||||
constructor(workspacePath?: string) {
|
||||
@@ -32,7 +31,6 @@ export class FileIgnoreController {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.ignoreInstance = ignore()
|
||||
this.loadedContents = []
|
||||
this.realpathCache.clear()
|
||||
|
||||
if (!this.workspacePath) {
|
||||
@@ -48,7 +46,6 @@ export class FileIgnoreController {
|
||||
if (kilocodeignoreContent.trim()) {
|
||||
this.ignoreInstance.add(kilocodeignoreContent)
|
||||
this.ignoreInstance.add(KILOCODEIGNORE)
|
||||
this.loadedContents.push({ file: KILOCODEIGNORE, content: kilocodeignoreContent })
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -59,7 +56,6 @@ export class FileIgnoreController {
|
||||
const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8")
|
||||
if (gitignoreContent.trim()) {
|
||||
this.ignoreInstance.add(gitignoreContent)
|
||||
this.loadedContents.push({ file: GITIGNORE, content: gitignoreContent })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,31 +123,7 @@ export class FileIgnoreController {
|
||||
return !this.ignoreInstance.ignores(relative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a list of candidate paths to those allowed.
|
||||
* When no workspace path was provided, returns an empty array.
|
||||
*/
|
||||
filterPaths(paths: string[]): string[] {
|
||||
if (!this.workspacePath) {
|
||||
return []
|
||||
}
|
||||
return paths.filter((candidate) => this.validateAccess(candidate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns user-facing instructions explaining why access is restricted.
|
||||
*/
|
||||
getInstructions(): string | undefined {
|
||||
if (this.loadedContents.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sections = this.loadedContents.map(({ file, content }) => `# ${file}\n\n${content.trimEnd()}`)
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.loadedContents = []
|
||||
this.realpathCache.clear()
|
||||
this.ignoreInstance = ignore()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient, McpStatus } from "@kilocode/sdk/v2/client"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
|
||||
export type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
|
||||
type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
|
||||
|
||||
export class BrowserAutomationService implements vscode.Disposable {
|
||||
private state: BrowserAutomationState = "disabled"
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private stateListeners: Array<(state: BrowserAutomationState) => void> = []
|
||||
|
||||
// MCP server name used when registering with the CLI backend
|
||||
private static readonly MCP_SERVER_NAME = "kilo-playwright"
|
||||
@@ -23,22 +22,6 @@ export class BrowserAutomationService implements vscode.Disposable {
|
||||
)
|
||||
}
|
||||
|
||||
/** Current state */
|
||||
getState(): BrowserAutomationState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/** Subscribe to state changes */
|
||||
onStateChange(listener: (state: BrowserAutomationState) => void): () => void {
|
||||
this.stateListeners.push(listener)
|
||||
return () => {
|
||||
const idx = this.stateListeners.indexOf(listener)
|
||||
if (idx >= 0) {
|
||||
this.stateListeners.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read settings and enable/disable accordingly.
|
||||
* Called on construction and when settings change.
|
||||
@@ -150,24 +133,6 @@ export class BrowserAutomationService implements vscode.Disposable {
|
||||
this.setState("disabled")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current MCP server status from the CLI backend.
|
||||
*/
|
||||
async getServerStatus(): Promise<McpStatus | null> {
|
||||
const client = this.getClient()
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = this.getWorkspaceDirectory()
|
||||
const { data: allStatus } = await client.mcp.status({ directory }, { throwOnError: true })
|
||||
return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getClient(): KiloClient | null {
|
||||
try {
|
||||
return this.connectionService.getClient()
|
||||
@@ -190,9 +155,6 @@ export class BrowserAutomationService implements vscode.Disposable {
|
||||
}
|
||||
console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`)
|
||||
this.state = state
|
||||
for (const listener of this.stateListeners) {
|
||||
listener(state)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -200,6 +162,5 @@ export class BrowserAutomationService implements vscode.Disposable {
|
||||
d.dispose()
|
||||
}
|
||||
this.disposables = []
|
||||
this.stateListeners = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +136,6 @@ export class MarketplaceApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client"
|
||||
|
||||
/**
|
||||
* Returns the number of sessions currently in "busy" state.
|
||||
* Used to warn users before operations that will interrupt running sessions.
|
||||
*/
|
||||
export function getBusySessionCount(map: Map<string, SessionStatus["type"]>): number {
|
||||
let count = 0
|
||||
for (const status of map.values()) {
|
||||
if (status === "busy") count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all current session statuses and seed the provided map + webview.
|
||||
* Called on connect so the Settings panel knows about already-running sessions
|
||||
|
||||
@@ -81,7 +81,6 @@ describe("ErrorBackoff", () => {
|
||||
})
|
||||
|
||||
it("is not fatal initially", () => {
|
||||
expect(backoff.isFatal()).toBe(false)
|
||||
expect(backoff.getFatalStatus()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -89,21 +88,18 @@ describe("ErrorBackoff", () => {
|
||||
it("blocks after a 402 error", () => {
|
||||
backoff.failure(new Error("SSE failed: 402 Payment Required"))
|
||||
expect(backoff.blocked()).toBe(true)
|
||||
expect(backoff.isFatal()).toBe(true)
|
||||
expect(backoff.getFatalStatus()).toBe(402)
|
||||
})
|
||||
|
||||
it("blocks after a 401 error", () => {
|
||||
backoff.failure(new Error("SSE failed: 401 Unauthorized"))
|
||||
expect(backoff.blocked()).toBe(true)
|
||||
expect(backoff.isFatal()).toBe(true)
|
||||
expect(backoff.getFatalStatus()).toBe(401)
|
||||
})
|
||||
|
||||
it("blocks after a 403 error", () => {
|
||||
backoff.failure(new Error("SSE failed: 403 Forbidden"))
|
||||
expect(backoff.blocked()).toBe(true)
|
||||
expect(backoff.isFatal()).toBe(true)
|
||||
expect(backoff.getFatalStatus()).toBe(403)
|
||||
})
|
||||
|
||||
@@ -168,7 +164,6 @@ describe("ErrorBackoff", () => {
|
||||
|
||||
backoff.reset()
|
||||
expect(backoff.blocked()).toBe(false)
|
||||
expect(backoff.isFatal()).toBe(false)
|
||||
expect(backoff.getFatalStatus()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -178,7 +173,6 @@ describe("ErrorBackoff", () => {
|
||||
|
||||
backoff.success()
|
||||
expect(backoff.blocked()).toBe(false)
|
||||
expect(backoff.isFatal()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -232,7 +226,6 @@ describe("ErrorBackoff", () => {
|
||||
it("fatal error overrides retriable backoff", () => {
|
||||
backoff.failure(new Error("SSE failed: 500 Internal Server Error"))
|
||||
backoff.failure(new Error("SSE failed: 402 Payment Required"))
|
||||
expect(backoff.isFatal()).toBe(true)
|
||||
expect(backoff.blocked()).toBe(true)
|
||||
})
|
||||
|
||||
@@ -244,7 +237,6 @@ describe("ErrorBackoff", () => {
|
||||
|
||||
backoff.success()
|
||||
expect(backoff.blocked()).toBe(false)
|
||||
expect(backoff.isFatal()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,6 @@ describe("FileIgnoreController", () => {
|
||||
expect(controller.validateAccess("secret/keys.txt")).toBe(false)
|
||||
expect(controller.validateAccess(path.join(workspace, "a.snap"))).toBe(false)
|
||||
expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true)
|
||||
expect(controller.getInstructions()).toContain(".kilocodeignore")
|
||||
})
|
||||
|
||||
it("does NOT block .env files unless explicitly listed", async () => {
|
||||
@@ -81,7 +80,6 @@ describe("FileIgnoreController", () => {
|
||||
expect(controller.validateAccess(path.join(workspace, "node_modules", "foo.js"))).toBe(false)
|
||||
expect(controller.validateAccess(path.join(workspace, "build", "output.js"))).toBe(false)
|
||||
expect(controller.validateAccess(path.join(workspace, "src", "main.ts"))).toBe(true)
|
||||
expect(controller.getInstructions()).toContain(".gitignore")
|
||||
})
|
||||
|
||||
it("blocks .env files via hardcoded sensitive patterns", async () => {
|
||||
@@ -167,12 +165,5 @@ describe("FileIgnoreController", () => {
|
||||
expect(controller.validateAccess("/some/file.ts")).toBe(false)
|
||||
expect(controller.validateAccess("relative/file.ts")).toBe(false)
|
||||
})
|
||||
|
||||
it("filterPaths returns empty array", async () => {
|
||||
const controller = new FileIgnoreController("")
|
||||
await controller.initialize()
|
||||
|
||||
expect(controller.filterPaths(["/some/file.ts", "other.ts"])).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { seedSessionStatuses, getBusySessionCount } from "../../src/session-status"
|
||||
import { seedSessionStatuses } from "../../src/session-status"
|
||||
import type { SessionStatus } from "@kilocode/sdk/v2/client"
|
||||
|
||||
/**
|
||||
@@ -196,23 +196,3 @@ describe("seedSessionStatuses", () => {
|
||||
expect(msgs).toEqual([{ type: "sessionStatus", sessionID: "confirmed", status: "busy" }])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getBusySessionCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getBusySessionCount", () => {
|
||||
it("returns 0 for empty map", () => {
|
||||
expect(getBusySessionCount(new Map())).toBe(0)
|
||||
})
|
||||
|
||||
it("counts only busy entries, not idle or retry", () => {
|
||||
const map = new Map<string, SessionStatus["type"]>([
|
||||
["a", "busy"],
|
||||
["b", "idle"],
|
||||
["c", "retry"],
|
||||
["d", "busy"],
|
||||
])
|
||||
expect(getBusySessionCount(map)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user