Created
March 13, 2025 08:26
-
-
Save PrashamTrivedi/b671efab22c61eef5555a6d2963dbd5b to your computer and use it in GitHub Desktop.
Workspace Manager for VS Code - A web interface to manage workspace files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S deno run -A | |
/* | |
Workspace Manager Web Interface | |
This script creates a web server that provides a UI for managing VS Code workspace files. | |
Features: | |
- Enable/disable workspace folders | |
- Add new folders with automatic TypeScript project detection | |
- Configure server port via environment variable or command line argument | |
- Configure workspace file path via command line argument | |
- HTMX for dynamic updates | |
- Tailwind CSS for styling | |
Usage: | |
PORT=3000 ./workspace_manager.ts | |
# or | |
./workspace_manager.ts --port 3000 --file /path/to/workspace.code-workspace | |
Required permissions: | |
--allow-net: For running the web server | |
--allow-read: For reading workspace file | |
--allow-write: For updating workspace file | |
*/ | |
import { join } from "jsr:@std/path"; | |
import { exists } from "jsr:@std/fs"; | |
import { parseArgs } from "jsr:@std/cli/parse-args"; | |
import { parse } from "jsr:@std/jsonc"; | |
// Types for workspace configuration | |
interface WorkspaceFolder { | |
name: string; | |
path: string; | |
} | |
interface EslintWorkingDirectory { | |
directory: string; | |
changeProcessCWD: boolean; | |
} | |
interface WorkspaceConfig { | |
folders: WorkspaceFolder[]; | |
excludedFolders: WorkspaceFolder[]; | |
settings: { | |
"eslint.workingDirectories": EslintWorkingDirectory[]; | |
[key: string]: unknown; | |
}; | |
extensions?: { | |
recommendations: string[]; | |
}; | |
} | |
// Add this after the WorkspaceConfig interface | |
const PROTECTED_DIRECTORIES = [ | |
".vscode", | |
".devcontainer", // Example protected directory | |
"taskNotes", | |
"scripts", | |
".claude", | |
"aiderHistory", | |
]; | |
// Parse command line arguments | |
const flags = parseArgs(Deno.args, { | |
string: ["port", "file"], | |
default: { | |
port: Deno.env.get("PORT") || "3000", | |
file: "workspace.code-workspace", | |
}, | |
}); | |
const PORT = parseInt(flags.port); | |
const WORKSPACE_FILE = flags.file; | |
// HTML template with Tailwind CSS | |
const BASE_HTML = ` | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Workspace Manager</title> | |
<script src="https://unpkg.com/[email protected]"></script> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script> | |
tailwind.config = { | |
darkMode: 'media' | |
} | |
function initializeSearch() { | |
const searchInput = document.getElementById('folderSearch'); | |
const clearSearch = document.getElementById('clearSearch'); | |
let searchTimeout; | |
function filterFolders() { | |
const searchValue = searchInput.value.toLowerCase(); | |
const folders = document.querySelectorAll('.folder-item'); | |
folders.forEach(folder => { | |
const name = folder.querySelector('.folder-name').textContent.toLowerCase(); | |
const path = folder.querySelector('.folder-path').textContent.toLowerCase(); | |
if (searchValue === '' || name.includes(searchValue) || path.includes(searchValue)) { | |
folder.classList.remove('hidden'); | |
} else { | |
folder.classList.add('hidden'); | |
} | |
}); | |
} | |
searchInput?.addEventListener('input', (e) => { | |
clearTimeout(searchTimeout); | |
if (e.target.value.length === 0 || e.target.value.length >= 3) { | |
searchTimeout = setTimeout(filterFolders, 300); | |
} | |
}); | |
clearSearch?.addEventListener('click', () => { | |
searchInput.value = ''; | |
filterFolders(); | |
}); | |
} | |
function toggleSection(sectionId) { | |
const section = document.getElementById(sectionId); | |
const icon = document.getElementById(sectionId + '-icon'); | |
if (section.classList.contains('section-collapsed')) { | |
// Calculate the content height first | |
section.classList.remove('section-collapsed'); | |
section.style.maxHeight = section.scrollHeight + 'px'; | |
icon.textContent = '▼'; | |
// After animation completes, set to 'auto' to handle dynamic content changes | |
setTimeout(() => { | |
if (!section.classList.contains('section-collapsed')) { | |
section.style.maxHeight = 'none'; | |
} | |
}, 300); | |
} else { | |
// Set fixed height before collapsing to enable animation | |
section.style.maxHeight = section.scrollHeight + 'px'; | |
// Force browser to recognize the above style before changing it | |
setTimeout(() => { | |
section.classList.add('section-collapsed'); | |
section.style.maxHeight = '0'; | |
icon.textContent = '▶'; | |
}, 10); | |
} | |
} | |
document.addEventListener('DOMContentLoaded', initializeSearch); | |
document.addEventListener('htmx:afterSettle', initializeSearch); | |
</script> | |
<style> | |
.folder-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); | |
gap: 1rem; | |
} | |
.folders-container { | |
height: 600px; | |
overflow: hidden; | |
} | |
.folders-scroll { | |
height: calc(100% - 2rem); | |
overflow-y: auto; | |
} | |
.section-header { | |
cursor: pointer; | |
user-select: none; | |
padding: 8px; | |
border-radius: 4px; | |
transition: background-color 0.2s; | |
} | |
.section-header:hover { | |
background-color: rgba(0, 0, 0, 0.05); | |
} | |
.dark .section-header:hover { | |
background-color: rgba(255, 255, 255, 0.1); | |
} | |
.toggle-icon { | |
display: inline-flex; | |
align-items: center; | |
justify-content: center; | |
width: 24px; | |
height: 24px; | |
border-radius: 50%; | |
background-color: rgba(59, 130, 246, 0.1); | |
color: #3b82f6; | |
margin-right: 8px; | |
font-size: 14px; | |
transition: all 0.2s; | |
} | |
.dark .toggle-icon { | |
background-color: rgba(96, 165, 250, 0.2); | |
color: #60a5fa; | |
} | |
/* Animation styles */ | |
.collapsible-section { | |
overflow: hidden; | |
transition: max-height 0.3s ease-in-out; | |
} | |
.section-collapsed { | |
max-height: 0 !important; | |
} | |
</style> | |
</head> | |
<body class="bg-gray-100 dark:bg-gray-900 min-h-screen p-4 md:p-8"> | |
<div class="max-w-6xl mx-auto"> | |
<h1 class="text-3xl font-bold mb-8 text-gray-800 dark:text-white">Workspace Manager</h1> | |
<div id="content"> | |
{{CONTENT_PLACEHOLDER}} | |
</div> | |
</div> | |
</body> | |
</html> | |
`; | |
async function readWorkspaceConfig(): Promise<WorkspaceConfig> { | |
const content = await Deno.readTextFile(WORKSPACE_FILE); | |
return parse(content); | |
} | |
async function writeWorkspaceConfig(config: WorkspaceConfig): Promise<void> { | |
await Deno.writeTextFile( | |
WORKSPACE_FILE, | |
JSON.stringify(config, null, 2), | |
); | |
} | |
async function _isTypeScriptProject(path: string): Promise<boolean> { | |
return await exists(join(path, "tsconfig.json")) || | |
await exists(join(path, "package.json")); | |
} | |
function renderFolderList(config: WorkspaceConfig): string { | |
// Separate protected and non-protected folders | |
const [protectedFolders, normalFolders] = config.folders.reduce( | |
([protectedFolders, normal], folder) => { | |
const isProtected = PROTECTED_DIRECTORIES.some( | |
(dir) => folder.path.startsWith(dir) || folder.path === dir, | |
); | |
return isProtected | |
? [[...protectedFolders, folder], normal] | |
: [protectedFolders, [...normal, folder]]; | |
}, | |
[[] as WorkspaceFolder[], [] as WorkspaceFolder[]], | |
); | |
const renderFolder = (folder: WorkspaceFolder, isProtected: boolean) => ` | |
<div class="folder-item flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors group hover:shadow-md"> | |
<div class="flex-1 min-w-0 mr-4"> | |
<div class="folder-name font-medium text-gray-800 dark:text-white truncate" title="${folder.name}"> | |
${folder.name}${isProtected ? " (Protected)" : ""} | |
</div> | |
<div class="folder-path text-sm text-gray-500 dark:text-gray-400 truncate" title="${folder.path}"> | |
${folder.path} | |
</div> | |
</div> | |
${ | |
!isProtected | |
? ` | |
<button | |
hx-post="/toggle/${encodeURIComponent(folder.path)}/exclude" | |
hx-target="#content" | |
class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white" | |
> | |
Exclude | |
</button> | |
` | |
: "" | |
} | |
</div> | |
`; | |
const protectedFoldersHtml = protectedFolders.map((folder) => | |
renderFolder(folder, true) | |
).join(""); | |
const normalFoldersHtml = normalFolders.map((folder) => | |
renderFolder(folder, false) | |
).join(""); | |
const excludedFolders = (config.excludedFolders || []).map((folder) => ` | |
<div class="folder-item flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow transition-colors group hover:shadow-md"> | |
<div class="flex-1 min-w-0 mr-4"> | |
<div class="folder-name font-medium text-gray-400 dark:text-gray-500 truncate" title="${folder.name}"> | |
${folder.name} | |
</div> | |
<div class="folder-path text-sm text-gray-400 dark:text-gray-500 truncate" title="${folder.path}"> | |
${folder.path} | |
</div> | |
</div> | |
<button | |
hx-post="/toggle/${encodeURIComponent(folder.path)}/include" | |
hx-target="#content" | |
class="px-4 py-2 rounded bg-green-500 hover:bg-green-600 text-white" | |
> | |
Include | |
</button> | |
</div> | |
`).join(""); | |
return ` | |
<div class="mb-4"> | |
<div class="relative"> | |
<input | |
type="text" | |
id="folderSearch" | |
placeholder="Search folders..." | |
class="w-full p-2 pr-10 border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white" | |
/> | |
<button | |
id="clearSearch" | |
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" | |
> | |
✕ | |
</button> | |
</div> | |
</div> | |
<div class="grid md:grid-cols-2 gap-8 mb-8"> | |
<div class="folders-container"> | |
<div class="mb-6"> | |
<div class="section-header flex items-center mb-4" onclick="toggleSection('protectedFolders')"> | |
<span id="protectedFolders-icon" class="toggle-icon">▼</span> | |
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">Protected Folders</h2> | |
</div> | |
<div id="protectedFolders" class="collapsible-section space-y-2"> | |
${protectedFoldersHtml} | |
</div> | |
</div> | |
<div> | |
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-white">Active Folders</h2> | |
<div class="folders-scroll space-y-2 pr-2"> | |
${normalFoldersHtml} | |
</div> | |
</div> | |
</div> | |
<div class="folders-container"> | |
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-white">Excluded Folders</h2> | |
<div class="folders-scroll space-y-2 pr-2"> | |
${excludedFolders} | |
</div> | |
</div> | |
</div> | |
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 transition-colors"> | |
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-white">Add New Folder</h2> | |
<form hx-post="/add" hx-target="#content"> | |
<div class="grid md:grid-cols-3 gap-4 mb-4"> | |
<input | |
type="text" | |
name="name" | |
placeholder="Folder Name" | |
class="flex-1 p-2 border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white" | |
required | |
/> | |
<input | |
type="text" | |
name="path" | |
placeholder="Folder Path" | |
class="flex-1 p-2 border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white" | |
required | |
/> | |
<select name="status" class="p-2 border rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white"> | |
<option value="active">Active</option> | |
<option value="excluded">Excluded</option> | |
</select> | |
</div> | |
<div class="flex flex-wrap items-center gap-6 mb-4"> | |
<div class="flex items-center gap-2"> | |
<input | |
type="checkbox" | |
id="eslint" | |
name="eslint" | |
class="rounded border-gray-300" | |
/> | |
<label for="eslint" class="text-gray-800 dark:text-white">Add to ESLint working directories</label> | |
</div> | |
<div class="flex items-center gap-2"> | |
<input | |
type="checkbox" | |
id="protected" | |
name="protected" | |
class="rounded border-gray-300" | |
/> | |
<label for="protected" class="text-gray-800 dark:text-white">Mark as protected</label> | |
</div> | |
</div> | |
<button | |
type="submit" | |
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" | |
> | |
Add Folder | |
</button> | |
</form> | |
</div> | |
`; | |
} | |
async function handleRequest(req: Request): Promise<Response> { | |
const url = new URL(req.url); | |
try { | |
const config = await readWorkspaceConfig(); | |
if (req.method === "GET" && url.pathname === "/") { | |
const content = renderFolderList(config); | |
return new Response( | |
BASE_HTML.replace("{{CONTENT_PLACEHOLDER}}", content), | |
{ | |
headers: { "Content-Type": "text/html" }, | |
}, | |
); | |
} | |
if (req.method === "POST" && url.pathname.startsWith("/toggle/")) { | |
const [_, __, encodedPath, action] = url.pathname.split("/"); | |
const folderPath = decodeURIComponent(encodedPath); | |
// Add protection check - but only for exclude actions, allow including back | |
const isProtected = PROTECTED_DIRECTORIES.some((dir) => | |
folderPath.startsWith(dir) || folderPath === dir | |
); | |
if (isProtected && action === "exclude") { | |
return new Response("Cannot exclude protected directory", { | |
status: 403, | |
}); | |
} | |
if (action === "exclude") { | |
const folderIndex = config.folders.findIndex((f) => | |
f.path === folderPath | |
); | |
if (folderIndex !== -1) { | |
const folder = config.folders.splice(folderIndex, 1)[0]; | |
config.excludedFolders = config.excludedFolders || []; | |
config.excludedFolders.push(folder); | |
} | |
} else if (action === "include") { | |
const folderIndex = config.excludedFolders?.findIndex((f) => | |
f.path === folderPath | |
) ?? -1; | |
if (folderIndex !== -1) { | |
const folder = config.excludedFolders!.splice(folderIndex, 1)[0]; | |
config.folders.push(folder); | |
} | |
} | |
await writeWorkspaceConfig(config); | |
return new Response(renderFolderList(config), { | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} | |
if (req.method === "POST" && url.pathname === "/add") { | |
const formData = await req.formData(); | |
const name = formData.get("name")?.toString() || ""; | |
const path = formData.get("path")?.toString() || ""; | |
const status = formData.get("status")?.toString() || "active"; | |
const addToEslint = formData.get("eslint") !== null; | |
const isProtected = formData.get("protected") !== null; | |
const newFolder: WorkspaceFolder = { name, path }; | |
// If marked as protected, add to PROTECTED_DIRECTORIES | |
if ( | |
isProtected && !PROTECTED_DIRECTORIES.some((dir) => | |
dir === path || path.startsWith(dir) | |
) | |
) { | |
PROTECTED_DIRECTORIES.push(path); | |
} | |
if (status === "active") { | |
config.folders.push(newFolder); | |
} else if (!isProtected) { | |
// Only add to excluded if not protected | |
config.excludedFolders = config.excludedFolders || []; | |
config.excludedFolders.push(newFolder); | |
} else { | |
// If protected but status is excluded, override and add to active folders | |
config.folders.push(newFolder); | |
} | |
if (addToEslint) { | |
config.settings["eslint.workingDirectories"] = | |
config.settings["eslint.workingDirectories"] || []; | |
config.settings["eslint.workingDirectories"].push({ | |
directory: path, | |
changeProcessCWD: true, | |
}); | |
} | |
await writeWorkspaceConfig(config); | |
return new Response(renderFolderList(config), { | |
headers: { "Content-Type": "text/html" }, | |
}); | |
} | |
return new Response("Not Found", { status: 404 }); | |
} catch (error) { | |
console.error("Error:", error); | |
return new Response("Internal Server Error", { status: 500 }); | |
} | |
} | |
console.log(`Server running on http://localhost:${PORT}`); | |
await Deno.serve({ port: PORT }, handleRequest); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Way to run it
workspaceManager.ts --port {YourPort} --file SOMETHING.code-workspace