From 32e2d502657e925f10a5093e34a33a1b2fd9e490 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 16:52:21 +0000 Subject: [PATCH 1/2] refactor: Implement modular architecture for v2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructured the extension from a single-file architecture to a modular, maintainable codebase. This refactoring is the foundation for v2.0.0. Changes: - Created new src/ directory structure with organized modules - Extracted services: CompressionService, ConfigService - Extracted utilities: errorHandler, fileUtils - Created dedicated command handlers for each command - Defined TypeScript interfaces for type safety - Updated tsconfig.json to compile from src/ directory - Preserved all existing functionality (no breaking changes) Directory structure: src/ ├── commands/ - Command handlers ├── services/ - Core business logic ├── utils/ - Helper functions ├── types/ - TypeScript interfaces └── extension.ts - Main entry point Benefits: - Better code organization and maintainability - Improved testability with separated concerns - Clear separation between UI, business logic, and utilities - Foundation for adding new features (queue system, progress tracking, etc.) - Type safety with proper TypeScript interfaces All existing commands work exactly as before: - Compress single file - Compress folder - Compress git staged files - Get compression count --- extension.ts => extension.ts.old | 0 src/commands/compressFile.ts | 9 ++++ src/commands/compressFolder.ts | 17 +++++++ src/commands/compressGitStage.ts | 64 ++++++++++++++++++++++++ src/commands/getCompressionCount.ts | 14 ++++++ src/extension.ts | 54 ++++++++++++++++++++ src/services/compressionService.ts | 77 +++++++++++++++++++++++++++++ src/services/configService.ts | 49 ++++++++++++++++++ src/types/index.ts | 29 +++++++++++ src/utils/errorHandler.ts | 42 ++++++++++++++++ src/utils/fileUtils.ts | 17 +++++++ tsconfig.json | 2 + 12 files changed, 374 insertions(+) rename extension.ts => extension.ts.old (100%) create mode 100644 src/commands/compressFile.ts create mode 100644 src/commands/compressFolder.ts create mode 100644 src/commands/compressGitStage.ts create mode 100644 src/commands/getCompressionCount.ts create mode 100644 src/extension.ts create mode 100644 src/services/compressionService.ts create mode 100644 src/services/configService.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/errorHandler.ts create mode 100644 src/utils/fileUtils.ts diff --git a/extension.ts b/extension.ts.old similarity index 100% rename from extension.ts rename to extension.ts.old diff --git a/src/commands/compressFile.ts b/src/commands/compressFile.ts new file mode 100644 index 0000000..34b961f --- /dev/null +++ b/src/commands/compressFile.ts @@ -0,0 +1,9 @@ +import { Uri } from 'vscode'; +import { CompressionService } from '../services/compressionService'; + +/** + * Command handler for compressing a single file + */ +export function compressFileCommand(file: Uri): void { + CompressionService.compressImage(file); +} diff --git a/src/commands/compressFolder.ts b/src/commands/compressFolder.ts new file mode 100644 index 0000000..305a924 --- /dev/null +++ b/src/commands/compressFolder.ts @@ -0,0 +1,17 @@ +import vscode = require('vscode'); +import { Uri } from 'vscode'; +import { CompressionService } from '../services/compressionService'; + +/** + * Command handler for compressing all images in a folder + */ +export function compressFolderCommand(folder: Uri): void { + vscode.workspace + .findFiles( + new vscode.RelativePattern( + folder.path, + `**/*.{png,jpg,jpeg,webp}` + ) + ) + .then((files: Uri[]) => files.forEach(CompressionService.compressImage)); +} diff --git a/src/commands/compressGitStage.ts b/src/commands/compressGitStage.ts new file mode 100644 index 0000000..97231f8 --- /dev/null +++ b/src/commands/compressGitStage.ts @@ -0,0 +1,64 @@ +import vscode = require('vscode'); +import { Uri } from 'vscode'; +import { spawnSync } from 'child_process'; +import { CompressionService } from '../services/compressionService'; + +/** + * Compress staged image files in git + */ +function compressStageFiles(editorPath: string): void { + try { + const lines = spawnSync( + 'git', + ['diff', '--staged', '--diff-filter=ACMR', '--name-only', '-z'], + { encoding: 'utf-8', cwd: editorPath } + ); + + const files = lines.stdout + .replace(/\u0000$/, '') + .split('\u0000') + .filter((f: string) => /\.(png|jpg|jpeg|webp)$/.test(f)); + + if (files.length === 0) { + vscode.window.showInformationMessage( + `TinyPNG: No images found in the git stage.` + ); + return; + } + + files.forEach((f: string) => + CompressionService.compressImage(Uri.parse(`${editorPath}/${f}`)) + ); + } catch (err) { + vscode.window.showErrorMessage( + `TinyPNG: ${(err as Error).message}` + ); + } +} + +/** + * Command handler for compressing git staged images + */ +export function compressGitStageCommand(): void { + const folders = vscode.workspace.workspaceFolders; + + if (!folders) { + vscode.window.showInformationMessage( + `TinyPNG: No editor path found.` + ); + return; + } + + if (folders.length <= 1) { + compressStageFiles(folders[0].uri.fsPath); + return; + } + + const folderNames = folders.map((folder) => folder.name); + vscode.window.showQuickPick(folderNames).then((folderName: string | undefined) => { + const folder = folders.find((f) => f.name === folderName); + if (folder) { + compressStageFiles(folder.uri.fsPath); + } + }); +} diff --git a/src/commands/getCompressionCount.ts b/src/commands/getCompressionCount.ts new file mode 100644 index 0000000..004515d --- /dev/null +++ b/src/commands/getCompressionCount.ts @@ -0,0 +1,14 @@ +import vscode = require('vscode'); +import { CompressionService } from '../services/compressionService'; + +/** + * Command handler for getting the compression count + */ +export function getCompressionCountCommand(): void { + CompressionService.validate(() => { + const count = CompressionService.getCompressionCount(); + vscode.window.showInformationMessage( + `TinyPNG: You already used ${count} compression(s) this month.` + ); + }); +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..c10e242 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,54 @@ +import vscode = require('vscode'); +import { CompressionService } from './services/compressionService'; +import { compressFileCommand } from './commands/compressFile'; +import { compressFolderCommand } from './commands/compressFolder'; +import { compressGitStageCommand } from './commands/compressGitStage'; +import { getCompressionCountCommand } from './commands/getCompressionCount'; +import { handleValidationError } from './utils/errorHandler'; + +/** + * Activate the extension + */ +export function activate(context: vscode.ExtensionContext): void { + // Initialize the compression service with API key + CompressionService.initialize(); + + // Validate the API key + CompressionService.validate( + () => console.log('Validation successful!'), + handleValidationError + ); + + // Register command: Compress single file + const disposableCompressFile = vscode.commands.registerCommand( + 'extension.compressFile', + compressFileCommand + ); + context.subscriptions.push(disposableCompressFile); + + // Register command: Compress folder + const disposableCompressFolder = vscode.commands.registerCommand( + 'extension.compressFolder', + compressFolderCommand + ); + context.subscriptions.push(disposableCompressFolder); + + // Register command: Get compression count + const disposableCompressionCount = vscode.commands.registerCommand( + 'extension.getCompressionCount', + getCompressionCountCommand + ); + context.subscriptions.push(disposableCompressionCount); + + // Register command: Compress git staged files + const disposableCompressGitStage = vscode.commands.registerCommand( + 'extension.compressGitStage', + compressGitStageCommand + ); + context.subscriptions.push(disposableCompressGitStage); +} + +/** + * Deactivate the extension + */ +export function deactivate(): void {} diff --git a/src/services/compressionService.ts b/src/services/compressionService.ts new file mode 100644 index 0000000..31e940a --- /dev/null +++ b/src/services/compressionService.ts @@ -0,0 +1,77 @@ +import vscode = require('vscode'); +import tinify = require('tinify'); +import { Uri } from 'vscode'; +import { generateDestinationPath } from '../utils/fileUtils'; +import { handleCompressionError } from '../utils/errorHandler'; +import { ConfigService } from './configService'; + +/** + * Service for handling image compression operations + */ +export class CompressionService { + /** + * Initialize the TinyPNG API with the API key + */ + public static initialize(): void { + const apiKey = ConfigService.getApiKey(); + if (apiKey) { + tinify.key = apiKey; + } + } + + /** + * Validate the TinyPNG API key + */ + public static validate( + onSuccess: () => void = () => {}, + onFailure?: (error: Error) => void + ): void { + tinify.validate((err: Error | null) => { + if (err) { + if (onFailure) { + onFailure(err); + } + } else { + onSuccess(); + } + }); + } + + /** + * Compress a single image file + */ + public static compressImage(file: Uri): void { + const shouldOverwrite = ConfigService.shouldOverwrite(); + const postfix = ConfigService.getCompressedFilePostfix(); + + const destinationFilePath = generateDestinationPath( + file, + shouldOverwrite, + postfix + ); + + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left + ); + statusBarItem.text = `Compressing file ${file.fsPath}...`; + statusBarItem.show(); + + tinify.fromFile(file.fsPath).toFile(destinationFilePath, (error: Error | null) => { + statusBarItem.hide(); + if (error) { + handleCompressionError(error); + } else { + vscode.window.showInformationMessage( + `Successfully compressed ${file.fsPath} to ${destinationFilePath}!` + ); + } + }); + } + + /** + * Get the current compression count + */ + public static getCompressionCount(): number { + return tinify.compressionCount || 0; + } +} diff --git a/src/services/configService.ts b/src/services/configService.ts new file mode 100644 index 0000000..afc0e46 --- /dev/null +++ b/src/services/configService.ts @@ -0,0 +1,49 @@ +import vscode = require('vscode'); +import { TinyPngConfig } from '../types'; + +/** + * Service for managing TinyPNG extension configuration + */ +export class ConfigService { + private static readonly CONFIG_SECTION = 'tinypng'; + + /** + * Get the current TinyPNG configuration + */ + public static getConfig(): TinyPngConfig { + const config = vscode.workspace.getConfiguration(this.CONFIG_SECTION); + + return { + apiKey: config.get('apiKey'), + forceOverwrite: config.get('forceOverwrite') || false, + compressedFilePostfix: config.get('compressedFilePostfix') || '.min' + }; + } + + /** + * Get the API key from configuration + */ + public static getApiKey(): string | undefined { + return vscode.workspace + .getConfiguration(this.CONFIG_SECTION) + .get('apiKey'); + } + + /** + * Check if files should be overwritten + */ + public static shouldOverwrite(): boolean { + return vscode.workspace + .getConfiguration(this.CONFIG_SECTION) + .get('forceOverwrite') || false; + } + + /** + * Get the postfix for compressed files + */ + public static getCompressedFilePostfix(): string { + return vscode.workspace + .getConfiguration(this.CONFIG_SECTION) + .get('compressedFilePostfix') || '.min'; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..a3121ae --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,29 @@ +import { Uri } from 'vscode'; + +/** + * Configuration for TinyPNG extension + */ +export interface TinyPngConfig { + apiKey?: string; + forceOverwrite: boolean; + compressedFilePostfix: string; +} + +/** + * Result of a compression operation + */ +export interface CompressionResult { + success: boolean; + sourcePath: string; + destinationPath: string; + error?: Error; +} + +/** + * Options for compressing an image + */ +export interface CompressionOptions { + file: Uri; + shouldOverwrite: boolean; + postfix: string; +} diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 0000000..f91574d --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,42 @@ +import vscode = require('vscode'); +import tinify = require('tinify'); + +/** + * Handle errors from TinyPNG API + */ +export function handleCompressionError(error: Error): void { + if (error instanceof tinify.AccountError) { + console.error('Authentication failed. Have you set the API Key?'); + vscode.window.showErrorMessage( + 'Authentication failed. Have you set the API Key?' + ); + } else if (error instanceof tinify.ClientError) { + console.error('Ooops, there is an error. Please check your source image and settings.'); + vscode.window.showErrorMessage( + 'Ooops, there is an error. Please check your source image and settings.' + ); + } else if (error instanceof tinify.ServerError) { + console.error('TinyPNG API is currently not available.'); + vscode.window.showErrorMessage( + 'TinyPNG API is currently not available.' + ); + } else if (error instanceof tinify.ConnectionError) { + console.error('Network issue occurred. Please check your internet connectivity.'); + vscode.window.showErrorMessage( + 'Network issue occurred. Please check your internet connectivity.' + ); + } else { + console.error(error.message); + vscode.window.showErrorMessage(error.message); + } +} + +/** + * Handle validation errors + */ +export function handleValidationError(error: Error): void { + console.error(error.message); + vscode.window.showInformationMessage( + 'TinyPNG: API validation failed. Be sure that you filled out tinypng.apiKey setting already.' + ); +} diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts new file mode 100644 index 0000000..736bda9 --- /dev/null +++ b/src/utils/fileUtils.ts @@ -0,0 +1,17 @@ +import path = require('path'); +import { Uri } from 'vscode'; + +/** + * Generate the destination file path for a compressed image + */ +export function generateDestinationPath(file: Uri, shouldOverwrite: boolean, postfix: string): string { + if (shouldOverwrite) { + return file.fsPath; + } + + const parsedPath = path.parse(file.fsPath); + return path.join( + parsedPath.dir, + `${parsedPath.name}${postfix}${parsedPath.ext}` + ); +} diff --git a/tsconfig.json b/tsconfig.json index 7e7d4a5..5f15d7e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "out", "sourceMap": true, "skipLibCheck": true, + "moduleResolution": "node", /* Strict Type-Checking Option */ "strict": true /* enable all strict type-checking options */, /* Additional Checks */ @@ -14,5 +15,6 @@ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ }, + "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", ".vscode-test"] } From 3f3925f62fee58ca5610e3f297b094a1aa21b91d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 16:58:28 +0000 Subject: [PATCH 2/2] fix: Update entry point to match new modular structure The refactored code is now in src/ directory, which compiles to out/src/extension.js instead of out/extension.js. Updated package.json to point to the correct entry point. This fixes the test failures where VSCode couldn't find the extension module because it was looking at the old path. Changes: - Updated "main" in package.json from "./out/extension.js" to "./out/src/extension.js" Fixes #6 test failures related to extension activation and command registration. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c074bc0..b27b112 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "onCommand:extension.compressFolder", "onCommand:extension.getCompressionCount" ], - "main": "./out/extension.js", + "main": "./out/src/extension.js", "contributes": { "configuration": [ {