Feat/sql explorer (#174)

* chore: reworking plugin internals into modules

* feat: settings module is now working, added debug module, enabled new api module

* chore: restoring external API

* feat: refactoring project to use proper inversion of control

* feat: plugins can now be loaded in any order

* chore: final file migration, all typescript is in modules now

* chore: fixing dependencies of cellParser

* feat: csv and json views are only registered when they are not colliding with other plugins

* feat: fixed library behaviour on canvas

* feat: added ability to hide columns from csv files

* feat: adding global tables support (wip)

* feat: global tables full implementation

* feat: sqlite databases can now be previewed in explorer

* feat: improved explorer view

* feat: highlighting code in the copy code modal

* chore: fixing typechecks

* chore: adding missing changeset
This commit is contained in:
Kacper Kula 2025-08-10 11:08:13 +02:00 committed by GitHub
parent 2bfd2060d7
commit 531d486d06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1034 additions and 114 deletions

View file

@ -0,0 +1,5 @@
---
"sqlseal": minor
---
Added SQLSeal Explorer that makes it easy to work on new queries

View file

@ -0,0 +1,5 @@
---
"sqlseal": minor
---
sqlite databases can now be previewed using explorer view

View file

@ -0,0 +1,5 @@
---
"sqlseal": patch
---
highlighting code in the copy modal

View file

@ -13,7 +13,7 @@ import { ColumnDefinition } from "../../../utils/types";
import { sanitise } from "../../../utils/sanitiseColumn";
function toObjectArray(stmt: Statement) {
export function toObjectArray(stmt: Statement) {
const ret = []
while (stmt.step()) {
ret.push(stmt.getAsObject())

View file

@ -26,11 +26,12 @@ export class CodeblockProcessor extends MarkdownRenderChild {
private source: string,
private ctx: MarkdownPostProcessorContext,
private rendererRegistry: RendererRegistry,
private db: SqlSealDatabase,
private db: Pick<SqlSealDatabase, 'select' | 'explain'>,
private cellParser: ModernCellParser,
private settings: Settings,
private app: App,
private sync: Sync,
private tq: typeof transformQuery = transformQuery
) {
super(el);
@ -118,7 +119,8 @@ export class CodeblockProcessor extends MarkdownRenderChild {
const registeredTablesForContext =
await this.sync.getTablesMappingForContext(this.sourceKey);
const res = transformQuery(this.query, registeredTablesForContext);
// Transforming Query
const res = this.tq(this.query, registeredTablesForContext);
const transformedQuery = res.sql;
if (this.flags.refresh) {

View file

@ -0,0 +1,134 @@
import { EditorState } from "@codemirror/state";
import { CodeblockProcessor } from "../editor/codeblockHandler/CodeblockProcessor";
import { EditorView, keymap } from "@codemirror/view";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
import { EditorMenuBar } from "./EditorMenuBar";
import { MemoryDatabase } from "./database/memoryDatabase";
import { SchemaVisualiser } from "./schemaVisualiser/SchemaVisualiser";
import { activateView } from "./activateView";
import { App } from "obsidian";
import { GLOBAL_TABLES_VIEW_TYPE } from "../globalTables/GlobalTablesView";
import { GridApi } from "ag-grid-community";
type CodeblockProcessorWrapper = (
el: HTMLElement,
source: string,
) => Promise<CodeblockProcessor>;
const DEFAULT_QUERY = "SELECT *\nFROM files\nLIMIT 10";
export class Editor {
constructor(
private codeblockProcessorGenerator: CodeblockProcessorWrapper,
private viewPluginGenerator: ViewPluginGeneratorType,
private app: App,
private query: string = DEFAULT_QUERY,
private db: MemoryDatabase | null = null
) {}
codeblockElement: HTMLElement | null = null;
render(el: HTMLElement) {
el.empty();
const menuBar = new EditorMenuBar(!!this.db);
const c = el.createDiv({ cls: "sqlseal-explorer-container" });
menuBar.render(c);
const grid = c.createDiv({ cls: "sqlseal-explorer-grid-container" });
const codeSidebar = grid.createDiv({ cls: "sqlseal-explorer-code" });
codeSidebar.classList.add("cm-sqlseal-explorer");
// codeSidebar.textContent = "CODE"
const rightPane = grid.createDiv({ cls: 'sqlseal-explorer-right-pane' })
const contentSidebar = rightPane.createDiv({ cls: "sqlseal-explorer-render" });
const structure = rightPane.createDiv({ cls: 'sqlseal-explorer-structure' })
structure.hide()
this.codeblockElement = contentSidebar;
this.createEditor(codeSidebar);
this.createCodeblockProcessor(this.codeblockElement, this.query);
if (this.db) {
// rendering structure
const schema = this.db.getSchema()
const vis = new SchemaVisualiser(schema)
vis.show(structure)
} else {
}
const events = menuBar.events;
events.on("play", () => {
this.play()
});
events.on('structure', (b) => {
if (structure.checkVisibility({ checkVisibilityCSS: true })) {
// structure visible
structure.hide()
contentSidebar.show();
(b as any).setIcon('database')
} else {
// structure invisible
structure.show()
contentSidebar.hide();
(b as any).setIcon('table')
}
})
events.on('globals', () => {
activateView(this.app, GLOBAL_TABLES_VIEW_TYPE)
})
}
createCodeblockProcessor(el: HTMLElement, source: string) {
return this.codeblockProcessorGenerator(el, source);
}
editor: EditorView;
createEditor(el: HTMLElement) {
const state = EditorState.create({
doc: this.query,
extensions: [
// this.createCustomLanguage(),
// this.createChangeListener(),
this.createKeyBindings(),
this.viewPluginGenerator(true),
EditorView.theme({
"&": { height: "100%" },
".cm-scroller": { fontFamily: "monospace" },
".cm-content": {
caretColor: "var(--color-base-100)",
},
}),
],
});
this.editor = new EditorView({
state,
parent: el,
});
}
createKeyBindings() {
return keymap.of([
{
key: "Mod-r", // Save shortcut example
run: () => {
this.play()
return true
},
},
]);
}
async play() {
this.query = this.editor.state.doc.toString();
if (this.codeblockElement) {
const processor = await this.createCodeblockProcessor(this.codeblockElement, this.query);
// const renderer = processor.renderer
// if ('communicator' in renderer && 'gridApi' in (renderer as any)['communicator']) {
// const api: GridApi = (renderer.communicator as any).gridApi
// api.setGridOption('paginationAutoPageSize', true)
// }
}
}
}

View file

@ -0,0 +1,57 @@
import { args, BusBuilder } from "@hypersphere/omnibus"
import { ButtonComponent } from "obsidian"
export class EditorMenuBar {
bus = new BusBuilder()
.register('play', args<[]>())
.register('structure', args<[ButtonComponent]>())
.register('globals', args<[]>())
.build()
constructor(private fileDatabasePreview: boolean = false) {
}
strucureButton: ButtonComponent
render(el: HTMLElement) {
const container = el.createDiv({ cls: 'sqlseal-menubar-container' })
new ButtonComponent(container)
.setIcon('play')
.setClass('sqlseal-menubar-button')
.setTooltip('Run (CMD+R)', {
placement: 'bottom',
delay: 1
})
.onClick(() => this.bus.trigger('play'))
if (this.fileDatabasePreview) {
const b = new ButtonComponent(container)
.setIcon('database')
.setClass('sqlseal-menubar-button')
.setTooltip('Structure', {
placement: 'bottom',
delay: 1
})
.onClick(() => {
const but = b
this.bus.trigger('structure', but as any)
})
}
container.createDiv({ cls: 'separator' })
if (!this.fileDatabasePreview) {
// global button
const b = new ButtonComponent(container)
.setIcon('bolt')
.setClass('sqlseal-menubar-button')
.setTooltip('Global Tables', { delay: 1, placement: 'bottom' })
.onClick(() => {
this.bus.trigger('globals')
})
}
}
get events() {
return this.bus.getRegistrator()
}
}

View file

@ -0,0 +1,95 @@
import { FileView, IconName, MarkdownPostProcessorContext, Menu, TextFileView, TFile, WorkspaceLeaf } from "obsidian";
import { MemoryDatabase } from "./database/memoryDatabase";
import { DatabaseManager } from "./database/databaseManager";
import { TableInfo } from "./schemaVisualiser/TableVisualiser";
import { SchemaVisualiser } from "./schemaVisualiser/SchemaVisualiser";
import { ExplorerView } from "./explorer/ExplorerView";
import { Editor } from "./Editor";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
import { CodeblockProcessor } from "../editor/codeblockHandler/CodeblockProcessor";
import { RendererRegistry } from "../editor/renderer/rendererRegistry";
import { ModernCellParser } from "../syntaxHighlight/cellParser/ModernCellParser";
import { Settings } from "../settings/Settings";
import { Sync } from "../sync/sync/sync";
export const FILE_DATABASE_VIEW = 'sqlseal-sqlite-file-view'
const INITIAL_QUERY = "SELECT name\nFROM sqlite_master\nWHERE type='table'"
export class FileDatabaseExplorerView extends FileView {
constructor(
leaf: WorkspaceLeaf,
private manager: DatabaseManager,
private viewPluginGenerator: ViewPluginGeneratorType,
private rendererRegistry: RendererRegistry,
private cellParser: ModernCellParser,
private settings: Settings,
private sync: Sync,
) {
super(leaf)
}
getViewType(): string {
return FILE_DATABASE_VIEW
}
getDisplayText(): string {
return this.file?.basename || 'Database'
}
async onOpen() {
}
onPaneMenu(menu: Menu, source: "more-options" | "tab-header" | string): void {
menu.addItem(i => i.setTitle('test'))
}
db: MemoryDatabase
async onLoadFile(file: TFile): Promise<void> {
const db = await this.manager.getDatabaseConnection(file)
await db.connect()
this.db = db
// GETTING ALL TABLES
this.schema = db.getSchema()
this.render()
}
schema: TableInfo[]
render() {
const codeblockProcessorGenerator = async (el: HTMLElement, source: string) => {
const ctx: MarkdownPostProcessorContext = {
docId: "",
sourcePath: "",
frontmatter: {},
} as any;
const processor = new CodeblockProcessor(
el,
source,
ctx,
this.rendererRegistry,
this.db as any, // FIXME
this.cellParser,
this.settings,
this.app,
this.sync,
);
await processor.onload();
await processor.render();
return processor
}
const editor = new Editor(codeblockProcessorGenerator, this.viewPluginGenerator,this.app, INITIAL_QUERY, this.db)
editor.render(this.contentEl)
// const vis = new SchemaVisualiser(this.schema)
// vis.show(container)
}
getIcon(): IconName {
return 'database'
}
}

View file

@ -0,0 +1,80 @@
import { makeInjector } from "@hypersphere/dity";
import { ExplorerModule } from "./module";
import { addIcon, App, Plugin, WorkspaceLeaf } from "obsidian";
import { SqlSealDatabase } from "../database/database";
import { ModernCellParser } from "../syntaxHighlight/cellParser/ModernCellParser";
import { RendererRegistry } from "../editor/renderer/rendererRegistry";
import { Sync } from "../sync/sync/sync";
import { Settings } from "../settings/Settings";
import { ExplorerView } from "./explorer/ExplorerView";
import { ViewPlugin } from "@codemirror/view";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
import { FILE_DATABASE_VIEW, FileDatabaseExplorerView } from "./FileDatabaseExplorerView";
import { DatabaseManager } from "./database/databaseManager";
import { activateView } from "./activateView";
// @ts-ignore: Handled by esbuild
import SQLSealIcon from "./sqlseal-bw.svg";
@(makeInjector<ExplorerModule, "factory">()([
"plugin",
"app",
"db",
"cellParser",
"rendererRegistry",
"sync",
"settings",
"viewPluginGenerator",
"dbManager"
]))
export class InitFactory {
make(
plugin: Plugin,
app: App,
db: SqlSealDatabase,
cellParser: ModernCellParser,
rendererRegistry: RendererRegistry,
sync: Sync,
settings: Settings,
viewPluginGenerator: ViewPluginGeneratorType,
dbManager: DatabaseManager
) {
return () => {
plugin.registerView(
"sqlseal-explorer-view",
(leaf) =>
new ExplorerView(
leaf,
rendererRegistry,
db,
cellParser,
settings,
sync,
viewPluginGenerator
),
);
addIcon("logo-sqlseal", SQLSealIcon);
plugin.addRibbonIcon("logo-sqlseal", "SQLSeal Explorer", () =>
activateView(plugin.app, "sqlseal-explorer-view"),
);
// Register for extenion
plugin.registerView(FILE_DATABASE_VIEW, (leaf) => {
return new FileDatabaseExplorerView(leaf, dbManager, viewPluginGenerator, rendererRegistry, cellParser, settings, sync)
})
plugin.registerExtensions(['sqlite'], FILE_DATABASE_VIEW)
plugin.addCommand({
id: 'sqlseal-command-explorer',
name: 'Open SQLSeal Explorer',
icon: 'logo-sqlseal',
callback: () => activateView(app, 'sqlseal-explorer-view')
})
};
}
}

View file

@ -0,0 +1,22 @@
import { App, WorkspaceLeaf } from "obsidian";
export const activateView = async (app: App, name: string) => {
const { workspace } = app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(name);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
leaf = workspace.getLeaf("tab");
if (!leaf) {
return;
}
await leaf.setViewState({ type: name, active: true });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
};

View file

@ -0,0 +1,32 @@
import { TFile } from "obsidian";
import { MemoryDatabase } from "./memoryDatabase";
import wasmBinary from '../../../../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm'
import initSqlJs from '@jlongster/sql.js';
export class DatabaseManager {
constructor() {}
private sql: initSqlJs.SqlJsStatic | null = null
private async getConnection() {
if (this.sql) {
return this.sql
}
const SQL = await initSqlJs({
wasmBinary: wasmBinary,
});
this.sql = SQL
return SQL
}
async getDatabaseConnection(file: TFile) {
// FIXME: connecting to database
const connection = await this.getConnection()
const db = new MemoryDatabase(connection, file);
await db.connect()
return db
}
getGlobalDatabaseConnection() {}
}

View file

@ -0,0 +1,52 @@
import { TFile } from "obsidian";
import { BindParams, Database, ParamsObject } from "sql.js";
import { toObjectArray } from "../../database/worker/database";
import { TableInfo } from "../schemaVisualiser/TableVisualiser";
export class MemoryDatabase {
private db: Database
constructor(private sql: initSqlJs.SqlJsStatic, private file: TFile) {
}
async connect() {
const binary = await this.file.vault.readBinary(this.file)
this.db = new this.sql.Database(Buffer.from(binary))
}
query<T = ParamsObject>(query: string, params: BindParams = null): { data: T[], columns: keyof T } {
const stmt = this.db.prepare(query, params)
const data = toObjectArray(stmt)
const columns = stmt.getColumnNames()
stmt.free()
return { data: data, columns } as any
}
select<T = ParamsObject>(query: string, params: BindParams = null) {
return this.query<T>(query, params)
}
explain() {
return {}
}
allTables() {
return this.query<{name: string}>(`select name from sqlite_master where type='table'`)
}
getColumns(tableName: string) {
return this.query<{ name: string, type: string }>('select name, type from pragma_table_info(@tableName)', { '@tableName': tableName })
}
getSchema(): TableInfo[] {
const tables = this.allTables().data
return tables.map(t => {
const columns = this.getColumns(t.name)
return {
name: t.name,
columns: columns.data
}
})
}
}

View file

@ -0,0 +1,83 @@
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, ViewPlugin } from "@codemirror/view";
import {
ItemView,
MarkdownPostProcessorContext,
WorkspaceLeaf,
} from "obsidian";
import { CodeblockProcessor } from "../../editor/codeblockHandler/CodeblockProcessor";
import { SqlSealDatabase } from "../../database/database";
import { RendererRegistry } from "../../editor/renderer/rendererRegistry";
import { CellParser } from "../../../../types-package/dist/src/cellParser";
import { Settings } from "../../settings/Settings";
import { ModernCellParser } from "../../syntaxHighlight/cellParser/ModernCellParser";
import { Sync } from "../../sync/sync/sync";
import { Language } from "@codemirror/language";
import { ViewPluginGeneratorType } from "../../syntaxHighlight/viewPluginGenerator";
import { Editor } from "../Editor";
import { GridApi } from "ag-grid-community";
export class ExplorerView extends ItemView {
constructor(
leaf: WorkspaceLeaf,
private rendererRegistry: RendererRegistry,
private db: Pick<SqlSealDatabase, 'select' | 'explain'>,
private cellParser: ModernCellParser,
private settings: Settings,
private sync: Sync,
private viewPluginGenerator: ViewPluginGeneratorType
) {
super(leaf);
}
private editor: EditorView;
getViewType() {
return "sqlseal-explorer-view";
}
getDisplayText() {
return "SQLSeal Explorer";
}
getIcon() {
return 'logo-sqlseal'
}
async onOpen() {
const content = this.contentEl;
const codeblockProcessorGenerator = async (el: HTMLElement, source: string) => {
const ctx: MarkdownPostProcessorContext = {
docId: "",
sourcePath: "",
frontmatter: {},
} as any;
const processor = new CodeblockProcessor(
el,
source,
ctx,
this.rendererRegistry,
this.db,
this.cellParser,
this.settings,
this.app,
this.sync,
);
await processor.onload();
// Resizing
const renderer = processor.renderer
if ('communicator' in renderer && 'gridApi' in (renderer as any)['communicator']) {
const api: GridApi = (renderer.communicator as any).gridApi
api.setGridOption('paginationAutoPageSize', true)
}
await processor.render();
return processor
}
const editor = new Editor(codeblockProcessorGenerator, this.viewPluginGenerator, this.app)
editor.render(content)
}
}

View file

@ -0,0 +1,81 @@
@use '../schemaVisualiser/schemaVisualiser.scss';
.sqlseal-explorer-container {
height: 100%;
width: 100%;
max-height: 100%;
display: grid;
grid-template-rows: auto 1fr;
}
.sqlseal-explorer-grid-container {
display: grid;
grid-template-columns: 50% 50%;
gap: 1em;
height: 100%;
width: 100%;
padding: 1em;
}
.sqlseal-explorer-code {
// background: orange;
}
.sqlseal-explorer-render {
// background: red;
overflow: scroll;
}
.sqlseal-menubar-container {
padding: 0.2em 0;
display: flex;
}
.sqlseal-menubar-container .separator {
flex: 1;
}
.sqlseal-menubar-button {
border: 0;
--input-shadow: none;
}
.sqlseal-explorer-right-pane {
display: grid;
container-type: inline-size;
height: 100%;
// place-content: center;
// align-content: stretch;
// grid-template-rows: 100%;
// width: 100%;
// height: 100%;
& > * {
// width: 100%;
// height: 100%;
grid-area: 1 / 1;
height: 100%;
}
}
.sqlseal-explorer-right-pane {
& .ag-root-wrapper {
height: 100%;
}
& .ag-root-wrapper-body {
flex: 1;
}
& .ag-paging-page-size {
display: none;
}
& .ag-paging-row-summary-panel {
display: none;
}
& .ag-paging-panel {
justify-content: center;
}
}

View file

@ -0,0 +1,31 @@
import { asClass, asFactory, buildContainer } from "@hypersphere/dity";
import { InitFactory } from "./InitFactory";
import { App, Plugin } from "obsidian";
import { ModernCellParser } from "../syntaxHighlight/cellParser/ModernCellParser";
import { SqlSealDatabase } from "../database/database";
import { Settings } from "../settings/Settings";
import { Sync } from "../sync/sync/sync";
import { RendererRegistry } from "../editor/renderer/rendererRegistry";
import { ViewPlugin } from "@codemirror/view";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
import { DatabaseManager } from "./database/databaseManager";
export const explorer = buildContainer((c) =>
c
.register({
init: asFactory(InitFactory),
dbManager: asClass(DatabaseManager)
})
.externals<{
app: App,
cellParser: ModernCellParser,
db: SqlSealDatabase,
settings: Settings,
sync: Sync
rendererRegistry: RendererRegistry,
plugin: Plugin,
viewPluginGenerator: ViewPluginGeneratorType
}>(),
);
export type ExplorerModule = typeof explorer;

View file

@ -0,0 +1,14 @@
import { TableInfo, TableVisualiser } from "./TableVisualiser"
export class SchemaVisualiser {
constructor(private info: TableInfo[]) { }
show(container: HTMLElement) {
container.empty()
const c = container.createDiv({ cls: 'sqlseal-tv-container' })
this.info
.map(i => new TableVisualiser(i))
.forEach(v => v.show(c))
}
}

View file

@ -0,0 +1,30 @@
export interface ColumnInfo {
name: string
type: string
}
export interface TableInfo {
name: string
columns: ColumnInfo[]
}
export class TableVisualiser {
constructor(private info: TableInfo) {
}
show(el: HTMLElement) {
const cont = el.createDiv({ cls: 'sqlseal-tv-table' })
cont.createDiv({ cls: 'sqlseal-tv-table-name', text: this.info.name })
const columns = cont.createEl('ul', { cls: 'sqlseal-tv-columns' })
this.info.columns.forEach(info => {
columns.appendChild(this.createColumn(info))
})
}
createColumn(info: ColumnInfo) {
const row = createEl('li')
row.createEl('span', { text: info.name, cls: 'sqlseal-tv-column-name' })
row.createEl('span', { text: info.type, cls: 'sqlseal-tv-column-class' })
return row
}
}

View file

@ -0,0 +1,29 @@
.sqlseal-tv-container {
gap: 1em;
}
.sqlseal-tv-table {
display: inline-block;
border: 1px solid black;
margin-right: 1em;
margin-bottom: 1em;
}
.sqlseal-tv-table-name {
padding: 0.5em 1em;
background: #DDD;
border-bottom: 2px solid black;
}
.sqlseal-tv-columns {
list-style: none;
margin: 0;
padding: 0.5em 1em;
}
.sqlseal-tv-columns li {
display: flex;
flex-direction: row;
gap: 1em;
justify-content: space-between;
}

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,46 +1,27 @@
import { makeInjector } from "@hypersphere/dity";
import { GlobalTablesModule } from "./module";
import { addIcon, App, Plugin, WorkspaceLeaf } from "obsidian";
import { App, Plugin, WorkspaceLeaf } from "obsidian";
import { GLOBAL_TABLES_VIEW_TYPE, GlobalTablesView } from "./GlobalTablesView";
// @ts-ignore: Handled by esbuild
import SQLSealIcon from './sqlseal-bw.svg'
import { Sync } from "../sync/sync/sync";
import { activateView } from "../explorer/activateView";
@(makeInjector<GlobalTablesModule, 'factory'>()(
['plugin', 'app', 'sync']
))
@(makeInjector<GlobalTablesModule, "factory">()(["plugin", "app", "sync"]))
export class GlobalTablesViewRegister {
make(plugin: Plugin, app: App, sync: Sync) {
make(plugin: Plugin, app: App, sync: Sync) {
const activateView = async () => {
const { workspace } = plugin.app;
return () => {
plugin.registerView(
GLOBAL_TABLES_VIEW_TYPE,
(leaf) => new GlobalTablesView(leaf, app.vault, sync),
);
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(GLOBAL_TABLES_VIEW_TYPE);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
leaf = workspace.getLeaf('tab')
if (!leaf) { return }
await leaf.setViewState({ type: GLOBAL_TABLES_VIEW_TYPE, active: true });
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf);
}
return () => {
plugin.registerView(GLOBAL_TABLES_VIEW_TYPE, leaf => new GlobalTablesView(leaf, app.vault, sync))
// SQLSeal Icon
addIcon('logo-sqlseal', SQLSealIcon)
plugin.addRibbonIcon('logo-sqlseal', 'SQLSeal Global Tables', activateView)
}
}
}
plugin.addCommand({
id: 'sqlseal-command-global-tables',
name: 'Open global tables configuration',
icon: 'logo-sqlseal',
callback: () => activateView(app, GLOBAL_TABLES_VIEW_TYPE)
})
};
}
}

View file

@ -12,7 +12,8 @@ type InitFn = () => void
'sync.init',
'debug.init',
'api.init',
'globalTables.init'
'globalTables.init',
'explorer.init'
]))
export class Init {
async make(
@ -23,7 +24,8 @@ export class Init {
syncInit: InitFn,
debugInit: InitFn,
apiInit: InitFn,
globalTablesInit: InitFn
globalTablesInit: InitFn,
explorerInit: InitFn
) {
return () => {
settingsInit()
@ -34,6 +36,7 @@ export class Init {
// debugInit()
apiInit()
globalTablesInit()
explorerInit()
}
}
}

View file

@ -11,6 +11,7 @@ import { contextMenu } from '../contextMenu/module'
import { debugModule } from '../debug/module'
import { apiModule } from '../api/module'
import { globalTables } from '../globalTables/module'
import { explorer } from '../explorer/module'
const obsidian = buildContainer(c => c
.externals<{
@ -32,7 +33,8 @@ export const mainModule = buildContainer(c => c
contextMenu,
debug: debugModule,
api: apiModule,
globalTables
globalTables,
explorer
})
.register({
init: asFactory(Init)
@ -59,6 +61,7 @@ export const mainModule = buildContainer(c => c
'settings.app': 'obsidian.app',
'settings.plugin': 'obsidian.plugin',
'settings.cellParser': 'syntaxHighlight.cellParser',
'settings.viewPluginGenerator': 'syntaxHighlight.viewPluginGenerator'
})
.resolve({
'syntaxHighlight.app': 'obsidian.app',
@ -84,6 +87,16 @@ export const mainModule = buildContainer(c => c
'globalTables.app': 'obsidian.app',
'globalTables.sync': 'sync.syncBus'
})
.resolve({
'explorer.app': 'obsidian.app',
'explorer.cellParser': 'syntaxHighlight.cellParser',
'explorer.db': 'db.db',
'explorer.settings': 'settings.settings',
'explorer.plugin': 'obsidian.plugin',
'explorer.rendererRegistry': 'editor.rendererRegistry',
'explorer.sync': 'sync.syncBus',
'explorer.viewPluginGenerator': 'syntaxHighlight.viewPluginGenerator'
})
)
export type MainModule = typeof mainModule

View file

@ -2,8 +2,6 @@ import { makeInjector } from '@hypersphere/dity';
import { App, PluginSettingTab, Setting, Plugin } from 'obsidian';
import { SettingsModule } from './module';
import { Settings } from './Settings';
import { SettingsCSVControls } from './settingsTabSection/SettingsCSVControls';
import { SettingsJsonControls } from './settingsTabSection/SettingsJsonControls';
import { SettingsControls } from './settingsTabSection/SettingsControls';
export interface SQLSealSettings {

View file

@ -5,16 +5,18 @@ import { SQLSealSettingsTab } from "./SQLSealSettingsTab";
import { Settings } from "./Settings";
import { SettingsCSVControls } from "./settingsTabSection/SettingsCSVControls";
import { SettingsJsonControls } from "./settingsTabSection/SettingsJsonControls";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
@(makeInjector<SettingsModule>()(["plugin", "settingsTab", "app", "settings"]))
@(makeInjector<SettingsModule>()(["plugin", "settingsTab", "app", "settings", "viewPluginGenerator"]))
export class SettingsInit {
async make(
plugin: Plugin,
settingsTab: SQLSealSettingsTab,
app: App,
settings: Settings,
viewPluginGenerator: ViewPluginGeneratorType
) {
const csvControl = new SettingsCSVControls(settings, app, plugin);
const csvControl = new SettingsCSVControls(settings, app, plugin, viewPluginGenerator);
const jsonControl = new SettingsJsonControls(settings, app, plugin);
const controls = [csvControl, jsonControl];

View file

@ -1,8 +1,18 @@
import { App, Modal, Notice, Setting, TFile } from "obsidian";
import { sanitise } from "../../../utils/sanitiseColumn";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { ViewPluginGeneratorType } from "../../syntaxHighlight/viewPluginGenerator";
const query = (tableName: string, path: string) => `\`\`\`sqlseal
TABLE ${tableName} = file(${path})
SELECT * FROM ${tableName}
LIMIT 100
\`\`\``
export class CodeSampleModal extends Modal {
constructor(app: App, private file: TFile) {
constructor(app: App, private file: TFile, private viewPluginGenerator: ViewPluginGeneratorType) {
super(app);
}
@ -10,29 +20,37 @@ export class CodeSampleModal extends Modal {
const {contentEl} = this;
contentEl.createEl('h2', {text: 'SQLSeal Code'});
// Add container for code
const textArea = contentEl.createEl('textarea', {
cls: 'sql-seal-modal-code',
attr: {
rows: '10'
}
});
contentEl.classList.add('sqlseal-modal-copycode')
// Setup actual editor here
const tableName = sanitise(this.file.basename)
textArea.setText(`\`\`\`sqlseal
TABLE ${tableName} = file(${this.file.path})
const q = query(tableName, this.file.path)
SELECT * FROM ${tableName}
LIMIT 100
\`\`\``);
const state = EditorState.create({
doc: q,
extensions: [
this.viewPluginGenerator(false),
EditorView.theme({
"&": { height: "100%" },
".cm-scroller": { fontFamily: "monospace" },
".cm-content": {
caretColor: "var(--color-base-100)",
},
}),
]
})
new EditorView({
state,
parent: contentEl.createDiv({ cls: 'cm-sqlseal-overlay' }),
});
// Add copy button
new Setting(contentEl)
.addButton(button => button
.setButtonText('Copy to Clipboard')
.onClick(async () => {
await navigator.clipboard.writeText(textArea.getText());
await navigator.clipboard.writeText(q);
new Notice('Copied to clipboard!');
}));
}

View file

@ -4,12 +4,14 @@ import { SettingsFactory } from "./settingsFactory";
import { SQLSealSettingsTab } from "./SQLSealSettingsTab";
import { SettingsInit } from "./init";
import { ModernCellParser } from "../syntaxHighlight/cellParser/ModernCellParser";
import { ViewPluginGeneratorType } from "../syntaxHighlight/viewPluginGenerator";
export const settingsModule = buildContainer(c => c
.externals<{
'plugin': Plugin,
'app': App,
'cellParser': ModernCellParser
'cellParser': ModernCellParser,
'viewPluginGenerator': ViewPluginGeneratorType
}>()
.register({
'settings': asFactory(SettingsFactory),

View file

@ -5,10 +5,15 @@ import {
} from "../utils/viewInspector";
import { SettingsControls } from "./SettingsControls";
import { CSV_VIEW_EXTENSIONS, CSV_VIEW_TYPE, CSVView } from "../view/CSVView";
import { ViewPluginGeneratorType } from "../../syntaxHighlight/viewPluginGenerator";
export class SettingsCSVControls extends SettingsControls {
private registeredView: string | null = null;
constructor(settings: Settings, app: App, plugin: Plugin, private viewPluginGenerator: ViewPluginGeneratorType) {
super(settings, app, plugin)
}
register() {
if (this.settings.get("enableViewer")) {
const view = checkTypeViewAvaiability(this.app, CSV_VIEW_EXTENSIONS[0]);
@ -19,7 +24,7 @@ export class SettingsCSVControls extends SettingsControls {
this.plugin.registerView(
CSV_VIEW_TYPE,
(leaf) => new CSVView(leaf, this.settings),
(leaf) => new CSVView(leaf, this.settings, this.viewPluginGenerator),
);
this.plugin.registerExtensions(CSV_VIEW_EXTENSIONS, CSV_VIEW_TYPE);
}

View file

@ -14,6 +14,7 @@ import { DeleteConfirmationModal } from "../modal/deleteConfirmationModal";
import { CSVColumnContextMenu } from "../menu/csvColumnContextMenu";
import { AgColumn, Column } from "ag-grid-community";
import { CSVViewMenuBar } from "./CSVViewMenuBar";
import { ViewPluginGeneratorType } from "../../syntaxHighlight/viewPluginGenerator";
const delay = (n: number) => new Promise((resolve) => setTimeout(resolve, n));
@ -28,6 +29,7 @@ export class CSVView extends TextFileView {
constructor(
leaf: WorkspaceLeaf,
private readonly settings: Settings,
private readonly viewPluginGenerator: ViewPluginGeneratorType
) {
super(leaf);
}
@ -334,7 +336,7 @@ export class CSVView extends TextFileView {
if (!this.file) {
return;
}
const modal = new CodeSampleModal(this.app, this.file);
const modal = new CodeSampleModal(this.app, this.file, this.viewPluginGenerator);
modal.open();
})

View file

@ -17,6 +17,11 @@ import { FilePathWidget } from './widgets/FilePathWidget';
import { RendererRegistry } from '../../editor/renderer/rendererRegistry';
import { SQLSealLangDefinition } from '../../editor/parser';
interface CodeBlockMatch {
startIndex: number,
content: string
}
const markDecorations = {
blockFlag: Decoration.mark({ class: 'cm-sqlseal-block-flag' }),
blockQuery: Decoration.mark({ class: 'cm-sqlseal-block-query' }),
@ -39,7 +44,7 @@ export class SQLSealViewPlugin implements PluginValue {
private readonly app: App;
private readonly renderers: RendererRegistry;
constructor(view: EditorView, app: App, renderers: RendererRegistry) {
constructor(view: EditorView, app: App, renderers: RendererRegistry, private allIsCode: boolean) {
this.app = app;
this.renderers = renderers;
this.decorations = this.buildDecorations(view);
@ -69,59 +74,134 @@ export class SQLSealViewPlugin implements PluginValue {
return results
}
private buildDecorations(view: EditorView): DecorationSet {
const builder: Array<Range<Decoration>> = [];
private getCodeBlocks(view: EditorView): CodeBlockMatch[] {
const text = view.state.doc.toString();
if (this.allIsCode) {
return [{
startIndex: 0,
content: text
}]
}
// Parsing
const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g;
let match;
let results: CodeBlockMatch[] = []
while ((match = codeBlockRegex.exec(text)) !== null) {
const blockStart = match.index;
const langTagEnd = blockStart + match[1].length + 3;
const sqlContent = match[2];
const contentStart = langTagEnd + 1;
results.push({
content: sqlContent,
startIndex: contentStart
})
}
return results
}
decorateFilename(dec: Decorator, { content, startIndex }: CodeBlockMatch) {
let hasQuotes = false;
// Get the actual filename text from the document
let filePath = content.slice(dec.start, dec.end)
const decorations = this.parseWithGrammar(sqlContent);
// Remove leading & trailing quotes, if captured.
if (filePath.startsWith('"')) {
filePath = filePath.substring(1, filePath.length - 1)
hasQuotes = true;
}
if (decorations) {
decorations.forEach(dec => {
if (dec.type === 'filename') {
let hasQuotes = false;
// Get the actual filename text from the document
let filePath = view.state.doc.sliceString(
contentStart + dec.start,
contentStart + dec.end
);
// Create widget decoration for the filename
const widget = new FilePathWidget(filePath, this.app);
return Decoration.replace({
widget,
inclusive: true
}).range(
startIndex + dec.start + Number(hasQuotes),
startIndex + dec.end - Number(hasQuotes)
)
}
// Remove leading & trailing quotes, if captured.
if (filePath.startsWith('"')) {
filePath = filePath.substring(1, filePath.length - 1)
hasQuotes = true;
}
// Create widget decoration for the filename
const widget = new FilePathWidget(filePath, this.app);
builder.push(Decoration.replace({
widget,
inclusive: true
}).range(
contentStart + dec.start + Number(hasQuotes),
contentStart + dec.end - Number(hasQuotes)
));
} else {
const decoration = markDecorations[dec.type as keyof typeof markDecorations];
privateDecorateCodeblock(codeblockMatch: CodeBlockMatch): Array<Range<Decoration>> {
const { content, startIndex } = codeblockMatch
const decorations = this.parseWithGrammar(content);
return (decorations || []).flatMap(dec => {
switch (dec.type) {
case 'filename':
return this.decorateFilename(dec, codeblockMatch)
default:
const decoration = markDecorations[dec.type as keyof typeof markDecorations];
if (decoration) {
builder.push(decoration.range(
contentStart + dec.start,
contentStart + dec.end
));
return decoration.range(
startIndex + dec.start,
startIndex + dec.end
)
} else {
return []
}
}
});
}
}
}
return Decoration.set(builder, true);
private buildDecorations(view: EditorView): DecorationSet {
const builder: Array<Range<Decoration>> = [];
// const text = view.state.doc.toString();
// const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g;
// let match;
const results = this.getCodeBlocks(view)
const decorators = results.flatMap(r => this.privateDecorateCodeblock(r))
return Decoration.set(decorators, true);
// while ((match = codeBlockRegex.exec(text)) !== null) {
// const blockStart = match.index;
// const langTagEnd = blockStart + match[1].length + 3;
// const sqlContent = match[2];
// const contentStart = langTagEnd + 1;
// const decorations = this.parseWithGrammar(sqlContent)
// if (decorations) {
// decorations.forEach(dec => {
// if (dec.type === 'filename') {
// let hasQuotes = false;
// // Get the actual filename text from the document
// let filePath = view.state.doc.sliceString(
// contentStart + dec.start,
// contentStart + dec.end
// );
// // Remove leading & trailing quotes, if captured.
// if (filePath.startsWith('"')) {
// filePath = filePath.substring(1, filePath.length - 1)
// hasQuotes = true;
// }
// // Create widget decoration for the filename
// const widget = new FilePathWidget(filePath, this.app);
// builder.push(Decoration.replace({
// widget,
// inclusive: true
// }).range(
// contentStart + dec.start + Number(hasQuotes),
// contentStart + dec.end - Number(hasQuotes)
// ));
// } else {
// const decoration = markDecorations[dec.type as keyof typeof markDecorations];
// if (decoration) {
// builder.push(decoration.range(
// contentStart + dec.start,
// contentStart + dec.end
// ));
// }
// }
// });
// }
// }
// return Decoration.set(builder, true);
}
}

View file

@ -6,18 +6,13 @@ import { RendererRegistry } from "../editor/renderer/rendererRegistry";
import { SQLSealViewPlugin } from "./editorExtension/syntaxHighlight";
@(makeInjector<SyntaxHighlightModule, 'factory'>()([
'app', 'rendererRegistry', 'plugin'
'plugin', 'viewPluginGenerator'
]))
export class SyntaxHighlightInit {
make(app: App, rendererRegistry: RendererRegistry, plugin: Plugin) {
make(plugin: Plugin, viewPluginGenerator: () => ViewPlugin<any>) {
return () => {
// FIXME: settings here.
plugin.registerEditorExtension([
ViewPlugin.define(
(view: EditorView) => new SQLSealViewPlugin(view, app, rendererRegistry),
{ decorations: v => v.decorations }
)
]);
plugin.registerEditorExtension([viewPluginGenerator()]);
}
}
}

View file

@ -4,6 +4,7 @@ import { App, Plugin } from "obsidian";
import { RendererRegistry } from "../editor/renderer/rendererRegistry";
import { CellParserFactory } from "./cellParser/factory";
import { SqlSealDatabase } from "../database/database";
import { ViewPluginGenerator } from "./viewPluginGenerator";
export const syntaxHighlight = buildContainer((c) =>
c
@ -16,8 +17,9 @@ export const syntaxHighlight = buildContainer((c) =>
.register({
init: asFactory(SyntaxHighlightInit),
cellParser: asFactory(CellParserFactory),
viewPluginGenerator: asFactory(ViewPluginGenerator)
})
.exports("init", "cellParser"),
.exports("init", "cellParser", "viewPluginGenerator"),
);
export type SyntaxHighlightModule = typeof syntaxHighlight;

View file

@ -0,0 +1,22 @@
import { makeInjector } from "@hypersphere/dity";
import { SyntaxHighlightModule } from "./module";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { SQLSealViewPlugin } from "./editorExtension/syntaxHighlight";
import { App } from "obsidian";
import { RendererRegistry } from "../editor/renderer/rendererRegistry";
export type ViewPluginGeneratorType = ReturnType<ViewPluginGenerator['make']>
@(makeInjector<SyntaxHighlightModule, 'factory'>()([
'app', 'rendererRegistry'
]))
export class ViewPluginGenerator {
make(app: App, rendererRegistry: RendererRegistry) {
return (allIsCode: boolean = false) => {
return ViewPlugin.define(
(view: EditorView) => new SQLSealViewPlugin(view, app, rendererRegistry, allIsCode),
{ decorations: v => v.decorations }
)
}
}
}

View file

@ -9,3 +9,5 @@
@use 'canvas';
@use 'autocomplete';
@use 'global-tables';
@use '../modules/explorer/explorer/style.scss';

View file

@ -97,7 +97,7 @@
color: #6ff77a;
}
:is(.cm-sqlseal-block-query, .cm-sqlseal-block-view, .cm-sqlseal-block-flag, .cm-sqlseal-block-table)::before {
:is(.cm-sqlseal-block-query, .cm-sqlseal-block-view, .cm-sqlseal-block-flag, .cm-sqlseal-block-table, .cm-sqlseal-explorer)::before {
background-color: var(--color);
width: 5px;
top: -3px;
@ -111,3 +111,39 @@
.cm-indent + :is(.cm-sqlseal-block-query, .cm-sqlseal-block-view, .cm-sqlseal-block-flag, .cm-sqlseal-block-table)::before {
display: none;
}
.cm-sqlseal-explorer .cm-line {
position: relative;
}
.cm-sqlseal-overlay {
border: 1px solid #AAA;
padding: 1em;
border-radius: 5px;
}
.cm-sqlseal-overlay .cm-line {
position: relative;
}
.cm-sqlseal-overlay .cm-editor {
outline: none;
}
.sqlseal-modal-copycode .setting-item {
border: 0;
}
.cm-sqlseal-explorer :is(.cm-sqlseal-block-query, .cm-sqlseal-block-view, .cm-sqlseal-block-flag, .cm-sqlseal-block-table)::before {
left: -3px;
}
.cm-sqlseal-explorer .cm-focused {
outline: none;
}
.cm-sqlseal-explorer .cm-editor {
border-left-width: 0;
background: var(--color-base-20);
padding: 8px;
}

2
styles.css Normal file

File diff suppressed because one or more lines are too long