project rename
57
tabby-local/src/api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface Shell {
|
||||
id: string
|
||||
name?: string
|
||||
command: string
|
||||
args?: string[]
|
||||
env: Record<string, string>
|
||||
|
||||
/**
|
||||
* Base path to which shell's internal FS is relative
|
||||
* Currently used for WSL only
|
||||
*/
|
||||
fsBase?: string
|
||||
|
||||
/**
|
||||
* SVG icon
|
||||
*/
|
||||
icon?: string
|
||||
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend to add support for more shells
|
||||
*/
|
||||
export abstract class ShellProvider {
|
||||
abstract provide (): Promise<Shell[]>
|
||||
}
|
||||
|
||||
|
||||
export interface SessionOptions {
|
||||
restoreFromPTYID?: string
|
||||
name?: string
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
width?: number
|
||||
height?: number
|
||||
pauseAfterExit?: boolean
|
||||
runAsAdministrator?: boolean
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string
|
||||
color?: string
|
||||
sessionOptions: SessionOptions
|
||||
shell?: string
|
||||
isBuiltin?: boolean
|
||||
icon?: string
|
||||
disableDynamicTitle?: boolean
|
||||
}
|
||||
|
||||
export interface ChildProcess {
|
||||
pid: number
|
||||
ppid: number
|
||||
command: string
|
||||
}
|
52
tabby-local/src/buttonProvider.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ToolbarButtonProvider, ToolbarButton, ConfigService, SelectorOption, SelectorService } from 'tabby-core'
|
||||
import { ElectronService } from 'tabby-electron'
|
||||
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class ButtonProvider extends ToolbarButtonProvider {
|
||||
constructor (
|
||||
electron: ElectronService,
|
||||
private selector: SelectorService,
|
||||
private config: ConfigService,
|
||||
private terminal: TerminalService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async activate () {
|
||||
const options: SelectorOption<void>[] = []
|
||||
const profiles = await this.terminal.getProfiles({ skipDefault: !this.config.store.terminal.showDefaultProfiles })
|
||||
|
||||
for (const profile of profiles) {
|
||||
options.push({
|
||||
icon: profile.icon,
|
||||
name: profile.name,
|
||||
callback: () => this.terminal.openTab(profile),
|
||||
})
|
||||
}
|
||||
|
||||
await this.selector.show('Select profile', options)
|
||||
}
|
||||
|
||||
provide (): ToolbarButton[] {
|
||||
return [
|
||||
{
|
||||
icon: require('./icons/plus.svg'),
|
||||
title: 'New terminal',
|
||||
touchBarNSImage: 'NSTouchBarAddDetailTemplate',
|
||||
click: () => {
|
||||
this.terminal.openTab()
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: require('./icons/profiles.svg'),
|
||||
title: 'New terminal with profile',
|
||||
click: () => this.activate(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
119
tabby-local/src/cli.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as path from 'path'
|
||||
import * as fs from 'mz/fs'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CLIHandler, CLIEvent, AppService, ConfigService, HostWindowService } from 'tabby-core'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
@Injectable()
|
||||
export class TerminalCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = 0
|
||||
|
||||
constructor (
|
||||
private config: ConfigService,
|
||||
private hostWindow: HostWindowService,
|
||||
private terminal: TerminalService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
|
||||
if (op === 'open') {
|
||||
this.handleOpenDirectory(path.resolve(event.cwd, event.argv.directory))
|
||||
} else if (op === 'run') {
|
||||
this.handleRunCommand(event.argv.command)
|
||||
} else if (op === 'profile') {
|
||||
this.handleOpenProfile(event.argv.profileName)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async handleOpenDirectory (directory: string) {
|
||||
if (directory.length > 1 && (directory.endsWith('/') || directory.endsWith('\\'))) {
|
||||
directory = directory.substring(0, directory.length - 1)
|
||||
}
|
||||
if (await fs.exists(directory)) {
|
||||
if ((await fs.stat(directory)).isDirectory()) {
|
||||
this.terminal.openTab(undefined, directory)
|
||||
this.hostWindow.bringToFront()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleRunCommand (command: string[]) {
|
||||
this.terminal.openTab({
|
||||
name: '',
|
||||
sessionOptions: {
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
},
|
||||
}, null, true)
|
||||
this.hostWindow.bringToFront()
|
||||
}
|
||||
|
||||
private handleOpenProfile (profileName: string) {
|
||||
const profile = this.config.store.terminal.profiles.find(x => x.name === profileName)
|
||||
if (!profile) {
|
||||
console.error('Requested profile', profileName, 'not found')
|
||||
return
|
||||
}
|
||||
this.terminal.openTabWithOptions(profile.sessionOptions)
|
||||
this.hostWindow.bringToFront()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class OpenPathCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = -100
|
||||
|
||||
constructor (
|
||||
private terminal: TerminalService,
|
||||
private hostWindow: HostWindowService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
const op = event.argv._[0]
|
||||
const opAsPath = op ? path.resolve(event.cwd, op) : null
|
||||
|
||||
if (opAsPath && (await fs.lstat(opAsPath)).isDirectory()) {
|
||||
this.terminal.openTab(undefined, opAsPath)
|
||||
this.hostWindow.bringToFront()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutoOpenTabCLIHandler extends CLIHandler {
|
||||
firstMatchOnly = true
|
||||
priority = -1000
|
||||
|
||||
constructor (
|
||||
private app: AppService,
|
||||
private config: ConfigService,
|
||||
private terminal: TerminalService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async handle (event: CLIEvent): Promise<boolean> {
|
||||
if (!event.secondInstance && this.config.store.terminal.autoOpen) {
|
||||
this.app.ready$.subscribe(() => {
|
||||
this.terminal.openTab()
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
73
tabby-local/src/components/editProfileModal.component.pug
Normal file
@@ -0,0 +1,73 @@
|
||||
.modal-body
|
||||
.form-group
|
||||
label Name
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='profile.name',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Command
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='profile.sessionOptions.command',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Arguments
|
||||
.input-group(
|
||||
*ngFor='let arg of profile.sessionOptions.args; index as i; trackBy: trackByIndex',
|
||||
)
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='profile.sessionOptions.args[i]',
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='profile.sessionOptions.args.splice(i, 1)')
|
||||
i.fas.fa-trash
|
||||
|
||||
.mt-2
|
||||
button.btn.btn-secondary((click)='profile.sessionOptions.args.push("")')
|
||||
i.fas.fa-plus.mr-2
|
||||
| Add
|
||||
|
||||
.form-line(*ngIf='uac.isAvailable')
|
||||
.header
|
||||
.title Run as administrator
|
||||
toggle(
|
||||
[(ngModel)]='profile.sessionOptions.runAsAdministrator',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Working directory
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='profile.sessionOptions.cwd',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Environment
|
||||
environment-editor(
|
||||
type='text',
|
||||
[(model)]='profile.sessionOptions.env',
|
||||
)
|
||||
|
||||
.form-group
|
||||
label Tab color
|
||||
input.form-control(
|
||||
type='text',
|
||||
autofocus,
|
||||
[(ngModel)]='profile.color',
|
||||
placeholder='#000000'
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Disable dynamic tab title
|
||||
.description Connection name will be used as a title instead
|
||||
toggle([(ngModel)]='profile.disableDynamicTitle')
|
||||
|
||||
.modal-footer
|
||||
button.btn.btn-outline-primary((click)='save()') Save
|
||||
button.btn.btn-outline-danger((click)='cancel()') Cancel
|
36
tabby-local/src/components/editProfileModal.component.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { UACService } from '../services/uac.service'
|
||||
import { Profile } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./editProfileModal.component.pug'),
|
||||
})
|
||||
export class EditProfileModalComponent {
|
||||
profile: Profile
|
||||
|
||||
constructor (
|
||||
public uac: UACService,
|
||||
private modalInstance: NgbActiveModal,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.profile.sessionOptions.env = this.profile.sessionOptions.env ?? {}
|
||||
this.profile.sessionOptions.args = this.profile.sessionOptions.args ?? []
|
||||
}
|
||||
|
||||
save () {
|
||||
this.modalInstance.close(this.profile)
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalInstance.dismiss()
|
||||
}
|
||||
|
||||
trackByIndex (index) {
|
||||
return index
|
||||
}
|
||||
}
|
13
tabby-local/src/components/environmentEditor.component.pug
Normal file
@@ -0,0 +1,13 @@
|
||||
.mb-2.d-flex.align-items-center(*ngFor='let pair of vars')
|
||||
.input-group
|
||||
input.form-control.w-25([(ngModel)]='pair.key', (blur)='emitUpdate()', placeholder='Variable name')
|
||||
.input-group-append
|
||||
.input-group-text =
|
||||
input.form-control.w-50([(ngModel)]='pair.value', (blur)='emitUpdate()', placeholder='Value')
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='removeEnvironmentVar(pair.key)')
|
||||
i.fas.fa-trash
|
||||
|
||||
button.btn.btn-secondary((click)='addEnvironmentVar()')
|
||||
i.fas.fa-plus.mr-2
|
||||
span Add
|
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
47
tabby-local/src/components/environmentEditor.component.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, Output, Input } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'environment-editor',
|
||||
template: require('./environmentEditor.component.pug'),
|
||||
styles: [require('./environmentEditor.component.scss')],
|
||||
})
|
||||
export class EnvironmentEditorComponent {
|
||||
@Output() modelChange = new Subject<any>()
|
||||
vars: { key: string, value: string }[] = []
|
||||
private cachedModel: any
|
||||
|
||||
@Input() get model (): any {
|
||||
return this.cachedModel
|
||||
}
|
||||
|
||||
set model (value) {
|
||||
this.vars = Object.entries(value).map(([k, v]) => ({ key: k, value: v as string }))
|
||||
this.cachedModel = this.getModel()
|
||||
}
|
||||
|
||||
getModel () {
|
||||
const model = {}
|
||||
for (const pair of this.vars) {
|
||||
model[pair.key] = pair.value
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
emitUpdate () {
|
||||
this.cachedModel = this.getModel()
|
||||
this.modelChange.next(this.cachedModel)
|
||||
}
|
||||
|
||||
addEnvironmentVar () {
|
||||
this.vars.push({ key: '', value: '' })
|
||||
}
|
||||
|
||||
removeEnvironmentVar (key: string) {
|
||||
this.vars = this.vars.filter(x => x.key !== key)
|
||||
this.emitUpdate()
|
||||
}
|
||||
|
||||
}
|
104
tabby-local/src/components/shellSettingsTab.component.pug
Normal file
@@ -0,0 +1,104 @@
|
||||
h3.mb-3 Shell
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Profile
|
||||
.description Default profile for new tabs
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.terminal.profile',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option(
|
||||
*ngFor='let profile of profiles',
|
||||
[ngValue]='terminal.getProfileID(profile)'
|
||||
) {{profile.name}}
|
||||
|
||||
|
||||
.form-line(*ngIf='isConPTYAvailable')
|
||||
.header
|
||||
.title Use ConPTY
|
||||
.description Enables the experimental Windows ConPTY API
|
||||
|
||||
toggle(
|
||||
[(ngModel)]='config.store.terminal.useConPTY',
|
||||
(ngModelChange)='config.save()'
|
||||
)
|
||||
|
||||
.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.useConPTY && isConPTYAvailable && !isConPTYStable')
|
||||
.mr-auto Windows 10 build 18309 or above is recommended for ConPTY
|
||||
|
||||
.alert.alert-info.d-flex.align-items-center(*ngIf='config.store.terminal.profile.startsWith("WSL") && (!config.store.terminal.useConPTY)')
|
||||
.mr-auto WSL terminal only supports TrueColor with ConPTY
|
||||
|
||||
.form-line(*ngIf='config.store.terminal.profile == "custom-shell"')
|
||||
.header
|
||||
.title Custom shell
|
||||
|
||||
input.form-control(
|
||||
type='text',
|
||||
[(ngModel)]='config.store.terminal.customShell',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Working directory
|
||||
.input-group
|
||||
input.form-control(
|
||||
type='text',
|
||||
placeholder='Home directory',
|
||||
[(ngModel)]='config.store.terminal.workingDirectory',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
.input-group-append
|
||||
button.btn.btn-secondary((click)='pickWorkingDirectory()')
|
||||
i.fas.fa-folder-open
|
||||
|
||||
.form-line
|
||||
.header
|
||||
.title Directory for new tabs
|
||||
|
||||
select.form-control(
|
||||
[(ngModel)]='config.store.terminal.alwaysUseWorkingDirectory',
|
||||
(ngModelChange)='config.save()',
|
||||
)
|
||||
option([ngValue]='false') Same as active tab's directory
|
||||
option([ngValue]='true') The working directory from above
|
||||
|
||||
.form-line.align-items-start
|
||||
.header
|
||||
.title Environment
|
||||
.description Inject additional environment variables
|
||||
|
||||
environment-editor([(model)]='this.config.store.terminal.environment')
|
||||
|
||||
.form-line(*ngIf='config.store.terminal.profiles.length > 0')
|
||||
.header
|
||||
.title Show default profiles in the selector
|
||||
.description If disabled, only custom profiles will show up in the profile selector
|
||||
|
||||
toggle(
|
||||
[(ngModel)]='config.store.terminal.showDefaultProfiles',
|
||||
(ngModelChange)='config.save()'
|
||||
)
|
||||
|
||||
h3.mt-3 Saved Profiles
|
||||
|
||||
.list-group.list-group-flush.mt-3.mb-3
|
||||
.list-group-item.list-group-item-action.d-flex.align-items-center(
|
||||
*ngFor='let profile of config.store.terminal.profiles',
|
||||
(click)='editProfile(profile)',
|
||||
)
|
||||
.mr-auto
|
||||
div {{profile.name}}
|
||||
.text-muted {{profile.sessionOptions.command}}
|
||||
button.btn.btn-outline-danger.ml-1((click)='$event.stopPropagation(); deleteProfile(profile)')
|
||||
i.fas.fa-trash
|
||||
|
||||
.pb-4(ngbDropdown, placement='top-left')
|
||||
button.btn.btn-primary(ngbDropdownToggle)
|
||||
i.fas.fa-fw.fa-plus
|
||||
| New profile
|
||||
div(ngbDropdownMenu)
|
||||
button.dropdown-item(*ngFor='let shell of shells', (click)='newProfile(shell)') {{shell.name}}
|
93
tabby-local/src/components/shellSettingsTab.component.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { ConfigService, HostAppService, Platform, WIN_BUILD_CONPTY_SUPPORTED, WIN_BUILD_CONPTY_STABLE, isWindowsBuild } from 'tabby-core'
|
||||
import { ElectronService, ElectronHostWindow } from 'tabby-electron'
|
||||
import { EditProfileModalComponent } from './editProfileModal.component'
|
||||
import { Shell, Profile } from '../api'
|
||||
import { TerminalService } from '../services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
template: require('./shellSettingsTab.component.pug'),
|
||||
})
|
||||
export class ShellSettingsTabComponent {
|
||||
shells: Shell[] = []
|
||||
profiles: Profile[] = []
|
||||
Platform = Platform
|
||||
isConPTYAvailable: boolean
|
||||
isConPTYStable: boolean
|
||||
private configSubscription: Subscription
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
public hostApp: HostAppService,
|
||||
public hostWindow: ElectronHostWindow,
|
||||
public terminal: TerminalService,
|
||||
private electron: ElectronService,
|
||||
private ngbModal: NgbModal,
|
||||
) {
|
||||
config.store.terminal.environment = config.store.terminal.environment || {}
|
||||
this.configSubscription = this.config.changed$.subscribe(() => {
|
||||
this.reload()
|
||||
})
|
||||
this.reload()
|
||||
|
||||
this.isConPTYAvailable = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED)
|
||||
this.isConPTYStable = isWindowsBuild(WIN_BUILD_CONPTY_STABLE)
|
||||
}
|
||||
|
||||
async ngOnInit (): Promise<void> {
|
||||
this.shells = (await this.terminal.shells$.toPromise())!
|
||||
}
|
||||
|
||||
ngOnDestroy (): void {
|
||||
this.configSubscription.unsubscribe()
|
||||
}
|
||||
|
||||
async reload (): Promise<void> {
|
||||
this.profiles = await this.terminal.getProfiles({ includeHidden: true })
|
||||
}
|
||||
|
||||
async pickWorkingDirectory (): Promise<void> {
|
||||
const profile = await this.terminal.getProfileByID(this.config.store.terminal.profile)
|
||||
const shell = this.shells.find(x => x.id === profile?.shell)
|
||||
if (!shell) {
|
||||
return
|
||||
}
|
||||
const paths = (await this.electron.dialog.showOpenDialog(
|
||||
this.hostWindow.getWindow(),
|
||||
{
|
||||
defaultPath: shell.fsBase,
|
||||
properties: ['openDirectory', 'showHiddenFiles'],
|
||||
}
|
||||
)).filePaths
|
||||
this.config.store.terminal.workingDirectory = paths[0]
|
||||
}
|
||||
|
||||
newProfile (shell: Shell): void {
|
||||
const profile: Profile = {
|
||||
name: shell.name ?? '',
|
||||
shell: shell.id,
|
||||
sessionOptions: this.terminal.optionsFromShell(shell),
|
||||
}
|
||||
this.config.store.terminal.profiles = [profile, ...this.config.store.terminal.profiles]
|
||||
this.config.save()
|
||||
this.reload()
|
||||
}
|
||||
|
||||
editProfile (profile: Profile): void {
|
||||
const modal = this.ngbModal.open(EditProfileModalComponent)
|
||||
modal.componentInstance.profile = Object.assign({}, profile)
|
||||
modal.result.then(result => {
|
||||
Object.assign(profile, result)
|
||||
this.config.save()
|
||||
})
|
||||
}
|
||||
|
||||
deleteProfile (profile: Profile): void {
|
||||
this.config.store.terminal.profiles = this.config.store.terminal.profiles.filter(x => x !== profile)
|
||||
this.config.save()
|
||||
this.reload()
|
||||
}
|
||||
}
|
107
tabby-local/src/components/terminalTab.component.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Component, Input, Injector } from '@angular/core'
|
||||
import { BaseTabProcess, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from 'tabby-core'
|
||||
import { BaseTerminalTabComponent } from 'tabby-terminal'
|
||||
import { SessionOptions } from '../api'
|
||||
import { Session } from '../session'
|
||||
|
||||
/** @hidden */
|
||||
@Component({
|
||||
selector: 'terminalTab',
|
||||
template: BaseTerminalTabComponent.template,
|
||||
styles: BaseTerminalTabComponent.styles,
|
||||
animations: BaseTerminalTabComponent.animations,
|
||||
})
|
||||
export class TerminalTabComponent extends BaseTerminalTabComponent {
|
||||
@Input() sessionOptions: SessionOptions
|
||||
session: Session|null = null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
||||
constructor (
|
||||
injector: Injector,
|
||||
) {
|
||||
super(injector)
|
||||
}
|
||||
|
||||
ngOnInit (): void {
|
||||
this.logger = this.log.create('terminalTab')
|
||||
this.session = new Session(this.injector)
|
||||
|
||||
const isConPTY = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED) && this.config.store.terminal.useConPTY
|
||||
|
||||
this.subscribeUntilDestroyed(this.hotkeys.matchedHotkey, hotkey => {
|
||||
if (!this.hasFocus) {
|
||||
return
|
||||
}
|
||||
switch (hotkey) {
|
||||
case 'home':
|
||||
this.sendInput(isConPTY ? '\x1b[H' : '\x1bOH')
|
||||
break
|
||||
case 'end':
|
||||
this.sendInput(isConPTY ? '\x1b[F' : '\x1bOF')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
super.ngOnInit()
|
||||
}
|
||||
|
||||
protected onFrontendReady (): void {
|
||||
this.initializeSession(this.size.columns, this.size.rows)
|
||||
this.savedStateIsLive = this.sessionOptions.restoreFromPTYID === this.session?.getPTYID()
|
||||
super.onFrontendReady()
|
||||
}
|
||||
|
||||
initializeSession (columns: number, rows: number): void {
|
||||
this.session!.start({
|
||||
...this.sessionOptions,
|
||||
width: columns,
|
||||
height: rows,
|
||||
})
|
||||
|
||||
this.attachSessionHandlers(true)
|
||||
this.recoveryStateChangedHint.next()
|
||||
}
|
||||
|
||||
async getRecoveryToken (): Promise<any> {
|
||||
const cwd = this.session ? await this.session.getWorkingDirectory() : null
|
||||
return {
|
||||
type: 'app:terminal-tab',
|
||||
sessionOptions: {
|
||||
...this.sessionOptions,
|
||||
cwd: cwd ?? this.sessionOptions.cwd,
|
||||
restoreFromPTYID: this.session?.getPTYID(),
|
||||
},
|
||||
savedState: this.frontend?.saveState(),
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentProcess (): Promise<BaseTabProcess|null> {
|
||||
const children = await this.session?.getChildProcesses()
|
||||
if (!children?.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name: children[0].command,
|
||||
}
|
||||
}
|
||||
|
||||
async canClose (): Promise<boolean> {
|
||||
const children = await this.session?.getChildProcesses()
|
||||
if (!children?.length) {
|
||||
return true
|
||||
}
|
||||
return (await this.platform.showMessageBox(
|
||||
{
|
||||
type: 'warning',
|
||||
message: `"${children[0].command}" is still running. Close?`,
|
||||
buttons: ['Cancel', 'Kill'],
|
||||
defaultId: 1,
|
||||
}
|
||||
)).response === 1
|
||||
}
|
||||
|
||||
ngOnDestroy (): void {
|
||||
super.ngOnDestroy()
|
||||
this.session?.destroy()
|
||||
}
|
||||
}
|
62
tabby-local/src/config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ConfigProvider, Platform } from 'tabby-core'
|
||||
|
||||
/** @hidden */
|
||||
export class TerminalConfigProvider extends ConfigProvider {
|
||||
defaults = {
|
||||
hotkeys: {
|
||||
'copy-current-path': [],
|
||||
shell: {
|
||||
__nonStructural: true,
|
||||
},
|
||||
profile: {
|
||||
__nonStructural: true,
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
autoOpen: false,
|
||||
customShell: '',
|
||||
workingDirectory: '',
|
||||
alwaysUseWorkingDirectory: false,
|
||||
useConPTY: true,
|
||||
showDefaultProfiles: true,
|
||||
environment: {},
|
||||
profiles: [],
|
||||
},
|
||||
}
|
||||
|
||||
platformDefaults = {
|
||||
[Platform.macOS]: {
|
||||
terminal: {
|
||||
shell: 'default',
|
||||
profile: 'user-default',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
'⌘-T',
|
||||
],
|
||||
},
|
||||
},
|
||||
[Platform.Windows]: {
|
||||
terminal: {
|
||||
shell: 'clink',
|
||||
profile: 'cmd-clink',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
'Ctrl-Shift-T',
|
||||
],
|
||||
},
|
||||
},
|
||||
[Platform.Linux]: {
|
||||
terminal: {
|
||||
shell: 'default',
|
||||
profile: 'user-default',
|
||||
},
|
||||
hotkeys: {
|
||||
'new-tab': [
|
||||
'Ctrl-Shift-T',
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
29
tabby-local/src/hotkeys.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HotkeyDescription, HotkeyProvider } from 'tabby-core'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class LocalTerminalHotkeyProvider extends HotkeyProvider {
|
||||
hotkeys: HotkeyDescription[] = [
|
||||
{
|
||||
id: 'new-tab',
|
||||
name: 'New tab',
|
||||
},
|
||||
]
|
||||
|
||||
constructor (
|
||||
private terminal: TerminalService,
|
||||
) { super() }
|
||||
|
||||
async provide (): Promise<HotkeyDescription[]> {
|
||||
const profiles = await this.terminal.getProfiles()
|
||||
return [
|
||||
...this.hotkeys,
|
||||
...profiles.map(profile => ({
|
||||
id: `profile.${this.terminal.getProfileID(profile)}`,
|
||||
name: `New tab: ${profile.name}`,
|
||||
})),
|
||||
]
|
||||
}
|
||||
}
|
1
tabby-local/src/icons/alpine.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="300" height="300" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" font-family="Roboto" font-size="14" text-anchor="middle" viewBox="0 0 65 57"><defs><style type="text/css"/></defs><use x=".5" y=".5" xlink:href="#A"/><symbol id="A" overflow="visible"><g fill="#0d597f" fill-rule="nonzero" stroke="none"><path d="M23.252 34.527v-6.745l-4.855 4.864c.474.333.968.635 1.48.906.463.243.87.434 1.303.58a7.97 7.97 0 0 0 1.13.304c.348.064.66.093.95.096m24.822-.562a1.17 1.17 0 0 0 .142.1c.123.078.252.145.385.203a2.93 2.93 0 0 0 .637.194c.296.06.598.088.9.087a5.84 5.84 0 0 0 .955-.087 7.24 7.24 0 0 0 1.138-.301c.453-.16.895-.354 1.32-.58.52-.274 1.02-.58 1.503-.918l-3.685-3.6-12.2-12.258-5.356 5.356-7.23-7.455L8.44 32.647c.48.337.98.644 1.5.918.47.246.9.434 1.317.58a7.18 7.18 0 0 0 1.135.301 5.53 5.53 0 0 0 .955.087 4.53 4.53 0 0 0 .9-.087 3.29 3.29 0 0 0 .637-.194c.134-.054.263-.12.385-.197l.145-.104 8.193-8.193 2.924-2.808 8.106 8.106 2.837 2.912c.046.037.094.07.145.1.122.078.25.145.385.2a3.49 3.49 0 0 0 .637.194c.255.052.556.087.903.087a5.84 5.84 0 0 0 .955-.087c.387-.068.768-.168 1.138-.3a9.94 9.94 0 0 0 1.32-.579c.52-.274 1.02-.58 1.503-.918l-6.508-6.37 1.2-1.2 5.63 5.63 3.283 3.254M47.996.02l15.998 27.714-15.99 27.694H15.998L0 27.714 15.998 0z"/><path d="M38.022 26.367L33.76 22.11l.304-.304 4.3 4.244z"/></g></symbol></svg>
|
After Width: | Height: | Size: 1.4 KiB |
1
tabby-local/src/icons/clink.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-windows fa-w-14 fa-2x" data-icon="windows" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="#0ff" stroke="none" stroke-width="1" d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
After Width: | Height: | Size: 392 B |
1
tabby-local/src/icons/cmd.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-windows fa-w-14 fa-2x" data-icon="windows" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="#fff" stroke="none" stroke-width="1" d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
|
After Width: | Height: | Size: 392 B |
1
tabby-local/src/icons/cmder-powershell.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-lambda fa-w-14 fa-2x" data-icon="lambda" data-prefix="fas" focusable="false" role="img" viewBox="0 0 448 512"><path fill="#0ff" stroke="none" stroke-width="1" d="M424 384h-43.5L197.6 48.68A32.018 32.018 0 0 0 169.5 32H24C10.75 32 0 42.74 0 56v48c0 13.25 10.75 24 24 24h107.5l4.63 8.49L3.25 446.55C-3.53 462.38 8.08 480 25.31 480h52.23c9.6 0 18.28-5.72 22.06-14.55l95.02-221.72L314.4 463.32A32.018 32.018 0 0 0 342.5 480H424c13.25 0 24-10.75 24-24v-48c0-13.26-10.75-24-24-24z"/></svg>
|
After Width: | Height: | Size: 567 B |
1
tabby-local/src/icons/cmder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-lambda fa-w-14 fa-2x" data-icon="lambda" data-prefix="fas" focusable="false" role="img" viewBox="0 0 448 512"><path fill="#8ab91d" stroke="none" stroke-width="1" d="M424 384h-43.5L197.6 48.68A32.018 32.018 0 0 0 169.5 32H24C10.75 32 0 42.74 0 56v48c0 13.25 10.75 24 24 24h107.5l4.63 8.49L3.25 446.55C-3.53 462.38 8.08 480 25.31 480h52.23c9.6 0 18.28-5.72 22.06-14.55l95.02-221.72L314.4 463.32A32.018 32.018 0 0 0 342.5 480H424c13.25 0 24-10.75 24-24v-48c0-13.26-10.75-24-24-24z"/></svg>
|
After Width: | Height: | Size: 570 B |
1
tabby-local/src/icons/cygwin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g stroke-linejoin="round" stroke-width="4"><path fill="#eee" d="M94,19l-28-9h-39c-9,0-19,9-19,19v47c0,9,10,19,19,19h39l28-10l-28-9h-26c-9,0-13-3-13-13v-22c0-10,4-13,13-13h26z"/><path fill="#0f0" d="M94,52l-41-11h-13v3c10,0,10,16,0,16v3h13z"/></g></svg>
|
After Width: | Height: | Size: 315 B |
1
tabby-local/src/icons/debian.svg
Normal file
After Width: | Height: | Size: 5.0 KiB |
1
tabby-local/src/icons/git-bash.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-git-alt fa-w-14 fa-3x" data-icon="git-alt" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="#f05033" stroke="none" stroke-width="1" d="M439.55 236.05L244 40.45a28.87 28.87 0 0 0-40.81 0l-40.66 40.63 51.52 51.52c27.06-9.14 52.68 16.77 43.39 43.68l49.66 49.66c34.23-11.8 61.18 31 35.47 56.69-26.49 26.49-70.21-2.87-56-37.34L240.22 199v121.85c25.3 12.54 22.26 41.85 9.08 55a34.34 34.34 0 0 1-48.55 0c-17.57-17.6-11.07-46.91 11.25-56v-123c-20.8-8.51-24.6-30.74-18.64-45L142.57 101 8.45 235.14a28.86 28.86 0 0 0 0 40.81l195.61 195.6a28.86 28.86 0 0 0 40.8 0l194.69-194.69a28.86 28.86 0 0 0 0-40.81z"/></svg>
|
After Width: | Height: | Size: 718 B |
1
tabby-local/src/icons/linux.svg
Normal file
After Width: | Height: | Size: 17 KiB |
1
tabby-local/src/icons/plus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-plus fa-w-12 fa-3x" data-icon="plus" data-prefix="fal" focusable="false" role="img" viewBox="0 0 384 512"><path fill="#fff" stroke="none" stroke-width="1" d="M376 232H216V72c0-4.42-3.58-8-8-8h-32c-4.42 0-8 3.58-8 8v160H8c-4.42 0-8 3.58-8 8v32c0 4.42 3.58 8 8 8h160v160c0 4.42 3.58 8 8 8h32c4.42 0 8-3.58 8-8V280h160c4.42 0 8-3.58 8-8v-32c0-4.42-3.58-8-8-8z"/></svg>
|
After Width: | Height: | Size: 449 B |
1
tabby-local/src/icons/powershell-core.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-terminal fa-w-20 fa-2x" data-icon="terminal" data-prefix="fas" focusable="false" role="img" viewBox="0 0 640 512"><path fill="purple" stroke="none" stroke-width="1" d="M257.981 272.971L63.638 467.314c-9.373 9.373-24.569 9.373-33.941 0L7.029 444.647c-9.357-9.357-9.375-24.522-.04-33.901L161.011 256 6.99 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L257.981 239.03c9.373 9.372 9.373 24.568 0 33.941zM640 456v-32c0-13.255-10.745-24-24-24H312c-13.255 0-24 10.745-24 24v32c0 13.255 10.745 24 24 24h304c13.255 0 24-10.745 24-24z"/></svg>
|
After Width: | Height: | Size: 662 B |
1
tabby-local/src/icons/powershell.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-terminal fa-w-20 fa-2x" data-icon="terminal" data-prefix="fas" focusable="false" role="img" viewBox="0 0 640 512"><path fill="#0ff" stroke="none" stroke-width="1" d="M257.981 272.971L63.638 467.314c-9.373 9.373-24.569 9.373-33.941 0L7.029 444.647c-9.357-9.357-9.375-24.522-.04-33.901L161.011 256 6.99 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L257.981 239.03c9.373 9.372 9.373 24.568 0 33.941zM640 456v-32c0-13.255-10.745-24-24-24H312c-13.255 0-24 10.745-24 24v32c0 13.255 10.745 24 24 24h304c13.255 0 24-10.745 24-24z"/></svg>
|
After Width: | Height: | Size: 660 B |
1
tabby-local/src/icons/profiles.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" class="svg-inline--fa fa-window-restore fa-w-16 fa-3x" data-icon="window-restore" data-prefix="fal" focusable="false" role="img" viewBox="0 0 512 512"><path fill="#fff" stroke="none" stroke-width="1" d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zM32 144c0-8.8 7.2-16 16-16h320c8.8 0 16 7.2 16 16v80H32v-80zm352 320c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V256h352v208zm96-96c0 8.8-7.2 16-16 16h-48V144c0-26.5-21.5-48-48-48H128V48c0-8.8 7.2-16 16-16h320c8.8 0 16 7.2 16 16v320z"/></svg>
|
After Width: | Height: | Size: 665 B |
1
tabby-local/src/icons/suse.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="svg8" width="256" height="256" version="1.1" viewBox="0 0 256 256"><metadata id="metadata5"/><g id="layer1" transform="matrix(6.9999999,0,0,6.9999736,16,-7617.6082)" style="fill:#73ba25;fill-opacity:1"><g style="fill:#73ba25;fill-opacity:1" id="g838" transform="matrix(0.26458333,0,0,0.26458333,-10.590624,-38.473045)"><circle id="path872" cx="507.464" cy="3582.83" r="0" style="opacity:.3;fill:#73ba25;fill-opacity:1;stroke:none;stroke-width:1.90573967;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/><path id="path819" d="m 100.5002,4267.059 a 60.472077,60.472442 0 0 0 -47.607132,23.2988 c 7.375955,1.9706 12.596534,3.6642 14.160064,4.1895 0.0245,-0.9593 0.183589,-9.5391 0.183589,-9.5391 0,0 0.0202,-0.1964 0.124999,-0.2988 0.13497,-0.1318 0.330078,-0.092 0.330078,-0.092 1.939989,0.281 43.348482,6.4189 60.802382,16.5899 2.15548,1.261 3.21941,2.6017 4.5488,3.9609 4.82477,4.99 11.19998,25.7389 11.88469,30.0176 0.0269,0.1681 -0.18083,0.3507 -0.26953,0.4199 h -0.002 c -0.4957,0.3868 -1.03554,0.789 -1.57616,1.1484 -4.12998,2.7709 -13.64449,9.4312 -25.85142,8.3438 -10.96493,-0.97 -25.290388,-7.2597 -42.560284,-18.6387 1.69799,3.9756 3.371,7.9635 5.04489,11.9492 2.500985,1.299 26.640524,13.5997 38.554464,13.3594 9.59593,-0.1999 19.85892,-4.8804 23.96469,-7.3516 0,0 0.90227,-0.5436 1.29491,-0.2402 0.4295,0.3318 0.31068,0.8402 0.20898,1.3594 -0.25259,1.1786 -0.82764,3.3289 -1.21873,4.3496 l -0.33008,0.832 c -0.46999,1.2592 -0.92111,2.4296 -1.79101,3.1504 -2.41868,2.1993 -6.27908,3.9491 -12.32804,6.5781 -9.34995,4.09 -24.51938,6.6911 -38.603293,6.6016 -5.04437,-0.1123 -9.91781,-0.672 -14.197174,-1.1719 -8.782187,-0.9915 -15.927854,-1.7959 -20.285038,1.3555 a 60.472077,60.472442 0 0 0 45.517305,20.7734 60.472077,60.472442 0 0 0 60.47229,-60.4726 60.472077,60.472442 0 0 0 -60.47229,-60.4727 z m 13.4882,35.0879 c -4.73327,-0.1509 -9.24668,1.5194 -12.70695,4.75 -3.458684,3.2199 -5.437952,7.6097 -5.613251,12.3399 -0.326998,9.7581 7.334241,17.9803 17.083881,18.3398 4.75477,0.1596 9.25839,-1.5118 12.71867,-4.7617 3.44988,-3.2099 5.42915,-7.5999 5.61325,-12.3301 0.335,-9.7494 -7.33546,-17.9889 -17.0956,-18.3379 z m -0.14844,5.2188 c 6.82096,0.242 12.16127,5.972 11.93157,12.791 -0.1053,3.2885 -1.49253,6.3369 -3.90231,8.5976 -2.41329,2.2502 -5.56743,3.4203 -8.87691,3.3203 -6.80516,-0.251 -12.14564,-5.9877 -11.91594,-12.8085 0.1,-3.3008 1.51475,-6.3495 3.91403,-8.5997 2.39919,-2.2502 5.53828,-3.42 8.84956,-3.3007 z m 2.02147,6.2011 c -3.03067,0 -5.47848,1.631 -5.47848,3.6602 0,2.01 2.44781,3.6504 5.47848,3.6504 3.02888,0 5.4863,-1.6405 5.4863,-3.6504 0,-2.0292 -2.45572,-3.6602 -5.4863,-3.6602 z" style="opacity:1;fill:#73ba25;fill-opacity:1;stroke:none;stroke-width:1.90559804;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/></g></g></svg>
|
After Width: | Height: | Size: 2.8 KiB |
1
tabby-local/src/icons/ubuntu.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 256 256"><g><path fill="#DD4814" d="M255.637396,127.683191 C255.637396,198.196551 198.47207,255.363378 127.954205,255.363378 C57.4348387,255.363378 0.27026393,198.196551 0.27026393,127.683191 C0.27026393,57.1653255 57.4355894,0 127.954205,0 C198.472821,0 255.637396,57.1653255 255.637396,127.683191 L255.637396,127.683191 Z"/><path fill="#FFF" d="M41.1334194,110.63254 C31.7139707,110.63254 24.0827683,118.264493 24.0827683,127.683191 C24.0827683,137.097384 31.7139707,144.728587 41.1334194,144.728587 C50.5476129,144.728587 58.1788152,137.097384 58.1788152,127.683191 C58.1788152,118.264493 50.5476129,110.63254 41.1334194,110.63254 L41.1334194,110.63254 Z M162.848282,188.111202 C154.694569,192.820551 151.898839,203.240727 156.608938,211.389935 C161.313032,219.543648 171.733208,222.338628 179.886921,217.629279 C188.039883,212.925185 190.835613,202.505009 186.126264,194.350545 C181.42217,186.202088 170.995988,183.407109 162.848282,188.111202 L162.848282,188.111202 Z M78.1618299,127.683191 C78.1618299,110.836739 86.5295015,95.9534545 99.3332551,86.9409032 L86.8703343,66.0667683 C71.9555191,76.0365044 60.8581818,91.271132 56.2464282,109.113806 C61.6276833,113.504845 65.0720469,120.189372 65.0720469,127.68244 C65.0720469,135.171003 61.6276833,141.855531 56.2464282,146.246569 C60.852176,164.094499 71.9495132,179.329877 86.8703343,189.299613 L99.3332551,168.420223 C86.5295015,159.412927 78.1618299,144.530393 78.1618299,127.683191 L78.1618299,127.683191 Z M127.954205,77.8855601 C153.967109,77.8855601 175.30895,97.8302874 177.549138,123.265877 L201.839859,122.907777 C200.644692,104.129689 192.441431,87.2719765 179.836622,74.875871 C173.354792,77.3247625 165.86773,76.9501466 159.396411,73.2197537 C152.91383,69.4788504 148.849361,63.1681877 147.738276,56.3177478 C141.438123,54.5790499 134.807648,53.6271202 127.952704,53.6271202 C116.168446,53.6271202 105.026815,56.3950733 95.1344047,61.2913548 L106.979472,82.5175836 C113.351695,79.5521877 120.460387,77.8855601 127.954205,77.8855601 L127.954205,77.8855601 Z M127.954205,177.475566 C120.460387,177.475566 113.351695,175.808188 106.980223,172.843543 L95.1351554,194.069021 C105.027566,198.971308 116.169196,201.740012 127.954205,201.740012 C134.80915,201.740012 141.439625,200.787331 147.739026,199.043378 C148.850111,192.192938 152.916082,185.888282 159.397161,182.140622 C165.872985,178.404223 173.355543,178.036364 179.837372,180.485255 C192.442182,168.08915 200.645443,151.231437 201.84061,132.453349 L177.543883,132.095249 C175.30895,157.537595 153.967859,177.475566 127.954205,177.475566 L127.954205,177.475566 Z M162.842276,67.2446686 C170.995988,71.9532669 181.416915,69.1642933 186.121009,61.0105806 C190.830358,52.856868 188.041384,42.4359413 179.886921,37.7258416 C171.733208,33.0217478 161.313032,35.8167273 156.602182,43.9704399 C151.898839,52.1196481 154.693818,62.5405748 162.842276,67.2446686 L162.842276,67.2446686 Z"/></g></svg>
|
After Width: | Height: | Size: 3.0 KiB |
134
tabby-local/src/index.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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, { HostAppService, ToolbarButtonProvider, TabRecoveryProvider, ConfigProvider, HotkeysService, HotkeyProvider, TabContextMenuItemProvider, CLIHandler, ConfigService } from 'tabby-core'
|
||||
import TabbyTerminalModule from 'tabby-terminal'
|
||||
import TabbyElectronPlugin from 'tabby-electron'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
import { ShellSettingsTabComponent } from './components/shellSettingsTab.component'
|
||||
import { EditProfileModalComponent } from './components/editProfileModal.component'
|
||||
import { EnvironmentEditorComponent } from './components/environmentEditor.component'
|
||||
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
import { DockMenuService } from './services/dockMenu.service'
|
||||
|
||||
import { ButtonProvider } from './buttonProvider'
|
||||
import { RecoveryProvider } from './recoveryProvider'
|
||||
import { ShellProvider } from './api'
|
||||
import { ShellSettingsTabProvider } from './settings'
|
||||
import { TerminalConfigProvider } from './config'
|
||||
import { LocalTerminalHotkeyProvider } from './hotkeys'
|
||||
import { NewTabContextMenu, SaveAsProfileContextMenu } from './tabContextMenu'
|
||||
|
||||
import { CmderShellProvider } from './shells/cmder'
|
||||
import { CustomShellProvider } from './shells/custom'
|
||||
import { Cygwin32ShellProvider } from './shells/cygwin32'
|
||||
import { Cygwin64ShellProvider } from './shells/cygwin64'
|
||||
import { GitBashShellProvider } from './shells/gitBash'
|
||||
import { LinuxDefaultShellProvider } from './shells/linuxDefault'
|
||||
import { MacOSDefaultShellProvider } from './shells/macDefault'
|
||||
import { POSIXShellsProvider } from './shells/posix'
|
||||
import { PowerShellCoreShellProvider } from './shells/powershellCore'
|
||||
import { WindowsDefaultShellProvider } from './shells/winDefault'
|
||||
import { WindowsStockShellsProvider } from './shells/windowsStock'
|
||||
import { WSLShellProvider } from './shells/wsl'
|
||||
|
||||
import { AutoOpenTabCLIHandler, OpenPathCLIHandler, TerminalCLIHandler } from './cli'
|
||||
|
||||
/** @hidden */
|
||||
@NgModule({
|
||||
imports: [
|
||||
BrowserModule,
|
||||
FormsModule,
|
||||
NgbModule,
|
||||
ToastrModule,
|
||||
TabbyCorePlugin,
|
||||
TabbyElectronPlugin,
|
||||
TabbyTerminalModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: SettingsTabProvider, useClass: ShellSettingsTabProvider, multi: true },
|
||||
|
||||
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
|
||||
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
|
||||
{ provide: ConfigProvider, useClass: TerminalConfigProvider, multi: true },
|
||||
{ provide: HotkeyProvider, useClass: LocalTerminalHotkeyProvider, multi: true },
|
||||
|
||||
{ provide: ShellProvider, useClass: WindowsDefaultShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: MacOSDefaultShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: LinuxDefaultShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: WindowsStockShellsProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: PowerShellCoreShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: CmderShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: CustomShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: Cygwin32ShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: Cygwin64ShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: GitBashShellProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: POSIXShellsProvider, multi: true },
|
||||
{ provide: ShellProvider, useClass: WSLShellProvider, multi: true },
|
||||
|
||||
{ provide: TabContextMenuItemProvider, useClass: NewTabContextMenu, multi: true },
|
||||
{ provide: TabContextMenuItemProvider, useClass: SaveAsProfileContextMenu, multi: true },
|
||||
|
||||
{ provide: CLIHandler, useClass: TerminalCLIHandler, multi: true },
|
||||
{ provide: CLIHandler, useClass: OpenPathCLIHandler, multi: true },
|
||||
{ provide: CLIHandler, useClass: AutoOpenTabCLIHandler, multi: true },
|
||||
|
||||
// For WindowsDefaultShellProvider
|
||||
PowerShellCoreShellProvider,
|
||||
WSLShellProvider,
|
||||
WindowsStockShellsProvider,
|
||||
],
|
||||
entryComponents: [
|
||||
TerminalTabComponent,
|
||||
ShellSettingsTabComponent,
|
||||
EditProfileModalComponent,
|
||||
] as any[],
|
||||
declarations: [
|
||||
TerminalTabComponent,
|
||||
ShellSettingsTabComponent,
|
||||
EditProfileModalComponent,
|
||||
EnvironmentEditorComponent,
|
||||
] as any[],
|
||||
exports: [
|
||||
TerminalTabComponent,
|
||||
EnvironmentEditorComponent,
|
||||
],
|
||||
})
|
||||
export default class LocalTerminalModule { // eslint-disable-line @typescript-eslint/no-extraneous-class
|
||||
private constructor (
|
||||
hotkeys: HotkeysService,
|
||||
terminal: TerminalService,
|
||||
hostApp: HostAppService,
|
||||
dockMenu: DockMenuService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
hotkeys.matchedHotkey.subscribe(async (hotkey) => {
|
||||
if (hotkey === 'new-tab') {
|
||||
terminal.openTab()
|
||||
}
|
||||
if (hotkey === 'new-window') {
|
||||
hostApp.newWindow()
|
||||
}
|
||||
if (hotkey.startsWith('profile.')) {
|
||||
const profile = await terminal.getProfileByID(hotkey.split('.')[1])
|
||||
if (profile) {
|
||||
terminal.openTabWithOptions(profile.sessionOptions)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
dockMenu.update()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export { TerminalTabComponent }
|
||||
export { TerminalService, ShellProvider }
|
||||
export * from './api'
|
33
tabby-local/src/recoveryProvider.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { TabRecoveryProvider, RecoveredTab, RecoveryToken } from 'tabby-core'
|
||||
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class RecoveryProvider extends TabRecoveryProvider {
|
||||
async applicableTo (recoveryToken: RecoveryToken): Promise<boolean> {
|
||||
return recoveryToken.type === 'app:terminal-tab'
|
||||
}
|
||||
|
||||
async recover (recoveryToken: RecoveryToken): Promise<RecoveredTab> {
|
||||
return {
|
||||
type: TerminalTabComponent,
|
||||
options: {
|
||||
sessionOptions: recoveryToken.sessionOptions,
|
||||
savedState: recoveryToken.savedState,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
duplicate (recoveryToken: RecoveryToken): RecoveryToken {
|
||||
return {
|
||||
...recoveryToken,
|
||||
sessionOptions: {
|
||||
...recoveryToken.sessionOptions,
|
||||
restoreFromPTYID: null,
|
||||
},
|
||||
savedState: null,
|
||||
}
|
||||
}
|
||||
}
|
47
tabby-local/src/services/dockMenu.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NgZone, Injectable } from '@angular/core'
|
||||
import { ConfigService, HostAppService, Platform } from 'tabby-core'
|
||||
import { ElectronService } from 'tabby-electron'
|
||||
import { TerminalService } from './terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DockMenuService {
|
||||
appVersion: string
|
||||
|
||||
private constructor (
|
||||
private electron: ElectronService,
|
||||
private config: ConfigService,
|
||||
private hostApp: HostAppService,
|
||||
private zone: NgZone,
|
||||
private terminalService: TerminalService,
|
||||
) {
|
||||
config.changed$.subscribe(() => this.update())
|
||||
}
|
||||
|
||||
update (): void {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
this.electron.app.setJumpList(this.config.store.terminal.profiles.length ? [{
|
||||
type: 'custom',
|
||||
name: 'Profiles',
|
||||
items: this.config.store.terminal.profiles.map(profile => ({
|
||||
type: 'task',
|
||||
program: process.execPath,
|
||||
args: `profile "${profile.name}"`,
|
||||
title: profile.name,
|
||||
iconPath: process.execPath,
|
||||
iconIndex: 0,
|
||||
})),
|
||||
}] : null as any)
|
||||
}
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
this.electron.app.dock.setMenu(this.electron.Menu.buildFromTemplate(
|
||||
this.config.store.terminal.profiles.map(profile => ({
|
||||
label: profile.name,
|
||||
click: () => this.zone.run(() => {
|
||||
this.terminalService.openTabWithOptions(profile.sessionOptions)
|
||||
}),
|
||||
}))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
150
tabby-local/src/services/terminal.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import slugify from 'slugify'
|
||||
import { Observable, AsyncSubject } from 'rxjs'
|
||||
import { Injectable, Inject } from '@angular/core'
|
||||
import { AppService, Logger, LogService, ConfigService, SplitTabComponent } from 'tabby-core'
|
||||
import { TerminalTabComponent } from '../components/terminalTab.component'
|
||||
import { ShellProvider, Shell, SessionOptions, Profile } from '../api'
|
||||
import { UACService } from './uac.service'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TerminalService {
|
||||
private shells = new AsyncSubject<Shell[]>()
|
||||
private logger: Logger
|
||||
|
||||
/**
|
||||
* A fresh list of all available shells
|
||||
*/
|
||||
get shells$ (): Observable<Shell[]> { return this.shells }
|
||||
|
||||
/** @hidden */
|
||||
private constructor (
|
||||
private app: AppService,
|
||||
private config: ConfigService,
|
||||
private uac: UACService,
|
||||
@Inject(ShellProvider) private shellProviders: ShellProvider[],
|
||||
log: LogService,
|
||||
) {
|
||||
this.logger = log.create('terminal')
|
||||
|
||||
config.ready$.toPromise().then(() => {
|
||||
this.reloadShells()
|
||||
config.changed$.subscribe(() => {
|
||||
this.reloadShells()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getProfiles ({ includeHidden, skipDefault }: { includeHidden?: boolean, skipDefault?: boolean } = {}): Promise<Profile[]> {
|
||||
const shells = (await this.shells$.toPromise())!
|
||||
return [
|
||||
...this.config.store.terminal.profiles,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
...skipDefault ? [] : shells.filter(x => includeHidden || !x.hidden).map(shell => ({
|
||||
name: shell.name,
|
||||
shell: shell.id,
|
||||
icon: shell.icon,
|
||||
sessionOptions: this.optionsFromShell(shell),
|
||||
isBuiltin: true,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
getProfileID (profile: Profile): string {
|
||||
return slugify(profile.name, { remove: /[:.]/g }).toLowerCase()
|
||||
}
|
||||
|
||||
async getProfileByID (id: string): Promise<Profile|null> {
|
||||
const profiles = await this.getProfiles({ includeHidden: true })
|
||||
return profiles.find(x => this.getProfileID(x) === id) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a new terminal with a specific shell and CWD
|
||||
* @param pause Wait for a keypress when the shell exits
|
||||
*/
|
||||
async openTab (profile?: Profile|null, cwd?: string|null, pause?: boolean): Promise<TerminalTabComponent> {
|
||||
if (!profile) {
|
||||
profile = await this.getProfileByID(this.config.store.terminal.profile)
|
||||
if (!profile) {
|
||||
profile = (await this.getProfiles({ includeHidden: true }))[0]
|
||||
}
|
||||
}
|
||||
|
||||
cwd = cwd ?? profile.sessionOptions.cwd
|
||||
|
||||
if (cwd && !fs.existsSync(cwd)) {
|
||||
console.warn('Ignoring non-existent CWD:', cwd)
|
||||
cwd = null
|
||||
}
|
||||
|
||||
if (!cwd) {
|
||||
if (!this.config.store.terminal.alwaysUseWorkingDirectory) {
|
||||
if (this.app.activeTab instanceof TerminalTabComponent && this.app.activeTab.session) {
|
||||
cwd = await this.app.activeTab.session.getWorkingDirectory()
|
||||
}
|
||||
if (this.app.activeTab instanceof SplitTabComponent) {
|
||||
const focusedTab = this.app.activeTab.getFocusedTab()
|
||||
|
||||
if (focusedTab instanceof TerminalTabComponent && focusedTab.session) {
|
||||
cwd = await focusedTab.session.getWorkingDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
cwd = cwd ?? this.config.store.terminal.workingDirectory
|
||||
}
|
||||
|
||||
this.logger.info(`Starting profile ${profile.name}`, profile)
|
||||
const sessionOptions = {
|
||||
...profile.sessionOptions,
|
||||
pauseAfterExit: pause,
|
||||
cwd: cwd ?? undefined,
|
||||
}
|
||||
|
||||
const tab = this.openTabWithOptions(sessionOptions)
|
||||
if (profile.color) {
|
||||
(this.app.getParentTab(tab) ?? tab).color = profile.color
|
||||
}
|
||||
if (profile.disableDynamicTitle) {
|
||||
tab.enableDynamicTitle = false
|
||||
tab.setTitle(profile.name)
|
||||
}
|
||||
return tab
|
||||
}
|
||||
|
||||
optionsFromShell (shell: Shell): SessionOptions {
|
||||
return {
|
||||
command: shell.command,
|
||||
args: shell.args ?? [],
|
||||
env: shell.env,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a terminal with custom session options
|
||||
*/
|
||||
openTabWithOptions (sessionOptions: SessionOptions): TerminalTabComponent {
|
||||
if (sessionOptions.runAsAdministrator && this.uac.isAvailable) {
|
||||
sessionOptions = this.uac.patchSessionOptionsForUAC(sessionOptions)
|
||||
}
|
||||
this.logger.info('Using session options:', sessionOptions)
|
||||
|
||||
return this.app.openNewTab(
|
||||
TerminalTabComponent,
|
||||
{ sessionOptions }
|
||||
) as TerminalTabComponent
|
||||
}
|
||||
|
||||
private async getShells (): Promise<Shell[]> {
|
||||
const shellLists = await Promise.all(this.config.enabledServices(this.shellProviders).map(x => x.provide()))
|
||||
return shellLists.reduce((a, b) => a.concat(b), [])
|
||||
}
|
||||
|
||||
private async reloadShells () {
|
||||
this.shells = new AsyncSubject<Shell[]>()
|
||||
const shells = await this.getShells()
|
||||
this.logger.debug('Shells list:', shells)
|
||||
this.shells.next(shells)
|
||||
this.shells.complete()
|
||||
}
|
||||
}
|
41
tabby-local/src/services/uac.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild } from 'tabby-core'
|
||||
import { ElectronService } from 'tabby-electron'
|
||||
import { SessionOptions } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UACService {
|
||||
isAvailable = false
|
||||
|
||||
private constructor (
|
||||
private electron: ElectronService,
|
||||
) {
|
||||
this.isAvailable = isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED)
|
||||
}
|
||||
|
||||
patchSessionOptionsForUAC (sessionOptions: SessionOptions): SessionOptions {
|
||||
let helperPath = path.join(
|
||||
path.dirname(this.electron.app.getPath('exe')),
|
||||
'resources',
|
||||
'extras',
|
||||
'UAC.exe',
|
||||
)
|
||||
|
||||
if (process.env.TABBY_DEV) {
|
||||
helperPath = path.join(
|
||||
path.dirname(this.electron.app.getPath('exe')),
|
||||
'..', '..', '..',
|
||||
'extras',
|
||||
'UAC.exe',
|
||||
)
|
||||
}
|
||||
|
||||
const options = { ...sessionOptions }
|
||||
options.args = [options.command, ...options.args ?? []]
|
||||
options.command = helperPath
|
||||
return options
|
||||
}
|
||||
|
||||
}
|
352
tabby-local/src/session.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import * as psNode from 'ps-node'
|
||||
import * as fs from 'mz/fs'
|
||||
import * as os from 'os'
|
||||
import { Injector } from '@angular/core'
|
||||
import { HostAppService, ConfigService, WIN_BUILD_CONPTY_SUPPORTED, isWindowsBuild, Platform, BootstrapData, BOOTSTRAP_DATA } from 'tabby-core'
|
||||
import { BaseSession } from 'tabby-terminal'
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { getWorkingDirectoryFromPID } from 'native-process-working-directory'
|
||||
import { SessionOptions, ChildProcess } from './api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var macOSNativeProcessList = require('macos-native-processlist') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
var windowsProcessTree = require('windows-process-tree') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi
|
||||
const OSC1337Prefix = Buffer.from('\x1b]1337;')
|
||||
const OSC1337Suffix = Buffer.from('\x07')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
export class PTYProxy {
|
||||
private id: string
|
||||
private subscriptions: Map<string, any> = new Map()
|
||||
|
||||
static spawn (...options: any[]): PTYProxy {
|
||||
return new PTYProxy(null, ...options)
|
||||
}
|
||||
|
||||
static restore (id: string): PTYProxy|null {
|
||||
if (ipcRenderer.sendSync('pty:exists', id)) {
|
||||
return new PTYProxy(id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private constructor (id: string|null, ...options: any[]) {
|
||||
if (id) {
|
||||
this.id = id
|
||||
} else {
|
||||
this.id = ipcRenderer.sendSync('pty:spawn', ...options)
|
||||
}
|
||||
}
|
||||
|
||||
getPTYID (): string {
|
||||
return this.id
|
||||
}
|
||||
|
||||
getPID (): number {
|
||||
return ipcRenderer.sendSync('pty:get-pid', this.id)
|
||||
}
|
||||
|
||||
subscribe (event: string, handler: (..._: any[]) => void): void {
|
||||
const key = `pty:${this.id}:${event}`
|
||||
const newHandler = (_event, ...args) => handler(...args)
|
||||
this.subscriptions.set(key, newHandler)
|
||||
ipcRenderer.on(key, newHandler)
|
||||
}
|
||||
|
||||
ackData (length: number): void {
|
||||
ipcRenderer.send('pty:ack-data', this.id, length)
|
||||
}
|
||||
|
||||
unsubscribeAll (): void {
|
||||
for (const k of this.subscriptions.keys()) {
|
||||
ipcRenderer.off(k, this.subscriptions.get(k))
|
||||
}
|
||||
}
|
||||
|
||||
resize (columns: number, rows: number): void {
|
||||
ipcRenderer.send('pty:resize', this.id, columns, rows)
|
||||
}
|
||||
|
||||
write (data: Buffer): void {
|
||||
ipcRenderer.send('pty:write', this.id, data)
|
||||
}
|
||||
|
||||
kill (signal?: string): void {
|
||||
ipcRenderer.send('pty:kill', this.id, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
export class Session extends BaseSession {
|
||||
private pty: PTYProxy|null = null
|
||||
private ptyClosed = false
|
||||
private pauseAfterExit = false
|
||||
private guessedCWD: string|null = null
|
||||
private reportedCWD: string
|
||||
private initialCWD: string|null = null
|
||||
private config: ConfigService
|
||||
private hostApp: HostAppService
|
||||
private bootstrapData: BootstrapData
|
||||
|
||||
constructor (injector: Injector) {
|
||||
super()
|
||||
this.config = injector.get(ConfigService)
|
||||
this.hostApp = injector.get(HostAppService)
|
||||
this.bootstrapData = injector.get(BOOTSTRAP_DATA)
|
||||
}
|
||||
|
||||
start (options: SessionOptions): void {
|
||||
this.name = options.name ?? ''
|
||||
|
||||
let pty: PTYProxy|null = null
|
||||
|
||||
if (options.restoreFromPTYID) {
|
||||
pty = PTYProxy.restore(options.restoreFromPTYID)
|
||||
options.restoreFromPTYID = undefined
|
||||
}
|
||||
|
||||
if (!pty) {
|
||||
const env = {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
TERM_PROGRAM: 'Tabby',
|
||||
...options.env,
|
||||
...this.config.store.terminal.environment || {},
|
||||
}
|
||||
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
env.COMSPEC = this.bootstrapData.executable
|
||||
}
|
||||
|
||||
delete env['']
|
||||
|
||||
if (this.hostApp.platform === Platform.macOS && !process.env.LC_ALL) {
|
||||
const locale = process.env.LC_CTYPE ?? 'en_US.UTF-8'
|
||||
Object.assign(env, {
|
||||
LANG: locale,
|
||||
LC_ALL: locale,
|
||||
LC_MESSAGES: locale,
|
||||
LC_NUMERIC: locale,
|
||||
LC_COLLATE: locale,
|
||||
LC_MONETARY: locale,
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
let cwd = options.cwd || process.env.HOME
|
||||
|
||||
if (!fs.existsSync(cwd)) {
|
||||
console.warn('Ignoring non-existent CWD:', cwd)
|
||||
cwd = undefined
|
||||
}
|
||||
|
||||
pty = PTYProxy.spawn(options.command, options.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: options.width ?? 80,
|
||||
rows: options.height ?? 30,
|
||||
encoding: null,
|
||||
cwd,
|
||||
env: env,
|
||||
// `1` instead of `true` forces ConPTY even if unstable
|
||||
useConpty: (isWindowsBuild(WIN_BUILD_CONPTY_SUPPORTED) && this.config.store.terminal.useConPTY ? 1 : false) as any,
|
||||
})
|
||||
|
||||
this.guessedCWD = cwd ?? null
|
||||
}
|
||||
|
||||
this.pty = pty
|
||||
|
||||
this.truePID = this.pty.getPID()
|
||||
|
||||
setTimeout(async () => {
|
||||
// Retrieve any possible single children now that shell has fully started
|
||||
let processes = await this.getChildProcesses()
|
||||
while (processes.length === 1) {
|
||||
this.truePID = processes[0].pid
|
||||
processes = await this.getChildProcesses()
|
||||
}
|
||||
this.initialCWD = await this.getWorkingDirectory()
|
||||
}, 2000)
|
||||
|
||||
this.open = true
|
||||
|
||||
this.pty.subscribe('data', (array: Uint8Array) => {
|
||||
this.pty!.ackData(array.length)
|
||||
|
||||
let data = Buffer.from(array)
|
||||
data = this.processOSC1337(data)
|
||||
this.emitOutput(data)
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
this.guessWindowsCWD(data.toString())
|
||||
}
|
||||
})
|
||||
|
||||
this.pty.subscribe('exit', () => {
|
||||
if (this.pauseAfterExit) {
|
||||
return
|
||||
} else if (this.open) {
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
this.pty.subscribe('close', () => {
|
||||
this.ptyClosed = true
|
||||
if (this.pauseAfterExit) {
|
||||
this.emitOutput(Buffer.from('\r\nPress any key to close\r\n'))
|
||||
} else if (this.open) {
|
||||
this.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
this.pauseAfterExit = options.pauseAfterExit ?? false
|
||||
|
||||
this.destroyed$.subscribe(() => this.pty!.unsubscribeAll())
|
||||
}
|
||||
|
||||
getPTYID (): string|null {
|
||||
return this.pty?.getPTYID() ?? null
|
||||
}
|
||||
|
||||
resize (columns: number, rows: number): void {
|
||||
this.pty?.resize(columns, rows)
|
||||
}
|
||||
|
||||
write (data: Buffer): void {
|
||||
if (this.ptyClosed) {
|
||||
this.destroy()
|
||||
}
|
||||
if (this.open) {
|
||||
this.pty?.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
kill (signal?: string): void {
|
||||
this.pty?.kill(signal)
|
||||
}
|
||||
|
||||
async getChildProcesses (): Promise<ChildProcess[]> {
|
||||
if (!this.truePID) {
|
||||
return []
|
||||
}
|
||||
if (this.hostApp.platform === Platform.macOS) {
|
||||
const processes = await macOSNativeProcessList.getProcessList()
|
||||
return processes.filter(x => x.ppid === this.truePID).map(p => ({
|
||||
pid: p.pid,
|
||||
ppid: p.ppid,
|
||||
command: p.name,
|
||||
}))
|
||||
}
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
return new Promise<ChildProcess[]>(resolve => {
|
||||
windowsProcessTree.getProcessTree(this.truePID, tree => {
|
||||
resolve(tree ? tree.children.map(child => ({
|
||||
pid: child.pid,
|
||||
ppid: tree.pid,
|
||||
command: child.name,
|
||||
})) : [])
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise<ChildProcess[]>((resolve, reject) => {
|
||||
psNode.lookup({ ppid: this.truePID }, (err, processes) => {
|
||||
if (err) {
|
||||
return reject(err)
|
||||
}
|
||||
resolve(processes as ChildProcess[])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async gracefullyKillProcess (): Promise<void> {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
this.kill()
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.kill('SIGTERM')
|
||||
setImmediate(() => {
|
||||
try {
|
||||
process.kill(this.pty!.getPID(), 0)
|
||||
// still alive
|
||||
setTimeout(() => {
|
||||
this.kill('SIGKILL')
|
||||
resolve()
|
||||
}, 1000)
|
||||
} catch {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
supportsWorkingDirectory (): boolean {
|
||||
return !!(this.truePID || this.reportedCWD || this.guessedCWD)
|
||||
}
|
||||
|
||||
async getWorkingDirectory (): Promise<string|null> {
|
||||
if (this.reportedCWD) {
|
||||
return this.reportedCWD
|
||||
}
|
||||
if (!this.truePID) {
|
||||
return null
|
||||
}
|
||||
let cwd: string|null = null
|
||||
try {
|
||||
cwd = getWorkingDirectoryFromPID(this.truePID)
|
||||
} catch (exc) {
|
||||
console.error(exc)
|
||||
}
|
||||
|
||||
try {
|
||||
cwd = await fs.realpath(cwd)
|
||||
} catch {}
|
||||
|
||||
if (this.hostApp.platform === Platform.Windows && (cwd === this.initialCWD || cwd === process.env.WINDIR)) {
|
||||
// shell doesn't truly change its process' CWD
|
||||
cwd = null
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
cwd = cwd || this.guessedCWD
|
||||
|
||||
try {
|
||||
await fs.access(cwd)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
private guessWindowsCWD (data: string) {
|
||||
const match = windowsDirectoryRegex.exec(data)
|
||||
if (match) {
|
||||
this.guessedCWD = match[0]
|
||||
}
|
||||
}
|
||||
|
||||
private processOSC1337 (data: Buffer) {
|
||||
if (data.includes(OSC1337Prefix)) {
|
||||
const preData = data.subarray(0, data.indexOf(OSC1337Prefix))
|
||||
const params = data.subarray(data.indexOf(OSC1337Prefix) + OSC1337Prefix.length)
|
||||
const postData = params.subarray(params.indexOf(OSC1337Suffix) + OSC1337Suffix.length)
|
||||
const paramString = params.subarray(0, params.indexOf(OSC1337Suffix)).toString()
|
||||
|
||||
if (paramString.startsWith('CurrentDir=')) {
|
||||
this.reportedCWD = paramString.split('=')[1]
|
||||
if (this.reportedCWD.startsWith('~')) {
|
||||
this.reportedCWD = os.homedir() + this.reportedCWD.substring(1)
|
||||
}
|
||||
data = Buffer.concat([preData, postData])
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
16
tabby-local/src/settings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { SettingsTabProvider } from 'tabby-settings'
|
||||
|
||||
import { ShellSettingsTabComponent } from './components/shellSettingsTab.component'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class ShellSettingsTabProvider extends SettingsTabProvider {
|
||||
id = 'terminal-shell'
|
||||
icon = 'list-ul'
|
||||
title = 'Shell'
|
||||
|
||||
getComponentType (): any {
|
||||
return ShellSettingsTabComponent
|
||||
}
|
||||
}
|
57
tabby-local/src/shells/cmder.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class CmderShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!process.env.CMDER_ROOT) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'cmder',
|
||||
name: 'Cmder',
|
||||
command: 'cmd.exe',
|
||||
args: [
|
||||
'/k',
|
||||
path.join(process.env.CMDER_ROOT, 'vendor', 'init.bat'),
|
||||
],
|
||||
icon: require('../icons/cmder.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cmderps',
|
||||
name: 'Cmder PowerShell',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-nologo',
|
||||
'-noprofile',
|
||||
'-noexit',
|
||||
'-command',
|
||||
`Invoke-Expression '. ''${path.join(process.env.CMDER_ROOT, 'vendor', 'profile.ps1')}'''`,
|
||||
],
|
||||
icon: require('../icons/cmder-powershell.svg'),
|
||||
env: {},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
25
tabby-local/src/shells/custom.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ConfigService } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class CustomShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
const args = this.config.store.terminal.customShell.split(' ')
|
||||
return [{
|
||||
id: 'custom',
|
||||
name: 'Custom shell',
|
||||
command: args[0],
|
||||
args: args.slice(1),
|
||||
env: {},
|
||||
}]
|
||||
}
|
||||
}
|
44
tabby-local/src/shells/cygwin32.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class Cygwin32ShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
const cygwinPath = wnr.getRegistryValue(wnr.HK.LM, 'Software\\WOW6432Node\\Cygwin\\setup', 'rootdir')
|
||||
|
||||
if (!cygwinPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 'cygwin32',
|
||||
name: 'Cygwin (32 bit)',
|
||||
command: path.join(cygwinPath, 'bin', 'bash.exe'),
|
||||
args: ['--login', '-i'],
|
||||
icon: require('../icons/cygwin.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
44
tabby-local/src/shells/cygwin64.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class Cygwin64ShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
const cygwinPath = wnr.getRegistryValue(wnr.HK.LM, 'Software\\Cygwin\\setup', 'rootdir')
|
||||
|
||||
if (!cygwinPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 'cygwin64',
|
||||
name: 'Cygwin',
|
||||
command: path.join(cygwinPath, 'bin', 'bash.exe'),
|
||||
args: ['--login', '-i'],
|
||||
icon: require('../icons/cygwin.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
48
tabby-local/src/shells/gitBash.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class GitBashShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
let gitBashPath = wnr.getRegistryValue(wnr.HK.LM, 'Software\\GitForWindows', 'InstallPath')
|
||||
|
||||
if (!gitBashPath) {
|
||||
gitBashPath = wnr.getRegistryValue(wnr.HK.CU, 'Software\\GitForWindows', 'InstallPath')
|
||||
}
|
||||
|
||||
if (!gitBashPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 'git-bash',
|
||||
name: 'Git-Bash',
|
||||
command: path.join(gitBashPath, 'bin', 'bash.exe'),
|
||||
args: ['--login', '-i'],
|
||||
icon: require('../icons/git-bash.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
45
tabby-local/src/shells/linuxDefault.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform, LogService, Logger } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class LinuxDefaultShellProvider extends ShellProvider {
|
||||
private logger: Logger
|
||||
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
log: LogService,
|
||||
) {
|
||||
super()
|
||||
this.logger = log.create('linuxDefaultShell')
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Linux) {
|
||||
return []
|
||||
}
|
||||
const line = (await fs.readFile('/etc/passwd', { encoding: 'utf-8' }))
|
||||
.split('\n').find(x => x.startsWith(`${process.env.LOGNAME}:`))
|
||||
if (!line) {
|
||||
this.logger.warn('Could not detect user shell')
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'User default',
|
||||
command: '/bin/sh',
|
||||
env: {},
|
||||
}]
|
||||
} else {
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'User default',
|
||||
command: line.split(':')[6],
|
||||
args: ['--login'],
|
||||
hidden: true,
|
||||
env: {},
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
43
tabby-local/src/shells/macDefault.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { exec } from 'mz/child_process'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class MacOSDefaultShellProvider extends ShellProvider {
|
||||
private cachedShell?: string
|
||||
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.macOS) {
|
||||
return []
|
||||
}
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'User default',
|
||||
command: await this.getDefaultShellCached(),
|
||||
args: ['--login'],
|
||||
hidden: true,
|
||||
env: {},
|
||||
}]
|
||||
}
|
||||
|
||||
private async getDefaultShellCached () {
|
||||
if (!this.cachedShell) {
|
||||
this.cachedShell = await this.getDefaultShell()
|
||||
}
|
||||
return this.cachedShell!
|
||||
}
|
||||
|
||||
private async getDefaultShell () {
|
||||
const shellEntry = (await exec(`/usr/bin/dscl . -read /Users/${process.env.LOGNAME} UserShell`))[0].toString()
|
||||
return shellEntry.split(' ')[1].trim()
|
||||
}
|
||||
}
|
33
tabby-local/src/shells/posix.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import slugify from 'slugify'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class POSIXShellsProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform === Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
return (await fs.readFile('/etc/shells', { encoding: 'utf-8' }))
|
||||
.split('\n')
|
||||
.map(x => x.trim())
|
||||
.filter(x => x && !x.startsWith('#'))
|
||||
.map(x => ({
|
||||
id: slugify(x),
|
||||
name: x.split('/')[2],
|
||||
command: x,
|
||||
args: ['-l'],
|
||||
env: {},
|
||||
}))
|
||||
}
|
||||
}
|
43
tabby-local/src/shells/powershellCore.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class PowerShellCoreShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pwshPath = wnr.getRegistryValue(wnr.HK.LM, 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe', '')
|
||||
|
||||
if (!pwshPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 'powershell-core',
|
||||
name: 'PowerShell Core',
|
||||
command: pwshPath,
|
||||
args: ['-nologo'],
|
||||
icon: require('../icons/powershell-core.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
51
tabby-local/src/shells/winDefault.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
import { WSLShellProvider } from './wsl'
|
||||
import { PowerShellCoreShellProvider } from './powershellCore'
|
||||
import { WindowsStockShellsProvider } from './windowsStock'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class WindowsDefaultShellProvider extends ShellProvider {
|
||||
private providers: ShellProvider[]
|
||||
|
||||
constructor (
|
||||
psc: PowerShellCoreShellProvider,
|
||||
wsl: WSLShellProvider,
|
||||
stock: WindowsStockShellsProvider,
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
this.providers = [
|
||||
psc,
|
||||
wsl,
|
||||
stock,
|
||||
]
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
// Figure out a sensible default
|
||||
const shellLists = await Promise.all(this.providers.map(x => x.provide()))
|
||||
for (const list of shellLists) {
|
||||
if (list.length) {
|
||||
const shell = list[list.length - 1]
|
||||
|
||||
return [{
|
||||
...shell,
|
||||
id: 'default',
|
||||
name: `Default (${shell.name})`,
|
||||
hidden: true,
|
||||
env: {},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
71
tabby-local/src/shells/windowsStock.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as path from 'path'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform } from 'tabby-core'
|
||||
import { ElectronService } from 'tabby-electron'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class WindowsStockShellsProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
private electron: ElectronService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
let clinkPath = path.join(
|
||||
path.dirname(this.electron.app.getPath('exe')),
|
||||
'resources',
|
||||
'extras',
|
||||
'clink',
|
||||
`clink_${process.arch}.exe`,
|
||||
)
|
||||
|
||||
if (process.env.TABBY_DEV) {
|
||||
clinkPath = path.join(
|
||||
path.dirname(this.electron.app.getPath('exe')),
|
||||
'..', '..', '..',
|
||||
'extras',
|
||||
'clink',
|
||||
`clink_${process.arch}.exe`,
|
||||
)
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'clink',
|
||||
name: 'CMD (clink)',
|
||||
command: 'cmd.exe',
|
||||
args: ['/k', clinkPath, 'inject'],
|
||||
env: {
|
||||
// Tell clink not to emulate ANSI handling
|
||||
WT_SESSION: '0',
|
||||
},
|
||||
icon: require('../icons/clink.svg'),
|
||||
},
|
||||
{
|
||||
id: 'cmd',
|
||||
name: 'CMD (stock)',
|
||||
command: 'cmd.exe',
|
||||
env: {},
|
||||
icon: require('../icons/cmd.svg'),
|
||||
},
|
||||
{
|
||||
id: 'powershell',
|
||||
name: 'PowerShell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-nologo'],
|
||||
icon: require('../icons/powershell.svg'),
|
||||
env: {
|
||||
TERM: 'cygwin',
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
111
tabby-local/src/shells/wsl.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as fs from 'mz/fs'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { Injectable } from '@angular/core'
|
||||
import { HostAppService, Platform, isWindowsBuild, WIN_BUILD_WSL_EXE_DISTRO_FLAG } from 'tabby-core'
|
||||
|
||||
import { ShellProvider, Shell } from '../api'
|
||||
|
||||
/* eslint-disable block-scoped-var */
|
||||
|
||||
try {
|
||||
var wnr = require('windows-native-registry') // eslint-disable-line @typescript-eslint/no-var-requires, no-var
|
||||
} catch { }
|
||||
|
||||
// WSL Distribution List
|
||||
// https://docs.microsoft.com/en-us/windows/wsl/install-win10#install-your-linux-distribution-of-choice
|
||||
/* eslint-disable quote-props */
|
||||
const wslIconMap: Record<string, string> = {
|
||||
'Alpine': require('../icons/alpine.svg'),
|
||||
'Debian': require('../icons/debian.svg'),
|
||||
'kali-linux': require('../icons/linux.svg'),
|
||||
'SLES-12': require('../icons/suse.svg'),
|
||||
'openSUSE-Leap-15-1': require('../icons/suse.svg'),
|
||||
'Ubuntu-16.04': require('../icons/ubuntu.svg'),
|
||||
'Ubuntu-18.04': require('../icons/ubuntu.svg'),
|
||||
'Ubuntu': require('../icons/ubuntu.svg'),
|
||||
'Linux': require('../icons/linux.svg'),
|
||||
}
|
||||
/* eslint-enable quote-props */
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class WSLShellProvider extends ShellProvider {
|
||||
constructor (
|
||||
private hostApp: HostAppService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async provide (): Promise<Shell[]> {
|
||||
if (this.hostApp.platform !== Platform.Windows) {
|
||||
return []
|
||||
}
|
||||
|
||||
const bashPath = `${process.env.windir}\\system32\\bash.exe`
|
||||
const wslPath = `${process.env.windir}\\system32\\wsl.exe`
|
||||
|
||||
const lxssPath = 'Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
|
||||
const lxss = wnr.getRegistryKey(wnr.HK.CU, lxssPath)
|
||||
const shells: Shell[] = []
|
||||
|
||||
if (null != lxss && null != lxss.DefaultDistribution) {
|
||||
const defaultDistKey = wnr.getRegistryKey(wnr.HK.CU, lxssPath + '\\' + String(lxss.DefaultDistribution.value))
|
||||
if (defaultDistKey?.DistributionName) {
|
||||
const shell: Shell = {
|
||||
id: 'wsl',
|
||||
name: 'WSL / Default distro',
|
||||
command: wslPath,
|
||||
env: {
|
||||
TERM: 'xterm-color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
icon: wslIconMap[defaultDistKey.DistributionName.value],
|
||||
}
|
||||
shells.push(shell)
|
||||
}
|
||||
}
|
||||
|
||||
if (!lxss || !lxss.DefaultDistribution || !isWindowsBuild(WIN_BUILD_WSL_EXE_DISTRO_FLAG)) {
|
||||
if (await fs.exists(bashPath)) {
|
||||
return [{
|
||||
id: 'wsl',
|
||||
name: 'WSL / Bash on Windows',
|
||||
icon: wslIconMap.Linux,
|
||||
command: bashPath,
|
||||
env: {
|
||||
TERM: 'xterm-color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
}]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
for (const child of wnr.listRegistrySubkeys(wnr.HK.CU, lxssPath) as string[]) {
|
||||
const childKey = wnr.getRegistryKey(wnr.HK.CU, lxssPath + '\\' + child)
|
||||
if (!childKey.DistributionName) {
|
||||
continue
|
||||
}
|
||||
const wslVersion = (childKey.Flags?.value || 0) & 8 ? 2 : 1
|
||||
const name = childKey.DistributionName.value
|
||||
const fsBase = wslVersion === 2 ? `\\\\wsl$\\${name}` : childKey.BasePath.value as string + '\\rootfs'
|
||||
const slug = slugify(name, { remove: /[:.]/g })
|
||||
const shell: Shell = {
|
||||
id: `wsl-${slug}`,
|
||||
name: `WSL / ${name}`,
|
||||
command: wslPath,
|
||||
args: ['-d', name],
|
||||
fsBase,
|
||||
env: {
|
||||
TERM: 'xterm-color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
icon: wslIconMap[name],
|
||||
}
|
||||
shells.push(shell)
|
||||
}
|
||||
|
||||
return shells
|
||||
}
|
||||
}
|
129
tabby-local/src/tabContextMenu.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ConfigService, BaseTabComponent, TabContextMenuItemProvider, TabHeaderComponent, SplitTabComponent, NotificationsService, MenuItemOptions } from 'tabby-core'
|
||||
import { TerminalTabComponent } from './components/terminalTab.component'
|
||||
import { UACService } from './services/uac.service'
|
||||
import { TerminalService } from './services/terminal.service'
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class SaveAsProfileContextMenu extends TabContextMenuItemProvider {
|
||||
constructor (
|
||||
private config: ConfigService,
|
||||
private notifications: NotificationsService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, _tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
if (!(tab instanceof TerminalTabComponent)) {
|
||||
return []
|
||||
}
|
||||
const items: MenuItemOptions[] = [
|
||||
{
|
||||
label: 'Save as profile',
|
||||
click: async () => {
|
||||
const profile = {
|
||||
sessionOptions: {
|
||||
...tab.sessionOptions,
|
||||
cwd: await tab.session?.getWorkingDirectory() ?? tab.sessionOptions.cwd,
|
||||
},
|
||||
name: tab.sessionOptions.command,
|
||||
}
|
||||
this.config.store.terminal.profiles = [
|
||||
...this.config.store.terminal.profiles,
|
||||
profile,
|
||||
]
|
||||
this.config.save()
|
||||
this.notifications.info('Saved')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
/** @hidden */
|
||||
@Injectable()
|
||||
export class NewTabContextMenu extends TabContextMenuItemProvider {
|
||||
weight = 10
|
||||
|
||||
constructor (
|
||||
public config: ConfigService,
|
||||
private terminalService: TerminalService,
|
||||
private uac: UACService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getItems (tab: BaseTabComponent, tabHeader?: TabHeaderComponent): Promise<MenuItemOptions[]> {
|
||||
const profiles = await this.terminalService.getProfiles()
|
||||
|
||||
const items: MenuItemOptions[] = [
|
||||
{
|
||||
label: 'New terminal',
|
||||
click: () => {
|
||||
this.terminalService.openTabWithOptions((tab as any).sessionOptions)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'New with profile',
|
||||
submenu: profiles.map(profile => ({
|
||||
label: profile.name,
|
||||
click: async () => {
|
||||
let workingDirectory = this.config.store.terminal.workingDirectory
|
||||
if (this.config.store.terminal.alwaysUseWorkingDirectory !== true && tab instanceof TerminalTabComponent) {
|
||||
workingDirectory = await tab.session?.getWorkingDirectory()
|
||||
}
|
||||
await this.terminalService.openTab(profile, workingDirectory)
|
||||
},
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
if (this.uac.isAvailable) {
|
||||
items.push({
|
||||
label: 'New admin tab',
|
||||
submenu: profiles.map(profile => ({
|
||||
label: profile.name,
|
||||
click: () => {
|
||||
this.terminalService.openTabWithOptions({
|
||||
...profile.sessionOptions,
|
||||
runAsAdministrator: true,
|
||||
})
|
||||
},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (tab instanceof TerminalTabComponent && tabHeader && this.uac.isAvailable) {
|
||||
items.push({
|
||||
label: 'Duplicate as administrator',
|
||||
click: () => {
|
||||
this.terminalService.openTabWithOptions({
|
||||
...tab.sessionOptions,
|
||||
runAsAdministrator: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (tab instanceof TerminalTabComponent && tab.parent instanceof SplitTabComponent && tab.parent.getAllTabs().length > 1) {
|
||||
items.push({
|
||||
label: 'Focus all panes',
|
||||
click: () => {
|
||||
tab.focusAllPanes()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (tab instanceof TerminalTabComponent && tab.session?.supportsWorkingDirectory()) {
|
||||
items.push({
|
||||
label: 'Copy current path',
|
||||
click: () => tab.copyCurrentPath(),
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
}
|