### EEIC後期実験:大規模ソフトウェアを手探る # チーム7 レポート ## はじめに この記事は東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」のレポートです. この実験ではオープンソースソフトウェア(OSS)に変更を加え, 便利な機能を追加することを試みるものです. 私たちはMicrosoft社が提供するコードエディタである「Visual Studio Code」(以下VSCodeと略す)のソースコードを編集し, 以下に示す三つの機能を追加することができました. - エディタで右クリックした時に全選択できるようにする. - 開いているエディターすべてを保存せずに閉じられるようにする. - ターミナル上で, マウスカーソルをクリックのみで動かせるようにする. ## ビルドの方法 - ソースコード VSCodeはオープンソースソフトウェアであり, そのソースコードは[github](https://github.com/microsoft/vscode.git)で公開されています. まずはそこからローカル環境へ`git clone`でソースコードをとってきましょう. - ビルド VSCodeはnpmと並ぶ代表的なパッケージマネージャーであるyarnによって管理されています. ```bash yarn yarn watch ``` 以上のコマンドを実行することでcode.shという VSCodeの実行ファイルを作ることができます. 他に必要なパッケージや注意事項については[過去のレポート](https://hackmd.io/CjUrC5RuQ2ehr5sgd9dYnA)と[過去のブログ](https://eeic2020-doss1.hatenablog.com/entry/2020/11/02/220316)を参考にしました. :::warning 実行に時間がかかるので気長に待ちましょう. 私の環境ではyarn watchに30分以上かかりました. ::: ```bash ./scripts/code.sh ``` ソースコードが入っているフォルダに移動して以上のコマンドを入力したらVSCodeが立ち上がるはずです. ## デバッグ方法 VSCodeをデバッグするに当たり, Chrome Developer Toolsを用いることにしました. VSCodeをターミナルから立ち上げた後, Command+Shift+iで画像のような画面を開くことができます. ![chrome developer tools](https://i.imgur.com/fEK3Ccq.png) 使いやすさからVSCodeの「実行とデバッグ」の機能を利用してVSCodeのソースコードをデバッグしようとしましたが, macOSの場合 ```bash Could not attach to main target ``` Ubuntuの場合 ```bash Could not find any debuggable target ``` このようなエラー文が出るとともに起動された画面が閉じてしまいました. 再起動するとエラーは解消しましたが, このままデバッグできそうにもなかったので断念しました. 今回はユーザインターフェースについての機能追加を行いたいので, デバッグ画面の右側にあるEvent Listener Breakpointsから注目したい動作にチェックを入れてブレイクポイントを設置しました. そうすると, 左の画面で指定の動作をするとプログラムがそこで止まり, 動作を追うことができるようになります. 目当ての関数などが見つかったらそこにブレイクポイントを置くことでスムーズにデバッグを行うことができます. ## エディタで右クリックした時に全選択できるようにする. これはVSCodeの[issues](https://github.com/microsoft/vscode/issues/163674)に上がっていたものをベースにしました. 1. はじめにどうやってカットをしているか探ってみる. - 先ほど紹介したchrome developer toolsを使いました. - Event Listener Breakpointsの中のanimationを有効化. - 右クリックした時に動作が止まるようになります. - 目当ての場所でピッタリ止まるわけではないので, ステップを進めたり, 行きすぎていると思ったらCall Stackを確認し, 少し前の地点にブレイクポイントを設定してやり直すと良いです. - エディタ上で右クリックをするとポップアップ画面が表示されるので, その実装部を調べます. ![](https://i.imgur.com/hhhbGbq.png) animationをブレークポイントに設定した上でエディタ上で右クリックをすると, 上のコードで停止します. これはおそらくGUIを表示させている箇所だと思われます. 確かに青色の再生ボタンを押すとポップアップ画面が表示されて再び停止します. ポップアップウィンドウの中から"Cut"を押したのち, 再び青色の再生ボタンを押して処理を進めてみましょう. ![](https://i.imgur.com/UBLQB8d.png) ここでコードが止まるはずです. 私たちが探しているのはクリックという入力を"Cut"するという命令に変換している場所なので, もっと前の部分を探さなければならないと推測できます. 写真でソースコードの右に表示されているリストはCall Stackのリストで, 下に行くほど前の命令になります. 一番上の関数にViewModelという文字が含まれていますが, ViewModelとは中の処理(Model)をグラフィック(View)に反映させるためのインターフェイスのようなものなのでこの時点では既にclickは"Cut"命令に翻訳されていると伺えます. 逆に下の方に行くとclickという関数があり, ここではまだ"Cut"に変換されていないです. その間を見てみると`executeCommand`という関数があり, ここで"Cut"という命令を実行していると予想できます. ![](https://i.imgur.com/emQ0Vk5.png) これをクリックしてサイドバーのWatch->+でこの関数の引数である"id"と検索してください. 中身は`editor.action.clipboardCutAction`であると確認できると思います. **ここに全選択を示す何かしらの変数を入れれば全選択することができると考えられるでしょう.** 2. ソースコードに検索をかける どうやってコマンドが実装してあるのか少し見当がついたので, 一度ソースコード全体から`editor.action.clipboardCutAction`を検索してみると, `clipboard.ts`のなかに以下のような記述があることがわかります. ```typescript export const CutAction = supportsCut ? registerCommand(new MultiCommand({ id: 'editor.action.clipboardCutAction', precondition: undefined, kbOpts: ( // Do not bind cut keybindings in the browser, // since browsers do that for us and it avoids security prompts platform.isNative ? { primary: KeyMod.CtrlCmd | KeyCode.KeyX, win: { primary: KeyMod.CtrlCmd | KeyCode.KeyX, secondary: [KeyMod.Shift | KeyCode.Delete] }, weight: KeybindingWeight.EditorContrib } : undefined ), menuOpts: [{ menuId: MenuId.MenubarEditMenu, group: '2_ccp', title: nls.localize({ key: 'miCut', comment: ['&& denotes a mnemonic'] }, "Cu&&t"), order: 1 }, { menuId: MenuId.EditorContext, group: CLIPBOARD_CONTEXT_MENU_GROUP, title: nls.localize('actions.clipboard.cutLabel', "Cut"), when: EditorContextKeys.writable, order: 1, }, { menuId: MenuId.CommandPalette, group: '', title: nls.localize('actions.clipboard.cutLabel', "Cut"), order: 1 }, { menuId: MenuId.SimpleEditorContext, group: CLIPBOARD_CONTEXT_MENU_GROUP, title: nls.localize('actions.clipboard.cutLabel', "Cut"), when: EditorContextKeys.writable, order: 1, }] })) : undefined; ``` コピーやペーストについても同じような構造体があり, これがコマンドの実装を担っていると考えられます. またVSCodeで全選択の機能が部分的に実装されていることからこれと同じような構造体が全選択に関しても存在していると考えられます. 実際に`editor.action`でソースコードを検索すると`editorExtentions.ts`の中に以下の記述が見つかります. ```typescript export const SelectAllCommand = registerCommand(new MultiCommand({ id: 'editor.action.selectAll', precondition: undefined, kbOpts: { weight: KeybindingWeight.EditorCore, kbExpr: null, primary: KeyMod.CtrlCmd | KeyCode.KeyA }, menuOpts: [{ menuId: MenuId.MenubarSelectionMenu, group: '1_basic', title: nls.localize({ key: 'miSelectAll', comment: ['&& denotes a mnemonic'] }, "&&Select All"), order: 1 }, { menuId: MenuId.CommandPalette, group: '', title: nls.localize('selectAll', "Select All"), order: 1 }] })); ``` CutActionと比べると`menueOpts`の中身が少ないことがわかります. そのなかでも`MenuID`が`EditorContext`のものはエディタの右クリック時における処理を表していると考えられるので, 以下の記述を追加しました. ```diff export const SelectAllCommand = registerCommand(new MultiCommand({ id: 'editor.action.selectAll', precondition: undefined, kbOpts: { weight: KeybindingWeight.EditorCore, kbExpr: null, primary: KeyMod.CtrlCmd | KeyCode.KeyA }, menuOpts: [{ menuId: MenuId.MenubarSelectionMenu, group: '1_basic', title: nls.localize({ key: 'miSelectAll', comment: ['&& denotes a mnemonic'] }, "&&Select All"), order: 1 + }, { + menuId: MenuId.EditorContext, + group: CLIPBOARD_CONTEXT_MENU_GROUP, + title: nls.localize('selectAll', "Select All"), + order: 3 + }, { menuId: MenuId.CommandPalette, group: '', title: nls.localize('selectAll', "Select All"), order: 1 }] })); ``` これにより, エディタで右クリックをした時に以下のようにselect allと表示されるようになりました. ![](https://i.imgur.com/ndmJDm0.png) 3. pull requestを出す せっかくなのでgithubのvscodeの公式リポジトリにプルリクエストを出してみました. ![](https://i.imgur.com/TJlAMie.png) 今は返信待ちなので, 進展があったら追記します. ## 開いているエディターすべてを保存せずに閉じられるようにする. VSCodeの[Issues#163634](https://github.com/microsoft/vscode/issues/163634)で提案されている, 開いているエディターすべてを保存せずに閉じる機能を追加しました. オートセーブを切った状態でエディターを閉じようとすると"Save", "Don't Save", "Cancel"の選択画面が出るようになっています. もしコマンドラインなどから, 存在しないファイルをたくさん開いてしまった場合, エディターそれぞれに対して前述の画面が出てきてしまうと面倒です. そのため, 選択画面に"Don't Save Any"というボタンを追加して全てのエディターを保存せずに閉じたい, というのがissueの主旨です. - ボタンの追加 まずは選択画面に"Don't Save Any"のボタンを追加します. "Don't Save"で検索をかけると該当箇所が出てくるので以下のように編集しました. `abstractFileDialogService.ts`の`doShowSaveConfirm`関数 ```typescript= const buttons: string[] = [ fileNamesOrResources.length > 1 ? nls.localize({ key: 'saveAll', comment: ['&& denotes a mnemonic'] }, "&&Save All") : nls.localize({ key: 'save', comment: ['&& denotes a mnemonic'] }, "&&Save"), nls.localize({ key: 'dontSave', comment: ['&& denotes a mnemonic'] }, "Do&&n't Save"), nls.localize('dontSaveAny', "Don't Save Any"), nls.localize('cancel', "Cancel") ]; const { choice } = await this.dialogService.show(Severity.Warning, message, buttons, { cancelId: 2, detail }); switch (choice) { case 0: return ConfirmResult.SAVE; case 1: return ConfirmResult.DONT_SAVE; case 2: return ConfirmResult.DONT_SAVE_ANY; default: return ConfirmResult.CANCEL; } ``` `dialogs.ts` ```typescript= export const enum ConfirmResult { SAVE, DONT_SAVE, DONT_SAVE_ANY, CANCEL } ``` このように変更を加えることで, "Don't Save Any"のボタンを追加することができました. `choice`には, `this.dialogService.show`の結果`button`の何番目が選択されたのかが数字として代入されます. それをわかりやすいように`ConfirmResult.DONT_SAVE_ANY`などとして返しています. 同様に, `mergeEditorInputModel.ts`の`ConfirmClose`関数でも`choice`をもとにConfirmResultを返している箇所があるのでそこも以下のように変更しました. ```typescript= if (choice === 3) { // cancel: stay in editor return ConfirmResult.CANCEL; } else if (choice === 2) { // dont save any await Promise.all(inputModels.map(m => m._discard())); return ConfirmResult.DONT_SAVE_ANY; } else if (choice === 0) { // save with conflicts await Promise.all(inputModels.map(m => m.accept())); return ConfirmResult.SAVE; // Save is a no-op anyway } else { // discard changes await Promise.all(inputModels.map(m => m._discard())); return ConfirmResult.DONT_SAVE; // Revert is a no-op } ``` ボタンの画面はこのように表示されます. ![Don't Save Any](https://i.imgur.com/ylVkQEp.png) - "Don't Save Any"の機能そのものの実装 処理がどのように行われているかデバッグツールで調べていくうちに, 上記のConfirmResult.~が`editorGroupView.ts`の`doHandleCloseConfirmation`関数内の`confirmation`に渡されていることがわかりました.そして`confirmation`の値によってセーブを行ったり, 最後に保存された状態に戻したりといった処理を行っています. また, `doHandleCloseConfirmation`関数を呼び出す`handleCloseConfirmation`関数はこのようになっています. ```typescript= private async handleCloseConfirmation(editors: EditorInput[]): Promise<boolean /* veto */> { if (!editors.length) { return false; // no veto } const editor = editors.shift()!; // To prevent multiple confirmation dialogs from showing up one after the other // we check if a pending confirmation is currently showing and if so, join that let handleCloseConfirmationPromise = this.mapEditorToPendingConfirmation.get(editor); if (!handleCloseConfirmationPromise) { handleCloseConfirmationPromise = this.doHandleCloseConfirmation(editor); this.mapEditorToPendingConfirmation.set(editor, handleCloseConfirmationPromise); } let veto: boolean; try { veto = await handleCloseConfirmationPromise; } finally { this.mapEditorToPendingConfirmation.delete(editor); } // Return for the first veto we got if (veto) { return veto; } // Otherwise continue with the remainders return this.handleCloseConfirmation(editors); } ``` 6行目にある`editors.shift()`によって参照するエディターをずらしながら再帰的に実行することで, 全てのエディターを参照することができます. ゆえに, `handleCloseConfirmation`と`doHandleCloseConfirmation`に"Don't Save Any"が押されたことを意味するbooleanの引数を追加すれば上手く機能が実装できると考えられます. しかしここで問題となるのが引数の状態をどのように保持するかです. `handleCloseConfirmation`は再帰的に実行されるので, 関数内で変数を初期化すると毎回初期化されて情報を引き継ぐことができません. `handleCloseConfirmation`の返り値は`veto`ですが, Promiseがついているせいで今のままではbooleanしか返すことができません. そこで`veto`をbooleanではなくnumberに変更することにしました. `handleCloseConfirmation`関数の変更点は以下の通りです. ```typescript= private async handleCloseConfirmation(editors: EditorInput[], dont_save_any: boolean = false): Promise<boolean /* veto */> { if (!editors.length) { return false; // no veto } const editor = editors.shift()!; let veto: number = 1; // To prevent multiple confirmation dialogs from showing up one after the other // we check if a pending confirmation is currently showing and if so, join that let handleCloseConfirmationPromise = this.mapEditorToPendingConfirmation.get(editor); if (!handleCloseConfirmationPromise) { veto = await this.doHandleCloseConfirmation(editor, dont_save_any); handleCloseConfirmationPromise = (veto === 1) ? true : false; this.mapEditorToPendingConfirmation.set(editor, handleCloseConfirmationPromise); } this.mapEditorToPendingConfirmation.delete(editor); // Return for the first veto we got if (veto === 1) { return true; } // Otherwise continue with the remainders if (veto === 2) { return this.handleCloseConfirmation(editors, true); } else { return this.handleCloseConfirmation(editors); } } ``` "Don't Save Any"が押されたかどうかを表すboolean変数`dont_save_any`を追加し, デフォルト値をfalseにしています. もとのコードでは, `veto`が1のときtrueを返し, 0のとき再帰呼び出しを行います. それに加えて, "Don't Save Any"が押されたときには`veto`に2が入るので, `dont_save_any`をtrueにして返します. これによって`handleCloseConfirmation`関数内で呼び出される`doHandleCloseConfirmation`関数の挙動が変化します. 以下が`doHandleCloseConfirmaion`関数の変更点です. ```typescript= // No auto-save on focus change or custom confirmation handler: ask user if (!autoSave) { // Switch to editor that we want to handle for confirmation await this.doOpenEditor(editor); if (dont_save_any) { confirmation = ConfirmResult.DONT_SAVE_ANY; } else if (typeof editor.closeHandler?.confirm === 'function') { // Let editor handle confirmation if implemented confirmation = await editor.closeHandler.confirm([{ editor, groupId: this.id }]); } else { // Show a file specific confirmation let name: string; if (editor instanceof SideBySideEditorInput) { name = editor.primary.getName(); // prefer shorter names by using primary's name in this case } else { name = editor.getName(); } confirmation = await this.fileDialogService.showSaveConfirm([name]); } } ``` 6行目にあるように, `dont_save_any`がtrueであれば`confirmation`に`ConfirmResult.DONT_SAVE_ANY`を代入します. ```typescript= // Otherwise, handle accordingly switch (confirmation) { //中略 case ConfirmResult.DONT_SAVE_ANY: try { // first try a normal revert where the contents of the editor are restored await editor.revert(this.id); return editor.isDirty() ? 1 : 2; // veto if still dirty } catch (error) { this.logService.error(error); // if that fails, since we are about to close the editor, we accept that // the editor cannot be reverted and instead do a soft revert that just // enables us to close the editor. With this, a user can always close a // dirty editor even when reverting fails. await editor.revert(this.id, { soft: true }); return editor.isDirty() ? 1 : 2; // veto if still dirty } case ConfirmResult.CANCEL: return 1; // veto } ``` "Don't Save Any"のケース処理は"Don't Save"の処理に一部変更を加えたものです. 変更箇所は10, 21行目の`return editor.isDirty() ? 1 : 2;`の部分です. "Don’t Save"では条件演算子の部分を使わず`editor.isDirty()`を返します. すなわち"Don't Save"のときに0(no veto)で返すところを2で返すことで"Don't Save"と"Don't Save Any"を区別しています. 以上のように変更を加えることでオートセーブを切っているときに, 開いているエディター全てを保存せず一度に閉じることができるようになりました. ## ターミナルでクリックでカーソルを移動できるようにする これはソースコードの変更自体は一行で済んだのですが, そこに辿り着くまで、そして辿り着いてからの道のりが長かったので共有します. - 結論 - 実装部はvscodeのソースコードではなく, xterm.jsという外部パッケージのソースコードにありました. - それはVSCodeのソースコードを見つからないわけだったのですが, デバッガをつかってとても慎重に探すと見つけることができました. ```diff - if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) { + if (this.selectionText.length <= 1) { if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) { const coordinates = this._mouseService.getCoords( event, this._element, this._bufferService.cols, this._bufferService.rows, false ); if (coordinates && coordinates[0] !== undefined && coordinates[1] !== undefined) { const sequence = moveToCellSequence(coordinates[0] - 1, coordinates[1] - 1, this._bufferService, this._coreService.decPrivateModes.applicationCursorKeys); this._coreService.triggerDataEvent(sequence, true); } } } else { this._fireEventIfSelectionChanged(); } } ``` 実際に加えた変更. altキーを押しながらクリックすると所望の動作が実現されるので, その条件を削除した. - ここで生じた問題 - xterm.jsはyarnでインストールされる外部パッケージなので, そのままでは変更を加えることができません. - そこで次の手順でビルドを行いました. 1. Githubからxterm.jsのソースコードを取ってくる. 2. xterm.jsを変更する. 3. xterm.jsをビルドする. - `$ yarn && yarn package` - これでlibディレクトリが作成されます 4. `$ yarn link`で自作ライブラリのシンボリックリンクを作成する. 5. VSCodeディレクトリに戻り`$ yarn link add [xtermの相対パス]`を実行. 6. NodeModule内のxtermフォルダが自作のものに置き換わっていることを確認. 以上の操作でxterm.jsに加えた変更をvscodeに反映させることができました. xtermをいじろうと言う人は少ないと思いますが, ソースコードではなく外部ライブラリに変更を加えたいと言う方には参考になると思います. ## 感想 TypeScriptに触ったことがなかったため, ビルドやデバッグの方法に慣れずエラーが多発して大変でしたが, オブジェクト指向を実感でき, デバッガを用いる良い練習となりました. ターミナルに変更を施すのは難しかったですが, デバッグにはChrome Developer ToolsのEvent Listener Breakpointsが非常に役に立ちました.