claude-code-best/src/components/permissions/rules/AddWorkspaceDirectory.tsx
2026-04-07 16:17:48 +08:00

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>
)
}