Path: blob/main/src/vs/editor/contrib/quickAccess/browser/gotoLineQuickAccess.ts
5254 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';8import { ThemeIcon } from '../../../../base/common/themables.js';9import { localize } from '../../../../nls.js';10import { IQuickInputButton, IQuickPick, IQuickPickItem, QuickInputButtonLocation } from '../../../../platform/quickinput/common/quickInput.js';11import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';12import { getCodeEditor } from '../../../browser/editorBrowser.js';13import { EditorOption, RenderLineNumbersType } from '../../../common/config/editorOptions.js';14import { CursorColumns } from '../../../common/core/cursorColumns.js';15import { IPosition } from '../../../common/core/position.js';16import { IRange } from '../../../common/core/range.js';17import { IEditor, ScrollType } from '../../../common/editorCommon.js';18import { AbstractEditorNavigationQuickAccessProvider, IQuickAccessTextEditorContext } from './editorNavigationQuickAccess.js';1920interface IGotoLineQuickPickItem extends IQuickPickItem, Partial<IPosition> { }2122export abstract class AbstractGotoLineQuickAccessProvider extends AbstractEditorNavigationQuickAccessProvider {2324static readonly GO_TO_LINE_PREFIX = ':';25static readonly GO_TO_OFFSET_PREFIX = '::';26private static readonly ZERO_BASED_OFFSET_STORAGE_KEY = 'gotoLine.useZeroBasedOffset';2728constructor() {29super({ canAcceptInBackground: true });30}3132protected abstract readonly storageService: IStorageService;3334private get useZeroBasedOffset() {35return this.storageService.getBoolean(36AbstractGotoLineQuickAccessProvider.ZERO_BASED_OFFSET_STORAGE_KEY,37StorageScope.APPLICATION,38false);39}4041private set useZeroBasedOffset(value: boolean) {42this.storageService.store(43AbstractGotoLineQuickAccessProvider.ZERO_BASED_OFFSET_STORAGE_KEY,44value,45StorageScope.APPLICATION,46StorageTarget.USER);47}4849protected provideWithoutTextEditor(picker: IQuickPick<IGotoLineQuickPickItem, { useSeparators: true }>): IDisposable {50const label = localize('gotoLine.noEditor', "Open a text editor first to go to a line or an offset.");5152picker.items = [{ label }];53picker.ariaLabel = label;5455return Disposable.None;56}5758protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick<IGotoLineQuickPickItem, { useSeparators: true }>, token: CancellationToken): IDisposable {59const editor = context.editor;60const disposables = new DisposableStore();6162// Set initial ariaLabel for screen readers63picker.ariaLabel = localize('gotoLine.ariaLabel', "Go to line. Type a line number, optionally followed by colon and column number.");6465// Goto line once picked66disposables.add(picker.onDidAccept(event => {67const [item] = picker.selectedItems;68if (item) {69if (!item.lineNumber) {70return;71}7273this.gotoLocation(context, { range: this.toRange(item.lineNumber, item.column), keyMods: picker.keyMods, preserveFocus: event.inBackground });7475if (!event.inBackground) {76picker.hide();77}78}79}));8081// Add a toggle to switch between 1- and 0-based offsets.82const offsetButton: IQuickInputButton = {83iconClass: ThemeIcon.asClassName(Codicon.indexZero),84tooltip: localize('gotoLineToggleButton', "Toggle Zero-Based Offset"),85location: QuickInputButtonLocation.Input,86toggle: { checked: this.useZeroBasedOffset }87};8889// React to picker changes90const updatePickerAndEditor = () => {91const inputText = picker.value.trim().substring(AbstractGotoLineQuickAccessProvider.GO_TO_LINE_PREFIX.length);92const { inOffsetMode, lineNumber, column, label } = this.parsePosition(editor, inputText);9394// Show toggle only when input text starts with '::'.95picker.buttons = inOffsetMode ? [offsetButton] : [];9697// Picker98picker.items = [{99lineNumber,100column,101label,102ariaLabel: lineNumber103? localize('gotoLine.itemAriaLabel', "Go to line {0}, column {1}. Press Enter to navigate.", lineNumber, column || 1)104: label,105}];106107// Clear decorations for invalid range108if (!lineNumber) {109this.clearDecorations(editor);110return;111}112113// Reveal114const range = this.toRange(lineNumber, column);115editor.revealRangeInCenter(range, ScrollType.Smooth);116117// Decorate118this.addDecorations(editor, range);119};120121disposables.add(picker.onDidTriggerButton(button => {122if (button === offsetButton) {123this.useZeroBasedOffset = button.toggle?.checked ?? !this.useZeroBasedOffset;124updatePickerAndEditor();125}126}));127128updatePickerAndEditor();129disposables.add(picker.onDidChangeValue(() => updatePickerAndEditor()));130131// Adjust line number visibility as needed132const codeEditor = getCodeEditor(editor);133if (codeEditor) {134const options = codeEditor.getOptions();135const lineNumbers = options.get(EditorOption.lineNumbers);136if (lineNumbers.renderType === RenderLineNumbersType.Relative) {137codeEditor.updateOptions({ lineNumbers: 'on' });138139disposables.add(toDisposable(() => codeEditor.updateOptions({ lineNumbers: 'relative' })));140}141}142143return disposables;144}145146private toRange(lineNumber = 1, column = 1): IRange {147return {148startLineNumber: lineNumber,149startColumn: column,150endLineNumber: lineNumber,151endColumn: column152};153}154155protected parsePosition(editor: IEditor, value: string): Partial<IPosition> & { inOffsetMode?: boolean; label: string } {156const model = this.getModel(editor);157if (!model) {158return {159label: localize('gotoLine.noEditor', "Open a text editor first to go to a line or an offset.")160};161}162163// Support ::<offset> notation to navigate to a specific offset in the model.164if (value.startsWith(':')) {165let offset = parseInt(value.substring(1), 10);166const maxOffset = model.getValueLength();167if (isNaN(offset)) {168// No valid offset specified.169return {170inOffsetMode: true,171label: this.useZeroBasedOffset ?172localize('gotoLine.offsetPromptZero', "Type a character position to go to (from 0 to {0}).", maxOffset - 1) :173localize('gotoLine.offsetPrompt', "Type a character position to go to (from 1 to {0}).", maxOffset)174};175} else {176const reverse = offset < 0;177if (!this.useZeroBasedOffset) {178// Convert 1-based offset to model's 0-based.179offset -= Math.sign(offset);180}181182if (reverse) {183// Offset from the end of the buffer184offset += maxOffset;185}186187const pos = model.getPositionAt(offset);188const visibleColumn = CursorColumns.visibleColumnFromColumn(189model.getLineContent(pos.lineNumber),190pos.column,191model.getOptions().tabSize) + 1;192193return {194...pos,195inOffsetMode: true,196label: localize('gotoLine.goToPosition', "Press 'Enter' to go to line {0} at column {1}.", pos.lineNumber, visibleColumn)197};198}199} else {200// Support line-col formats of `line,col`, `line:col`, `line#col`201const parts = value.split(/,|:|#/);202203const maxLine = model.getLineCount();204let lineNumber = parseInt(parts[0]?.trim(), 10);205if (parts.length < 1 || isNaN(lineNumber)) {206return {207label: localize('gotoLine.linePrompt', "Type a line number to go to (from 1 to {0}).", maxLine)208};209}210211// Handle negative line numbers and clip to valid range.212lineNumber = lineNumber >= 0 ? lineNumber : (maxLine + 1) + lineNumber;213lineNumber = Math.min(Math.max(1, lineNumber), maxLine);214215// Treat column number as visible column216const tabSize = model.getOptions().tabSize;217const lineContent = model.getLineContent(lineNumber);218const maxColumn = CursorColumns.visibleColumnFromColumn(lineContent, model.getLineMaxColumn(lineNumber), tabSize) + 1;219220let column = parseInt(parts[1]?.trim(), 10);221if (parts.length < 2 || isNaN(column)) {222return {223lineNumber,224column: 1,225label: parts.length < 2 ?226localize('gotoLine.lineColumnPrompt', "Press 'Enter' to go to line {0} or enter colon : to add a column number.", lineNumber) :227localize('gotoLine.columnPrompt', "Press 'Enter' to go to line {0} or enter a column number (from 1 to {1}).", lineNumber, maxColumn)228};229}230231// Handle negative column numbers and clip to valid range.232column = column >= 0 ? column : maxColumn + column;233column = Math.min(Math.max(1, column), maxColumn);234235const realColumn = CursorColumns.columnFromVisibleColumn(lineContent, column - 1, tabSize);236return {237lineNumber,238column: realColumn,239label: localize('gotoLine.goToPosition', "Press 'Enter' to go to line {0} at column {1}.", lineNumber, column)240};241}242}243}244245246