Plugin Lifecycle & Hooks
Understanding the plugin lifecycle and available hooks for responding to events in Lokus.
Lifecycle States
A plugin goes through several states:
NOT_LOADED → LOADING → LOADED → ACTIVATING → ACTIVE → DEACTIVATING → DEACTIVATED
↕
ERRORState Descriptions
NOT_LOADED
- Plugin manifest not yet loaded
- No resources allocated
LOADING
- Reading manifest
- Validating dependencies
- Checking permissions
LOADED
- Manifest validated
- Ready for activation
- Waiting for activation event
ACTIVATING
- Running
activate()method - Registering contributions
- Setting up resources
ACTIVE
- Plugin fully operational
- Responding to events
- Executing commands
DEACTIVATING
- Running
deactivate()method - Cleaning up resources
- Unregistering contributions
DEACTIVATED
- All resources released
- No longer responding to events
ERROR
- Plugin encountered fatal error
- Requires user intervention
- Can be reactivated after fix
Activation
Activation Events
Plugins activate based on events defined in manifest:
{
"activationEvents": [
"onStartup",
"onLanguage:markdown",
"onCommand:myPlugin.hello",
"onView:myPlugin.sidebar",
"workspaceContains:**/*.md",
"onDebug",
"onUri"
]
}activate() Method
export default class MyPlugin implements Plugin {
async activate(context: PluginContext) {
console.log('Plugin activating...')
// Initialize services
await this.initializeServices(context)
// Register commands
this.registerCommands(context)
// Set up event listeners
this.setupEventListeners(context)
// Start background tasks
this.startBackgroundTasks()
console.log('Plugin activated successfully')
}
}Plugin Context
The PluginContext provides:
interface PluginContext {
// Plugin identification
pluginId: string
manifest: PluginManifest
// API access
api: LokusAPI
// Storage paths
storageUri: string // Plugin-specific storage
globalStorageUri: string // Shared across workspaces
assetUri: string // Plugin assets
// State management
globalState: Memento // Persistent state
workspaceState: Memento // Workspace-specific state
secrets: SecretStorage // Secure storage
// Environment info
extensionMode: ExtensionMode // Production/Development/Test
environment: PluginEnvironment
// Permissions
permissions: ReadonlySet<Permission>
// Resource management
subscriptions: Disposable[] // Auto-disposed on deactivation
}Deactivation
deactivate() Method
Proper cleanup is essential for all resources, especially with the new APIs:
export default class MyPlugin implements Plugin {
private timers: NodeJS.Timeout[] = []
private watchers: Disposable[] = []
private terminals: Terminal[] = []
private statusBarItems: StatusBarItem[] = []
private treeViews: TreeView[] = []
private outputChannels: OutputChannel[] = []
async activate(context: PluginContext) {
// Setup...
}
async deactivate() {
console.log('Plugin deactivating...')
// Clear timers and intervals
this.timers.forEach(timer => clearTimeout(timer))
this.timers = []
// Dispose file watchers
this.watchers.forEach(watcher => watcher.dispose())
this.watchers = []
// Dispose terminals
this.terminals.forEach(terminal => terminal.dispose())
this.terminals = []
// Dispose status bar items
this.statusBarItems.forEach(item => item.dispose())
this.statusBarItems = []
// Dispose tree views
this.treeViews.forEach(view => view.dispose())
this.treeViews = []
// Dispose output channels
this.outputChannels.forEach(channel => channel.dispose())
this.outputChannels = []
// Close network connections
await this.closeConnections()
// Save state before exit
await this.saveState()
console.log('Plugin deactivated')
}
}Automatic Cleanup
Resources registered in context.subscriptions are automatically disposed:
async activate(context: PluginContext) {
// These will be automatically disposed
context.subscriptions.push(
api.commands.register({ /* ... */ }),
api.editor.onDidChangeTextDocument(() => {}),
api.fs.watch('/path', () => {})
)
}
// No need to manually dispose in deactivate()Event Hooks
Editor Events
// Document changes
context.api.editor.onDidChangeTextDocument(event => {
console.log('Document changed:', event.document.uri)
event.contentChanges.forEach(change => {
console.log('Change:', change.text)
})
})
// Selection changes
context.api.editor.onDidChangeTextEditorSelection(event => {
console.log('Selection changed:', event.selections)
})
// Active editor changes
context.api.editor.onDidChangeActiveTextEditor(editor => {
if (editor) {
console.log('Active editor:', editor.document.uri)
}
})
// Document open/close
context.api.workspace.onDidOpenTextDocument(doc => {
console.log('Document opened:', doc.uri)
})
context.api.workspace.onDidCloseTextDocument(doc => {
console.log('Document closed:', doc.uri)
})
// Document save
context.api.workspace.onDidSaveTextDocument(doc => {
console.log('Document saved:', doc.uri)
})Workspace Events
// Workspace folders changed
context.api.workspace.onDidChangeWorkspaceFolders(event => {
console.log('Added folders:', event.added)
console.log('Removed folders:', event.removed)
})
// Configuration changed
context.api.config.onDidChange((key, value) => {
console.log(`Config changed: ${key} = $\\{value\\}`)
})
// File system events
context.api.fs.watch('/path', event => {
console.log(`File ${event.type}: $\\{event.path\\}`)
})Custom Events
// Subscribe to custom events
context.api.events.on('myPlugin.customEvent', data => {
console.log('Custom event received:', data)
})
// Emit custom events
context.api.events.emit('myPlugin.customEvent', {
message: 'Hello'
})
// One-time event
context.api.events.once('myPlugin.initialized', () => {
console.log('Plugin initialized')
})State Management
Workspace State
State specific to current workspace:
async activate(context: PluginContext) {
// Read workspace state
const lastOpened = context.workspaceState.get<string>('lastOpened')
// Update workspace state
await context.workspaceState.update('lastOpened', new Date().toISOString())
// Get all keys
const keys = context.workspaceState.keys()
}Global State
State shared across all workspaces:
async activate(context: PluginContext) {
// Read global state
const settings = context.globalState.get('settings', {
theme: 'dark',
autoSave: true
})
// Update global state
await context.globalState.update('settings', {
...settings,
lastSync: Date.now()
})
}Secrets
Secure storage for sensitive data:
async activate(context: PluginContext) {
// Store secret
await context.secrets.store('api-key', 'secret-value')
// Retrieve secret
const apiKey = await context.secrets.get('api-key')
// Delete secret
await context.secrets.delete('api-key')
// Listen to changes
context.secrets.onDidChange(event => {
console.log('Secret changed:', event.key)
})
}Background Tasks
Periodic Tasks
class MyPlugin implements Plugin {
private syncInterval?: NodeJS.Timeout
async activate(context: PluginContext) {
// Start periodic sync
this.syncInterval = setInterval(() => {
this.syncData()
}, 60000) // Every minute
}
async deactivate() {
// Stop periodic task
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
}
private async syncData() {
try {
await this.api.network.fetch('https://api.example.com/sync')
} catch (error) {
console.error('Sync failed:', error)
}
}
}One-time Initialization
async activate(context: PluginContext) {
// Check if first run
const isFirstRun = !context.globalState.get('initialized')
if (isFirstRun) {
await this.runFirstTimeSetup()
await context.globalState.update('initialized', true)
}
// Check for updates
const lastVersion = context.globalState.get<string>('version')
if (lastVersion !== context.manifest.version) {
await this.runMigration(lastVersion, context.manifest.version)
await context.globalState.update('version', context.manifest.version)
}
}Error Handling
Graceful Degradation
async activate(context: PluginContext) {
try {
await this.initializeCriticalFeature()
} catch (error) {
context.api.log('error', 'Failed to initialize feature', error)
// Show user notification
const retry = await context.api.ui.showNotification(
'Failed to initialize plugin',
'error',
[
{ id: 'retry', label: 'Retry' },
{ id: 'disable', label: 'Disable' }
]
)
if (retry === 'retry') {
await this.activate(context)
}
}
}Error Recovery
class ResilientPlugin implements Plugin {
private retryCount = 0
private maxRetries = 3
async activate(context: PluginContext) {
try {
await this.initialize()
this.retryCount = 0 // Reset on success
} catch (error) {
if (this.retryCount < this.maxRetries) {
this.retryCount++
// Exponential backoff
const delay = Math.pow(2, this.retryCount) * 1000
setTimeout(() => {
this.activate(context)
}, delay)
} else {
// Give up after max retries
context.api.ui.showNotification(
'Plugin initialization failed after multiple attempts',
'error'
)
}
}
}
}Performance Optimization
Lazy Loading
async activate(context: PluginContext) {
// Register command immediately
context.subscriptions.push(
context.api.commands.register({
id: 'myPlugin.heavyOperation',
title: 'Heavy Operation',
handler: async () => {
// Load heavy dependency only when needed
const { default: heavyLib } = await import('./heavy-lib')
return heavyLib.process()
}
})
)
// Don't load heavy dependencies during activation
}Debouncing
import { debounce } from 'lodash'
async activate(context: PluginContext) {
// Debounce expensive operations
const debouncedUpdate = debounce(() => {
this.updateView()
}, 300)
context.api.editor.onDidChangeTextDocument(() => {
debouncedUpdate()
})
}Caching
class CachedPlugin implements Plugin {
private cache = new Map()
async getData(key: string) {
// Check cache first
if (this.cache.has(key)) {
return this.cache.get(key)
}
// Fetch and cache
const data = await this.fetchData(key)
this.cache.set(key, data)
return data
}
async deactivate() {
// Clear cache on deactivation
this.cache.clear()
}
}Testing Lifecycle
import { createMockContext, TestHelper } from 'lokus-plugin-sdk/testing'
describe('Plugin Lifecycle', () => {
let context: PluginContext
let plugin: MyPlugin
beforeEach(() => {
context = createMockContext()
plugin = new MyPlugin()
})
it('should activate successfully', async () => {
await plugin.activate(context)
expect(context.subscriptions.length).toBeGreaterThan(0)
})
it('should deactivate cleanly', async () => {
await plugin.activate(context)
await plugin.deactivate()
// Verify all resources disposed
expect(context.subscriptions.every(s => s.disposed)).toBe(true)
})
it('should handle activation error', async () => {
// Simulate error
jest.spyOn(plugin as any, 'initialize').mockRejectedValue(
new Error('Init failed')
)
await expect(plugin.activate(context)).rejects.toThrow()
})
})Cleanup Patterns for New APIs
Terminal Cleanup
class MyPlugin {
private terminals: Map<string, Terminal> = new Map()
createBuildTerminal() {
const terminal = this.context.terminal.createTerminal({
name: 'Build',
cwd: this.context.workspace?.rootPath
})
this.terminals.set('build', terminal)
return terminal
}
async deactivate() {
// Close all terminals
for (const [name, terminal] of this.terminals) {
terminal.dispose()
}
this.terminals.clear()
}
}Tree View Cleanup
class MyPlugin {
private treeView?: TreeView
registerExplorer() {
this.treeView = this.context.ui.createTreeView('myPlugin.explorer', {
treeDataProvider: this.treeDataProvider
})
}
async deactivate() {
// Dispose tree view
if (this.treeView) {
this.treeView.dispose()
this.treeView = undefined
}
}
}Status Bar Cleanup
class MyPlugin {
private statusBarItem?: StatusBarItem
createStatusBar() {
this.statusBarItem = this.context.ui.createStatusBarItem('left', 100)
this.statusBarItem.text = '$(check) Ready'
this.statusBarItem.show()
}
async deactivate() {
// Dispose status bar item
if (this.statusBarItem) {
this.statusBarItem.dispose()
this.statusBarItem = undefined
}
}
}Language Provider Cleanup
class MyPlugin {
private languageProviders: Disposable[] = []
registerLanguageFeatures() {
// Register completion provider
const completionProvider = this.context.languages.registerCompletionProvider(
'markdown',
this.completionProvider
)
this.languageProviders.push(completionProvider)
// Register hover provider
const hoverProvider = this.context.languages.registerHoverProvider(
'markdown',
this.hoverProvider
)
this.languageProviders.push(hoverProvider)
}
async deactivate() {
// Dispose all language providers
this.languageProviders.forEach(provider => provider.dispose())
this.languageProviders = []
}
}Output Channel Cleanup
class MyPlugin {
private outputChannel?: OutputChannel
setupLogging() {
this.outputChannel = this.context.ui.createOutputChannel('My Plugin')
this.outputChannel.appendLine('Plugin initialized')
}
async deactivate() {
// Dispose output channel
if (this.outputChannel) {
this.outputChannel.dispose()
this.outputChannel = undefined
}
}
}Event Listener Cleanup
class MyPlugin {
private eventSubscriptions: Disposable[] = []
setupEventListeners() {
// Terminal events
const terminalDisposed = this.context.terminal.onDidCloseTerminal((terminal) => {
console.log('Terminal closed:', terminal.name)
})
this.eventSubscriptions.push(terminalDisposed)
// Configuration changes
const configChanged = this.context.config.onDidChange((e) => {
console.log('Config changed:', e.key)
})
this.eventSubscriptions.push(configChanged)
// Theme changes
const themeChanged = this.context.themes.onDidChangeTheme((theme) => {
console.log('Theme changed:', theme.id)
})
this.eventSubscriptions.push(themeChanged)
}
async deactivate() {
// Dispose all event listeners
this.eventSubscriptions.forEach(sub => sub.dispose())
this.eventSubscriptions = []
}
}Best Practices
- Fast Activation - Keep activation code minimal
- Cleanup All Resources - Dispose terminals, tree views, status bars, language providers, and event listeners
- Handle Errors - Gracefully handle activation/deactivation errors
- Use Subscriptions - Leverage context.subscriptions for auto-cleanup
- State Management - Use provided state APIs, not global variables
- Background Tasks - Clean up timers and intervals
- Event Listeners - Always unsubscribe from events in deactivate
- UI Elements - Dispose all UI elements (status bars, tree views, output channels)
- Test Lifecycle - Write tests for activation/deactivation with all resources