mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-22 03:03:56 +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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}} 個のファイルを開いています',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
||||
// ============================================================================
|
||||
// Строка состояния
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
34
packages/cli/src/ui/commands/addDirCommand.tsx
Normal file
34
packages/cli/src/ui/commands/addDirCommand.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue