2
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?

Qiitanがほしい人の一人アドカレAdvent Calendar 2024

Day 8

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

Last updated at Posted at 2024-12-08

ここは文字数稼ぐところ

どうも限界派遣SESです。

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

とりあえずネタの説明

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

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

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

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

コマンドの実装

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

パスの受け取り

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

image.png

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

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.tsxuseEffectでメッセージを受け取るイベントを設置してあげます。

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側から呼び出してあげる事でtargetPathApp.tsxにわたすことが出来ます。

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

回転するリールの作成

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

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を記述しています。
できたら申し訳ない。

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で行っています。
なお、ゾロ目になった際はお祝いムードにしたかったのでゲーミング背景になるようにしてみました。

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をローテーションさせることでそれっぽくしています。

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を受け取ってそれらからコマンドを生成します。

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

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

image.png

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

image.png

chmodを実行してくれます!

image.png

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

$ 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で公開していますので、興味がある方は遊んでみてください。

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

おしまい。

  1. スロットの回ってるやつの名前(今まで知らんかった)

2
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
2
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?