This commit is contained in:
Eugene Pankov 2017-03-25 18:12:43 +01:00
parent 384716417a
commit b7bac490d2
19 changed files with 239 additions and 204 deletions

View file

@ -1,6 +1,7 @@
export { Tab } from './tab'
export { TabRecoveryProviderType, ITabRecoveryProvider } from './tabRecovery'
export { ToolbarButtonProviderType, IToolbarButton, IToolbarButtonProvider } from './toolbarButtonProvider'
export { TabRecoveryProvider } from './tabRecovery'
export { ToolbarButtonProvider, IToolbarButton } from './toolbarButtonProvider'
export { AppService } from 'services/app'
export { PluginsService } from 'services/plugins'
export { ElectronService } from 'services/electron'

View file

@ -1,7 +1,5 @@
import { Tab } from './tab'
export interface ITabRecoveryProvider {
recover (recoveryToken: any): Tab
export abstract class TabRecoveryProvider {
abstract recover (recoveryToken: any): Tab
}
export const TabRecoveryProviderType = 'app:TabRecoveryProviderType'

View file

@ -5,8 +5,6 @@ export interface IToolbarButton {
click: () => void
}
export interface IToolbarButtonProvider {
provide (): IToolbarButton[]
export abstract class ToolbarButtonProvider {
abstract provide (): IToolbarButton[]
}
export const ToolbarButtonProviderType = 'app:ToolbarButtonProviderType'

View file

@ -27,6 +27,7 @@ import { TitleBarComponent } from 'components/titleBar'
let plugins = [
require('./settings').default,
require('./terminal').default,
require('./link-highlighter').default,
]
@NgModule({

View file

@ -1,4 +1,4 @@
import { Component, trigger, style, animate, transition, state } from '@angular/core'
import { Component, Inject, trigger, style, animate, transition, state } from '@angular/core'
import { ToasterConfig } from 'angular2-toaster'
import { ElectronService } from 'services/electron'
@ -8,9 +8,8 @@ import { LogService } from 'services/log'
import { QuitterService } from 'services/quitter'
import { ConfigService } from 'services/config'
import { DockingService } from 'services/docking'
import { PluginsService } from 'services/plugins'
import { AppService, IToolbarButton, IToolbarButtonProvider, ToolbarButtonProviderType } from 'api'
import { AppService, IToolbarButton, ToolbarButtonProvider } from 'api'
import 'angular2-toaster/lib/toaster.css'
import 'global.less'
@ -49,8 +48,8 @@ export class AppRootComponent {
public hostApp: HostAppService,
public hotkeys: HotkeysService,
public config: ConfigService,
private plugins: PluginsService,
public app: AppService,
@Inject(ToolbarButtonProvider) private toolbarButtonProviders: ToolbarButtonProvider[],
log: LogService,
_quitter: QuitterService,
) {
@ -129,10 +128,9 @@ export class AppRootComponent {
getToolbarButtons (aboveZero: boolean): IToolbarButton[] {
let buttons: IToolbarButton[] = []
this.plugins.getAll<IToolbarButtonProvider>(ToolbarButtonProviderType)
.forEach((provider) => {
buttons = buttons.concat(provider.provide())
})
this.toolbarButtonProviders.forEach((provider) => {
buttons = buttons.concat(provider.provide())
})
return buttons
.filter((button) => (button.weight > 0) === aboveZero)
.sort((a: IToolbarButton, b: IToolbarButton) => (a.weight || 0) - (b.weight || 0))

View file

@ -0,0 +1,6 @@
export abstract class LinkHandler {
regex: string
convert (uri: string): string { return uri }
verify (_uri: string): boolean { return true }
abstract handle (uri: string): void
}

View file

@ -0,0 +1,126 @@
/*
This plugin is based on Hyperterm Hyperlinks:
https://github.com/zeit/hyperlinks/blob/master/index.js
*/
import { Inject, Injectable } from '@angular/core'
import { LinkHandler } from './api'
import { TerminalDecorator } from '../terminal/api'
const debounceDelay = 500
@Injectable()
export class LinkHighlighterDecorator extends TerminalDecorator {
constructor (@Inject(LinkHandler) private handlers: LinkHandler[]) {
super()
}
decorate (terminal): void {
const Screen = terminal.screen_.constructor
if (Screen._linkHighlighterInstalled) {
return
}
Screen._linkHighlighterInstalled = true
const oldInsertString = Screen.prototype.insertString
const oldDeleteChars = Screen.prototype.deleteChars
let self = this
Screen.prototype.insertString = function (...args) {
let ret = oldInsertString.bind(this)(...args)
self.debounceInsertLinks(this)
return ret
}
Screen.prototype.deleteChars = function (...args) {
let ret = oldDeleteChars.bind(this)(...args)
self.debounceInsertLinks(this)
return ret
}
}
debounceInsertLinks (screen) {
if (screen.__insertLinksTimeout) {
screen.__insertLinksRebounce = true
} else {
screen.__insertLinksTimeout = window.setTimeout(() => {
this.insertLinks(screen)
screen.__insertLinksTimeout = null
if (screen.__insertLinksRebounce) {
screen.__insertLinksRebounce = false
this.debounceInsertLinks(screen)
}
}, debounceDelay)
}
}
insertLinks (screen) {
if ('#text' === screen.cursorNode_.nodeName) {
// replace text node to element
const cursorNode = document.createElement('span');
cursorNode.textContent = screen.cursorNode_.textContent;
screen.cursorRowNode_.replaceChild(cursorNode, screen.cursorNode_);
screen.cursorNode_ = cursorNode;
}
const traverse = (parentNode: Node) => {
Array.from(parentNode.childNodes).forEach((node) => {
if (node.nodeName == '#text') {
parentNode.replaceChild(this.urlizeNode(node), node)
} else if (node.nodeName != 'A') {
traverse(node)
}
})
}
screen.rowsArray.forEach((x) => traverse(x))
}
urlizeNode (node) {
let matches = []
this.handlers.forEach((handler) => {
let regex = new RegExp(handler.regex, 'gi')
let match
while (match = regex.exec(node.textContent)) {
let uri = handler.convert(match[0])
if (!handler.verify(uri)) {
continue;
}
matches.push({
start: regex.lastIndex - match[0].length,
end: regex.lastIndex,
text: match[0],
uri,
handler
})
}
})
if (matches.length == 0) {
return node
}
matches.sort((a, b) => a.start < b.start ? -1 : 1)
let span = document.createElement('span')
let position = 0
matches.forEach((match) => {
if (match.start < position) {
return
}
if (match.start > position) {
span.appendChild(document.createTextNode(node.textContent.slice(position, match.start)))
}
let a = document.createElement('a')
a.textContent = match.text
a.addEventListener('click', () => {
match.handler.handle(match.uri)
})
span.appendChild(a)
position = match.end
})
span.appendChild(document.createTextNode(node.textContent.slice(position)))
return span
}
}

View file

@ -0,0 +1,36 @@
import * as fs from 'fs'
import { Injectable } from '@angular/core'
import { LinkHandler } from './api'
import { ElectronService } from 'api'
@Injectable()
export class URLHandler extends LinkHandler {
regex = 'http(s)?://[^\\s;\'"]+[^,;\\s]'
constructor (private electron: ElectronService) {
super()
}
handle (uri: string) {
this.electron.shell.openExternal(uri)
}
}
@Injectable()
export class FileHandler extends LinkHandler {
regex = '/[^\\s.,;\'"]+'
constructor (private electron: ElectronService) {
super()
}
verify (uri: string) {
return fs.existsSync(uri)
}
handle (uri: string) {
this.electron.shell.openExternal('file://' + uri)
}
}

View file

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { LinkHandler } from './api'
import { FileHandler, URLHandler } from './handlers'
import { TerminalDecorator } from '../terminal/api'
import { LinkHighlighterDecorator } from './decorator'
@NgModule({
providers: [
{ provide: LinkHandler, useClass: FileHandler, multi: true },
{ provide: LinkHandler, useClass: URLHandler, multi: true },
{ provide: TerminalDecorator, useClass: LinkHighlighterDecorator, multi: true },
],
})
class LinkHighlighterModule {
}
export default LinkHighlighterModule

View file

@ -1,139 +0,0 @@
import * as fs from 'fs'
import { ElectronService } from 'services/electron'
const debounceDelay = 500
abstract class Handler {
constructor (protected plugin) { }
regex: string
convert (uri: string): string { return uri }
verify (_uri: string): boolean { return true }
abstract handle (uri: string): void
}
class URLHandler extends Handler {
regex = 'http(s)?://[^\\s;\'"]+[^.,;\\s]'
handle (uri: string) {
this.plugin.electron.shell.openExternal(uri)
}
}
class FileHandler extends Handler {
regex = '/[^\\s.,;\'"]+'
verify (uri: string) {
return fs.existsSync(uri)
}
handle (uri: string) {
this.plugin.electron.shell.openExternal('file://' + uri)
}
}
export default class HyperlinksPlugin {
handlers = []
handlerClasses = [
URLHandler,
FileHandler,
]
electron: ElectronService
constructor ({ electron }) {
this.electron = electron
this.handlers = this.handlerClasses.map((x) => new x(this))
}
preTerminalInit ({ terminal }) {
const oldInsertString = terminal.screen_.constructor.prototype.insertString
const oldDeleteChars = terminal.screen_.constructor.prototype.deleteChars
terminal.screen_.insertString = (...args) => {
let ret = oldInsertString.bind(terminal.screen_)(...args)
this.debounceInsertLinks(terminal.screen_)
return ret
}
terminal.screen_.deleteChars = (...args) => {
let ret = oldDeleteChars.bind(terminal.screen_)(...args)
this.debounceInsertLinks(terminal.screen_)
return ret
}
}
debounceInsertLinks (screen) {
if (screen.__insertLinksTimeout) {
screen.__insertLinksRebounce = true
} else {
screen.__insertLinksTimeout = window.setTimeout(() => {
this.insertLinks(screen)
screen.__insertLinksTimeout = null
if (screen.__insertLinksRebounce) {
screen.__insertLinksRebounce = false
this.debounceInsertLinks(screen)
}
}, debounceDelay)
}
}
insertLinks (screen) {
const traverse = (parentNode: Node) => {
Array.from(parentNode.childNodes).forEach((node) => {
if (node.nodeName == '#text') {
parentNode.replaceChild(this.urlizeNode(node), node)
} else if (node.nodeName != 'A') {
traverse(node)
}
})
}
screen.rowsArray.forEach((x) => traverse(x))
}
urlizeNode (node) {
let matches = []
this.handlers.forEach((handler) => {
let regex = new RegExp(handler.regex, 'gi')
let match
while (match = regex.exec(node.textContent)) {
let uri = handler.convert(match[0])
if (!handler.verify(uri)) {
continue;
}
matches.push({
start: regex.lastIndex - match[0].length,
end: regex.lastIndex,
text: match[0],
uri,
handler
})
}
})
if (matches.length == 0) {
return node
}
matches.sort((a, b) => a.start < b.start ? -1 : 1)
let span = document.createElement('span')
let position = 0
matches.forEach((match) => {
if (match.start < position) {
return
}
if (match.start > position) {
span.appendChild(document.createTextNode(node.textContent.slice(position, match.start)))
}
let a = document.createElement('a')
a.textContent = match.text
a.addEventListener('click', () => {
match.handler.handle(match.uri)
})
span.appendChild(a)
position = match.end
})
span.appendChild(document.createTextNode(node.textContent.slice(position)))
return span
}
}

View file

@ -1,8 +1,7 @@
import { Injectable } from '@angular/core'
import { Inject, Injectable } from '@angular/core'
import { Logger, LogService } from 'services/log'
import { Tab } from 'api/tab'
import { PluginsService } from 'services/plugins'
import { ITabRecoveryProvider, TabRecoveryProviderType } from 'api/tabRecovery'
import { TabRecoveryProvider } from 'api/tabRecovery'
@Injectable()
@ -13,7 +12,7 @@ export class AppService {
logger: Logger
constructor (
private plugins: PluginsService,
@Inject(TabRecoveryProvider) private tabRecoveryProviders: TabRecoveryProvider[],
log: LogService,
) {
this.logger = log.create('app')
@ -83,9 +82,8 @@ export class AppService {
restoreTabs () {
if (window.localStorage.tabsRecovery) {
let providers = this.plugins.getAll<ITabRecoveryProvider>(TabRecoveryProviderType)
JSON.parse(window.localStorage.tabsRecovery).forEach((token) => {
for (let provider of providers) {
for (let provider of this.tabRecoveryProviders) {
try {
let tab = provider.recover(token)
if (tab) {
@ -93,8 +91,8 @@ export class AppService {
return
}
} catch (_) { }
this.logger.warn('Cannot restore tab from the token:', token)
}
this.logger.warn('Cannot restore tab from the token:', token)
})
this.saveTabs()
}

View file

@ -1,13 +1,15 @@
import { Injectable } from '@angular/core'
import { IToolbarButtonProvider, IToolbarButton, AppService } from 'api'
import { ToolbarButtonProvider, IToolbarButton, AppService } from 'api'
import { SettingsTab } from './tab'
@Injectable()
export class ButtonProvider implements IToolbarButtonProvider {
export class ButtonProvider extends ToolbarButtonProvider {
constructor (
private app: AppService,
) { }
) {
super()
}
provide (): IToolbarButton[] {
return [{

View file

@ -1,5 +1,5 @@
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { FormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
@ -9,7 +9,7 @@ import { HotkeyHintComponent } from './components/hotkeyHint'
import { HotkeyInputModalComponent } from './components/hotkeyInputModal'
import { SettingsPaneComponent } from './components/settingsPane'
import { PluginsService, ToolbarButtonProviderType, TabRecoveryProviderType } from 'api'
import { ToolbarButtonProvider, TabRecoveryProvider } from 'api'
import { ButtonProvider } from './buttonProvider'
import { RecoveryProvider } from './recoveryProvider'
@ -22,8 +22,8 @@ import { RecoveryProvider } from './recoveryProvider'
NgbModule,
],
providers: [
ButtonProvider,
RecoveryProvider,
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true }
],
entryComponents: [
HotkeyInputModalComponent,
@ -38,14 +38,6 @@ import { RecoveryProvider } from './recoveryProvider'
],
})
class SettingsModule {
constructor (
plugins: PluginsService,
buttonProvider: ButtonProvider,
recoveryProvider: RecoveryProvider,
) {
plugins.register(ToolbarButtonProviderType, buttonProvider, 1)
plugins.register(TabRecoveryProviderType, recoveryProvider)
}
}

View file

@ -1,10 +1,10 @@
import { Injectable } from '@angular/core'
import { Tab, ITabRecoveryProvider } from 'api'
import { Tab, TabRecoveryProvider } from 'api'
import { SettingsTab } from './tab'
@Injectable()
export class RecoveryProvider implements ITabRecoveryProvider {
export class RecoveryProvider extends TabRecoveryProvider {
recover (recoveryToken: any): Tab {
if (recoveryToken.type == 'app:settings') {
return new SettingsTab()

3
app/src/terminal/api.ts Normal file
View file

@ -0,0 +1,3 @@
export abstract class TerminalDecorator {
abstract decorate (terminal): void
}

View file

@ -1,16 +1,16 @@
import { Injectable } from '@angular/core'
import { IToolbarButtonProvider, IToolbarButton, AppService } from 'api'
import { ToolbarButtonProvider, IToolbarButton, AppService } from 'api'
import { SessionsService } from './services/sessions'
import { TerminalTab } from './tab'
@Injectable()
export class ButtonProvider implements IToolbarButtonProvider {
export class ButtonProvider extends ToolbarButtonProvider {
constructor (
private app: AppService,
private sessions: SessionsService,
) {
super()
}
provide (): IToolbarButton[] {

View file

@ -1,11 +1,11 @@
import { Subscription } from 'rxjs'
import { Component, NgZone, Output, EventEmitter, ElementRef } from '@angular/core'
import { Component, NgZone, Output, Inject, EventEmitter, ElementRef } from '@angular/core'
import { ConfigService } from 'services/config'
import { PluginsService } from 'services/plugins'
import { BaseTabComponent } from 'components/baseTab'
import { TerminalTab } from '../tab'
import { TerminalDecorator } from '../api'
import { hterm, preferenceManager } from '../hterm'
@ -27,7 +27,7 @@ export class TerminalTabComponent extends BaseTabComponent<TerminalTab> {
private zone: NgZone,
private elementRef: ElementRef,
public config: ConfigService,
private plugins: PluginsService,
@Inject(TerminalDecorator) private decorators: TerminalDecorator[],
) {
super()
this.startupTime = performance.now()
@ -42,7 +42,9 @@ export class TerminalTabComponent extends BaseTabComponent<TerminalTab> {
})
this.terminal = new hterm.hterm.Terminal()
//this.pluginDispatcher.emit('preTerminalInit', { terminal: this.terminal })
this.decorators.forEach((decorator) => {
decorator.decorate(this.terminal)
})
this.terminal.setWindowTitle = (title) => {
this.zone.run(() => {
this.title = title
@ -77,7 +79,6 @@ export class TerminalTabComponent extends BaseTabComponent<TerminalTab> {
}
this.terminal.decorate(this.elementRef.nativeElement)
this.configure()
//this.pluginDispatcher.emit('postTerminalInit', { terminal: this.terminal })
}
configure () {

View file

@ -1,8 +1,8 @@
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { FormsModule } from '@angular/forms'
import { PluginsService, ToolbarButtonProviderType, TabRecoveryProviderType } from 'api'
import { ToolbarButtonProvider, TabRecoveryProvider } from 'api'
import { TerminalTabComponent } from './components/terminalTab'
import { SessionsService } from './services/sessions'
@ -16,9 +16,9 @@ import { RecoveryProvider } from './recoveryProvider'
FormsModule,
],
providers: [
ButtonProvider,
{ provide: ToolbarButtonProvider, useClass: ButtonProvider, multi: true },
{ provide: TabRecoveryProvider, useClass: RecoveryProvider, multi: true },
SessionsService,
RecoveryProvider,
],
entryComponents: [
TerminalTabComponent,
@ -28,14 +28,6 @@ import { RecoveryProvider } from './recoveryProvider'
],
})
class TerminalModule {
constructor (
plugins: PluginsService,
buttonProvider: ButtonProvider,
recoveryProvider: RecoveryProvider,
) {
plugins.register(ToolbarButtonProviderType, buttonProvider)
plugins.register(TabRecoveryProviderType, recoveryProvider)
}
}

View file

@ -1,12 +1,14 @@
import { Injectable } from '@angular/core'
import { Tab, ITabRecoveryProvider } from 'api'
import { Tab, TabRecoveryProvider } from 'api'
import { TerminalTab } from './tab'
import { SessionsService } from './services/sessions'
@Injectable()
export class RecoveryProvider implements ITabRecoveryProvider {
constructor (private sessions: SessionsService) { }
export class RecoveryProvider extends TabRecoveryProvider {
constructor (private sessions: SessionsService) {
super()
}
recover (recoveryToken: any): Tab {
if (recoveryToken.type == 'app:terminal') {