290 lines
8.2 KiB
TypeScript
290 lines
8.2 KiB
TypeScript
import figures from 'figures'
|
|
import * as React from 'react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useDebounceCallback } from 'usehooks-ts'
|
|
import {
|
|
addDirHelpMessage,
|
|
validateDirectoryForWorkspace,
|
|
} from '../../../commands/add-dir/validation.js'
|
|
import TextInput from '../../../components/TextInput.js'
|
|
import { type KeyboardEvent, Box, Text } from '@anthropic/ink'
|
|
import { useKeybinding } from '../../../keybindings/useKeybinding.js'
|
|
import type { ToolPermissionContext } from '../../../Tool.js'
|
|
import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js'
|
|
import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.js'
|
|
import { Select } from '../../CustomSelect/select.js'
|
|
import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
|
import {
|
|
PromptInputFooterSuggestions,
|
|
type SuggestionItem,
|
|
} from '../../PromptInput/PromptInputFooterSuggestions.js'
|
|
|
|
type Props = {
|
|
onAddDirectory: (path: string, remember?: boolean) => void
|
|
onCancel: () => void
|
|
permissionContext: ToolPermissionContext
|
|
directoryPath?: string // When directoryPath is provided, show selection options instead of input
|
|
}
|
|
|
|
type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no'
|
|
|
|
const REMEMBER_DIRECTORY_OPTIONS: Array<{
|
|
value: RememberDirectoryOption
|
|
label: string
|
|
}> = [
|
|
{
|
|
value: 'yes-session',
|
|
label: 'Yes, for this session',
|
|
},
|
|
{
|
|
value: 'yes-remember',
|
|
label: 'Yes, and remember this directory',
|
|
},
|
|
{
|
|
value: 'no',
|
|
label: 'No',
|
|
},
|
|
]
|
|
|
|
function PermissionDescription(): React.ReactNode {
|
|
return (
|
|
<Text dimColor>
|
|
Claude Code will be able to read files in this directory and make edits
|
|
when auto-accept edits is on.
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
function DirectoryDisplay({ path }: { path: string }): React.ReactNode {
|
|
return (
|
|
<Box flexDirection="column" paddingX={2} gap={1}>
|
|
<Text color="permission">{path}</Text>
|
|
<PermissionDescription />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function DirectoryInput({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
error,
|
|
suggestions,
|
|
selectedSuggestion,
|
|
}: {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
onSubmit: (value: string) => void
|
|
error: string | null
|
|
suggestions: SuggestionItem[]
|
|
selectedSuggestion: number
|
|
}): React.ReactNode {
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text>Enter the path to the directory:</Text>
|
|
<Box borderDimColor borderStyle="round" marginY={1} paddingLeft={1}>
|
|
<TextInput
|
|
showCursor
|
|
placeholder={`Directory path${figures.ellipsis}`}
|
|
value={value}
|
|
onChange={onChange}
|
|
onSubmit={onSubmit}
|
|
columns={80}
|
|
cursorOffset={value.length}
|
|
onChangeCursorOffset={() => {}}
|
|
/>
|
|
</Box>
|
|
{suggestions.length > 0 && (
|
|
<Box marginBottom={1}>
|
|
<PromptInputFooterSuggestions
|
|
suggestions={suggestions}
|
|
selectedSuggestion={selectedSuggestion}
|
|
/>
|
|
</Box>
|
|
)}
|
|
{error && <Text color="error">{error}</Text>}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export function AddWorkspaceDirectory({
|
|
onAddDirectory,
|
|
onCancel,
|
|
permissionContext,
|
|
directoryPath,
|
|
}: Props): React.ReactNode {
|
|
const [directoryInput, setDirectoryInput] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([])
|
|
const [selectedSuggestion, setSelectedSuggestion] = useState(0)
|
|
const options = useMemo(() => REMEMBER_DIRECTORY_OPTIONS, [])
|
|
|
|
// Fetch directory completions
|
|
const fetchSuggestions = useCallback(async (path: string) => {
|
|
if (!path) {
|
|
setSuggestions([])
|
|
setSelectedSuggestion(0)
|
|
return
|
|
}
|
|
const completions = await getDirectoryCompletions(path)
|
|
setSuggestions(completions)
|
|
setSelectedSuggestion(0)
|
|
}, [])
|
|
|
|
const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100)
|
|
|
|
useEffect(() => {
|
|
void debouncedFetchSuggestions(directoryInput)
|
|
}, [directoryInput, debouncedFetchSuggestions])
|
|
|
|
const applySuggestion = useCallback((suggestion: SuggestionItem) => {
|
|
const newPath = suggestion.id + '/'
|
|
setDirectoryInput(newPath)
|
|
setError(null)
|
|
// Suggestions will update via the useEffect
|
|
}, [])
|
|
|
|
// Handle directory submission from input
|
|
const handleSubmit = useCallback(
|
|
async (newPath: string) => {
|
|
const result = await validateDirectoryForWorkspace(
|
|
newPath,
|
|
permissionContext,
|
|
)
|
|
|
|
if (result.resultType === 'success') {
|
|
onAddDirectory(result.absolutePath, false)
|
|
} else {
|
|
setError(addDirHelpMessage(result))
|
|
}
|
|
},
|
|
[permissionContext, onAddDirectory],
|
|
)
|
|
|
|
// Handle Esc to cancel (Ctrl+C handled by global keybindings)
|
|
// Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input)
|
|
useKeybinding('confirm:no', onCancel, { context: 'Settings' })
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
if (suggestions.length > 0) {
|
|
// Tab: accept selected suggestion and continue (for drilling into subdirs)
|
|
if (e.key === 'tab') {
|
|
e.preventDefault()
|
|
const suggestion = suggestions[selectedSuggestion]
|
|
if (suggestion) {
|
|
applySuggestion(suggestion)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Enter: apply selected suggestion and submit
|
|
if (e.key === 'return') {
|
|
e.preventDefault()
|
|
const suggestion = suggestions[selectedSuggestion]
|
|
if (suggestion) {
|
|
void handleSubmit(suggestion.id + '/')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
|
e.preventDefault()
|
|
setSelectedSuggestion(prev =>
|
|
prev <= 0 ? suggestions.length - 1 : prev - 1,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
|
e.preventDefault()
|
|
setSelectedSuggestion(prev =>
|
|
prev >= suggestions.length - 1 ? 0 : prev + 1,
|
|
)
|
|
return
|
|
}
|
|
}
|
|
},
|
|
[suggestions, selectedSuggestion, applySuggestion, handleSubmit],
|
|
)
|
|
|
|
const handleSelect = useCallback(
|
|
(value: string) => {
|
|
if (!directoryPath) return
|
|
|
|
const selectionValue = value as RememberDirectoryOption
|
|
|
|
switch (selectionValue) {
|
|
case 'yes-session':
|
|
onAddDirectory(directoryPath, false)
|
|
break
|
|
case 'yes-remember':
|
|
onAddDirectory(directoryPath, true)
|
|
break
|
|
case 'no':
|
|
onCancel()
|
|
break
|
|
}
|
|
},
|
|
[directoryPath, onAddDirectory, onCancel],
|
|
)
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
tabIndex={0}
|
|
autoFocus
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<Dialog
|
|
title="Add directory to workspace"
|
|
onCancel={onCancel}
|
|
color="permission"
|
|
isCancelActive={false}
|
|
inputGuide={
|
|
directoryPath
|
|
? undefined
|
|
: exitState =>
|
|
exitState.pending ? (
|
|
<Text>Press {exitState.keyName} again to exit</Text>
|
|
) : (
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="Tab" action="complete" />
|
|
<KeyboardShortcutHint shortcut="Enter" action="add" />
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Settings"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
</Byline>
|
|
)
|
|
}
|
|
>
|
|
{directoryPath ? (
|
|
<Box flexDirection="column" gap={1}>
|
|
<DirectoryDisplay path={directoryPath} />
|
|
<Select
|
|
options={options}
|
|
onChange={handleSelect}
|
|
onCancel={() => handleSelect('no')}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Box flexDirection="column" gap={1} marginX={2}>
|
|
<PermissionDescription />
|
|
<DirectoryInput
|
|
value={directoryInput}
|
|
onChange={setDirectoryInput}
|
|
onSubmit={handleSubmit}
|
|
error={error}
|
|
suggestions={suggestions}
|
|
selectedSuggestion={selectedSuggestion}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Dialog>
|
|
</Box>
|
|
)
|
|
}
|