Skip to content

Commit 65f23b9

Browse files
committed
fix(ui): load system command completions asynchronously
1 parent 0de8f9e commit 65f23b9

4 files changed

Lines changed: 191 additions & 66 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { __loadCommandsFromPathForTests } from './useSystemCommands'
6+
7+
const tmpDirs: string[] = []
8+
9+
afterEach(async () => {
10+
await Promise.all(tmpDirs.splice(0).map(dir => rm(dir, { recursive: true })))
11+
})
12+
13+
describe('loadCommandsFromPath', () => {
14+
test('loads essential commands and executable PATH entries asynchronously', async () => {
15+
const dir = await mkdtemp(join(tmpdir(), 'kode-commands-'))
16+
tmpDirs.push(dir)
17+
18+
const commandName =
19+
process.platform === 'win32'
20+
? 'kode-test-command.cmd'
21+
: 'kode-test-command'
22+
const commandPath = join(dir, commandName)
23+
await writeFile(commandPath, '#!/bin/sh\necho ok\n', 'utf8')
24+
if (process.platform !== 'win32') {
25+
await chmod(commandPath, 0o755)
26+
}
27+
28+
const commands = await __loadCommandsFromPathForTests(dir)
29+
30+
expect(commands).toContain('git')
31+
expect(commands).toContain(commandName)
32+
})
33+
})
Lines changed: 101 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { readdir, stat } from 'node:fs/promises'
2+
import { delimiter, join } from 'node:path'
3+
import { useEffect, useState } from 'react'
24

35
import { debug as debugLogger } from '#core/utils/debugLogger'
46
import { logError } from '#core/utils/log'
@@ -7,61 +9,114 @@ import {
79
getMinimalFallbackCommands,
810
} from '#cli-utils/completion/commonUnixCommands'
911

12+
const COMMAND_SCAN_BATCH_SIZE = 64
13+
type CommandDirent = {
14+
name: string | Buffer
15+
isFile(): boolean
16+
isSymbolicLink(): boolean
17+
}
18+
19+
async function yieldToEventLoop(): Promise<void> {
20+
await new Promise(resolve => setTimeout(resolve, 0))
21+
}
22+
23+
async function loadCommandsFromPath(
24+
pathValue: string,
25+
shouldStop: () => boolean = () => false,
26+
): Promise<string[]> {
27+
const pathDirs = Array.from(
28+
new Set(
29+
pathValue
30+
.split(delimiter)
31+
.map(dir => dir.trim())
32+
.filter(Boolean),
33+
),
34+
)
35+
const commandSet = new Set<string>(getEssentialCommands())
36+
37+
for (const dir of pathDirs) {
38+
if (shouldStop()) break
39+
40+
let entries: CommandDirent[]
41+
try {
42+
entries = (await readdir(dir, {
43+
withFileTypes: true,
44+
})) as CommandDirent[]
45+
} catch {
46+
continue
47+
}
48+
49+
for (let i = 0; i < entries.length; i += COMMAND_SCAN_BATCH_SIZE) {
50+
if (shouldStop()) break
51+
52+
const batch = entries.slice(i, i + COMMAND_SCAN_BATCH_SIZE)
53+
const commandNames = await Promise.all(
54+
batch.map(async entry => {
55+
try {
56+
if (!entry.isFile() && !entry.isSymbolicLink()) return null
57+
58+
const entryName = String(entry.name)
59+
const fullPath = join(dir, entryName)
60+
const stats = await stat(fullPath)
61+
const isExecutable =
62+
process.platform === 'win32' || (stats.mode & 0o111) !== 0
63+
return stats.isFile() && isExecutable ? entryName : null
64+
} catch {
65+
return null
66+
}
67+
}),
68+
)
69+
70+
for (const commandName of commandNames) {
71+
if (commandName) commandSet.add(commandName)
72+
}
73+
74+
await yieldToEventLoop()
75+
}
76+
}
77+
78+
return Array.from(commandSet).sort()
79+
}
80+
1081
export function useSystemCommands(): {
1182
systemCommands: string[]
1283
isLoadingCommands: boolean
1384
} {
14-
const [systemCommands, setSystemCommands] = useState<string[]>([])
85+
const [systemCommands, setSystemCommands] = useState<string[]>(() =>
86+
getEssentialCommands(),
87+
)
1588
const [isLoadingCommands, setIsLoadingCommands] = useState(false)
1689

17-
const loadSystemCommands = useCallback(async () => {
18-
if (systemCommands.length > 0 || isLoadingCommands) return
90+
useEffect(() => {
91+
let cancelled = false
92+
const shouldStop = () => cancelled
1993

20-
setIsLoadingCommands(true)
21-
try {
22-
const { readdirSync, statSync } = await import('fs')
23-
const pathDirs = (process.env.PATH || '').split(':').filter(Boolean)
24-
const commandSet = new Set<string>()
25-
26-
getEssentialCommands().forEach(cmd => commandSet.add(cmd))
27-
28-
for (const dir of pathDirs) {
29-
try {
30-
if (readdirSync && statSync) {
31-
const entries = readdirSync(dir)
32-
for (const entry of entries) {
33-
try {
34-
const fullPath = `${dir}/${entry}`
35-
const stats = statSync(fullPath)
36-
if (stats.isFile() && (stats.mode & 0o111) !== 0) {
37-
commandSet.add(entry)
38-
}
39-
} catch {
40-
// Skip files we can't stat
41-
}
42-
}
43-
}
44-
} catch {
45-
// Skip directories we can't read
46-
}
94+
async function loadSystemCommands(): Promise<void> {
95+
setIsLoadingCommands(true)
96+
try {
97+
const next = await loadCommandsFromPath(
98+
process.env.PATH || '',
99+
shouldStop,
100+
)
101+
if (!shouldStop()) setSystemCommands(next)
102+
} catch (error) {
103+
logError(error)
104+
debugLogger.warn('UNIFIED_COMPLETION_SYSTEM_COMMANDS_LOAD_FAILED', {
105+
error: error instanceof Error ? error.message : String(error),
106+
})
107+
if (!shouldStop()) setSystemCommands(getMinimalFallbackCommands())
108+
} finally {
109+
if (!shouldStop()) setIsLoadingCommands(false)
47110
}
48-
49-
const next = Array.from(commandSet).sort()
50-
setSystemCommands(next)
51-
} catch (error) {
52-
logError(error)
53-
debugLogger.warn('UNIFIED_COMPLETION_SYSTEM_COMMANDS_LOAD_FAILED', {
54-
error: error instanceof Error ? error.message : String(error),
55-
})
56-
setSystemCommands(getMinimalFallbackCommands())
57-
} finally {
58-
setIsLoadingCommands(false)
59111
}
60-
}, [systemCommands.length, isLoadingCommands])
61112

62-
useEffect(() => {
63-
loadSystemCommands()
64-
}, [loadSystemCommands])
113+
void loadSystemCommands()
114+
return () => {
115+
cancelled = true
116+
}
117+
}, [])
65118

66119
return { systemCommands, isLoadingCommands }
67120
}
121+
122+
export const __loadCommandsFromPathForTests = loadCommandsFromPath
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { getEssentialCommands } from './commonUnixCommands'
3+
import { generateUnixCommandSuggestions } from './unixCommandSuggestions'
4+
5+
describe('generateUnixCommandSuggestions', () => {
6+
test('shows fallback command matches while full command scan is loading', () => {
7+
const suggestions = generateUnixCommandSuggestions({
8+
prefix: 'gi',
9+
systemCommands: getEssentialCommands(),
10+
isLoadingCommands: true,
11+
})
12+
13+
expect(suggestions.map(s => s.value)).toContain('git')
14+
expect(suggestions.some(s => s.metadata?.isLoading)).toBe(false)
15+
})
16+
17+
test('shows loading only when loading has no command match', () => {
18+
const suggestions = generateUnixCommandSuggestions({
19+
prefix: 'zzzz-no-match',
20+
systemCommands: getEssentialCommands(),
21+
isLoadingCommands: true,
22+
})
23+
24+
expect(suggestions).toHaveLength(1)
25+
expect(suggestions[0]?.metadata?.isLoading).toBe(true)
26+
})
27+
})

apps/cli/src/utils/completion/unixCommandSuggestions.ts

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,10 @@ import {
55
} from './commonUnixCommands'
66
import type { UnifiedSuggestion } from './types'
77

8-
export function generateUnixCommandSuggestions(args: {
9-
prefix: string
10-
systemCommands: string[]
11-
isLoadingCommands: boolean
12-
}): UnifiedSuggestion[] {
13-
const { prefix, systemCommands, isLoadingCommands } = args
14-
if (!prefix) return []
15-
16-
if (isLoadingCommands) {
17-
return [
18-
{
19-
value: 'loading...',
20-
displayValue: `⏳ Loading system commands...`,
21-
type: 'file' as const,
22-
score: 0,
23-
metadata: { isLoading: true },
24-
},
25-
]
26-
}
27-
8+
function buildCommandSuggestions(
9+
prefix: string,
10+
systemCommands: string[],
11+
): UnifiedSuggestion[] {
2812
const commonCommands = getCommonSystemCommands(systemCommands)
2913
const uniqueCommands = Array.from(new Set(commonCommands))
3014
const matches = matchCommands(uniqueCommands, prefix)
@@ -59,3 +43,29 @@ export function generateUnixCommandSuggestions(args: {
5943
metadata: { isUnixCommand: true },
6044
}))
6145
}
46+
47+
function loadingSuggestion(): UnifiedSuggestion {
48+
return {
49+
value: 'loading...',
50+
displayValue: 'Loading system commands...',
51+
type: 'file' as const,
52+
score: 0,
53+
metadata: { isLoading: true },
54+
}
55+
}
56+
57+
export function generateUnixCommandSuggestions(args: {
58+
prefix: string
59+
systemCommands: string[]
60+
isLoadingCommands: boolean
61+
}): UnifiedSuggestion[] {
62+
const { prefix, systemCommands, isLoadingCommands } = args
63+
if (!prefix) return []
64+
65+
const commandSuggestions = buildCommandSuggestions(prefix, systemCommands)
66+
if (commandSuggestions.length > 0) return commandSuggestions
67+
68+
if (isLoadingCommands) return [loadingSuggestion()]
69+
70+
return []
71+
}

0 commit comments

Comments
 (0)