diff --git a/src/vs/editor/browser/view/viewController.ts b/src/vs/editor/browser/view/viewController.ts index 9f94a1b4f824e..2066a317d0c15 100644 --- a/src/vs/editor/browser/view/viewController.ts +++ b/src/vs/editor/browser/view/viewController.ts @@ -175,16 +175,22 @@ export class ViewController { } } - // Check if current token is a string. + // Expand to the contiguous run of string tokens (StandardTokenType.String) around the click position. const lineTokens = tokens.getLineTokens(lineNumber); - const index = lineTokens.findTokenIndexAtOffset(column - 1); - if (lineTokens.getStandardTokenType(index) !== StandardTokenType.String) { - return undefined; + let startIndex = lineTokens.findTokenIndexAtOffset(column - 1); + let endIndex = startIndex; + while (startIndex > 0 && + lineTokens.getStandardTokenType(startIndex - 1) === StandardTokenType.String) { + startIndex--; + } + while (endIndex + 1 < lineTokens.getCount() && + lineTokens.getStandardTokenType(endIndex + 1) === StandardTokenType.String) { + endIndex++; } // Verify the click is after starting or before closing quote. - const tokenStart = lineTokens.getStartOffset(index); - const tokenEnd = lineTokens.getEndOffset(index); + const tokenStart = lineTokens.getStartOffset(startIndex); + const tokenEnd = lineTokens.getEndOffset(endIndex); if (column !== tokenStart + 2 && column !== tokenEnd) { return undefined; } diff --git a/src/vs/editor/test/browser/view/viewController.test.ts b/src/vs/editor/test/browser/view/viewController.test.ts index 95d44801cb795..1a7f4faf2e03b 100644 --- a/src/vs/editor/test/browser/view/viewController.test.ts +++ b/src/vs/editor/test/browser/view/viewController.test.ts @@ -309,6 +309,23 @@ suite('ViewController - String content selection', () => { assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); }); + test('Select string content containing escape characters', () => { + // 0123456789... + const text = 'var x = "hello\\"world";'; + // Token layout: [0..8) Other [8..22) String("hello\"world") [22..23) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 9, type: StandardTokenType.String }, + { startIndex: 14, type: StandardTokenType.String }, + { startIndex: 16, type: StandardTokenType.String }, + { startIndex: 21, type: StandardTokenType.String }, + { startIndex: 22, type: StandardTokenType.Other }, + ]); + // Column right after opening quote: offset 9 → column 10 + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello\\"world'); + }); + // -- Click in middle of string should NOT select the whole string -- test('Click in middle of string does not select whole string', () => { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 63e7b67818a8f..3c12b59418e0e 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -411,20 +411,10 @@ export class ActionList extends Disposable { focusPrevious() { this._list.focusPrevious(1, true, undefined, this.focusCondition); - const focused = this._list.getFocus(); - if (focused.length > 0) { - this._list.reveal(focused[0]); - } - this._list.domFocus(); } focusNext() { this._list.focusNext(1, true, undefined, this.focusCondition); - const focused = this._list.getFocus(); - if (focused.length > 0) { - this._list.reveal(focused[0]); - } - this._list.domFocus(); } acceptSelected(preview?: boolean) { @@ -457,7 +447,6 @@ export class ActionList extends Disposable { } private onFocus() { - this._list.domFocus(); const focused = this._list.getFocus(); if (focused.length === 0) { return; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 2ab2855d70430..1e1d84d7d65e6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -1026,10 +1026,6 @@ export class PromptsService extends Disposable implements IPromptsService { const userHomeUri = await this.pathService.userHome(); const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - // Get workspace root for resolving relative cwd paths - const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - let hasDisabledClaudeHooks = false; const collectedHooks: Record = { [HookType.SessionStart]: [], @@ -1042,11 +1038,18 @@ export class PromptsService extends Disposable implements IPromptsService { [HookType.Stop]: [], }; + const defaultFolder = this.workspaceService.getWorkspace().folders[0]; + for (const hookFile of hookFiles) { try { const content = await this.fileService.readFile(hookFile.uri); const json = parseJSONC(content.value.toString()); + // Resolve the workspace folder that contains this hook file for cwd resolution, + // falling back to the first workspace folder for user-level hooks outside the workspace + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(hookFile.uri) ?? defaultFolder; + const workspaceRootUri = hookWorkspaceFolder?.uri; + // Use format-aware parsing that handles Copilot and Claude formats const { format, hooks, disabledAllHooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); @@ -1339,10 +1342,6 @@ export class PromptsService extends Disposable implements IPromptsService { const userHomeUri = await this.pathService.userHome(); const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - // Get workspace root for resolving relative cwd paths - const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); for (const promptPath of hookFiles) { @@ -1383,6 +1382,11 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + // Resolve the workspace folder that contains this hook file for cwd resolution, + // falling back to the first workspace folder for user-level hooks outside the workspace + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = hookWorkspaceFolder?.uri; + // Use format-aware parsing to check for disabledAllHooks const { disabledAllHooks } = parseHooksFromFile(uri, json, workspaceRootUri, userHome); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index dbcc9c0b2facd..2b803e88df2a3 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -38,7 +38,7 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../. import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, HOOKS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; @@ -48,6 +48,7 @@ import { IPathService } from '../../../../../../services/path/common/pathService import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { ChatModeKind } from '../../../../common/constants.js'; +import { HookType } from '../../../../common/promptSyntax/hookSchema.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -3370,4 +3371,57 @@ suite('PromptsService', () => { 'Skill without header should be included when applying userInvocable filter (defaults to true)'); }); }); + + suite('hooks', () => { + test('multi-root workspace resolves cwd to per-hook-file workspace folder', async function () { + const folder1Uri = URI.file('/workspace-a'); + const folder2Uri = URI.file('/workspace-b'); + + workspaceContextService.setWorkspace(testWorkspace(folder1Uri, folder2Uri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, { [HOOKS_SOURCE_FOLDER]: true }); + + await mockFiles(fileService, [ + { + path: '/workspace-a/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo folder-a' }, + ], + }, + }), + ], + }, + { + path: '/workspace-b/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo folder-b' }, + ], + }, + }), + ], + }, + ]); + + const result = await service.getHooks(CancellationToken.None); + assert.ok(result, 'Expected hooks result'); + + const preToolUseHooks = result.hooks[HookType.PreToolUse]; + assert.ok(preToolUseHooks, 'Expected PreToolUse hooks'); + assert.strictEqual(preToolUseHooks.length, 2, 'Expected two PreToolUse hooks'); + + const hookA = preToolUseHooks.find(h => h.command === 'echo folder-a'); + const hookB = preToolUseHooks.find(h => h.command === 'echo folder-b'); + assert.ok(hookA, 'Expected hook from folder-a'); + assert.ok(hookB, 'Expected hook from folder-b'); + + assert.strictEqual(hookA.cwd?.path, folder1Uri.path, 'Hook from folder-a should have cwd pointing to workspace-a'); + assert.strictEqual(hookB.cwd?.path, folder2Uri.path, 'Hook from folder-b should have cwd pointing to workspace-b'); + }); + }); });