diff --git a/src/vs/editor/common/cursor/cursorMoveCommands.ts b/src/vs/editor/common/cursor/cursorMoveCommands.ts index a447e3a8f7598..d3183d48af57c 100644 --- a/src/vs/editor/common/cursor/cursorMoveCommands.ts +++ b/src/vs/editor/common/cursor/cursorMoveCommands.ts @@ -294,6 +294,9 @@ export class CursorMoveCommands { if (unit === CursorMove.Unit.WrappedLine) { // Move up by view lines return this._moveUpByViewLines(viewModel, cursors, inSelectionMode, value); + } else if (unit === CursorMove.Unit.FoldedLine) { + // Move up by model lines, skipping over folded regions + return this._moveUpByFoldedLines(viewModel, cursors, inSelectionMode, value); } else { // Move up by model lines return this._moveUpByModelLines(viewModel, cursors, inSelectionMode, value); @@ -303,6 +306,9 @@ export class CursorMoveCommands { if (unit === CursorMove.Unit.WrappedLine) { // Move down by view lines return this._moveDownByViewLines(viewModel, cursors, inSelectionMode, value); + } else if (unit === CursorMove.Unit.FoldedLine) { + // Move down by model lines, skipping over folded regions + return this._moveDownByFoldedLines(viewModel, cursors, inSelectionMode, value); } else { // Move down by model lines return this._moveDownByModelLines(viewModel, cursors, inSelectionMode, value); @@ -515,6 +521,113 @@ export class CursorMoveCommands { return result; } + private static _moveDownByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { + const model = viewModel.model; + const lineCount = model.getLineCount(); + const hiddenAreas = viewModel.getHiddenAreas(); + + return cursors.map(cursor => { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.endLineNumber + : cursor.modelState.position.lineNumber; + + const targetLine = CursorMoveCommands._targetFoldedDown(startLine, count, hiddenAreas, lineCount); + const delta = targetLine - startLine; + if (delta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveDown(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); + }); + } + + private static _moveUpByFoldedLines(viewModel: IViewModel, cursors: CursorState[], inSelectionMode: boolean, count: number): PartialCursorState[] { + const model = viewModel.model; + const hiddenAreas = viewModel.getHiddenAreas(); + + return cursors.map(cursor => { + const startLine = cursor.modelState.hasSelection() && !inSelectionMode + ? cursor.modelState.selection.startLineNumber + : cursor.modelState.position.lineNumber; + + const targetLine = CursorMoveCommands._targetFoldedUp(startLine, count, hiddenAreas); + const delta = startLine - targetLine; + if (delta === 0) { + return CursorState.fromModelState(cursor.modelState); + } + return CursorState.fromModelState(MoveOperations.moveUp(viewModel.cursorConfig, model, cursor.modelState, inSelectionMode, delta)); + }); + } + + // Compute the target line after moving `count` steps downward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedDown(startLine: number, count: number, hiddenAreas: Range[], lineCount: number): number { + let line = startLine; + let i = 0; + + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < line + 1) { + i++; + } + + for (let step = 0; step < count; step++) { + if (line >= lineCount) { + return lineCount; + } + + let candidate = line + 1; + while (i < hiddenAreas.length && hiddenAreas[i].endLineNumber < candidate) { + i++; + } + + if (i < hiddenAreas.length && hiddenAreas[i].startLineNumber <= candidate) { + candidate = hiddenAreas[i].endLineNumber + 1; + } + + if (candidate > lineCount) { + // The next visible line does not exist (e.g. a fold reaches EOF). + return line; + } + + line = candidate; + } + + return line; + } + + // Compute the target line after moving `count` steps upward from `startLine`, + // treating each folded region as a single step. + private static _targetFoldedUp(startLine: number, count: number, hiddenAreas: Range[]): number { + let line = startLine; + let i = hiddenAreas.length - 1; + + while (i >= 0 && hiddenAreas[i].startLineNumber > line - 1) { + i--; + } + + for (let step = 0; step < count; step++) { + if (line <= 1) { + return 1; + } + + let candidate = line - 1; + while (i >= 0 && hiddenAreas[i].startLineNumber > candidate) { + i--; + } + + if (i >= 0 && hiddenAreas[i].endLineNumber >= candidate) { + candidate = hiddenAreas[i].startLineNumber - 1; + } + + if (candidate < 1) { + // The previous visible line does not exist (e.g. a fold reaches BOF). + return line; + } + + line = candidate; + } + + return line; + } + private static _moveToViewPosition(viewModel: IViewModel, cursor: CursorState, inSelectionMode: boolean, toViewLineNumber: number, toViewColumn: number): PartialCursorState { return CursorState.fromViewState(cursor.viewState.move(inSelectionMode, toViewLineNumber, toViewColumn, 0)); } @@ -626,8 +739,10 @@ export namespace CursorMove { \`\`\` * 'by': Unit to move. Default is computed based on 'to' value. \`\`\` - 'line', 'wrappedLine', 'character', 'halfLine' + 'line', 'wrappedLine', 'character', 'halfLine', 'foldedLine' \`\`\` + Use 'foldedLine' with 'up'/'down' to move by logical lines while treating each + folded region as a single step. * 'value': Number of units to move. Default is '1'. * 'select': If 'true' makes the selection. Default is 'false'. * 'noHistory': If 'true' does not add the movement to navigation history. Default is 'false'. @@ -643,7 +758,7 @@ export namespace CursorMove { }, 'by': { 'type': 'string', - 'enum': ['line', 'wrappedLine', 'character', 'halfLine'] + 'enum': ['line', 'wrappedLine', 'character', 'halfLine', 'foldedLine'] }, 'value': { 'type': 'number', @@ -695,7 +810,8 @@ export namespace CursorMove { Line: 'line', WrappedLine: 'wrappedLine', Character: 'character', - HalfLine: 'halfLine' + HalfLine: 'halfLine', + FoldedLine: 'foldedLine' }; /** @@ -781,6 +897,9 @@ export namespace CursorMove { case RawUnit.HalfLine: unit = Unit.HalfLine; break; + case RawUnit.FoldedLine: + unit = Unit.FoldedLine; + break; } return { @@ -855,6 +974,7 @@ export namespace CursorMove { WrappedLine, Character, HalfLine, + FoldedLine, } } diff --git a/src/vs/editor/contrib/find/browser/findDecorations.ts b/src/vs/editor/contrib/find/browser/findDecorations.ts index 1940e2a315262..6ce266c699eae 100644 --- a/src/vs/editor/contrib/find/browser/findDecorations.ts +++ b/src/vs/editor/contrib/find/browser/findDecorations.ts @@ -103,7 +103,7 @@ export class FindDecorations implements IDisposable { const candidates = this._editor.getModel().getDecorationsInRange(desiredRange); for (const candidate of candidates) { const candidateOpts = candidate.options; - if (candidateOpts === FindDecorations._FIND_MATCH_DECORATION || candidateOpts === FindDecorations._CURRENT_FIND_MATCH_DECORATION) { + if (candidateOpts === FindDecorations._FIND_MATCH_DECORATION || candidateOpts === FindDecorations._FIND_MATCH_NO_OVERVIEW_DECORATION || candidateOpts === FindDecorations._CURRENT_FIND_MATCH_DECORATION) { return this._getDecorationIndex(candidate.id); } } diff --git a/src/vs/editor/contrib/find/test/browser/findModel.test.ts b/src/vs/editor/contrib/find/test/browser/findModel.test.ts index 09a0de5a7ae5f..8e2bbc3ccd362 100644 --- a/src/vs/editor/contrib/find/test/browser/findModel.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findModel.test.ts @@ -2383,4 +2383,23 @@ suite('FindModel', () => { }); + test('issue #288515: Wrong current index in find widget if matches > 1000', () => { + // Create 1001 lines of 'hello' + const textArr = Array(1001).fill('hello'); + withTestCodeEditor(textArr, {}, (_editor) => { + const editor = _editor as IActiveCodeEditor; + + // Place cursor at line 900, selecting 'hello' + editor.setSelection(new Selection(900, 1, 900, 6)); + + const findState = disposables.add(new FindReplaceState()); + findState.change({ searchString: 'hello' }, false); + disposables.add(new FindModelBoundToEditorModel(editor, findState)); + + assert.strictEqual(findState.matchesCount, 1001); + // With cursor selecting 'hello' at line 900, matchesPosition should be 900 + assert.strictEqual(findState.matchesPosition, 900); + }); + }); + }); diff --git a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts index 2bc7cab868a76..95348d44230af 100644 --- a/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts +++ b/src/vs/editor/contrib/semanticTokens/browser/documentSemanticTokens.ts @@ -6,7 +6,7 @@ import { RunOnceScheduler } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import * as errors from '../../../../base/common/errors.js'; -import { Disposable, IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -27,6 +27,7 @@ import { SEMANTIC_HIGHLIGHTING_SETTING_ID, isSemanticColoringEnabled } from '../ export class DocumentSemanticTokensFeature extends Disposable { private readonly _watchers = new ResourceMap(); + private readonly _providerChangeListeners = this._register(new DisposableStore()); constructor( @ISemanticTokensStylingService semanticTokensStylingService: ISemanticTokensStylingService, @@ -38,6 +39,8 @@ export class DocumentSemanticTokensFeature extends Disposable { ) { super(); + const provider = languageFeaturesService.documentSemanticTokensProvider; + const register = (model: ITextModel) => { this._watchers.get(model.uri)?.dispose(); this._watchers.set(model.uri, new ModelSemanticColoring(model, semanticTokensStylingService, themeService, languageFeatureDebounceService, languageFeaturesService)); @@ -60,6 +63,20 @@ export class DocumentSemanticTokensFeature extends Disposable { } } }; + + const bindProviderChangeListeners = () => { + this._providerChangeListeners.clear(); + for (const p of provider.allNoModel()) { + if (typeof p.onDidChange === 'function') { + this._providerChangeListeners.add(p.onDidChange(() => { + for (const watcher of this._watchers.values()) { + watcher.handleProviderDidChange(p); + } + })); + } + } + }; + modelService.getModels().forEach(model => { if (isSemanticColoringEnabled(model, themeService, configurationService)) { register(model); @@ -82,6 +99,13 @@ export class DocumentSemanticTokensFeature extends Disposable { } })); this._register(themeService.onDidColorThemeChange(handleSettingOrThemeChange)); + bindProviderChangeListeners(); + this._register(provider.onDidChange(() => { + bindProviderChangeListeners(); + for (const watcher of this._watchers.values()) { + watcher.handleRegistryChange(); + } + })); } override dispose(): void { @@ -104,7 +128,7 @@ class ModelSemanticColoring extends Disposable { private readonly _fetchDocumentSemanticTokens: RunOnceScheduler; private _currentDocumentResponse: SemanticTokensResponse | null; private _currentDocumentRequestCancellationTokenSource: CancellationTokenSource | null; - private _documentProvidersChangeListeners: IDisposable[]; + private _relevantProviders = new Set(); private _providersChangedDuringRequest: boolean; constructor( @@ -123,8 +147,8 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens = this._register(new RunOnceScheduler(() => this._fetchDocumentSemanticTokensNow(), ModelSemanticColoring.REQUEST_MIN_DELAY)); this._currentDocumentResponse = null; this._currentDocumentRequestCancellationTokenSource = null; - this._documentProvidersChangeListeners = []; this._providersChangedDuringRequest = false; + this._updateRelevantProviders(); this._register(this._model.onDidChangeContent(() => { if (!this._fetchDocumentSemanticTokens.isScheduled()) { @@ -147,31 +171,10 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource = null; } this._setDocumentSemanticTokens(null, null, null, []); + this._updateRelevantProviders(); this._fetchDocumentSemanticTokens.schedule(0); })); - const bindDocumentChangeListeners = () => { - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; - for (const provider of this._provider.all(model)) { - if (typeof provider.onDidChange === 'function') { - this._documentProvidersChangeListeners.push(provider.onDidChange(() => { - if (this._currentDocumentRequestCancellationTokenSource) { - // there is already a request running, - this._providersChangedDuringRequest = true; - return; - } - this._fetchDocumentSemanticTokens.schedule(0); - })); - } - } - }; - bindDocumentChangeListeners(); - this._register(this._provider.onDidChange(() => { - bindDocumentChangeListeners(); - this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); - })); - this._register(themeService.onDidColorThemeChange(_ => { // clear out existing tokens this._setDocumentSemanticTokens(null, null, null, []); @@ -181,6 +184,27 @@ class ModelSemanticColoring extends Disposable { this._fetchDocumentSemanticTokens.schedule(0); } + public handleRegistryChange(): void { + this._updateRelevantProviders(); + this._fetchDocumentSemanticTokens.schedule(this._debounceInformation.get(this._model)); + } + + public handleProviderDidChange(provider: DocumentSemanticTokensProvider): void { + if (!this._relevantProviders.has(provider)) { + return; + } + if (this._currentDocumentRequestCancellationTokenSource) { + // there is already a request running, + this._providersChangedDuringRequest = true; + return; + } + this._fetchDocumentSemanticTokens.schedule(0); + } + + private _updateRelevantProviders(): void { + this._relevantProviders = new Set(this._provider.all(this._model)); + } + public override dispose(): void { if (this._currentDocumentResponse) { this._currentDocumentResponse.dispose(); @@ -190,8 +214,6 @@ class ModelSemanticColoring extends Disposable { this._currentDocumentRequestCancellationTokenSource.cancel(); this._currentDocumentRequestCancellationTokenSource = null; } - dispose(this._documentProvidersChangeListeners); - this._documentProvidersChangeListeners = []; this._setDocumentSemanticTokens(null, null, null, []); this._isDisposed = true; diff --git a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts index 511842715693f..f0f79d731d105 100644 --- a/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts +++ b/src/vs/editor/test/browser/controller/cursorMoveCommand.test.ts @@ -506,6 +506,114 @@ suite('Cursor move by blankline test', () => { }); }); +// Tests for 'foldedLine' unit: moves by model lines but treats each fold as a single step. +// This is the semantics required by vim's j/k: move through visible lines, skip hidden ones. + +suite('Cursor move command - foldedLine unit', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function executeFoldTest(callback: (editor: ITestCodeEditor, viewModel: ViewModel) => void): void { + withTestCodeEditor([ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + ].join('\n'), {}, (editor, viewModel) => { + callback(editor, viewModel); + }); + } + + test('move down by foldedLine skips a fold below the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 4 is hidden (folded under line 3 as header) + viewModel.setHiddenAreas([new Range(4, 1, 4, 1)]); + moveTo(viewModel, 2, 1); + // j from line 2 → line 3 (visible fold header) + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 3, 1); + // j from line 3 (fold header) → line 4 is hidden, lands on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine skips a fold above the cursor', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden (folded under line 2 as header) + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 4, 1); + // k from line 4: line 3 is hidden, lands on line 2 (fold header) + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 2, 1); + // k from line 2 → line 1 + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count treats each fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Line 3 is hidden + viewModel.setHiddenAreas([new Range(3, 1, 3, 1)]); + moveTo(viewModel, 1, 1); + // 3j from line 1: step1→2, step2→3(hidden)→4, step3→5 + moveDownByFoldedLine(viewModel, 3); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine skips a multi-line fold as one step', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden (folded under line 1 as header) + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // j from line 1: lines 2-4 are all hidden, lands directly on line 5 + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move down by foldedLine at last line stays at last line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 5, 1); + moveDownByFoldedLine(viewModel); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine at first line stays at first line', () => { + executeFoldTest((editor, viewModel) => { + moveTo(viewModel, 1, 1); + moveUpByFoldedLine(viewModel); + cursorEqual(viewModel, 1, 1); + }); + }); + + test('move down by foldedLine with count clamps to last visible line after fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 1, 1); + // 2j should land on line 5 and clamp there. + moveDownByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 5, 1); + }); + }); + + test('move up by foldedLine with count clamps to first visible line before fold', () => { + executeFoldTest((editor, viewModel) => { + // Lines 2-4 are hidden. Visible lines are 1 and 5. + viewModel.setHiddenAreas([new Range(2, 1, 4, 1)]); + moveTo(viewModel, 5, 1); + // 2k should land on line 1 and clamp there. + moveUpByFoldedLine(viewModel, 2); + cursorEqual(viewModel, 1, 1); + }); + }); +}); + // Move command function move(viewModel: ViewModel, args: any) { @@ -564,6 +672,14 @@ function moveDownByModelLine(viewModel: ViewModel, noOfLines: number = 1, select move(viewModel, { to: CursorMove.RawDirection.Down, value: noOfLines, select: select }); } +function moveDownByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Down, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + +function moveUpByFoldedLine(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { + move(viewModel, { to: CursorMove.RawDirection.Up, by: CursorMove.RawUnit.FoldedLine, value: noOfLines, select: select }); +} + function moveToTop(viewModel: ViewModel, noOfLines: number = 1, select?: boolean) { move(viewModel, { to: CursorMove.RawDirection.ViewPortTop, value: noOfLines, select: select }); } diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index a167319371ca0..9304d12db48a8 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -15,7 +15,7 @@ import { Iterable } from '../../../base/common/iterator.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { localize } from '../../../nls.js'; import { createActionViewItem, getActionBarActions } from './menuEntryActionViewItem.js'; -import { IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../common/actions.js'; import { createConfigureKeybindingAction } from '../common/menuService.js'; import { ICommandService } from '../../commands/common/commands.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; @@ -332,6 +332,11 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { private readonly _onDidChangeMenuItems = this._store.add(new Emitter()); get onDidChangeMenuItems() { return this._onDidChangeMenuItems.event; } + private readonly _menu: IMenu; + private readonly _menuOptions: IMenuActionOptions | undefined; + private readonly _toolbarOptions: IToolBarRenderOptions | undefined; + private readonly _container: HTMLElement; + constructor( container: HTMLElement, menuId: MenuId, @@ -361,30 +366,44 @@ export class MenuWorkbenchToolBar extends WorkbenchToolBar { } }, menuService, contextKeyService, contextMenuService, keybindingService, commandService, telemetryService); + this._container = container; + this._menuOptions = options?.menuOptions; + this._toolbarOptions = options?.toolbarOptions; + // update logic - const menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); - const updateToolbar = () => { - const { primary, secondary } = getActionBarActions( - menu.getActions(options?.menuOptions), - options?.toolbarOptions?.primaryGroup, - options?.toolbarOptions?.shouldInlineSubmenu, - options?.toolbarOptions?.useSeparatorsInPrimaryActions - ); - container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); - super.setActions(primary, secondary); - }; - - this._store.add(menu.onDidChange(() => { - updateToolbar(); + this._menu = this._store.add(menuService.createMenu(menuId, contextKeyService, { emitEventsForSubmenuChanges: true, eventDebounceDelay: options?.eventDebounceDelay })); + + this._store.add(this._menu.onDidChange(() => { + this._updateToolbar(); this._onDidChangeMenuItems.fire(this); })); this._store.add(actionViewService.onDidChange(e => { if (e === menuId) { - updateToolbar(); + this._updateToolbar(); } })); - updateToolbar(); + this._updateToolbar(); + } + + private _updateToolbar(): void { + const { primary, secondary } = getActionBarActions( + this._menu.getActions(this._menuOptions), + this._toolbarOptions?.primaryGroup, + this._toolbarOptions?.shouldInlineSubmenu, + this._toolbarOptions?.useSeparatorsInPrimaryActions + ); + this._container.classList.toggle('has-no-actions', primary.length === 0 && secondary.length === 0); + super.setActions(primary, secondary); + } + + /** + * Force the toolbar to immediately re-evaluate its menu actions. + * Use this after synchronously updating context keys to avoid + * layout shifts caused by the debounced menu change event. + */ + refresh(): void { + this._updateToolbar(); } /** diff --git a/src/vs/server/node/remoteExtensionManagement.ts b/src/vs/server/node/remoteExtensionManagement.ts index df587cf746828..4a38e83ea25a0 100644 --- a/src/vs/server/node/remoteExtensionManagement.ts +++ b/src/vs/server/node/remoteExtensionManagement.ts @@ -8,6 +8,7 @@ import { ILogService } from '../../platform/log/common/log.js'; import { Emitter, Event } from '../../base/common/event.js'; import { VSBuffer } from '../../base/common/buffer.js'; import { ProcessTimeRunOnceScheduler } from '../../base/common/async.js'; +import { IDisposable } from '../../base/common/lifecycle.js'; function printTime(ms: number): string { let h = 0; @@ -45,6 +46,7 @@ export class ManagementConnection { private _disposed: boolean; private _disconnectRunner1: ProcessTimeRunOnceScheduler; private _disconnectRunner2: ProcessTimeRunOnceScheduler; + private readonly _socketCloseListener: IDisposable; constructor( private readonly _logService: ILogService, @@ -69,11 +71,11 @@ export class ManagementConnection { this._cleanResources(); }, this._reconnectionShortGraceTime); - this.protocol.onDidDispose(() => { + Event.once(this.protocol.onDidDispose)(() => { this._log(`The client has disconnected gracefully, so the connection will be disposed.`); this._cleanResources(); }); - this.protocol.onSocketClose(() => { + this._socketCloseListener = this.protocol.onSocketClose(() => { this._log(`The client has disconnected, will wait for reconnection ${printTime(this._reconnectionGraceTime)} before disposing...`); // The socket has closed, let's give the renderer a certain amount of time to reconnect this._disconnectRunner1.schedule(); @@ -106,6 +108,7 @@ export class ManagementConnection { this._disposed = true; this._disconnectRunner1.dispose(); this._disconnectRunner2.dispose(); + this._socketCloseListener.dispose(); const socket = this.protocol.getSocket(); this.protocol.sendDisconnect(); this.protocol.dispose(); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 8bd18f8f337ce..85f4a6cbd63d7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -197,6 +197,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return [URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}://github/${metadata.owner}/${metadata.name}`), undefined]; } + const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; + if (workingDirectoryPath) { + return [URI.file(workingDirectoryPath), undefined]; + } + const repositoryPath = metadata?.repositoryPath as string | undefined; const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index bb0fb8eec64c0..50ef42e0a2f0e 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -242,7 +242,7 @@ function _createExtHostProtocol(): Promise { clearTimeout(timer); protocol = new PersistentProtocol({ socket, initialChunk: initialDataChunk }); protocol.sendResume(); - protocol.onDidDispose(() => onTerminate('renderer disconnected')); + Event.once(protocol.onDidDispose)(() => onTerminate('renderer disconnected')); resolve(protocol); // Wait for rich client to reconnect diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 5130b606b9832..c3ae8477d422a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -50,7 +50,7 @@ import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../.. import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; -import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag } from '../../common/widget/annotations.js'; +import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasEditCodeblockUriTag } from '../../common/widget/annotations.js'; import { checkModeOption } from '../../common/chat.js'; import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -530,7 +530,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer submenu.actions.length <= 1 }, actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { @@ -756,6 +755,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part instanceof ChatThinkingContentPart); - const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasCodeblockUri(part)); + const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); if (hasRenderedThinkingPart && hasEditPillMarkdown) { return false; } @@ -1480,11 +1485,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer c.kind === 'thinking' || c.kind === 'toolInvocation' || c.kind === 'toolInvocationSerialized') : -1; const isFinalAnswerPart = isFinalRenderPass && context.contentIndex > lastPinnedPartIndex; - if (!this.hasCodeblockUri(markdown) || isFinalAnswerPart) { + if (!this.hasEditCodeblockUri(markdown) || isFinalAnswerPart) { this.finalizeCurrentThinkingPart(context, templateData); } const fillInIncompleteTokens = isResponseVM(element) && (!element.isComplete || element.isCanceled || element.errorDetails?.responseIsFiltered || element.errorDetails?.responseIsIncomplete || !!element.renderData); diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index b43079b5add3a..c2fe2859e216a 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -57,9 +57,25 @@ export function annotateSpecialMarkdownContent(response: Iterable${item.content.value}`; @@ -88,6 +104,18 @@ export function annotateSpecialMarkdownContent(response: Iterable { + const result = annotateSpecialMarkdownContent([ + { kind: 'inlineReference', inlineReference: URI.parse('file:///index.ts'), name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' is the entry point', { isTrusted: true, supportThemeIcons: true }) }, + ]); + + assert.strictEqual(result.length, 1); + const md = result[0] as IChatMarkdownContent; + assert.ok(md.content.value.includes('[index.ts]')); + assert.ok(md.content.value.includes('_vscodecontentref_')); + assert.ok(md.content.value.endsWith(' is the entry point')); + assert.ok(md.inlineReferences); + assert.strictEqual(md.content.isTrusted, true); + assert.strictEqual(md.content.supportThemeIcons, true); + }); + + test('inline reference after regular text does not force-merge incompatible markdown', () => { + const result = annotateSpecialMarkdownContent([ + content('See '), + { kind: 'inlineReference', inlineReference: URI.parse('file:///index.ts'), name: 'index.ts' }, + { kind: 'markdownContent', content: new MarkdownString(' more info', { isTrusted: true, supportThemeIcons: true }) }, + ]); + + // The first item has "See [index.ts](...)" with default markdown properties, + // the second item has different properties - they must stay separate. + assert.strictEqual(result.length, 2); + const first = result[0] as IChatMarkdownContent; + assert.ok(first.content.value.startsWith('See ')); + assert.ok(first.inlineReferences); + const second = result[1] as IChatMarkdownContent; + assert.strictEqual(second.content.value, ' more info'); + assert.strictEqual(second.content.isTrusted, true); + }); + }); + + suite('hasEditCodeblockUriTag', () => { + test('returns true for edit codeblock URI tags', () => { + const editTag = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(editTag), true); + }); + + test('returns false for non-edit codeblock URI tags', () => { + const nonEditTag = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(nonEditTag), false); + }); + + test('returns true for edit codeblock URI tags with subAgentInvocationId', () => { + const editTagWithSubAgent = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(editTagWithSubAgent), true); + }); + + test('returns false for non-edit codeblock URI tags with subAgentInvocationId', () => { + const nonEditTagWithSubAgent = 'file:///test.ts'; + assert.strictEqual(hasEditCodeblockUriTag(nonEditTagWithSubAgent), false); + }); + + test('returns false for text without codeblock URI tags', () => { + assert.strictEqual(hasEditCodeblockUriTag('some plain text'), false); + }); + + test('returns false for text with only partial tag prefix', () => { + assert.strictEqual(hasEditCodeblockUriTag(' { + const multipleEditTags = 'some text file:///test.ts more file:///other.ts'; + assert.strictEqual(hasEditCodeblockUriTag(multipleEditTags), true); + }); + + test('returns false for text containing only non-edit codeblock URI tags', () => { + const multipleNonEditTags = 'some text file:///test.ts more file:///other.ts'; + assert.strictEqual(hasEditCodeblockUriTag(multipleNonEditTags), false); + }); + }); });