Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/vs/editor/browser/view/viewController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 17 additions & 0 deletions src/vs/editor/test/browser/view/viewController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
11 changes: 0 additions & 11 deletions src/vs/platform/actionWidget/browser/actionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,20 +411,10 @@ export class ActionList<T> 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) {
Expand Down Expand Up @@ -457,7 +447,6 @@ export class ActionList<T> extends Disposable {
}

private onFocus() {
this._list.domFocus();
const focused = this._list.getFocus();
if (focused.length === 0) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, IHookCommand[]> = {
[HookType.SessionStart]: [],
Expand All @@ -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);

Expand Down Expand Up @@ -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<boolean>(PromptsConfig.USE_CLAUDE_HOOKS);
const hookFiles = await this.listPromptFiles(PromptsType.hook, token);
for (const promptPath of hookFiles) {
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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');
});
});
});
Loading