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

                                    ERROR

State 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

export default class MyPlugin implements Plugin {
  private timers: NodeJS.Timeout[] = []
  private watchers: Disposable[] = []
 
  async activate(context: PluginContext) {
    // Setup...
  }
 
  async deactivate() {
    console.log('Plugin deactivating...')
 
    // Clear timers
    this.timers.forEach(timer => clearTimeout(timer))
    this.timers = []
 
    // Dispose watchers
    this.watchers.forEach(watcher => watcher.dispose())
    this.watchers = []
 
    // Close connections
    await this.closeConnections()
 
    // Save state
    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()
  })
})

Best Practices

  1. Fast Activation - Keep activation code minimal
  2. Cleanup Resources - Always dispose resources in deactivate
  3. Handle Errors - Gracefully handle activation/deactivation errors
  4. Use Subscriptions - Leverage context.subscriptions for auto-cleanup
  5. State Management - Use provided state APIs, not global variables
  6. Background Tasks - Clean up timers and intervals
  7. Test Lifecycle - Write tests for activation/deactivation

Resources