Skip to content

Instantly share code, notes, and snippets.

@PrashamTrivedi
Created March 13, 2025 08:26
Show Gist options
  • Save PrashamTrivedi/b671efab22c61eef5555a6d2963dbd5b to your computer and use it in GitHub Desktop.
Save PrashamTrivedi/b671efab22c61eef5555a6d2963dbd5b to your computer and use it in GitHub Desktop.
Workspace Manager for VS Code - A web interface to manage workspace files
#!/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);
@PrashamTrivedi
Copy link
Author

Way to run it

workspaceManager.ts --port {YourPort} --file SOMETHING.code-workspace

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment