ここは文字数稼ぐところ
どうも限界派遣SESです。
ひとりアドカレ8日目の本日はずっとやりたいネタを消化しにきました。
とりあえずネタの説明
本日の元ネタはコレです。
CHMODをスロットで実行できるCLIアプリケーションです。
こーゆーネタっぽいアプリつくりたいなーってずっと思ってたんですよね!
私の大好物です!
とは言いつつも、流石にこれと同じようなものを同じくCLIで実装してしまっては面白くないですよね。
そういうことで、先日私がVSCodeの拡張機能でReact
+ Tailwindcss
を使えるようにしたのはコレをVSCodeの拡張機能としてGUIを実装したかったからです。
コマンドの実装
コマンド実装はWebViewからVSCode側のAPIを呼んだりといろいろと複雑な点が多いので1つずつ見ていきましょう。
パスの受け取り
コマンド実行時には対象となるディレクトリやファイルのパスを受け取る必要があるため、コマンド実行時に以下のプロンプトが出てくれないといけません。
これを実装するためにはextension.ts
のコマンドが呼び出された時にvscode.window.showOpenDialog
を呼ぶようにしてあげるだけで良いです。
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
でメッセージを受け取るイベントを設置してあげます。
// メッセージを受け取って対象のパスを取得するイベント
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
にわたすことが出来ます。
// WebViewにtargetPathを送信
panel.webview.postMessage({ command: 'setTargetPath', targetPath });
回転するリールの作成
chmod
を実行するために3つのリール1が必要になります。
そのため、components
ディレクトリを作成してreel.tsx
とreel.css
を作っていきましょう。
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を記述しています。
できたら申し訳ない。
.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
で行っています。
なお、ゾロ目になった際はお祝いムードにしたかったのでゲーミング背景になるようにしてみました。
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をローテーションさせることでそれっぽくしています。
@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
を探して実行していきます。
するとCHMODを行う対象を選択させられるのでポチポチします。
するとリールが回り始めるのでStopを押していくと、
chmod
を実行してくれます!
実際にパーミッションが変更されているのが確認できますね。
$ 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で公開していますので、興味がある方は遊んでみてください。
ネタ枠でアプリを作成したいなーとずっと思っていたので今回のネタが出来て満足でした。
おしまい。
-
スロットの回ってるやつの名前(今まで知らんかった) ↩