mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 12:40:44 +00:00
feat(permissions): add workspace directory management tab
- Add Workspace tab to PermissionsDialog with full directory management UI
- Directory list view: initial (non-removable) dirs shown inline,
runtime-added dirs selectable; "Add directory…" always first
- Add directory input view: filesystem autocomplete with ↑/↓ navigation
and Tab-to-complete; path validation (existence, type, duplicate,
subdirectory checks)
- Remove directory confirmation view
- Save directly to project settings (SettingScope.Workspace), no scope
selection step
- Add onTab/onUp/onDown props to TextInput to intercept keys before buffer
- Add removeDirectory() and isInitialDirectory() to WorkspaceContext
- Add --add-dir CLI alias for --include-directories
- Add /add-dir slash command (alias for /directory add)
- Add permissions.additionalDirectories settings field
- Add i18n keys for all workspace directory UI strings (en/zh/de/ja/pt/ru)"
This commit is contained in:
parent
217d59c892
commit
e793e82729
14 changed files with 780 additions and 13 deletions
|
|
@ -7,6 +7,9 @@
|
|||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as nodePath from 'node:path';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
|
@ -21,6 +24,7 @@ import type {
|
|||
RuleWithSource,
|
||||
RuleType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isPathWithinRoot } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -39,7 +43,10 @@ type DialogView =
|
|||
| 'rule-list' // main rule list view
|
||||
| 'add-rule-input' // text input for new rule
|
||||
| 'add-rule-scope' // scope selector after entering a rule
|
||||
| 'delete-confirm'; // confirm rule deletion
|
||||
| 'delete-confirm' // confirm rule deletion
|
||||
| 'ws-dir-list' // workspace directory list
|
||||
| 'ws-add-dir-input' // text input for adding a directory
|
||||
| 'ws-remove-confirm'; // confirm directory removal
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope items (matches Claude Code screenshot layout)
|
||||
|
|
@ -160,6 +167,15 @@ export function PermissionsDialog({
|
|||
const [pendingRuleText, setPendingRuleText] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleWithSource | null>(null);
|
||||
|
||||
// --- Workspace directory state ---
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const [newDirInput, setNewDirInput] = useState('');
|
||||
const [dirInputError, setDirInputError] = useState('');
|
||||
const [dirInputRemountKey, setDirInputRemountKey] = useState(0);
|
||||
const [completionIndex, setCompletionIndex] = useState(0);
|
||||
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
|
||||
const [dirRefreshKey, setDirRefreshKey] = useState(0);
|
||||
|
||||
// Refresh rules from PermissionManager
|
||||
const refreshRules = useCallback(() => {
|
||||
if (pm) {
|
||||
|
|
@ -171,6 +187,214 @@ export function PermissionsDialog({
|
|||
refreshRules();
|
||||
}, [refreshRules]);
|
||||
|
||||
// --- Workspace directory helpers ---
|
||||
const directories = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
dirRefreshKey; // dependency to trigger re-computation
|
||||
return workspaceContext.getDirectories();
|
||||
}, [workspaceContext, dirRefreshKey]);
|
||||
|
||||
const initialDirs = useMemo(
|
||||
() => new Set(workspaceContext.getInitialDirectories()),
|
||||
[workspaceContext],
|
||||
);
|
||||
|
||||
// Filesystem completions based on current input
|
||||
const dirCompletions = useMemo(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return [];
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const endsWithSep =
|
||||
expanded.endsWith('/') || expanded.endsWith(nodePath.sep);
|
||||
const searchDir = endsWithSep ? expanded : nodePath.dirname(expanded);
|
||||
const prefix = endsWithSep ? '' : nodePath.basename(expanded);
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(searchDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
e.name.startsWith(prefix) &&
|
||||
!e.name.startsWith('.'),
|
||||
)
|
||||
.map((e) => nodePath.join(searchDir, e.name))
|
||||
.slice(0, 6);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [newDirInput]);
|
||||
|
||||
const handleDirInputChange = useCallback(
|
||||
(text: string) => {
|
||||
setNewDirInput(text);
|
||||
if (dirInputError) setDirInputError('');
|
||||
},
|
||||
[dirInputError],
|
||||
);
|
||||
|
||||
// Reset selection to first item whenever the completions list changes
|
||||
useEffect(() => {
|
||||
setCompletionIndex(0);
|
||||
}, [dirCompletions]);
|
||||
|
||||
const handleDirTabComplete = useCallback(() => {
|
||||
const selected = dirCompletions[completionIndex] ?? dirCompletions[0];
|
||||
if (selected) {
|
||||
setNewDirInput(selected + '/');
|
||||
setDirInputRemountKey((k) => k + 1);
|
||||
}
|
||||
}, [dirCompletions, completionIndex]);
|
||||
|
||||
const handleDirCompletionUp = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex(
|
||||
(prev) => (prev - 1 + dirCompletions.length) % dirCompletions.length,
|
||||
);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const handleDirCompletionDown = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex((prev) => (prev + 1) % dirCompletions.length);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const dirListItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
}> = [];
|
||||
// 'Add directory…' always FIRST
|
||||
items.push({
|
||||
label: t('Add directory…'),
|
||||
value: '__add_dir__',
|
||||
key: '__add_dir__',
|
||||
});
|
||||
// Only show non-initial (runtime-added) directories in the selectable list
|
||||
for (const dir of directories) {
|
||||
if (!initialDirs.has(dir)) {
|
||||
items.push({
|
||||
label: dir,
|
||||
value: dir,
|
||||
key: `dir-${dir}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [directories, initialDirs]);
|
||||
|
||||
const handleDirListSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__add_dir__') {
|
||||
setNewDirInput('');
|
||||
setView('ws-add-dir-input');
|
||||
return;
|
||||
}
|
||||
// Selecting a directory → offer to remove if not initial
|
||||
if (!initialDirs.has(value)) {
|
||||
setRemoveDirTarget(value);
|
||||
setView('ws-remove-confirm');
|
||||
}
|
||||
},
|
||||
[initialDirs],
|
||||
);
|
||||
|
||||
const handleAddDirSubmit = useCallback(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const absoluteExpanded = nodePath.isAbsolute(expanded)
|
||||
? expanded
|
||||
: nodePath.resolve(expanded);
|
||||
|
||||
// Existence & type checks
|
||||
if (!fs.existsSync(absoluteExpanded)) {
|
||||
setDirInputError(t('Directory does not exist.'));
|
||||
return;
|
||||
}
|
||||
if (!fs.statSync(absoluteExpanded).isDirectory()) {
|
||||
setDirInputError(t('Path is not a directory.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve real path to match what workspaceContext stores
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = fs.realpathSync(absoluteExpanded);
|
||||
} catch {
|
||||
resolved = absoluteExpanded;
|
||||
}
|
||||
|
||||
// Validate: exact duplicate
|
||||
if ((directories as string[]).includes(resolved)) {
|
||||
setDirInputError(t('This directory is already in the workspace.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: is a subdirectory of an existing workspace directory
|
||||
for (const existingDir of directories) {
|
||||
if (isPathWithinRoot(resolved, existingDir)) {
|
||||
setDirInputError(
|
||||
t('Already covered by existing directory: {{dir}}', {
|
||||
dir: existingDir,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirInputError('');
|
||||
|
||||
// Add to workspace context (already validated)
|
||||
workspaceContext.addDirectory(resolved);
|
||||
|
||||
// Persist directly to project (Workspace) settings
|
||||
const key = 'context.includeDirectories';
|
||||
const currentDirs = (settings.merged as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const existingDirs = currentDirs?.['includeDirectories'] ?? [];
|
||||
if (!existingDirs.includes(resolved)) {
|
||||
settings.setValue(SettingScope.Workspace, key, [
|
||||
...existingDirs,
|
||||
resolved,
|
||||
]);
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setView('ws-dir-list');
|
||||
setNewDirInput('');
|
||||
}, [newDirInput, directories, workspaceContext, settings]);
|
||||
|
||||
const handleRemoveDirConfirm = useCallback(() => {
|
||||
if (!removeDirTarget) return;
|
||||
|
||||
// Remove from workspace context
|
||||
workspaceContext.removeDirectory(removeDirTarget);
|
||||
|
||||
// Remove from settings (try both scopes)
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const contextSection = (scopeSettings as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const scopeDirs = contextSection?.['includeDirectories'];
|
||||
if (scopeDirs?.includes(removeDirTarget)) {
|
||||
const updated = scopeDirs.filter((d: string) => d !== removeDirTarget);
|
||||
settings.setValue(scope, 'context.includeDirectories', updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
}, [removeDirTarget, workspaceContext, settings]);
|
||||
|
||||
// Filter rules for current tab
|
||||
const currentTabRules = useMemo(() => {
|
||||
if (activeTab.id === 'workspace') return [];
|
||||
|
|
@ -215,13 +439,16 @@ export function PermissionsDialog({
|
|||
|
||||
const handleTabCycle = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
setActiveTabIndex(
|
||||
(prev) => (prev + direction + tabs.length) % tabs.length,
|
||||
);
|
||||
const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length;
|
||||
setActiveTabIndex(newIndex);
|
||||
setSearchQuery('');
|
||||
setIsSearchActive(false);
|
||||
setDirInputError('');
|
||||
// Set the appropriate default view for each tab
|
||||
const newTab = tabs[newIndex]!;
|
||||
setView(newTab.id === 'workspace' ? 'ws-dir-list' : 'rule-list');
|
||||
},
|
||||
[tabs.length],
|
||||
[activeTabIndex, tabs],
|
||||
);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
|
|
@ -368,27 +595,179 @@ export function PermissionsDialog({
|
|||
return;
|
||||
}
|
||||
}
|
||||
// Workspace tab views
|
||||
if (view === 'ws-dir-list') {
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
handleTabCycle(1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' || key.name === 'left') {
|
||||
handleTabCycle(key.name === 'right' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-add-dir-input') {
|
||||
if (key.name === 'escape') {
|
||||
setDirInputError('');
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-remove-confirm') {
|
||||
if (key.name === 'escape') {
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
handleRemoveDirConfirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// --- Workspace tab placeholder ---
|
||||
if (activeTab.id === 'workspace') {
|
||||
// --- Workspace tab: add directory input ---
|
||||
if (activeTab.id === 'workspace' && view === 'ws-add-dir-input') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Add directory to workspace')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text>{t('Enter the path to the directory:')}</Text>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<TextInput
|
||||
key={dirInputRemountKey}
|
||||
value={newDirInput}
|
||||
onChange={handleDirInputChange}
|
||||
onSubmit={handleAddDirSubmit}
|
||||
onTab={dirCompletions.length > 0 ? handleDirTabComplete : undefined}
|
||||
onUp={dirCompletions.length > 0 ? handleDirCompletionUp : undefined}
|
||||
onDown={
|
||||
dirCompletions.length > 0 ? handleDirCompletionDown : undefined
|
||||
}
|
||||
placeholder={t('Enter directory path…')}
|
||||
isActive={true}
|
||||
validationErrors={dirInputError ? [dirInputError] : []}
|
||||
/>
|
||||
</Box>
|
||||
{/* Filesystem completions: ↑/↓ to navigate, Tab to apply */}
|
||||
{dirCompletions.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
||||
{dirCompletions.map((completion, idx) => {
|
||||
const name = nodePath.basename(completion);
|
||||
const isSelected = idx === completionIndex;
|
||||
return (
|
||||
<Box key={completion}>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{`${name}/`}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{` directory`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tab to complete · Enter to add · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: remove directory confirmation ---
|
||||
if (
|
||||
activeTab.id === 'workspace' &&
|
||||
view === 'ws-remove-confirm' &&
|
||||
removeDirTarget
|
||||
) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text bold>{t('Remove directory?')}</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{removeDirTarget}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>
|
||||
{t(
|
||||
'Use /trust to manage folder trust settings for this workspace.',
|
||||
'Are you sure you want to remove this directory from the workspace?',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: directory list (default) ---
|
||||
if (activeTab.id === 'workspace') {
|
||||
const initialDirArray = Array.from(initialDirs);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{/* Initial (non-removable) dirs: shown inline with dash, same visual level as list */}
|
||||
{initialDirArray.map((dir, idx) => (
|
||||
<Box key={dir} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{'- '}</Text>
|
||||
<Text>{dir}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{idx === 0
|
||||
? t(' (Original working directory)')
|
||||
: t(' (from settings)')}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{/* Selectable list: runtime-added dirs + 'Add directory…' at end */}
|
||||
<RadioButtonSelect
|
||||
items={dirListItems}
|
||||
onSelect={handleDirListSelect}
|
||||
isFocused={view === 'ws-dir-list'}
|
||||
showNumbers={true}
|
||||
showScrollArrows={false}
|
||||
maxItemsToShow={15}
|
||||
/>
|
||||
<FooterHint view={view} />
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -594,7 +973,7 @@ function TabBar({
|
|||
}
|
||||
|
||||
function FooterHint({ view }: { view: DialogView }): React.JSX.Element {
|
||||
if (view !== 'rule-list') return <></>;
|
||||
if (view !== 'rule-list' && view !== 'ws-dir-list') return <></>;
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export interface TextInputProps {
|
|||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
onSubmit?: () => void;
|
||||
/** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */
|
||||
onTab?: () => void;
|
||||
/** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */
|
||||
onUp?: () => void;
|
||||
/** Called when ↓ is pressed; if provided, prevents cursor-down in the buffer. */
|
||||
onDown?: () => void;
|
||||
placeholder?: string;
|
||||
height?: number; // lines in viewport; >1 enables multiline
|
||||
isActive?: boolean; // when false, ignore keypresses
|
||||
|
|
@ -32,6 +38,9 @@ export function TextInput({
|
|||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onTab,
|
||||
onUp,
|
||||
onDown,
|
||||
placeholder,
|
||||
height = 1,
|
||||
isActive = true,
|
||||
|
|
@ -65,6 +74,22 @@ export function TextInput({
|
|||
(key: Key) => {
|
||||
if (!buffer || !isActive) return;
|
||||
|
||||
// Tab completion: delegate to caller instead of inserting a tab character
|
||||
if (key.name === 'tab') {
|
||||
onTab?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow-key completion navigation: delegate to caller
|
||||
if (key.name === 'up' && onUp) {
|
||||
onUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down' && onDown) {
|
||||
onDown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter
|
||||
if (keyMatchers[Command.SUBMIT](key) || key.name === 'return') {
|
||||
if (allowMultiline) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue