├── .gitattributes ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── commands.txt ├── config.py ├── lib ├── command.js ├── editor.js ├── renderer.js ├── terminal.js ├── utils.js └── wizard.js ├── package.json ├── server.js ├── setup.sh ├── start.sh └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | config.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Elias Benbourenane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: source setup.sh && bash start.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HerokuShell-Rclone 2 | Interface Heroku's shell through Telegram, plus Rclone support 3 | 4 | [](https://heroku.com/deploy?template=https://github.com/mohitjoshi155/HerokuShell-Rclone/tree/master) 5 | 6 | ## Installation 7 | - First get the following prepared: 8 | - A @BotFather bot token. [Tutorial](https://www.siteguarding.com/en/how-to-get-telegram-bot-api-token) 9 | - Your Telegram user ID. Talk with [this](https://t.me/userinfobot) bot 10 | - [OPTIONAL] Your Rclone config, you need to encode it to [Base64](https://www.base64encode.org) 11 | - Next click the Deploy button above, make sure you have a Heroku account 12 | - Fill in the text fields with the information you prepared 13 | - Press the `Deploy` button 14 | - Wait for the app to finish deploying 15 | - Go to the app's dashboard and go onto the 'Resources' tab 16 | - On the `web npm start` worker, click the pencil icon and toggle it off 17 | - On the `worker source setup.sh && bash start.sh` worker, click the the pencil icon and toggle it on 18 | - Now open telegram and use the commands below to interact with your bot 19 | 20 | ## Commands 21 | - To use rclone commands do `/run rclone [param]...` 22 | ``` 23 | run - Execute command 24 | enter - Send input lines to command 25 | type - Type keys into command 26 | control - Type Control+Letter 27 | meta - Send the next typed key with Alt 28 | keypad - Toggle keypad for special keys 29 | redraw - Force the command to repaint 30 | end - Send EOF to command 31 | cancel - Interrupt command 32 | kill - Send signal to process 33 | status - View status and current settings 34 | cd - Change directory 35 | env - Manipulate the environment 36 | shell - Change shell used to run commands 37 | resize - Change the terminal size 38 | setsilent - Enable / disable silent output 39 | setlinkpreviews - Enable / disable link expansion 40 | setinteractive - Enable / disable shell interactive flag 41 | help - Get help 42 | file - View and edit small text files 43 | upload - Upload and overwrite raw files 44 | r - Alias for /run or /enter 45 | ``` 46 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HerokuShell Rclone", 3 | "description": "Bot to interface Heroku shell through Telegram (with Rclone)", 4 | "keywords": [ 5 | "telegram", 6 | "shell", 7 | "rclone", 8 | "heroku" 9 | ], 10 | "repository": "https://github.com/eliasbenb/HerokuShell-Rclone.git", 11 | "env": { 12 | "BOT_TOKEN": { 13 | "description": "The bot token provided by @BotFather", 14 | "required": true 15 | }, 16 | "OWNER_ID": { 17 | "description": "Your Telegram user ID, get it from @userinfobot", 18 | "value": "00000", 19 | "required": true 20 | }, 21 | "RCLONE_CONFIG_BASE64": { 22 | "description": "Your Rclone config encoded using Base64", 23 | "required": false 24 | } 25 | }, 26 | "buildpacks": [ 27 | { 28 | "url": "heroku/nodejs" 29 | }, 30 | { 31 | "url": "heroku/python" 32 | }, 33 | { 34 | "url": "https://github.com/jonathanong/heroku-buildpack-ffmpeg-latest.git" 35 | }, 36 | { 37 | "url": "https://github.com/opendoor-labs/heroku-buildpack-p7zip.git" 38 | }, 39 | { 40 | "url": "https://github.com/heroku/heroku-buildpack-apt.git" 41 | }, 42 | { 43 | "url": "https://github.com/magneto261290/megatools-heroku-buildpack" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /commands.txt: -------------------------------------------------------------------------------- 1 | run - Execute command 2 | enter - Send input lines to command 3 | type - Type keys into command 4 | control - Type Control+Letter 5 | meta - Send the next typed key with Alt 6 | keypad - Toggle keypad for special keys 7 | redraw - Force the command to repaint 8 | end - Send EOF to command 9 | cancel - Interrupt command 10 | kill - Send signal to process 11 | status - View status and current settings 12 | cd - Change directory 13 | env - Manipulate the environment 14 | shell - Change shell used to run commands 15 | resize - Change the terminal size 16 | setsilent - Enable / disable silent output 17 | setlinkpreviews - Enable / disable link expansion 18 | setinteractive - Enable / disable shell interactive flag 19 | help - Get help 20 | file - View and edit small text files 21 | upload - Upload and overwrite raw files 22 | r - Alias for /run or /enter 23 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | w = open('config.json', 'w+') 4 | w.write('{') 5 | w.write('\n') 6 | w.write(' "authToken": "'+os.getenv('BOT_TOKEN')+'",') 7 | w.write('\n') 8 | w.write(' "owner": '+os.getenv('OWNER_ID')) 9 | w.write('\n') 10 | w.write('}') -------------------------------------------------------------------------------- /lib/command.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attaches to a chat, spawns a pty, attaches it to the terminal emulator 3 | * and the renderer and manages them. Handles incoming commands & input, 4 | * and posts complimentary messages such as command itself and output code. 5 | **/ 6 | 7 | var util = require("util"); 8 | var escapeHtml = require("escape-html"); 9 | var pty = require("node-pty"); 10 | var termios = require("node-termios"); 11 | var utils = require("./utils"); 12 | var terminal = require("./terminal"); 13 | var renderer = require("./renderer"); 14 | var tsyms = termios.native.ALL_SYMBOLS; 15 | 16 | function Command(reply, context, command) { 17 | var toUser = reply.destination > 0; 18 | 19 | this.startTime = Date.now(); 20 | this.reply = reply; 21 | this.command = command; 22 | this.pty = pty.spawn(context.shell, [context.interactive ? "-ic" : "-c", command], { 23 | cols: context.size.columns, 24 | rows: context.size.rows, 25 | cwd: context.cwd, 26 | env: context.env, 27 | }); 28 | this.termios = new termios.Termios(this.pty._fd); 29 | this.termios.c_lflag &= ~(tsyms.ISIG | tsyms.IEXTEN); 30 | this.termios.c_lflag &= ~tsyms.ECHO; // disable ECHO 31 | this.termios.c_lflag |= tsyms.ICANON | tsyms.ECHONL; // we need it for /end, it needs to be active beforehand 32 | this.termios.c_iflag = (this.termios.c_iflag & ~(tsyms.INLCR | tsyms.IGNCR)) | tsyms.ICRNL; // CR to NL 33 | this.termios.writeTo(this.pty._fd); 34 | 35 | this.terminal = terminal.createTerminal({ 36 | columns: context.size.columns, 37 | rows: context.size.rows, 38 | }); 39 | this.state = this.terminal.state; 40 | this.renderer = new renderer.Renderer(reply, this.state, { 41 | cursorString: "\uD83D\uDD38", 42 | cursorBlinkString: "\uD83D\uDD38", 43 | hidePreview: !context.linkPreviews, 44 | unfinishedHidePreview: true, 45 | silent: context.silent, 46 | unfinishedSilent: true, 47 | maxLinesWait: toUser ? 20 : 30, 48 | maxLinesEmitted: 30, 49 | lineTime: toUser ? 400 : 1200, 50 | chunkTime: toUser ? 3000 : 6000, 51 | editTime: toUser ? 300 : 2500, 52 | unfinishedTime: toUser ? 1000 : 2000, 53 | startFill: "· ", 54 | }); 55 | this._initKeypad(); 56 | //FIXME: take additional steps to reduce messages sent to group. do typing actions count? 57 | 58 | // Post initial message 59 | this.initialMessage = new utils.EditedMessage(reply, this._renderInitial(), "HTML"); 60 | 61 | // Process command output 62 | this.pty.on("data", this._ptyData.bind(this)); 63 | 64 | // Handle command exit 65 | this.pty.on("exit", this._exit.bind(this)); 66 | } 67 | util.inherits(Command, require("events").EventEmitter); 68 | 69 | Command.prototype._renderInitial = function _renderInitial() { 70 | var content = "", title = this.state.metas.title, badges = this.badges || ""; 71 | if (title) { 72 | content += "" + escapeHtml(title) + "\n"; 73 | content += badges + "$ " + escapeHtml(this.command); 74 | } else { 75 | content += badges + "$ " + escapeHtml(this.command) + ""; 76 | } 77 | return content; 78 | } 79 | 80 | Command.prototype._ptyData = function _ptyData(chunk) { 81 | //FIXME: implement some backpressure, for example, read smaller chunks, stop reading if there are >= 20 lines waiting to be pushed, set the HWM 82 | if ((typeof chunk !== "string") && !(chunk instanceof String)) 83 | throw new Error("Expected a String, you liar."); 84 | this.interacted = true; 85 | this.terminal.write(chunk, "utf-8", this._update.bind(this)); 86 | }; 87 | 88 | Command.prototype._update = function _update() { 89 | this.initialMessage.edit(this._renderInitial()); 90 | this.renderer.update(); 91 | }; 92 | 93 | Command.prototype.resize = function resize(size) { 94 | this.interacted = true; 95 | this.metaActive = false; 96 | this.state.resize(size); 97 | this._update(); 98 | this.pty.resize(size.columns, size.rows); 99 | }; 100 | 101 | Command.prototype.redraw = function redraw() { 102 | this.interacted = true; 103 | this.metaActive = false; 104 | this.pty.redraw(); 105 | }; 106 | 107 | Command.prototype.sendSignal = function sendSignal(signal, group) { 108 | this.interacted = true; 109 | this.metaActive = false; 110 | var pid = this.pty.pid; 111 | if (group) pid = -pid; 112 | process.kill(pid, signal); 113 | }; 114 | 115 | Command.prototype.sendEof = function sendEof() { 116 | this.interacted = true; 117 | this.metaActive = false; 118 | 119 | // I don't know how to cause a 'buffer flush to the app' (the effect of Control+D) 120 | // without actually pressing it into the console. So let's do just that. 121 | // TTY needs to be in ICANON mode from the start, enabling it now doesn't work 122 | 123 | // write EOF control character 124 | this.termios.loadFrom(this.pty._fd); 125 | this.pty.write(Buffer.from([ this.termios.c_cc[tsyms.VEOF] ])); 126 | }; 127 | 128 | Command.prototype._exit = function _exit(code, signal) { 129 | this._update(); 130 | this.renderer.flushUnfinished(); 131 | 132 | 133 | //FIXME: could wait until all edits are made before posting exited message 134 | if ((Date.now() - this.startTime) < 2000 && !signal && code === 0 && !this.interacted) { 135 | // For short lived commands that completed without output, we simply add a tick to the original message 136 | this.badges = "\u2705 "; 137 | this.initialMessage.edit(this._renderInitial()); 138 | } else { 139 | if (signal) 140 | this.reply.html("\uD83D\uDC80 Killed by %s.", utils.formatSignal(signal)); 141 | else if (code === 0) 142 | this.reply.html("\u2705 Exited correctly."); 143 | else 144 | this.reply.html("\u26D4 Exited with %s.", code); 145 | } 146 | 147 | this._removeKeypad(); 148 | this.emit("exit"); 149 | }; 150 | 151 | Command.prototype.handleReply = function handleReply(msg) { 152 | //FIXME: feature: if photo, file, video, voice or music, put the terminal in raw mode, hold off further input, pipe binary asset to terminal, restore 153 | //Flags we would need to touch: -INLCR -IGNCR -ICRNL -IUCLC -ISIG -ICANON -IEXTEN, and also for convenience -ECHO -ECHONL 154 | 155 | if (msg.type !== "text") return false; 156 | this.sendInput(msg.text); 157 | }; 158 | 159 | Command.prototype.sendInput = function sendInput(text, noTerminate) { 160 | this.interacted = true; 161 | text = text.replace(/\n/g, "\r"); 162 | if (!noTerminate) text += "\r"; 163 | if (this.metaActive) text = "\x1b" + text; 164 | this.pty.write(text); 165 | this.metaActive = false; 166 | }; 167 | 168 | Command.prototype.toggleMeta = function toggleMeta(metaActive) { 169 | if (metaActive === undefined) metaActive = !this.metaActive; 170 | this.metaActive = metaActive; 171 | }; 172 | 173 | Command.prototype.setSilent = function setSilent(silent) { 174 | this.renderer.options.silent = silent; 175 | }; 176 | 177 | Command.prototype.setLinkPreviews = function setLinkPreviews(linkPreviews) { 178 | this.renderer.options.hidePreview = !linkPreviews; 179 | }; 180 | 181 | Command.prototype._initKeypad = function _initKeypad() { 182 | this.keypadToken = utils.generateToken(); 183 | 184 | var keys = { 185 | esc: { label: "ESC", content: "\x1b" }, 186 | tab: { label: "⇥", content: "\t" }, 187 | enter: { label: "⏎", content: "\r" }, 188 | backspace: { label: "↤", content: "\x7F" }, 189 | space: { label: " ", content: " " }, 190 | 191 | up: { label: "↑", content: "\x1b[A", appKeypadContent: "\x1bOA" }, 192 | down: { label: "↓", content: "\x1b[B", appKeypadContent: "\x1bOB" }, 193 | right: { label: "→", content: "\x1b[C", appKeypadContent: "\x1bOC" }, 194 | left: { label: "←", content: "\x1b[D", appKeypadContent: "\x1bOD" }, 195 | 196 | insert: { label: "INS", content: "\x1b[2~" }, 197 | del: { label: "DEL", content: "\x1b[3~" }, 198 | home: { label: "⇱", content: "\x1bOH" }, 199 | end: { label: "⇲", content: "\x1bOF" }, 200 | 201 | prevPage: { label: "⇈", content: "\x1b[5~" }, 202 | nextPage: { label: "⇊", content: "\x1b[6~" }, 203 | }; 204 | 205 | var keypad = [ 206 | [ "esc", "up", "backspace", "del" ], 207 | [ "left", "space", "right", "home" ], 208 | [ "tab", "down", "enter", "end" ], 209 | ]; 210 | 211 | this.buttons = []; 212 | this.inlineKeyboard = keypad.map(function (row) { 213 | return row.map(function (name) { 214 | var button = keys[name]; 215 | var data = JSON.stringify({ token: this.keypadToken, button: this.buttons.length }); 216 | var keyboardButton = { text: button.label, callback_data: data }; 217 | this.buttons.push(button); 218 | return keyboardButton; 219 | }.bind(this)); 220 | }.bind(this)); 221 | 222 | this.reply.bot.callback(function (query, next) { 223 | try { 224 | var data = JSON.parse(query.data); 225 | } catch (e) { return next(); } 226 | if (data.token !== this.keypadToken) return next(); 227 | this._keypadPressed(data.button, query); 228 | }.bind(this)); 229 | }; 230 | 231 | Command.prototype.toggleKeypad = function toggleKeypad() { 232 | if (this.keypadMessage) { 233 | this.keypadMessage.markup = null; 234 | this.keypadMessage.refresh(); 235 | this.keypadMessage = null; 236 | return; 237 | } 238 | 239 | // FIXME: this is pretty badly implemented, we should wait until last message (or message with cursor) has known id 240 | var messages = this.renderer.messages; 241 | var msg = messages[messages.length - 1].ref; 242 | msg.markup = {inline_keyboard: this.inlineKeyboard}; 243 | msg.refresh(); 244 | this.keypadMessage = msg; 245 | }; 246 | 247 | Command.prototype._keypadPressed = function _keypadPressed(id, query) { 248 | this.interacted = true; 249 | if (typeof id !== "number" || !(id in this.buttons)) return; 250 | var button = this.buttons[id]; 251 | var content = button.content; 252 | if (button.appKeypadContent !== undefined && this.state.getMode("appKeypad")) 253 | content = button.appKeypadContent; 254 | this.pty.write(content); 255 | query.answer(); 256 | }; 257 | 258 | Command.prototype._removeKeypad = function _removeKeypad() { 259 | if (this.keypadMessage) this.toggleKeypad(); 260 | }; 261 | 262 | 263 | 264 | exports.Command = Command; 265 | -------------------------------------------------------------------------------- /lib/editor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements a simple select-replace file editor in Telegram. 3 | * It works as follows: 4 | * 5 | * 1. The user invokes the editor with a non-empty file. 6 | * 2. The contents of the file are posted as a message. 7 | * 3. The user replies to that message with (part of) the text. 8 | * The bot will locate that substring in the file contents and track the message. 9 | * 4. The user edits his message. 10 | * The bot will then replace the original substring, save the file and edit its message. 11 | * If there are any problems with saving the file, the editor may detach. 12 | * 13 | * NOTE: sync I/O is used for simplicity; be careful! (TODO) 14 | **/ 15 | 16 | var fs = require("fs"); 17 | var escapeHtml = require("escape-html"); 18 | var utils = require("./utils"); 19 | 20 | function ChunkedString(text) { 21 | this.text = text; 22 | this.chunks = []; 23 | } 24 | 25 | ChunkedString.prototype.findAcquire = function findAcquire(text) { 26 | if (text.length == 0) throw Error("Empty find text not allowed"); 27 | var index = this.text.indexOf(text); 28 | if (index == -1) 29 | throw Error("The substring was not found. Wrapping in tildes may be necessary."); 30 | if (index != this.text.lastIndexOf(text)) 31 | throw Error("There are multiple instances of the passed substring"); 32 | return this.acquire(index, text.length); 33 | }; 34 | 35 | ChunkedString.prototype.acquire = function acquire(offset, length) { 36 | if (offset < 0 || length <= 0 || offset + length > this.text.length) 37 | throw Error("Invalid coordinates"); 38 | for (var i = 0; i < this.chunks.length; i++) { 39 | var c = this.chunks[i]; 40 | if (offset + length > c.offset || c.offset + c.text.length > offset) 41 | throw Error("Chunk overlaps"); 42 | } 43 | var chunk = { offset: offset, text: this.text.substring(offset, offset + length) }; 44 | this.chunks.push(chunk); 45 | return chunk; 46 | }; 47 | 48 | ChunkedString.prototype.release = function release(chunk) { 49 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 50 | this.chunks.splice(index, 1); 51 | }; 52 | 53 | ChunkedString.prototype.modify = function modify(chunk, text) { 54 | if (this.chunks.indexOf(chunk) == -1) throw Error("Invalid chunk given"); 55 | if (text.length == 0) throw Error("Empty replacement not allowed"); 56 | var end = chunk.offset + chunk.text.length; 57 | this.text = this.text.substring(0, chunk.offset) + text + this.text.substring(end); 58 | var diff = text.length - chunk.text.length; 59 | chunk.text = text; 60 | this.chunks.forEach(function (c) { 61 | if (c.offset > chunk.offset) c.offset += diff; 62 | }); 63 | }; 64 | 65 | 66 | function Editor(reply, file, encoding) { 67 | if (!encoding) encoding = "utf-8"; 68 | this.reply = reply; 69 | this.file = file; 70 | this.encoding = encoding; 71 | 72 | // TODO: support for longer files (paginated, etc.) 73 | // FIXME: do it correctly using fd, keeping it open 74 | var contents = fs.readFileSync(file, encoding); 75 | if (contents.length > 1500 || contents.split("\n") > 50) 76 | throw Error("The file is too long"); 77 | 78 | this.contents = new ChunkedString(contents); 79 | this.chunks = {}; // associates each message ID to an active chunk 80 | 81 | this.message = new utils.EditedMessage(reply, this._render(), "HTML"); 82 | this.fileTouched = false; 83 | } 84 | 85 | Editor.prototype._render = function _render() { 86 | if (!this.contents.text.trim()) return "(empty file)"; 87 | return "
" + escapeHtml(this.contents.text) + ""; 88 | }; 89 | 90 | Editor.prototype.handleReply = function handleReply(msg) { 91 | this.message.idPromise.then(function (id) { 92 | if (this.detached) return; 93 | if (msg.reply.id != id) return; 94 | try { 95 | this.chunks[msg.id] = this.contents.findAcquire(msg.text); 96 | } catch (e) { 97 | this.reply.html("%s", e.message); 98 | } 99 | }.bind(this)); 100 | }; 101 | 102 | Editor.prototype.handleEdit = function handleEdit(msg) { 103 | if (this.detached) return false; 104 | if (!Object.hasOwnProperty.call(this.chunks, msg.id)) return false; 105 | this.contents.modify(this.chunks[msg.id], msg.text); 106 | this.attemptSave(); 107 | return true; 108 | }; 109 | 110 | Editor.prototype.attemptSave = function attemptSave() { 111 | this.fileTouched = true; 112 | process.nextTick(function () { 113 | if (!this.fileTouched) return; 114 | if (this.detached) return; 115 | this.fileTouched = false; 116 | 117 | // TODO: check for file external modification, fail then 118 | try { 119 | fs.writeFileSync(this.file, this.contents.text, this.encoding); 120 | } catch (e) { 121 | this.reply.html("Couldn't save file: %s", e.message); 122 | return; 123 | } 124 | this.message.edit(this._render()); 125 | }.bind(this)); 126 | }; 127 | 128 | Editor.prototype.detach = function detach() { 129 | this.detached = true; 130 | }; 131 | 132 | module.exports.Editor = Editor; 133 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This class keeps a logical mapping of lines to messages. 3 | * It doesn't actually render or send messages, it delegates 4 | * that task to the renderer. 5 | * 6 | * FIXME: do something to prevent extremely long messages to be 7 | * sent (and rejected) when too many lines are inserted in between 8 | * a message. 9 | **/ 10 | 11 | var escapeHtml = require("escape-html"); 12 | var utils = require("./utils"); 13 | 14 | function Renderer(reply, state, options) { 15 | if (!options) options = {}; 16 | this.reply = reply; 17 | this.state = state; 18 | this.options = options; 19 | 20 | this.offset = 0; 21 | this.messages = []; 22 | this.orphanLines = []; 23 | this.unfinishedLine = null; 24 | this.totalLines = 0; 25 | 26 | state.on("lineChanged", this._lineChanged.bind(this)); 27 | state.on("linesRemoving", this._linesRemoving.bind(this)); 28 | state.on("linesScrolling", this._linesScrolling.bind(this)); 29 | state.on("linesInserted", this._linesInserted.bind(this)); 30 | 31 | this.initTimers(); 32 | } 33 | 34 | 35 | /** MESSAGE MAPPING **/ 36 | 37 | Renderer.prototype.ensureLinesCreated = function ensureLinesCreated(y) { 38 | if (this.totalLines < y) { 39 | this.orphanLines = this.orphanLines.concat(this.state.lines.slice(this.totalLines, y)); 40 | this.totalLines = y; 41 | this.newLinesChanged = true; 42 | } 43 | }; 44 | 45 | Renderer.prototype._lineChanged = function _lineChanged(y) { 46 | if (this.state.length - y <= this.orphanLines.length) 47 | this.newLinesChanged = true; 48 | }; 49 | 50 | Renderer.prototype._linesRemoving = function _linesRemoving(y, n) { 51 | this.ensureLinesCreated(this.state.lines.length); 52 | 53 | // Seek until we arrive at the wanted line 54 | y += this.offset; 55 | var idx = 0, lineIdx = 0; 56 | while (y) { 57 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 58 | if (lineIdx < lines.length) { lineIdx++; y--; } 59 | else { idx++; lineIdx = 0; } 60 | } 61 | 62 | // Remove following lines 63 | this.totalLines -= n; 64 | while (n) { 65 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 66 | if (lines.splice(lineIdx, 1).length) n--; 67 | else { idx++; lineIdx = 0; } 68 | } 69 | 70 | if (idx >= this.messages.length) this.newLinesChanged = true; 71 | }; 72 | 73 | Renderer.prototype._linesScrolling = function _linesScrolling(n) { 74 | this.ensureLinesCreated(this.state.lines.length); 75 | 76 | if (n > 0) { 77 | // Scrolling up: increment offset, discarding message if necessary 78 | this.offset += n; 79 | this.totalLines -= n; 80 | while (this.messages.length) { 81 | var message = this.messages[0]; 82 | if (message.lines.length > this.offset) break; 83 | if (message.rendered !== message.ref.lastText) break; 84 | this.offset -= message.lines.length; 85 | this.messages.shift(); 86 | } 87 | } else { 88 | // Scrolling down: just delete bottom lines (leaving them would complicate everything) 89 | n = -n; 90 | this._linesRemoving(this.state.lines.length - n, n); 91 | } 92 | }; 93 | 94 | Renderer.prototype._linesInserted = function _linesInserted(y, n) { 95 | this.ensureLinesCreated(y); 96 | var pos = y; 97 | 98 | // Seek until we arrive at the wanted line, *just before the next one* 99 | y += this.offset; 100 | var idx = 0, lineIdx = 0; 101 | while (true) { 102 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 103 | if (lineIdx < lines.length) { 104 | if (!y) break; 105 | lineIdx++; y--; 106 | } else { idx++; lineIdx = 0; } 107 | } 108 | 109 | // Insert lines 110 | this.totalLines += n; 111 | while (n) { 112 | var lines = (idx === this.messages.length) ? this.orphanLines : this.messages[idx].lines; 113 | lines.splice(lineIdx, 0, this.state.lines[pos]); 114 | n--, lineIdx++, pos++; 115 | } 116 | 117 | if (idx === this.messages.length) this.newLinesChanged = true; 118 | }; 119 | 120 | Renderer.prototype.update = function update() { 121 | this.ensureLinesCreated(this.state.lines.length); 122 | 123 | // Rerender messages, scheduling flush if some changed 124 | var linesChanged = false; 125 | this.messages.forEach(function (message) { 126 | var rendered = this.render(message); 127 | if (rendered !== message.rendered) { 128 | message.rendered = rendered; 129 | linesChanged = true; 130 | } 131 | }.bind(this)); 132 | 133 | if (linesChanged) this.editedLineTimer.set(); 134 | if (this.newLinesChanged) this.newLineTimer.reset(); 135 | this.newLinesChanged = false; 136 | 137 | // Make sure orphan lines are processed 138 | this.orphanLinesUpdated(); 139 | }; 140 | 141 | Renderer.prototype.emitMessage = function emitMessage(count, silent, disablePreview) { 142 | if (count < 0 || count > this.orphanLines.length) throw new Error("Should not happen."); 143 | 144 | if (count > this.options.maxLinesEmitted) 145 | count = this.options.maxLinesEmitted; 146 | var lines = this.orphanLines.splice(0, count); 147 | var message = { lines: lines }; 148 | this.messages.push(message); 149 | message.rendered = this.render(message); 150 | var reply = this.reply.silent(silent).disablePreview(disablePreview); 151 | message.ref = new utils.EditedMessage(reply, message.rendered, "HTML"); 152 | this.orphanLinesUpdated(); 153 | }; 154 | 155 | 156 | /** HTML RENDERING **/ 157 | 158 | /* Given a line, return true if potentially monospaced */ 159 | Renderer.prototype.evaluateCode = function evaluateCode(str) { 160 | //FIXME: line just between two code lines should be come code 161 | if (str.indexOf(" ") !== -1 || /[-_,:;<>()/\\~|'"=^]{4}/.exec(str)) 162 | return true; 163 | return false; 164 | }; 165 | 166 | /* Given a message object, render to an HTML snippet */ 167 | Renderer.prototype.render = function render(message) { 168 | var cursorString = this.state.getMode("cursorBlink") ? this.options.cursorBlinkString : this.options.cursorString; 169 | var isWhitespace = true, x = this.state.cursor[0]; 170 | 171 | var html = message.lines.map(function (line, idx) { 172 | var hasCursor = (this.state.getMode("cursor")) && (this.state.getLine() === line); 173 | if (!line.code && this.evaluateCode(line.str)) line.code = true; 174 | 175 | var content = line.str; 176 | if (hasCursor || line.str.trim().length) isWhitespace = false; 177 | if (idx === 0 && !content.substring(0, this.options.startFill.length).trim()) { 178 | // The message would start with spaces, which would get trimmed by telegram 179 | if (!(hasCursor && x < this.options.startFill.length)) 180 | content = this.options.startFill + content.substring(this.options.startFill.length); 181 | } 182 | 183 | if (hasCursor) 184 | content = escapeHtml(content.substring(0, x)) + cursorString + escapeHtml(content.substring(x)); 185 | else 186 | content = escapeHtml(content); 187 | 188 | if (line.code) content = "
" + content + "
";
189 | return content;
190 | }.bind(this)).join("\n");
191 |
192 | if (isWhitespace) return "(empty)";
193 | return html;
194 | };
195 |
196 |
197 | /** FLUSH SCHEDULING **/
198 |
199 | Renderer.prototype.initTimers = function initTimers() {
200 | // Set when an existent line changes, cancelled when edited lines flushed
201 | this.editedLineTimer = new utils.Timer(this.options.editTime).on("fire", this.flushEdited.bind(this));
202 |
203 | // Set when a new line is added or changed, cancelled on new lines flush
204 | this.newChunkTimer = new utils.Timer(this.options.chunkTime).on("fire", this.flushNew.bind(this));
205 | // Reset when a new line is added or changed, cancelled on new lines flush
206 | this.newLineTimer = new utils.Timer(this.options.lineTime).on("fire", this.flushNew.bind(this));
207 |
208 | // Set when there is an unfinished nonempty line, cancelled when reference changes or line becomes empty
209 | this.unfinishedLineTimer = new utils.Timer(this.options.unfinishedTime).on("fire", this.flushUnfinished.bind(this));
210 |
211 | this.newChunkTimer.on("active", function () {
212 | this.reply.action("typing");
213 | }.bind(this));
214 | //FIXME: should we emit actions on edits?
215 | };
216 |
217 | Renderer.prototype.orphanLinesUpdated = function orphanLinesUpdated() {
218 | var newLines = this.orphanLines.length - 1;
219 | if (newLines >= this.options.maxLinesWait) {
220 | // Flush immediately
221 | this.flushNew();
222 | } else if (newLines > 0) {
223 | this.newChunkTimer.set();
224 | } else {
225 | this.newChunkTimer.cancel();
226 | this.newLineTimer.cancel();
227 | }
228 |
229 | // Update unfinished line
230 | var unfinishedLine = this.orphanLines[this.orphanLines.length - 1];
231 | if (unfinishedLine && this.totalLines === this.state.rows && unfinishedLine.str.length === this.state.columns)
232 | unfinishedLine = null;
233 |
234 | if (this.unfinishedLine !== unfinishedLine) {
235 | this.unfinishedLine = unfinishedLine;
236 | this.unfinishedLineTimer.cancel();
237 | }
238 |
239 | if (unfinishedLine && unfinishedLine.str.length) this.unfinishedLineTimer.set();
240 | else this.unfinishedLineTimer.cancel();
241 | };
242 |
243 | Renderer.prototype.flushEdited = function flushEdited() {
244 | this.messages.forEach(function (message) {
245 | if (message.rendered !== message.ref.lastText)
246 | message.ref.edit(message.rendered);
247 | });
248 | this.editedLineTimer.cancel();
249 | };
250 |
251 | Renderer.prototype.flushNew = function flushNew() {
252 | this.flushEdited();
253 | var count = this.orphanLines.length;
254 | if (this.unfinishedLine) count--;
255 | if (count <= 0) return;
256 | this.emitMessage(count, !!this.options.silent, !!this.options.hidePreview);
257 | };
258 |
259 | Renderer.prototype.flushUnfinished = function flushUnfinished() {
260 | do this.flushNew(); while (this.orphanLines.length > 1);
261 | if (this.orphanLines.length < 1 || this.orphanLines[0].str.length === 0) return;
262 | this.emitMessage(1, !!this.options.unfinishedSilent, !!this.options.unfinishedHidePreview);
263 | };
264 |
265 |
266 |
267 | exports.Renderer = Renderer;
268 |
--------------------------------------------------------------------------------
/lib/terminal.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implements a terminal emulator. We use terminal.js for the
3 | * dirty work of parsing the escape sequences, but implement our
4 | * own TermState, with some quirks when compared to a standard
5 | * terminal emulator:
6 | *
7 | * - Lines and characters are created on demand. The terminal
8 | * starts out with no content. The reason being, you don't want
9 | * empty lines to be immediately pushed to your Telegram chat
10 | * after starting a command.
11 | *
12 | * - Allows lines of length higher than the column size. The extra
13 | * characters are appended but the cursor keeps right at the edge.
14 | * Telegram already wraps long lines, having them wrapped by the
15 | * terminal would be ugly.
16 | *
17 | * - Graphic attributes not implemented for now (would not be used
18 | * for Telegram anyways).
19 | *
20 | * - Doesn't have an alternate buffer for now (wouldn't make much
21 | * sense for Telegram rendering...) FIXME
22 | *
23 | * The terminal emulator emits events when lines get inserted, changed,
24 | * removed or go out of view, similar to the original TermState.
25 | **/
26 |
27 | var util = require("util");
28 | var Terminal = require("terminal.js");
29 |
30 | //FIXME: investigate using a patched palette for better support on android
31 | var GRAPHICS = {
32 | '`': '\u25C6',
33 | 'a': '\u2592',
34 | 'b': '\u2409',
35 | 'c': '\u240C',
36 | 'd': '\u240D',
37 | 'e': '\u240A',
38 | 'f': '\u00B0',
39 | 'g': '\u00B1',
40 | 'h': '\u2424',
41 | 'i': '\u240B',
42 | 'j': '\u2518',
43 | 'k': '\u2510',
44 | 'l': '\u250C',
45 | 'm': '\u2514',
46 | 'n': '\u253C',
47 | 'o': '\u23BA',
48 | 'p': '\u23BB',
49 | 'q': '\u2500',
50 | 'r': '\u23BC',
51 | 's': '\u23BD',
52 | 't': '\u251C',
53 | 'u': '\u2524',
54 | 'v': '\u2534',
55 | 'w': '\u252C',
56 | 'x': '\u2502',
57 | 'y': '\u2264',
58 | 'z': '\u2265',
59 | '{': '\u03C0',
60 | '|': '\u2260',
61 | '}': '\u00A3',
62 | '~': '\u00B7'
63 | };
64 |
65 |
66 | /** INITIALIZATION & ACCESSORS **/
67 |
68 | function TermState(options) {
69 | if (!options) options = {};
70 | this.rows = options.rows || 24;
71 | this.columns = options.columns || 80;
72 |
73 | this.defaultAttributes = {
74 | fg: null,
75 | bg: null,
76 | bold: false,
77 | underline: false,
78 | italic: false,
79 | blink: false,
80 | inverse: false,
81 | };
82 | this.reset();
83 | }
84 | util.inherits(TermState, require("events").EventEmitter);
85 |
86 | TermState.prototype.reset = function reset() {
87 | this.lines = [];
88 | this.cursor = [0,0];
89 | this.savedCursor = [0,0];
90 |
91 | this.modes = {
92 | cursor: true,
93 | cursorBlink: false,
94 | appKeypad: false,
95 | wrap: true,
96 | insert: false,
97 | crlf: false,
98 | mousebtn: false,
99 | mousemtn: false,
100 | reverse: false,
101 | graphic: false,
102 | mousesgr: false,
103 | };
104 | this.attributes = Object.create(this.defaultAttributes);
105 | this._charsets = {
106 | "G0": "unicode",
107 | "G1": "unicode",
108 | "G2": "unicode",
109 | "G3": "unicode",
110 | };
111 | this._mappedCharset = "G0";
112 | this._mappedCharsetNext = "G0";
113 | this.metas = {
114 | title: '',
115 | icon: ''
116 | };
117 | this.leds = {};
118 | this._tabs = [];
119 | this.emit("reset");
120 | };
121 |
122 | function getGenericSetter(field) {
123 | return function genericSetter(name, value) {
124 | this[field + "s"][name] = value;
125 | this.emit(field, name);
126 | };
127 | }
128 |
129 | TermState.prototype.setMode = getGenericSetter("mode");
130 | TermState.prototype.setMeta = getGenericSetter("meta");
131 | TermState.prototype.setAttribute = getGenericSetter("attribute");
132 | TermState.prototype.setLed = getGenericSetter("led");
133 |
134 | TermState.prototype.getMode = function getMode(mode) {
135 | return this.modes[mode];
136 | };
137 |
138 | TermState.prototype.getLed = function getLed(led) {
139 | return !!this.leds[led];
140 | };
141 |
142 | TermState.prototype.ledOn = function ledOn(led) {
143 | this.setLed(led, true);
144 | return this;
145 | };
146 |
147 | TermState.prototype.resetLeds = function resetLeds() {
148 | this.leds = {};
149 | return this;
150 | };
151 |
152 | TermState.prototype.resetAttribute = function resetAttribute(name) {
153 | this.attributes[name] = this.defaultAttributes[name];
154 | return this;
155 | };
156 |
157 | TermState.prototype.mapCharset = function(target, nextOnly) {
158 | this._mappedCharset = target;
159 | if (!nextOnly) this._mappedCharsetNext = target;
160 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
161 | };
162 |
163 | TermState.prototype.selectCharset = function(charset, target) {
164 | if (!target) target = this._mappedCharset;
165 | this._charsets[target] = charset;
166 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
167 | };
168 |
169 |
170 | /** CORE METHODS **/
171 |
172 | /* Move the cursor */
173 | TermState.prototype.setCursor = function setCursor(x, y) {
174 | if (typeof x === 'number')
175 | this.cursor[0] = x;
176 |
177 | if (typeof y === 'number')
178 | this.cursor[1] = y;
179 |
180 | this.cursor = this.getCursor();
181 | this.emit("cursor");
182 | return this;
183 | };
184 |
185 | /* Get the real cursor position (the logical one may be off-bounds) */
186 | TermState.prototype.getCursor = function getCursor() {
187 | var x = this.cursor[0], y = this.cursor[1];
188 |
189 | if (x >= this.columns) x = this.columns - 1;
190 | else if (x < 0) x = 0;
191 |
192 | if (y >= this.rows) y = this.rows - 1;
193 | else if (y < 0) y = 0;
194 |
195 | return [x,y];
196 | };
197 |
198 | /* Get the line at specified position, allocating it if necessary */
199 | TermState.prototype.getLine = function getLine(y) {
200 | if (typeof y !== "number") y = this.getCursor()[1];
201 | if (y < 0) throw new Error("Invalid position to write to");
202 |
203 | // Insert lines until the line at this position is available
204 | while (!(y < this.lines.length))
205 | this.lines.push({ str: "", attr: null });
206 |
207 | return this.lines[y];
208 | };
209 |
210 | /* Replace the line at specified position, allocating it if necessary */
211 | TermState.prototype.setLine = function setLine(y, line) {
212 | if (typeof y !== "number") line = y, y = this.getCursor()[1];
213 | this.getLine(y);
214 | this.lines[y] = line;
215 | return this;
216 | };
217 |
218 | /* Write chunk of text (single-line assumed) beginning at position */
219 | TermState.prototype._writeChunk = function _writeChunk(position, chunk, insert) {
220 | var x = position[0], line = this.getLine(position[1]);
221 | if (x < 0) throw new Error("Invalid position to write to");
222 |
223 | // Insert spaces until the wanted column is available
224 | while (line.str.length < x)
225 | line.str += " ";
226 |
227 | // Write the chunk at position
228 | line.str = line.str.substring(0, x) + chunk + line.str.substring(x + (insert ? 0 : chunk.length));
229 | //TODO: add attribute
230 |
231 | this.emit("lineChanged", position[1]);
232 | return this;
233 | };
234 |
235 | /* Remove characters beginning at position */
236 | TermState.prototype.removeChar = function removeChar(n) {
237 | var x = this.cursor[0], line = this.getLine();
238 | if (x < 0) throw new Error("Invalid position to delete from");
239 |
240 | // Insert spaces until the wanted column is available
241 | while (line.str.length < x)
242 | line.str += " ";
243 |
244 | // Remove characters
245 | line.str = line.str.substring(0, x) + line.str.substring(x + n);
246 |
247 | this.emit("lineChanged", this.cursor[1]);
248 | return this;
249 | };
250 |
251 | TermState.prototype.eraseInLine = function eraseInLine(n) {
252 | var x = this.cursor[0], line = this.getLine();
253 | switch (n || 0) {
254 | case "after":
255 | case 0:
256 | line.str = line.str.substring(0, x);
257 | break;
258 |
259 | case "before":
260 | case 1:
261 | var str = "";
262 | while (str.length < x) str += " ";
263 | line.str = str + line.str.substring(x);
264 | break;
265 |
266 | case "all":
267 | case 2:
268 | line.str = "";
269 | break;
270 | }
271 | this.emit("lineChanged", this.cursor[1]);
272 | return this;
273 | };
274 |
275 | TermState.prototype.eraseInDisplay = function eraseInDisplay(n) {
276 | switch (n || 0) {
277 | case "below":
278 | case "after":
279 | case 0:
280 | this.eraseInLine(n);
281 | this.removeLine(this.lines.length - (this.cursor[1]+1), this.cursor[1]+1);
282 | break;
283 |
284 | case "above":
285 | case "before":
286 | case 1:
287 | for (var y = 0; y < this.cursor[1]; y++) {
288 | this.lines[y].str = "";
289 | this.emit("lineChanged", y);
290 | }
291 | this.eraseInLine(n);
292 | break;
293 |
294 | case "all":
295 | case 2:
296 | this.removeLine(this.lines.length, 0);
297 | break;
298 | }
299 | return this;
300 | };
301 |
302 | TermState.prototype.removeLine = function removeLine(n, y) {
303 | if (typeof y !== "number") y = this.cursor[1];
304 | if (n <= 0) return this;
305 |
306 | if (y + n > this.lines.length)
307 | n = this.lines.length - y;
308 | if (n <= 0) return this;
309 |
310 | this.emit("linesRemoving", y, n);
311 | this.lines.splice(y, n);
312 | return this;
313 | };
314 |
315 | TermState.prototype.insertLine = function insertLine(n, y) {
316 | if (typeof y !== "number") y = this.cursor[1];
317 | if (n <= 0) return this;
318 |
319 | if (y + n > this.rows)
320 | n = this.rows - y;
321 | if (n <= 0) return this;
322 |
323 | this.getLine(y);
324 | this.removeLine((this.lines.length + n) - this.rows, this.rows - n);
325 | for (var i = 0; i < n; i++)
326 | this.lines.splice(y, 0, { str: "", attr: null });
327 | this.emit("linesInserted", y, n);
328 | return this;
329 | };
330 |
331 | TermState.prototype.scroll = function scroll(n) {
332 | if (n > 0) { // up
333 | if (n > this.lines.length) n = this.lines.length; //FIXME: is this okay?
334 | if (n > 0) this.emit("linesScrolling", n);
335 | this.lines = this.lines.slice(n);
336 | } else if (n < 0) { // down
337 | n = -n;
338 | if (n > this.rows) n = this.rows; //FIXME: is this okay?
339 | var extraLines = (this.lines.length + n) - this.rows;
340 | if (extraLines > 0) this.emit("linesScrolling", -extraLines);
341 | this.lines = this.lines.slice(0, this.rows - n);
342 | this.insertLine(n, 0);
343 | }
344 | return this;
345 | };
346 |
347 |
348 | /** HIGH LEVEL **/
349 |
350 | TermState.prototype._graphConvert = function(content) {
351 | // optimization for 99% of the time
352 | if(this._mappedCharset === this._mappedCharsetNext && !this.modes.graphic) {
353 | return content;
354 | }
355 |
356 | var result = "", i;
357 | for(i = 0; i < content.length; i++) {
358 | result += (this.modes.graphic && content[i] in GRAPHICS) ?
359 | GRAPHICS[content[i]] :
360 | content[i];
361 | this._mappedCharset = this._mappedCharsetNext;
362 | this.modes.graphic = this._charsets[this._mappedCharset] === "graphics"; // backwards compatibility
363 | }
364 | return result;
365 | };
366 |
367 | TermState.prototype.write = function write(chunk) {
368 | chunk.split("\n").forEach(function (line, i) {
369 | if (i > 0) {
370 | // Begin new line
371 | if (this.cursor[1] + 1 >= this.rows)
372 | this.scroll(1);
373 | this.mvCursor(0, 1);
374 | this.getLine();
375 | }
376 |
377 | if (!line.length) return;
378 | if (this.getMode("graphic")) this.getLine().code = true;
379 | line = this._graphConvert(line);
380 | this._writeChunk(this.cursor, line, this.getMode("insert"));
381 | this.cursor[0] += line.length;
382 | }.bind(this));
383 | this.emit("cursor");
384 | return this;
385 | };
386 |
387 | TermState.prototype.resize = function resize(size) {
388 | if (this.lines.length > size.rows)
389 | this.scroll(this.lines.length - size.rows);
390 | this.rows = size.rows;
391 | this.columns = size.columns;
392 | this.setCursor();
393 | this.emit("resize", size);
394 | return this;
395 | };
396 |
397 | TermState.prototype.mvCursor = function mvCursor(x, y) {
398 | var cursor = this.getCursor();
399 | return this.setCursor(cursor[0] + x, cursor[1] + y);
400 | };
401 |
402 | TermState.prototype.toString = function toString() {
403 | return this.lines.map(function (line) { return line.str; }).join("\n");
404 | };
405 |
406 | TermState.prototype.prevLine = function prevLine() {
407 | if (this.cursor[1] > 0) this.mvCursor(0, -1);
408 | else this.scroll(-1);
409 | return this;
410 | };
411 |
412 | TermState.prototype.nextLine = function nextLine() {
413 | if (this.cursor[1] < this.rows - 1) this.mvCursor(0, +1);
414 | else this.scroll(+1);
415 | return this;
416 | };
417 |
418 | TermState.prototype.saveCursor = function saveCursor() {
419 | this.savedCursor = this.getCursor();
420 | return this;
421 | };
422 |
423 | TermState.prototype.restoreCursor = function restoreCursor() {
424 | this.cursor = this.savedCursor;
425 | return this.setCursor();
426 | };
427 |
428 | TermState.prototype.insertBlank = function insertBlank(n) {
429 | var str = "";
430 | while (str.length < n) str += " ";
431 | return this._writeChunk(this.cursor, str, true);
432 | };
433 |
434 | TermState.prototype.eraseCharacters = function eraseCharacters(n) {
435 | var str = "";
436 | while (str.length < n) str += " ";
437 | return this._writeChunk(this.cursor, str, false);
438 | };
439 |
440 | TermState.prototype.setScrollRegion = function setScrollRegion(n, m) {
441 | //TODO
442 | return this;
443 | };
444 |
445 | TermState.prototype.switchBuffer = function switchBuffer(alt) {
446 | if (this.alt !== alt) {
447 | this.scroll(this.lines.length);
448 | this.alt = alt;
449 | }
450 | return this;
451 | };
452 |
453 | TermState.prototype.getBufferRowCount = function getBufferRowCount() {
454 | return this.lines.length;
455 | };
456 |
457 |
458 | /**
459 | * moves Cursor forward or backward a specified amount of tabs
460 | * @param n {number} - number of tabs to move. <0 moves backward, >0 moves
461 | * forward
462 | */
463 | TermState.prototype.mvTab = function(n) {
464 | var x = this.getCursor()[0];
465 | var tabMax = this._tabs[this._tabs.length - 1] || 0;
466 | var positive = n > 0;
467 | n = Math.abs(n);
468 | while(n !== 0 && x > 0 && x < this.columns-1) {
469 | x += positive ? 1 : -1;
470 | if(this._tabs.indexOf(x) != -1 || (x > tabMax && x % 8 === 0))
471 | n--;
472 | }
473 | this.setCursor(x);
474 | };
475 |
476 | /**
477 | * set tab at specified position
478 | * @param pos {number} - position to set a tab at
479 | */
480 | TermState.prototype.setTab = function(pos) {
481 | // Set the default to current cursor if no tab position is specified
482 | if(pos === undefined) {
483 | pos = this.getCursor()[0];
484 | }
485 | // Only add the tab position if it is not there already
486 | if (this._tabs.indexOf(pos) != -1) {
487 | this._tabs.push(pos);
488 | this._tabs.sort();
489 | }
490 | };
491 |
492 | /**
493 | * remove a tab
494 | * @param pos {number} - position to remove a tab. Do nothing if the tab isn't
495 | * set at this position
496 | */
497 | TermState.prototype.removeTab = function(pos) {
498 | var i, tabs = this._tabs;
499 | for(i = 0; i < tabs.length && tabs[i] !== pos; i++);
500 | tabs.splice(i, 1);
501 | };
502 |
503 | /**
504 | * removes a tab at a given index
505 | * @params n {number} - can be one of the following
506 | *