terminalTab.component.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import { BehaviorSubject, Subject, Subscription } from 'rxjs'
  2. import 'rxjs/add/operator/bufferTime'
  3. import { Component, NgZone, Inject, Optional, ViewChild, HostBinding, Input } from '@angular/core'
  4. import { AppService, ConfigService, BaseTabComponent, ThemesService, HostAppService, HotkeysService, Platform } from 'terminus-core'
  5. import { Session, SessionsService } from '../services/sessions.service'
  6. import { TerminalDecorator, ResizeEvent, SessionOptions } from '../api'
  7. import { hterm, preferenceManager } from '../hterm'
  8. @Component({
  9. selector: 'terminalTab',
  10. template: '<div #content class="content" [style.opacity]="htermVisible ? 1 : 0"></div>',
  11. styles: [require('./terminalTab.component.scss')],
  12. })
  13. export class TerminalTabComponent extends BaseTabComponent {
  14. session: Session
  15. @Input() sessionOptions: SessionOptions
  16. @Input() zoom = 0
  17. @ViewChild('content') content
  18. @HostBinding('style.background-color') backgroundColor: string
  19. hterm: any
  20. sessionCloseSubscription: Subscription
  21. hotkeysSubscription: Subscription
  22. bell$ = new Subject()
  23. size: ResizeEvent
  24. resize$ = new Subject<ResizeEvent>()
  25. input$ = new Subject<string>()
  26. output$ = new Subject<string>()
  27. contentUpdated$ = new Subject<void>()
  28. alternateScreenActive$ = new BehaviorSubject(false)
  29. mouseEvent$ = new Subject<Event>()
  30. htermVisible = false
  31. private bellPlayer: HTMLAudioElement
  32. private io: any
  33. constructor (
  34. private zone: NgZone,
  35. private app: AppService,
  36. private themes: ThemesService,
  37. private hostApp: HostAppService,
  38. private hotkeys: HotkeysService,
  39. private sessions: SessionsService,
  40. public config: ConfigService,
  41. @Optional() @Inject(TerminalDecorator) private decorators: TerminalDecorator[],
  42. ) {
  43. super()
  44. this.decorators = this.decorators || []
  45. this.title = 'Terminal'
  46. this.resize$.first().subscribe(async (resizeEvent) => {
  47. this.session = this.sessions.addSession(
  48. Object.assign({}, this.sessionOptions, resizeEvent)
  49. )
  50. // this.session.output$.bufferTime(10).subscribe((datas) => {
  51. this.session.output$.subscribe(data => {
  52. // let data = datas.join('')
  53. this.zone.run(() => {
  54. this.output$.next(data)
  55. })
  56. this.write(data)
  57. })
  58. this.sessionCloseSubscription = this.session.closed$.subscribe(() => {
  59. this.app.closeTab(this)
  60. })
  61. this.session.releaseInitialDataBuffer()
  62. })
  63. this.hotkeysSubscription = this.hotkeys.matchedHotkey.subscribe(hotkey => {
  64. if (!this.hasFocus) {
  65. return
  66. }
  67. if (hotkey === 'copy') {
  68. this.hterm.copySelectionToClipboard()
  69. }
  70. if (hotkey === 'clear') {
  71. this.clear()
  72. }
  73. if (hotkey === 'zoom-in') {
  74. this.zoomIn()
  75. }
  76. if (hotkey === 'zoom-out') {
  77. this.zoomOut()
  78. }
  79. if (hotkey === 'reset-zoom') {
  80. this.resetZoom()
  81. }
  82. })
  83. this.bellPlayer = document.createElement('audio')
  84. this.bellPlayer.src = require<string>('../bell.ogg')
  85. }
  86. getRecoveryToken (): any {
  87. return {
  88. type: 'app:terminal',
  89. recoveryId: this.sessionOptions.recoveryId,
  90. }
  91. }
  92. ngOnInit () {
  93. this.focused$.subscribe(() => {
  94. this.configure()
  95. setTimeout(() => {
  96. this.hterm.scrollPort_.resize()
  97. this.hterm.scrollPort_.focus()
  98. }, 100)
  99. })
  100. this.hterm = new hterm.hterm.Terminal()
  101. this.decorators.forEach((decorator) => {
  102. decorator.attach(this)
  103. })
  104. this.attachHTermHandlers(this.hterm)
  105. this.hterm.onTerminalReady = () => {
  106. this.htermVisible = true
  107. this.hterm.installKeyboard()
  108. this.hterm.scrollPort_.setCtrlVPaste(true)
  109. this.io = this.hterm.io.push()
  110. this.attachIOHandlers(this.io)
  111. }
  112. this.hterm.decorate(this.content.nativeElement)
  113. this.configure()
  114. setTimeout(() => {
  115. this.output$.subscribe(() => {
  116. this.displayActivity()
  117. })
  118. }, 1000)
  119. this.bell$.subscribe(() => {
  120. if (this.config.store.terminal.bell === 'visual') {
  121. preferenceManager.set('background-color', 'rgba(128,128,128,.25)')
  122. setTimeout(() => {
  123. this.configure()
  124. }, 125)
  125. }
  126. if (this.config.store.terminal.bell === 'audible') {
  127. this.bellPlayer.play()
  128. }
  129. // TODO audible
  130. })
  131. }
  132. attachHTermHandlers (hterm: any) {
  133. hterm.setWindowTitle = (title) => {
  134. this.zone.run(() => {
  135. this.title = title
  136. })
  137. }
  138. const _setAlternateMode = hterm.setAlternateMode.bind(hterm)
  139. hterm.setAlternateMode = (state) => {
  140. _setAlternateMode(state)
  141. this.alternateScreenActive$.next(state)
  142. }
  143. hterm.primaryScreen_.syncSelectionCaret = () => null
  144. hterm.alternateScreen_.syncSelectionCaret = () => null
  145. hterm.primaryScreen_.terminal = hterm
  146. hterm.alternateScreen_.terminal = hterm
  147. const _onPaste = hterm.scrollPort_.onPaste_.bind(hterm.scrollPort_)
  148. hterm.scrollPort_.onPaste_ = (event) => {
  149. hterm.scrollPort_.pasteTarget_.value = event.clipboardData.getData('text/plain').trim()
  150. _onPaste()
  151. event.preventDefault()
  152. }
  153. const _resize = hterm.scrollPort_.resize.bind(hterm.scrollPort_)
  154. hterm.scrollPort_.resize = () => {
  155. if (!this.hasFocus) {
  156. return
  157. }
  158. _resize()
  159. }
  160. const _onMouse = hterm.onMouse_.bind(hterm)
  161. hterm.onMouse_ = (event) => {
  162. this.mouseEvent$.next(event)
  163. if (event.type === 'mousewheel') {
  164. if (event.ctrlKey || event.metaKey) {
  165. if (event.wheelDeltaY < 0) {
  166. this.zoomIn()
  167. } else {
  168. this.zoomOut()
  169. }
  170. } else if (event.altKey) {
  171. event.preventDefault()
  172. let delta = Math.round(event.wheelDeltaY / 50)
  173. this.sendInput(((delta > 0) ? '\u001bOA' : '\u001bOB').repeat(Math.abs(delta)))
  174. }
  175. }
  176. _onMouse(event)
  177. }
  178. hterm.ringBell = () => {
  179. this.bell$.next()
  180. }
  181. for (let screen of [hterm.primaryScreen_, hterm.alternateScreen_]) {
  182. const _insertString = screen.insertString.bind(screen)
  183. screen.insertString = (data) => {
  184. _insertString(data)
  185. this.contentUpdated$.next()
  186. }
  187. const _deleteChars = screen.deleteChars.bind(screen)
  188. screen.deleteChars = (count) => {
  189. let ret = _deleteChars(count)
  190. this.contentUpdated$.next()
  191. return ret
  192. }
  193. }
  194. }
  195. attachIOHandlers (io: any) {
  196. io.onVTKeystroke = io.sendString = (data) => {
  197. this.sendInput(data)
  198. this.zone.run(() => {
  199. this.input$.next(data)
  200. })
  201. }
  202. io.onTerminalResize = (columns, rows) => {
  203. // console.log(`Resizing to ${columns}x${rows}`)
  204. this.zone.run(() => {
  205. this.size = { width: columns, height: rows }
  206. if (this.session) {
  207. this.session.resize(columns, rows)
  208. }
  209. this.resize$.next(this.size)
  210. })
  211. }
  212. }
  213. sendInput (data: string) {
  214. this.session.write(data)
  215. }
  216. write (data: string) {
  217. this.io.writeUTF8(data)
  218. }
  219. clear () {
  220. this.hterm.wipeContents()
  221. this.hterm.onVTKeystroke('\f')
  222. }
  223. async configure (): Promise<void> {
  224. let config = this.config.store
  225. preferenceManager.set('font-family', `"${config.terminal.font}", "monospace-fallback", monospace`)
  226. this.setFontSize()
  227. preferenceManager.set('enable-bold', true)
  228. // preferenceManager.set('audible-bell-sound', '')
  229. preferenceManager.set('desktop-notification-bell', config.terminal.bell === 'notification')
  230. preferenceManager.set('enable-clipboard-notice', false)
  231. preferenceManager.set('receive-encoding', 'raw')
  232. preferenceManager.set('send-encoding', 'raw')
  233. preferenceManager.set('ctrl-plus-minus-zero-zoom', false)
  234. preferenceManager.set('scrollbar-visible', this.hostApp.platform === Platform.macOS)
  235. preferenceManager.set('copy-on-select', false)
  236. if (config.terminal.colorScheme.foreground) {
  237. preferenceManager.set('foreground-color', config.terminal.colorScheme.foreground)
  238. }
  239. if (config.terminal.background === 'colorScheme') {
  240. if (config.terminal.colorScheme.background) {
  241. this.backgroundColor = config.terminal.colorScheme.background
  242. preferenceManager.set('background-color', config.terminal.colorScheme.background)
  243. }
  244. } else {
  245. this.backgroundColor = null
  246. // hterm can't parse "transparent"
  247. preferenceManager.set('background-color', this.themes.findCurrentTheme().terminalBackground)
  248. }
  249. if (config.terminal.colorScheme.colors) {
  250. preferenceManager.set('color-palette-overrides', config.terminal.colorScheme.colors)
  251. }
  252. if (config.terminal.colorScheme.cursor) {
  253. preferenceManager.set('cursor-color', config.terminal.colorScheme.cursor)
  254. }
  255. let css = require('../hterm.userCSS.scss')
  256. if (!config.terminal.ligatures) {
  257. css += `
  258. * {
  259. font-feature-settings: "liga" 0;
  260. font-variant-ligatures: none;
  261. }
  262. `
  263. } else {
  264. css += `
  265. * {
  266. font-feature-settings: "liga" 1;
  267. font-variant-ligatures: initial;
  268. }
  269. `
  270. }
  271. css += config.appearance.css
  272. this.hterm.setCSS(css)
  273. this.hterm.setBracketedPaste(config.terminal.bracketedPaste)
  274. }
  275. zoomIn () {
  276. this.zoom++
  277. this.setFontSize()
  278. }
  279. zoomOut () {
  280. this.zoom--
  281. this.setFontSize()
  282. }
  283. resetZoom () {
  284. this.zoom = 0
  285. this.setFontSize()
  286. }
  287. ngOnDestroy () {
  288. this.decorators.forEach(decorator => {
  289. decorator.detach(this)
  290. })
  291. this.hotkeysSubscription.unsubscribe()
  292. if (this.sessionCloseSubscription) {
  293. this.sessionCloseSubscription.unsubscribe()
  294. }
  295. this.resize$.complete()
  296. this.input$.complete()
  297. this.output$.complete()
  298. this.contentUpdated$.complete()
  299. this.alternateScreenActive$.complete()
  300. this.mouseEvent$.complete()
  301. this.bell$.complete()
  302. }
  303. async destroy () {
  304. super.destroy()
  305. if (this.session && this.session.open) {
  306. await this.session.destroy()
  307. }
  308. }
  309. async canClose (): Promise<boolean> {
  310. let children = await this.session.getChildProcesses()
  311. if (children.length === 0) {
  312. return true
  313. }
  314. return confirm(`"${children[0].command}" is still running. Close?`)
  315. }
  316. private setFontSize () {
  317. preferenceManager.set('font-size', this.config.store.terminal.fontSize * Math.pow(1.1, this.zoom))
  318. }
  319. }