ファイルパーミッションってスロットっぽいよね？VSCodeの拡張機能でスロットしながらchmodを実行しよう！

## ここは文字数稼ぐところ

どうも限界派遣SESです。

ひとりアドカレ8日目の本日はずっとやりたいネタを消化しにきました。

## とりあえずネタの説明

本日の元ネタはコレです。
CHMODをスロットで実行できるCLIアプリケーションです。

https://qiita.com/jiro4989/items/2530c4f789916521a47a

こーゆーネタっぽいアプリつくりたいなーってずっと思ってたんですよね！
私の大好物です！

とは言いつつも、流石にこれと同じようなものを同じくCLIで実装してしまっては面白くないですよね。

そういうことで、先日私がVSCodeの拡張機能で`React` + `Tailwindcss`を使えるようにしたのはコレをVSCodeの拡張機能としてGUIを実装したかったからです。

https://qiita.com/nikawamikan/items/6ce4d6d6a621e22bb27a

## コマンドの実装

コマンド実装はWebViewからVSCode側のAPIを呼んだりといろいろと複雑な点が多いので1つずつ見ていきましょう。

### パスの受け取り

コマンド実行時には対象となるディレクトリやファイルのパスを受け取る必要があるため、コマンド実行時に以下のプロンプトが出てくれないといけません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/e58a3cc2-f477-a79f-fa3e-d953e69c3453.png)

これを実装するためには`extension.ts`のコマンドが呼び出された時に`vscode.window.showOpenDialog`を呼ぶようにしてあげるだけで良いです。

```ts: extension.ts
const disposable = vscode.commands.registerCommand(
    'slotchmod-for-vscode.slotchmod',
    async () => {

        // ユーザーにディレクトリを選択させる
        const uri = await vscode.window.showOpenDialog({
            canSelectFolders: true,
            canSelectFiles: true,
            canSelectMany: false,
            openLabel: 'Select Directory or File'
        });

        // キャンセルされた場合は早期リターン
        if (!uri) { return; }

        // chmod コマンドの対象パスを取得
        const targetPath = uri[0].path;
        
        ...
```

### WebViewに対してパスを受け渡し

WebViewは`App.tsx`で実装しています。
そのため、HTMLを直接記述してパスを受け渡すということができません。

そのため、`App.tsx`に`useEffect`でメッセージを受け取るイベントを設置してあげます。

```tsx: App.tsx
// メッセージを受け取って対象のパスを取得するイベント
useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
        const message = event.data;
        if (message.command === 'setTargetPath') {
            setTargetPath(message.targetPath);
        }
    };

    window.addEventListener('message', handleMessage);

    return () => {
        window.removeEventListener('message', handleMessage);
    };
}, []);
```

そして、このイベントを`extension.ts`側から呼び出してあげる事で`targetPath`を`App.tsx`にわたすことが出来ます。

```ts: extension.ts
// WebViewにtargetPathを送信
panel.webview.postMessage({ command: 'setTargetPath', targetPath });
```

### 回転するリールの作成

`chmod`を実行するために3つのリール[^1]が必要になります。
そのため、`components`ディレクトリを作成して`reel.tsx`と`reel.css`を作っていきましょう。

[^1]: スロットの回ってるやつの名前（今まで知らんかった）

```tsx: reel.tsx

import React, { useState, useEffect } from 'react';
import './reel.css';

// 回転速度(64/1000)秒ごとにアップデートする
const DELAY_MS = 64;

// リールの引数定義
interface ReelProps {
    index: number;
    onStop: (index: number, angle: number) => void;
}

export function Reel({ index, onStop }: ReelProps) {
    // リールが回転しているか？の状態
    const [isRotating, setIsRotating] = useState(true);
    // 回転角度
    const [rotationAngle, setRotationAngle] = useState(0);

    // ローテーションの切り替え
    const toggleRotation = () => {
        setIsRotating(!isRotating);

        onStop(index, (Math.abs(rotationAngle) % 360) / 45); // 親コンポーネントにインデックスと角度を通知
    };

    useEffect(() => {
        let intervalId: number | null = null;

        // リールを継続的に回すためのイベント
        if (isRotating) {
            intervalId = window.setInterval(() => {
                // 回転方向が上から下に向かって回るのでマイナス方向で回転
                setRotationAngle(prevAngle => prevAngle - 45);
            }, DELAY_MS);
        } else if (intervalId !== null) {
            clearInterval(intervalId);
        }

        return () => {
            if (intervalId !== null) {
                clearInterval(intervalId);
            }
        };
    }, [isRotating, rotationAngle, onStop, index]);

    return (
        <div className="flex flex-col items-center justify-center h-full">
            <div className="scene">
                <div className="reel"
                    style={{ transform: `rotateX(${rotationAngle}deg)`, transition: `${DELAY_MS * 1.1}ms` }}
                >
                    {[...Array(8)].map((_, i) => (
                        <div key={i} className="face"
                            {/* 45°ずつ回転させて8角形の筒のようなものを作ります。 */}
                            style={{ transform: `rotateX(${i * 45}deg) translateZ(120px)` }}>
                            {i}
                        </div>
                    ))}
                </div>
            </div>
            <div className="mt-36">
                {/* ローテーションを停止させるボタン */}
                <button
                    className={`mt-5 px-4 py-2 rounded ${isRotating ? 'bg-blue-500 text-white' : 'bg-gray-500 text-gray-300 cursor-not-allowed'}`}
                    onClick={toggleRotation}
                    disabled={!isRotating}
                >
                    Stop
                </button>
            </div>
        </div>
    );
};
```

リールを3Dで表示した際に`Tailwindcss`が対応していなさそうだったので直接CSSを記述しています。
できたら申し訳ない。

```css: reel.css
.scene {
    width: 100px;
    height: 100px;
    perspective: 3000px;
    margin: 0 auto;
}

.reel {
    width: 100px;
    height: 100px;
    margin-top: 5em;
    position: relative;
    transform-style: preserve-3d;
}

.face {
    position: absolute;
    width: 100px;
    height: 100px;
    background: white;
    border: 1px solid #ccc;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 50px;
    font-weight: bold;
    color: black;
}
```

### アプリケーションの画面の実装

アプリケーション全体は`App.tsx`で行っています。
なお、ゾロ目になった際はお祝いムードにしたかったのでゲーミング背景になるようにしてみました。

```tsx: App.tsx
import React, { useEffect, useState } from 'react';
import { Reel } from './components/reel'; // Adjust the path as necessary
import './App.css';

// VSCode側にイベントを送信するためのAPI
const vscode = (window as any).acquireVsCodeApi();

// リールの数
const REEL_COUNT = 3;

export function App() {
    const [reelStates, setReelStates] = useState<(number | null)[]>(
        Array.from({ length: REEL_COUNT }).map(() => null)
    );
    const [targetPath, setTargetPath] = useState<string | null>(null);
    const [allReelsStopped, setAllReelsStopped] = useState(false);
    const [isZorome, setIsZorome] = useState(false);

    // パーミッションの状態を文字列で取得します
    const getPermissions = () => {
        return reelStates.map(x => x ?? "?").join("");
    }

    // chmodコマンドを実行するためのハンドラ
    const handleChmod = () => {
        vscode.postMessage({
            command: 'slotchmod',
            payload: {
                reelStates
            }
        });
    };

    // リールを停止した時のハンドラ
    const handleReelStop = (index: number, angle: number) => {
        const newReelStates = [...reelStates];
        newReelStates[index] = angle;

        setReelStates(newReelStates);
    };

    // リールのステータスが変更された時に呼ばれるイベント
    useEffect(() => {
        // リールがそれぞれnullではない場合にイベントを実行します。
        if (reelStates.every(state => state !== null)) {
            setAllReelsStopped(true); 
            setIsZorome(new Set(reelStates).size === 1);
            handleChmod();
        }
    }, [reelStates]);

    // コマンド側からターゲットのパスを取得するためのイベント
    useEffect(() => {
        const handleMessage = (event: MessageEvent) => {
            const message = event.data;
            if (message.command === 'setTargetPath') {
                setTargetPath(message.targetPath);
            }
        };

        window.addEventListener('message', handleMessage);

        return () => {
            window.removeEventListener('message', handleMessage);
        };
    }, []);

    return (
        <div className={`flex flex-col items-center justify-center h-screen p-4 ${allReelsStopped ? (isZorome ? 'gaming-bg' : '') : ''}`}>
            <h1 className="text-2xl font-bold mb-4 text-center">SlotCHMOD</h1>
            {/* 現在のコマンドがどうなっているかを確認するための表示 */}
            <p className="mb-4 text-lg font-mono bg-gray-800 text-white p-2 rounded">
                chmod {getPermissions()} {targetPath}
            </p>
            {/* リールを3つ表示させる */}
            <div className="flex max-w-96 justify-around items-center mb-5 mt-5">
                {Array.from({ length: REEL_COUNT }).map((_, i) => (
                    <Reel key={i} index={i} onStop={handleReelStop} />
                ))}
            </div>
            {/* 全てのリールがストップした時に表示を更新 */}
            {allReelsStopped && (
                <p className="text-xl font-bold mt-4">
                    {isZorome ? '🎉 おめでとう！！ ゾロ目が出ました！！！ 🎉' : 'コマンドを実行しました！'}
                </p>
            )}
        </div>
    );
};
```

ゲーミング背景にするためのCSSはhueをローテーションさせることでそれっぽくしています。

```css: App.css
@keyframes hue-rotate {
    0% {
        filter: hue-rotate(0deg);
    }
    100% {
        filter: hue-rotate(360deg);
    }
}

.gaming-bg {
    background-color: red;
    animation: hue-rotate 5s infinite linear;
}
```

### コマンドの実装

`extension.ts`では`slotchmod`のイベントが呼ばれるのを待機します。
呼ばれた際は`payload`を受け取ってそれらからコマンドを生成します。

```ts
import * as vscode from 'vscode';
import * as childProcess from 'child_process';
import * as path from 'path';

export function activate(context: vscode.ExtensionContext) {

	// slotchmodコマンドを登録
	const disposable = vscode.commands.registerCommand(
		'slotchmod-for-vscode.slotchmod',
		async () => {

			// ユーザーにディレクトリを選択させる
			const uri = await vscode.window.showOpenDialog({
				canSelectFolders: true,
				canSelectFiles: true,
				canSelectMany: false,
				openLabel: 'Select Directory or File'
			});

			if (!uri) { return; }

			const targetPath = uri[0].path;

			// WebViewを開く
			const panel = vscode.window.createWebviewPanel(
				'slotchmod',
				`🎰 ${uri[0].path}`,
				vscode.ViewColumn.One,
				{
					enableScripts: true
				}
			);

			// WebViewに表示するHTMLを生成
			panel.webview.html = getWebviewContent(panel.webview, context.extensionUri);

			// WebViewがメッセージを受信した時の処理
			panel.webview.onDidReceiveMessage(
				message => {
					switch (message.command) {
						case 'slotchmod':
                            // reelの状態を配列として取得
							const reelStates = message.payload.reelStates as number[];
       
                            // 文字列に変換
							const chmodPermissions = reelStates.join('');

							// ゾロ目かどうかを判定
							const isZorome = new Set(reelStates).size === 1;

							// consoleのコマンドを生成する
							const command = `chmod ${chmodPermissions} ${targetPath}`;

							// コマンドを実行
							childProcess.exec(command, (error, stdout, stderr) => {
								// エラーが無ければ実行した事を通知
								if (!error) {
                                    // エラーがなければインフォメーションメッセージを表示
									if (isZorome) {
										vscode.window.showInformationMessage(`🎉🎉🎉 おめでとう！！ ${command} を実行しました！！ 🎉🎉🎉`);
									} else {
										vscode.window.showInformationMessage(`${command} を実行しました`);
									}
								}
    							else {
            						vscode.window.showInformationMessage('エラーが発生しました');
									console.error(`exec error: ${error}`);
									return;
								}
							});
							return;
					}
				},
				undefined,
				context.subscriptions
			);

			// WebViewにtargetPathを送信
			panel.webview.postMessage({ command: 'setTargetPath', targetPath });
		});

	context.subscriptions.push(disposable);
}

// Webview側で使用できるようにuriに変換する関数
function getUri(
	webview: vscode.Webview,
	extensionUri: vscode.Uri,
	pathList: string[]
) {
	// vscode.Uri.joinPath は、利用できなくなったため、path.join を使用
	return webview.asWebviewUri(vscode.Uri.file(path.join(extensionUri.fsPath, ...pathList)));
}

// Nonceを生成する関数
function getNonce() {
	let text = "";
	const possible =
		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
	for (let i = 0; i < 32; i++) {
		text += possible.charAt(Math.floor(Math.random() * possible.length));
	}
	return text;
}

// WebViewに表示するHTMLを生成
function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri) {
	const webviewUri = getUri(webview, extensionUri, ["dist", "webview.js"]);
	const nonce = getNonce();

	return `<!DOCTYPE html>
                <html lang="en">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>Cat Coding</title>
                </head>
                <body>
                    <div id="app"></div>
                    <script type="module" nonce="${nonce}" src="${webviewUri}"></script>
                </body>
            </html>`;
}

// This method is called when your extension is deactivated
export function deactivate() { }
```

## 実際に実行してみよう！

私はいつもどおりDevcontainer上で実行しているので、chmodで開けないファイルが出てもあまり問題出ないので思いっきりポチポチしてみます。

コマンドから`Slot chmod`を探して実行していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/6ae5532f-acd4-3513-6a0e-d685c5f455e5.png)

するとCHMODを行う対象を選択させられるのでポチポチします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/06aca2e5-3a04-df87-2740-c6c9fbc5ba02.png)

するとリールが回り始めるのでStopを押していくと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/2e18cf68-4a16-9ccf-53da-76531d10c559.png)

`chmod`を実行してくれます！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1239781/206f2a62-cb63-6275-d188-90ffd8a2febc.png)

実際にパーミッションが変更されているのが確認できますね。

```bash
$ ls -la

total 20
drwxr-xr-x 1 root root 4096 Jul 23 07:15 .
drwxr-xr-x 1 root root 4096 Dec  8 11:40 ..
d---rw---- 1 node node 4096 Dec  8 13:38 node
```

## まとめ

今回作成した拡張機能はGitHubで公開していますので、興味がある方は遊んでみてください。

https://github.com/nikawamikan/slotchmod-for-vscode

ネタ枠でアプリを作成したいなーとずっと思っていたので今回のネタが出来て満足でした。

おしまい。