CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/console.coffee
Views: 687
1
#########################################################################
2
# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
# License: MS-RSL – see LICENSE.md for details
4
#########################################################################
5
6
# An Xterm Console Window
7
8
ACTIVE_INTERVAL_MS = 10000
9
10
MAX_TERM_HISTORY = 500000
11
12
$ = window.$
13
14
{debounce} = require('underscore')
15
16
{EventEmitter} = require('events')
17
misc = require('@cocalc/util/misc')
18
{copy, filename_extension, required, defaults, to_json, uuid, from_json} = require('@cocalc/util/misc')
19
{redux} = require('./app-framework')
20
{alert_message} = require('./alerts')
21
22
templates = $("#webapp-console-templates")
23
console_template = templates.find(".webapp-console")
24
{getStudentProjectFunctionality} = require('@cocalc/frontend/course/')
25
26
{delay} = require('awaiting')
27
28
feature = require('./feature')
29
{webapp_client} = require('./webapp-client')
30
31
# How long to wait after any full rerender.
32
# Obviously, this is stupid code, but this code is slated to go away.
33
IGNORE_TERMINAL_TIME_MS = 500
34
35
IS_TOUCH = feature.IS_TOUCH # still have to use crappy mobile for now on
36
37
initfile_content = (filename) ->
38
"""# This initialization file is associated with your terminal in #{filename}.
39
# It is automatically run whenever it starts up -- restart the terminal via Ctrl-d and Return-key.
40
41
# Usually, your ~/.bashrc is executed and this behavior is emulated for completeness:
42
source ~/.bashrc
43
44
# You can export environment variables, e.g. to set custom GIT_* variables
45
# https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables
46
#export GIT_AUTHOR_NAME="Your Name"
47
#export GIT_AUTHOR_EMAIL="[email protected]"
48
#export GIT_COMMITTER_NAME="Your Name"
49
#export GIT_COMMITTER_EMAIL="[email protected]"
50
51
# It is also possible to automatically start a program ...
52
53
#sage
54
#sage -ipython
55
#top
56
57
# ... or even define a terminal specific function.
58
#hello () { echo "hello world"; }
59
"""
60
61
focused_console = undefined
62
client_keydown = (ev) ->
63
focused_console?.client_keydown(ev)
64
65
class Console extends EventEmitter
66
constructor: (opts={}) ->
67
super()
68
@opts = defaults opts,
69
element : required # DOM (or jQuery) element that is replaced by this console.
70
project_id : required
71
path : required
72
title : ""
73
filename : ""
74
rows : 40
75
cols : 100
76
editor : undefined # FileEditor instance -- needed for some actions, e.g., opening a file
77
close : undefined # if defined, called when close button clicked.
78
reconnect : undefined # if defined, opts.reconnect?() is called when session console wants to reconnect; this should call set_session.
79
80
font :
81
family : undefined
82
size : undefined # CSS font-size in points
83
line_height : 120 # CSS line-height percentage
84
85
highlight_mode : 'none'
86
color_scheme : undefined
87
on_pause : undefined # Called after pause_rendering is called
88
on_unpause : undefined # Called after unpause_rendering is called
89
on_reconnecting: undefined
90
on_reconnected : undefined
91
set_title : undefined
92
93
@_init_default_settings()
94
95
@project_id = @opts.project_id
96
@path = @opts.path
97
98
@mark_file_use = debounce(@mark_file_use, 3000)
99
@resize = debounce(@resize, 500)
100
101
@_project_actions = redux.getProjectActions(@project_id)
102
103
# The is_focused variable keeps track of whether or not the
104
# editor is focused. This impacts the cursor, and also whether
105
# messages such as open_file or open_directory are handled (see @init_mesg).
106
@is_focused = false
107
108
# Create the DOM element that realizes this console, from an HTML template.
109
@element = console_template.clone()
110
@element.processIcons()
111
@textarea = @element.find(".webapp-console-textarea")
112
113
# Record on the DOM element a reference to the console
114
# instance, which is useful for client code.
115
@element.data("console", @)
116
117
# Actually put the DOM element into the (likely visible) DOM
118
# in the place specified by the client.
119
$(@opts.element).replaceWith(@element)
120
121
# Set the initial title, though of course the term can change
122
# this via certain escape codes.
123
@set_title(@opts.title)
124
125
# Create the new Terminal object -- this is defined in
126
# static/term/term.js -- it's a nearly complete implementation of
127
# the xterm protocol.
128
129
@terminal = new Terminal
130
cols: @opts.cols
131
rows: @opts.rows
132
133
@terminal.IS_TOUCH = IS_TOUCH
134
135
@terminal.on 'title', (title) => @set_title(title)
136
137
@init_mesg()
138
139
# The first time Terminal.bindKeys is called, it makes Terminal
140
# listen on *all* keystrokes for the rest of the program. It
141
# only has to be done once -- any further times are ignored.
142
Terminal.bindKeys(client_keydown)
143
144
@scrollbar = @element.find(".webapp-console-scrollbar")
145
146
@scrollbar.scroll () =>
147
if @ignore_scroll
148
return
149
@set_term_to_scrollbar()
150
151
@terminal.on 'scroll', (top, rows) =>
152
@set_scrollbar_to_term()
153
154
@terminal.on 'data', (data) =>
155
#console.log('terminal data', @_ignore_terminal)
156
if @_ignore_terminal? and new Date() - @_ignore_terminal < IGNORE_TERMINAL_TIME_MS
157
#console.log("ignore", data)
158
return
159
#console.log("@terminal data='#{JSON.stringify(data)}'")
160
@conn?.write(data)
161
162
@_init_ttyjs()
163
164
# Initialize buttons
165
@_init_buttons()
166
@_init_input_line()
167
168
# Initialize the "set default font size" button that appears.
169
@_init_font_make_default()
170
171
# Initialize the paste bin
172
@_init_paste_bin()
173
174
# Init pausing rendering when user clicks
175
@_init_rendering_pause()
176
177
@textarea.on 'blur', =>
178
if @_focusing? # see comment in @focus.
179
@_focus_hidden_textarea()
180
181
# delete scroll buttons except on mobile
182
if not IS_TOUCH
183
@element.find(".webapp-console-up").hide()
184
@element.find(".webapp-console-down").hide()
185
186
@connect()
187
#window.c = @
188
189
connect: =>
190
if getStudentProjectFunctionality(@opts.project_id).disableTerminals
191
# short lines since this is only used on mobile.
192
@render("Terminals are currently disabled\r\nin this project.\r\nPlease contact your
193
instructor\r\nif you have questions.\r\n");
194
return;
195
api = await webapp_client.project_client.api(@project_id)
196
# aux_path for compat with new frame terminal editor.
197
{aux_file} = require("@cocalc/util/misc")
198
aux_path = aux_file("#{@path}-0", "term");
199
@conn = await api.terminal(aux_path)
200
@conn.on 'end', =>
201
if @conn?
202
@connect()
203
@_ignore = true
204
first_render = true
205
@conn.on 'data', (data) =>
206
#console.log("@conn got data '#{data}'")
207
#console.log("@conn data='#{JSON.stringify(data)}'")
208
#console.log("@conn got data of length", data.length)
209
if typeof(data) == 'string'
210
if @_rendering_is_paused
211
@_render_buffer ?= ''
212
@_render_buffer += data
213
else
214
if first_render
215
first_render = false
216
@_ignore_terminal = new Date()
217
@render(data)
218
else if typeof(data) == 'object'
219
@handle_control_mesg(data)
220
@resize_terminal()
221
222
reconnect: =>
223
@disconnect()
224
@connect()
225
226
handle_control_mesg: (data) =>
227
#console.log('terminal command', data)
228
switch data.cmd
229
when 'size'
230
@handle_resize(data.rows, data.cols)
231
break
232
when 'burst'
233
@element.find(".webapp-burst-indicator").show()
234
break
235
when 'no-burst'
236
@element.find(".webapp-burst-indicator").fadeOut(2000)
237
break
238
when 'no-ignore'
239
delete @_ignore
240
break
241
when 'close'
242
# remote request that we close this tab, e.g., one user
243
# booting all others out of his terminal session.
244
alert_message(type:'info', message:"You have been booted out of #{@opts.filename}.")
245
@_project_actions?.close_file(@opts.filename)
246
@_project_actions?.set_active_tab('files')
247
break
248
249
handle_resize: (rows, cols) =>
250
if @_terminal_size and rows == @_terminal_size.rows and cols == @_terminal_size.cols
251
return
252
# Resize the renderer
253
@_terminal_size = {rows:rows, cols:cols}
254
@terminal.resize(cols, rows)
255
@element.find(".webapp-console-terminal").css({width:null, height:null})
256
@full_rerender()
257
258
append_to_value: (data) =>
259
# this @value is used for copy/paste of the session history and @value_orig for resize/refresh
260
@value_orig ?= ''
261
@value_orig += data
262
@value += data.replace(/\x1b\[.{1,5}m|\x1b\].*0;|\x1b\[.*~|\x1b\[?.*l/g,'')
263
if @value_orig.length > MAX_TERM_HISTORY
264
@value_orig = @value_orig.slice(@value_orig.length - Math.round(MAX_TERM_HISTORY/1.5))
265
@full_rerender()
266
267
268
init_mesg: () =>
269
@terminal.on 'mesg', (mesg) =>
270
if @_ignore or not @is_focused # ignore messages when terminal not in focus (otherwise collaboration is confusing)
271
return
272
try
273
mesg = from_json(mesg)
274
switch mesg.event
275
when 'open'
276
i = 0
277
foreground = false
278
for v in mesg.paths
279
i += 1
280
if i == mesg.paths.length
281
foreground = true
282
if v.file?
283
@_project_actions?.open_file(path:v.file, foreground:foreground)
284
if v.directory? and foreground
285
@_project_actions?.open_directory(v.directory)
286
catch e
287
console.log("issue parsing message -- ", e)
288
289
reconnect_if_no_recent_data: =>
290
#console.log 'check for recent data'
291
if not @_got_remote_data? or new Date() - @_got_remote_data >= 15000
292
#console.log 'reconnecting since no recent data'
293
@reconnect()
294
295
set_state_connected: =>
296
@element.find(".webapp-console-terminal").css('opacity':'1')
297
@element.find("a[href=\"#refresh\"]").removeClass('btn-success').find(".fa").removeClass('fa-spin')
298
299
set_state_disconnected: =>
300
@element.find(".webapp-console-terminal").css('opacity':'.5')
301
@element.find("a[href=\"#refresh\"]").addClass('btn-success').find(".fa").addClass('fa-spin')
302
303
###
304
config_session: () =>
305
# The remote server sends data back to us to display:
306
@session.on 'data', (data) =>
307
# console.log("terminal got #{data.length} characters -- '#{data}'")
308
@_got_remote_data = new Date()
309
@set_state_connected() # connected if we are getting data.
310
if @_rendering_is_paused
311
@_render_buffer += data
312
else
313
@render(data)
314
315
if @_needs_resize
316
@resize()
317
318
@session.on 'reconnecting', () =>
319
#console.log('terminal: reconnecting')
320
@_reconnecting = new Date()
321
@set_state_disconnected()
322
323
@session.on 'reconnect', () =>
324
delete @_reconnecting
325
partial_code = false
326
@_needs_resize = true # causes a resize when we next get data.
327
@_connected = true
328
@_got_remote_data = new Date()
329
@set_state_connected()
330
@reset()
331
if @session.init_history?
332
#console.log("writing history")
333
try
334
ignore = @_ignore
335
@_ignore = true
336
@terminal.write(@session.init_history)
337
@_ignore = ignore
338
catch e
339
console.log(e)
340
#console.log("recording history for copy/paste buffer")
341
@append_to_value(@session.init_history)
342
343
# On first write we ignore any queued terminal attributes responses that result.
344
@terminal.queue = ''
345
@terminal.showCursor()
346
347
@session.on 'close', () =>
348
@_connected = false
349
350
# Initialize pinging the server to keep the console alive
351
#@_init_session_ping()
352
353
if @session.init_history?
354
#console.log("session -- history.length='#{@session.init_history.length}'")
355
try
356
ignore = @_ignore
357
@_ignore = true
358
@terminal.write(@session.init_history)
359
@_ignore = ignore
360
catch e
361
console.log(e)
362
# On first write we ignore any queued terminal attributes responses that result.
363
@terminal.queue = ''
364
@append_to_value(@session.init_history)
365
366
@terminal.showCursor()
367
@resize()
368
###
369
370
render: (data) =>
371
if not data?
372
return
373
try
374
@terminal.write(data)
375
@append_to_value(data)
376
377
if @scrollbar_nlines < @terminal.ybase
378
@update_scrollbar()
379
380
setTimeout(@set_scrollbar_to_term, 10)
381
# See https://github.com/sagemathinc/cocalc/issues/1301
382
#redux.getProjectActions(@project_id).flag_file_activity(@path)
383
catch e
384
# WARNING -- these are all basically bugs, I think...
385
# That said, try/catching them is better than having
386
# the whole terminal just be broken.
387
console.warn("terminal error -- ",e)
388
389
reset: () =>
390
# reset the terminal to clean; need to do this on connect or reconnect.
391
#$(@terminal.element).css('opacity':'0.5').animate(opacity:1, duration:500)
392
@value = @value_orig = ''
393
@scrollbar_nlines = 0
394
@scrollbar.empty()
395
@terminal.reset()
396
397
update_scrollbar: () =>
398
while @scrollbar_nlines < @terminal.ybase
399
@scrollbar.append($("<br>"))
400
@scrollbar_nlines += 1
401
402
pause_rendering: (immediate) =>
403
if @_rendering_is_paused
404
return
405
#console.log 'pause_rendering'
406
@_rendering_is_paused = true
407
if not @_render_buffer?
408
@_render_buffer = ''
409
f = () =>
410
if @_rendering_is_paused
411
@element.find("a[href=\"#pause\"]").addClass('btn-success').find('i').addClass('fa-play').removeClass('fa-pause')
412
if immediate
413
f()
414
else
415
setTimeout(f, 500)
416
@opts.on_pause?()
417
418
unpause_rendering: () =>
419
if not @_rendering_is_paused
420
return
421
#console.log 'unpause_rendering'
422
@_rendering_is_paused = false
423
f = () =>
424
@render(@_render_buffer)
425
@_render_buffer = ''
426
# Do the actual rendering the next time around, so that the copy operation completes with the
427
# current selection instead of the post-render empty version.
428
setTimeout(f, 0)
429
@element.find("a[href=\"#pause\"]").removeClass('btn-success').find('i').addClass('fa-pause').removeClass('fa-play')
430
@opts.on_unpause?()
431
432
#######################################################################
433
# Private Methods
434
#######################################################################
435
436
_on_pause_button_clicked: (e) =>
437
if @_rendering_is_paused
438
@unpause_rendering()
439
else
440
@pause_rendering(true)
441
return false
442
443
_init_rendering_pause: () =>
444
445
btn = @element.find("a[href=\"#pause\"]").click (e) =>
446
if @_rendering_is_paused
447
@unpause_rendering()
448
else
449
@pause_rendering(true)
450
return false
451
452
e = @element.find(".webapp-console-terminal")
453
e.mousedown () =>
454
@pause_rendering(false)
455
456
e.mouseup () =>
457
if not getSelection().toString()
458
@unpause_rendering()
459
return
460
461
e.on 'copy', =>
462
@unpause_rendering()
463
setTimeout(@focus, 5) # must happen in next cycle or copy will not work due to loss of focus.
464
465
mark_file_use: () =>
466
redux.getActions('file_use').mark_file(@project_id, @path, 'edit')
467
468
client_keydown: (ev) =>
469
@allow_resize = true
470
471
if @_ignore
472
# no matter what cancel ignore if the user starts typing, since we absolutely must not loose anything they type.
473
@_ignore = false
474
475
@mark_file_use()
476
477
if ev.ctrlKey and ev.shiftKey
478
switch ev.keyCode
479
when 190 # "control-shift->"
480
@_increase_font_size()
481
return false
482
when 188 # "control-shift-<"
483
@_decrease_font_size()
484
return false
485
else
486
delete @_ignore_terminal
487
488
if (ev.metaKey or ev.ctrlKey or ev.altKey) and (ev.keyCode in [17, 86, 91, 93, 223, 224]) # command or control key (could be a paste coming)
489
#console.log("resetting hidden textarea")
490
#console.log("clear hidden text area paste bin")
491
# clear the hidden textarea pastebin, since otherwise
492
# everything that the user typed before pasting appears
493
# in the paste, which is very, very bad.
494
# NOTE: we could do this on all keystrokes. WE restrict as above merely for efficiency purposes.
495
# See http://stackoverflow.com/questions/3902635/how-does-one-capture-a-macs-command-key-via-javascript
496
@textarea.val('')
497
if @_rendering_is_paused
498
if not (ev.ctrlKey or ev.metaKey or ev.altKey)
499
@unpause_rendering()
500
else
501
return false
502
503
_increase_font_size: () =>
504
@opts.font.size += 1
505
if @opts.font.size <= 159
506
@_font_size_changed()
507
508
_decrease_font_size: () =>
509
if @opts.font.size >= 2
510
@opts.font.size -= 1
511
@_font_size_changed()
512
513
_font_size_changed: () =>
514
@opts.editor?.local_storage("font-size",@opts.font.size)
515
$(@terminal.element).css('font-size':"#{@opts.font.size}px")
516
@element.find(".webapp-console-font-indicator-size").text(@opts.font.size)
517
@element.find(".webapp-console-font-indicator").stop().show().animate(opacity:1).fadeOut(duration:8000)
518
@resize_terminal()
519
520
_init_font_make_default: () =>
521
@element.find("a[href=\"#font-make-default\"]").click () =>
522
redux.getTable('account').set(terminal:{font_size:@opts.font.size})
523
return false
524
525
_init_default_settings: () =>
526
settings = redux.getStore('account').get_terminal_settings()
527
if not @opts.font.size?
528
@opts.font.size = settings?.font_size ? 14
529
if not @opts.color_scheme?
530
@opts.color_scheme = settings?.color_scheme ? "default"
531
if not @opts.font.family?
532
@opts.font.family = settings?.font ? "monospace"
533
534
_init_ttyjs: () ->
535
# Create the terminal DOM objects
536
@terminal.open()
537
@terminal.set_color_scheme(@opts.color_scheme)
538
539
# Give it our style; there is one in term.js (upstream), but it is named in a too-generic way.
540
@terminal.element.className = "webapp-console-terminal"
541
ter = $(@terminal.element)
542
@element.find(".webapp-console-terminal").replaceWith(ter)
543
544
ter.css
545
'font-family' : @opts.font.family + ", monospace" # monospace fallback
546
'font-size' : "#{@opts.font.size}px"
547
'line-height' : "#{@opts.font.line_height}%"
548
549
# Focus/blur handler.
550
if IS_TOUCH # so keyboard appears
551
@mobile_target = @element.find(".webapp-console-for-mobile").show()
552
@mobile_target.css('width', ter.css('width'))
553
@mobile_target.css('height', ter.css('height'))
554
@_click = (e) =>
555
if @is_hidden
556
return
557
t = $(e.target)
558
if t[0]==@mobile_target[0] or t.hasParent(@element).length > 0
559
@focus()
560
else
561
@blur()
562
$(document).on 'click', @_click
563
else
564
mousedown_focus = false
565
@_mousedown = (e) =>
566
if @is_hidden
567
return
568
if mousedown_focus or $(e.target).hasParent(@element).length > 0
569
mousedown_focus = true
570
@focus()
571
else
572
@blur()
573
$(document).on 'mousedown', @_mousedown
574
575
@_mouseup = (e) =>
576
if @is_hidden
577
return
578
mousedown_focus = false
579
t = $(e.target)
580
sel = window.getSelection().toString()
581
if t.hasParent(@element).length > 0 and sel.length == 0
582
@_focus_hidden_textarea()
583
$(document).on 'mouseup', @_mouseup
584
585
$(@terminal.element).bind 'copy', (e) =>
586
# re-enable paste but only *after* the copy happens
587
setTimeout(@_focus_hidden_textarea, 10)
588
589
# call this when deleting the terminal (removing it from DOM, etc.)
590
remove: () =>
591
@disconnect()
592
if @_mousedown?
593
$(document).off('mousedown', @_mousedown)
594
if @_mouseup?
595
$(document).off('mouseup', @_mouseup)
596
if @_click?
597
$(document).off('click', @_click)
598
599
_focus_hidden_textarea: () =>
600
@textarea.focus()
601
602
_init_fullscreen: () =>
603
fullscreen = @element.find("a[href=\"#fullscreen\"]")
604
exit_fullscreen = @element.find("a[href=\"#exit_fullscreen\"]")
605
fullscreen.on 'click', () =>
606
@fullscreen()
607
exit_fullscreen.show()
608
fullscreen.hide()
609
return false
610
exit_fullscreen.hide().on 'click', () =>
611
@exit_fullscreen()
612
exit_fullscreen.hide()
613
fullscreen.show()
614
return false
615
616
_init_buttons: () ->
617
editor = @terminal.editor
618
619
@element.find("a").tooltip(delay:{ show: 500, hide: 100 })
620
621
@element.find("a[href=\"#increase-font\"]").click () =>
622
@_increase_font_size()
623
return false
624
625
@element.find("a[href=\"#decrease-font\"]").click () =>
626
@_decrease_font_size()
627
return false
628
629
@element.find("a[href=\"#refresh\"]").click () =>
630
@reconnect()
631
return false
632
633
@element.find("a[href=\"#paste\"]").click () =>
634
id = uuid()
635
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>"
636
bootbox.alert(s)
637
elt = $("##{id}")
638
elt.val(@value).scrollTop(elt[0].scrollHeight)
639
return false
640
641
@element.find("a[href=\"#boot\"]").click () =>
642
@conn?.write({cmd:'boot'})
643
alert_message(type:'info', message:"This terminal should now close for all other users, which allows you to resize it as large as you want.")
644
645
@element.find("a[href=\"#initfile\"]").click () =>
646
initfn = misc.console_init_filename(@opts.filename)
647
content = initfile_content(@opts.filename)
648
webapp_client.exec
649
project_id : @project_id
650
command : "test ! -r '#{initfn}' && echo '#{content}' > '#{initfn}'"
651
bash : true
652
err_on_exit : false
653
cb : (err, output) =>
654
if err
655
alert_message(type:'error', message:"problem creating initfile: #{err}")
656
else
657
@_project_actions?.open_file(path:initfn, foreground:true)
658
659
open_copyable_history: () =>
660
id = uuid()
661
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>"
662
bootbox.alert(s)
663
elt = $("##{id}")
664
elt.val(@value).scrollTop(elt[0].scrollHeight)
665
666
open_init_file: () =>
667
initfn = misc.console_init_filename(@opts.filename)
668
content = initfile_content(@opts.filename)
669
webapp_client.exec
670
project_id : @project_id
671
command : "test ! -r '#{initfn}' && echo '#{content}' > '#{initfn}'"
672
bash : true
673
err_on_exit : false
674
cb : (err, output) =>
675
if err
676
alert_message(type:'error', message:"problem creating initfile: #{err}")
677
else
678
@_project_actions?.open_file(path:initfn, foreground:true)
679
680
_init_input_line: () =>
681
#if not IS_TOUCH
682
# @element.find(".webapp-console-mobile-input").hide()
683
# return
684
685
if not IS_TOUCH
686
@element.find(".webapp-console-mobile-input").hide()
687
688
input_line = @element.find('.webapp-console-input-line')
689
690
input_line.on 'focus', =>
691
@_input_line_is_focused = true
692
@terminal.blur()
693
input_line.on 'blur', =>
694
@_input_line_is_focused = false
695
696
submit_line = () =>
697
x = input_line.val()
698
# Apple text input replaces single and double quotes by some stupid
699
# fancy unicode, which is incompatible with most uses in the terminal.
700
# Here we switch it back. (Note: not doing exactly this renders basically all
701
# dev related tools on iPads very frustrating. Not so, CoCalc :-)
702
x = misc.replace_all(x, '“','"')
703
x = misc.replace_all(x, '”','"')
704
x = misc.replace_all(x, '‘',"'")
705
x = misc.replace_all(x, '’',"'")
706
x = misc.replace_all(x, '–', "--")
707
x = misc.replace_all(x, '—', "---")
708
@_ignore = false
709
@conn?.write(x)
710
input_line.val('')
711
712
input_line.on 'keydown', (e) =>
713
if e.which == 13
714
e.preventDefault()
715
submit_line()
716
@_ignore = false
717
@conn?.write("\n")
718
return false
719
else if e.which == 67 and e.ctrlKey
720
submit_line()
721
@terminal.keyDown(keyCode:67, shiftKey:false, ctrlKey:true)
722
723
@element.find(".webapp-console-submit-line").click () =>
724
#@focus()
725
submit_line()
726
@_ignore = false
727
@conn?.write("\n")
728
return false
729
730
@element.find(".webapp-console-submit-submit").click () =>
731
#@focus()
732
submit_line()
733
return false
734
735
@element.find(".webapp-console-submit-tab").click () =>
736
#@focus()
737
submit_line()
738
@terminal.keyDown(keyCode:9, shiftKey:false)
739
740
@element.find(".webapp-console-submit-esc").click () =>
741
#@focus()
742
submit_line()
743
@terminal.keyDown(keyCode:27, shiftKey:false, ctrlKey:false)
744
745
@element.find(".webapp-console-submit-up").click () =>
746
#@focus()
747
submit_line()
748
@terminal.keyDown(keyCode:38, shiftKey:false, ctrlKey:false)
749
750
@element.find(".webapp-console-submit-down").click () =>
751
#@focus()
752
submit_line()
753
@terminal.keyDown(keyCode:40, shiftKey:false, ctrlKey:false)
754
755
@element.find(".webapp-console-submit-left").click () =>
756
#@focus()
757
submit_line()
758
@terminal.keyDown(keyCode:37, shiftKey:false, ctrlKey:false)
759
760
@element.find(".webapp-console-submit-right").click () =>
761
#@focus()
762
submit_line()
763
@terminal.keyDown(keyCode:39, shiftKey:false, ctrlKey:false)
764
765
@element.find(".webapp-console-submit-ctrl-c").show().click (e) =>
766
#@focus()
767
submit_line()
768
@terminal.keyDown(keyCode:67, shiftKey:false, ctrlKey:true)
769
770
@element.find(".webapp-console-submit-ctrl-b").show().click (e) =>
771
#@focus()
772
submit_line()
773
@terminal.keyDown(keyCode:66, shiftKey:false, ctrlKey:true)
774
775
###
776
@element.find(".webapp-console-up").click () ->
777
vp = editor.getViewport()
778
editor.scrollIntoView({line:vp.from - 1, ch:0})
779
return false
780
781
@element.find(".webapp-console-down").click () ->
782
vp = editor.getViewport()
783
editor.scrollIntoView({line:vp.to, ch:0})
784
return false
785
786
if IS_TOUCH
787
@element.find(".webapp-console-tab").show().click (e) =>
788
@focus()
789
@terminal.keyDown(keyCode:9, shiftKey:false)
790
791
@_next_ctrl = false
792
@element.find(".webapp-console-control").show().click (e) =>
793
@focus()
794
@_next_ctrl = true
795
$(e.target).removeClass('btn-info').addClass('btn-warning')
796
797
@element.find(".webapp-console-esc").show().click (e) =>
798
@focus()
799
@terminal.keyDown(keyCode:27, shiftKey:false, ctrlKey:false)
800
###
801
802
_init_paste_bin: () =>
803
pb = @textarea
804
805
f = (evt) =>
806
@_ignore = false
807
data = pb.val()
808
pb.val('')
809
@conn?.write(data)
810
811
pb.on 'paste', =>
812
pb.val('')
813
setTimeout(f,5)
814
815
#######################################################################
816
# Public API
817
# Unless otherwise stated, these methods can be chained.
818
#######################################################################
819
820
disconnect: () =>
821
x = @conn
822
delete @conn
823
if x?
824
try
825
x.end()
826
catch err
827
# pass
828
829
# enter fullscreen mode
830
fullscreen: () =>
831
h = $(".navbar-fixed-top").height()
832
@element.css
833
position : 'absolute'
834
width : "97%"
835
top : h
836
left : 0
837
right : 0
838
bottom : 1
839
840
$(@terminal.element).css
841
position : 'absolute'
842
width : "100%"
843
top : "3.5em"
844
bottom : 1
845
846
@resize_terminal()
847
848
# exit fullscreen mode
849
exit_fullscreen: () =>
850
for elt in [$(@terminal.element), @element]
851
elt.css
852
position : 'relative'
853
top : 0
854
width : "100%"
855
@resize_terminal()
856
857
refresh: () =>
858
@terminal.refresh(0, @opts.rows-1)
859
@terminal.showCursor()
860
861
full_rerender: =>
862
@_ignore_terminal = new Date() # ignore output from *client side* of terminal until user types.
863
locals =
864
value : @value_orig
865
ignore : @_ignore
866
@reset()
867
@_ignore = true
868
if locals.value?
869
@render(locals.value)
870
@_ignore = locals.ignore # do not change value of @_ignore
871
@terminal.showCursor()
872
873
874
resize_terminal: () =>
875
@element.find(".webapp-console-terminal").css({width:'100%', height:'100%'})
876
877
# Wait for css change to take effect:
878
await delay(0)
879
880
# Determine size of container DOM.
881
# Determine the average width of a character by inserting 10 characters,
882
# seeing how wide that is, and dividing by 10. The result is typically not
883
# an integer, which is why we have to use multiple characters.
884
@_c = $("<span>Term-inal&nbsp;</span>").prependTo(@terminal.element)
885
character_width = @_c.width()/10
886
@_c.remove()
887
elt = $(@terminal.element)
888
889
# The above style trick for character width is not reliable for getting the height of each row.
890
# For that we use the terminal itself, since it already has rows, and hopefully at least
891
# one row has something in it (a div).
892
#
893
# The row height is in fact *NOT* constant -- it can vary by 1 (say) depending
894
# on what is in the row. So we compute the maximum line height, which is safe, so
895
# long as we throw out the outliers.
896
heights = ($(x).height() for x in elt.children())
897
# Eliminate weird outliers that sometimes appear (e.g., for last row); yes, this is
898
# pretty crazy...
899
heights = (x for x in heights when x <= heights[0] + 2)
900
row_height = Math.max( heights ... )
901
902
if character_width == 0 or row_height == 0
903
# The editor must not yet be visible -- do nothing
904
return
905
906
# Determine the number of columns from the width of a character, computed above.
907
font_size = @opts.font.size
908
new_cols = Math.max(1, Math.floor(elt.width() / character_width))
909
910
# Determine number of rows from the height of the row, as computed above.
911
height = elt.height()
912
if IS_TOUCH
913
height -= 60
914
new_rows = Math.max(1, Math.floor(height / row_height))
915
916
# Record our new size
917
@opts.cols = new_cols
918
@opts.rows = new_rows
919
920
# Send message to project to impact actual size of tty
921
@send_size_to_project()
922
# width still doesn't work right...
923
@element.find(".webapp-console-terminal").css({width:'100%', height:''})
924
925
send_size_to_project: =>
926
@conn?.write({cmd:'size', rows:@opts.rows, cols:@opts.cols})
927
928
set_scrollbar_to_term: () =>
929
if @terminal.ybase == 0 # less than 1 page of text in buffer
930
@scrollbar.hide()
931
return
932
else
933
@scrollbar.show()
934
935
if @ignore_scroll
936
return
937
@ignore_scroll = true
938
f = () =>
939
@ignore_scroll = false
940
setTimeout(f, 100)
941
max_scrolltop = @scrollbar[0].scrollHeight - @scrollbar.height()
942
@scrollbar.scrollTop(max_scrolltop * @terminal.ydisp / @terminal.ybase)
943
944
set_term_to_scrollbar: () =>
945
max_scrolltop = @scrollbar[0].scrollHeight - @scrollbar.height()
946
ydisp = Math.floor( @scrollbar.scrollTop() * @terminal.ybase / max_scrolltop)
947
@terminal.ydisp = ydisp
948
@terminal.refresh(0, @terminal.rows-1)
949
950
console_is_open: () => # not chainable
951
return @element.closest(document.documentElement).length > 0
952
953
blur: () =>
954
if focused_console == @
955
focused_console = undefined
956
957
@is_focused = false
958
959
try
960
@terminal.blur()
961
catch e
962
# WARNING: probably should investigate term.js issues further(?)
963
# ignore -- sometimes in some states the terminal code can raise an exception when explicitly blur-ing.
964
# This would totally break the client, which is bad, so we catch is.
965
$(@terminal.element).addClass('webapp-console-blur').removeClass('webapp-console-focus')
966
967
focus: (force) =>
968
if @_reconnecting? and new Date() - @_reconnecting > 10000
969
# reconnecting not working, so try again. Also, this handles the case
970
# when terminal switched to reconnecting state, user closed computer, comes
971
# back later, etc. Without this, would not attempt to reconnect until
972
# user touches keys.
973
@reconnect_if_no_recent_data()
974
975
if @is_focused and not force
976
return
977
978
# focusing the term blurs the textarea, so we save that fact here,
979
# so that the textarea.on 'blur' knows why it just blured
980
@_focusing = true
981
982
focused_console = @
983
@is_focused = true
984
@textarea.blur()
985
$(@terminal.element).focus()
986
987
if not IS_TOUCH
988
@_focus_hidden_textarea()
989
@terminal.focus()
990
991
$(@terminal.element).addClass('webapp-console-focus').removeClass('webapp-console-blur')
992
setTimeout((()=>delete @_focusing), 5) # critical!
993
994
set_title: (title) ->
995
@opts.set_title?(title)
996
@element.find(".webapp-console-title").text(title)
997
998
999
exports.Console = Console
1000
1001
$.fn.extend
1002
webapp_console: (opts={}) ->
1003
@each () ->
1004
t = $(this)
1005
if opts == false
1006
# disable existing console
1007
con = t.data('console')
1008
if con?
1009
con.remove()
1010
return t
1011
else
1012
opts0 = copy(opts)
1013
opts0.element = this
1014
return t.data('console', new Console(opts0))
1015
1016