ACTIVE_INTERVAL_MS = 10000
$ = window.$
{debounce} = require('underscore')
{EventEmitter} = require('events')
{alert_message} = require('./alerts')
misc = require('smc-util/misc')
{copy, filename_extension, required, defaults, to_json, uuid, from_json} = require('smc-util/misc')
{redux} = require('./smc-react')
{alert_message} = require('./alerts')
misc_page = require('./misc_page')
templates = $("#webapp-console-templates")
console_template = templates.find(".webapp-console")
feature = require('./feature')
IS_TOUCH = feature.IS_TOUCH
CSI = String.fromCharCode(0x9b)
initfile_content = (filename) ->
"""# This initialization file is associated with your terminal in #{filename}.
# It is automatically run whenever it starts up -- restart the terminal via Ctrl-d and Return-key.
# Usually, your ~/.bashrc is executed and this behavior is emulated for completeness:
source ~/.bashrc
# You can export environment variables, e.g. to set custom GIT_* variables
# https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables
#export GIT_AUTHOR_NAME="Your Name"
#export GIT_AUTHOR_EMAIL="[email protected]"
#export GIT_COMMITTER_NAME="Your Name"
#export GIT_COMMITTER_EMAIL="[email protected]"
# It is also possible to automatically start a program ...
#sage
#sage -ipython
#top
# ... or even define a terminal specific function.
#hello () { echo "hello world"; }
"""
focused_console = undefined
client_keydown = (ev) ->
focused_console?.client_keydown(ev)
class Console extends EventEmitter
constructor: (opts={}) ->
@opts = defaults opts,
element : required
project_id : required
path : required
session : undefined
title : ""
filename : ""
rows : 16
cols : 80
editor : undefined
close : undefined
reconnect : undefined
font :
family : undefined
size : undefined
line_height : 120
highlight_mode : 'none'
color_scheme : undefined
on_pause : undefined
on_unpause : undefined
on_reconnecting: undefined
on_reconnected : undefined
set_title : undefined
@_init_default_settings()
@project_id = @opts.project_id
@path = @opts.path
@mark_file_use = debounce(@mark_file_use, 3000)
@resize = debounce(@resize, 500)
@_project_actions = redux.getProjectActions(@project_id)
@is_focused = false
@element = console_template.clone()
@textarea = @element.find(".webapp-console-textarea")
@element.data("console", @)
$(@opts.element).replaceWith(@element)
@set_title(@opts.title)
@_init_colors()
@terminal = new Terminal
cols: @opts.cols
rows: @opts.rows
@terminal.IS_TOUCH = IS_TOUCH
@init_mesg()
Terminal.bindKeys(client_keydown)
@scrollbar = @element.find(".webapp-console-scrollbar")
@scrollbar.scroll () =>
if @ignore_scroll
return
@set_term_to_scrollbar()
@terminal.on 'scroll', (top, rows) =>
@set_scrollbar_to_term()
@_init_ttyjs()
@_init_buttons()
@_init_input_line()
@_init_font_make_default()
@_init_paste_bin()
@_init_rendering_pause()
@textarea.on 'blur', =>
if @_focusing?
@_focus_hidden_textarea()
if not IS_TOUCH
@element.find(".webapp-console-up").hide()
@element.find(".webapp-console-down").hide()
if opts.session?
@set_session(opts.session)
user_is_active: =>
@_last_active = new Date()
user_was_recently_active: =>
return new Date() - (@_last_active ? 0) <= ACTIVE_INTERVAL_MS
append_to_value: (data) =>
@value_orig += data
@value += data.replace(/\x1b\[.{1,5}m|\x1b\].*0;|\x1b\[.*~|\x1b\[?.*l/g,'')
init_mesg: () =>
@terminal.on 'mesg', (mesg) =>
if @_ignore or not @is_focused
return
try
mesg = from_json(mesg)
switch mesg.event
when 'open'
i = 0
foreground = false
for v in mesg.paths
i += 1
if i == mesg.paths.length
foreground = true
if v.file?
@_project_actions?.open_file(path:v.file, foreground:foreground)
if v.directory? and foreground
@_project_actions?.open_directory(v.directory)
catch e
console.log("issue parsing message -- ", e)
reconnect_if_no_recent_data: =>
if not @_got_remote_data? or new Date() - @_got_remote_data >= 15000
@session?.reconnect()
set_session: (session) =>
if @session?
console.warn("BUG: set_session called after session already set -- ignoring")
return
@session = session
@_ignore = true
@_connected = true
@_needs_resize = true
@terminal.on 'data', (data) =>
if @_ignore
return
if not @_connected
@session.reconnect (err) =>
if not err
@session.write_data(data)
return
@session.write_data(data)
{webapp_client} = require('./webapp_client')
latency = webapp_client.latency()
if latency?
delay = Math.min(10000, latency*20)
setTimeout(@reconnect_if_no_recent_data, delay)
@terminal.on 'title', (title) => @set_title(title)
@reset()
@resize_terminal()
@config_session()
set_state_connected: =>
@element.find(".webapp-console-terminal").css('opacity':'1')
@element.find("a[href=\"#refresh\"]").removeClass('btn-success').find(".fa").removeClass('fa-spin')
set_state_disconnected: =>
@element.find(".webapp-console-terminal").css('opacity':'.5')
@element.find("a[href=\"#refresh\"]").addClass('btn-success').find(".fa").addClass('fa-spin')
config_session: () =>
@session.on 'data', (data) =>
@_got_remote_data = new Date()
@set_state_connected()
if @_rendering_is_paused
@_render_buffer += data
else
@render(data)
if @_needs_resize
@resize()
@session.on 'reconnecting', () =>
@_reconnecting = new Date()
@set_state_disconnected()
@session.on 'reconnect', () =>
delete @_reconnecting
partial_code = false
@_needs_resize = true
@_connected = true
@_got_remote_data = new Date()
@set_state_connected()
@reset()
if @session.init_history?
try
@_ignore = true
@terminal.write(@session.init_history)
catch e
console.log(e)
@append_to_value(@session.init_history)
@terminal.queue = ''
@terminal.showCursor()
@session.on 'close', () =>
@_connected = false
if @session.init_history?
try
@terminal.write(@session.init_history)
catch e
console.log(e)
@terminal.queue = ''
@append_to_value(@session.init_history)
@terminal.showCursor()
@resize()
render: (data) =>
if not data?
return
try
@terminal.write(data)
@append_to_value(data)
if @scrollbar_nlines < @terminal.ybase
@update_scrollbar()
setTimeout(@set_scrollbar_to_term, 10)
catch e
console.warn("terminal error -- ",e)
reset: () =>
@value = @value_orig = ''
@scrollbar_nlines = 0
@scrollbar.empty()
@terminal.reset()
update_scrollbar: () =>
while @scrollbar_nlines < @terminal.ybase
@scrollbar.append($("<br>"))
@scrollbar_nlines += 1
pause_rendering: (immediate) =>
if @_rendering_is_paused
return
@_rendering_is_paused = true
if not @_render_buffer?
@_render_buffer = ''
f = () =>
if @_rendering_is_paused
@element.find("a[href=\"#pause\"]").addClass('btn-success').find('i').addClass('fa-play').removeClass('fa-pause')
if immediate
f()
else
setTimeout(f, 500)
@opts.on_pause?()
unpause_rendering: () =>
if not @_rendering_is_paused
return
@_rendering_is_paused = false
f = () =>
@render(@_render_buffer)
@_render_buffer = ''
setTimeout(f, 0)
@element.find("a[href=\"#pause\"]").removeClass('btn-success').find('i').addClass('fa-pause').removeClass('fa-play')
@opts.on_unpause?()
_on_pause_button_clicked: (e) =>
if @_rendering_is_paused
@unpause_rendering()
else
@pause_rendering(true)
return false
_init_rendering_pause: () =>
btn = @element.find("a[href=\"#pause\"]").click (e) =>
@user_is_active()
if @_rendering_is_paused
@unpause_rendering()
else
@pause_rendering(true)
return false
e = @element.find(".webapp-console-terminal")
e.mousedown () =>
@user_is_active()
@pause_rendering(false)
e.mouseup () =>
@user_is_active()
if not getSelection().toString()
@unpause_rendering()
return
s = misc_page.get_selection_start_node()
if s.closest(e).length == 0
@unpause_rendering()
e.on 'copy', =>
@user_is_active()
@unpause_rendering()
setTimeout(@focus, 5)
_init_colors: () =>
colors = Terminal.color_schemes[@opts.color_scheme].colors
for i in [0...16]
Terminal.colors[i] = colors[i]
if colors.length > 16
Terminal.defaultColors =
fg: colors[16]
bg: colors[17]
else
Terminal.defaultColors =
fg: colors[15]
bg: colors[0]
Terminal.colors[256] = Terminal.defaultColors.bg
Terminal.colors[257] = Terminal.defaultColors.fg
mark_file_use: () =>
redux.getActions('file_use').mark_file(@project_id, @path, 'edit')
client_keydown: (ev) =>
@allow_resize = true
if @_ignore
@_ignore = false
@mark_file_use()
@user_is_active()
if ev.ctrlKey and ev.shiftKey
switch ev.keyCode
when 190
@_increase_font_size()
return false
when 188
@_decrease_font_size()
return false
if (ev.metaKey or ev.ctrlKey or ev.altKey) and (ev.keyCode in [17, 86, 91, 93, 223, 224])
@textarea.val('')
if @_rendering_is_paused and not (ev.ctrlKey or ev.metaKey or ev.altKey)
@unpause_rendering()
_increase_font_size: () =>
@opts.font.size += 1
if @opts.font.size <= 159
@_font_size_changed()
_decrease_font_size: () =>
if @opts.font.size >= 2
@opts.font.size -= 1
@_font_size_changed()
_font_size_changed: () =>
@opts.editor?.local_storage("font-size",@opts.font.size)
$(@terminal.element).css('font-size':"#{@opts.font.size}px")
@element.find(".webapp-console-font-indicator-size").text(@opts.font.size)
@element.find(".webapp-console-font-indicator").stop().show().animate(opacity:1).fadeOut(duration:8000)
@resize()
_init_font_make_default: () =>
@element.find("a[href=\"#font-make-default\"]").click () =>
@user_is_active()
redux.getTable('account').set(terminal:{font_size:@opts.font.size})
return false
_init_default_settings: () =>
settings = redux.getStore('account').get_terminal_settings()
if not @opts.font.size?
@opts.font.size = settings?.font_size ? 14
if not @opts.color_scheme?
@opts.color_scheme = settings?.color_scheme ? "default"
if not @opts.font.family?
@opts.font.family = settings?.font ? "monospace"
_init_ttyjs: () ->
@terminal.open()
@terminal.element.className = "webapp-console-terminal"
ter = $(@terminal.element)
@element.find(".webapp-console-terminal").replaceWith(ter)
ter.css
'font-family' : @opts.font.family + ", monospace"
'font-size' : "#{@opts.font.size}px"
'line-height' : "#{@opts.font.line_height}%"
if IS_TOUCH
@mobile_target = @element.find(".webapp-console-for-mobile").show()
@mobile_target.css('width', ter.css('width'))
@mobile_target.css('height', ter.css('height'))
@_click = (e) =>
@user_is_active()
t = $(e.target)
if t[0]==@mobile_target[0] or t.hasParent(@element).length > 0
@focus()
else
@blur()
$(document).on 'click', @_click
else
@_mousedown = (e) =>
@user_is_active()
if $(e.target).hasParent(@element).length > 0
@focus()
else
@blur()
$(document).on 'mousedown', @_mousedown
@_mouseup = (e) =>
@user_is_active()
t = $(e.target)
sel = window.getSelection().toString()
if t.hasParent(@element).length > 0 and sel.length == 0
@_focus_hidden_textarea()
$(document).on 'mouseup', @_mouseup
$(@terminal.element).bind 'copy', (e) =>
@user_is_active()
setTimeout(@_focus_hidden_textarea, 10)
remove: () =>
@session?.close()
delete @session
@_connected = false
if @_mousedown?
$(document).off('mousedown', @_mousedown)
if @_mouseup?
$(document).off('mouseup', @_mouseup)
if @_click?
$(document).off('click', @_click)
_focus_hidden_textarea: () =>
@textarea.focus()
_init_fullscreen: () =>
fullscreen = @element.find("a[href=\"#fullscreen\"]")
exit_fullscreen = @element.find("a[href=\"#exit_fullscreen\"]")
fullscreen.on 'click', () =>
@user_is_active()
@fullscreen()
exit_fullscreen.show()
fullscreen.hide()
return false
exit_fullscreen.hide().on 'click', () =>
@user_is_active()
@exit_fullscreen()
exit_fullscreen.hide()
fullscreen.show()
return false
_init_buttons: () ->
editor = @terminal.editor
@element.find("a").tooltip(delay:{ show: 500, hide: 100 })
@element.find("a[href=\"#increase-font\"]").click () =>
@user_is_active()
@_increase_font_size()
return false
@element.find("a[href=\"#decrease-font\"]").click () =>
@user_is_active()
@_decrease_font_size()
return false
@element.find("a[href=\"#refresh\"]").click () =>
@user_is_active()
@session?.reconnect()
return false
@element.find("a[href=\"#paste\"]").click () =>
@user_is_active()
id = uuid()
s = "<h2><i class='fa project-file-icon fa-terminal'></i> Terminal Copy and Paste</h2>Copy and paste in terminals works as usual: to copy, highlight text then press ctrl+c (or command+c); press ctrl+v (or command+v) to paste. <br><br><span class='lighten'>NOTE: When no text is highlighted, ctrl+c sends the usual interrupt signal.</span><br><hr>You can copy the terminal history from here:<br><br><textarea readonly style='font-family: monospace;cursor: auto;width: 97%' id='#{id}' rows=10></textarea>"
bootbox.alert(s)
elt = $("##{id}")
elt.val(@value).scrollTop(elt[0].scrollHeight)
return false
@element.find("a[href=\"#initfile\"]").click () =>
initfn = misc.console_init_filename(@opts.filename)
content = initfile_content(@opts.filename)
{webapp_client} = require('./webapp_client')
webapp_client.exec
project_id : @project_id
command : "test ! -r '#{initfn}' && echo '#{content}' > '#{initfn}'"
bash : true
err_on_exit : false
cb : (err, output) =>
if err
alert_message(type:'error', message:"problem creating initfile: #{err}")
else
@_project_actions?.open_file(path:initfn, foreground:true)
open_copyable_history: () =>
id = uuid()
s = "<h2><i class='fa project-file-icon fa-terminal'></i> Terminal Copy and Paste</h2>Copy and paste in terminals works as usual: to copy, highlight text then press ctrl+c (or command+c); press ctrl+v (or command+v) to paste. <br><br><span class='lighten'>NOTE: When no text is highlighted, ctrl+c sends the usual interrupt signal.</span><br><hr>You can copy the terminal history from here:<br><br><textarea readonly style='font-family: monospace;cursor: auto;width: 97%' id='#{id}' rows=10></textarea>"
bootbox.alert(s)
elt = $("##{id}")
elt.val(@value).scrollTop(elt[0].scrollHeight)
open_init_file: () =>
initfn = misc.console_init_filename(@opts.filename)
content = initfile_content(@opts.filename)
{webapp_client} = require('./webapp_client')
webapp_client.exec
project_id : @project_id
command : "test ! -r '#{initfn}' && echo '#{content}' > '#{initfn}'"
bash : true
err_on_exit : false
cb : (err, output) =>
if err
alert_message(type:'error', message:"problem creating initfile: #{err}")
else
@_project_actions?.open_file(path:initfn, foreground:true)
_init_input_line: () =>
if not IS_TOUCH
@element.find(".webapp-console-mobile-input").hide()
input_line = @element.find('.webapp-console-input-line')
input_line.on 'focus', =>
@_input_line_is_focused = true
@terminal.blur()
input_line.on 'blur', =>
@_input_line_is_focused = false
submit_line = () =>
x = input_line.val()
x = misc.replace_all(x, '“','"')
x = misc.replace_all(x, '”','"')
x = misc.replace_all(x, '‘',"'")
x = misc.replace_all(x, '’',"'")
x = misc.replace_all(x, '–', "--")
x = misc.replace_all(x, '—', "---")
@_ignore = false
@session?.write_data(x)
input_line.val('')
input_line.on 'keydown', (e) =>
if e.which == 13
e.preventDefault()
submit_line()
@_ignore = false
@session?.write_data("\n")
return false
else if e.which == 67 and e.ctrlKey
submit_line()
@terminal.keyDown(keyCode:67, shiftKey:false, ctrlKey:true)
@element.find(".webapp-console-submit-line").click () =>
submit_line()
@_ignore = false
@session?.write_data("\n")
return false
@element.find(".webapp-console-submit-submit").click () =>
submit_line()
return false
@element.find(".webapp-console-submit-tab").click () =>
submit_line()
@terminal.keyDown(keyCode:9, shiftKey:false)
@element.find(".webapp-console-submit-esc").click () =>
submit_line()
@terminal.keyDown(keyCode:27, shiftKey:false, ctrlKey:false)
@element.find(".webapp-console-submit-up").click () =>
submit_line()
@terminal.keyDown(keyCode:38, shiftKey:false, ctrlKey:false)
@element.find(".webapp-console-submit-down").click () =>
submit_line()
@terminal.keyDown(keyCode:40, shiftKey:false, ctrlKey:false)
@element.find(".webapp-console-submit-left").click () =>
submit_line()
@terminal.keyDown(keyCode:37, shiftKey:false, ctrlKey:false)
@element.find(".webapp-console-submit-right").click () =>
submit_line()
@terminal.keyDown(keyCode:39, shiftKey:false, ctrlKey:false)
@element.find(".webapp-console-submit-ctrl-c").show().click (e) =>
submit_line()
@terminal.keyDown(keyCode:67, shiftKey:false, ctrlKey:true)
@element.find(".webapp-console-submit-ctrl-b").show().click (e) =>
submit_line()
@terminal.keyDown(keyCode:66, shiftKey:false, ctrlKey:true)
_init_paste_bin: () =>
pb = @textarea
f = (evt) =>
@_ignore = false
data = pb.val()
pb.val('')
@session?.write_data(data)
pb.on 'paste', =>
pb.val('')
setTimeout(f,5)
terminate_session: () =>
@session?.terminate_session()
fullscreen: () =>
h = $(".navbar-fixed-top").height()
@element.css
position : 'absolute'
width : "97%"
top : h
left : 0
right : 0
bottom : 1
$(@terminal.element).css
position : 'absolute'
width : "100%"
top : "3.5em"
bottom : 1
@resize()
exit_fullscreen: () =>
for elt in [$(@terminal.element), @element]
elt.css
position : 'relative'
top : 0
width : "100%"
@resize()
refresh: () =>
@terminal.refresh(0, @opts.rows-1)
@terminal.showCursor()
resize: =>
if not @user_was_recently_active()
return
if not @session?
return
if not @_connected
return
if not @value
return
@resize_terminal()
resize_code = (cols, rows) ->
return CSI + "4;#{rows};#{cols}t"
@session.write_data(resize_code(@opts.cols, @opts.rows))
@full_rerender()
@refresh()
@_needs_resize = false
full_rerender: =>
value = @value_orig
@reset()
@_ignore = true
@render(value)
resize_terminal: () =>
@_c = $("<span>Term-inal </span>").prependTo(@terminal.element)
character_width = @_c.width()/10
@_c.remove()
elt = $(@terminal.element)
heights = ($(x).height() for x in elt.children())
heights = (x for x in heights when x <= heights[0] + 2)
row_height = Math.max( heights ... )
if character_width == 0 or row_height == 0
return
font_size = @opts.font.size
new_cols = Math.max(1, Math.floor(elt.width() / character_width))
height = elt.height()
if IS_TOUCH
height -= 60
new_rows = Math.max(1, Math.floor(height / row_height))
@terminal.resize(new_cols, new_rows)
@opts.cols = new_cols
@opts.rows = new_rows
set_scrollbar_to_term: () =>
if @terminal.ybase == 0
@scrollbar.hide()
return
else
@scrollbar.show()
if @ignore_scroll
return
@ignore_scroll = true
f = () =>
@ignore_scroll = false
setTimeout(f, 100)
max_scrolltop = @scrollbar[0].scrollHeight - @scrollbar.height()
@scrollbar.scrollTop(max_scrolltop * @terminal.ydisp / @terminal.ybase)
set_term_to_scrollbar: () =>
max_scrolltop = @scrollbar[0].scrollHeight - @scrollbar.height()
ydisp = Math.floor( @scrollbar.scrollTop() * @terminal.ybase / max_scrolltop)
@terminal.ydisp = ydisp
@terminal.refresh(0, @terminal.rows-1)
console_is_open: () =>
return @element.closest(document.documentElement).length > 0
blur: () =>
if focused_console == @
focused_console = undefined
@is_focused = false
try
@terminal.blur()
catch e
$(@terminal.element).addClass('webapp-console-blur').removeClass('webapp-console-focus')
focus: (force) =>
if @_reconnecting? and new Date() - @_reconnecting > 10000
@reconnect_if_no_recent_data()
if @is_focused and not force
return
@_focusing = true
focused_console = @
@is_focused = true
@textarea.blur()
$(@terminal.element).focus()
if not IS_TOUCH
@_focus_hidden_textarea()
@terminal.focus()
$(@terminal.element).addClass('webapp-console-focus').removeClass('webapp-console-blur')
setTimeout((()=>delete @_focusing), 5)
set_title: (title) ->
@opts.set_title?(title)
@element.find(".webapp-console-title").text(title)
exports.Console = Console
$.fn.extend
webapp_console: (opts={}) ->
@each () ->
t = $(this)
if opts == false
con = t.data('console')
if con?
con.remove()
return t
else
opts0 = copy(opts)
opts0.element = this
return t.data('console', new Console(opts0))