Part 2 of 5: From Hobby Project to Enterprise-Grade Extension
After my "aha moment" about solving the daily file size frustration, I faced a crucial decision: Build it quick and dirty, or build it right? π€
π TL;DR
Building File Insights taught me that good architecture is the difference between a weekend hack and professional software. Using a Manager-Service pattern with TypeScript strict mode and proper resource management transformed a "simple" file size display into an enterprise-grade VS Code extension.
Key Architecture Decisions:
- ποΈ Manager-Service pattern for clean separation of concerns
- π‘οΈ TypeScript strict mode with Result types for bulletproof error handling
- β‘ Debounced updates and resource management for zero performance impact
- π Live configuration updates without VS Code restarts
I'd seen too many promising projects die slow deaths because they couldn't scale beyond their initial implementation. So I made a commitment that would define everything about File Insights: If I'm going to build this, I'm going to build it like it matters.
The Approaches That Failed First π
Before I landed on the current architecture, I tried several approaches that seemed "simpler":
β Attempt 1: Everything in extension.ts
// This was my first naive attempt
export function activate(context: vscode.ExtensionContext) {
let statusBarItem = vscode.window.createStatusBarItem();
vscode.window.onDidChangeActiveTextEditor(() => {
// 200 lines of mixed concerns here...
// UI logic, file operations, configuration, error handling
// It became unreadable FAST
});
}
Result: Unmaintainable mess after adding just 3 features
β Attempt 2: Class-based but still mixed concerns
class FileInsights {
updateDisplay() {
// Still mixing UI, business logic, and file operations
const stats = this.getFileStats(); // File operation
const formatted = this.formatSize(); // Business logic
this.statusBar.text = formatted; // UI operation
// When any layer changed, everything broke
}
}
Result: Brittle code that broke with every new feature
The Architecture Philosophy: SOLID Foundations πͺ
Why Architecture Matters for "Simple" Extensions
Many developers think: "It's just a VS Code extension that shows file sizesβhow complex can it be?"
Famous last words. π
Even simple tools can become maintenance nightmares without proper architecture. I wanted File Insights to be:
- Maintainable: Easy to add features without breaking existing functionality
- Testable: Every component isolated and verifiable
- Scalable: Ready for features I haven't even imagined yet
- Professional: Code quality that makes me proud, not embarrassed
The Breakthrough: Manager-Service Pattern π―
After those failures, I realized I needed true separation of concerns. Enter the Manager-Service pattern that saved File Insights:
src/
βββ extension.ts # Entry point (minimal, clean)
βββ managers/
β βββ extensionManager.ts # Orchestration & lifecycle
β βββ statusBarManager.ts # UI-specific logic
βββ services/
β βββ configurationService.ts # Settings management
β βββ fileService.ts # File system operations
βββ utils/
β βββ formatter.ts # Size formatting logic
β βββ logger.ts # Structured logging
βββ types/
βββ extension.ts # Core interfaces
βββ common.ts # Shared types
Why this architecture works:
- π¦ Single Responsibility: Each class has one job and does it well
- π Clear Boundaries: Managers coordinate, Services execute, Utils assist
- π§ͺ Testable: Every component can be tested in isolation
- π Maintainable: Adding features doesn't break existing functionality
- π Scalable: Easy to add new managers or services as needed
No more "god classes" that do everything! π
The Heart of the System: ExtensionManager π
The ExtensionManager
is the conductor of our orchestra. It doesn't do the workβit coordinates everyone else:
export class ExtensionManager {
private statusBarManager: StatusBarManager;
private config: FileInsightsConfig;
private disposables: vscode.Disposable[] = [];
private updateTimeout: NodeJS.Timeout | null = null;
constructor(context: vscode.ExtensionContext) {
// 1. Get configuration
this.config = ConfigurationService.getConfiguration();
// 2. Initialize UI
this.statusBarManager = new StatusBarManager(this.config);
// 3. Register everything
this.registerCommands(context);
this.registerEventListeners();
this.registerConfigurationListener();
// 4. Show initial state
this.updateFileStats();
}
}
Real-world impact: When I added the "Show Details" command, it took 15 minutes instead of hours because the architecture told me exactly where everything belonged:
- Command registration β
ExtensionManager.registerCommands()
- UI logic β
StatusBarManager.showFileDetails()
- File operations β
FileService.getFileStats()
- No existing code touched! β¨
The UI Layer: StatusBarManager π¨
The StatusBarManager
owns everything about the status bar display:
export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem | null = null;
private config: FileInsightsConfig;
updateFileStats(stats: FileStats | null): void {
if (!this.config.enabled || !stats) {
this.hide();
return;
}
// Size limit check
if (stats.size > this.config.maxFileSize) {
this.showMessage('File too large to analyze');
return;
}
// Format and display
const formattedSize = SizeFormatter.formatSize(stats.size, this.config);
this.showFileSize(formattedSize, stats);
}
}
Isolation success story: When VS Code changed their status bar API in version 1.94, I only had to update 12 lines in StatusBarManager
. Zero changes to business logic, file operations, or configuration. The architecture protected me from breaking changes! π
Features this enabled:
- π¨ Theme support without touching core logic
- π± Different status bar positions as simple config change
- β¨ Future animation support already architected
- π Status bar recreation on position change
Data Layer: Services That Actually Serve π
ConfigurationService: Settings Made Simple
export class ConfigurationService {
static getConfiguration(): FileInsightsConfig {
const config = vscode.workspace.getConfiguration('fileInsights');
return {
enabled: config.get('enabled', true),
displayFormat: config.get('displayFormat', 'auto'),
statusBarPosition: config.get('statusBarPosition', 'right'),
showTooltip: config.get('showTooltip', true),
refreshInterval: config.get('refreshInterval', 500),
maxFileSize: config.get('maxFileSize', 1073741824) // 1GB
};
}
}
Live updates breakthrough: This architecture enabled something most extensions can't do - true real-time configuration:
// When user changes ANY setting, everything updates instantly
static onDidChangeConfiguration(callback: (config: FileInsightsConfig) => void): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('fileInsights')) {
const newConfig = this.getConfiguration();
callback(newConfig); // Triggers cascade update
}
});
}
User experience: Change display format from "auto" to "KB" β see effect immediately. Change status bar position β seamless transition. No restart required! π
FileService: The Heavy Lifter
export class FileService {
static async getFileStats(uri?: vscode.Uri): Promise<Result<FileStats, string>> {
try {
const activeEditor = vscode.window.activeTextEditor;
const fileUri = uri || activeEditor?.document.uri;
if (!fileUri || fileUri.scheme !== 'file') {
return { success: false, error: 'No valid file URI provided' };
}
const stats = statSync(fileUri.fsPath);
return {
success: true,
data: {
size: stats.size,
path: fileUri.fsPath,
lastModified: stats.mtime
}
};
} catch (error: unknown) {
return { success: false, error: 'Failed to access file' };
}
}
}
Error handling evolution: I initially used try/catch everywhere. Disaster! π±
// This was a nightmare to debug
try {
const stats = getFileStats();
updateDisplay(stats);
} catch (error) {
// What failed? File access? Display update? Who knows!
console.log('Something broke');
}
Solution: Result pattern
// Now every operation is explicit about success/failure
const result = await FileService.getFileStats(uri);
if (result.success) {
this.statusBarManager.updateFileStats(result.data);
} else {
this.logger.warn('File access failed', result.error);
this.statusBarManager.hide();
}
Benefits: No silent failures, predictable control flow, better debugging! β
The Utilities: Small but Mighty π§
SizeFormatter: Making Bytes Human-Readable
export class SizeFormatter {
static formatSize(bytes: number, config: FileInsightsConfig): FormattedSize {
if (bytes === 0) {
return { value: 0, unit: 'B', formatted: '0 B' };
}
// Handle forced format (bytes, kb, mb)
if (config.displayFormat !== 'auto') {
return this.formatToSpecificUnit(bytes, config.displayFormat, config);
}
// Auto format based on size
const unitIndex = Math.floor(Math.log(bytes) / Math.log(1024));
const clampedIndex = Math.max(0, Math.min(unitIndex, 4)); // B, KB, MB, GB, TB
const value = bytes / Math.pow(1024, clampedIndex);
const unit = ['B', 'KB', 'MB', 'GB', 'TB'][clampedIndex];
const decimals = clampedIndex === 0 ? 0 : 2;
const formattedValue = Number(value.toFixed(decimals));
return {
value: formattedValue,
unit,
formatted: `${formattedValue} ${unit}`
};
}
}
Smart formatting insights: This took 6 iterations to get right!
// Failed attempts:
// v1: Always show decimals β "1.00 B" looked weird
// v2: Round everything β Lost precision on edge cases
// v3: Fixed 2 decimals β "1536.00 B" was overwhelming
// Final solution: Context-aware precision
const decimals = clampedIndex === 0 ? 0 : 2;
// Bytes = no decimals, everything else = 2 decimals
User psychology: 1,048,576 bytes becomes "1 MB", but 1,024 bytes stays "1024 B". Small files need precision, large files need readability. π―
Logger: Debugging Made Beautiful
export class Logger {
private context: string;
constructor(context: string) {
this.context = context;
}
info(message: string, ...args: LoggableValue[]): void {
this.log('INFO', message, ...args);
}
private log(level: LogLevel, message: string, ...args: LoggableValue[]): void {
const timestamp = new Date().toISOString();
const contextStr = picocolors.gray(`[${this.context}]`);
const levelStr = this.colorizeLevel(level);
const timestampStr = picocolors.gray(`[${timestamp}]`);
console.log(`${timestampStr} ${levelStr} ${contextStr} ${message}`);
}
}
Debugging game-changer: Before structured logging, bug reports were impossible to debug:
// Old way - useless for debugging
console.log('Error occurred');
// New way - tells the whole story
[2024-12-15T10:30:45.123Z] [ERROR] [FileService] Failed to access file: Permission denied /Users/dev/secret.txt
[2024-12-15T10:30:45.124Z] [INFO] [StatusBarManager] Hiding status bar due to file access error
Real impact: User reports went from "It's broken" to "Here's the exact error log from the Output Channel". Debugging time: 3 hours β 15 minutes. π
TypeScript: The Foundation of Confidence π
Every interface, every type, every parameter is explicitly defined:
// Core configuration interface
export interface FileInsightsConfig {
enabled: boolean;
displayFormat: 'auto' | 'bytes' | 'kb' | 'mb';
statusBarPosition: 'left' | 'right';
showTooltip: boolean;
refreshInterval: number;
maxFileSize: number;
}
// File statistics interface
export interface FileStats {
size: number;
path: string;
lastModified: Date;
}
// Result pattern for error handling
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
TypeScript discipline: Strict mode caught 47 bugs before users ever saw them:
// This would compile with loose TypeScript but fail at runtime
function formatSize(bytes: any): string { // β 'any' type
return bytes.toString(); // β What if bytes is undefined?
}
// Strict mode forces explicit contracts
function formatSize(bytes: number, config: FileInsightsConfig): FormattedSize {
// β
Compiler guarantees type safety
// β
Runtime failures become impossible
}
Strict mode rules I enforce:
- β No
any
types allowed - β No implicit returns
- β No unsafe property access
- β Every function has explicit types
- β All error cases handled
Result: If it compiles, it works. Period. π‘οΈ
Development Workflow: Professional Grade π
Build System: Webpack + TypeScript
// webpack.config.js (simplified)
module.exports = {
target: 'node',
entry: './src/extension.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'extension.js',
libraryTarget: 'commonjs2'
},
module: {
rules: [{
test: /\.ts$/,
exclude: /node_modules/,
use: 'ts-loader'
}]
},
externals: {
vscode: 'commonjs vscode'
}
};
Single bundle: The entire extension ships as one optimized extension.js
file. Faster loading, simpler deployment. β‘
Code Quality: ESLint + Prettier
{
"scripts": {
"lint": "eslint src --ext ts",
"format": "prettier --write .",
"compile": "webpack",
"watch": "webpack --watch",
"package": "webpack --mode production"
}
}
Consistent quality: Every commit is linted and formatted. No more bike-shedding about code style! π¨
Performance: Every Millisecond Matters β‘
Debounced Updates
private scheduleUpdate(): void {
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
}
this.updateTimeout = setTimeout(() => {
this.updateFileStats();
}, this.config.refreshInterval);
}
Why this matters: When you're typing rapidly, File Insights doesn't spam the file system. Updates are batched intelligently. π§
Large File Protection
if (stats.size > this.config.maxFileSize) {
this.showMessage('File too large to analyze');
return;
}
User configurable: Default 1GB limit, but power users can adjust. No crashes from analyzing massive files! πͺ
Lessons Learned: Architecture in Practice π
What Worked Brilliantly β¨
- Manager-Service Pattern: Adding new features is a joy, not a nightmare
- TypeScript Strict Mode: Caught dozens of bugs before they reached users
- Result Pattern: Error handling became predictable and testable
- Separation of Concerns: UI, business logic, and data access never mix
What I'd Do Differently π€
- More Granular Interfaces: Some interfaces grew a bit too large
- Better Dependency Injection: Would make testing even easier
- Event-Driven Architecture: Could reduce coupling between managers
The Human Side π
Building this architecture wasn't just about codeβit was about future me. Every design decision was made thinking: "Will I understand this in 6 months? Will I be able to extend it without breaking things?"
The answer, looking back after multiple feature releases, is a resounding YES! π
What's Next? πΊοΈ
In Part 3, we'll dive deep into the features that make File Insights specialβfrom smart size formatting to real-time updates to the command palette integration.
Part 4 will cover the technical challenges and creative solutions, including performance optimization and error handling.
Part 5 wraps up with testing strategies, performance considerations, and the roadmap ahead.
Architecture nerds unite! π€ What's your favorite pattern for VS Code extensions? Share your thoughts in the comments!
π **Useful Links:**
π **Next up: Part 3 - Feature Deep-Dive: The Magic Behind the User Experience
Building quality software takes time and care. If File Insights has made your development workflow smoother, please consider β starring the repository! π
Top comments (0)