mirror of
https://github.com/Eugeny/tabby.git
synced 2025-10-04 22:14:55 +00:00
project rename
This commit is contained in:
669
tabby-terminal/src/api/baseTerminalTab.component.ts
Normal file
669
tabby-terminal/src/api/baseTerminalTab.component.ts
Normal 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()
|
||||
}
|
||||
}
|
8
tabby-terminal/src/api/colorSchemeProvider.ts
Normal file
8
tabby-terminal/src/api/colorSchemeProvider.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { TerminalColorScheme } from './interfaces'
|
||||
|
||||
/**
|
||||
* Extend to add more terminal color schemes
|
||||
*/
|
||||
export abstract class TerminalColorSchemeProvider {
|
||||
abstract getSchemes (): Promise<TerminalColorScheme[]>
|
||||
}
|
12
tabby-terminal/src/api/contextMenuProvider.ts
Normal file
12
tabby-terminal/src/api/contextMenuProvider.ts
Normal 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[]>
|
||||
}
|
38
tabby-terminal/src/api/decorator.ts
Normal file
38
tabby-terminal/src/api/decorator.ts
Normal 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)
|
||||
}
|
||||
}
|
12
tabby-terminal/src/api/interfaces.ts
Normal file
12
tabby-terminal/src/api/interfaces.ts
Normal 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
BIN
tabby-terminal/src/bell.ogg
Normal file
Binary file not shown.
39
tabby-terminal/src/cli.ts
Normal file
39
tabby-terminal/src/cli.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@@ -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()',
|
||||
)
|
@@ -0,0 +1,9 @@
|
||||
color-scheme-preview {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Source Code Pro', monospace;
|
||||
min-height: 120px;
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
.content(#content, [style.opacity]='frontendIsReady ? 1 : 0')
|
||||
search-panel(
|
||||
*ngIf='showSearchPanel',
|
||||
@toolbarSlide,
|
||||
[frontend]='frontend',
|
||||
(close)='showSearchPanel = false'
|
||||
)
|
25
tabby-terminal/src/components/baseTerminalTab.component.scss
Normal file
25
tabby-terminal/src/components/baseTerminalTab.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
15
tabby-terminal/src/components/colorPicker.component.pug
Normal file
15
tabby-terminal/src/components/colorPicker.component.pug
Normal 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 }}
|
15
tabby-terminal/src/components/colorPicker.component.scss
Normal file
15
tabby-terminal/src/components/colorPicker.component.scss
Normal 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);
|
||||
}
|
26
tabby-terminal/src/components/colorPicker.component.ts
Normal file
26
tabby-terminal/src/components/colorPicker.component.ts
Normal 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)
|
||||
}
|
||||
}
|
@@ -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')
|
||||
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
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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')
|
@@ -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;
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
54
tabby-terminal/src/components/searchPanel.component.pug
Normal file
54
tabby-terminal/src/components/searchPanel.component.pug
Normal 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
|
17
tabby-terminal/src/components/searchPanel.component.scss
Normal file
17
tabby-terminal/src/components/searchPanel.component.scss
Normal 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;
|
||||
}
|
||||
}
|
58
tabby-terminal/src/components/searchPanel.component.ts
Normal file
58
tabby-terminal/src/components/searchPanel.component.ts
Normal 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()
|
||||
}
|
||||
}
|
160
tabby-terminal/src/components/terminalSettingsTab.component.pug
Normal file
160
tabby-terminal/src/components/terminalSettingsTab.component.pug
Normal 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()',
|
||||
)
|
@@ -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'])
|
||||
}
|
||||
}
|
188
tabby-terminal/src/config.ts
Normal file
188
tabby-terminal/src/config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
132
tabby-terminal/src/features/debug.ts
Normal file
132
tabby-terminal/src/features/debug.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
29
tabby-terminal/src/features/pathDrop.ts
Normal file
29
tabby-terminal/src/features/pathDrop.ts
Normal 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 + ' ')
|
||||
}
|
||||
}
|
224
tabby-terminal/src/features/zmodem.ts
Normal file
224
tabby-terminal/src/features/zmodem.ts
Normal 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'))
|
||||
}
|
||||
}
|
||||
}
|
BIN
tabby-terminal/src/fonts/SourceCodePro.otf
Normal file
BIN
tabby-terminal/src/fonts/SourceCodePro.otf
Normal file
Binary file not shown.
79
tabby-terminal/src/frontends/frontend.ts
Normal file
79
tabby-terminal/src/frontends/frontend.ts
Normal 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
|
||||
}
|
120
tabby-terminal/src/frontends/hterm.ts
Normal file
120
tabby-terminal/src/frontends/hterm.ts
Normal 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) { }
|
||||
}
|
35
tabby-terminal/src/frontends/hterm.userCSS.scss
Normal file
35
tabby-terminal/src/frontends/hterm.userCSS.scss
Normal 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");
|
||||
}
|
302
tabby-terminal/src/frontends/htermFrontend.ts
Normal file
302
tabby-terminal/src/frontends/htermFrontend.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
186
tabby-terminal/src/frontends/xterm.css
Normal file
186
tabby-terminal/src/frontends/xterm.css
Normal 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);
|
||||
}
|
392
tabby-terminal/src/frontends/xtermFrontend.ts
Normal file
392
tabby-terminal/src/frontends/xtermFrontend.ts
Normal 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
|
||||
}
|
77
tabby-terminal/src/hotkeys.ts
Normal file
77
tabby-terminal/src/hotkeys.ts
Normal 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
114
tabby-terminal/src/index.ts
Normal 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'
|
35
tabby-terminal/src/services/terminalFrontend.service.ts
Normal file
35
tabby-terminal/src/services/terminalFrontend.service.ts
Normal 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)!
|
||||
}
|
||||
}
|
60
tabby-terminal/src/session.ts
Normal file
60
tabby-terminal/src/session.ts
Normal 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>
|
||||
}
|
42
tabby-terminal/src/settings.ts
Normal file
42
tabby-terminal/src/settings.ts
Normal 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
|
||||
}
|
||||
}
|
66
tabby-terminal/src/tabContextMenu.ts
Normal file
66
tabby-terminal/src/tabContextMenu.ts
Normal 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 []
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user