Search (fixes #85)

This commit is contained in:
Eugene Pankov 2019-06-05 22:04:40 +02:00
parent c3693f5d44
commit 0e8482e28d
17 changed files with 487 additions and 11 deletions

View File

@ -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;

View File

@ -1,6 +1,7 @@
:host {
flex: auto;
display: flex;
flex-direction: column;
overflow: hidden;
&> .content {

View File

@ -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

View File

@ -0,0 +1,7 @@
.content(#content, [style.opacity]='frontendIsReady ? 1 : 0')
search-panel(
*ngIf='showSearchPanel',
@slideInOut,
[frontend]='frontend',
(close)='showSearchPanel = false'
)

View File

@ -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')

View 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

View File

@ -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);
}

View 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')
}
}
}

View File

@ -1,6 +1,7 @@
:host {
flex: auto;
display: flex;
flex-direction: column;
overflow: hidden;
&.top-padded {

View File

@ -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

View File

@ -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',
],
},
},
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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))
}

View 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
}
}

View File

@ -63,6 +63,10 @@ export class TerminalHotkeyProvider extends HotkeyProvider {
id: 'ctrl-c',
name: 'Intelligent Ctrl-C (copy/abort)',
},
{
id: 'search',
name: 'Search',
},
]
constructor (

View File

@ -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,