0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザで動く疑似ターミナルに vi を実装した話(Linuxコマンド学習サイトの中身)

0
Posted at

はじめに

Linuxコマンドの学習で、いちばん多い離脱ポイントはどこだと思いますか。

私の答えは「環境構築」です。ls も cd も打つ前に、VirtualBox のインストール、ISO のダウンロード、ネットワーク設定でつまずいて終わる。これを何とかしたくて、ブラウザだけで完結する疑似ターミナルを実装しました。

この記事では、その中身——とくに vi エディタをどう再現したか を中心に紹介します。

対象ページはこちらです。

作ったもの

ブラウザ上で以下が動きます。

$ ls
$ cd /var/log
$ grep error syslog
$ vi memo.txt

サーバーへの通信はゼロ。すべてクライアントサイドで完結します。

なぜ実機でもWASMでもないのか

選択肢は3つありました。

方式 メリット デメリット
実機に接続 完全に本物 同時接続数に上限、コスト、予約制になる
WASM(v86等でLinuxを動かす) 本物のカーネルが動く 初回数十MBのダウンロード、低スペック端末で厳しい
疑似ターミナル 軽量、同時接続無制限 想定外のコマンドに対応できない

学習サイトとして重視したのは「開いた瞬間に打てること」でした。数十MBのロードを待たせる時点で、環境構築で挫折する層を救えません。そこで疑似ターミナル方式を選びました。

仮想ファイルシステムの持ち方

まずファイルシステムをJSのオブジェクトで持ちます。

const fs = {
  "/": {
    type: "dir",
    children: {
      "home": {
        type: "dir",
        children: {
          "user": {
            type: "dir",
            children: {
              "memo.txt": { type: "file", content: "hello\nworld\n" }
            }
          }
        }
      },
      "var": { type: "dir", children: { /* ... */ } }
    }
  }
};

let cwd = "/home/user";

ls や cd は、このツリーを辿って結果を返すだけです。

function resolvePath(path, cwd) {
  const base = path.startsWith("/") ? [] : cwd.split("/").filter(Boolean);
  for (const seg of path.split("/").filter(Boolean)) {
    if (seg === ".") continue;
    if (seg === "..") base.pop();
    else base.push(seg);
  }
  return "/" + base.join("/");
}

.. の扱いさえ間違えなければ、ここは素直に書けます。

本題:vi をどう再現するか

ここが一番厄介でした。vi は「モードを持つ」からです。

モードの管理

const VI_MODE = {
  NORMAL: "normal",
  INSERT: "insert",
  COMMAND: "command",  // : を押した後
};

let viState = {
  mode: VI_MODE.NORMAL,
  lines: [],       // 編集中のバッファ
  cursor: { row: 0, col: 0 },
  filename: "",
  modified: false,
};

キー入力のハンドリング

通常のターミナルは「Enterが押されたら1行を解釈」でよいのですが、vi は1キーごとに反応する必要があります。

function handleViKey(e) {
  if (viState.mode === VI_MODE.NORMAL) {
    switch (e.key) {
      case "i":
        viState.mode = VI_MODE.INSERT;
        e.preventDefault();
        break;
      case "a":
        viState.cursor.col++;
        viState.mode = VI_MODE.INSERT;
        e.preventDefault();
        break;
      case "h": moveCursor(0, -1); break;
      case "j": moveCursor(1, 0); break;
      case "k": moveCursor(-1, 0); break;
      case "l": moveCursor(0, 1); break;
      case "x": deleteCharAtCursor(); break;
      case ":":
        viState.mode = VI_MODE.COMMAND;
        commandBuffer = ":";
        break;
    }
  } else if (viState.mode === VI_MODE.INSERT) {
    if (e.key === "Escape") {
      viState.mode = VI_MODE.NORMAL;
      // vi の仕様に合わせてカーソルを1つ戻す
      viState.cursor.col = Math.max(0, viState.cursor.col - 1);
    } else if (e.key.length === 1) {
      insertChar(e.key);
    }
  }
  render();
}

:wq の解釈

コマンドモードの入力を溜めて、Enterで解釈します。

function execExCommand(cmd) {
  switch (cmd) {
    case ":w":
      writeFile(viState.filename, viState.lines.join("\n"));
      viState.modified = false;
      return { exit: false };
    case ":wq":
    case ":x":
      writeFile(viState.filename, viState.lines.join("\n"));
      return { exit: true };
    case ":q":
      if (viState.modified) {
        return { exit: false, message: "E37: No write since last change (add ! to override)" };
      }
      return { exit: true };
    case ":q!":
      return { exit: true };  // 保存せず破棄
    default:
      return { exit: false, message: `E492: Not an editor command: ${cmd.slice(1)}` };
  }
}

エラーメッセージを本物と同じ E37: E492: にしているのは、学習者が実機で同じエラーに遭遇したときに見覚えがある状態を作るためです。ここは地味ですが効きます。

ハマったところ

① ブラウザのショートカットと衝突する

Ctrl+W(タブを閉じる)、Ctrl+D、/(Firefoxのクイック検索)など、vi のキーとブラウザ標準動作がぶつかります。

// ターミナルにフォーカスがある間だけ、特定キーを横取りする
term.addEventListener("keydown", (e) => {
  const swallow = ["Tab", "/", "ArrowUp", "ArrowDown"];
  if (swallow.includes(e.key) || (e.ctrlKey && "wdu".includes(e.key))) {
    e.preventDefault();
  }
  handleKey(e);
});

Ctrl+W だけはブラウザ側が preventDefault を無視する環境があるため、そこは諦めて代替案内を出しています。

② IME(日本語入力)が入ると壊れる

compositionstart / compositionend を見て、変換中はキーハンドラを止めます。

let composing = false;
term.addEventListener("compositionstart", () => composing = true);
term.addEventListener("compositionend", () => composing = false);

③ モバイルでEscキーがない

スマホには Esc キーがありません。画面下部に Esc : Tab の仮想キーを並べて解決しました。ここは実機にない工夫ですが、実用を優先しています。

正直に書いていること

このターミナルは本物のカーネルではありません。想定していないコマンドには応答できません。

その点はサイト上でも明記しています。「実機に近い体験」を謳って期待値を上げすぎると、学習者の信頼を損なうためです。入口としての疑似体験という位置づけを、はっきり書くようにしています。

おわりに

ls から vi まで、順番に打って試せるページを公開しています。300本ほどレッスンがあるので、気になるコマンドから覗いてみてください。

実装で気になる点があれば、コメントで教えていただけると嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?