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:
LaZzyMan 2026-03-11 10:54:59 +08:00
parent 217d59c892
commit e793e82729
14 changed files with 780 additions and 13 deletions

View file

@ -32,7 +32,7 @@ import {
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings , LoadedSettings } from './settings.js';
import type { Settings, LoadedSettings } from './settings.js';
import { SettingScope } from './settings.js';
import {
resolveCliGenerationConfig,
@ -378,6 +378,7 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'List all available extensions and exit.',
})
.option('include-directories', {
alias: 'add-dir',
type: 'array',
string: true,
description:
@ -715,7 +716,14 @@ export async function loadCliConfig(
const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
.concat((argv.includeDirectories || []).map(resolvePath))
.concat(
(
((settings.permissions as Record<string, unknown> | undefined)?.[
'additionalDirectories'
] as string[] | undefined) ?? []
).map(resolvePath),
);
// LSP configuration: enabled only via --experimental-lsp flag
const lspEnabled = argv.experimentalLsp === true;

View file

@ -835,6 +835,18 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
additionalDirectories: {
type: 'array',
label: 'Additional Directories',
category: 'Tools',
requiresRestart: false,
default: [] as string[],
description:
'Additional directories to include in the workspace context. ' +
'Alias for context.includeDirectories. Files in these directories are treated as workspace files.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},

View file

@ -1112,6 +1112,30 @@ export default {
'Search…': 'Suche…',
'Use /trust to manage folder trust settings for this workspace.':
'Verwenden Sie /trust, um die Ordnervertrauenseinstellungen für diesen Arbeitsbereich zu verwalten.',
// Workspace directory management
'Add directory…': 'Verzeichnis hinzufügen…',
'Add directory to workspace': 'Verzeichnis zum Arbeitsbereich hinzufügen',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code kann Dateien im Arbeitsbereich lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code kann Dateien in diesem Verzeichnis lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
'Enter the path to the directory:': 'Pfad zum Verzeichnis eingeben:',
'Enter directory path…': 'Verzeichnispfad eingeben…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab zum Vervollständigen · Enter zum Hinzufügen · Esc zum Abbrechen',
'Remove directory?': 'Verzeichnis entfernen?',
'Are you sure you want to remove this directory from the workspace?':
'Möchten Sie dieses Verzeichnis wirklich aus dem Arbeitsbereich entfernen?',
' (Original working directory)': ' (Ursprüngliches Arbeitsverzeichnis)',
' (from settings)': ' (aus Einstellungen)',
'Directory does not exist.': 'Verzeichnis existiert nicht.',
'Path is not a directory.': 'Pfad ist kein Verzeichnis.',
'This directory is already in the workspace.':
'Dieses Verzeichnis ist bereits im Arbeitsbereich.',
'Already covered by existing directory: {{dir}}':
'Bereits durch vorhandenes Verzeichnis abgedeckt: {{dir}}',
'Add directories to the workspace (alias for /directory add)':
'Verzeichnisse zum Arbeitsbereich hinzufügen (Alias für /directory add)',
// ============================================================================
// Status Bar

View file

@ -1097,6 +1097,30 @@ export default {
'Search…': 'Search…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust to manage folder trust settings for this workspace.',
// Workspace directory management
'Add directory…': 'Add directory…',
'Add directory to workspace': 'Add directory to workspace',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
'Enter the path to the directory:': 'Enter the path to the directory:',
'Enter directory path…': 'Enter directory path…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab to complete · Enter to add · Esc to cancel',
'Remove directory?': 'Remove directory?',
'Are you sure you want to remove this directory from the workspace?':
'Are you sure you want to remove this directory from the workspace?',
' (Original working directory)': ' (Original working directory)',
' (from settings)': ' (from settings)',
'Directory does not exist.': 'Directory does not exist.',
'Path is not a directory.': 'Path is not a directory.',
'This directory is already in the workspace.':
'This directory is already in the workspace.',
'Already covered by existing directory: {{dir}}':
'Already covered by existing directory: {{dir}}',
'Add directories to the workspace (alias for /directory add)':
'Add directories to the workspace (alias for /directory add)',
// ============================================================================
// Status Bar

View file

@ -801,6 +801,30 @@ export default {
'Search…': '検索…',
'Use /trust to manage folder trust settings for this workspace.':
'/trust を使用してこのワークスペースのフォルダ信頼設定を管理します。',
// Workspace directory management
'Add directory…': 'ディレクトリを追加…',
'Add directory to workspace': 'ワークスペースにディレクトリを追加',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code はワークスペース内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code はこのディレクトリ内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
'Enter the path to the directory:': 'ディレクトリのパスを入力してください:',
'Enter directory path…': 'ディレクトリパスを入力…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab で補完 · Enter で追加 · Esc でキャンセル',
'Remove directory?': 'ディレクトリを削除しますか?',
'Are you sure you want to remove this directory from the workspace?':
'このディレクトリをワークスペースから削除してもよろしいですか?',
' (Original working directory)': ' (元の作業ディレクトリ)',
' (from settings)': ' (設定より)',
'Directory does not exist.': 'ディレクトリが存在しません。',
'Path is not a directory.': 'パスはディレクトリではありません。',
'This directory is already in the workspace.':
'このディレクトリはすでにワークスペースに含まれています。',
'Already covered by existing directory: {{dir}}':
'既存のディレクトリによって既にカバーされています: {{dir}}',
'Add directories to the workspace (alias for /directory add)':
'ワークスペースにディレクトリを追加(/directory add のエイリアス)',
// Status Bar
'Using:': '使用中:',
'{{count}} open file': '{{count}} 個のファイルを開いています',

View file

@ -1115,6 +1115,30 @@ export default {
'Search…': 'Pesquisar…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust para gerenciar as configurações de confiança de pasta desta área de trabalho.',
// Workspace directory management
'Add directory…': 'Adicionar diretório…',
'Add directory to workspace': 'Adicionar diretório à área de trabalho',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'O Qwen Code pode ler arquivos na área de trabalho e fazer edições quando a aceitação automática está ativada.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'O Qwen Code poderá ler arquivos neste diretório e fazer edições quando a aceitação automática está ativada.',
'Enter the path to the directory:': 'Insira o caminho do diretório:',
'Enter directory path…': 'Insira o caminho do diretório…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab para completar · Enter para adicionar · Esc para cancelar',
'Remove directory?': 'Remover diretório?',
'Are you sure you want to remove this directory from the workspace?':
'Tem certeza de que deseja remover este diretório da área de trabalho?',
' (Original working directory)': ' (Diretório de trabalho original)',
' (from settings)': ' (das configurações)',
'Directory does not exist.': 'O diretório não existe.',
'Path is not a directory.': 'O caminho não é um diretório.',
'This directory is already in the workspace.':
'Este diretório já está na área de trabalho.',
'Already covered by existing directory: {{dir}}':
'Já coberto pelo diretório existente: {{dir}}',
'Add directories to the workspace (alias for /directory add)':
'Adicionar diretórios à área de trabalho (apelido para /directory add)',
// ============================================================================
// Status Bar

View file

@ -1113,6 +1113,30 @@ export default {
'Search…': 'Поиск…',
'Use /trust to manage folder trust settings for this workspace.':
'Используйте /trust для управления настройками доверия к папкам этой рабочей области.',
// Workspace directory management
'Add directory…': 'Добавить каталог…',
'Add directory to workspace': 'Добавить каталог в рабочую область',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code может читать файлы в рабочей области и вносить правки, когда автоприём правок включён.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code сможет читать файлы в этом каталоге и вносить правки, когда автоприём правок включён.',
'Enter the path to the directory:': 'Введите путь к каталогу:',
'Enter directory path…': 'Введите путь к каталогу…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab для завершения · Enter для добавления · Esc для отмены',
'Remove directory?': 'Удалить каталог?',
'Are you sure you want to remove this directory from the workspace?':
'Вы уверены, что хотите удалить этот каталог из рабочей области?',
' (Original working directory)': ' (Исходный рабочий каталог)',
' (from settings)': ' (из настроек)',
'Directory does not exist.': 'Каталог не существует.',
'Path is not a directory.': 'Путь не является каталогом.',
'This directory is already in the workspace.':
'Этот каталог уже есть в рабочей области.',
'Already covered by existing directory: {{dir}}':
'Уже охвачен существующим каталогом: {{dir}}',
'Add directories to the workspace (alias for /directory add)':
'Добавить каталоги в рабочую область (псевдоним для /directory add)',
// ============================================================================
// Строка состояния

View file

@ -1036,6 +1036,28 @@ export default {
'Search…': '搜索…',
'Use /trust to manage folder trust settings for this workspace.':
'使用 /trust 管理此工作区的文件夹信任设置。',
// Workspace directory management
'Add directory…': '添加目录…',
'Add directory to workspace': '添加工作区目录',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code 可以读取工作区中的文件,并在自动接受编辑模式开启时进行编辑。',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code 将能够读取此目录中的文件,并在自动接受编辑模式开启时进行编辑。',
'Enter the path to the directory:': '输入目录路径:',
'Enter directory path…': '输入目录路径…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab 补全 · 回车添加 · Esc 取消',
'Remove directory?': '删除目录?',
'Are you sure you want to remove this directory from the workspace?':
'确定要将此目录从工作区中移除吗?',
' (Original working directory)': ' (原始工作目录)',
' (from settings)': ' (来自设置)',
'Directory does not exist.': '目录不存在。',
'Path is not a directory.': '路径不是目录。',
'This directory is already in the workspace.': '此目录已在工作区中。',
'Already covered by existing directory: {{dir}}': '已被现有目录覆盖:{{dir}}',
'Add directories to the workspace (alias for /directory add)':
'将目录添加到工作区(/directory add 的别名)',
// ============================================================================
// Status Bar

View file

@ -8,6 +8,7 @@ import type { ICommandLoader } from './types.js';
import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { addDirCommand } from '../ui/commands/addDirCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
@ -60,6 +61,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
addDirCommand,
agentsCommand,
approvalModeCommand,
authCommand,

View file

@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { directoryCommand } from './directoryCommand.js';
import { t } from '../../i18n/index.js';
/**
* `/add-dir` a convenience alias that delegates to `/directory add`.
*
* Usage: `/add-dir /path/to/dir` (equivalent to `/directory add /path/to/dir`)
*/
export const addDirCommand: SlashCommand = {
name: 'add-dir',
altNames: [],
get description() {
return t('Add directories to the workspace (alias for /directory add)');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
// Delegate to the `add` subcommand of `/directory`
const addSubCommand = directoryCommand.subCommands?.find(
(sub) => sub.name === 'add',
);
if (!addSubCommand?.action) {
return;
}
return addSubCommand.action(context, args);
},
};

View file

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

View file

@ -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) {

View file

@ -412,3 +412,121 @@ describe('WorkspaceContext with optional directories', () => {
expect(directories).toEqual([cwd, existingDir1]);
});
});
describe('WorkspaceContext removeDirectory', () => {
let tempDir: string;
let cwd: string;
let addedDir: string;
let anotherDir: string;
beforeEach(() => {
tempDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-context-remove-')),
);
cwd = path.join(tempDir, 'project');
addedDir = path.join(tempDir, 'added');
anotherDir = path.join(tempDir, 'another');
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(addedDir, { recursive: true });
fs.mkdirSync(anotherDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should remove a runtime-added directory', () => {
const ctx = new WorkspaceContext(cwd);
ctx.addDirectory(addedDir);
expect(ctx.getDirectories()).toContain(addedDir);
const result = ctx.removeDirectory(addedDir);
expect(result).toBe(true);
expect(ctx.getDirectories()).not.toContain(addedDir);
});
it('should not remove an initial directory', () => {
const ctx = new WorkspaceContext(cwd, [addedDir]);
// Both cwd and addedDir are initial
const result = ctx.removeDirectory(cwd);
expect(result).toBe(false);
expect(ctx.getDirectories()).toContain(cwd);
const result2 = ctx.removeDirectory(addedDir);
expect(result2).toBe(false);
expect(ctx.getDirectories()).toContain(addedDir);
});
it('should return false for non-existent directory', () => {
const ctx = new WorkspaceContext(cwd);
const result = ctx.removeDirectory('/non/existent/path');
expect(result).toBe(false);
});
it('should notify listeners when a directory is removed', () => {
const ctx = new WorkspaceContext(cwd);
ctx.addDirectory(addedDir);
const listener = vi.fn();
ctx.onDirectoriesChanged(listener);
ctx.removeDirectory(addedDir);
expect(listener).toHaveBeenCalledOnce();
});
it('should not notify listeners when removal fails', () => {
const ctx = new WorkspaceContext(cwd);
const listener = vi.fn();
ctx.onDirectoriesChanged(listener);
ctx.removeDirectory(addedDir); // not in workspace
expect(listener).not.toHaveBeenCalled();
});
});
describe('WorkspaceContext isInitialDirectory', () => {
let tempDir: string;
let cwd: string;
let additionalDir: string;
let runtimeDir: string;
beforeEach(() => {
tempDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-context-initial-')),
);
cwd = path.join(tempDir, 'project');
additionalDir = path.join(tempDir, 'additional');
runtimeDir = path.join(tempDir, 'runtime');
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(additionalDir, { recursive: true });
fs.mkdirSync(runtimeDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should return true for the initial cwd directory', () => {
const ctx = new WorkspaceContext(cwd);
expect(ctx.isInitialDirectory(cwd)).toBe(true);
});
it('should return true for an additional initial directory', () => {
const ctx = new WorkspaceContext(cwd, [additionalDir]);
expect(ctx.isInitialDirectory(additionalDir)).toBe(true);
});
it('should return false for a runtime-added directory', () => {
const ctx = new WorkspaceContext(cwd);
ctx.addDirectory(runtimeDir);
expect(ctx.isInitialDirectory(runtimeDir)).toBe(false);
});
it('should return false for a directory not in the workspace', () => {
const ctx = new WorkspaceContext(cwd);
expect(ctx.isInitialDirectory('/some/random/path')).toBe(false);
});
});

View file

@ -112,6 +112,53 @@ export class WorkspaceContext {
return Array.from(this.initialDirectories);
}
/**
* Removes a directory from the workspace.
* Cannot remove initial directories (those set at construction time).
* @param directory The directory path to remove
* @returns True if the directory was removed, false if not found or is an initial directory
*/
removeDirectory(directory: string): boolean {
// Resolve to match the stored form
let resolved: string;
try {
resolved = this.resolveAndValidateDir(directory);
} catch {
// If we can't resolve it, try matching by raw string (e.g. directory was deleted)
resolved = path.isAbsolute(directory)
? directory
: path.resolve(process.cwd(), directory);
}
if (this.initialDirectories.has(resolved)) {
debugLogger.warn(`Cannot remove initial directory: ${resolved}`);
return false;
}
if (!this.directories.has(resolved)) {
return false;
}
this.directories.delete(resolved);
this.notifyDirectoriesChanged();
return true;
}
/**
* Checks whether a directory is an initial (non-removable) directory.
*/
isInitialDirectory(directory: string): boolean {
try {
const resolved = this.resolveAndValidateDir(directory);
return this.initialDirectories.has(resolved);
} catch {
const absolutePath = path.isAbsolute(directory)
? directory
: path.resolve(process.cwd(), directory);
return this.initialDirectories.has(absolutePath);
}
}
setDirectories(directories: readonly string[]): void {
const newDirectories = new Set<string>();
for (const dir of directories) {