【VS Code】Live Previewを最強にする、1ファイル完結型の爆速・軽量「プロジェクトナビゲーター」の構築方法

最終更新:

VS Codeの拡張機能「Live Preview」を使ってWebサイト制作やドキュメント管理をする際、標準のディレクトリ一覧が少し物足りないと感じたことはありませんか?

外部ライブラリを一切使わず、HTML、CSS、Node.jsの標準機能だけで動く、極限まで無骨でセマンティックな「マイ・プロジェクトナビゲーター」を構築する方法を紹介します。

💡 このツールの特徴

  • 超軽量&爆速: 外部のCSSフレームワークやJavaScriptライブラリは一切不使用。ブラウザ標準の等幅フォント(monospace)を活かした無骨なデザイン。
  • 1ファイル完結のデータ保持: 面倒な別ファイルへのデータ書き出しは不要。Node.jsが直接index.htmlの内部変数を上書き同期します。
  • 強力なセッション復元: ページをリロードしたり、ファイルを編集してLive Previewが自動更新されても、さっきまで開いていたフォルダの展開状態やタブの選択状態がそのまま維持されます。
  • システム連動テーマ: OSやブラウザのライトモード/ダークモード設定を自動検知して配色が切り替わります。
  • 画面幅に合わせた全幅表示: スマホサイズや画面分割で幅が狭くなると、自動で左右の余白を削り、エディタのような100%全幅表示になります。

🛠️ インストール・設定手順

作業スペース(ワークスペース)のルートディレクトリに、以下の 2つのファイル を作成するだけで完了します。

1. index.html の作成

ワークスペースのルートに index.html を作成し、以下のコードを貼り付けます。

.txt
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>プロジェクトナビゲーター</title>
    <style>
        :root {
            --bg-color: #ffffff;
            --text-color: #000000;
            --border-color: #000000;
            --sub-border-color: #cccccc;
            --meta-color: #666666;
            --link-color: #0000ee;
            --hover-bg: #f0f0f0;
            --weblink-hover-bg: #eef2ff;
        }

        @media (prefers-color-scheme: dark) {
            :root {
                --bg-color: #000000;
                --text-color: #ffffff;
                --border-color: #ffffff;
                --sub-border-color: #444444;
                --meta-color: #aaaaaa;
                --link-color: #99ccff;
                --hover-bg: #222222;
                --weblink-hover-bg: #001144;
            }
        }

        body {
            font-family: monospace, sans-serif;
            line-height: 1.5;
            background-color: var(--bg-color);
            color: var(--text-color);
            padding: 2rem;
            max-width: 900px;
            margin: 0 auto;
        }

        @media (max-width: 600px) {
            body {
                padding: 2rem 0;
            }
            header, .tabs {
                padding-left: 0.5rem;
                padding-right: 0.5rem;
            }
        }

        header {
            border-bottom: 1px solid var(--border-color);
            margin-bottom: 0.5rem;
            padding-bottom: 0.5rem;
        }
        h1 {
            font-size: 1.25rem;
            margin: 0;
        }
        .workspace-path {
            font-size: 0.85rem;
            color: var(--meta-color);
            margin: 0 0 0 0;
            word-break: break-all;
        }

        .tabs {
            margin: 1rem 0;
            font-size: 0.9rem;
        }
        .tabs button.tab-btn {
            display: inline;
            width: auto;
            padding: 0.2rem 0.5rem;
            color: var(--meta-color);
        }
        .tabs button.tab-btn.active {
            color: var(--text-color);
            font-weight: bold;
            text-decoration: underline;
        }
        
        ul {
            list-style-type: none;
            padding-left: 0;
            margin: 0;
        }
        li {
            margin: 0;
            padding: 0;
        }
        ul ul {
            padding-left: 1.25rem;
            border-left: 1px dashed var(--sub-border-color); 
            overflow: hidden;
            max-height: 0;
            transition: max-height 0.2s ease-out;
        }
        ul ul.expanded {
            max-height: 2000px; 
            transition: max-height 0.3s ease-in;
        }
        
        .tree-row {
            display: flex;
            align-items: stretch;
            width: 100%;
            margin: 0;
            padding: 0;
        }
        
        button {
            background: none;
            border: none;
            padding: 0.6rem 0.5rem; 
            font-family: inherit;
            font-size: inherit;
            color: var(--text-color);
            cursor: pointer;
            text-align: left;
            flex: 1;
            display: block;
            margin: 0;
        }
        button:hover {
            background-color: var(--hover-bg);
        }
        
        .file-button {
            background: none;
            border: none;
            padding: 0.6rem 0.5rem;
            font-family: inherit;
            font-size: inherit;
            color: var(--text-color);
            text-decoration: none;
            flex: 1;
            display: block;
            margin: 0;
        }
        .file-button:hover {
            background-color: var(--hover-bg);
        }
        .file-link-text {
            color: var(--link-color);
        }
        .file-button:hover .file-link-text {
            text-decoration: underline;
        }
        
        .tree-row a.web-link {
            display: flex;
            align-items: center;
            padding: 0 1rem;
            color: var(--link-color);
            text-decoration: none;
            margin: 0;
        }
        .tree-row a.web-link:hover {
            background-color: var(--weblink-hover-bg);
            text-decoration: underline;
        }

        .meta-info {
            color: var(--meta-color);
            font-size: 0.8rem;
            margin-left: 0.5rem;
            pointer-events: none; 
        }

        #file-tree.mode-web li:not([data-is-web="true"]) {
            display: none;
        }
    </style>
</head>
<body>

    <header>
        <h1 id="title-display"></h1>
        <p class="workspace-path" id="workspace-display"></p>
    </header>

    <div class="tabs" role="tablist" aria-label="表示切り替え">
        <button type="button" id="btn-all" class="tab-btn active" onclick="switchTab('all')">[All Files]</button>
        <button type="button" id="btn-web" class="tab-btn" onclick="switchTab('web')">[Web Sites Only]</button>
    </div>

    <main>
        <nav aria-label="ファイルシステムツリー">
            <ul id="file-tree" class="mode-all"></ul>
        </nav>
    </main>

    <script>
        const PROJECT_TREE = []; // DATA_PLACEHOLDER
        const WORKSPACE_PATH = ""; // PATH_PLACEHOLDER

        window.addEventListener('DOMContentLoaded', () => {
            document.getElementById('workspace-display').textContent = `${WORKSPACE_PATH || 'Unknown'}`;

            if (WORKSPACE_PATH) {
                const pathParts = WORKSPACE_PATH.split('/');
                const lastDir = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2];
                if (lastDir) {
                    document.getElementById('title-display').textContent = lastDir;
                }
            }

            if (PROJECT_TREE && PROJECT_TREE.length > 0) {
                calculateCounts(PROJECT_TREE);
                document.getElementById('file-tree').innerHTML = renderTree(PROJECT_TREE);
                restoreDirState(); 
                
                const savedTab = sessionStorage.getItem('active_tab') || 'all';
                switchTab(savedTab);
            } else {
                document.getElementById('file-tree').innerHTML = '<li>プロジェクトが空か、タスクによる初回データ注入を待っています。</li>';
            }
        });

        function calculateCounts(nodes) {
            nodes.forEach(node => {
                if (node.type === 'directory') {
                    calculateCounts(node.children);

                    let totalDirs = 0;
                    let totalFiles = 0;

                    node.children.forEach(child => {
                        if (child.type === 'directory') {
                            totalDirs += 1 + child.totalDirs;
                            totalFiles += child.totalFiles;
                        } else if (child.type === 'file') {
                            totalFiles += 1;
                        }
                    });

                    node.totalDirs = totalDirs;
                    node.totalFiles = totalFiles;
                }
            });
        }

        function findIndexUrl(node) {
            const directIndex = node.children.find(c => c.type === 'file' && c.name.toLowerCase() === 'index.html');
            if (directIndex) return '/' + directIndex.path;
            
            for (const child of node.children) {
                if (child.type === 'directory' && child.isWeb) {
                    return findIndexUrl(child);
                }
            }
            return '#';
        }

        function renderTree(nodes) {
            if (nodes.length === 0) return '';

            let html = '';
            nodes.forEach(node => {
                if (node.type === 'directory') {
                    const uniqueId = 'dir-' + btoa(unescape(encodeURIComponent(node.path))).replace(/=/g, '');
                    const metaHTML = `<span class="meta-info">(d:${node.totalDirs}/f:${node.totalFiles})</span>`;
                    
                    if (node.isWeb) {
                        const destinationUrl = findIndexUrl(node);
                        html += `
                            <li data-is-web="true">
                                <div class="tree-row">
                                    <button type="button" onclick="toggleDir('${uniqueId}')">/${node.name}${metaHTML}</button>
                                    <a href="${destinationUrl}" class="web-link">[Link]</a>
                                </div>
                                <ul id="${uniqueId}">
                                    ${renderTree(node.children)}
                                </ul>
                            </li>
                        `;
                    } else {
                        html += `
                            <li data-is-web="false">
                                <div class="tree-row">
                                    <button type="button" onclick="toggleDir('${uniqueId}')">/${node.name}${metaHTML}</button>
                                </div>
                                <ul id="${uniqueId}">
                                    ${renderTree(node.children)}
                                </ul>
                            </li>
                        `;
                    }
                } else if (node.type === 'file') {
                    html += `
                        <li data-is-web="false">
                            <div class="tree-row">
                                <a href="/${node.path}" class="button file-button"><span class="file-link-text">${node.name}</span></a>
                            </div>
                        </li>
                    `;
                }
            });
            return html;
        }

        function toggleDir(id) {
            const dir = document.getElementById(id);
            if (!dir) return;
            dir.classList.toggle('expanded');
            
            const isOpen = dir.classList.contains('expanded');
            sessionStorage.setItem(id, isOpen ? 'open' : 'hidden');
        }

        function restoreDirState() {
            document.querySelectorAll('#file-tree ul[id^="dir-"]').forEach(dir => {
                if (sessionStorage.getItem(dir.id) === 'open') {
                    dir.classList.add('expanded');
                }
            });
        }

        function switchTab(mode) {
            const tree = document.getElementById('file-tree');
            const btnAll = document.getElementById('btn-all');
            const btnWeb = document.getElementById('btn-web');

            if (mode === 'web') {
                tree.className = 'mode-web';
                btnAll.classList.remove('active');
                btnWeb.classList.add('active');
            } else {
                tree.className = 'mode-all';
                btnWeb.classList.remove('active');
                btnAll.classList.add('active');
            }
            sessionStorage.setItem('active_tab', mode);
        }
    </script>
</body>
</html>

2. バックグラウンドタスク generate-tree.js の作成

同じくルートディレクトリに generate-tree.js を作成し、以下のコードを貼り付けます。

.txt
const fs = require('fs');
const path = require('path');

// 自身のスクリプトや不要なシステムファイルを除外リストに登録
const IGNORE_LIST = ['.git', 'node_modules', '.vscode', 'generate-tree.js'];
let lastTreeString = "";

function scanDirectory(dirPath, baseDir = dirPath) {
    const result = [];
    let files = [];
    try { files = fs.readdirSync(dirPath); } catch (e) { return result; }

    files.forEach(file => {
        if (IGNORE_LIST.includes(file)) return;
        const fullPath = path.join(dirPath, file);
        const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
        
        try {
            if (fs.statSync(fullPath).isDirectory()) {
                const children = scanDirectory(fullPath, baseDir);
                const hasLocalIndex = children.some(c => c.type === 'file' && c.name.toLowerCase() === 'index.html');
                const hasChildIndex = children.some(c => c.type === 'directory' && c.isWeb);
                const isWeb = hasLocalIndex || hasChildIndex;

                result.push({ 
                    type: 'directory', 
                    name: file, 
                    path: relativePath, 
                    isWeb: isWeb, 
                    children: children 
                });
            } else {
                result.push({ 
                    type: 'file', 
                    name: file, 
                    path: relativePath, 
                    ext: path.extname(file).toLowerCase() 
                });
            }
        } catch (e) {}
    });
    
    return result.sort((a, b) => (b.type === 'directory') - (a.type === 'directory'));
}

function updateIndexHtml() {
    const treeData = scanDirectory(__dirname);
    const currentTreeString = JSON.stringify(treeData);
    const absoluteWorkspacePath = __dirname.replace(/\\/g, '/');
    
    const indexPath = path.join(__dirname, 'index.html');
    if (!fs.existsSync(indexPath)) return;
    
    let indexContent = fs.readFileSync(indexPath, 'utf8');
    let isChanged = false;
    
    // 1. ツリーデータの正規表現書き換え
    const treeRegex = /(const PROJECT_TREE = )([\s\S]*?)(; \/\/ DATA_PLACEHOLDER)/;
    if (treeRegex.test(indexContent)) {
        const matchedTree = indexContent.match(treeRegex)[2];
        if (matchedTree !== currentTreeString) {
            indexContent = indexContent.replace(treeRegex, `$1${currentTreeString}$3`);
            isChanged = true;
        }
    }
    
    // 2. ワークスペース絶対パスの正規表現書き換え
    const pathRegex = /(const WORKSPACE_PATH = ")(.*?)("; \/\/ PATH_PLACEHOLDER)/;
    if (pathRegex.test(indexContent)) {
        const matchedPath = indexContent.match(pathRegex)[2];
        if (matchedPath !== absoluteWorkspacePath) {
            indexContent = indexContent.replace(pathRegex, `$1${absoluteWorkspacePath}$3`);
            isChanged = true;
        }
    }
    
    if (isChanged) {
        fs.writeFileSync(indexPath, indexContent, 'utf8');
        console.log(`[${new Date().toLocaleTimeString()}] 📝 index.html へのデータ同期が完了しました`);
    }
}

updateIndexHtml();

// フォルダ内を監視し、変更があれば100msウェイトでインデックスを自動同期
fs.watch(__dirname, { recursive: true }, (eventType, filename) => {
    if (filename && !IGNORE_LIST.some(item => filename.includes(item)) && filename !== 'index.html') {
        clearTimeout(global.watchTimeout);
        global.watchTimeout = setTimeout(updateIndexHtml, 100);
    }
});

🚀 動かし方

  1. ターミナルでワークスペースのルートに移動し、同期スクリプトを常駐起動させます。
.txt
node generate-tree.js
  1. スクリプトが起動すると、自動的に index.html 内部の変数に現在のPCの絶対パスとツリー構造データが注入されます。
  2. VS Codeの「Live Preview」で index.html を開きます。

これで、無骨で超軽量なあなただけのプロジェクトツリー環境が爆速で立ち上がります!

🛠️ おすすめ:VS Code起動時にタスクを自動化する

毎回コマンドを叩くのが面倒な場合は、.vscode/tasks.json を作成して以下のように設定すると、VS Codeでこのフォルダを開いた瞬間に裏で勝手にスクリプトが常駐動作してくれます。

.txt
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Auto Project Tracker",
            "type": "shell",
            "command": "node generate-tree.js",
            "isBackground": true,
            "problemMatcher": [],
            "runOptions": {
                "runOn": "folderOpen"
            }
        }
    ]
}

📝 終わりに

無駄なアセットや重いフレームワークを削ぎ落とし、CSSセレクタによるフィルタリングを主軸にすることで、数百ファイル規模の環境でも一瞬で切り替わる最高にプレーンなツリーに仕上がりました。Live Previewライフのお供にぜひ役立ててください。