/
foult080
/
terminal-db
Обзор
Документация
Войти
/
foult080
/
terminal-db
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/workspace/Workspace.ts
407 строк
12 KB
foult080
production hardening: fix P0 bugs, remove dead code, add workspace tests
16 июл 2026, 07:34
16 июл 2026, 07:34
39c68ad
Код
Авторство
О чём код?
import type { CommandExecutor } from "../commands/CommandExecutor.js" import type { CommandRegistry } from "../commands/CommandRegistry.js" import type { AppConfig } from "../config/AppConfig.js" import type { ConfigLoader } from "../config/ConfigLoader.js" import type { EventBus } from "../core/events/EventBus.js" import type { Command } from "../core/types/commands.js" import type { ConnectionConfig, DbQueryResult, DbType, HistoryEntry, } from "../core/types/db.js" import type { IDbContext, SchemaItem } from "../db/types.js" import { ConnectionManager } from "../db/connections/ConnectionManager.js" import type { QueryHistory } from "../history/QueryHistory.js" import type { Tab } from "./TabManager.js" import { ClearCommand } from "../commands/builtins/ClearCommand.js" import { ConnectCommand } from "../commands/builtins/ConnectCommand.js" import { DisconnectCommand } from "../commands/builtins/DisconnectCommand.js" import { ExitCommand } from "../commands/builtins/ExitCommand.js" import { ExportCommand } from "../commands/builtins/ExportCommand.js" import { HelpCommand } from "../commands/builtins/HelpCommand.js" import { HistoryCommand } from "../commands/builtins/HistoryCommand.js" import { ImportCommand } from "../commands/builtins/ImportCommand.js" import { QueryCommand } from "../commands/builtins/QueryCommand.js" import { TabCloseCommand, TabNewCommand, TabNextCommand, TabPrevCommand, } from "../commands/builtins/TabCommands.js" import { ThemeCommand } from "../commands/builtins/ThemeCommand.js" import { TrainCommand } from "../commands/builtins/TrainCommand.js" import { SchemaProvider } from "../completion/SchemaProvider.js" import { SqlCompleter } from "../completion/SqlCompleter.js" import { DEFAULT_THEME, type ThemeColors, getTheme } from "../core/themes/index.js" import { CsvExporter } from "../db/csv/CsvExporter.js" import { QueryExecutor as PgQueryExecutor } from "../db/queries/QueryExecutor.js" import { TabManager } from "./TabManager.js" const DEFAULT_THEME_INIT = DEFAULT_THEME export interface WorkspaceState { commands: Command[] queryResult: DbQueryResult | null queryHistory: HistoryEntry[] schemaItems: SchemaItem[] connectionInfo: string | null statusMessage: string isQueryRunning: boolean queryError: string | null activeTheme: ThemeColors tabs: Tab[] activeTabId: string | null sqlEditorText: string dbType: DbType trainingActive: boolean } type Subscriber = (state: WorkspaceState) => void export class Workspace { private subscribers = new Set<Subscriber>() private cleanupFns: Array<() => void> = [] readonly tabManager: TabManager readonly schemaProvider: SchemaProvider readonly sqlCompleter: SqlCompleter private state: WorkspaceState = { commands: [], queryResult: null, queryHistory: [], schemaItems: [], connectionInfo: null, statusMessage: "", isQueryRunning: false, queryError: null, activeTheme: DEFAULT_THEME_INIT, tabs: [], activeTabId: null, sqlEditorText: "", dbType: "postgres", trainingActive: false, } constructor( readonly eventBus: EventBus, readonly registry: CommandRegistry, readonly executor: CommandExecutor, readonly dbContext: IDbContext, readonly history: QueryHistory, readonly config: AppConfig, readonly configLoader: ConfigLoader, dbType: DbType, ) { this.state.dbType = dbType this.tabManager = new TabManager() this.schemaProvider = new SchemaProvider(dbContext.schemaExplorer) this.sqlCompleter = new SqlCompleter(this.schemaProvider) this.registerBuiltinCommands() this.setupEventListeners() this.state.commands = this.registry.list() this.state.activeTheme = getTheme(this.config.settings.theme) this.syncTabs() } private registerBuiltinCommands(): void { this.registry.register(new HelpCommand(this.registry)) this.registry.register(new ConnectCommand(this)) this.registry.register(new DisconnectCommand(this)) this.registry.register(new QueryCommand(this)) this.registry.register(new HistoryCommand(this.history)) this.registry.register(new ClearCommand(this.eventBus)) this.registry.register(new ExitCommand(this)) this.registry.register(new ThemeCommand(this)) this.registry.register(new TabNewCommand(this)) this.registry.register(new TabCloseCommand(this)) this.registry.register(new TabNextCommand(this)) this.registry.register(new TabPrevCommand(this)) this.registry.register(new ExportCommand(this)) this.registry.register(new ImportCommand(this)) this.registry.register(new TrainCommand()) } private setupEventListeners(): void { this.cleanupFns.push( this.eventBus.on("connection:opened", () => { this.refreshConnectionInfo() this.refreshSchemas() }), ) this.cleanupFns.push( this.eventBus.on("connection:closed", () => { this.setState({ connectionInfo: null, schemaItems: [], queryResult: null }) }), ) this.cleanupFns.push( this.eventBus.on("query:executed", () => { this.setState({ isQueryRunning: true, queryError: null }) }), ) this.cleanupFns.push( this.eventBus.on("query:result", ({ result }) => { const dbResult = result as DbQueryResult this.setState({ isQueryRunning: false, queryResult: dbResult, queryError: null }) }), ) this.cleanupFns.push( this.eventBus.on("query:error", ({ sql, error }) => { this.setState({ isQueryRunning: false, queryError: error.message, }) this.history.add({ sql, executedAt: new Date(), duration: 0, rowCount: null, connectionId: this.dbContext.connectionManager.getActiveConnectionId() ?? "unknown", status: "error", errorMessage: error.message, }) this.syncHistory() }), ) } async shutdown(): Promise<void> { try { await this.disconnect() } catch { // ignore disconnect errors during shutdown } this.eventBus.emit("app:shutdown", {}) } destroy(): void { for (const cleanup of this.cleanupFns) { cleanup() } this.cleanupFns = [] this.subscribers.clear() } subscribe(fn: Subscriber): () => void { this.subscribers.add(fn) return () => { this.subscribers.delete(fn) } } getState(): WorkspaceState { return this.state } private setState(partial: Partial<WorkspaceState>): void { this.state = { ...this.state, ...partial } this.notify() } private notify(): void { for (const subscriber of this.subscribers) { subscriber(this.state) } } async executeCommand(commandId: string, args: Record<string, unknown> = {}): Promise<void> { try { await this.executor.execute(commandId, args) this.setState({ statusMessage: `Executed: /${commandId}` }) } catch (error) { this.setState({ statusMessage: `Error: ${(error as Error).message}`, }) } } async executeQuery(query: string): Promise<void> { const activeId = this.dbContext.connectionManager.getActiveConnectionId() if (!activeId) { this.setState({ statusMessage: "Not connected. Use /connect first.", }) return } const tabId = this.tabManager.getActiveId() if (tabId) { this.tabManager.updateSql(tabId, query) } try { const result = await this.dbContext.queryExecutor.execute(activeId, query) this.history.add({ sql: query, executedAt: new Date(), duration: result.duration, rowCount: result.rowCount, connectionId: activeId, status: "success", }) this.syncHistory() if (tabId) { this.tabManager.updateResult(tabId, result, null) } this.syncTabs() const formatted = this.dbContext.queryExecutor.formatResult(result) this.setState({ statusMessage: formatted }) } catch (error) { if (tabId) { this.tabManager.updateResult(tabId, null, (error as Error).message) } this.syncTabs() this.setState({ statusMessage: `Query error: ${(error as Error).message}`, }) } } async connect(config: ConnectionConfig): Promise<void> { try { await this.dbContext.connectionManager.connect(config) this.setState({ statusMessage: "Connected successfully", dbType: config.type, }) } catch (error) { this.setState({ statusMessage: `Connection failed: ${(error as Error).message}`, }) } } async disconnect(): Promise<void> { const activeId = this.dbContext.connectionManager.getActiveConnectionId() if (!activeId) { this.setState({ statusMessage: "No active connection" }) return } await this.dbContext.connectionManager.disconnect(activeId) this.setState({ connectionInfo: null, queryResult: null, queryError: null, schemaItems: [], statusMessage: "Disconnected", }) } async loadHistoryEntry(entry: HistoryEntry): Promise<void> { this.setState({ statusMessage: `Loaded: ${entry.sql.slice(0, 50)}...`, }) } getActiveTheme(): ThemeColors { return this.state.activeTheme } setTheme(name: string): void { const theme = getTheme(name) this.config.settings.theme = name this.configLoader.save(this.config) this.setState({ activeTheme: theme }) } saveConnection(config: ConnectionConfig): void { const idx = this.config.connections.findIndex( (c) => c.host === config.host && c.port === config.port && c.database === config.database && c.user === config.user, ) if (idx >= 0) { this.config.connections[idx] = { ...config } } else { this.config.connections.push({ ...config }) } this.configLoader.save(this.config) } getCsvExporter(): CsvExporter { if (!(this.dbContext.connectionManager instanceof ConnectionManager)) { throw new Error("CSV export is only available for PostgreSQL connections") } if (!(this.dbContext.queryExecutor instanceof PgQueryExecutor)) { throw new Error("CSV export requires PostgreSQL query executor") } return new CsvExporter(this.dbContext.queryExecutor) } addTab(): void { this.tabManager.addTab() this.syncTabs() } closeTab(): void { const activeId = this.tabManager.getActiveId() if (activeId) { this.tabManager.closeTab(activeId) this.syncTabs() } } nextTab(): void { this.tabManager.next() this.syncTabs() } prevTab(): void { this.tabManager.prev() this.syncTabs() } switchTab(id: string): void { this.tabManager.switchTo(id) this.syncTabs() } private syncTabs(): void { const activeTab = this.tabManager.getActiveTab() this.setState({ tabs: this.tabManager.listTabs(), activeTabId: this.tabManager.getActiveId(), queryResult: activeTab?.result ?? null, queryError: activeTab?.error ?? null, sqlEditorText: activeTab?.sql ?? "", }) } private syncHistory(): void { this.setState({ queryHistory: this.history.list() }) } private refreshConnectionInfo(): void { const activeId = this.dbContext.connectionManager.getActiveConnectionId() if (!activeId) return const info = this.dbContext.connectionManager.getConnectionInfo(activeId) if (info) { const prefix = info.config.type === "mongodb" ? "[Mongo]" : "[PG]" this.setState({ connectionInfo: info.config.name ? `${info.config.name} (${prefix} ${info.config.host}:${info.config.port}/${info.config.database})` : `${prefix} ${info.config.host}:${info.config.port}/${info.config.database} as ${info.config.user}`, }) } } private async refreshSchemas(): Promise<void> { const activeId = this.dbContext.connectionManager.getActiveConnectionId() if (!activeId) return try { await this.schemaProvider.refresh(activeId) const items = await this.dbContext.schemaExplorer.getItems(activeId) this.setState({ schemaItems: items as SchemaItem[] }) } catch { this.setState({ schemaItems: [] }) } } }