diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index cf68193c7..07bc5758d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 { 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 | undefined)?.[ + 'additionalDirectories' + ] as string[] | undefined) ?? [] + ).map(resolvePath), + ); // LSP configuration: enabled only via --experimental-lsp flag const lspEnabled = argv.experimentalLsp === true; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 182db99b4..614336630 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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, + }, }, }, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f5999683f..67ca93b15 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -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 diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 23b142b64..1b15ec108 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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 diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 4a053f96b..4545b02d0 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -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}} 個のファイルを開いています', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c80a8f21f..52f20b7e9 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -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 diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 87e040832..b5d216b82 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -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)', // ============================================================================ // Строка состояния diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 517820f3b..8570fb09e 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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 diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c92dd178a..ca24e3584 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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 { const allDefinitions: Array = [ aboutCommand, + addDirCommand, agentsCommand, approvalModeCommand, authCommand, diff --git a/packages/cli/src/ui/commands/addDirCommand.tsx b/packages/cli/src/ui/commands/addDirCommand.tsx new file mode 100644 index 000000000..810dcf889 --- /dev/null +++ b/packages/cli/src/ui/commands/addDirCommand.tsx @@ -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); + }, +}; diff --git a/packages/cli/src/ui/components/PermissionsDialog.tsx b/packages/cli/src/ui/components/PermissionsDialog.tsx index 02787044f..1ebb18d65 100644 --- a/packages/cli/src/ui/components/PermissionsDialog.tsx +++ b/packages/cli/src/ui/components/PermissionsDialog.tsx @@ -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(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(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)[ + 'context' + ] as Record | 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)[ + 'context' + ] as Record | 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 ( + + + {t('Add directory to workspace')} + + + + {t( + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.', + )} + + + {t('Enter the path to the directory:')} + + 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] : []} + /> + + {/* Filesystem completions: ↑/↓ to navigate, Tab to apply */} + {dirCompletions.length > 0 && ( + + {dirCompletions.map((completion, idx) => { + const name = nodePath.basename(completion); + const isSelected = idx === completionIndex; + return ( + + + {`${name}/`} + + {` directory`} + + ); + })} + + )} + + + {t('Tab to complete · Enter to add · Esc to cancel')} + + + + ); + } + + // --- Workspace tab: remove directory confirmation --- + if ( + activeTab.id === 'workspace' && + view === 'ws-remove-confirm' && + removeDirTarget + ) { return ( - - + {t('Remove directory?')} + + + {removeDirTarget} + + + {t( - 'Use /trust to manage folder trust settings for this workspace.', + 'Are you sure you want to remove this directory from the workspace?', )} + + + {t('Enter to confirm · Esc to cancel')} + + + + ); + } + + // --- Workspace tab: directory list (default) --- + if (activeTab.id === 'workspace') { + const initialDirArray = Array.from(initialDirs); + return ( + + + + {t( + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.', + )} + + + {/* Initial (non-removable) dirs: shown inline with dash, same visual level as list */} + {initialDirArray.map((dir, idx) => ( + + {'- '} + {dir} + + {idx === 0 + ? t(' (Original working directory)') + : t(' (from settings)')} + + + ))} + {/* Selectable list: runtime-added dirs + 'Add directory…' at end */} + ); @@ -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 ( diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40d471296..fd63d5078 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -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) { diff --git a/packages/core/src/utils/workspaceContext.test.ts b/packages/core/src/utils/workspaceContext.test.ts index 686c50ba3..77082adf4 100644 --- a/packages/core/src/utils/workspaceContext.test.ts +++ b/packages/core/src/utils/workspaceContext.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/utils/workspaceContext.ts b/packages/core/src/utils/workspaceContext.ts index 1b36f3650..bb09739d2 100755 --- a/packages/core/src/utils/workspaceContext.ts +++ b/packages/core/src/utils/workspaceContext.ts @@ -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(); for (const dir of directories) {