mirror of
https://github.com/Eugeny/tabby.git
synced 2024-12-09 06:20:22 +08:00
Search (fixes #85)
This commit is contained in:
parent
c3693f5d44
commit
0e8482e28d
@ -51,13 +51,13 @@ $input-disabled-bg: #333;
|
||||
$input-color: $body-color;
|
||||
$input-color-placeholder: #333;
|
||||
$input-border-color: #344;
|
||||
$input-border-width: 0;
|
||||
$input-border-width: 1px;
|
||||
//$input-box-shadow: inset 0 1px 1px rgba($black,.075);
|
||||
$input-border-radius: 0;
|
||||
$custom-select-border-radius: 0;
|
||||
$input-bg-focus: $input-bg;
|
||||
//$input-border-focus: lighten($brand-primary, 25%);
|
||||
//$input-box-shadow-focus: $input-box-shadow, rgba($input-border-focus, .6);
|
||||
$input-border-focus: lighten($blue, 25%);
|
||||
$input-focus-box-shadow: none;
|
||||
$input-color-focus: $input-color;
|
||||
$input-group-addon-bg: $body-bg;
|
||||
$input-group-addon-border-color: $input-border-color;
|
||||
|
@ -1,6 +1,7 @@
|
||||
:host {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&> .content {
|
||||
|
@ -13,6 +13,7 @@ import { SSHConnection, SSHSession } from '../api'
|
||||
></div>
|
||||
`,
|
||||
styles: [require('./sshTab.component.scss')],
|
||||
animations: BaseTerminalTabComponent.animations,
|
||||
})
|
||||
export class SSHTabComponent extends BaseTerminalTabComponent {
|
||||
connection: SSHConnection
|
||||
|
@ -0,0 +1,7 @@
|
||||
.content(#content, [style.opacity]='frontendIsReady ? 1 : 0')
|
||||
search-panel(
|
||||
*ngIf='showSearchPanel',
|
||||
@slideInOut,
|
||||
[frontend]='frontend',
|
||||
(close)='showSearchPanel = false'
|
||||
)
|
@ -2,6 +2,7 @@ import { Observable, Subject, Subscription } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { ToastrService } from 'ngx-toastr'
|
||||
import { NgZone, OnInit, OnDestroy, Inject, Injector, Optional, ViewChild, HostBinding, Input, ElementRef } from '@angular/core'
|
||||
import { trigger, transition, style, animate } from '@angular/animations'
|
||||
import { AppService, ConfigService, BaseTabComponent, ElectronService, HostAppService, HotkeysService, Platform, LogService, Logger } from 'terminus-core'
|
||||
|
||||
import { BaseSession, SessionsService } from '../services/sessions.service'
|
||||
@ -14,18 +15,25 @@ import { Frontend } from '../frontends/frontend'
|
||||
* A class to base your custom terminal tabs on
|
||||
*/
|
||||
export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit, OnDestroy {
|
||||
static template = `
|
||||
<div
|
||||
#content
|
||||
class="content"
|
||||
[style.opacity]="frontendIsReady ? 1 : 0"
|
||||
></div>
|
||||
`
|
||||
static template = require('./baseTerminalTab.component.pug')
|
||||
static styles = [require('./terminalTab.component.scss')]
|
||||
static animations = [
|
||||
trigger('slideInOut', [
|
||||
transition(':enter', [
|
||||
style({ transform: 'translateY(-25%)' }),
|
||||
animate('100ms ease-in-out', style({ transform: 'translateY(0%)' }))
|
||||
]),
|
||||
transition(':leave', [
|
||||
animate('100ms ease-in-out', style({ transform: 'translateY(-25%)' }))
|
||||
])
|
||||
])
|
||||
]
|
||||
|
||||
session: BaseSession
|
||||
@Input() zoom = 0
|
||||
|
||||
@Input() showSearchPanel = false
|
||||
|
||||
/** @hidden */
|
||||
@ViewChild('content') content
|
||||
|
||||
@ -122,6 +130,12 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
|
||||
case 'delete-next-word':
|
||||
this.sendInput('\x1bd')
|
||||
break
|
||||
case 'search':
|
||||
this.showSearchPanel = true
|
||||
setImmediate(() => {
|
||||
this.element.nativeElement.querySelector('.search-input').focus()
|
||||
})
|
||||
break
|
||||
}
|
||||
})
|
||||
this.bellPlayer = document.createElement('audio')
|
||||
|
34
terminus-terminal/src/components/searchPanel.component.pug
Normal file
34
terminus-terminal/src/components/searchPanel.component.pug
Normal file
@ -0,0 +1,34 @@
|
||||
.input-group.w-100
|
||||
input.search-input.form-control(
|
||||
type='search',
|
||||
[(ngModel)]='query',
|
||||
(ngModelChange)='notFound = false',
|
||||
[class.text-danger]='notFound',
|
||||
(click)='$event.stopPropagation()',
|
||||
(keyup.enter)='findNext()',
|
||||
(keyup.esc)='close.emit()',
|
||||
placeholder='Search...'
|
||||
)
|
||||
.input-group-append
|
||||
.btn-group
|
||||
button.btn.btn-outline-primary(
|
||||
(click)='options.caseSensitive = !options.caseSensitive',
|
||||
[class.active]='options.caseSensitive',
|
||||
ngbTooltip='Case sensitivity',
|
||||
placement='bottom'
|
||||
)
|
||||
i.fa.fa-fw.fa-font
|
||||
button.btn.btn-outline-primary(
|
||||
(click)='options.regex = !options.regex',
|
||||
[class.active]='options.regex',
|
||||
ngbTooltip='Regular expression',
|
||||
placement='bottom'
|
||||
)
|
||||
i.fa.fa-fw.fa-asterisk
|
||||
button.btn.btn-outline-primary(
|
||||
(click)='options.wholeWord = !options.wholeWord',
|
||||
[class.active]='options.wholeWord',
|
||||
ngbTooltip='Whole word',
|
||||
placement='bottom'
|
||||
)
|
||||
i.fa.fa-fw.fa-square
|
@ -0,0 +1,9 @@
|
||||
:host {
|
||||
position: fixed;
|
||||
width: 400px;
|
||||
align-self: center;
|
||||
z-index: 5;
|
||||
padding: 5px;
|
||||
border-radius: 0 0 3px 3px;
|
||||
background: rgba(0, 0, 0, .25);
|
||||
}
|
36
terminus-terminal/src/components/searchPanel.component.ts
Normal file
36
terminus-terminal/src/components/searchPanel.component.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||
import { ToastrService } from 'ngx-toastr'
|
||||
import { Frontend, ISearchOptions } from '../frontends/frontend'
|
||||
|
||||
@Component({
|
||||
selector: 'search-panel',
|
||||
template: require('./searchPanel.component.pug'),
|
||||
styles: [require('./searchPanel.component.scss')],
|
||||
})
|
||||
export class SearchPanelComponent {
|
||||
@Input() query: string
|
||||
@Input() frontend: Frontend
|
||||
notFound = false
|
||||
static globalOptions: ISearchOptions = {}
|
||||
options: ISearchOptions = SearchPanelComponent.globalOptions
|
||||
|
||||
@Output() close = new EventEmitter()
|
||||
|
||||
constructor (
|
||||
private toastr: ToastrService,
|
||||
) { }
|
||||
|
||||
findNext () {
|
||||
if (!this.frontend.findNext(this.query, this.options)) {
|
||||
this.notFound = true
|
||||
this.toastr.error('Not found')
|
||||
}
|
||||
}
|
||||
|
||||
findPrevious () {
|
||||
if (!this.frontend.findPrevious(this.query, this.options)) {
|
||||
this.notFound = true
|
||||
this.toastr.error('Not found')
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
:host {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&.top-padded {
|
||||
|
@ -12,6 +12,7 @@ import { WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from '../utils'
|
||||
selector: 'terminalTab',
|
||||
template: BaseTerminalTabComponent.template,
|
||||
styles: BaseTerminalTabComponent.styles,
|
||||
animations: BaseTerminalTabComponent.animations,
|
||||
})
|
||||
export class TerminalTabComponent extends BaseTerminalTabComponent {
|
||||
@Input() sessionOptions: SessionOptions
|
||||
|
@ -98,6 +98,9 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
'next-word': ['⌥-ArrowRight'],
|
||||
'delete-previous-word': ['⌥-Backspace'],
|
||||
'delete-next-word': ['⌥-Delete'],
|
||||
'search': [
|
||||
'⌘-F',
|
||||
],
|
||||
},
|
||||
},
|
||||
[Platform.Windows]: {
|
||||
@ -139,6 +142,9 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
'next-word': ['Ctrl-ArrowRight'],
|
||||
'delete-previous-word': ['Ctrl-Backspace'],
|
||||
'delete-next-word': ['Ctrl-Delete'],
|
||||
'search': [
|
||||
'Ctrl-Shift-F',
|
||||
],
|
||||
},
|
||||
},
|
||||
[Platform.Linux]: {
|
||||
@ -178,6 +184,9 @@ export class TerminalConfigProvider extends ConfigProvider {
|
||||
'next-word': ['Ctrl-ArrowRight'],
|
||||
'delete-previous-word': ['Ctrl-Backspace'],
|
||||
'delete-next-word': ['Ctrl-Delete'],
|
||||
'search': [
|
||||
'Ctrl-Shift-F',
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -2,6 +2,12 @@ import { Observable, Subject, AsyncSubject, ReplaySubject, BehaviorSubject } fro
|
||||
import { ResizeEvent } from '../api'
|
||||
import { ConfigService, ThemesService, HotkeysService } from 'terminus-core'
|
||||
|
||||
export interface ISearchOptions {
|
||||
regex?: boolean
|
||||
wholeWord?: boolean
|
||||
caseSensitive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend to add support for a different VT frontend implementation
|
||||
*/
|
||||
@ -64,4 +70,7 @@ export abstract class Frontend {
|
||||
|
||||
abstract configure (): void
|
||||
abstract setZoom (zoom: number): void
|
||||
|
||||
abstract findNext (term: string, searchOptions?: ISearchOptions): boolean
|
||||
abstract findPrevious (term: string, searchOptions?: ISearchOptions): boolean
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Frontend } from './frontend'
|
||||
import { Frontend, ISearchOptions } from './frontend'
|
||||
import { hterm, preferenceManager } from './hterm'
|
||||
|
||||
/** @hidden */
|
||||
@ -268,4 +268,12 @@ export class HTermFrontend extends Frontend {
|
||||
_onCursorBlink()
|
||||
}
|
||||
}
|
||||
|
||||
findNext (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
findPrevious (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { Frontend } from './frontend'
|
||||
import { Terminal, ITheme } from 'xterm'
|
||||
import { fit } from 'xterm/src/addons/fit/fit'
|
||||
import { enableLigatures } from 'xterm-addon-ligatures'
|
||||
import { SearchAddon, ISearchOptions } from './xtermSearchAddon'
|
||||
import 'xterm/lib/xterm.css'
|
||||
import './xterm.css'
|
||||
import deepEqual = require('deep-equal')
|
||||
@ -16,6 +17,7 @@ export class XTermFrontend extends Frontend {
|
||||
private resizeHandler: () => void
|
||||
private configuredTheme: ITheme = {}
|
||||
private copyOnSelect = false
|
||||
private search = new SearchAddon()
|
||||
|
||||
constructor () {
|
||||
super()
|
||||
@ -89,6 +91,8 @@ export class XTermFrontend extends Frontend {
|
||||
this.ready.next(null)
|
||||
this.ready.complete()
|
||||
|
||||
this.xterm.loadAddon(this.search)
|
||||
|
||||
window.addEventListener('resize', this.resizeHandler)
|
||||
|
||||
this.resizeHandler()
|
||||
@ -198,6 +202,14 @@ export class XTermFrontend extends Frontend {
|
||||
this.setFontSize()
|
||||
}
|
||||
|
||||
findNext (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
return this.search.findNext(term, searchOptions)
|
||||
}
|
||||
|
||||
findPrevious (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
return this.search.findPrevious(term, searchOptions)
|
||||
}
|
||||
|
||||
private setFontSize () {
|
||||
this.xterm.setOption('fontSize', this.configuredFontSize * Math.pow(1.1, this.zoom))
|
||||
}
|
||||
|
328
terminus-terminal/src/frontends/xtermSearchAddon.ts
Normal file
328
terminus-terminal/src/frontends/xtermSearchAddon.ts
Normal file
@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Terminal, IDisposable, ITerminalAddon } from 'xterm'
|
||||
|
||||
export interface ISearchOptions {
|
||||
regex?: boolean
|
||||
wholeWord?: boolean
|
||||
caseSensitive?: boolean
|
||||
incremental?: boolean
|
||||
}
|
||||
|
||||
export interface ISearchResult {
|
||||
term: string
|
||||
col: number
|
||||
row: number
|
||||
}
|
||||
|
||||
const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?'
|
||||
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000 // 15 secs
|
||||
|
||||
export class SearchAddon implements ITerminalAddon {
|
||||
private _terminal: Terminal | undefined
|
||||
|
||||
private _linesCache: string[] | undefined
|
||||
private _linesCacheTimeoutId = 0
|
||||
private _cursorMoveListener: IDisposable | undefined
|
||||
private _resizeListener: IDisposable | undefined
|
||||
|
||||
public activate (terminal: Terminal): void {
|
||||
this._terminal = terminal
|
||||
}
|
||||
|
||||
public dispose (): void {}
|
||||
|
||||
public findNext (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal) {
|
||||
throw new Error('Cannot use addon until it has been loaded')
|
||||
}
|
||||
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection()
|
||||
return false
|
||||
}
|
||||
|
||||
let startCol: number = 0
|
||||
let startRow = this._terminal.buffer.viewportY
|
||||
|
||||
if (this._terminal.hasSelection()) {
|
||||
const incremental = searchOptions ? searchOptions.incremental : false
|
||||
// Start from the selection end if there is a selection
|
||||
// For incremental search, use existing row
|
||||
const currentSelection = this._terminal.getSelectionPosition()!
|
||||
startRow = incremental ? currentSelection.startRow : currentSelection.endRow
|
||||
startCol = incremental ? currentSelection.startColumn : currentSelection.endColumn
|
||||
}
|
||||
|
||||
this._initLinesCache()
|
||||
|
||||
// A row that has isWrapped = false
|
||||
let findingRow = startRow
|
||||
// index of beginning column that _findInLine need to scan.
|
||||
let cumulativeCols = startCol
|
||||
// If startRow is wrapped row, scan for unwrapped row above.
|
||||
// So we can start matching on wrapped line from long unwrapped line.
|
||||
let currentLine = this._terminal.buffer.getLine(findingRow)
|
||||
while (currentLine && currentLine.isWrapped) {
|
||||
cumulativeCols += this._terminal.cols
|
||||
currentLine = this._terminal.buffer.getLine(--findingRow)
|
||||
}
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, findingRow, cumulativeCols, searchOptions)
|
||||
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.baseY + this._terminal.rows; y++) {
|
||||
|
||||
// If the current line is wrapped line, increase index of column to ignore the previous scan
|
||||
// Otherwise, reset beginning column index to zero with set new unwrapped line index
|
||||
result = this._findInLine(term, y, 0, searchOptions)
|
||||
if (result) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search from the top to the startRow (search the whole startRow again in
|
||||
// case startCol > 0)
|
||||
if (!result) {
|
||||
for (let y = 0; y < findingRow; y++) {
|
||||
result = this._findInLine(term, y, 0, searchOptions)
|
||||
if (result) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set selection and scroll if a result was found
|
||||
return this._selectResult(result)
|
||||
}
|
||||
|
||||
public findPrevious (term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal) {
|
||||
throw new Error('Cannot use addon until it has been loaded')
|
||||
}
|
||||
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection()
|
||||
return false
|
||||
}
|
||||
|
||||
const isReverseSearch = true
|
||||
let startRow = this._terminal.buffer.viewportY + this._terminal.rows - 1
|
||||
let startCol = this._terminal.cols
|
||||
|
||||
if (this._terminal.hasSelection()) {
|
||||
// Start from the selection start if there is a selection
|
||||
const currentSelection = this._terminal.getSelectionPosition()!
|
||||
startRow = currentSelection.startRow
|
||||
startCol = currentSelection.startColumn
|
||||
}
|
||||
|
||||
this._initLinesCache()
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, startRow, startCol, searchOptions, isReverseSearch)
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
// If the line is wrapped line, increase number of columns that is needed to be scanned
|
||||
// Se we can scan on wrapped line from unwrapped line
|
||||
let cumulativeCols = this._terminal.cols
|
||||
if (this._terminal.buffer.getLine(startRow)!.isWrapped) {
|
||||
cumulativeCols += startCol
|
||||
}
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch)
|
||||
if (result) {
|
||||
break
|
||||
}
|
||||
// If the current line is wrapped line, increase scanning range,
|
||||
// preparing for scanning on unwrapped line
|
||||
const line = this._terminal.buffer.getLine(y)
|
||||
if (line && line.isWrapped) {
|
||||
cumulativeCols += this._terminal.cols
|
||||
} else {
|
||||
cumulativeCols = this._terminal.cols
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search from the bottom to startRow (search the whole startRow again in
|
||||
// case startCol > 0)
|
||||
if (!result) {
|
||||
const searchFrom = this._terminal.buffer.baseY + this._terminal.rows - 1
|
||||
let cumulativeCols = this._terminal.cols
|
||||
for (let y = searchFrom; y >= startRow; y--) {
|
||||
result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch)
|
||||
if (result) {
|
||||
break
|
||||
}
|
||||
const line = this._terminal.buffer.getLine(y)
|
||||
if (line && line.isWrapped) {
|
||||
cumulativeCols += this._terminal.cols
|
||||
} else {
|
||||
cumulativeCols = this._terminal.cols
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set selection and scroll if a result was found
|
||||
return this._selectResult(result)
|
||||
}
|
||||
|
||||
private _initLinesCache (): void {
|
||||
const terminal = this._terminal!
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(terminal.buffer.length)
|
||||
this._cursorMoveListener = terminal.onCursorMove(() => this._destroyLinesCache())
|
||||
this._resizeListener = terminal.onResize(() => this._destroyLinesCache())
|
||||
}
|
||||
|
||||
window.clearTimeout(this._linesCacheTimeoutId)
|
||||
this._linesCacheTimeoutId = window.setTimeout(() => this._destroyLinesCache(), LINES_CACHE_TIME_TO_LIVE)
|
||||
}
|
||||
|
||||
private _destroyLinesCache (): void {
|
||||
this._linesCache = undefined
|
||||
if (this._cursorMoveListener) {
|
||||
this._cursorMoveListener.dispose()
|
||||
this._cursorMoveListener = undefined
|
||||
}
|
||||
if (this._resizeListener) {
|
||||
this._resizeListener.dispose()
|
||||
this._resizeListener = undefined
|
||||
}
|
||||
if (this._linesCacheTimeoutId) {
|
||||
window.clearTimeout(this._linesCacheTimeoutId)
|
||||
this._linesCacheTimeoutId = 0
|
||||
}
|
||||
}
|
||||
|
||||
private _isWholeWord (searchIndex: number, line: string, term: string): boolean {
|
||||
return (((searchIndex === 0) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex - 1]) !== -1)) &&
|
||||
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1)))
|
||||
}
|
||||
|
||||
protected _findInLine (term: string, row: number, col: number, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
const terminal = this._terminal!
|
||||
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
const firstLine = terminal.buffer.getLine(row)
|
||||
if (firstLine && firstLine.isWrapped) {
|
||||
return null
|
||||
}
|
||||
let stringLine = this._linesCache ? this._linesCache[row] : void 0
|
||||
if (stringLine === void 0) {
|
||||
stringLine = this._translateBufferLineToStringWithWrap(row, true)
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = stringLine
|
||||
}
|
||||
}
|
||||
|
||||
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase()
|
||||
const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase()
|
||||
|
||||
let resultIndex = -1
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = RegExp(searchTerm, 'g')
|
||||
let foundTerm: RegExpExecArray | null
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..col
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, col))) {
|
||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length
|
||||
term = foundTerm[0]
|
||||
searchRegex.lastIndex -= (term.length - 1)
|
||||
}
|
||||
} else {
|
||||
foundTerm = searchRegex.exec(searchStringLine.slice(col))
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length)
|
||||
term = foundTerm[0]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isReverseSearch) {
|
||||
if (col - searchTerm.length >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, col - searchTerm.length)
|
||||
}
|
||||
} else {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, col)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple rows
|
||||
if (resultIndex >= terminal.cols) {
|
||||
row += Math.floor(resultIndex / terminal.cols)
|
||||
resultIndex = resultIndex % terminal.cols
|
||||
}
|
||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const line = terminal.buffer.getLine(row)
|
||||
|
||||
if (line) {
|
||||
for (let i = 0; i < resultIndex; i++) {
|
||||
const cell = line.getCell(i)
|
||||
if (!cell) {
|
||||
break
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.char
|
||||
if (char.length > 1) {
|
||||
resultIndex -= char.length - 1
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const charWidth = cell.width
|
||||
if (charWidth === 0) {
|
||||
resultIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
term,
|
||||
col: resultIndex,
|
||||
row
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private _translateBufferLineToStringWithWrap (lineIndex: number, trimRight: boolean): string {
|
||||
const terminal = this._terminal!
|
||||
let lineString = ''
|
||||
let lineWrapsToNext: boolean
|
||||
|
||||
do {
|
||||
const nextLine = terminal.buffer.getLine(lineIndex + 1)
|
||||
lineWrapsToNext = nextLine ? nextLine.isWrapped : false
|
||||
const line = terminal.buffer.getLine(lineIndex)
|
||||
if (!line) {
|
||||
break
|
||||
}
|
||||
lineString += line.translateToString(!lineWrapsToNext && trimRight).substring(0, terminal.cols)
|
||||
lineIndex++
|
||||
} while (lineWrapsToNext)
|
||||
|
||||
return lineString
|
||||
}
|
||||
|
||||
private _selectResult (result: ISearchResult | undefined): boolean {
|
||||
const terminal = this._terminal!
|
||||
if (!result) {
|
||||
terminal.clearSelection()
|
||||
return false
|
||||
}
|
||||
terminal.select(result.col, result.row, result.term.length)
|
||||
terminal.scrollLines(result.row - terminal.buffer.viewportY)
|
||||
return true
|
||||
}
|
||||
}
|
@ -63,6 +63,10 @@ export class TerminalHotkeyProvider extends HotkeyProvider {
|
||||
id: 'ctrl-c',
|
||||
name: 'Intelligent Ctrl-C (copy/abort)',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'Search',
|
||||
},
|
||||
]
|
||||
|
||||
constructor (
|
||||
|
@ -18,6 +18,7 @@ import { ColorPickerComponent } from './components/colorPicker.component'
|
||||
import { EditProfileModalComponent } from './components/editProfileModal.component'
|
||||
import { EnvironmentEditorComponent } from './components/environmentEditor.component'
|
||||
import { BaseTerminalTabComponent } from './components/baseTerminalTab.component'
|
||||
import { SearchPanelComponent } from './components/searchPanel.component'
|
||||
|
||||
import { BaseSession } from './services/sessions.service'
|
||||
import { TerminalFrontendService } from './services/terminalFrontend.service'
|
||||
@ -112,6 +113,7 @@ import { XTermFrontend } from './frontends/xtermFrontend'
|
||||
TerminalSettingsTabComponent,
|
||||
EditProfileModalComponent,
|
||||
EnvironmentEditorComponent,
|
||||
SearchPanelComponent,
|
||||
],
|
||||
exports: [
|
||||
ColorPickerComponent,
|
||||
|
Loading…
Reference in New Issue
Block a user