diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd9e5b..dbe2d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to Simple DB are documented in this file. +## 0.1.3 - 2026-08-08 + +### Added + +- Persistent connection attachment for saved SQL files. A file remembers its selected Simple DB connection until the user changes it. +- A connection selector in the SQL editor toolbar and a connection-name indicator in the lower-right status bar. Unattached SQL files explicitly show `Simple DB: Select Connection`. +- Native VS Code `Go to Definition` and `Go to Declaration` integration backed by database metadata/DDL for supported objects, including Oracle packages and routines. + +### Changed + +- `Ctrl+Enter` is presented as **Execute Query** and executes the selection or current statement; **Execute Script** runs the entire document. +- Executing an unattached SQL file opens the connection picker once, attaches that connection to the file, and then continues the query. +- New Oracle editor sessions default to the connected user's schema, improving object navigation without extra setup. + +### Removed + +- Removed query History completely: Activity Bar view, commands, settings, storage service, tree provider, and QueryRunner writes. + ## 0.1.2 - 2026-08-08 ### Added diff --git a/README.md b/README.md index d759412..9d1909f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Simple DB -Simple DB `0.1.2` is a Visual Studio Code extension written entirely in JavaScript for working with **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** through a single interface. +Simple DB `0.1.3` is a Visual Studio Code extension written entirely in JavaScript for working with **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** through a single interface. The extension opens regular VS Code SQL documents. `F5` launches `src/extension.js` directly: there is no TypeScript, `tsconfig.json`, `dist` folder, or compilation step. @@ -11,7 +11,7 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. - Store passwords in `SecretStorage`, never inside profiles or the repository. - Connect to several database engines simultaneously and disconnect them explicitly. - Explore databases, schemas, and engine-specific objects. -- Open SQL editors linked to a connection, database, and schema. +- Attach any regular `.sql` file to a connection from the editor toolbar/status bar. Saved files remember that connection on the same VS Code installation until you change it. - Execute the selection, the statement at the cursor, or the entire document. - Execute arbitrary SQL: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `MERGE`, `CREATE`, `ALTER`, `DROP`, `TRUNCATE`, and any other syntax accepted by the server. - Generate `CREATE`, `ALTER`, and `DROP` scripts from the explorer and inspect object definitions/DDL. @@ -20,15 +20,16 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. - View results below the SQL editor in a resizable SQL Developer-style grid with row numbers, multiple result sets, types, `NULL`, affected rows, duration, and cell/row/selection copy actions. - Preserve 64-bit integers and high-precision `NUMBER` values exactly before displaying or exporting them. - Export already-retrieved results to CSV, JSON, or XLSX without executing the SQL again. -- Use configurable local query history with reopen, copy, and rerun actions. +- Use native **Go to Definition** / **Go to Declaration** navigation for database objects whose source or DDL is exposed by the connected engine, including routines and Oracle packages. ## Quick start 1. Open **Simple DB** in the Activity Bar and choose **Create Connection**. 2. Select the database engine and enter a connection name. SQLite uses the native file picker; network databases create a JSON profile with the correct default parameters. 3. Edit the generated JSON if needed, press `Ctrl+S`, then use **Test Connection** or **Connect** from the connection menu. Passwords stay outside JSON in VS Code `SecretStorage`. -4. Choose **New Query**, write SQL, and press `Ctrl+Enter` to execute the selection or the statement at the cursor. Use `Ctrl+Shift+Enter` to execute the entire document. -5. Results appear automatically in the resizable **Simple DB — Results** panel below the SQL editor. The same core commands are also available from the editor's **Simple DB** context submenu. +4. Open or create any `.sql` file. Click the database icon or **Simple DB: Select Connection** in the status bar and choose the connection this file should use. If you press `Ctrl+Enter` before choosing, Simple DB asks you once and attaches the selected connection automatically. +5. Press `Ctrl+Enter` (**Execute Query**) to run the selection or statement at the cursor. Use `Ctrl+Shift+Enter` (**Execute Script**) for the entire document. Saved SQL files keep their selected connection when closed and reopened. +6. Results appear automatically in the resizable **Simple DB — Results** panel below the SQL editor. `F12` / **Go to Definition** and **Go to Declaration** use the same attached connection to resolve database objects. ## Five database engines @@ -40,7 +41,7 @@ The extension opens regular VS Code SQL documents. `F5` launches `src/extension. | SQL Server | `mssql` | databases, schemas, tables, views, procedures/functions, indexes, triggers, sequences, types, synonyms, `GO` | | Oracle | `oracledb` Thin | schemas, tables, views/materialized views, procedures/functions, packages, indexes, triggers, sequences, types, synonyms, PL/SQL | -Oracle uses the default `node-oracledb` Thin mode, so normal connections do not require Oracle Client to be installed. Simple DB `0.1.2` uses SQL authentication with a username and password for SQL Server. +Oracle uses the default `node-oracledb` Thin mode, so normal connections do not require Oracle Client to be installed. Simple DB `0.1.3` uses SQL authentication with a username and password for SQL Server. ## No imposed row limit by default @@ -105,7 +106,6 @@ CSV export protects values that spreadsheet applications could interpret as form - SSL/TLS, encryption, and certificate trust are explicit options where supported by the database engine. - `simpleDb.confirmDestructiveQueries` is enabled by default. - `simpleDb.warnUnsafeDml` is enabled by default. -- Query history can contain literals written in SQL. It can be disabled with `simpleDb.history.enabled` or cleared from the **History** view. Example Oracle connection JSON: @@ -146,10 +146,11 @@ Right-clicking inside an editor now shows a native **Simple DB** submenu with th - Create Connection - New Query -- Execute Selection or Current Statement -- Execute Entire Document -- Change Editor Connection -- Show History +- Select Connection for SQL File +- Execute Query +- Execute Script + +VS Code's native **Go to Definition** and **Go to Declaration** actions are available in SQL editors. Simple DB resolves them against the connection attached to that file and opens the database-provided source/DDL in a read-only virtual SQL document. ### SQLite note @@ -162,8 +163,6 @@ SQLite runs in a dedicated WebAssembly Worker so long-running queries do not blo | `simpleDb.maxRows` | `0` | Optional limit per result set; `0` = unlimited | | `simpleDb.resultPageSize` | `500` | Rows per temporary storage/display page | | `simpleDb.maxCellCharacters` | `10000` | Maximum characters retained per cell in results | -| `simpleDb.history.enabled` | `true` | Store local query history | -| `simpleDb.history.maxEntries` | `500` | Maximum history entries | | `simpleDb.confirmDestructiveQueries` | `true` | Confirm `DROP`/`TRUNCATE` | | `simpleDb.warnUnsafeDml` | `true` | Warn about `UPDATE`/`DELETE` without `WHERE` | | `simpleDb.csvDelimiter` | `;` | CSV export delimiter | @@ -208,7 +207,7 @@ src/ managers/ # connections and editor sessions services/ # execution and export sql/ # dialect-aware splitter, safety rules, and DDL - storage/ # profiles, history, and paged results + storage/ # connection profiles, editor bindings, and paged results test/ # JavaScript tests ui/ # connection form views/ # trees and lower result grid diff --git a/package-lock.json b/package-lock.json index 75d0df6..aec1fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-db-suzdalenko", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-db-suzdalenko", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "exceljs": "4.4.0", diff --git a/package.json b/package.json index 987114e..b48261f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "simple-db-suzdalenko", "displayName": "Simple DB - SQL Client for SQLite, MySQL, PostgreSQL, SQL Server & Oracle", "description": "SQL client and database editor for SQLite, MySQL, PostgreSQL, SQL Server (MSSQL) and Oracle. Browse database objects, run queries, manage DDL and export results.", - "version": "0.1.2", + "version": "0.1.3", "publisher": "suzdalenko-dev", "license": "MIT", "icon": "resources/simple-db-marketplace.png", @@ -55,7 +55,6 @@ ], "activationEvents": [ "onView:simpleDb.connections", - "onView:simpleDb.history", "onView:simpleDb.results", "onLanguage:sql" ], @@ -124,12 +123,13 @@ }, { "command": "simpleDb.changeEditorConnection", - "title": "Change Editor Connection", - "category": "Simple DB" + "title": "Select Connection for SQL File", + "category": "Simple DB", + "icon": "$(database)" }, { "command": "simpleDb.executeCurrent", - "title": "Execute Selection or Current Statement", + "title": "Execute Query", "category": "Simple DB", "icon": "$(play)" }, @@ -140,7 +140,7 @@ }, { "command": "simpleDb.executeDocument", - "title": "Execute Entire Document", + "title": "Execute Script", "category": "Simple DB", "icon": "$(run-all)" }, @@ -165,38 +165,6 @@ "title": "Rollback", "category": "Simple DB" }, - { - "command": "simpleDb.showHistory", - "title": "Show History", - "category": "Simple DB", - "icon": "$(history)" - }, - { - "command": "simpleDb.clearHistory", - "title": "Clear History", - "category": "Simple DB", - "icon": "$(clear-all)" - }, - { - "command": "simpleDb.openHistoryEntry", - "title": "Open History Query", - "category": "Simple DB" - }, - { - "command": "simpleDb.copyHistoryEntry", - "title": "Copy SQL", - "category": "Simple DB" - }, - { - "command": "simpleDb.rerunHistoryEntry", - "title": "Run Again", - "category": "Simple DB" - }, - { - "command": "simpleDb.deleteHistoryEntry", - "title": "Delete from History", - "category": "Simple DB" - }, { "command": "simpleDb.selectTable", "title": "Open SELECT *", @@ -262,11 +230,6 @@ "id": "simpleDb.connections", "name": "Connections", "type": "tree" - }, - { - "id": "simpleDb.history", - "name": "History", - "type": "tree" } ], "simpleDbResults": [ @@ -294,11 +257,6 @@ "command": "simpleDb.openConnectionsFolder", "when": "view == simpleDb.connections", "group": "navigation@3" - }, - { - "command": "simpleDb.clearHistory", - "when": "view == simpleDb.history", - "group": "navigation@1" } ], "view/item/context": [ @@ -376,48 +334,34 @@ "command": "simpleDb.createObject", "when": "view == simpleDb.connections && viewItem =~ /simpleDb.(database|schema|group)/", "group": "object@1" - }, - { - "command": "simpleDb.openHistoryEntry", - "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", - "group": "inline@1" - }, - { - "command": "simpleDb.rerunHistoryEntry", - "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", - "group": "history@1" - }, - { - "command": "simpleDb.copyHistoryEntry", - "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", - "group": "history@2" - }, - { - "command": "simpleDb.deleteHistoryEntry", - "when": "view == simpleDb.history && viewItem == simpleDb.historyEntry", - "group": "history@3" } ], "editor/title": [ { - "command": "simpleDb.executeCurrent", - "when": "editorLangId == sql", + "command": "simpleDb.changeEditorConnection", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", "group": "navigation@1" }, { - "command": "simpleDb.executeDocument", - "when": "editorLangId == sql", + "command": "simpleDb.executeCurrent", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", "group": "navigation@2" }, { - "command": "simpleDb.cancelQuery", - "when": "editorLangId == sql", + "command": "simpleDb.executeDocument", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", "group": "navigation@3" + }, + { + "command": "simpleDb.cancelQuery", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", + "group": "navigation@4" } ], "editor/context": [ { "submenu": "simpleDb.editorContextMenu", + "when": "editorLangId == sql && resourceScheme != simple-db-definition", "group": "simpleDb@1" } ], @@ -431,23 +375,19 @@ "group": "1_connection@2" }, { - "command": "simpleDb.executeCurrent", + "command": "simpleDb.changeEditorConnection", "when": "editorLangId == sql", "group": "2_query@1" }, { - "command": "simpleDb.executeDocument", + "command": "simpleDb.executeCurrent", "when": "editorLangId == sql", "group": "2_query@2" }, { - "command": "simpleDb.changeEditorConnection", + "command": "simpleDb.executeDocument", "when": "editorLangId == sql", "group": "2_query@3" - }, - { - "command": "simpleDb.showHistory", - "group": "3_history@1" } ] }, @@ -456,13 +396,13 @@ "command": "simpleDb.executeCurrent", "key": "ctrl+enter", "mac": "cmd+enter", - "when": "editorTextFocus && editorLangId == sql" + "when": "editorTextFocus && editorLangId == sql && resourceScheme != simple-db-definition" }, { "command": "simpleDb.executeDocument", "key": "ctrl+shift+enter", "mac": "cmd+shift+enter", - "when": "editorTextFocus && editorLangId == sql" + "when": "editorTextFocus && editorLangId == sql && resourceScheme != simple-db-definition" } ], "configuration": { @@ -489,18 +429,6 @@ "maximum": 1000000, "description": "Maximum number of characters retained per cell in the result view." }, - "simpleDb.history.enabled": { - "type": "boolean", - "default": true, - "description": "Store local query history." - }, - "simpleDb.history.maxEntries": { - "type": "integer", - "default": 500, - "minimum": 0, - "maximum": 5000, - "description": "Maximum number of queries retained in history." - }, "simpleDb.confirmDestructiveQueries": { "type": "boolean", "default": true, diff --git a/src/extension.js b/src/extension.js index 8475c2e..d05fdbe 100644 --- a/src/extension.js +++ b/src/extension.js @@ -3,8 +3,12 @@ const path = require('node:path'); const vscode = require('vscode'); const { ConnectionManager } = require('./managers/connectionManager'); -const { EditorSessionManager } = require('./managers/editorSessionManager'); +const { + DEFINITION_SCHEME, + EditorSessionManager, +} = require('./managers/editorSessionManager'); const { QueryRunner } = require('./services/queryRunner'); +const { SqlNavigationProvider } = require('./views/sqlNavigationProvider'); const { ExportService } = require('./services/exportService'); const { OBJECT_LABELS, @@ -14,24 +18,15 @@ const { objectTypesForEngine, } = require('./sql/ddlTemplates'); const { ConnectionStore } = require('./storage/connectionStore'); -const { HistoryStore } = require('./storage/historyStore'); +const { EditorConnectionStore } = require('./storage/editorConnectionStore'); const { ResultStore } = require('./storage/resultStore'); const { promptConnection, promptPassword } = require('./ui/connectionForm'); const { ConnectionsTreeProvider } = require('./views/connectionsTreeProvider'); -const { HistoryTreeProvider } = require('./views/historyTreeProvider'); const { RESULT_VIEW_ID, ResultPanel } = require('./views/resultPanel'); const { getDatabaseEngine } = require('./databaseEngines'); let runtime = null; -function historyConfiguration() { - const config = vscode.workspace.getConfiguration('simpleDb'); - return { - enabled: config.get('history.enabled', true), - maxEntries: Math.max(0, Number(config.get('history.maxEntries', 500))), - }; -} - function exportConfiguration() { const config = vscode.workspace.getConfiguration('simpleDb'); return { @@ -73,7 +68,7 @@ function schemaContext(profile, node, database) { if (node?.schema) return node.schema; if (profile.engine === 'postgresql') return 'public'; if (profile.engine === 'sqlserver') return 'dbo'; - if (profile.engine === 'oracle') return ''; + if (profile.engine === 'oracle') return String(profile.user || '').toUpperCase(); if (profile.engine === 'sqlite' || profile.engine === 'mysql') return database; return ''; } @@ -134,11 +129,12 @@ async function activate(context) { ); } const connectionManager = new ConnectionManager(connectionStore); + const editorConnectionStore = new EditorConnectionStore(context.globalState); const editorSessionManager = new EditorSessionManager( connectionStore, connectionManager, + editorConnectionStore, ); - const historyStore = new HistoryStore(context.globalState, historyConfiguration); const config = vscode.workspace.getConfiguration('simpleDb'); const resultStore = new ResultStore( path.join(context.globalStorageUri.fsPath, 'results'), @@ -161,27 +157,41 @@ async function activate(context) { editorSessionManager, resultStore, resultPanel, - historyStore, }); const connectionsProvider = new ConnectionsTreeProvider( connectionStore, connectionManager, ); - const historyProvider = new HistoryTreeProvider(historyStore); + const sqlNavigationProvider = new SqlNavigationProvider( + connectionStore, + connectionManager, + editorSessionManager, + ); const connectionsView = vscode.window.createTreeView('simpleDb.connections', { treeDataProvider: connectionsProvider, showCollapseAll: true, }); - const historyView = vscode.window.createTreeView('simpleDb.history', { - treeDataProvider: historyProvider, - }); + const definitionContentRegistration = vscode.workspace.registerTextDocumentContentProvider( + DEFINITION_SCHEME, + sqlNavigationProvider, + ); + const definitionRegistration = vscode.languages.registerDefinitionProvider( + { language: 'sql' }, + sqlNavigationProvider, + ); + const declarationRegistration = vscode.languages.registerDeclarationProvider( + { language: 'sql' }, + sqlNavigationProvider, + ); context.subscriptions.push( connectionsView, - historyView, resultViewRegistration, + definitionContentRegistration, + definitionRegistration, + declarationRegistration, connectionsProvider, - historyProvider, + sqlNavigationProvider, editorSessionManager, ); @@ -314,6 +324,8 @@ async function activate(context) { if (answer !== 'Delete') return; await connectionManager.disconnect(profile.id); await connectionStore.delete(profile.id); + await editorConnectionStore.deleteProfile(profile.id); + editorSessionManager.refreshStatusBar(); connectionsProvider.refresh(); }); @@ -577,56 +589,6 @@ async function activate(context) { await vscode.env.clipboard.writeText(qualified); }); - registerCommand(context, 'simpleDb.showHistory', () => - vscode.commands.executeCommand('simpleDb.history.focus'), - ); - - registerCommand(context, 'simpleDb.clearHistory', async () => { - if (!historyStore.list().length) return; - const answer = await vscode.window.showWarningMessage( - 'Clear all local Simple DB query history?', - { modal: true }, - 'Clear History', - ); - if (answer === 'Clear History') await historyStore.clear(); - }); - - const historyEntry = (node) => node?.entry || historyStore.get(node?.id); - - registerCommand(context, 'simpleDb.openHistoryEntry', async (node) => { - const entry = historyEntry(node); - if (!entry) throw new Error('The history entry no longer exists.'); - const profile = connectionStore.get(entry.profileId); - if (!profile) throw new Error('The connection associated with this query no longer exists.'); - await editorSessionManager.createQuery(profile, entry.sql, { - database: entry.database, - schema: entry.schema, - }); - }); - - registerCommand(context, 'simpleDb.copyHistoryEntry', async (node) => { - const entry = historyEntry(node); - if (!entry) throw new Error('The history entry no longer exists.'); - await vscode.env.clipboard.writeText(entry.sql); - }); - - registerCommand(context, 'simpleDb.rerunHistoryEntry', async (node) => { - const entry = historyEntry(node); - if (!entry) throw new Error('The history entry no longer exists.'); - const profile = connectionStore.get(entry.profileId); - if (!profile) throw new Error('The connection associated with this query no longer exists.'); - await editorSessionManager.createQuery(profile, entry.sql, { - database: entry.database, - schema: entry.schema, - }); - await queryRunner.run('document'); - }); - - registerCommand(context, 'simpleDb.deleteHistoryEntry', async (node) => { - const entry = historyEntry(node); - if (entry) await historyStore.delete(entry.id); - }); - const connectionFileSaveDisposable = vscode.workspace.onDidSaveTextDocument( async (document) => { const filePath = document.uri.fsPath; diff --git a/src/managers/editorSessionManager.js b/src/managers/editorSessionManager.js index 6adf31d..531843a 100644 --- a/src/managers/editorSessionManager.js +++ b/src/managers/editorSessionManager.js @@ -4,17 +4,39 @@ const { randomUUID } = require('node:crypto'); const vscode = require('vscode'); const { getDatabaseEngine } = require('../databaseEngines'); +const DEFINITION_SCHEME = 'simple-db-definition'; + +function databaseForProfile(profile, preferred = '') { + return ( + preferred || + profile.database || + profile.serviceName || + (profile.engine === 'sqlite' ? 'main' : '') + ); +} + +function schemaForProfile(profile, database, preferred = '') { + if (preferred) return preferred; + if (profile.engine === 'postgresql') return 'public'; + if (profile.engine === 'sqlserver') return 'dbo'; + if (profile.engine === 'oracle') return String(profile.user || '').toUpperCase(); + if (profile.engine === 'mysql') return database || profile.database || ''; + if (profile.engine === 'sqlite') return database || 'main'; + return ''; +} + class EditorSessionManager { - constructor(connectionStore, connectionManager) { + constructor(connectionStore, connectionManager, editorConnectionStore) { this.connectionStore = connectionStore; this.connectionManager = connectionManager; + this.editorConnectionStore = editorConnectionStore; this.sessions = new Map(); this.statusBar = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, + vscode.StatusBarAlignment.Right, 100, ); this.statusBar.command = 'simpleDb.changeEditorConnection'; - this.statusBar.tooltip = 'Simple DB: change the SQL editor connection'; + this.statusBar.tooltip = 'Simple DB: select the connection for this SQL file'; this.activeEditorDisposable = vscode.window.onDidChangeActiveTextEditor(() => { this.refreshStatusBar(); @@ -22,23 +44,33 @@ class EditorSessionManager { this.transactionListener = () => this.refreshStatusBar(); this.connectionManager.on('transaction', this.transactionListener); this.connectionManager.on('change', this.transactionListener); + this.refreshStatusBar(); } _key(document) { return document.uri.toString(); } - createSession(document, profile, options = {}) { + _isSqlDocument(document) { + return document?.languageId === 'sql' && document.uri.scheme !== DEFINITION_SCHEME; + } + + _canPersist(document) { + return ( + this._isSqlDocument(document) && + !document.isUntitled && + document.uri.scheme !== 'untitled' + ); + } + + _newSession(document, profile, options = {}) { + const database = databaseForProfile(profile, options.database); const session = { id: randomUUID(), documentUri: document.uri.toString(), profileId: profile.id, - database: - options.database || - profile.database || - profile.serviceName || - (profile.engine === 'sqlite' ? 'main' : ''), - schema: options.schema || '', + database, + schema: schemaForProfile(profile, database, options.schema), runningExecutionId: null, transactionNeedsRollback: false, }; @@ -47,8 +79,39 @@ class EditorSessionManager { return session; } + async _persist(document, session) { + if (!this.editorConnectionStore || !this._canPersist(document)) return; + await this.editorConnectionStore.set(this._key(document), { + profileId: session.profileId, + database: session.database, + schema: session.schema, + }); + } + + _restore(document) { + if (!this.editorConnectionStore || !this._canPersist(document)) return undefined; + const binding = this.editorConnectionStore.get(this._key(document)); + if (!binding) return undefined; + const profile = this.connectionStore.get(binding.profileId); + if (!profile) { + void this.editorConnectionStore.delete(this._key(document)).catch(() => {}); + return undefined; + } + return this._newSession(document, profile, { + database: binding.database, + schema: binding.schema, + }); + } + + async createSession(document, profile, options = {}) { + const session = this._newSession(document, profile, options); + await this._persist(document, session); + return session; + } + get(document) { - return document ? this.sessions.get(this._key(document)) : undefined; + if (!document) return undefined; + return this.sessions.get(this._key(document)) || this._restore(document); } getActive() { @@ -72,19 +135,16 @@ class EditorSessionManager { content: `${heading}${initialSql}`, }); await vscode.window.showTextDocument(document, { preview: false }); - const session = this.createSession(document, profile, options); + const session = await this.createSession(document, profile, options); return { document, session }; } - async ensureActiveSession() { - const editor = vscode.window.activeTextEditor; - if (!editor) { - throw new Error('There is no active SQL editor.'); - } - const existing = this.get(editor.document); - if (existing) { - return existing; + async ensureSession(document) { + if (!this._isSqlDocument(document)) { + throw new Error('Open a regular SQL file before selecting a database connection.'); } + const existing = this.get(document); + if (existing) return existing; const profiles = this.connectionStore.list(); if (!profiles.length) { @@ -94,24 +154,28 @@ class EditorSessionManager { profiles.map((profile) => ({ label: `$(database) ${profile.name}`, description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + detail: databaseForProfile(profile), profile, })), { - title: 'Simple DB — Link Editor to Connection', - placeHolder: 'Select the connection for this SQL editor', + title: 'Simple DB — Select Connection for SQL File', + placeHolder: 'This SQL file will keep using the selected connection', + ignoreFocusOut: true, }, ); - if (!pick) { - return null; - } - return this.createSession(editor.document, pick.profile); + if (!pick) return null; + return this.createSession(document, pick.profile); + } + + async ensureActiveSession() { + const editor = vscode.window.activeTextEditor; + if (!editor) throw new Error('There is no active SQL editor.'); + return this.ensureSession(editor.document); } async changeActiveConnection() { const editor = vscode.window.activeTextEditor; - if (!editor) { - return; - } + if (!editor || !this._isSqlDocument(editor.document)) return; const session = this.get(editor.document); if (session?.runningExecutionId) { throw new Error('Cancel or wait for the active query before changing connections.'); @@ -121,28 +185,32 @@ class EditorSessionManager { } const profiles = this.connectionStore.list(); + if (!profiles.length) throw new Error('Create a connection in Simple DB first.'); const pick = await vscode.window.showQuickPick( profiles.map((profile) => ({ - label: profile.name, + label: `${profile.id === session?.profileId ? '$(check) ' : '$(database) '}${profile.name}`, description: getDatabaseEngine(profile.engine)?.displayName || profile.engine, + detail: databaseForProfile(profile), profile, })), - { title: 'Simple DB — Change Editor Connection' }, + { + title: 'Simple DB — Select Connection for SQL File', + placeHolder: session + ? 'Choose a different connection for this SQL file' + : 'Choose the connection this SQL file should use', + ignoreFocusOut: true, + }, ); - if (!pick) { - return; - } + if (!pick) return; if (session) { session.profileId = pick.profile.id; - session.database = - pick.profile.database || - pick.profile.serviceName || - (pick.profile.engine === 'sqlite' ? 'main' : ''); - session.schema = ''; + session.database = databaseForProfile(pick.profile); + session.schema = schemaForProfile(pick.profile, session.database); session.transactionNeedsRollback = false; + await this._persist(editor.document, session); } else { - this.createSession(editor.document, pick.profile); + await this.createSession(editor.document, pick.profile); } this.refreshStatusBar(); } @@ -158,14 +226,25 @@ class EditorSessionManager { } refreshStatusBar() { - const session = this.getActive(); - if (!session) { + const editor = vscode.window.activeTextEditor; + if (!editor || !this._isSqlDocument(editor.document)) { this.statusBar.hide(); return; } + + const session = this.get(editor.document); + if (!session) { + this.statusBar.text = '$(database) Simple DB: Select Connection'; + this.statusBar.tooltip = 'No connection is attached to this SQL file. Click to select one.'; + this.statusBar.show(); + return; + } + const profile = this.connectionStore.get(session.profileId); if (!profile) { - this.statusBar.text = '$(warning) Simple DB | connection deleted'; + this.sessions.delete(this._key(editor.document)); + this.statusBar.text = '$(warning) Simple DB: Select Connection'; + this.statusBar.tooltip = 'The connection previously attached to this SQL file no longer exists.'; this.statusBar.show(); return; } @@ -174,13 +253,21 @@ class EditorSessionManager { const connected = this.connectionManager.isConnected(profile.id); const transaction = this.connectionManager.hasTransaction(profile.id, session.id); const mode = session.transactionNeedsRollback - ? 'TX requires ROLLBACK' + ? 'ROLLBACK required' : transaction ? 'TX active' : 'Auto-commit'; - const running = session.runningExecutionId ? ' | $(sync~spin) Running' : ''; - const connectionIcon = connected ? '$(database)' : '$(circle-slash)'; - this.statusBar.text = `${connectionIcon} ${engine} | ${profile.name} | ${session.database || '-'} | ${mode}${running}`; + const running = session.runningExecutionId ? ' · $(sync~spin) Running' : ''; + const connectionIcon = connected ? '$(database)' : '$(circle-outline)'; + this.statusBar.text = `${connectionIcon} ${profile.name}${running}`; + this.statusBar.tooltip = [ + `Simple DB — ${profile.name}`, + engine, + session.database ? `Database: ${session.database}` : '', + session.schema ? `Schema: ${session.schema}` : '', + connected ? `Connected · ${mode}` : `Connects on first query · ${mode}`, + 'Click to change the connection for this SQL file.', + ].filter(Boolean).join('\n'); this.statusBar.show(); } @@ -194,5 +281,8 @@ class EditorSessionManager { } module.exports = { + DEFINITION_SCHEME, EditorSessionManager, + databaseForProfile, + schemaForProfile, }; diff --git a/src/services/queryRunner.js b/src/services/queryRunner.js index 574ca69..af30513 100644 --- a/src/services/queryRunner.js +++ b/src/services/queryRunner.js @@ -14,7 +14,6 @@ class QueryRunner { this.editorSessionManager = options.editorSessionManager; this.resultStore = options.resultStore; this.resultPanel = options.resultPanel; - this.historyStore = options.historyStore; } _configuration() { @@ -41,7 +40,6 @@ class QueryRunner { const selected = editor.document.getText(selection); return { statements: splitSqlDocument(selected, engineId), - historySql: selected, baseOffset: editor.document.offsetAt(selection.start), }; } @@ -51,7 +49,6 @@ class QueryRunner { const selected = editor.document.getText(selection); return { statements: splitSqlDocument(selected, engineId), - historySql: selected, baseOffset: editor.document.offsetAt(selection.start), }; } @@ -59,14 +56,12 @@ class QueryRunner { const statement = findStatementAtOffset(documentText, engineId, offset); return { statements: statement ? [statement] : [], - historySql: statement?.sql || '', baseOffset: 0, }; } return { statements: splitSqlDocument(documentText, engineId), - historySql: documentText, baseOffset: 0, }; } @@ -358,20 +353,6 @@ class QueryRunner { affectedRows: totalAffectedRows, }); - await this.historyStore.add({ - engine: profile.engine, - profileId: profile.id, - connectionName: profile.name, - database: session.database, - schema: session.schema, - sql: extracted.historySql, - durationMs, - rows: totalRows, - affectedRows: totalAffectedRows, - success: !failure, - error: failure?.message || '', - }); - if (schemaChanged) this.connectionManager.notifyChanged(profile.id); if (editor.document.isClosed) { await this.resultStore.deleteExecution(executionId); diff --git a/src/services/sqlNavigation.js b/src/services/sqlNavigation.js new file mode 100644 index 0000000..25485b6 --- /dev/null +++ b/src/services/sqlNavigation.js @@ -0,0 +1,226 @@ +'use strict'; + +const { getDatabaseEngine } = require('../databaseEngines'); + +const IDENTIFIER = '(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|\\[(?:[^\\]]|\\]\\])*\\]|[A-Za-z_$#][A-Za-z0-9_$#]*)'; +const QUALIFIED_IDENTIFIER = new RegExp( + `${IDENTIFIER}(?:\\s*\\.\\s*${IDENTIFIER}){0,3}`, + 'g', +); + +const OBJECT_TYPE_BY_GROUP = Object.freeze({ + tables: 'table', + views: 'view', + materializedViews: 'materializedView', + procedures: 'procedure', + packages: 'package', + indexes: 'index', + triggers: 'trigger', + sequences: 'sequence', + types: 'type', + synonyms: 'synonym', + events: 'event', +}); + +const NAVIGATION_GROUP_ORDER = Object.freeze([ + 'packages', + 'procedures', + 'views', + 'tables', + 'materializedViews', + 'types', + 'triggers', + 'sequences', + 'synonyms', + 'indexes', + 'events', +]); + +function unquoteIdentifier(identifier) { + const value = String(identifier || '').trim(); + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replaceAll('""', '"'); + } + if (value.startsWith('`') && value.endsWith('`')) { + return value.slice(1, -1).replaceAll('``', '`'); + } + if (value.startsWith('[') && value.endsWith(']')) { + return value.slice(1, -1).replaceAll(']]', ']'); + } + return value; +} + +function extractSqlReference(text, offset) { + const source = String(text || ''); + const point = Math.max(0, Math.min(Number(offset) || 0, source.length)); + QUALIFIED_IDENTIFIER.lastIndex = 0; + for (let match = QUALIFIED_IDENTIFIER.exec(source); match; match = QUALIFIED_IDENTIFIER.exec(source)) { + const start = match.index; + const end = start + match[0].length; + if (point < start || point > end) continue; + const parts = match[0].split('.').map(unquoteIdentifier).filter(Boolean); + if (!parts.length) return null; + return { + text: match[0], + parts, + start, + end, + }; + } + return null; +} + +function defaultDatabase(profile, session) { + return ( + session?.database || + profile.database || + profile.serviceName || + (profile.engine === 'sqlite' ? 'main' : '') + ); +} + +function defaultSchema(profile, session, database) { + if (session?.schema) return session.schema; + if (profile.engine === 'postgresql') return 'public'; + if (profile.engine === 'sqlserver') return 'dbo'; + if (profile.engine === 'oracle') return String(profile.user || '').toUpperCase(); + if (profile.engine === 'mysql') return database || profile.database || ''; + if (profile.engine === 'sqlite') return database || 'main'; + return ''; +} + +function navigationCandidates(profile, session, reference) { + const parts = reference?.parts || []; + if (!parts.length) return []; + + const database = defaultDatabase(profile, session); + const schema = defaultSchema(profile, session, database); + const candidates = []; + const add = (candidate) => { + const key = [ + candidate.database, + candidate.schema, + candidate.name, + (candidate.groups || []).join(','), + ].join('|').toLowerCase(); + if (!candidates.some((item) => item.key === key)) { + candidates.push({ ...candidate, key }); + } + }; + + if (parts.length === 1) { + add({ database, schema, name: parts[0] }); + return candidates; + } + + if (profile.engine === 'oracle') { + if (parts.length >= 3) { + add({ + database, + schema: parts[parts.length - 3], + name: parts[parts.length - 2], + memberName: parts[parts.length - 1], + groups: ['packages'], + }); + } else { + add({ database, schema: parts[0], name: parts[1] }); + add({ + database, + schema, + name: parts[0], + memberName: parts[1], + groups: ['packages'], + }); + } + return candidates; + } + + if (profile.engine === 'sqlserver' && parts.length >= 3) { + add({ + database: parts[parts.length - 3], + schema: parts[parts.length - 2], + name: parts[parts.length - 1], + }); + return candidates; + } + + if (profile.engine === 'mysql') { + add({ + database: parts[parts.length - 2], + schema: parts[parts.length - 2], + name: parts[parts.length - 1], + }); + return candidates; + } + + add({ + database, + schema: parts[parts.length - 2], + name: parts[parts.length - 1], + }); + return candidates; +} + +function matchingObject(objects, name) { + const exact = (objects || []).find((object) => String(object.name) === name); + if (exact) return exact; + const folded = String(name).toLocaleLowerCase('en-US'); + return (objects || []).find( + (object) => String(object.name).toLocaleLowerCase('en-US') === folded, + ); +} + +async function resolveSqlDefinition(connectionManager, profile, session, reference) { + const engineGroups = getDatabaseEngine(profile.engine)?.objectGroups || []; + const defaultGroups = NAVIGATION_GROUP_ORDER.filter((group) => + engineGroups.includes(group), + ); + + for (const candidate of navigationCandidates(profile, session, reference)) { + const groups = candidate.groups || defaultGroups; + for (const group of groups) { + let objects; + try { + objects = await connectionManager.listObjectGroup( + profile.id, + candidate.database, + candidate.schema, + group, + ); + } catch (_error) { + continue; + } + const object = matchingObject(objects, candidate.name); + if (!object) continue; + + const objectType = OBJECT_TYPE_BY_GROUP[group]; + if (!objectType) continue; + const definition = await connectionManager.getObjectDefinition( + profile.id, + candidate.database, + candidate.schema, + candidate.name, + objectType, + object, + ); + if (!definition) continue; + return { + database: candidate.database, + schema: candidate.schema, + name: candidate.name, + memberName: candidate.memberName || '', + objectType, + metadata: object, + definition: String(definition), + }; + } + } + return null; +} + +module.exports = { + extractSqlReference, + navigationCandidates, + resolveSqlDefinition, + unquoteIdentifier, +}; diff --git a/src/storage/editorConnectionStore.js b/src/storage/editorConnectionStore.js new file mode 100644 index 0000000..c6c626a --- /dev/null +++ b/src/storage/editorConnectionStore.js @@ -0,0 +1,57 @@ +'use strict'; + +const EDITOR_CONNECTIONS_KEY = 'simpleDb.editorConnections.v1'; + +class EditorConnectionStore { + constructor(state) { + this.state = state; + } + + _all() { + const stored = this.state.get(EDITOR_CONNECTIONS_KEY, {}); + return stored && typeof stored === 'object' && !Array.isArray(stored) + ? { ...stored } + : {}; + } + + get(documentUri) { + const binding = this._all()[String(documentUri)]; + return binding && typeof binding === 'object' ? { ...binding } : undefined; + } + + async set(documentUri, binding) { + const key = String(documentUri); + const all = this._all(); + all[key] = { + profileId: String(binding.profileId), + database: String(binding.database || ''), + schema: String(binding.schema || ''), + }; + await this.state.update(EDITOR_CONNECTIONS_KEY, all); + } + + async delete(documentUri) { + const key = String(documentUri); + const all = this._all(); + if (!Object.hasOwn(all, key)) return; + delete all[key]; + await this.state.update(EDITOR_CONNECTIONS_KEY, all); + } + + async deleteProfile(profileId) { + const all = this._all(); + let changed = false; + for (const [key, binding] of Object.entries(all)) { + if (binding?.profileId === profileId) { + delete all[key]; + changed = true; + } + } + if (changed) await this.state.update(EDITOR_CONNECTIONS_KEY, all); + } +} + +module.exports = { + EDITOR_CONNECTIONS_KEY, + EditorConnectionStore, +}; diff --git a/src/storage/historyStore.js b/src/storage/historyStore.js deleted file mode 100644 index bf23988..0000000 --- a/src/storage/historyStore.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict'; - -const { randomUUID } = require('node:crypto'); - -const HISTORY_KEY = 'simpleDb.queryHistory.v1'; -const MAX_SQL_CHARACTERS = 100000; - -class HistoryStore { - constructor(globalState, configurationProvider) { - this.globalState = globalState; - this.configurationProvider = configurationProvider; - this.listeners = new Set(); - } - - _configuration() { - return this.configurationProvider(); - } - - list() { - const history = this.globalState.get(HISTORY_KEY, []); - return Array.isArray(history) ? history.map((entry) => ({ ...entry })) : []; - } - - get(entryId) { - return this.list().find((entry) => entry.id === entryId); - } - - onDidChange(listener) { - this.listeners.add(listener); - return { dispose: () => this.listeners.delete(listener) }; - } - - _emit() { - for (const listener of this.listeners) { - listener(); - } - } - - async add(entry) { - const config = this._configuration(); - if (!config.enabled || config.maxEntries <= 0) { - return null; - } - - const sql = String(entry.sql || ''); - const stored = { - id: randomUUID(), - timestamp: entry.timestamp || new Date().toISOString(), - engine: entry.engine, - profileId: entry.profileId, - connectionName: entry.connectionName, - database: entry.database || '', - schema: entry.schema || '', - sql: - sql.length > MAX_SQL_CHARACTERS - ? `${sql.slice(0, MAX_SQL_CHARACTERS)}\n-- [History truncated]` - : sql, - durationMs: Number(entry.durationMs || 0), - rows: Number(entry.rows || 0), - affectedRows: Number(entry.affectedRows || 0), - success: entry.success !== false, - error: entry.error ? String(entry.error) : '', - }; - - const history = [stored, ...this.list()].slice(0, config.maxEntries); - await this.globalState.update(HISTORY_KEY, history); - this._emit(); - return stored; - } - - async delete(entryId) { - await this.globalState.update( - HISTORY_KEY, - this.list().filter((entry) => entry.id !== entryId), - ); - this._emit(); - } - - async clear() { - await this.globalState.update(HISTORY_KEY, []); - this._emit(); - } -} - -module.exports = { - HISTORY_KEY, - HistoryStore, -}; diff --git a/src/test/editorConnectionStore.test.js b/src/test/editorConnectionStore.test.js new file mode 100644 index 0000000..07b296c --- /dev/null +++ b/src/test/editorConnectionStore.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const { + EDITOR_CONNECTIONS_KEY, + EditorConnectionStore, +} = require('../storage/editorConnectionStore'); + +function memoryMemento() { + const values = new Map(); + return { + get: (key, fallback) => (values.has(key) ? values.get(key) : fallback), + update: async (key, value) => values.set(key, value), + values, + }; +} + +describe('EditorConnectionStore', () => { + it('remembers a connection independently for each saved SQL file', async () => { + const state = memoryMemento(); + const store = new EditorConnectionStore(state); + await store.set('file:///work/report.sql', { + profileId: 'oracle-production', + database: 'ORCL', + schema: 'FROXA', + }); + await store.set('file:///work/local.sql', { + profileId: 'sqlite-local', + database: 'main', + schema: 'main', + }); + + expect(store.get('file:///work/report.sql')).toEqual({ + profileId: 'oracle-production', + database: 'ORCL', + schema: 'FROXA', + }); + expect(store.get('file:///work/local.sql').profileId).toBe('sqlite-local'); + expect(state.values.get(EDITOR_CONNECTIONS_KEY)).toHaveProperty( + 'file:///work/report.sql', + ); + }); + + it('removes every saved SQL-file binding when its connection is deleted', async () => { + const store = new EditorConnectionStore(memoryMemento()); + await store.set('file:///one.sql', { profileId: 'deleted' }); + await store.set('file:///two.sql', { profileId: 'keep' }); + await store.set('file:///three.sql', { profileId: 'deleted' }); + + await store.deleteProfile('deleted'); + + expect(store.get('file:///one.sql')).toBeUndefined(); + expect(store.get('file:///three.sql')).toBeUndefined(); + expect(store.get('file:///two.sql').profileId).toBe('keep'); + }); +}); diff --git a/src/test/manifest.test.js b/src/test/manifest.test.js new file mode 100644 index 0000000..f29d9d1 --- /dev/null +++ b/src/test/manifest.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const manifest = require('../../package.json'); + +describe('extension manifest', () => { + it('ships 0.1.3 without the removed History feature', () => { + expect(manifest.version).toBe('0.1.3'); + expect(JSON.stringify(manifest).toLowerCase()).not.toContain('history'); + }); + + it('puts connection selection next to query execution in SQL editors', () => { + const editorTitle = manifest.contributes.menus['editor/title']; + expect(editorTitle.map((entry) => entry.command)).toEqual([ + 'simpleDb.changeEditorConnection', + 'simpleDb.executeCurrent', + 'simpleDb.executeDocument', + 'simpleDb.cancelQuery', + ]); + expect( + manifest.contributes.commands.find( + (command) => command.command === 'simpleDb.changeEditorConnection', + ), + ).toMatchObject({ + title: 'Select Connection for SQL File', + icon: '$(database)', + }); + }); +}); diff --git a/src/test/resultViewHtml.test.js b/src/test/resultViewHtml.test.js index c074702..dfffb4c 100644 --- a/src/test/resultViewHtml.test.js +++ b/src/test/resultViewHtml.test.js @@ -21,7 +21,7 @@ describe('lower Results view', () => { const panel = packageJson.contributes.viewsContainers.panel; const views = packageJson.contributes.views.simpleDbResults; - expect(packageJson.version).toBe('0.1.2'); + expect(packageJson.version).toBe('0.1.3'); expect(panel).toContainEqual( expect.objectContaining({ id: 'simpleDbResults', title: 'Simple DB' }), ); diff --git a/src/test/sqlNavigation.test.js b/src/test/sqlNavigation.test.js new file mode 100644 index 0000000..f4a2b78 --- /dev/null +++ b/src/test/sqlNavigation.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const { + extractSqlReference, + navigationCandidates, + resolveSqlDefinition, +} = require('../services/sqlNavigation'); + +describe('SQL navigation', () => { + it('extracts qualified and quoted identifiers at the cursor', () => { + const sql = 'SELECT * FROM "Sales"."Order Lines";'; + const reference = extractSqlReference(sql, sql.indexOf('Order') + 2); + + expect(reference.parts).toEqual(['Sales', 'Order Lines']); + }); + + it('maps SQL Server three-part names to database, schema, and object', () => { + const [candidate] = navigationCandidates( + { id: 'mssql', engine: 'sqlserver', database: 'master' }, + { database: 'master', schema: 'dbo' }, + { parts: ['SalesDb', 'reporting', 'BuildReport'] }, + ); + + expect(candidate).toMatchObject({ + database: 'SalesDb', + schema: 'reporting', + name: 'BuildReport', + }); + }); + + it('resolves an Oracle package member to the package source', async () => { + const calls = []; + const manager = { + listObjectGroup: async (_profileId, _database, schema, group) => { + calls.push({ schema, group }); + if (schema === 'FROXA' && group === 'packages') { + return [{ name: 'ORDER_API', type: 'VALID' }]; + } + return []; + }, + getObjectDefinition: async (_profileId, _database, schema, name, type) => { + expect({ schema, name, type }).toEqual({ + schema: 'FROXA', + name: 'ORDER_API', + type: 'package', + }); + return 'CREATE OR REPLACE PACKAGE ORDER_API AS\n PROCEDURE CREATE_ORDER;\nEND;'; + }, + }; + + const target = await resolveSqlDefinition( + manager, + { id: 'oracle', engine: 'oracle', serviceName: 'ORCL', user: 'froxa' }, + { database: 'ORCL', schema: 'FROXA' }, + { parts: ['ORDER_API', 'CREATE_ORDER'] }, + ); + + expect(target).toMatchObject({ + schema: 'FROXA', + name: 'ORDER_API', + memberName: 'CREATE_ORDER', + objectType: 'package', + }); + expect(target.definition).toContain('PROCEDURE CREATE_ORDER'); + expect(calls).toContainEqual({ schema: 'FROXA', group: 'packages' }); + }); + + it('resolves a PostgreSQL function through the native routine metadata', async () => { + const manager = { + listObjectGroup: async (_profileId, _database, schema, group) => + schema === 'public' && group === 'procedures' + ? [{ name: 'calculate_total', type: 'FUNCTION', signature: 'integer' }] + : [], + getObjectDefinition: async () => + 'CREATE OR REPLACE FUNCTION public.calculate_total(integer) RETURNS integer AS $$ SELECT $1 $$ LANGUAGE sql;', + }; + + const target = await resolveSqlDefinition( + manager, + { id: 'pg', engine: 'postgresql', database: 'app' }, + { database: 'app', schema: 'public' }, + { parts: ['calculate_total'] }, + ); + + expect(target).toMatchObject({ + schema: 'public', + name: 'calculate_total', + objectType: 'procedure', + }); + expect(target.definition).toContain('FUNCTION public.calculate_total'); + }); +}); diff --git a/src/views/historyTreeProvider.js b/src/views/historyTreeProvider.js deleted file mode 100644 index 99d07c7..0000000 --- a/src/views/historyTreeProvider.js +++ /dev/null @@ -1,78 +0,0 @@ -'use strict'; - -const vscode = require('vscode'); -const { getDatabaseEngine } = require('../databaseEngines'); - -function compactSql(sql) { - return String(sql || '') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 90); -} - -class HistoryTreeProvider { - constructor(historyStore) { - this.historyStore = historyStore; - this.changeEmitter = new vscode.EventEmitter(); - this.onDidChangeTreeData = this.changeEmitter.event; - this.historyDisposable = historyStore.onDidChange(() => this.refresh()); - } - - refresh() { - this.changeEmitter.fire(); - } - - getTreeItem(node) { - if (node.kind === 'empty') { - const item = new vscode.TreeItem('No queries in history'); - item.iconPath = new vscode.ThemeIcon('info'); - return item; - } - - const entry = node.entry; - const item = new vscode.TreeItem( - compactSql(entry.sql) || '(empty query)', - vscode.TreeItemCollapsibleState.None, - ); - item.contextValue = 'simpleDb.historyEntry'; - const affected = entry.affectedRows ? ` • ${entry.affectedRows} affected` : ''; - item.description = `${entry.connectionName} • ${entry.rows} rows${affected} • ${entry.durationMs} ms`; - item.iconPath = new vscode.ThemeIcon(entry.success ? 'pass-filled' : 'error'); - item.command = { - command: 'simpleDb.openHistoryEntry', - title: 'Open Query', - arguments: [node], - }; - - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown( - `**${getDatabaseEngine(entry.engine)?.displayName || entry.engine} — ${entry.connectionName}** \n`, - ); - tooltip.appendMarkdown(`${new Date(entry.timestamp).toLocaleString()} \n`); - tooltip.appendCodeblock(entry.sql, 'sql'); - if (entry.error) { - tooltip.appendMarkdown(`\nError: ${entry.error}`); - } - item.tooltip = tooltip; - return item; - } - - getChildren(node) { - if (node) { - return []; - } - const entries = this.historyStore.list(); - return entries.length - ? entries.map((entry) => ({ kind: 'history', entry })) - : [{ kind: 'empty' }]; - } - - dispose() { - this.historyDisposable.dispose(); - this.changeEmitter.dispose(); - } -} - -module.exports = { - HistoryTreeProvider, -}; diff --git a/src/views/sqlNavigationProvider.js b/src/views/sqlNavigationProvider.js new file mode 100644 index 0000000..16a319f --- /dev/null +++ b/src/views/sqlNavigationProvider.js @@ -0,0 +1,95 @@ +'use strict'; + +const vscode = require('vscode'); +const { DEFINITION_SCHEME } = require('../managers/editorSessionManager'); +const { extractSqlReference, resolveSqlDefinition } = require('../services/sqlNavigation'); + +function findSymbolPosition(text, symbol) { + if (!symbol) return new vscode.Position(0, 0); + const escaped = String(symbol).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`\\b${escaped}\\b`, 'i').exec(text); + if (!match) return new vscode.Position(0, 0); + const before = text.slice(0, match.index); + const lines = before.split('\n'); + return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); +} + +class SqlNavigationProvider { + constructor(connectionStore, connectionManager, editorSessionManager) { + this.connectionStore = connectionStore; + this.connectionManager = connectionManager; + this.editorSessionManager = editorSessionManager; + this.documents = new Map(); + this.serial = 0; + } + + provideTextDocumentContent(uri) { + return this.documents.get(uri.toString()) || '-- Simple DB definition is no longer available.'; + } + + async _location(document, position, token) { + if (document.uri.scheme === DEFINITION_SCHEME || token.isCancellationRequested) { + return null; + } + const reference = extractSqlReference( + document.getText(), + document.offsetAt(position), + ); + if (!reference) return null; + + const session = await this.editorSessionManager.ensureSession(document); + if (!session || token.isCancellationRequested) return null; + const profile = this.connectionStore.get(session.profileId); + if (!profile) return null; + + await this.connectionManager.ensureConnected(profile.id); + if (token.isCancellationRequested) return null; + const target = await resolveSqlDefinition( + this.connectionManager, + profile, + session, + reference, + ); + if (!target || token.isCancellationRequested) return null; + + const qualified = [target.schema, target.name].filter(Boolean).join('.'); + const header = `-- Simple DB | ${profile.name} | ${target.objectType} ${qualified}\n\n`; + const content = `${header}${target.definition}`; + const serial = this.serial; + this.serial += 1; + const uri = vscode.Uri.from({ + scheme: DEFINITION_SCHEME, + path: `/${encodeURIComponent(target.name)}.sql`, + query: `profile=${encodeURIComponent(profile.id)}&v=${serial}`, + }); + if (this.documents.size >= 50) { + this.documents.delete(this.documents.keys().next().value); + } + this.documents.set(uri.toString(), content); + + const symbol = target.memberName || target.name; + return new vscode.Location(uri, findSymbolPosition(content, symbol)); + } + + async provideDefinition(document, position, token) { + try { + return await this._location(document, position, token); + } catch (error) { + vscode.window.showErrorMessage(`Simple DB: could not find definition — ${error.message}`); + return null; + } + } + + async provideDeclaration(document, position, token) { + return this.provideDefinition(document, position, token); + } + + dispose() { + this.documents.clear(); + } +} + +module.exports = { + SqlNavigationProvider, + findSymbolPosition, +};