project rename

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

57
tabby-local/src/api.ts Normal file
View 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
}

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

View 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

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

View 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

View File

@@ -0,0 +1,3 @@
:host {
display: block;
}

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

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

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

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

View 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}`,
})),
]
}
}

View 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

View 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

View 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

View 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

View 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

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View 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

View 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

View 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

View 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

View 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

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

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

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

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

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

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

View 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: {},
},
]
}
}

View 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: {},
}]
}
}

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

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

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

View 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: {},
}]
}
}
}

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

View 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: {},
}))
}
}

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

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

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

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

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