project rename

This commit is contained in:
Eugene Pankov
2021-06-29 23:57:04 +02:00
parent c61be3d52b
commit 43cd3318da
609 changed files with 510 additions and 530 deletions

View File

@@ -0,0 +1,669 @@
import { Observable, Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import colors from 'ansi-colors'
import { NgZone, OnInit, OnDestroy, Injector, ViewChild, HostBinding, Input, ElementRef, InjectFlags } from '@angular/core'
import { trigger, transition, style, animate, AnimationTriggerMetadata } from '@angular/animations'
import { AppService, ConfigService, BaseTabComponent, HostAppService, HotkeysService, NotificationsService, Platform, LogService, Logger, TabContextMenuItemProvider, SplitTabComponent, SubscriptionContainer, MenuItemOptions, PlatformService, HostWindowService } from 'tabby-core'
import { BaseSession } from '../session'
import { TerminalFrontendService } from '../services/terminalFrontend.service'
import { Frontend } from '../frontends/frontend'
import { ResizeEvent } from './interfaces'
import { TerminalDecorator } from './decorator'
/**
* A class to base your custom terminal tabs on
*/
export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit, OnDestroy {
static template: string = require<string>('../components/baseTerminalTab.component.pug')
static styles: string[] = [require<string>('../components/baseTerminalTab.component.scss')]
static animations: AnimationTriggerMetadata[] = [
trigger('toolbarSlide', [
transition(':enter', [
style({
transform: 'translateY(-25%)',
opacity: '0',
}),
animate('100ms ease-out', style({
transform: 'translateY(0%)',
opacity: '1',
})),
]),
transition(':leave', [
animate('100ms ease-out', style({
transform: 'translateY(-25%)',
opacity: '0',
})),
]),
]),
trigger('panelSlide', [
transition(':enter', [
style({
transform: 'translateY(25%)',
opacity: '0',
}),
animate('100ms ease-out', style({
transform: 'translateY(0%)',
opacity: '1',
})),
]),
transition(':leave', [
animate('100ms ease-out', style({
transform: 'translateY(25%)',
opacity: '0',
})),
]),
]),
]
session: BaseSession|null = null
savedState?: any
savedStateIsLive = false
@Input() zoom = 0
@Input() showSearchPanel = false
/** @hidden */
@ViewChild('content') content
/** @hidden */
@HostBinding('style.background-color') backgroundColor: string|null = null
/** @hidden */
@HostBinding('class.top-padded') topPadded: boolean
frontend?: Frontend
/** @hidden */
frontendIsReady = false
frontendReady = new Subject<void>()
size: ResizeEvent
/**
* Enables normall passthrough from session output to terminal input
*/
enablePassthrough = true
/**
* Enables receiving dynamic window/tab title provided by the shell
*/
enableDynamicTitle = true
alternateScreenActive = false
// Deps start
config: ConfigService
element: ElementRef
protected zone: NgZone
protected app: AppService
protected hostApp: HostAppService
protected hotkeys: HotkeysService
protected platform: PlatformService
protected terminalContainersService: TerminalFrontendService
protected notifications: NotificationsService
protected log: LogService
protected decorators: TerminalDecorator[] = []
protected contextMenuProviders: TabContextMenuItemProvider[]
protected hostWindow: HostWindowService
// Deps end
protected logger: Logger
protected output = new Subject<string>()
protected sessionChanged = new Subject<BaseSession|null>()
private bellPlayer: HTMLAudioElement
private termContainerSubscriptions = new SubscriptionContainer()
private allFocusModeSubscription: Subscription|null = null
private sessionHandlers = new SubscriptionContainer()
private sessionSupportsBracketedPaste = false
get input$ (): Observable<Buffer> {
if (!this.frontend) {
throw new Error('Frontend not ready')
}
return this.frontend.input$
}
get output$ (): Observable<string> { return this.output }
get resize$ (): Observable<ResizeEvent> {
if (!this.frontend) {
throw new Error('Frontend not ready')
}
return this.frontend.resize$
}
get alternateScreenActive$ (): Observable<boolean> {
if (!this.frontend) {
throw new Error('Frontend not ready')
}
return this.frontend.alternateScreenActive$
}
get frontendReady$ (): Observable<void> { return this.frontendReady }
get sessionChanged$ (): Observable<BaseSession|null> { return this.sessionChanged }
constructor (protected injector: Injector) {
super()
this.config = injector.get(ConfigService)
this.element = injector.get(ElementRef)
this.zone = injector.get(NgZone)
this.app = injector.get(AppService)
this.hostApp = injector.get(HostAppService)
this.hotkeys = injector.get(HotkeysService)
this.platform = injector.get(PlatformService)
this.terminalContainersService = injector.get(TerminalFrontendService)
this.notifications = injector.get(NotificationsService)
this.log = injector.get(LogService)
this.decorators = injector.get<any>(TerminalDecorator, null, InjectFlags.Optional) as TerminalDecorator[]
this.contextMenuProviders = injector.get<any>(TabContextMenuItemProvider, null, InjectFlags.Optional) as TabContextMenuItemProvider[]
this.hostWindow = injector.get(HostWindowService)
this.logger = this.log.create('baseTerminalTab')
this.setTitle('Terminal')
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, async hotkey => {
if (!this.hasFocus) {
return
}
switch (hotkey) {
case 'ctrl-c':
if (this.frontend?.getSelection()) {
this.frontend.copySelection()
this.frontend.clearSelection()
this.notifications.notice('Copied')
} else {
if (this.parent && this.parent instanceof SplitTabComponent && this.parent._allFocusMode) {
for (const tab of this.parent.getAllTabs()) {
if (tab instanceof BaseTerminalTabComponent) {
tab.sendInput('\x03')
}
}
} else {
this.sendInput('\x03')
}
}
break
case 'copy':
this.frontend?.copySelection()
this.frontend?.clearSelection()
this.notifications.notice('Copied')
break
case 'paste':
this.paste()
break
case 'select-all':
this.frontend?.selectAll()
break
case 'clear':
this.frontend?.clear()
break
case 'zoom-in':
this.zoomIn()
break
case 'zoom-out':
this.zoomOut()
break
case 'reset-zoom':
this.resetZoom()
break
case 'previous-word':
this.sendInput({
[Platform.Windows]: '\x1b[1;5D',
[Platform.macOS]: '\x1bb',
[Platform.Linux]: '\x1bb',
}[this.hostApp.platform])
break
case 'next-word':
this.sendInput({
[Platform.Windows]: '\x1b[1;5C',
[Platform.macOS]: '\x1bf',
[Platform.Linux]: '\x1bf',
}[this.hostApp.platform])
break
case 'delete-previous-word':
this.sendInput('\x1b\x7f')
break
case 'delete-next-word':
this.sendInput({
[Platform.Windows]: '\x1bd\x1b[3;5~',
[Platform.macOS]: '\x1bd',
[Platform.Linux]: '\x1bd',
}[this.hostApp.platform])
break
case 'search':
this.showSearchPanel = true
setImmediate(() => {
this.element.nativeElement.querySelector('.search-input').focus()
})
break
case 'pane-focus-all':
this.focusAllPanes()
break
case 'copy-current-path':
this.copyCurrentPath()
break
}
})
this.bellPlayer = document.createElement('audio')
this.bellPlayer.src = require('../bell.ogg').default
this.contextMenuProviders.sort((a, b) => a.weight - b.weight)
}
/** @hidden */
ngOnInit (): void {
this.focused$.subscribe(() => {
this.configure()
this.frontend?.focus()
})
this.frontend = this.terminalContainersService.getFrontend(this.session)
this.frontendReady$.pipe(first()).subscribe(() => {
this.onFrontendReady()
})
this.frontend.resize$.pipe(first()).subscribe(async ({ columns, rows }) => {
this.size = { columns, rows }
this.frontendReady.next()
this.config.enabledServices(this.decorators).forEach(decorator => {
try {
decorator.attach(this)
} catch (e) {
this.logger.warn('Decorator attach() throws', e)
}
})
setTimeout(() => {
this.session?.resize(columns, rows)
}, 1000)
this.session?.releaseInitialDataBuffer()
})
this.alternateScreenActive$.subscribe(x => {
this.alternateScreenActive = x
})
setImmediate(async () => {
if (this.hasFocus) {
await this.frontend!.attach(this.content.nativeElement)
this.frontend!.configure()
} else {
this.focused$.pipe(first()).subscribe(async () => {
await this.frontend!.attach(this.content.nativeElement)
this.frontend!.configure()
})
}
})
this.attachTermContainerHandlers()
this.configure()
setTimeout(() => {
this.output.subscribe(() => {
this.displayActivity()
})
}, 1000)
this.frontend.bell$.subscribe(() => {
if (this.config.store.terminal.bell === 'visual') {
this.frontend?.visualBell()
}
if (this.config.store.terminal.bell === 'audible') {
this.bellPlayer.play()
}
})
this.frontend.focus()
this.blurred$.subscribe(() => {
this.cancelFocusAllPanes()
})
}
protected onFrontendReady (): void {
this.frontendIsReady = true
if (this.savedState) {
this.frontend!.restoreState(this.savedState)
if (!this.savedStateIsLive) {
this.frontend!.write('\r\n\r\n')
this.frontend!.write(colors.bgWhite.black(' * ') + colors.bgBlackBright.white(' History restored '))
this.frontend!.write('\r\n\r\n')
}
}
}
async buildContextMenu (): Promise<MenuItemOptions[]> {
let items: MenuItemOptions[] = []
for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(this)))) {
items = items.concat(section)
items.push({ type: 'separator' })
}
items.splice(items.length - 1, 1)
return items
}
/**
* Feeds input into the active session
*/
sendInput (data: string|Buffer): void {
if (!(data instanceof Buffer)) {
data = Buffer.from(data, 'utf-8')
}
this.session?.write(data)
if (this.config.store.terminal.scrollOnInput) {
this.frontend?.scrollToBottom()
}
}
/**
* Feeds input into the terminal frontend
*/
write (data: string): void {
if (!this.frontend) {
throw new Error('Frontend not ready')
}
if (this.config.store.terminal.detectProgress) {
const percentageMatch = /(^|[^\d])(\d+(\.\d+)?)%([^\d]|$)/.exec(data)
if (!this.alternateScreenActive && percentageMatch) {
const percentage = percentageMatch[3] ? parseFloat(percentageMatch[2]) : parseInt(percentageMatch[2])
if (percentage > 0 && percentage <= 100) {
this.setProgress(percentage)
}
} else {
this.setProgress(null)
}
}
if (data.includes('\x1b[?2004h')) {
this.sessionSupportsBracketedPaste = true
}
if (data.includes('\x1b[?2004l')) {
this.sessionSupportsBracketedPaste = false
}
this.frontend.write(data)
}
async paste (): Promise<void> {
let data = this.platform.readClipboard()
if (this.config.store.terminal.bracketedPaste && this.sessionSupportsBracketedPaste) {
data = `\x1b[200~${data}\x1b[201~`
}
if (this.hostApp.platform === Platform.Windows) {
data = data.replace(/\r\n/g, '\r')
} else {
data = data.replace(/\n/g, '\r')
}
if (!this.alternateScreenActive) {
data = data.trim()
if (data.includes('\r') && this.config.store.terminal.warnOnMultilinePaste) {
const buttons = ['Paste', 'Cancel']
const result = (await this.platform.showMessageBox(
{
type: 'warning',
detail: data,
message: `Paste multiple lines?`,
buttons,
defaultId: 0,
}
)).response
if (result === 1) {
return
}
}
}
this.sendInput(data)
}
/**
* Applies the user settings to the terminal
*/
configure (): void {
this.frontend?.configure()
this.topPadded = this.hostApp.platform === Platform.macOS
&& this.config.store.appearance.frame === 'thin'
&& this.config.store.appearance.tabsLocation !== 'top'
if (this.config.store.terminal.background === 'colorScheme') {
if (this.config.store.terminal.colorScheme.background) {
this.backgroundColor = this.config.store.terminal.colorScheme.background
}
} else {
this.backgroundColor = null
}
}
zoomIn (): void {
this.zoom++
this.frontend?.setZoom(this.zoom)
}
zoomOut (): void {
this.zoom--
this.frontend?.setZoom(this.zoom)
}
resetZoom (): void {
this.zoom = 0
this.frontend?.setZoom(this.zoom)
}
focusAllPanes (): void {
if (this.allFocusModeSubscription) {
return
}
if (this.parent instanceof SplitTabComponent) {
const parent = this.parent
parent._allFocusMode = true
parent.layout()
this.allFocusModeSubscription = this.frontend?.input$.subscribe(data => {
for (const tab of parent.getAllTabs()) {
if (tab !== this && tab instanceof BaseTerminalTabComponent) {
tab.sendInput(data)
}
}
}) ?? null
}
}
cancelFocusAllPanes (): void {
if (!this.allFocusModeSubscription) {
return
}
if (this.parent instanceof SplitTabComponent) {
this.allFocusModeSubscription?.unsubscribe?.()
this.allFocusModeSubscription = null
this.parent._allFocusMode = false
this.parent.layout()
}
}
async copyCurrentPath (): Promise<void> {
let cwd: string|null = null
if (this.session?.supportsWorkingDirectory()) {
cwd = await this.session.getWorkingDirectory()
}
if (cwd) {
this.platform.setClipboard({ text: cwd })
this.notifications.notice('Copied')
} else {
this.notifications.error('Shell does not support current path detection')
}
}
/** @hidden */
ngOnDestroy (): void {
super.ngOnDestroy()
}
async destroy (): Promise<void> {
this.frontend?.detach(this.content.nativeElement)
this.frontend = undefined
this.content.nativeElement.remove()
this.detachTermContainerHandlers()
this.config.enabledServices(this.decorators).forEach(decorator => {
try {
decorator.detach(this)
} catch (e) {
this.logger.warn('Decorator attach() throws', e)
}
})
this.output.complete()
super.destroy()
if (this.session?.open) {
await this.session.destroy()
}
}
protected detachTermContainerHandlers (): void {
this.termContainerSubscriptions.cancelAll()
}
protected async handleRightClick (event: MouseEvent): Promise<void> {
event.preventDefault()
event.stopPropagation()
if (this.config.store.terminal.rightClick === 'menu') {
this.platform.popupContextMenu(await this.buildContextMenu(), event)
} else if (this.config.store.terminal.rightClick === 'paste') {
this.paste()
}
}
protected attachTermContainerHandlers (): void {
this.detachTermContainerHandlers()
if (!this.frontend) {
throw new Error('Frontend not ready')
}
const maybeConfigure = () => {
if (this.hasFocus) {
setTimeout(() => this.configure(), 250)
}
}
this.termContainerSubscriptions.subscribe(this.frontend.title$, title => this.zone.run(() => {
if (this.enableDynamicTitle) {
this.setTitle(title)
}
}))
this.termContainerSubscriptions.subscribe(this.focused$, () => this.frontend && (this.frontend.enableResizing = true))
this.termContainerSubscriptions.subscribe(this.blurred$, () => this.frontend && (this.frontend.enableResizing = false))
this.termContainerSubscriptions.subscribe(this.frontend.mouseEvent$, async event => {
if (event.type === 'mousedown') {
if (event.which === 1) {
this.cancelFocusAllPanes()
}
if (event.which === 2) {
if (this.config.store.terminal.pasteOnMiddleClick) {
this.paste()
}
event.preventDefault()
event.stopPropagation()
return
}
if (event.which === 3 || event.which === 1 && event.ctrlKey) {
this.handleRightClick(event)
return
}
}
if (event.type === 'mousewheel') {
let wheelDeltaY = 0
if ('wheelDeltaY' in event) {
wheelDeltaY = (event as MouseWheelEvent)['wheelDeltaY']
} else {
wheelDeltaY = (event as MouseWheelEvent)['deltaY']
}
if (event.altKey) {
event.preventDefault()
const delta = Math.round(wheelDeltaY / 50)
this.sendInput((delta > 0 ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta)))
}
}
})
this.termContainerSubscriptions.subscribe(this.frontend.input$, data => {
this.sendInput(data)
})
this.termContainerSubscriptions.subscribe(this.frontend.resize$, ({ columns, rows }) => {
this.logger.debug(`Resizing to ${columns}x${rows}`)
this.size = { columns, rows }
this.zone.run(() => {
if (this.session?.open) {
this.session.resize(columns, rows)
}
})
})
this.termContainerSubscriptions.subscribe(this.platform.displayMetricsChanged$, maybeConfigure)
this.termContainerSubscriptions.subscribe(this.hostWindow.windowMoved$, maybeConfigure)
}
setSession (session: BaseSession|null, destroyOnSessionClose = false): void {
if (session) {
if (this.session) {
this.setSession(null)
}
this.detachSessionHandlers()
this.session = session
this.attachSessionHandlers(destroyOnSessionClose)
} else {
this.detachSessionHandlers()
this.session = null
}
this.sessionChanged.next(session)
}
protected attachSessionHandler <T> (observable: Observable<T>, handler: (v: T) => void): void {
this.sessionHandlers.subscribe(observable, handler)
}
protected attachSessionHandlers (destroyOnSessionClose = false): void {
if (!this.session) {
throw new Error('Session not set')
}
// this.session.output$.bufferTime(10).subscribe((datas) => {
this.attachSessionHandler(this.session.output$, data => {
if (this.enablePassthrough) {
this.output.next(data)
this.write(data)
}
})
if (destroyOnSessionClose) {
this.attachSessionHandler(this.session.closed$, () => {
this.frontend?.destroy()
this.destroy()
})
}
this.attachSessionHandler(this.session.destroyed$, () => {
this.setSession(null)
})
}
protected detachSessionHandlers (): void {
this.sessionHandlers.cancelAll()
}
}

View File

@@ -0,0 +1,8 @@
import { TerminalColorScheme } from './interfaces'
/**
* Extend to add more terminal color schemes
*/
export abstract class TerminalColorSchemeProvider {
abstract getSchemes (): Promise<TerminalColorScheme[]>
}

View File

@@ -0,0 +1,12 @@
import type { MenuItemOptions } from 'tabby-core'
import { BaseTerminalTabComponent } from './baseTerminalTab.component'
/**
* Extend to add more terminal context menu items
* @deprecated
*/
export abstract class TerminalContextMenuItemProvider {
weight: number
abstract getItems (tab: BaseTerminalTabComponent): Promise<MenuItemOptions[]>
}

View File

@@ -0,0 +1,38 @@
import { Subscription } from 'rxjs'
import { BaseTerminalTabComponent } from './baseTerminalTab.component'
/**
* Extend to automatically run actions on new terminals
*/
export abstract class TerminalDecorator {
private smartSubscriptions = new Map<BaseTerminalTabComponent, Subscription[]>()
/**
* Called when a new terminal tab starts
*/
attach (terminal: BaseTerminalTabComponent): void { } // eslint-disable-line
/**
* Called before a terminal tab is destroyed.
* Make sure to call super()
*/
detach (terminal: BaseTerminalTabComponent): void {
for (const s of this.smartSubscriptions.get(terminal) ?? []) {
s.unsubscribe()
}
this.smartSubscriptions.delete(terminal)
}
/**
* Automatically cancel @subscription once detached from @terminal
*/
protected subscribeUntilDetached (terminal: BaseTerminalTabComponent, subscription?: Subscription): void {
if (!subscription) {
return
}
if (!this.smartSubscriptions.has(terminal)) {
this.smartSubscriptions.set(terminal, [])
}
this.smartSubscriptions.get(terminal)?.push(subscription)
}
}

View File

@@ -0,0 +1,12 @@
export interface ResizeEvent {
columns: number
rows: number
}
export interface TerminalColorScheme {
name: string
foreground: string
background: string
cursor: string
colors: string[]
}

BIN
tabby-terminal/src/bell.ogg Normal file

Binary file not shown.

39
tabby-terminal/src/cli.ts Normal file
View File

@@ -0,0 +1,39 @@
import shellEscape from 'shell-escape'
import { Injectable } from '@angular/core'
import { CLIHandler, CLIEvent, AppService, HostWindowService } from 'tabby-core'
import { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
@Injectable()
export class TerminalCLIHandler extends CLIHandler {
firstMatchOnly = true
priority = 0
constructor (
private app: AppService,
private hostWindow: HostWindowService,
) {
super()
}
async handle (event: CLIEvent): Promise<boolean> {
const op = event.argv._[0]
if (op === 'paste') {
let text = event.argv.text
if (event.argv.escape) {
text = shellEscape([text])
}
this.handlePaste(text)
return true
}
return false
}
private handlePaste (text: string) {
if (this.app.activeTab instanceof BaseTerminalTabComponent && this.app.activeTab.session) {
this.app.activeTab.sendInput(text)
this.hostWindow.bringToFront()
}
}
}

View File

@@ -0,0 +1,120 @@
h3.mb-3 Appearance
.row
.col-12.col-md-6
.form-line
.header
.title Font
.input-group.w-75
input.form-control.w-75(
type='text',
[ngbTypeahead]='fontAutocomplete',
[(ngModel)]='config.store.terminal.font',
(ngModelChange)='config.save()',
)
input.form-control.w-25(
type='number',
max='48',
[(ngModel)]='config.store.terminal.fontSize',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Enable font ligatures
toggle(
[(ngModel)]='config.store.terminal.ligatures',
(ngModelChange)='config.save()',
)
.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.ligatures && config.store.terminal.frontend == "xterm-webgl"') Ligatures are not supported by the WebGL frontend
.col-12.col-md-6
color-scheme-preview([scheme]='config.store.terminal.colorScheme', [fontPreview]='true')
.form-line
.header
.title Terminal background
.btn-group(
[(ngModel)]='config.store.terminal.background',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"theme"'
)
| From theme
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"colorScheme"'
)
| From color scheme
.form-line
.header
.title Cursor shape
.btn-group(
[(ngModel)]='config.store.terminal.cursor',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"block"'
)
| █
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"beam"'
)
| |
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"underline"'
)
| ▁
.form-line
.header
.title Blink cursor
toggle(
[(ngModel)]='config.store.terminal.cursorBlink',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Fallback font
.description A second font family used to display characters missing in the main font
input.form-control(
type='text',
[ngbTypeahead]='fontAutocomplete',
[(ngModel)]='config.store.terminal.fallbackFont',
(ngModelChange)='config.save()'
)
.form-line
.header
.title Custom CSS
textarea.form-control.mb-5(
[(ngModel)]='config.store.appearance.css',
(ngModelChange)='saveConfiguration()',
)

View File

@@ -0,0 +1,9 @@
color-scheme-preview {
flex-shrink: 0;
margin-bottom: 20px;
}
textarea {
font-family: 'Source Code Pro', monospace;
min-height: 120px;
}

View File

@@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Observable } from 'rxjs'
import { debounce } from 'utils-decorators/dist/cjs'
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
import { Component } from '@angular/core'
import { ConfigService, getCSSFontFamily, PlatformService } from 'tabby-core'
/** @hidden */
@Component({
template: require('./appearanceSettingsTab.component.pug'),
styles: [require('./appearanceSettingsTab.component.scss')],
})
export class AppearanceSettingsTabComponent {
fonts: string[] = []
constructor (
public config: ConfigService,
private platform: PlatformService,
) { }
async ngOnInit () {
this.fonts = await this.platform.listFonts()
}
fontAutocomplete = (text$: Observable<string>) => {
return text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map(query => this.fonts.filter(v => new RegExp(query, 'gi').test(v))),
map(list => Array.from(new Set(list))),
)
}
getPreviewFontFamily () {
return getCSSFontFamily(this.config.store)
}
@debounce(500)
saveConfiguration (requireRestart?: boolean) {
this.config.save()
if (requireRestart) {
this.config.requestRestart()
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
:host {
flex: auto;
display: flex;
flex-direction: column;
overflow: hidden;
&.top-padded {
padding-top: 20px;
}
&> .content {
flex: auto;
position: relative;
display: block;
overflow: hidden;
margin: 15px;
transition: opacity ease-out 0.25s;
opacity: 0;
div[style]:last-child {
background: black !important;
color: white !important;
}
}
}

View File

@@ -0,0 +1,15 @@
ng-template(#content)
.preview(
[style.width]='"100%"',
[style.background]='model',
)
input.form-control(type='text', [(ngModel)]='model', (ngModelChange)='onChange()', #input)
div(
[ngbPopover]='content',
[style.background]='model',
(click)='open()',
autoClose='outside',
container='body',
#popover='ngbPopover',
) {{ title }}

View File

@@ -0,0 +1,15 @@
div {
width: 32px;
height: 32px;
box-shadow: 0 1px 1px rgba(0,0,0,.5);
border: none;
margin: 5px 10px 5px 0;
border-radius: 2px;
text-align: center;
font-weight: bold;
font-size: 17px;
display: inline-block;
color: #fff;
line-height: 31px;
text-shadow: 0 1px 1px rgba(0,0,0,.5);
}

View File

@@ -0,0 +1,26 @@
import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
/** @hidden */
@Component({
selector: 'color-picker',
template: require('./colorPicker.component.pug'),
styles: [require('./colorPicker.component.scss')],
})
export class ColorPickerComponent {
@Input() model: string
@Input() title: string
@Output() modelChange = new EventEmitter<string>()
@ViewChild('popover') popover: NgbPopover
open (): void {
setImmediate(() => {
this.popover.open()
this.popover['_windowRef'].location.nativeElement.querySelector('input').focus()
})
}
onChange (): void {
this.modelChange.emit(this.model)
}
}

View File

@@ -0,0 +1,35 @@
.preview(
[style.font-family]='getPreviewFontFamily()',
[style.font-size]='(fontPreview ? config.store.terminal.fontSize : 11) + "px"',
[style.background-color]='scheme.background',
[style.color]='scheme.foreground',
[style.font-feature-settings]='\'"liga" \' + config.store.terminal.ligatures ? 1 : 0',
[style.font-variant-ligatures]='config.store.terminal.ligatures ? "initial" : "none"',
)
div
span([style.color]='scheme.colors[2]') john
span([style.color]='scheme.colors[6]') @
span([style.color]='scheme.colors[4]') doe-pc
strong([style.color]='scheme.colors[1]') $
span ls
span([style.background-color]='scheme.cursor') &nbsp;
div
span -rwxr-xr-x 1 root
strong([style.color]='scheme.colors[3]') Documents
div
span -rwxr-xr-x 1 root
strong([style.color]='scheme.colors[5]') Downloads
div
span -rwxr-xr-x 1 root
strong([style.color]='scheme.colors[13]') Pictures
div
span -rwxr-xr-x 1 root
strong([style.color]='scheme.colors[12]') Music
div(*ngIf='fontPreview')
span -rwxr-xr-x 1 root
span([style.color]='scheme.colors[2]') 実行可能ファイル
div(*ngIf='fontPreview')
span -rwxr-xr-x 1 root
span([style.color]='scheme.colors[6]') sym
span ->
span([style.color]='scheme.colors[1]') link

View File

@@ -0,0 +1,15 @@
:host {
display: block;
}
.preview {
margin-top: 10px;
margin-left: 10px;
padding: 5px 10px;
border-radius: 4px;
overflow: hidden;
span {
white-space: pre;
}
}

View File

@@ -0,0 +1,29 @@
import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'
import { BaseComponent, ConfigService, getCSSFontFamily } from 'tabby-core'
import { TerminalColorScheme } from '../api/interfaces'
/** @hidden */
@Component({
selector: 'color-scheme-preview',
template: require('./colorSchemePreview.component.pug'),
styles: [require('./colorSchemePreview.component.scss')],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ColorSchemePreviewComponent extends BaseComponent {
@Input() scheme: TerminalColorScheme
@Input() fontPreview = false
constructor (
public config: ConfigService,
changeDetector: ChangeDetectorRef,
) {
super()
this.subscribeUntilDestroyed(config.changed$, () => {
changeDetector.markForCheck()
})
}
getPreviewFontFamily (): string {
return getCSSFontFamily(this.config.store)
}
}

View File

@@ -0,0 +1,96 @@
.head
h3.mb-3 Current color scheme
.d-flex.align-items-center(*ngIf='!editing')
span {{getCurrentSchemeName()}}
.mr-auto
.btn-toolbar
button.btn.btn-secondary((click)='editScheme()')
i.fas.fa-pen
span Edit
.mr-1
button.btn.btn-danger(
(click)='deleteScheme(config.store.terminal.colorScheme)',
*ngIf='currentCustomScheme'
)
i.fas.fa-trash
span Delete
div(*ngIf='editing')
.form-group
label Name
input.form-control(type='text', [(ngModel)]='config.store.terminal.colorScheme.name')
.form-group
color-picker(
[(model)]='config.store.terminal.colorScheme.foreground',
(modelChange)='config.save()',
title='FG',
)
color-picker(
[(model)]='config.store.terminal.colorScheme.background',
(modelChange)='config.save()',
title='BG',
)
color-picker(
[(model)]='config.store.terminal.colorScheme.cursor',
(modelChange)='config.save()',
title='CU',
)
color-picker(
*ngFor='let _ of config.store.terminal.colorScheme.colors; let idx = index; trackBy: colorsTrackBy',
[(model)]='config.store.terminal.colorScheme.colors[idx]',
(modelChange)='config.save()',
[title]='idx',
)
color-scheme-preview([scheme]='config.store.terminal.colorScheme')
.btn-toolbar.d-flex.mt-2(*ngIf='editing')
.mr-auto
button.btn.btn-primary((click)='saveScheme()')
i.fas.fa-check
span Save
.mr-1
button.btn.btn-secondary((click)='cancelEditing()')
i.fas.fa-times
span Cancel
hr.mt-3.mb-4
.input-group.mb-3
.input-group-prepend
.input-group-text
i.fas.fa-fw.fa-search
input.form-control(type='search', placeholder='Search color schemes', [(ngModel)]='filter')
.body
.list-group-light.mb-3
ng-container(*ngFor='let scheme of allColorSchemes')
.list-group-item.list-group-item-action(
[hidden]='filter && !scheme.name.toLowerCase().includes(filter.toLowerCase())',
(click)='selectScheme(scheme)',
[class.active]='(currentCustomScheme || currentStockScheme) === scheme'
)
.d-flex.w-100.align-items-center
i.fas.fa-fw([class.fa-check]='(currentCustomScheme || currentStockScheme) === scheme')
.ml-2
.mr-auto
span {{scheme.name}}
.badge.badge-info.ml-2(*ngIf='customColorSchemes.includes(scheme)') Custom
div
.d-flex
.swatch(
*ngFor='let index of colorIndexes.slice(0, 8)',
[style.background-color]='scheme.colors[index]'
)
.d-flex
.swatch(
*ngFor='let index of colorIndexes.slice(8, 16)',
[style.background-color]='scheme.colors[index]'
)
color-scheme-preview([scheme]='scheme')

View File

@@ -0,0 +1,22 @@
.head {
flex: none;
}
.body {
overflow: auto;
flex: auto;
min-height: 0;
}
.swatch {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 3px;
margin-bottom: 3px;
box-shadow: 0 1px 1px rgba(0, 0, 0, .5);
}
.list-group-item color-scheme-preview {
margin-left: 14px;
}

View File

@@ -0,0 +1,104 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import deepEqual from 'deep-equal'
import { Component, Inject, Input, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'
import { ConfigService, PlatformService } from 'tabby-core'
import { TerminalColorSchemeProvider } from '../api/colorSchemeProvider'
import { TerminalColorScheme } from '../api/interfaces'
/** @hidden */
@Component({
template: require('./colorSchemeSettingsTab.component.pug'),
styles: [require('./colorSchemeSettingsTab.component.scss')],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ColorSchemeSettingsTabComponent {
@Input() stockColorSchemes: TerminalColorScheme[] = []
@Input() customColorSchemes: TerminalColorScheme[] = []
@Input() allColorSchemes: TerminalColorScheme[] = []
@Input() filter = ''
@Input() editing = false
colorIndexes = [...new Array(16).keys()]
currentStockScheme: TerminalColorScheme|null = null
currentCustomScheme: TerminalColorScheme|null = null
constructor (
@Inject(TerminalColorSchemeProvider) private colorSchemeProviders: TerminalColorSchemeProvider[],
private changeDetector: ChangeDetectorRef,
private platform: PlatformService,
public config: ConfigService,
) { }
async ngOnInit () {
this.stockColorSchemes = (await Promise.all(this.config.enabledServices(this.colorSchemeProviders).map(x => x.getSchemes()))).reduce((a, b) => a.concat(b))
this.stockColorSchemes.sort((a, b) => a.name.localeCompare(b.name))
this.customColorSchemes = this.config.store.terminal.customColorSchemes
this.changeDetector.markForCheck()
this.update()
}
ngOnChanges () {
this.update()
}
selectScheme (scheme: TerminalColorScheme) {
this.config.store.terminal.colorScheme = { ...scheme }
this.config.save()
this.cancelEditing()
this.update()
}
update () {
this.currentCustomScheme = this.findMatchingScheme(this.config.store.terminal.colorScheme, this.customColorSchemes)
this.currentStockScheme = this.findMatchingScheme(this.config.store.terminal.colorScheme, this.stockColorSchemes)
this.allColorSchemes = this.customColorSchemes.concat(this.stockColorSchemes)
this.changeDetector.markForCheck()
}
editScheme () {
this.editing = true
}
saveScheme () {
this.customColorSchemes = this.customColorSchemes.filter(x => x.name !== this.config.store.terminal.colorScheme.name)
this.customColorSchemes.push(this.config.store.terminal.colorScheme)
this.config.store.terminal.customColorSchemes = this.customColorSchemes
this.config.save()
this.cancelEditing()
this.update()
}
cancelEditing () {
this.editing = false
}
async deleteScheme (scheme: TerminalColorScheme) {
if ((await this.platform.showMessageBox(
{
type: 'warning',
message: `Delete "${scheme.name}"?`,
buttons: ['Keep', 'Delete'],
defaultId: 1,
}
)).response === 1) {
this.customColorSchemes = this.customColorSchemes.filter(x => x.name !== scheme.name)
this.config.store.terminal.customColorSchemes = this.customColorSchemes
this.config.save()
this.update()
}
}
getCurrentSchemeName () {
return (this.currentCustomScheme ?? this.currentStockScheme)?.name ?? 'Custom'
}
findMatchingScheme (scheme: TerminalColorScheme, schemes: TerminalColorScheme[]) {
return schemes.find(x => deepEqual(x, scheme)) ?? null
}
colorsTrackBy (index) {
return index
}
}

View File

@@ -0,0 +1,54 @@
input.search-input.form-control(
type='text',
[(ngModel)]='query',
(ngModelChange)='onQueryChange()',
[class.text-danger]='notFound',
(click)='$event.stopPropagation()',
(keyup.enter)='findPrevious()',
(keyup.esc)='close.emit()',
placeholder='Search...'
)
button.btn.btn-link(
(click)='findPrevious()',
ngbTooltip='Search up',
placement='bottom'
)
i.fa.fa-fw.fa-arrow-up
button.btn.btn-link(
(click)='findNext()',
ngbTooltip='Search down',
placement='bottom'
)
i.fa.fa-fw.fa-arrow-down
.mr-2
button.btn.btn-link(
(click)='options.caseSensitive = !options.caseSensitive; saveSearchOptions()',
[class.active]='options.caseSensitive',
ngbTooltip='Case sensitivity',
placement='bottom'
)
i.fa.fa-fw.fa-font
button.btn.btn-link(
(click)='options.regex = !options.regex; saveSearchOptions()',
[class.active]='options.regex',
ngbTooltip='Regular expression',
placement='bottom'
)
i.fa.fa-fw.fa-asterisk
button.btn.btn-link(
(click)='options.wholeWord = !options.wholeWord; saveSearchOptions()',
[class.active]='options.wholeWord',
ngbTooltip='Whole word',
placement='bottom'
)
i.fa.fa-fw.fa-text-width
.mr-2
button.btn.btn-link((click)='close.emit()')
i.fa.fa-fw.fa-times

View File

@@ -0,0 +1,17 @@
:host {
position: fixed;
width: 400px;
right: 50px;
z-index: 5;
padding: 10px;
border-radius: 0 0 3px 3px;
background: rgba(0, 0, 0, .95);
border: 1px solid rgba(0, 0, 0, .5);
border-top: 0;
display: flex;
button {
padding: 0 6px;
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,58 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { Frontend, SearchOptions } from '../frontends/frontend'
import { ConfigService, NotificationsService } from 'tabby-core'
@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
options: SearchOptions = {
incremental: true,
...this.config.store.terminal.searchOptions,
}
@Output() close = new EventEmitter()
constructor (
private notifications: NotificationsService,
public config: ConfigService,
) { }
onQueryChange (): void {
this.notFound = false
this.findPrevious(true)
}
findNext (incremental = false): void {
if (!this.query) {
return
}
if (!this.frontend.findNext(this.query, { ...this.options, incremental: incremental || undefined })) {
this.notFound = true
this.notifications.notice('Not found')
}
}
findPrevious (incremental = false): void {
if (!this.query) {
return
}
if (!this.frontend.findPrevious(this.query, { ...this.options, incremental: incremental || undefined })) {
this.notFound = true
this.notifications.notice('Not found')
}
}
saveSearchOptions (): void {
this.config.store.terminal.searchOptions.regex = this.options.regex
this.config.store.terminal.searchOptions.caseSensitive = this.options.caseSensitive
this.config.store.terminal.searchOptions.wholeWord = this.options.wholeWord
this.config.save()
}
}

View File

@@ -0,0 +1,160 @@
h3.mb-3 Terminal
.form-line(*ngIf='hostApp.platform !== Platform.Web')
.header
.title Frontend
.description Switches terminal frontend implementation (experimental)
select.form-control(
[(ngModel)]='config.store.terminal.frontend',
(ngModelChange)='config.save()',
)
option(value='hterm') hterm
option(value='xterm') xterm
option(value='xterm-webgl') xterm (WebGL)
.form-line
.header
.title Terminal bell
.btn-group(
[(ngModel)]='config.store.terminal.bell',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"off"'
)
| Off
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"visual"'
)
| Visual
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
[value]='"audible"'
)
| Audible
.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.bell != "audible" && (config.store.terminal.profile || "").startsWith("wsl")')
.mr-auto WSL terminal bell can only be muted via Volume Mixer
button.btn.btn-secondary((click)='openWSLVolumeMixer()') Show Mixer
.form-line
.header
.title Right click
.btn-group(
[(ngModel)]='config.store.terminal.rightClick',
(ngModelChange)='config.save()',
ngbRadioGroup
)
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
value='off'
)
| Off
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
value='menu'
)
| Context menu
label.btn.btn-secondary(ngbButtonLabel)
input(
type='radio',
ngbButton,
value='paste'
)
| Paste
.form-line
.header
.title Paste on middle-click
toggle(
[(ngModel)]='config.store.terminal.pasteOnMiddleClick',
(ngModelChange)='config.save()',
)
.form-line(*ngIf='hostApp.platform !== Platform.Web')
.header
.title Auto-open a terminal on app start
toggle(
[(ngModel)]='config.store.terminal.autoOpen',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Restore terminal tabs on app start
toggle(
[(ngModel)]='config.store.terminal.recoverTabs',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Bracketed paste (requires shell support)
.description Prevents accidental execution of pasted commands
toggle(
[(ngModel)]='config.store.terminal.bracketedPaste',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Copy on select
toggle(
[(ngModel)]='config.store.terminal.copyOnSelect',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Scroll on input
.description Scrolls the terminal to the bottom on user input
toggle(
[(ngModel)]='config.store.terminal.scrollOnInput',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Use Alt key as the Meta key
.description Lets the shell handle Meta key instead of OS
toggle(
[(ngModel)]='config.store.terminal.altIsMeta',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Word separators
.description Double-click selection will stop at these characters
input.form-control(
type='text',
placeholder=' ()[]{}\'"',
[(ngModel)]='config.store.terminal.wordSeparator',
(ngModelChange)='config.save()',
)
.form-line
.header
.title Warn on multi-line paste
.description Show a confirmation box when pasting multiple lines
toggle(
[(ngModel)]='config.store.terminal.warnOnMultilinePaste',
(ngModelChange)='config.save()',
)

View File

@@ -0,0 +1,21 @@
import { Component } from '@angular/core'
import { ConfigService, HostAppService, Platform, PlatformService } from 'tabby-core'
/** @hidden */
@Component({
template: require('./terminalSettingsTab.component.pug'),
})
export class TerminalSettingsTabComponent {
Platform = Platform
constructor (
public config: ConfigService,
public hostApp: HostAppService,
private platform: PlatformService,
) { }
openWSLVolumeMixer (): void {
this.platform.openPath('sndvol.exe')
this.platform.exec('wsl.exe', ['tput', 'bel'])
}
}

View File

@@ -0,0 +1,188 @@
import { ConfigProvider, Platform } from 'tabby-core'
/** @hidden */
export class TerminalConfigProvider extends ConfigProvider {
defaults = {
terminal: {
frontend: 'xterm',
fontSize: 14,
fallbackFont: null,
linePadding: 0,
bell: 'off',
bracketedPaste: false,
background: 'theme',
ligatures: false,
cursor: 'block',
cursorBlink: true,
hideTabIndex: false,
hideCloseButton: false,
rightClick: 'menu',
pasteOnMiddleClick: true,
copyOnSelect: false,
scrollOnInput: true,
altIsMeta: false,
wordSeparator: ' ()[]{}\'"',
colorScheme: {
__nonStructural: true,
name: 'Material',
foreground: '#eceff1',
background: 'rgba(38, 50, 56, 1)',
selection: null,
cursor: '#FFCC00',
colors: [
'#000000',
'#D62341',
'#9ECE58',
'#FAED70',
'#396FE2',
'#BB80B3',
'#2DDAFD',
'#d0d0d0',
'rgba(255, 255, 255, 0.2)',
'#FF5370',
'#C3E88D',
'#FFCB6B',
'#82AAFF',
'#C792EA',
'#89DDFF',
'#ffffff',
],
},
customColorSchemes: [],
warnOnMultilinePaste: true,
searchRegexAlwaysEnabled: false,
searchOptions: {
regex: false,
wholeWord: false,
caseSensitive: false,
},
detectProgress: true,
scrollbackLines: 25000,
},
}
platformDefaults = {
[Platform.macOS]: {
terminal: {
font: 'Menlo',
},
hotkeys: {
'ctrl-c': ['Ctrl-C'],
copy: [
'⌘-C',
],
paste: [
'⌘-V',
],
clear: [
'⌘-K',
],
'select-all': ['⌘-A'],
'zoom-in': [
'⌘-=',
'⌘-Shift-=',
],
'zoom-out': [
'⌘--',
'⌘-Shift--',
],
'reset-zoom': [
'⌘-0',
],
home: ['⌘-Left', 'Home'],
end: ['⌘-Right', 'End'],
'previous-word': ['⌥-Left'],
'next-word': ['⌥-Right'],
'delete-previous-word': ['⌥-Backspace'],
'delete-next-word': ['⌥-Delete'],
search: [
'⌘-F',
],
'pane-focus-all': [
'⌘-Shift-I',
],
},
},
[Platform.Windows]: {
terminal: {
font: 'Consolas',
rightClick: 'paste',
pasteOnMiddleClick: false,
copyOnSelect: true,
},
hotkeys: {
'ctrl-c': ['Ctrl-C'],
copy: [
'Ctrl-Shift-C',
],
paste: [
'Ctrl-Shift-V',
],
'select-all': ['Ctrl-Shift-A'],
clear: [],
'zoom-in': [
'Ctrl-=',
'Ctrl-Shift-=',
],
'zoom-out': [
'Ctrl--',
'Ctrl-Shift--',
],
'reset-zoom': [
'Ctrl-0',
],
home: ['Home'],
end: ['End'],
'previous-word': ['Ctrl-Left'],
'next-word': ['Ctrl-Right'],
'delete-previous-word': ['Ctrl-Backspace'],
'delete-next-word': ['Ctrl-Delete'],
search: [
'Ctrl-Shift-F',
],
'pane-focus-all': [
'Ctrl-Shift-I',
],
},
},
[Platform.Linux]: {
terminal: {
font: 'Liberation Mono',
},
hotkeys: {
'ctrl-c': ['Ctrl-C'],
copy: [
'Ctrl-Shift-C',
],
paste: [
'Ctrl-Shift-V',
],
'select-all': ['Ctrl-Shift-A'],
clear: [],
'zoom-in': [
'Ctrl-=',
'Ctrl-Shift-=',
],
'zoom-out': [
'Ctrl--',
'Ctrl-Shift--',
],
'reset-zoom': [
'Ctrl-0',
],
home: ['Home'],
end: ['End'],
'previous-word': ['Ctrl-Left'],
'next-word': ['Ctrl-Right'],
'delete-previous-word': ['Ctrl-Backspace'],
'delete-next-word': ['Ctrl-Delete'],
search: [
'Ctrl-Shift-F',
],
'pane-focus-all': [
'Ctrl-Shift-I',
],
},
},
}
}

View File

@@ -0,0 +1,132 @@
import { Injectable } from '@angular/core'
import { TerminalDecorator } from '../api/decorator'
import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component'
import { PlatformService } from 'tabby-core'
/** @hidden */
@Injectable()
export class DebugDecorator extends TerminalDecorator {
constructor (
private platform: PlatformService,
) {
super()
}
attach (terminal: BaseTerminalTabComponent): void {
let sessionOutputBuffer = ''
const bufferLength = 8192
this.subscribeUntilDetached(terminal, terminal.session!.output$.subscribe(data => {
sessionOutputBuffer += data
if (sessionOutputBuffer.length > bufferLength) {
sessionOutputBuffer = sessionOutputBuffer.substring(sessionOutputBuffer.length - bufferLength)
}
}))
terminal.addEventListenerUntilDestroyed(terminal.content.nativeElement, 'keyup', (e: KeyboardEvent) => {
// Ctrl-Shift-Alt-1
if (e.which === 49 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doSaveState(terminal)
}
// Ctrl-Shift-Alt-2
if (e.which === 50 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doLoadState(terminal)
}
// Ctrl-Shift-Alt-3
if (e.which === 51 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doCopyState(terminal)
}
// Ctrl-Shift-Alt-4
if (e.which === 52 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doPasteState(terminal)
}
// Ctrl-Shift-Alt-5
if (e.which === 53 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doSaveOutput(sessionOutputBuffer)
}
// Ctrl-Shift-Alt-6
if (e.which === 54 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doLoadOutput(terminal)
}
// Ctrl-Shift-Alt-7
if (e.which === 55 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doCopyOutput(sessionOutputBuffer)
}
// Ctrl-Shift-Alt-8
if (e.which === 56 && e.ctrlKey && e.shiftKey && e.altKey) {
this.doPasteOutput(terminal)
}
})
}
private async loadFile (): Promise<string|null> {
const transfer = await this.platform.startUpload()
if (!transfer.length) {
return null
}
const data = await transfer[0].readAll()
transfer[0].close()
return data.toString()
}
private async saveFile (content: string, name: string) {
const data = Buffer.from(content)
const transfer = await this.platform.startDownload(name, data.length)
if (transfer) {
transfer.write(data)
transfer.close()
}
}
private doSaveState (terminal: BaseTerminalTabComponent) {
this.saveFile(terminal.frontend!.saveState(), 'state.txt')
}
private async doCopyState (terminal: BaseTerminalTabComponent) {
const data = '```' + JSON.stringify(terminal.frontend!.saveState()) + '```'
this.platform.setClipboard({ text: data })
}
private async doLoadState (terminal: BaseTerminalTabComponent) {
const data = await this.loadFile()
if (data) {
terminal.frontend!.restoreState(data)
}
}
private async doPasteState (terminal: BaseTerminalTabComponent) {
let data = this.platform.readClipboard()
if (data) {
if (data.startsWith('`')) {
data = data.substring(3, data.length - 3)
}
terminal.frontend!.restoreState(JSON.parse(data))
}
}
private doSaveOutput (buffer: string) {
this.saveFile(buffer, 'output.txt')
}
private async doCopyOutput (buffer: string) {
const data = '```' + JSON.stringify(buffer) + '```'
this.platform.setClipboard({ text: data })
}
private async doLoadOutput (terminal: BaseTerminalTabComponent) {
const data = await this.loadFile()
if (data) {
terminal.frontend?.write(data)
}
}
private async doPasteOutput (terminal: BaseTerminalTabComponent) {
let data = this.platform.readClipboard()
if (data) {
if (data.startsWith('`')) {
data = data.substring(3, data.length - 3)
}
terminal.frontend?.write(JSON.parse(data))
}
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core'
import { TerminalDecorator } from '../api/decorator'
import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component'
/** @hidden */
@Injectable()
export class PathDropDecorator extends TerminalDecorator {
attach (terminal: BaseTerminalTabComponent): void {
setTimeout(() => {
this.subscribeUntilDetached(terminal, terminal.frontend?.dragOver$.subscribe(event => {
event.preventDefault()
}))
this.subscribeUntilDetached(terminal, terminal.frontend?.drop$.subscribe((event: DragEvent) => {
for (const file of event.dataTransfer!.files as any) {
this.injectPath(terminal, file.path)
}
event.preventDefault()
}))
})
}
private injectPath (terminal: BaseTerminalTabComponent, path: string) {
if (path.includes(' ')) {
path = `"${path}"`
}
path = path.replace(/\\/g, '\\\\')
terminal.sendInput(path + ' ')
}
}

View File

@@ -0,0 +1,224 @@
import colors from 'ansi-colors'
import * as ZModem from 'zmodem.js'
import { Observable } from 'rxjs'
import { filter, first } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { TerminalDecorator } from '../api/decorator'
import { BaseTerminalTabComponent } from '../api/baseTerminalTab.component'
import { LogService, Logger, HotkeysService, PlatformService, FileUpload } from 'tabby-core'
const SPACER = ' '
/** @hidden */
@Injectable()
export class ZModemDecorator extends TerminalDecorator {
private logger: Logger
private activeSession: any = null
private cancelEvent: Observable<any>
constructor (
log: LogService,
hotkeys: HotkeysService,
private platform: PlatformService,
) {
super()
this.logger = log.create('zmodem')
this.cancelEvent = hotkeys.hotkey$.pipe(filter(x => x === 'ctrl-c'))
}
attach (terminal: BaseTerminalTabComponent): void {
const sentry = new ZModem.Sentry({
to_terminal: data => {
if (!terminal.enablePassthrough) {
terminal.write(data)
}
},
sender: data => terminal.session!.write(Buffer.from(data)),
on_detect: async detection => {
try {
terminal.enablePassthrough = false
await this.process(terminal, detection)
} finally {
terminal.enablePassthrough = true
}
},
on_retract: () => {
this.showMessage(terminal, 'transfer cancelled')
},
})
setTimeout(() => {
this.attachToSession(sentry, terminal)
this.subscribeUntilDetached(terminal, terminal.sessionChanged$.subscribe(() => {
this.attachToSession(sentry, terminal)
}))
})
}
private attachToSession (sentry, terminal) {
if (!terminal.session) {
return
}
this.subscribeUntilDetached(terminal, terminal.session.binaryOutput$.subscribe(data => {
const chunkSize = 1024
for (let i = 0; i <= Math.floor(data.length / chunkSize); i++) {
try {
sentry.consume(Buffer.from(data.slice(i * chunkSize, (i + 1) * chunkSize)))
} catch (e) {
this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + e)
this.logger.error('protocol error', e)
this.activeSession.abort()
this.activeSession = null
terminal.enablePassthrough = true
return
}
}
}))
}
private async process (terminal, detection): Promise<void> {
this.showMessage(terminal, colors.bgBlue.black(' ZMODEM ') + ' Session started')
this.showMessage(terminal, '------------------------')
const zsession = detection.confirm()
this.activeSession = zsession
this.logger.info('new session', zsession)
if (zsession.type === 'send') {
const transfers = await this.platform.startUpload({ multiple: true })
let filesRemaining = transfers.length
let sizeRemaining = transfers.reduce((a, b) => a + b.getSize(), 0)
for (const transfer of transfers) {
await this.sendFile(terminal, zsession, transfer, filesRemaining, sizeRemaining)
filesRemaining--
sizeRemaining -= transfer.getSize()
}
this.activeSession = null
await zsession.close()
} else {
zsession.on('offer', xfer => {
this.receiveFile(terminal, xfer, zsession)
})
zsession.start()
await new Promise(resolve => zsession.on('session_end', resolve))
this.activeSession = null
}
}
private async receiveFile (terminal, xfer, zsession) {
const details: {
name: string,
size: number,
} = xfer.get_details()
this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + details.name, true)
this.logger.info('offered', xfer)
const transfer = await this.platform.startDownload(details.name, details.size)
if (!transfer) {
this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + details.name)
xfer.skip()
return
}
let canceled = false
const cancelSubscription = this.cancelEvent.subscribe(() => {
if (terminal.hasFocus) {
try {
zsession._skip()
} catch {}
canceled = true
}
})
try {
await Promise.race([
xfer.accept({
on_input: chunk => {
if (canceled) {
return
}
transfer.write(Buffer.from(chunk))
this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / details.size).toString().padStart(3, ' ') + '% ') + ' ' + details.name, true)
},
}),
this.cancelEvent.pipe(first()).toPromise(),
])
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (canceled) {
transfer.cancel()
this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + details.name)
} else {
transfer.close()
this.showMessage(terminal, colors.bgGreen.black(' Received ') + ' ' + details.name)
}
} catch {
this.showMessage(terminal, colors.bgRed.black(' Error ') + ' ' + details.name)
}
cancelSubscription.unsubscribe()
}
private async sendFile (terminal, zsession, transfer: FileUpload, filesRemaining, sizeRemaining) {
const offer = {
name: transfer.getName(),
size: transfer.getSize(),
mode: 0o755,
files_remaining: filesRemaining,
bytes_remaining: sizeRemaining,
}
this.logger.info('offering', offer)
this.showMessage(terminal, colors.bgYellow.black(' Offered ') + ' ' + offer.name, true)
const xfer = await zsession.send_offer(offer)
if (xfer) {
let canceled = false
const cancelSubscription = this.cancelEvent.subscribe(() => {
if (terminal.hasFocus) {
canceled = true
}
})
while (true) {
const chunk = await transfer.read()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (canceled || !chunk.length) {
break
}
await xfer.send(chunk)
this.showMessage(terminal, colors.bgYellow.black(' ' + Math.round(100 * transfer.getCompletedBytes() / offer.size).toString().padStart(3, ' ') + '% ') + offer.name, true)
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (canceled) {
transfer.cancel()
} else {
transfer.close()
}
await xfer.end()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (canceled) {
this.showMessage(terminal, colors.bgRed.black(' Canceled ') + ' ' + offer.name)
} else {
this.showMessage(terminal, colors.bgGreen.black(' Sent ') + ' ' + offer.name)
}
cancelSubscription.unsubscribe()
} else {
transfer.cancel()
this.showMessage(terminal, colors.bgRed.black(' Rejected ') + ' ' + offer.name)
this.logger.warn('rejected by the other side')
}
}
private showMessage (terminal, msg: string, overwrite = false) {
terminal.write(Buffer.from(`\r${msg}${SPACER}`))
if (!overwrite) {
terminal.write(Buffer.from('\r\n'))
}
}
}

Binary file not shown.

View File

@@ -0,0 +1,79 @@
import { Injector } from '@angular/core'
import { Observable, Subject, AsyncSubject, ReplaySubject, BehaviorSubject } from 'rxjs'
import { ResizeEvent } from '../api/interfaces'
export interface SearchOptions {
regex?: boolean
wholeWord?: boolean
caseSensitive?: boolean
incremental?: true
}
/**
* Extend to add support for a different VT frontend implementation
*/
export abstract class Frontend {
enableResizing = true
protected ready = new AsyncSubject<void>()
protected title = new ReplaySubject<string>(1)
protected alternateScreenActive = new BehaviorSubject<boolean>(false)
protected mouseEvent = new Subject<MouseEvent>()
protected bell = new Subject<void>()
protected contentUpdated = new Subject<void>()
protected input = new Subject<Buffer>()
protected resize = new ReplaySubject<ResizeEvent>(1)
protected dragOver = new Subject<DragEvent>()
protected drop = new Subject<DragEvent>()
get ready$ (): Observable<void> { return this.ready }
get title$ (): Observable<string> { return this.title }
get alternateScreenActive$ (): Observable<boolean> { return this.alternateScreenActive }
get mouseEvent$ (): Observable<MouseEvent> { return this.mouseEvent }
get bell$ (): Observable<void> { return this.bell }
get contentUpdated$ (): Observable<void> { return this.contentUpdated }
get input$ (): Observable<Buffer> { return this.input }
get resize$ (): Observable<ResizeEvent> { return this.resize }
get dragOver$ (): Observable<DragEvent> { return this.dragOver }
get drop$ (): Observable<DragEvent> { return this.drop }
constructor (protected injector: Injector) { }
destroy (): void {
for (const o of [
this.ready,
this.title,
this.alternateScreenActive,
this.mouseEvent,
this.bell,
this.contentUpdated,
this.input,
this.resize,
this.dragOver,
this.drop,
]) {
o.complete()
}
}
abstract attach (host: HTMLElement): Promise<void>
detach (host: HTMLElement): void { } // eslint-disable-line
abstract getSelection (): string
abstract copySelection (): void
abstract selectAll (): void
abstract clearSelection (): void
abstract focus (): void
abstract write (data: string): void
abstract clear (): void
abstract visualBell (): void
abstract scrollToBottom (): void
abstract configure (): void
abstract setZoom (zoom: number): void
abstract findNext (term: string, searchOptions?: SearchOptions): boolean
abstract findPrevious (term: string, searchOptions?: SearchOptions): boolean
abstract saveState (): any
abstract restoreState (state: string): void
}

View File

@@ -0,0 +1,120 @@
/* eslint-disable */
/** @hidden */
export const hterm = require('hterm-umdjs')
hterm.hterm.defaultStorage = new hterm.lib.Storage.Memory()
/** @hidden */
export const preferenceManager = new hterm.hterm.PreferenceManager('default')
hterm.hterm.VT.ESC['k'] = function (parseState) {
parseState.resetArguments()
function parseOSC (ps) {
if (!this.parseUntilStringTerminator_(ps) || ps.func === parseOSC) {
return
}
this.terminal.setWindowTitle(ps.args[0])
}
parseState.func = parseOSC
}
preferenceManager.set('background-color', '#1D272D')
preferenceManager.set('color-palette-overrides', {
0: '#1D272D',
})
hterm.hterm.Terminal.prototype.showOverlay = () => null
hterm.hterm.Terminal.prototype.setCSS = function (css) {
const doc = this.scrollPort_.document_
if (!doc.querySelector('#user-css')) {
const node = doc.createElement('style')
node.id = 'user-css'
doc.head.appendChild(node)
}
doc.querySelector('#user-css').innerText = css
}
const oldCharWidthDisregardAmbiguous = hterm.lib.wc.charWidthDisregardAmbiguous
hterm.lib.wc.charWidthDisregardAmbiguous = codepoint => {
if ((codepoint >= 0x1f300 && codepoint <= 0x1f64f) ||
(codepoint >= 0x1f680 && codepoint <= 0x1f6ff)) {
return 2
}
return oldCharWidthDisregardAmbiguous(codepoint)
}
hterm.hterm.Terminal.prototype.applyCursorShape = function () {
const modes = [
[hterm.hterm.Terminal.cursorShape.BLOCK, true],
[this.defaultCursorShape || hterm.hterm.Terminal.cursorShape.BLOCK, false],
[hterm.hterm.Terminal.cursorShape.BLOCK, false],
[hterm.hterm.Terminal.cursorShape.UNDERLINE, true],
[hterm.hterm.Terminal.cursorShape.UNDERLINE, false],
[hterm.hterm.Terminal.cursorShape.BEAM, true],
[hterm.hterm.Terminal.cursorShape.BEAM, false],
]
const modeNumber = this.cursorMode || 1
if (modeNumber >= modes.length) {
console.warn('Unknown cursor style: ' + modeNumber)
return
}
setTimeout(() => {
this.setCursorShape(modes[modeNumber][0])
this.setCursorBlink(modes[modeNumber][1])
})
setTimeout(() => {
this.setCursorVisible(true)
})
}
hterm.hterm.VT.CSI[' q'] = function (parseState) {
const arg = parseState.args[0]
this.terminal.cursorMode = arg
this.terminal.applyCursorShape()
}
hterm.hterm.VT.OSC['4'] = function (parseState) {
const args: string[] = parseState.args[0].split(';')
const pairCount = args.length / 2
const colorPalette = this.terminal.getTextAttributes().colorPalette
const responseArray: string[] = []
for (let pairNumber = 0; pairNumber < pairCount; ++pairNumber) {
const colorIndex = parseInt(args[pairNumber * 2])
let colorValue = args[pairNumber * 2 + 1]
if (colorIndex >= colorPalette.length) {
continue
}
if (colorValue === '?') {
colorValue = hterm.lib.colors.rgbToX11(colorPalette[colorIndex])
if (colorValue) {
responseArray.push(colorIndex.toString() + ';' + colorValue)
}
continue
}
colorValue = hterm.lib.colors.x11ToCSS(colorValue)
if (colorValue) {
this.terminal.colorPaletteOverrides[colorIndex] = colorValue
colorPalette[colorIndex] = colorValue
}
}
if (responseArray.length) {
this.terminal.io.sendString('\x1b]4;' + responseArray.join(';') + '\x07')
}
}
const _collapseToEnd = Selection.prototype.collapseToEnd
Selection.prototype.collapseToEnd = function () {
try {
_collapseToEnd.apply(this)
} catch (e) { }
}

View File

@@ -0,0 +1,35 @@
a {
cursor: pointer;
}
a:hover {
text-decoration: underline;
}
x-screen {
transition: 0.125s ease background;
background: transparent;
&::-webkit-scrollbar {
width: 3px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
}
x-row > span {
display: inline-block;
height: inherit;
&.wc-node {
vertical-align: top;
}
}
@font-face {
font-family: "monospace-fallback";
src: url(../fonts/SourceCodePro.otf) format("opentype");
}

View File

@@ -0,0 +1,302 @@
import { Injector } from '@angular/core'
import { ConfigService, getCSSFontFamily, ThemesService } from 'tabby-core'
import { Frontend, SearchOptions } from './frontend'
import { hterm, preferenceManager } from './hterm'
/** @hidden */
export class HTermFrontend extends Frontend {
term: any
io: any
private htermIframe: HTMLElement
private initialized = false
private configuredFontSize = 0
private configuredLinePadding = 0
private configuredBackgroundColor = 'transparent'
private zoom = 0
private configService: ConfigService
private themesService: ThemesService
constructor (injector: Injector) {
super(injector)
this.configService = injector.get(ConfigService)
this.themesService = injector.get(ThemesService)
}
async attach (host: HTMLElement): Promise<void> {
if (!this.initialized) {
this.init()
this.initialized = true
preferenceManager.set('background-color', 'transparent')
this.term.decorate(host)
this.htermIframe = this.term.scrollPort_.iframe_
} else {
host.appendChild(this.htermIframe)
}
}
getSelection (): string {
return this.term.getSelectionText()
}
copySelection (): void {
this.term.copySelectionToClipboard()
}
selectAll (): void {
const content = this.term.getDocument().body.children[0]
const selection = content.ownerDocument.defaultView.getSelection()
selection.setBaseAndExtent(content, 0, content, 1)
}
clearSelection (): void {
this.term.getDocument().getSelection().removeAllRanges()
}
focus (): void {
setTimeout(() => {
this.term.scrollPort_.resize()
this.term.scrollPort_.focus()
}, 100)
}
write (data: string): void {
this.io.writeUTF8(data)
}
clear (): void {
this.term.wipeContents()
this.term.onVTKeystroke('\f')
}
configure (): void {
const config = this.configService.store
this.configuredFontSize = config.terminal.fontSize
this.configuredLinePadding = config.terminal.linePadding
this.setFontSize()
preferenceManager.set('font-family', getCSSFontFamily(config))
preferenceManager.set('enable-bold', true)
// preferenceManager.set('audible-bell-sound', '')
preferenceManager.set('desktop-notification-bell', config.terminal.bell === 'notification')
preferenceManager.set('enable-clipboard-notice', false)
preferenceManager.set('receive-encoding', 'raw')
preferenceManager.set('send-encoding', 'raw')
preferenceManager.set('ctrl-plus-minus-zero-zoom', false)
preferenceManager.set('scrollbar-visible', process.platform === 'darwin')
preferenceManager.set('copy-on-select', config.terminal.copyOnSelect)
preferenceManager.set('pass-meta-v', false)
preferenceManager.set('alt-is-meta', config.terminal.altIsMeta)
preferenceManager.set('alt-sends-what', 'browser-key')
preferenceManager.set('alt-gr-mode', 'ctrl-alt')
preferenceManager.set('pass-alt-number', true)
preferenceManager.set('cursor-blink', config.terminal.cursorBlink)
preferenceManager.set('clear-selection-after-copy', true)
preferenceManager.set('scroll-on-output', false)
preferenceManager.set('scroll-on-keystroke', config.terminal.scrollOnInput)
if (config.terminal.colorScheme.foreground) {
preferenceManager.set('foreground-color', config.terminal.colorScheme.foreground)
}
if (config.terminal.background === 'colorScheme') {
if (config.terminal.colorScheme.background) {
preferenceManager.set('background-color', config.terminal.colorScheme.background)
}
} else {
preferenceManager.set('background-color', config.appearance.vibrancy ? 'transparent' : this.themesService.findCurrentTheme().terminalBackground)
}
this.configuredBackgroundColor = preferenceManager.get('background-color')
if (!this.term) {
return
}
let css = require('./hterm.userCSS.scss') // eslint-disable-line
if (!config.terminal.ligatures) {
css += `
* {
font-feature-settings: "liga" 0;
font-variant-ligatures: none;
}
`
} else {
css += `
* {
font-feature-settings: "liga" 1;
font-variant-ligatures: initial;
}
`
}
css += config.appearance.css
this.term.setCSS(css)
if (config.terminal.colorScheme.colors) {
preferenceManager.set(
'color-palette-overrides',
Object.assign([], config.terminal.colorScheme.colors, this.term.colorPaletteOverrides)
)
}
if (config.terminal.colorScheme.cursor) {
preferenceManager.set('cursor-color', config.terminal.colorScheme.cursor)
}
this.term.setBracketedPaste(config.terminal.bracketedPaste)
this.term.defaultCursorShape = {
block: hterm.hterm.Terminal.cursorShape.BLOCK,
underline: hterm.hterm.Terminal.cursorShape.UNDERLINE,
beam: hterm.hterm.Terminal.cursorShape.BEAM,
}[config.terminal.cursor]
this.term.applyCursorShape()
this.term.setCursorBlink(config.terminal.cursorBlink)
if (config.terminal.cursorBlink) {
this.term.onCursorBlink_()
}
}
setZoom (zoom: number): void {
this.zoom = zoom
this.setFontSize()
}
visualBell (): void {
preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
setTimeout(() => {
preferenceManager.set('background-color', this.configuredBackgroundColor)
}, 125)
}
scrollToBottom (): void {
this.term.scrollEnd()
}
findNext (_term: string, _searchOptions?: SearchOptions): boolean {
return false
}
findPrevious (_term: string, _searchOptions?: SearchOptions): boolean {
return false
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
saveState (): any { }
// eslint-disable-next-line @typescript-eslint/no-empty-function
restoreState (_state: string): void { }
private setFontSize () {
const size = this.configuredFontSize * Math.pow(1.1, this.zoom)
preferenceManager.set('font-size', size)
if (this.term) {
setTimeout(() => {
this.term.scrollPort_.characterSize = this.term.scrollPort_.measureCharacterSize()
this.term.setFontSize(size)
})
}
}
private init () {
this.term = new hterm.hterm.Terminal()
this.term.colorPaletteOverrides = []
this.term.onTerminalReady = () => {
this.term.installKeyboard()
this.term.scrollPort_.setCtrlVPaste(true)
this.io = this.term.io.push()
this.io.onVTKeystroke = this.io.sendString = data => this.input.next(Buffer.from(data, 'utf-8'))
this.io.onTerminalResize = (columns, rows) => {
this.resize.next({ columns, rows })
}
this.ready.next()
this.ready.complete()
this.term.scrollPort_.document_.addEventListener('dragOver', event => {
this.dragOver.next(event)
})
this.term.scrollPort_.document_.addEventListener('drop', event => {
this.drop.next(event)
})
}
this.term.setWindowTitle = title => this.title.next(title)
const _setAlternateMode = this.term.setAlternateMode.bind(this.term)
this.term.setAlternateMode = (state) => {
_setAlternateMode(state)
this.alternateScreenActive.next(state)
}
this.term.primaryScreen_.syncSelectionCaret = () => null
this.term.alternateScreen_.syncSelectionCaret = () => null
this.term.primaryScreen_.terminal = this.term
this.term.alternateScreen_.terminal = this.term
this.term.scrollPort_.onPaste_ = (event) => {
event.preventDefault()
}
const _resize = this.term.scrollPort_.resize.bind(this.term.scrollPort_)
this.term.scrollPort_.resize = () => {
if (this.enableResizing) {
_resize()
}
}
const _onMouse = this.term.onMouse_.bind(this.term)
this.term.onMouse_ = (event) => {
this.mouseEvent.next(event)
if (event.type === 'mousedown' && event.which === 3) {
event.preventDefault()
event.stopPropagation()
return
}
if (event.type === 'mousewheel' && event.altKey) {
event.preventDefault()
}
_onMouse(event)
}
this.term.ringBell = () => this.bell.next()
for (const screen of [this.term.primaryScreen_, this.term.alternateScreen_]) {
const _insertString = screen.insertString.bind(screen)
screen.insertString = (data) => {
_insertString(data)
this.contentUpdated.next()
}
const _deleteChars = screen.deleteChars.bind(screen)
screen.deleteChars = (count) => {
const ret = _deleteChars(count)
this.contentUpdated.next()
return ret
}
const _expandSelection = screen.expandSelection.bind(screen)
screen.expandSelection = (selection) => {
// Drop whitespace at the end of selection
const range = selection.getRangeAt(0)
if (range.endOffset > 0 && range.endContainer.nodeType === 3 && range.endContainer.textContent !== '') {
while (/[\s\S]+\s$/.test(range.endContainer.textContent.substr(0, range.endOffset))) {
range.setEnd(range.endContainer, range.endOffset - 1)
}
}
_expandSelection(selection)
}
}
const _measureCharacterSize = this.term.scrollPort_.measureCharacterSize.bind(this.term.scrollPort_)
this.term.scrollPort_.measureCharacterSize = () => {
const size = _measureCharacterSize()
size.height += this.configuredLinePadding
return size
}
const _onCursorBlink = this.term.onCursorBlink_.bind(this.term)
this.term.onCursorBlink_ = () => {
this.term.cursorNode_.style.opacity = '0'
_onCursorBlink()
}
}
}

View File

@@ -0,0 +1,186 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
font-feature-settings: "liga" 0;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 10;
}
.xterm .xterm-helper-textarea {
/*
* HACK: to fix IE's blinking cursor
* Move textarea out of the screen to the far left, so that the cursor is not visible.
*/
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -10;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm {
cursor: text;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 100;
color: transparent;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
opacity: 0.5;
}
.xterm-underline {
text-decoration: underline;
}
/*----*/
@font-face {
font-family: "monospace-fallback";
src: url(../fonts/SourceCodePro.otf) format("opentype");
}
.xterm-viewport::-webkit-scrollbar {
background: rgba(0, 0, 0, .125);
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, .25);
}

View File

@@ -0,0 +1,392 @@
import { Injector } from '@angular/core'
import { ConfigService, getCSSFontFamily, HostAppService, HotkeysService, Platform, PlatformService } from 'tabby-core'
import { Frontend, SearchOptions } from './frontend'
import { Terminal, ITheme } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { LigaturesAddon } from 'xterm-addon-ligatures'
import { SearchAddon } from 'xterm-addon-search'
import { WebglAddon } from 'xterm-addon-webgl'
import { Unicode11Addon } from 'xterm-addon-unicode11'
import { SerializeAddon } from 'xterm-addon-serialize'
import './xterm.css'
import deepEqual from 'deep-equal'
import { Attributes } from 'xterm/src/common/buffer/Constants'
import { AttributeData } from 'xterm/src/common/buffer/AttributeData'
import { CellData } from 'xterm/src/common/buffer/CellData'
const COLOR_NAMES = [
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
]
/** @hidden */
export class XTermFrontend extends Frontend {
enableResizing = true
protected xtermCore: any
protected enableWebGL = false
private xterm: Terminal
private element?: HTMLElement
private configuredFontSize = 0
private configuredLinePadding = 0
private zoom = 0
private resizeHandler: () => void
private configuredTheme: ITheme = {}
private copyOnSelect = false
private search = new SearchAddon()
private fitAddon = new FitAddon()
private serializeAddon = new SerializeAddon()
private ligaturesAddon?: LigaturesAddon
private webGLAddon?: WebglAddon
private opened = false
private resizeObserver?: any
private configService: ConfigService
private hotkeysService: HotkeysService
private platformService: PlatformService
private hostApp: HostAppService
constructor (injector: Injector) {
super(injector)
this.configService = injector.get(ConfigService)
this.hotkeysService = injector.get(HotkeysService)
this.platformService = injector.get(PlatformService)
this.hostApp = injector.get(HostAppService)
this.xterm = new Terminal({
allowTransparency: true,
windowsMode: process.platform === 'win32',
})
this.xtermCore = (this.xterm as any)._core
this.xterm.onBinary(data => {
this.input.next(Buffer.from(data, 'binary'))
})
this.xterm.onData(data => {
this.input.next(Buffer.from(data, 'utf-8'))
})
this.xterm.onResize(({ cols, rows }) => {
this.resize.next({ rows, columns: cols })
})
this.xterm.onTitleChange(title => {
this.title.next(title)
})
this.xterm.onSelectionChange(() => {
if (this.copyOnSelect && this.getSelection()) {
this.copySelection()
}
})
this.xterm.onBell(() => {
this.bell.next()
})
this.xterm.loadAddon(this.fitAddon)
this.xterm.loadAddon(this.serializeAddon)
this.xterm.loadAddon(new Unicode11Addon())
this.xterm.unicode.activeVersion = '11'
const keyboardEventHandler = (name: string, event: KeyboardEvent) => {
this.hotkeysService.pushKeystroke(name, event)
let ret = true
if (this.hotkeysService.getCurrentPartiallyMatchedHotkeys().length !== 0) {
event.stopPropagation()
event.preventDefault()
ret = false
}
this.hotkeysService.processKeystrokes()
this.hotkeysService.emitKeyEvent(event)
return ret
}
this.xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => {
if (this.hostApp.platform !== Platform.Web) {
if (event.getModifierState('Meta') && event.key.toLowerCase() === 'v') {
event.preventDefault()
return false
}
}
if (event.getModifierState('Meta') && event.key.startsWith('Arrow')) {
return false
}
return keyboardEventHandler('keydown', event)
})
this.xtermCore._scrollToBottom = this.xtermCore.scrollToBottom.bind(this.xtermCore)
this.xtermCore.scrollToBottom = () => null
this.resizeHandler = () => {
try {
if (this.xterm.element && getComputedStyle(this.xterm.element).getPropertyValue('height') !== 'auto') {
const t = window.getComputedStyle(this.xterm.element.parentElement!)
const r = parseInt(t.getPropertyValue('height'))
const n = Math.max(0, parseInt(t.getPropertyValue('width')))
const o = window.getComputedStyle(this.xterm.element)
const i = r - (parseInt(o.getPropertyValue('padding-top')) + parseInt(o.getPropertyValue('padding-bottom')))
const l = n - (parseInt(o.getPropertyValue('padding-right')) + parseInt(o.getPropertyValue('padding-left'))) - this.xtermCore.viewport.scrollBarWidth
const actualCellWidth = this.xtermCore._renderService.dimensions.actualCellWidth || 9
const actualCellHeight = this.xtermCore._renderService.dimensions.actualCellHeight || 17
const cols = Math.floor(l / actualCellWidth)
const rows = Math.floor(i / actualCellHeight)
if (!isNaN(cols) && !isNaN(rows)) {
this.xterm.resize(cols, rows)
}
}
} catch (e) {
// tends to throw when element wasn't shown yet
console.warn('Could not resize xterm', e)
}
}
this.xtermCore._keyUp = (e: KeyboardEvent) => {
this.xtermCore.updateCursorStyle(e)
keyboardEventHandler('keyup', e)
}
this.xterm.buffer.onBufferChange(() => {
const altBufferActive = this.xterm.buffer.active.type === 'alternate'
this.alternateScreenActive.next(altBufferActive)
})
}
async attach (host: HTMLElement): Promise<void> {
this.configure()
this.element = host
this.xterm.open(host)
this.opened = true
// Work around font loading bugs
await new Promise(resolve => setTimeout(resolve, this.hostApp.platform === Platform.Web ? 1000 : 0))
if (this.enableWebGL) {
this.webGLAddon = new WebglAddon()
this.xterm.loadAddon(this.webGLAddon)
}
this.ready.next()
this.ready.complete()
this.xterm.loadAddon(this.search)
window.addEventListener('resize', this.resizeHandler)
this.resizeHandler()
host.addEventListener('dragOver', (event: any) => this.dragOver.next(event))
host.addEventListener('drop', event => this.drop.next(event))
host.addEventListener('mousedown', event => this.mouseEvent.next(event))
host.addEventListener('mouseup', event => this.mouseEvent.next(event))
host.addEventListener('mousewheel', event => this.mouseEvent.next(event as MouseEvent))
host.addEventListener('contextmenu', event => {
event.preventDefault()
event.stopPropagation()
})
this.resizeObserver = new window['ResizeObserver'](() => setTimeout(() => this.resizeHandler()))
this.resizeObserver.observe(host)
}
detach (_host: HTMLElement): void {
window.removeEventListener('resize', this.resizeHandler)
this.resizeObserver?.disconnect()
}
destroy (): void {
super.destroy()
this.webGLAddon?.dispose()
this.xterm.dispose()
}
getSelection (): string {
return this.xterm.getSelection()
}
copySelection (): void {
const text = this.getSelection()
if (text.length < 1024 * 32) {
this.platformService.setClipboard({
text: this.getSelection(),
html: this.getSelectionAsHTML(),
})
} else {
this.platformService.setClipboard({
text: this.getSelection(),
})
}
}
selectAll (): void {
this.xterm.selectAll()
}
clearSelection (): void {
this.xterm.clearSelection()
}
focus (): void {
setTimeout(() => this.xterm.focus())
}
write (data: string): void {
this.xterm.write(data)
}
clear (): void {
this.xterm.clear()
}
visualBell (): void {
if (this.element) {
this.element.style.animation = 'none'
setTimeout(() => {
this.element!.style.animation = 'terminalShakeFrames 0.3s ease'
})
}
}
scrollToBottom (): void {
this.xtermCore._scrollToBottom()
}
configure (): void {
const config = this.configService.store
setImmediate(() => {
if (this.xterm.cols && this.xterm.rows && this.xtermCore.charMeasure) {
if (this.xtermCore.charMeasure) {
this.xtermCore.charMeasure.measure(this.xtermCore.options)
}
if (this.xtermCore.renderer) {
this.xtermCore.renderer._updateDimensions()
}
this.resizeHandler()
}
})
this.xterm.setOption('fontFamily', getCSSFontFamily(config))
this.xterm.setOption('bellStyle', config.terminal.bell)
this.xterm.setOption('cursorStyle', {
beam: 'bar',
}[config.terminal.cursor] || config.terminal.cursor)
this.xterm.setOption('cursorBlink', config.terminal.cursorBlink)
this.xterm.setOption('macOptionIsMeta', config.terminal.altIsMeta)
this.xterm.setOption('scrollback', config.terminal.scrollbackLines)
this.xterm.setOption('wordSeparator', config.terminal.wordSeparator)
this.configuredFontSize = config.terminal.fontSize
this.configuredLinePadding = config.terminal.linePadding
this.setFontSize()
this.copyOnSelect = config.terminal.copyOnSelect
const theme: ITheme = {
foreground: config.terminal.colorScheme.foreground,
selection: config.terminal.colorScheme.selection || '#88888888',
background: config.terminal.background === 'colorScheme' ? config.terminal.colorScheme.background : '#00000000',
cursor: config.terminal.colorScheme.cursor,
}
for (let i = 0; i < COLOR_NAMES.length; i++) {
theme[COLOR_NAMES[i]] = config.terminal.colorScheme.colors[i]
}
if (this.xtermCore._colorManager && !deepEqual(this.configuredTheme, theme)) {
this.xterm.setOption('theme', theme)
this.configuredTheme = theme
}
if (this.opened && config.terminal.ligatures && !this.ligaturesAddon && this.hostApp.platform !== Platform.Web) {
this.ligaturesAddon = new LigaturesAddon()
this.xterm.loadAddon(this.ligaturesAddon)
}
}
setZoom (zoom: number): void {
this.zoom = zoom
this.setFontSize()
}
findNext (term: string, searchOptions?: SearchOptions): boolean {
return this.search.findNext(term, searchOptions)
}
findPrevious (term: string, searchOptions?: SearchOptions): boolean {
return this.search.findPrevious(term, searchOptions)
}
saveState (): any {
return this.serializeAddon.serialize(1000)
}
restoreState (state: string): void {
this.xterm.write(state)
}
private setFontSize () {
const scale = Math.pow(1.1, this.zoom)
this.xterm.setOption('fontSize', this.configuredFontSize * scale)
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
this.xterm.setOption('lineHeight', (this.configuredFontSize + this.configuredLinePadding * 2) / this.configuredFontSize)
this.resizeHandler()
}
private getSelectionAsHTML (): string {
let html = `<div style="font-family: '${this.configService.store.terminal.font}', monospace; white-space: pre">`
const selection = this.xterm.getSelectionPosition()
if (!selection) {
return ''
}
if (selection.startRow === selection.endRow) {
html += this.getLineAsHTML(selection.startRow, selection.startColumn, selection.endColumn)
} else {
html += this.getLineAsHTML(selection.startRow, selection.startColumn, this.xterm.cols)
for (let y = selection.startRow + 1; y < selection.endRow; y++) {
html += this.getLineAsHTML(y, 0, this.xterm.cols)
}
html += this.getLineAsHTML(selection.endRow, 0, selection.endColumn)
}
html += '</div>'
return html
}
private getHexColor (mode: number, color: number, def: string): string {
if (mode === Attributes.CM_RGB) {
const rgb = AttributeData.toColorRGB(color)
return rgb.map(x => x.toString(16).padStart(2, '0')).join('')
}
if (mode === Attributes.CM_P16 || mode === Attributes.CM_P256) {
return this.configService.store.terminal.colorScheme.colors[color]
}
return def
}
private getLineAsHTML (y: number, start: number, end: number): string {
let html = '<div>'
let lastStyle: string|null = null
const line = (this.xterm.buffer.active.getLine(y) as any)._line
const cell = new CellData()
for (let i = start; i < end; i++) {
line.loadCell(i, cell)
const fg = this.getHexColor(cell.getFgColorMode(), cell.getFgColor(), this.configService.store.terminal.colorScheme.foreground)
const bg = this.getHexColor(cell.getBgColorMode(), cell.getBgColor(), this.configService.store.terminal.colorScheme.background)
const style = `color: ${fg}; background: ${bg}; font-weight: ${cell.isBold() ? 'bold' : 'normal'}; font-style: ${cell.isItalic() ? 'italic' : 'normal'}; text-decoration: ${cell.isUnderline() ? 'underline' : 'none'}`
if (style !== lastStyle) {
if (lastStyle !== null) {
html += '</span>'
}
html += `<span style="${style}">`
lastStyle = style
}
html += line.getString(i) || ' '
}
html += '</span></div>'
return html
}
}
/** @hidden */
export class XTermWebGLFrontend extends XTermFrontend {
protected enableWebGL = true
}

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@angular/core'
import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
/** @hidden */
@Injectable()
export class TerminalHotkeyProvider extends HotkeyProvider {
hotkeys: HotkeyDescription[] = [
{
id: 'copy',
name: 'Copy to clipboard',
},
{
id: 'paste',
name: 'Paste from clipboard',
},
{
id: 'home',
name: 'Beginning of the line',
},
{
id: 'end',
name: 'End of the line',
},
{
id: 'previous-word',
name: 'Jump to previous word',
},
{
id: 'next-word',
name: 'Jump to next word',
},
{
id: 'delete-previous-word',
name: 'Delete previous word',
},
{
id: 'delete-next-word',
name: 'Delete next word',
},
{
id: 'clear',
name: 'Clear terminal',
},
{
id: 'zoom-in',
name: 'Zoom in',
},
{
id: 'zoom-out',
name: 'Zoom out',
},
{
id: 'reset-zoom',
name: 'Reset zoom',
},
{
id: 'ctrl-c',
name: 'Intelligent Ctrl-C (copy/abort)',
},
{
id: 'copy-current-path',
name: 'Copy current path',
},
{
id: 'search',
name: 'Search',
},
{
id: 'pane-focus-all',
name: 'Focus all panes at once (broadcast)',
},
]
async provide (): Promise<HotkeyDescription[]> {
return this.hotkeys
}
}

114
tabby-terminal/src/index.ts Normal file
View File

@@ -0,0 +1,114 @@
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { FormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { ToastrModule } from 'ngx-toastr'
import TabbyCorePlugin, { ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler } from 'tabby-core'
import { SettingsTabProvider } from 'tabby-settings'
import { AppearanceSettingsTabComponent } from './components/appearanceSettingsTab.component'
import { ColorSchemeSettingsTabComponent } from './components/colorSchemeSettingsTab.component'
import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.component'
import { ColorPickerComponent } from './components/colorPicker.component'
import { ColorSchemePreviewComponent } from './components/colorSchemePreview.component'
import { SearchPanelComponent } from './components/searchPanel.component'
import { TerminalFrontendService } from './services/terminalFrontend.service'
import { TerminalDecorator } from './api/decorator'
import { TerminalContextMenuItemProvider } from './api/contextMenuProvider'
import { TerminalColorSchemeProvider } from './api/colorSchemeProvider'
import { TerminalSettingsTabProvider, AppearanceSettingsTabProvider, ColorSchemeSettingsTabProvider } from './settings'
import { DebugDecorator } from './features/debug'
import { PathDropDecorator } from './features/pathDrop'
import { ZModemDecorator } from './features/zmodem'
import { TerminalConfigProvider } from './config'
import { TerminalHotkeyProvider } from './hotkeys'
import { CopyPasteContextMenu, LegacyContextMenu } from './tabContextMenu'
import { hterm } from './frontends/hterm'
import { Frontend } from './frontends/frontend'
import { HTermFrontend } from './frontends/htermFrontend'
import { XTermFrontend, XTermWebGLFrontend } from './frontends/xtermFrontend'
import { TerminalCLIHandler } from './cli'
/** @hidden */
@NgModule({
imports: [
BrowserModule,
FormsModule,
NgbModule,
ToastrModule,
TabbyCorePlugin,
],
providers: [
{ provide: SettingsTabProvider, useClass: AppearanceSettingsTabProvider, multi: true },
{ provide: SettingsTabProvider, useClass: ColorSchemeSettingsTabProvider, multi: true },
{ provide: SettingsTabProvider, useClass: TerminalSettingsTabProvider, multi: true },
{ provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true },
{ provide: HotkeyProvider, useClass: TerminalHotkeyProvider, multi: true },
{ provide: TerminalDecorator, useClass: PathDropDecorator, multi: true },
{ provide: TerminalDecorator, useClass: ZModemDecorator, multi: true },
{ provide: TerminalDecorator, useClass: DebugDecorator, multi: true },
{ provide: TabContextMenuItemProvider, useClass: CopyPasteContextMenu, multi: true },
{ provide: TabContextMenuItemProvider, useClass: LegacyContextMenu, multi: true },
{ provide: CLIHandler, useClass: TerminalCLIHandler, multi: true },
],
entryComponents: [
AppearanceSettingsTabComponent,
ColorSchemeSettingsTabComponent,
TerminalSettingsTabComponent,
] as any[],
declarations: [
ColorPickerComponent,
ColorSchemePreviewComponent,
AppearanceSettingsTabComponent,
ColorSchemeSettingsTabComponent,
TerminalSettingsTabComponent,
SearchPanelComponent,
] as any[],
exports: [
ColorPickerComponent,
SearchPanelComponent,
],
})
export default class TerminalModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
private constructor (
hotkeys: HotkeysService,
) {
const events = [
{
name: 'keydown',
htermHandler: 'onKeyDown_',
},
{
name: 'keyup',
htermHandler: 'onKeyUp_',
},
]
events.forEach((event) => {
const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
hotkeys.pushKeystroke(event.name, nativeEvent)
if (hotkeys.getCurrentPartiallyMatchedHotkeys().length === 0) {
oldHandler.bind(this)(nativeEvent)
} else {
nativeEvent.stopPropagation()
nativeEvent.preventDefault()
}
hotkeys.processKeystrokes()
hotkeys.emitKeyEvent(nativeEvent)
}
})
}
}
export { TerminalFrontendService, TerminalDecorator, TerminalContextMenuItemProvider, TerminalColorSchemeProvider }
export { Frontend, XTermFrontend, XTermWebGLFrontend, HTermFrontend }
export { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
export * from './api/interfaces'
export * from './session'

View File

@@ -0,0 +1,35 @@
import { Injectable, Injector } from '@angular/core'
import { ConfigService } from 'tabby-core'
import { Frontend } from '../frontends/frontend'
import { HTermFrontend } from '../frontends/htermFrontend'
import { XTermFrontend, XTermWebGLFrontend } from '../frontends/xtermFrontend'
import { BaseSession } from '../session'
@Injectable({ providedIn: 'root' })
export class TerminalFrontendService {
private containers = new WeakMap<BaseSession, Frontend>()
/** @hidden */
private constructor (
private config: ConfigService,
private injector: Injector,
) { }
getFrontend (session?: BaseSession|null): Frontend {
if (!session) {
const frontend: Frontend = new {
xterm: XTermFrontend,
'xterm-webgl': XTermWebGLFrontend,
hterm: HTermFrontend,
}[this.config.store.terminal.frontend](this.injector)
return frontend
}
if (!this.containers.has(session)) {
this.containers.set(
session,
this.getFrontend(),
)
}
return this.containers.get(session)!
}
}

View File

@@ -0,0 +1,60 @@
import { Observable, Subject } from 'rxjs'
/**
* A session object for a [[BaseTerminalTabComponent]]
* Extend this to implement custom I/O and process management for your terminal tab
*/
export abstract class BaseSession {
open: boolean
name: string
truePID: number
protected output = new Subject<string>()
protected binaryOutput = new Subject<Buffer>()
protected closed = new Subject<void>()
protected destroyed = new Subject<void>()
private initialDataBuffer = Buffer.from('')
private initialDataBufferReleased = false
get output$ (): Observable<string> { return this.output }
get binaryOutput$ (): Observable<Buffer> { return this.binaryOutput }
get closed$ (): Observable<void> { return this.closed }
get destroyed$ (): Observable<void> { return this.destroyed }
emitOutput (data: Buffer): void {
if (!this.initialDataBufferReleased) {
this.initialDataBuffer = Buffer.concat([this.initialDataBuffer, data])
} else {
this.output.next(data.toString())
this.binaryOutput.next(data)
}
}
releaseInitialDataBuffer (): void {
this.initialDataBufferReleased = true
this.output.next(this.initialDataBuffer.toString())
this.binaryOutput.next(this.initialDataBuffer)
this.initialDataBuffer = Buffer.from('')
}
async destroy (): Promise<void> {
if (this.open) {
this.open = false
this.closed.next()
this.destroyed.next()
this.closed.complete()
this.destroyed.complete()
this.output.complete()
this.binaryOutput.complete()
await this.gracefullyKillProcess()
}
}
abstract start (options: unknown): void
abstract resize (columns: number, rows: number): void
abstract write (data: Buffer): void
abstract kill (signal?: string): void
abstract gracefullyKillProcess (): Promise<void>
abstract supportsWorkingDirectory (): boolean
abstract getWorkingDirectory (): Promise<string|null>
}

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core'
import { SettingsTabProvider } from 'tabby-settings'
import { AppearanceSettingsTabComponent } from './components/appearanceSettingsTab.component'
import { TerminalSettingsTabComponent } from './components/terminalSettingsTab.component'
import { ColorSchemeSettingsTabComponent } from './components/colorSchemeSettingsTab.component'
/** @hidden */
@Injectable()
export class AppearanceSettingsTabProvider extends SettingsTabProvider {
id = 'terminal-appearance'
icon = 'swatchbook'
title = 'Appearance'
getComponentType (): any {
return AppearanceSettingsTabComponent
}
}
/** @hidden */
@Injectable()
export class ColorSchemeSettingsTabProvider extends SettingsTabProvider {
id = 'terminal-color-scheme'
icon = 'palette'
title = 'Color Scheme'
getComponentType (): any {
return ColorSchemeSettingsTabComponent
}
}
/** @hidden */
@Injectable()
export class TerminalSettingsTabProvider extends SettingsTabProvider {
id = 'terminal'
icon = 'terminal'
title = 'Terminal'
getComponentType (): any {
return TerminalSettingsTabComponent
}
}

View File

@@ -0,0 +1,66 @@
import { Injectable, Optional, Inject } from '@angular/core'
import { BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, NotificationsService, MenuItemOptions } from 'tabby-core'
import { BaseTerminalTabComponent } from './api/baseTerminalTab.component'
import { TerminalContextMenuItemProvider } from './api/contextMenuProvider'
/** @hidden */
@Injectable()
export class CopyPasteContextMenu extends TabContextMenuItemProvider {
weight = -10
constructor (
private notifications: NotificationsService,
) {
super()
}
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
if (tabHeader) {
return []
}
if (tab instanceof BaseTerminalTabComponent) {
return [
{
label: 'Copy',
click: (): void => {
setTimeout(() => {
tab.frontend?.copySelection()
this.notifications.notice('Copied')
})
},
},
{
label: 'Paste',
click: () => tab.paste(),
},
]
}
return []
}
}
/** @hidden */
@Injectable()
export class LegacyContextMenu extends TabContextMenuItemProvider {
weight = 1
constructor (
@Optional() @Inject(TerminalContextMenuItemProvider) protected contextMenuProviders: TerminalContextMenuItemProvider[]|null,
) {
super()
}
async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
if (!this.contextMenuProviders) {
return []
}
if (tab instanceof BaseTerminalTabComponent) {
let items: MenuItemOptions[] = []
for (const p of this.contextMenuProviders) {
items = items.concat(await p.getItems(tab))
}
return items
}
return []
}
}