Ursb. Me /terminal/ (no mobile adaptation, please visit in PC), this page is very interesting, it can be used as a personal blog system or for Linux beginners to learn terminal commands, now share to everyone ~

Open source address: airingursb/terminal

0 x01 style

The opening effect of the page is as follows:

If you are poor, you can’t afford to buy such a high quality computer. If you are poor, you can’t buy such a high quality computer.

Note: The logo in the screenshot is printed by Archey. MAC can install it by typing Brew Install Archey directly.

Command input is implemented using only an input tag:

<span class="prefix">[<span id='usr'>usr</span>@<span class="host">ursb.me</span> <span id="pos">~</span>] %</span>
<input type="text" class="input-text">Copy the code

Of course, the original style is too ugly, so we must beautify the input tag:

.input-text {
    display: inline-block;
    background-color: transparent;
    border: none;
    -moz-appearance: none;
    -webkit-appearance: none;
    outline: 0;
    box-sizing: border-box;
    font-size: 17px;
    font-family: Monaco, Cutive Mono, Courier New, Consolas, monospace;
    font-weight: 700;
    color: #fff;
    width: 300px;
    padding-block-end: 0
}Copy the code

Although it is accessed in a browser, after all, we want to simulate the effect of the terminal, so the style of the mouse had better be modified:

* {
    cursor: text;
}Copy the code

0x02 Render logic

Printing new content each time is a process of stitching together new content from the previous HTML and redrawing it. The rendering time is when the user presses enter, so you need to listen for keyDown events; The render function is mainFunc, which passes in the user’s input and the user’s current directory, which is a global variable that is needed in many commands to determine the user’s current location.

e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>Nice to Meet U : )<br/>')
e_html.animate({ scrollTop: $(document).height() }, 0)Copy the code

Add a scrolling animation after each rendering so that the browser simulates the terminal’s behavior as realistically as possible.

$(document).bind('keydown'.function (b) {
  e_input.focus()
  if (b.keyCode === 13) {
    e_main.html($('#main').html())
    e_html.animate({ scrollTop: $(document).height() }, 0)
    mainFunc(e_input.val(), nowPosition)
    hisCommand.push(e_input.val())
    isInHis = 0
    e_input.val(' ')}// Ctrl + U clears the input shortcut keys
  if (b.keyCode === 85 && b.ctrlKey === true) {
    e_input.val(' ')
    e_input.focus()
  }
})Copy the code

There is also a shortcut key Ctrl + U to clear the current input, and there are other shortcuts readers can do the same.

0x03 help

We know that the specification of Linix is command[Options…]. First, in case the user is not aware, I implement the simplest help command word. The effect is as follows:

Look directly at the code, this is directly printed content, very simple to implement.

switch (command) {
    case 'help':
      e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>' + '[sudo ]command[ Options...] 

You can use following commands:



cd

ls

cat

clear

help

exit



Besides, there are some hidden commands, try to find them!

'
) e_html.animate({ scrollTop: $(document).height() }, 0) break }Copy the code

Command takes the element before the first space of the input tag:

command = input.split(' ') [0]Copy the code

Now that you know how to take the command word, the various types of command word printing can be their own as a small egg to achieve ~ here is not an example, readers can read the source code to understand.

0x04 clear

Clear is to clear the console, which is very simple to implement, according to our rendering logic, directly clear the contents of the outer div.

case 'clear':
  e_main.html(' ')
  e_html.animate({ scrollTop: $(document).height() }, 0)
  breakCopy the code

Since it is a blogging system, it is not always possible to render all the content on the front page code, so a fixed help command or a simple print command can do this. However, if our directory structure changes, or we want to write a new article, or modify the contents of the file, we would need to significantly modify the code of the static HTML file, which is obviously not practical.

The system is also supporting the realization of the corresponding background, the role of the server is used to read the directory and file content stored in the server, and provide the corresponding interface to return data to the front end.

The server stores files at the following levels:

Next, let’s look at some of the more difficult features.

0x05 ls

The ls command is widely used in Linux to display the target list. The output of the ls command can be colored and highlighted to partition different types of files.

Therefore, our three key points for implementing this feature are:

  1. Gets the current location of the user
  2. Gets all files and directories under the current location
  3. You need to distinguish between files and directories in order to distinguish styles

For the first point, the second argument in mainFunc is mandatory and is a global variable that we carefully maintain (maintained in the CD command).

For the second point, we provide an interface on the back end:

router.get('/ls', (req, res) => {
  let { dir } = req.query
  glob(`src/file${dir}* * `, {}, (err, files) => {
    if (dir === '/') {
      files = files.map(i= > i.replace('src/file/'.' '))
      files = files.filter(i= >! i.includes('/')) // Filter out secondary directories
    } else {
      // If not, replace the current directory
      dir = dir.substring(1)
      files = files.map(i= > i.replace('src/file/'.' ').replace(dir, ' '))
      files = files.filter(i= >! i.includes('/') && !i.includes(dir.substring(0, dir.length - 1))) // Filter out secondary directories and current directories
    }
    return res.jsonp({ code: 0.data: files.map(i= > i.replace('src/file/'.' ').replace(dir, ' '))})})})Copy the code

File traversal here we use glob, a third-party open source library. If the user is in the home directory, we need to filter out the files in the secondary directory, because ls can only see the contents of this directory. If the user is in another directory, we also need to filter out the current directory because the packet returned by glob contains the name of the current directory.

After that, the front end is called directly:

case 'ls':
  // dir: /dir/
  $.ajax({
    url: host + '/ls'.data: { dir: position.replace('~'.' ') + '/' },
    dataType: 'jsonp'.success: (res) = > {
      if (res.code === 0) {
        let data = res.data.map(i= > {
          if(! i.includes('. ')) {
            / / directory
            i = `<span class="ls-dir">${i}</span>`
          }
          return i
        })
        e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>' + data.join('    ') + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)}}})breakCopy the code

In the front end, we distinguish between directories and files based on whether the file name has a ‘.’, and add a new style to the directory. However, this distinction is not exact, because directory names can also have ‘.’, and directory is essentially a file. The rigorous approach should be based on the system’s ls-L command, the blog system we are implementing is not so complex, so simply based on ‘.’ is appropriate.

The implementation effect is as follows:

0x06 cd

The server provides the interface, pos is the user’s current location, and dir is the relative path that the user wants to switch. Note that files are filtered here, because the argument after the CD command only accepts directories; Also, there is no filtering out of the secondary directory, because the CD command is followed by the directory path, possibly deep level. If the directory does not exist, you simply return an error code and a message.

router.get('/cd', (req, res) => {
  let { pos, dir } = req.query

  glob(`src/file${pos}* * `, {}, (err, files) => {
    pos = pos.substring(1)
    files = files.filter(i= >! i.includes('. ')) // Filter out the files
    files = files.map(i= > i.replace('src/file/'.' ').replace(pos, ' '))
    dir = dir.substring(0, dir.length - 1)
    if (files.indexOf(dir) === - 1) {
      // Directory does not exist
      return res.jsonp({ code: 404.message: 'cd: no such file or directory: ' + dir })
    } else {
      return res.jsonp({ code: 0})}})})Copy the code

Front-end direct calls are fine, but there are several cases to distinguish:

  1. Back to the home directory: CD | | CD ~ | | CD ~ /
  2. Switch to another directory
    1. Users in the home directory: CD ~ / dir | | CD. / dir | | CD dir
    2. User in other directory: CD.. || cd .. / || cd .. /dir || cd dir || cd ./dir
      1. Switch to another level of the absolute path: CD ~/dir
      2. Switch to a relative path to a deeper level: CD dir | | CD. / dir | | CD.. /dir || cd .. || cd .. / || cd .. /.. /

For scenario 1, the implementation is simpler, simply cutting the current directory back to ‘~’.

if(! input.split(' ') [1] || input.split(' ') [1= = ='~' || input.split(' ') [1= = ='~ /') {
    / / back to the home directory: CD | | CD ~ | | CD ~ /
    nowPosition = '~'
    e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>')
    e_html.animate({ scrollTop: $(document).height() }, 0)
    e_pos.html(nowPosition)
}Copy the code

For scenario 2, it is also determined whether it is in the home directory because the parsing rules are different. In fact, you can do a compatibility merge into one situation. Because of the length of the code, only the code for scenario 2.2.2, the most complex scenario, is listed here:

let pos = '/' + nowPosition.replace('~ /'.' ') + '/'
let backCount = input.split(' ') [1].match(/\.\.\//g) && input.split(' ') [1].match(/\.\.\//g).length || 0

pos = nowPosition.split('/') // [~, blog, img]
nowPosition = pos.slice(0, pos.length - backCount) // [~, blog]
nowPosition = nowPosition.join('/') // ~/blog

pos = '/' + nowPosition.replace('~'.' ').replace('/'.' ')  + '/'
dir = dir + '/'
dir = dir.startsWith('/') && dir.substring(1) || dir // Adapter: CD./dir
$.ajax({
    url: host + '/cd'.data: { dir, pos },
    dataType: 'jsonp'.success: (res) = > {
      if (res.code === 0) {
        nowPosition = '~' + pos.substring(1) + dir.substring(0, dir.length - 1) // ~/blog/img
        e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)
        e_pos.html(nowPosition)
      } else if (res.code === 404) {
        e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>' + res.message + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)}}})Copy the code

The core part is to calculate the number of rollback layers and determine what the rollback path should be based on the number of rollback layers. The number of rollback layers is matched with the re to find the ‘.. The number of /’ is enough, and the path calculation is easily done by converting arrays and strings to and from each other.

The effect is as follows:

0x07 cat

The implementation of cat is basically the same as that of CD. You only need to replace directory processing with file processing.

The server provides interfaces:

router.get('/cat', (req, res) => {
  let { filename, dir } = req.query

  // Multilevel directory stitching: located at ~/blog/img, cat banner/menu.md
  dir = (dir + filename).split('/')
  filename = dir.pop() // Discard the last level, which must be a file
  dir = dir.join('/') + '/'

  glob(`src/file${dir}*.md`, {}, (err, files) => {
    dir = dir.substring(1)
    files = files.map(i= > i.replace('src/file/'.' ').replace(dir, ' '))
    filename = filename.replace('/'.' ')

    if (files.indexOf(filename) === - 1) {
      return res.jsonp({ code: 404.message: 'cat: no such file or directory: ' + filename })
    } else {
      fs.readFile(`src/file/${dir}/${filename}`.'utf-8', (err, data) => {
        return res.jsonp({ code: 0, data })
      })
    }
  })
})Copy the code

The directory concatenation is performed on the server. Unlike the CD command, the nowPosition does not change, so it can be calculated on the server.

If the file exists, read the file content and return. If the file does not exist, an error code and a message are returned.

Unlike CD, CAT is simpler, and the front end doesn’t need to distinguish between so many cases. Since we no longer need to maintain nowPosition to calculate the current path, globs support relative paths.

case 'cat':
  file = input.split(' ') [1]
  $.ajax({
    url: host + '/cat'.data: { filename: file, dir: position.replace('~'.' ') + '/' },
    dataType: 'jsonp'.success: (res) = > {
      if (res.code === 0) {
        e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>' + res.data.replace(/\n/g.'<br/>') + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)}else if (res.code === 404) {
        e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + position + '] % ' + input + '<br/>' + res.message + '<br/>')
        e_html.animate({ scrollTop: $(document).height() }, 0)}}})breakCopy the code

The implementation effect is as follows:

0x08 Automatic Completion

Those familiar with the command line know that in most cases the command line is much faster than a graphical interface. The main reason is that the command line tool supports Tab completion, which allows users to type a string of commands in just a few characters. The basic functions used in this way, of course, need to be implemented.

The premise of automatic completion is that the system knows what the complete content is after completion. Our analog terminal is only reading files and directories for the time being, so the premise of automatic completion is that the system stores complete directories and files.

Use two global variables to store directory and file data, respectively, when the page opens:

$(document).ready((a)= > {
  // Initialize directories and files
  $.ajax({
    url: host + '/list'.data: { dir: '/' },
    dataType: 'jsonp'.success: (res) = > {
      if (res.code === 0) {
        directory = res.data.directory
        directory.shift(); // remove the first ~
        files = res.data.files
      }
    }
  })
})Copy the code

The server interface is implemented as follows:

router.get('/list', (req, res) => {
  // Get all directories and all files
  let { dir } = req.query
  glob(`src/file${dir}* * `, {}, (err, files) => {
    if (dir === '/') {
      files = files.map(i= > i.replace('src/file/'.' '))
    }
    files[0] = '~' // Initialize the home directory
    let directory = files.filter(i= >! i.includes('. ')) // Filter out the files
    files = files.filter(i= > i.includes('. ')) // Keep only files

    // Files are sorted by hierarchy (first alphabetical by default) so that the front end implements the shortest hierarchy first matching
    files = files.sort((a, b) = > {
      let deapA = a.match(/\//g) && a.match(/\//g).length || 0
      let deapB = b.match(/\//g) && b.match(/\//g).length || 0

      return deapA - deapB
    })

    return res.jsonp({ code: 0.data: {directory, files }})
  })
})Copy the code

Well, the notes are more detailed, just look at the notes… The resulting two array structures are as follows:

Note that for directories, we use the default character list order, because CD to a directory autocomplete, should follow the shortest path matching; For files, we sort them according to the depth of the hierarchy, because a file named CAT is matched according to the shallowest path, that is, files in the current directory should be matched first.

The front end needs to listen for Tab keyDown events:

if (b.keyCode === 9) {
    pressTab(e_input.val())
    b.preventDefault()
    e_html.animate({ scrollTop: $(document).height() }, 0)
    e_input.focus()
  }Copy the code

For the pressTab function, there are three cases (because the only commands we implement with arguments are cat and CD) :

  1. Completion command
  2. Complete the parameters of the cat command
  3. Parameter after completing the CD command

The implementation of case 1 is a bit silly:

command = input.split(' ') [0]
if (command === 'l') e_input.val('ls')
if (command === 'c') {
  e_main.html($('#main').html() + '[<span id="usr">' + usrName + '</span>@<span class="host">ursb.me</span> ' + nowPosition + '] % ' + input + '

cat    cd    claer

'
)}if (command === 'ca') e_input.val('cat') if (command === 'cl' || command === 'cle' || command === 'clea') e_input.val('clea')Copy the code

For case 2, cat autocomplete applies only to files, that is, to elements in our global variable files. Note that the prefix ‘./’ is handled well. Post code directly:

if (input.split(' ') [1] && command === 'cat') {
    file = input.split(' ') [1]
    let pos = nowPosition.replace('~'.' ').replace('/'.' ') // Remove the ~ prefix of the home directory and the ~/ prefix of other directories
    let prefix = ' '

    if (file.startsWith('/')) {
        prefix = '/'
        file = file.replace('/'.' ')}if (nowPosition === '~') {
        files.every(i= > {
          if (i.startsWith(pos + file)) {
            e_input.val('cat ' + prefix + i)
            return false
          }
          return true})}else {
        pos = pos + '/'
        files.every(i= > {
          if (i.startsWith(pos + file)) {
            e_input.val('cat ' + prefix + i.replace(pos, ' '))
            return false
          }
          return true}}})Copy the code

In case 3, the implementation is basically the same as in case 2, but the CD command auto-complete only matches the directory, that is, the elements in our global variable directory. Due to the length of the problem, and here to achieve and the above code is basically repeated, I will not paste.

0x09 History Command

Linux terminals can press up and down arrow keys to browse user history input commands, this is also a very important and basic function, so let’s implement it.

Let’s start with a few global variables to store the history of the commands entered by the user.

let hisCommand = [] // History command
let cour = 0 / / pointer
let isInHis = 0 // Whether the command is the currently entered command. 0 yes, 1 noCopy the code

The isInHis variable is used to judge whether the input content is in the history record, that is, even if the user does not press enter, the input content can still be reproduced after pressing the up key, so as not to empty it. (isInHis = 0 after press Enter)

Added up and down arrow keys to listen for keyDown bindings:

if (b.keyCode === 38) historyCmd('up')
if (b.keyCode === 40) historyCmd('down')Copy the code

The historyCmd function takes arguments that indicate the order in which the user looked, whether it was the first or the last.

let historyCmd = (k) = >{$('body,html').animate({ scrollTop: $(document).height() }, 0)

  if(k ! = ='up' || isInHis) {
    if (k === 'up' && isInHis) {
      if (cour >= 1) {
        cour--
        e_input.val(hisCommand[cour])
      }
    }
    if (k === 'down' && isInHis) {
      if (cour + 1 <= hisCommand.length - 1) {
        cour++
        $(".input-text").val(hisCommand[cour])
      } else if (cour + 1 === hisCommand.length) {
        $(".input-text").val(inputCache)
      }
    }
  } else {
    inputCache = e_input.val()
    e_input.val(hisCommand[hisCommand.length - 1])
    cour = hisCommand.length - 1
    isInHis = 1}}Copy the code

Code implementation is relatively simple, according to the up and down keys to move the pointer array.

This code has been open source (airingursb/terminal), interested partners can submit PR, let us do better simulation terminal ~