From a0a08e0356832ab46a8cbcfa01e9bb2b7123b503 Mon Sep 17 00:00:00 2001 From: huyplb Date: Thu, 15 Oct 2026 23:14:51 -0600 Subject: [PATCH 1/2] fix(access): call privilege compare Insight, and count only people on the report Db2 returns system roles that hold nothing. Subtracting those from the shorter list of principals who have grants showed a negative user count. Co-authored-by: Cursor --- apps/e2e/src/generated/test-ids.ts | 2 +- .../access/components/AccessReport.tsx | 12 +++++++++-- .../access/components/AccessView.test.tsx | 1 + .../features/access/components/AccessView.tsx | 2 +- .../access/components/PermissionDiff.tsx | 4 ++-- docs/testing/TEST_IDS.md | 2 +- packages/shared/src/nav.ts | 2 +- packages/shared/src/permissions.ts | 2 +- .../sql/src/modules/access/effective.test.ts | 20 +++++++++++++++++++ packages/sql/src/modules/access/effective.ts | 4 +++- 10 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 0fb3edf09..7acb03c4d 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -275,7 +275,7 @@ export const TestIds = { diffTablesList: (index: string | number) => `diff-tables-${index}-list`, /** div */ diffUnsupported: 'diff-unsupported', - /** div · Permission Diff */ + /** div · Insight */ permissionDiff: 'permission-diff', }, PermissionInspector: { diff --git a/apps/web/src/frontend/features/access/components/AccessReport.tsx b/apps/web/src/frontend/features/access/components/AccessReport.tsx index 0fa5ddaa1..145690dcb 100644 --- a/apps/web/src/frontend/features/access/components/AccessReport.tsx +++ b/apps/web/src/frontend/features/access/components/AccessReport.tsx @@ -147,8 +147,16 @@ export const AccessReport: React.FC<{ embedded?: boolean }> = ({ embedded = fals
{/* Roles and privileges are counted in different units — principals versus grants — so they do not share an icon. */} - - + p.kind === 'user').length} + /> + p.kind !== 'user').length} + /> { fetchDbAccess.mockResolvedValue(catalog('postgres', 'alice', 'only_on_postgres')); render(); + expect(screen.getByTestId('access-tab-diff').textContent).toBe('Insight'); fireEvent.click(screen.getByTestId('access-tab-diff')); fireEvent.change(screen.getByTestId('access-connection'), { target: { value: 'c1' } }); diff --git a/apps/web/src/frontend/features/access/components/AccessView.tsx b/apps/web/src/frontend/features/access/components/AccessView.tsx index 08fa80435..e7e6f8449 100644 --- a/apps/web/src/frontend/features/access/components/AccessView.tsx +++ b/apps/web/src/frontend/features/access/components/AccessView.tsx @@ -31,7 +31,7 @@ const SECTIONS: { }[] = [ { id: 'permission', label: 'Permissions', icon: ShieldCheck, permission: 'access.builder' }, { id: 'users', label: 'Users', icon: UserCog, permission: 'access.users' }, - { id: 'diff', label: 'Diff', icon: GitCompare, permission: 'access.diff' }, + { id: 'diff', label: 'Insight', icon: GitCompare, permission: 'access.diff' }, ]; function loadConnectionId(connections: { id: string }[]): string { diff --git a/apps/web/src/frontend/features/access/components/PermissionDiff.tsx b/apps/web/src/frontend/features/access/components/PermissionDiff.tsx index 26b603c8e..4a1a2f9d8 100644 --- a/apps/web/src/frontend/features/access/components/PermissionDiff.tsx +++ b/apps/web/src/frontend/features/access/components/PermissionDiff.tsx @@ -232,7 +232,7 @@ export const PermissionDiff: React.FC<{
-

Permission Diff

+

Insight

  1. Choose the database, then the user or role.
  2. Load catalog — that reads the privileges this database already granted.
  3. @@ -344,7 +344,7 @@ export const PermissionDiff: React.FC<{
    - Diff & SQL + Insight & SQL + )} + + + ))} + +
    +
    + {orders.map((row, index) => ( + + ))} + +
    )} + ) : tab === 'workspace' ? ( + <> +
    + +
    + {scriptError && ( +

    + {scriptError} +

    + )} + {!canWriteScripts && ( +

    + Everyone in this workspace can open these. An editor saves and deletes them. +

    + )} + {scripts.length === 0 ? ( +

    + No shared scripts yet. Save the query in the editor and everyone here can open it. +

    + ) : ( +
      + {scripts.map((script) => ( +
    • + + + {script.updatedBy?.split('@')[0] ?? ''} + +
      + {canWriteScripts && ( + + )} +
      +
    • + ))} +
    + )} + ) : ( <> {recentQueries.length > 0 && ( diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx index 39389c965..a25d64b6b 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlEditorPane.tsx @@ -23,7 +23,8 @@ import { parseFoxScript, type SplitStatement, } from '@/shared/lib/sql-splitter'; -import { ensureSqlCompletions } from '../lib/completion'; +import { ensureSqlCompletions, shouldOpenClauseSuggest } from '../lib/completion'; +import { disarmPasswordAutofill } from '../lib/passwordAutofill'; import { applyFoxscriptMarkers, clearFoxscriptMarkers } from '../lib/foxscriptDiagnostics'; import { setSqlCaretOffsetGetter, @@ -237,6 +238,13 @@ export const SqlEditorPane: React.FC = ({ return ( <> + {/* A form with no password field, so Enter stays in the editor. Without + it Chrome treats this origin's saved login as belonging to the textarea. */} +
    event.preventDefault()} + > = ({ onMount={(editor, monaco) => { editorRef.current = editor; monacoRef.current = monaco; + const disarm = () => disarmPasswordAutofill(editor.getDomNode?.() ?? null); + disarm(); + // Monaco writes autocomplete="off" while building the textarea, and + // Chrome ignores "off" on a site that has a saved login. Stamp again + // after that write, and whenever the editor is focused. + requestAnimationFrame(disarm); + editor.onDidFocusEditorText(disarm); ensureSqlCompletions(monaco); ensureFoxscriptVirtualProviders(monaco); ensureFoxscriptSemanticTokens(monaco, () => useSqlEditorStore.getState().schemaCache); @@ -261,6 +276,22 @@ export const SqlEditorPane: React.FC = ({ syncFoxscriptVirtualDocs(monaco, model, fox); } }); + // `onDidType` is on Monaco's ICodeEditor but not in the public + // editor.api typings, so it is reached through one narrow cast. + const typing = editor as typeof editor & { + onDidType?: (listener: (text: string) => void) => void; + }; + typing.onDidType?.((typed: string) => { + if (typed !== ' ' && typed !== '=') return; + const model = editor.getModel?.(); + const pos = editor.getPosition?.(); + if (!model || !pos) return; + // The clause keyword touching the caret is on the caret's own line, + // so read that line instead of copying the whole buffer per space. + const before = model.getLineContent(pos.lineNumber).slice(0, pos.column - 1); + if (!shouldOpenClauseSuggest(before)) return; + editor.trigger('keyboard', 'editor.action.triggerSuggest', { auto: true }); + }); editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => onRunRef.current?.()); // Ctrl/Cmd+S → bookmark current query editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { @@ -357,6 +388,7 @@ export const SqlEditorPane: React.FC = ({ }} options={editorOptions} /> + { const state = useSqlEditorStore.getState(); const active = state.tabs.find((t) => t.id === state.activeTabId) ?? state.tabs[0]!; const destIds = state.activeConnectionIds(); + const explorerId = schemaExplorerConnectionId(); + const explorerEntry = explorerId ? state.schemaCache[explorerId] : undefined; + const explorerReady = explorerEntry?.status === 'ready' && !!explorerEntry.tables; + if (explorerId && !explorerReady && explorerEntry?.status !== 'loading') { + void state.ensureSchema(explorerId); + } + // Select schema is the catalog the query is written against. Other + // destinations are only a fallback until that catalog has loaded. + const sourceIds = explorerReady ? [explorerId] : destIds; const connections = useSyncStore.getState().connections; - const schemas = destIds + const schemas = sourceIds .map((id) => { const entry = state.schemaCache[id]; if (entry?.status !== 'ready' || !entry.tables) { @@ -426,7 +439,7 @@ export const SqlEditorView: React.FC = () => { } open={sidebarOpen.bookmarks} onToggle={() => selectSidebar('bookmarks')} @@ -576,7 +589,7 @@ export const SqlEditorView: React.FC = () => { }, { id: 'bookmarks', - title: 'Bookmarks', + title: 'Scripts', visible: canEditorBookmarks, icon: , }, diff --git a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx index 481571603..1a1acece7 100644 --- a/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx +++ b/apps/web/src/frontend/features/sql-editor/components/SqlSchemaExplorer.tsx @@ -63,6 +63,11 @@ export function peekModifierLabel(): string { return /mac|iphone|ipad/i.test(platform) ? 'Cmd' : 'Ctrl'; } +/** Connection chosen in Select schema. Completions read that schema's tables. */ +export function schemaExplorerConnectionId(): string { + return readStoredExplorerId(); +} + function readStoredExplorerId(): string { try { return localStorage.getItem(EXPLORER_CONN_KEY)?.trim() || ''; diff --git a/apps/web/src/frontend/features/sql-editor/lib/completion.ts b/apps/web/src/frontend/features/sql-editor/lib/completion.ts index 979413b5b..3ffcaa1a3 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/completion.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/completion.ts @@ -1,6 +1,6 @@ import type * as Monaco from 'monaco-editor/editor/editor.api'; import { extractTableAliases } from '@/shared/lib/sql-splitter'; -import { isInFromTablePosition, suggestTableAlias } from '@/features/sql-editor/lib/selectClauseEdit'; +import { suggestTableAlias } from '@/features/sql-editor/lib/selectClauseEdit'; import { projectToVirtualDoc } from '@/features/sql-editor/lib/foxscriptVirtualDocs'; import { filterCallParameters, getCompletionContext } from './sqlEditorBridge'; import { @@ -21,6 +21,177 @@ const LIGHT_KEYWORDS = [ const LANG_IDS = ['sql', 'pgsql', 'mysql', 'foxschema-sql', 'foxscript'] as const; +/** + * Where the caret is in a SELECT, so the popup can offer the next kind of name. + * + * `table` — after FROM or JOIN, the list is tables (each with an alias). + * `join-modifier` — after LEFT / RIGHT / INNER / OUTER, the next word is JOIN. + * `expression` — after ON, WHERE, AND, OR, HAVING, or in the select list: + * columns already in the query, written `alias.column`, plus common functions. + */ +type CompletionSlot = 'table' | 'join-modifier' | 'expression' | 'other'; + +/** + * Clause keyword → the slot it opens. One table drives all three uses: the + * set of words {@link withoutPartialIdent} keeps, the scan regex, and the + * slot lookup — so a new keyword is added in exactly one place. + */ +const SLOT_BY_WORD: Record = { + SELECT: 'expression', ON: 'expression', WHERE: 'expression', HAVING: 'expression', + AND: 'expression', OR: 'expression', GROUP: 'expression', ORDER: 'expression', + FROM: 'table', JOIN: 'table', INTO: 'table', UPDATE: 'table', + LEFT: 'join-modifier', RIGHT: 'join-modifier', FULL: 'join-modifier', + INNER: 'join-modifier', CROSS: 'join-modifier', OUTER: 'join-modifier', + LIMIT: 'other', OFFSET: 'other', UNION: 'other', SET: 'other', VALUES: 'other', +}; + +const SLOT_WORDS = new Set(Object.keys(SLOT_BY_WORD)); + +/** Built once, not per keystroke. `lastIndex` is reset before every scan. */ +const SLOT_RE = new RegExp(`\\b(${[...SLOT_WORDS].join('|')})\\b`, 'gi'); + +/** A space after one of these opens the popup without Ctrl+Space. */ +const AUTO_OPEN = new Set([ + 'FROM', 'JOIN', 'ON', 'WHERE', 'AND', 'OR', 'HAVING', + 'LEFT', 'RIGHT', 'FULL', 'INNER', 'CROSS', 'OUTER', + '=', +]); + +const COMMON_SQL_FUNCTIONS: { name: string; snippet: string; detail: string }[] = [ + { name: 'COUNT', snippet: 'COUNT(${1:*})', detail: 'count rows or values' }, + { name: 'SUM', snippet: 'SUM(${1:expr})', detail: 'sum' }, + { name: 'AVG', snippet: 'AVG(${1:expr})', detail: 'average' }, + { name: 'MIN', snippet: 'MIN(${1:expr})', detail: 'minimum' }, + { name: 'MAX', snippet: 'MAX(${1:expr})', detail: 'maximum' }, + { name: 'COALESCE', snippet: 'COALESCE(${1:expr}, ${2:fallback})', detail: 'first non-null' }, + { name: 'NULLIF', snippet: 'NULLIF(${1:expr}, ${2:value})', detail: 'null when equal' }, + { name: 'UPPER', snippet: 'UPPER(${1:expr})', detail: 'uppercase' }, + { name: 'LOWER', snippet: 'LOWER(${1:expr})', detail: 'lowercase' }, + { name: 'TRIM', snippet: 'TRIM(${1:expr})', detail: 'trim spaces' }, + { name: 'LENGTH', snippet: 'LENGTH(${1:expr})', detail: 'length' }, + { name: 'SUBSTRING', snippet: 'SUBSTRING(${1:expr}, ${2:1}, ${3:n})', detail: 'substring' }, + { name: 'REPLACE', snippet: 'REPLACE(${1:expr}, ${2:from}, ${3:to})', detail: 'replace' }, + { name: 'CONCAT', snippet: 'CONCAT(${1:a}, ${2:b})', detail: 'concatenate' }, + { name: 'CAST', snippet: 'CAST(${1:expr} AS ${2:type})', detail: 'cast' }, + { name: 'ROUND', snippet: 'ROUND(${1:expr}, ${2:0})', detail: 'round' }, + { name: 'ABS', snippet: 'ABS(${1:expr})', detail: 'absolute value' }, + { name: 'CURRENT_DATE', snippet: 'CURRENT_DATE', detail: 'current date' }, + { name: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP', detail: 'current timestamp' }, +]; + +function stripSqlLiterals(sql: string): string { + return sql.replace(/'(?:''|[^'])*'/g, "''").replace(/--[^\n]*/g, ' '); +} + +/** Drop the identifier being typed, but keep a finished clause keyword. */ +function withoutPartialIdent(sql: string): string { + const match = /[A-Za-z_][\w$]*$/.exec(sql); + if (!match) return sql; + if (SLOT_WORDS.has(match[0].toUpperCase())) return sql; + return sql.slice(0, match.index); +} + +/** + * The text the two readers below both work from: literals and comments + * blanked, and the identifier being typed dropped. Computed once per + * keystroke and passed to both, since each pass copies the whole prefix. + */ +export function completionBody(sqlBeforeCursor: string): string { + return withoutPartialIdent(stripSqlLiterals(sqlBeforeCursor)); +} + +export function completionSlot(sqlBeforeCursor: string, body = completionBody(sqlBeforeCursor)): CompletionSlot { + let slot: CompletionSlot = 'other'; + SLOT_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = SLOT_RE.exec(body)) !== null) { + slot = SLOT_BY_WORD[match[1]!.toUpperCase()] ?? 'other'; + } + return slot; +} + +/** + * True when a just-typed space or `=` should open the clause list. + * + * Monaco skips trigger characters whenever quick suggestions are on, so a + * space after FROM / LEFT / ON never opened the popup by itself. + */ +export function shouldOpenClauseSuggest( + sqlBeforeCursor: string, + body = completionBody(sqlBeforeCursor) +): boolean { + const keyword = clauseKeywordBeforeCursor(sqlBeforeCursor, body); + return keyword != null && AUTO_OPEN.has(keyword); +} + +/** Clause keyword touching the caret, once the word being typed is set aside. */ +export function clauseKeywordBeforeCursor( + sqlBeforeCursor: string, + precomputed = completionBody(sqlBeforeCursor) +): string | null { + const body = precomputed.trimEnd(); + // `ON c.id = ` is still the ON list — a space here should offer the other alias. + if (/(?:<>|!=|<=|>=|=|<|>)$/.test(body)) return '='; + const match = /\b([A-Za-z_]+)$/.exec(body); + return match ? match[1]!.toUpperCase() : null; +} + +/** + * `schema.table` → `table`. `lastIndexOf` returns -1 when there is no dot, so + * `slice(0)` already gives the whole name back — no need to test for one. + */ +function bareName(name: string): string { + return name.slice(name.lastIndexOf('.') + 1); +} + +/** One pass: per table, the alias to qualify its columns with. */ +function columnQualifiers(aliases: Record): { alias: string; table: string }[] { + const byTable = new Map(); + for (const [alias, table] of Object.entries(aliases)) { + const lower = table.toLowerCase(); + const prev = byTable.get(lower); + const short = alias !== lower && alias !== bareName(lower) ? alias : prev?.short; + byTable.set(lower, { table, short }); + } + return [...byTable.values()].map(({ table, short }) => ({ + alias: short ?? bareName(table.toLowerCase()), + table, + })); +} + +type SchemaTableEntry = { table: { name: string; objectType?: string }; schema?: string }; + +/** + * Lower-cased table name → its table and owning schema, under both the + * qualified and the bare name. Built once per request: the two lookups below + * used to rescan every schema for every suggested table, which made building + * a FROM list O(tables²) on each keystroke. + */ +function tableSchemaIndex( + schemas: ReturnType['schemas'] +): Map { + const index = new Map(); + for (const src of schemas) { + const schema = src.schema?.trim(); + const usable = schema && /^[A-Za-z_][\w$]*$/.test(schema) ? schema : undefined; + for (const table of src.tables) { + const lower = table.name.toLowerCase(); + const entry: SchemaTableEntry = { table, schema: usable }; + if (!index.has(lower)) index.set(lower, entry); + const bare = bareName(lower); + if (!index.has(bare)) index.set(bare, entry); + } + } + return index; +} + +function qualifiedTableName(name: string, index: Map): string { + if (name.includes('.')) return name; + if (!/^[A-Za-z_][\w$]*$/.test(name)) return name; + const schema = index.get(name.toLowerCase())?.schema; + return schema ? `${schema}.${name}` : name; +} + let registered = false; /** Rebuilt when schemaCache revision changes. */ let cachedTries: SchemaTrieBundle | null = null; @@ -55,13 +226,103 @@ function triesFor(schemas: ReturnType['schemas']): * Alias support: `alias.` / `alias.col…` resolves via {@link extractTableAliases} * to the underlying table's columns. Aliases themselves are also suggested. */ +function expressionSuggestions(args: { + monaco: typeof Monaco; + range: Monaco.IRange; + prefix: string; + aliases: Record; + tries: SchemaTrieBundle; + schemas: ReturnType['schemas']; + tableIndex: () => ReturnType; +}): Monaco.languages.CompletionItem[] { + const { monaco, range, prefix, aliases, tries, schemas, tableIndex } = args; + const items: Monaco.languages.CompletionItem[] = []; + const seen = new Set(); + for (const { alias, table } of columnQualifiers(aliases)) { + const tableLower = table.toLowerCase(); + const colTrie = + tries.columnsByTable.get(tableLower) ?? tries.columnsByTable.get(bareName(tableLower)); + // The index is built at most once per request, not once per alias. + const cols = colTrie ? trieCollect(colTrie, '') : columnsForTable(tableIndex(), table); + for (const col of cols) { + const qualified = `${alias}.${col}`; + const key = qualified.toLowerCase(); + if (seen.has(key)) continue; + const aliasLower = alias.toLowerCase(); + const colLower = col.toLowerCase(); + if ( + prefix && + !key.startsWith(prefix) && + !colLower.startsWith(prefix) && + !aliasLower.startsWith(prefix) + ) { + continue; + } + seen.add(key); + items.push({ + label: qualified, + kind: monaco.languages.CompletionItemKind.Field, + insertText: qualified, + filterText: `${qualified} ${col}`, + detail: `column · ${table}`, + sortText: `0_${alias}_${col}`, + range, + }); + } + } + for (const fn of COMMON_SQL_FUNCTIONS) { + if (prefix && !fn.name.toLowerCase().startsWith(prefix)) continue; + items.push({ + label: fn.name, + kind: monaco.languages.CompletionItemKind.Function, + insertText: fn.snippet, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + filterText: fn.name, + detail: `function · ${fn.detail}`, + sortText: `1_${fn.name}`, + range, + }); + } + for (const src of schemas) { + for (const table of src.tables) { + if (table.objectType !== 'FUNCTION') continue; + const bare = bareName(table.name); + const key = bare.toLowerCase(); + if (seen.has(`fn:${key}`)) continue; + if (prefix && !key.startsWith(prefix)) continue; + seen.add(`fn:${key}`); + items.push({ + label: bare, + kind: monaco.languages.CompletionItemKind.Function, + insertText: `${bare}()`, + filterText: bare, + detail: `function · ${src.schema ?? table.name}`, + sortText: `1b_${bare}`, + range, + }); + } + } + for (const kw of ['AND', 'OR', 'NOT', 'IN', 'LIKE', 'IS', 'NULL', 'BETWEEN']) { + if (prefix && !kw.toLowerCase().startsWith(prefix)) continue; + items.push({ + label: kw, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: kw, + detail: 'keyword', + sortText: `2_${kw}`, + range, + }); + } + return items; +} + export function ensureSqlCompletions(monaco: typeof Monaco): void { if (registered) return; registered = true; const provider: Monaco.languages.CompletionItemProvider = { - triggerCharacters: ['.', '{'], - provideCompletionItems(model, position) { + triggerCharacters: ['.', '{', ' '], + provideCompletionItems(model, position, context) { // Inside `-- @js` / `-- @ts` fences, FoxScript virtual-doc providers own suggest. if (projectToVirtualDoc(model, position)) { return { suggestions: [] }; @@ -91,8 +352,19 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { endLineNumber: position.lineNumber, endColumn: position.column, }); - const fromTablePos = isInFromTablePosition(sqlBeforeCursor); - const tableIndex = buildTableIndex(schemas); + // Both readers work from the same stripped prefix — one pass, not two. + const body = completionBody(sqlBeforeCursor); + const slot = completionSlot(sqlBeforeCursor, body); + const fromTablePos = slot === 'table' || slot === 'join-modifier'; + // A space opens the list only at FROM / JOIN / ON / WHERE / AND. Anywhere + // else a space is just a space — Ctrl+Space still completes. + if (context?.triggerCharacter === ' ' && !shouldOpenClauseSuggest(sqlBeforeCursor, body)) { + return { suggestions: [] }; + } + // Only the cold fallbacks need the full index; most keystrokes are + // served by the tries, so build it on first use. + let builtIndex: TableIndex | null = null; + const tableIndexLazy = (): TableIndex => (builtIndex ??= buildTableIndex(schemas)); const tries = triesFor(schemas); const prefix = (word.word || '').toLowerCase(); @@ -254,7 +526,7 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { ); const cols = colTrie ? trieCollect(colTrie, partial) - : columnsForTable(tableIndex, tableName).filter( + : columnsForTable(tableIndexLazy(), tableName).filter( (name) => !partial || name.toLowerCase().startsWith(partial) ); // Replace only the column fragment after the dot (not the alias). @@ -380,29 +652,46 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { }; } + if (slot === 'expression') { + return { + suggestions: expressionSuggestions({ + monaco, + range, + prefix, + aliases, + tries, + schemas, + tableIndex: tableIndexLazy, + }), + }; + } + const seen = new Set(); const suggestions: Monaco.languages.CompletionItem[] = []; // Aliases first so `u` suggests the alias before unrelated keywords. - for (const [alias, table] of Object.entries(aliases)) { - const tableLower = table.toLowerCase(); - if (alias === tableLower) continue; // skip bare table self-map - if (tableLower.endsWith('.' + alias)) continue; - const bare = tableLower.includes('.') - ? tableLower.slice(tableLower.lastIndexOf('.') + 1) - : tableLower; - if (alias === bare) continue; - if (prefix && !alias.startsWith(prefix)) continue; - if (seen.has(alias)) continue; - seen.add(alias); - suggestions.push({ - label: alias, - kind: monaco.languages.CompletionItemKind.Variable, - insertText: alias, - detail: `alias → ${table}`, - sortText: `0_${alias}`, - range, - }); + // In the table slot the next name is a table, not an alias already used. + if (slot !== 'table') { + for (const [alias, table] of Object.entries(aliases)) { + const tableLower = table.toLowerCase(); + if (alias === tableLower) continue; // skip bare table self-map + if (tableLower.endsWith('.' + alias)) continue; + const bare = tableLower.includes('.') + ? tableLower.slice(tableLower.lastIndexOf('.') + 1) + : tableLower; + if (alias === bare) continue; + if (prefix && !alias.startsWith(prefix)) continue; + if (seen.has(alias)) continue; + seen.add(alias); + suggestions.push({ + label: alias, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: alias, + detail: `alias → ${table}`, + sortText: `0_${alias}`, + range, + }); + } } /** @@ -427,7 +716,7 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { kind: monaco.languages.CompletionItemKind.Module, insertText: `${schemaName}.`, detail: 'schema', - sortText: `0a_${schemaName}`, + sortText: slot === 'table' ? `1_${schemaName}` : `0a_${schemaName}`, range, command: { id: 'editor.action.triggerSuggest', title: 'Suggest tables' }, }); @@ -435,15 +724,16 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { const tableNames = trieCollect(tries.tables, prefix); const takenAliases = new Set(Object.keys(aliases)); + // One index for the whole loop: both the objectType lookup and the + // schema qualification below used to rescan every schema per table. + const schemaIndex = tableSchemaIndex(schemas); for (const name of tableNames) { const key = name.toLowerCase(); - const bare = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key; + const bare = bareName(key); if (seen.has(key) || seen.has(bare)) continue; seen.add(key); seen.add(bare); - const meta = schemas - .flatMap((s) => s.tables) - .find((t) => t.name.toLowerCase() === key || t.name.toLowerCase() === bare); + const meta = (schemaIndex.get(key) ?? schemaIndex.get(bare))?.table; // In FROM/JOIN, insert `table alias` so columns can use the short name. let insertText = name; let detail = (meta?.objectType ?? 'TABLE').toLowerCase(); @@ -451,7 +741,8 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { if (fromTablePos) { const alias = suggestTableAlias(bare, takenAliases); takenAliases.add(alias); - insertText = `${name} ${alias}`; + const qualified = qualifiedTableName(name, schemaIndex); + insertText = `${qualified} ${alias}`; label = `${name} ${alias}`; detail = `${detail} · alias ${alias}`; } @@ -459,13 +750,34 @@ export function ensureSqlCompletions(monaco: typeof Monaco): void { label, kind: monaco.languages.CompletionItemKind.Class, insertText, - filterText: name, + filterText: fromTablePos ? `${name} ${insertText}` : name, detail, - sortText: `1_${name}`, + sortText: + slot === 'join-modifier' ? `2_${name}` : slot === 'table' ? `0_${name}` : `1_${name}`, range, }); } + if (slot === 'table' || slot === 'join-modifier') { + const keywords = + slot === 'join-modifier' + ? ['JOIN', 'OUTER', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS'] + : ['JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'ON', 'WHERE', 'AND']; + const keywordSort = slot === 'join-modifier' ? '0' : '8'; + for (const kw of keywords) { + if (prefix && !kw.toLowerCase().startsWith(prefix)) continue; + suggestions.push({ + label: kw, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: kw, + detail: 'keyword', + sortText: `${keywordSort}_${kw}`, + range, + }); + } + return { suggestions }; + } + // Procedures / functions — show IN/OUT/INOUT signature in detail; insert CALL/fn(…). for (const src of schemas) { for (const t of src.tables) { diff --git a/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts b/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts index 6170b5909..8f2210304 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/completionSchemaDot.test.ts @@ -19,12 +19,20 @@ import { describe, expect, it, beforeEach, vi } from 'vitest'; vi.mock('./foxscriptVirtualDocs', () => ({ projectToVirtualDoc: () => null })); -import { ensureSqlCompletions } from './completion'; +import { ensureSqlCompletions, shouldOpenClauseSuggest } from './completion'; import { setCompletionContextGetter, type CompletionSchemaSource } from './sqlEditorBridge'; -type Suggestion = { label: string; detail?: string; insertText: string; command?: { id: string } }; +type Suggestion = { + label: string; + detail?: string; + insertText: string; + command?: { id: string }; + filterText?: string; +}; -let provider: { provideCompletionItems: (m: unknown, p: unknown) => { suggestions: Suggestion[] } }; +let provider: { + provideCompletionItems: (m: unknown, p: unknown, c?: unknown) => { suggestions: Suggestion[] }; +}; const KIND = new Proxy({}, { get: (_t, k) => String(k) }); const monaco = { @@ -44,7 +52,7 @@ const monaco = { * test for `o.` needs text *after* the cursor — which is exactly the case the * first version of this helper could not express, and it hid a real bug. */ -function complete(text: string): Suggestion[] { +function complete(text: string, triggerCharacter?: string): Suggestion[] { const caret = text.indexOf('|'); const line = caret === -1 ? text : text.slice(0, caret) + text.slice(caret + 1); const column = (caret === -1 ? line.length : caret) + 1; @@ -57,7 +65,8 @@ function complete(text: string): Suggestion[] { getValueInRange: ({ startColumn, endColumn }: { startColumn: number; endColumn: number }) => line.slice(startColumn - 1, endColumn - 1), }; - return provider.provideCompletionItems(model, { lineNumber: 1, column }).suggestions; + const context = triggerCharacter ? { triggerCharacter, triggerKind: 1 } : {}; + return provider.provideCompletionItems(model, { lineNumber: 1, column }, context).suggestions; } const labels = (line: string) => complete(line).map((s) => s.label); @@ -199,3 +208,70 @@ describe('schema names are offered before the dot', () => { expect(items.filter((s) => s.label === 'orders' && s.detail === 'schema')).toHaveLength(0); }); }); + +describe('FROM, JOIN, ON, WHERE, and AND', () => { + it('offers each table with an alias after FROM, qualified by the selected schema', () => { + const orders = complete('SELECT * FROM ').find((s) => s.label.startsWith('orders')); + expect(orders?.insertText).toMatch(/^demo_a\.orders \w+$/); + expect(orders?.detail).toMatch(/alias /); + }); + + it('offers tables again on a JOIN that follows ON', () => { + const items = complete('SELECT * FROM orders o INNER JOIN customers c ON c.email = o.id JOIN '); + expect(items.some((s) => s.insertText.startsWith('demo_a.order_items'))).toBe(true); + expect(items.some((s) => s.insertText === 'c.email')).toBe(false); + }); + + it('offers tables with aliases after INNER JOIN', () => { + const customers = complete('SELECT * FROM orders o INNER JOIN ').find((s) => + s.label.startsWith('customers') + ); + expect(customers?.insertText).toMatch(/^demo_a\.customers \w+$/); + }); + + it('offers alias.column after ON, not another table', () => { + const items = complete('SELECT * FROM orders o INNER JOIN customers c ON '); + const inserted = items.map((s) => s.insertText); + expect(inserted).toContain('o.id'); + expect(inserted).toContain('o.total'); + expect(inserted).toContain('c.email'); + expect(inserted.some((text) => /^demo_a\.customers \w+$/.test(text))).toBe(false); + }); + + it('offers columns and common functions after WHERE and AND', () => { + const where = complete('SELECT * FROM orders o WHERE '); + expect(where.map((s) => s.insertText)).toContain('o.total'); + expect(where.map((s) => s.label)).toContain('COUNT'); + expect(where.find((s) => s.label === 'COUNT')?.insertText).toBe('COUNT(${1:*})'); + expect(where.map((s) => s.label)).toContain('COALESCE'); + + const and = complete('SELECT * FROM orders o WHERE o.id = 1 AND '); + expect(and.map((s) => s.insertText)).toContain('o.id'); + expect(and.map((s) => s.label)).toContain('UPPER'); + }); + + it('opens the list after LEFT and after =, not after a select-list space', () => { + expect(shouldOpenClauseSuggest('SELECT * FROM orders o LEFT ')).toBe(true); + expect(shouldOpenClauseSuggest('SELECT * FROM orders o LEFT OUTER JOIN customers c ON c.id = ')).toBe(true); + expect(shouldOpenClauseSuggest('SELECT * ')).toBe(false); + }); + + it('after LEFT offers JOIN and OUTER, and after = offers both aliases', () => { + const left = complete('SELECT * FROM orders o LEFT ', ' '); + expect(left.map((s) => s.label)).toEqual(expect.arrayContaining(['JOIN', 'OUTER'])); + const joined = complete('SELECT * FROM orders o LEFT OUTER JOIN ', ' '); + expect(joined.some((s) => s.insertText.startsWith('demo_a.customers'))).toBe(true); + const eq = complete('SELECT * FROM orders o LEFT OUTER JOIN customers c ON c.id = ', ' '); + const inserted = eq.map((s) => s.insertText); + expect(inserted).toContain('c.email'); + expect(inserted).toContain('o.id'); + expect(inserted).toContain('o.total'); + }); + + it('opens on a space after FROM, and stays quiet on a space in the select list', () => { + expect(complete('SELECT * FROM ', ' ').some((s) => s.insertText.startsWith('demo_a.orders'))).toBe( + true + ); + expect(complete('SELECT * ', ' ')).toEqual([]); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/lib/monacoSetup.ts b/apps/web/src/frontend/features/sql-editor/lib/monacoSetup.ts index cf1151f6b..6193a35e3 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/monacoSetup.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/monacoSetup.ts @@ -3,6 +3,9 @@ import { loader } from '@monaco-editor/react'; // Import only the editor core + the SQL dialects we use, not all ~90 languages import * as monaco from 'monaco-editor/editor/editor.api'; +// The slim editor API does not include the suggestion list. Without this +// import, completion providers register and never appear. +import 'monaco-editor/editor/contrib/suggest/browser/suggestController.js'; import 'monaco-editor/languages/definitions/sql/register'; import 'monaco-editor/languages/definitions/pgsql/register'; import 'monaco-editor/languages/definitions/mysql/register'; diff --git a/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.test.ts b/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.test.ts new file mode 100644 index 000000000..137d53bbd --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.test.ts @@ -0,0 +1,27 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { disarmPasswordAutofill } from './passwordAutofill'; + +describe('disarmPasswordAutofill', () => { + it('marks the editor textarea so Enter does not unlock saved passwords', () => { + document.body.innerHTML = + '
    '; + disarmPasswordAutofill(document.getElementById('ed')); + const area = document.querySelector('textarea')!; + expect(area.getAttribute('autocomplete')).toBe('one-time-code'); + expect(area.getAttribute('data-form-type')).toBe('other'); + expect(area.getAttribute('data-1p-ignore')).toBe('true'); + expect(area.getAttribute('data-lpignore')).toBe('true'); + expect(area.getAttribute('name')).toBe('foxschema-sql-buffer'); + }); + + it('does nothing when the editor has not mounted a textarea yet', () => { + expect(() => disarmPasswordAutofill(null)).not.toThrow(); + expect(() => disarmPasswordAutofill(document.createElement('div'))).not.toThrow(); + }); +}); diff --git a/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.ts b/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.ts new file mode 100644 index 000000000..7af6f4bb9 --- /dev/null +++ b/apps/web/src/frontend/features/sql-editor/lib/passwordAutofill.ts @@ -0,0 +1,34 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Keep the SQL editor out of the browser's password manager. + * + * Sign-in on this origin teaches Chrome that the site has a login. Enter in + * Monaco's textarea then asks macOS to unlock saved passwords (the Touch ID + * "AutoFill for Google Chrome" sheet). `autocomplete="off"` is ignored once + * Chrome has made that decision. `one-time-code` is a real token that is not + * a username or a password, so Chrome does not open the keychain for it. + */ + +const ATTRS: Record = { + autocomplete: 'one-time-code', + autocorrect: 'off', + autocapitalize: 'off', + spellcheck: 'false', + 'data-lpignore': 'true', + 'data-1p-ignore': 'true', + 'data-bwignore': 'true', + 'data-form-type': 'other', + name: 'foxschema-sql-buffer', +}; + +/** Stamp every textarea under `root` so Enter is not a login submit. */ +export function disarmPasswordAutofill(root: ParentNode | null | undefined): void { + if (!root) return; + const areas = root.querySelectorAll('textarea'); + areas.forEach((area) => { + for (const [key, value] of Object.entries(ATTRS)) area.setAttribute(key, value); + }); +} diff --git a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts index c2b4f1e48..828e09cc5 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.test.ts @@ -7,7 +7,6 @@ import { ensureTableHasAlias, findSelectListRange, insertIntoSelectList, - isInFromTablePosition, isSelectOrFromKeyword, parseSelectListItems, removeFromSelectList, @@ -184,9 +183,4 @@ describe('selectClauseEdit', () => { expect(ensureAllFromTablesAliased(sql)).toBe(sql); // idempotent }); - it('isInFromTablePosition detects FROM list vs WHERE', () => { - expect(isInFromTablePosition('SELECT * FROM ord')).toBe(true); - expect(isInFromTablePosition('SELECT * FROM orders o WHERE o.')).toBe(false); - expect(isInFromTablePosition('SELECT orders.')).toBe(false); - }); }); diff --git a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts index 1be8f04b7..2adff76e2 100644 --- a/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts +++ b/apps/web/src/frontend/features/sql-editor/lib/selectClauseEdit.ts @@ -439,24 +439,6 @@ export function addTableWithAliasToFrom( return { sql: nextSql, alias: nextAlias, added: true }; } -/** True when the caret is in a FROM / JOIN table position (not WHERE/SELECT list). */ -export function isInFromTablePosition(sqlBeforeCursor: string): boolean { - const re = /\b(FROM|JOIN)\b/gi; - let last = -1; - let lastLen = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(sqlBeforeCursor)) !== null) { - last = m.index; - lastLen = m[0].length; - } - if (last < 0) return false; - const afterKw = sqlBeforeCursor.slice(last + lastLen); - // Still in FROM/JOIN list if we haven't hit a following clause keyword. - return !/\b(?:WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|OFFSET|FETCH|UNION|EXCEPT|INTERSECT|FOR\s+UPDATE|SELECT|UPDATE|DELETE|INSERT|SET)\b/i.test( - afterKw - ); -} - /** True when the word at offset is SELECT or FROM (case-insensitive). */ export function isSelectOrFromKeyword(sql: string, offset: number): 'select' | 'from' | null { if (offset < 0 || offset > sql.length) return null; diff --git a/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts b/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts index 20f28c309..634186bd3 100644 --- a/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts +++ b/apps/web/src/frontend/features/workspaces/api/workspacesApi.ts @@ -41,6 +41,27 @@ const enc = encodeURIComponent; export const apiListWorkspaces = (): Promise => api.get('/workspaces'); +export interface WorkspaceScript { + id: string; + title: string; + sql: string; + updatedBy: string | null; + updatedAt: string; +} + +/** Scripts stored on the current workspace. Every member can open them. */ +export async function apiListWorkspaceScripts(): Promise<{ scripts: WorkspaceScript[]; canWrite: boolean }> { + return api.get('/workspaces/scripts'); +} + +export async function apiSaveWorkspaceScript(title: string, sql: string): Promise { + return (await api.post<{ script: WorkspaceScript }>('/workspaces/scripts', { title, sql })).script; +} + +export async function apiDeleteWorkspaceScript(id: string): Promise { + await api.delete(`/workspaces/scripts/${enc(id)}`); +} + export async function apiCreateWorkspace(name: string): Promise { return (await api.post<{ workspace: WorkspaceItem }>('/workspaces', { name })).workspace; } diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx index db8e27ace..b13665219 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.test.tsx @@ -68,11 +68,12 @@ describe('WorkspaceMenu', () => { apiSelectWorkspace.mockResolvedValue(undefined); const reload = vi.fn(); render( undefined} reload={reload} />); - const current = await screen.findByTestId('workspace-menu-item-own'); - expect(current.getAttribute('aria-current')).toBe('true'); + const current = (await screen.findByTestId('workspace-menu-select')) as HTMLSelectElement; + expect(current.value).toBe('own'); + expect(screen.getByTestId('workspace-menu-item-team')).toBeTruthy(); expect(screen.queryByTestId('workspace-menu-create')).toBeNull(); - fireEvent.click(screen.getByTestId('workspace-menu-item-team')); + fireEvent.change(current, { target: { value: 'team' } }); await waitFor(() => expect(apiSelectWorkspace).toHaveBeenCalledWith('team')); expect(reload).toHaveBeenCalled(); }); diff --git a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx index 37aa2cadf..6efee186e 100644 --- a/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx +++ b/apps/web/src/frontend/features/workspaces/components/WorkspaceMenu.tsx @@ -8,7 +8,7 @@ * because every open view holds data from the old workspace. */ import React, { useEffect, useState } from 'react'; -import { Check, Compass, Layers, Plus, Settings2 } from 'lucide-react'; +import { Compass, Plus, Settings2 } from 'lucide-react'; import { prepareWorkspaceSwitch } from '@/shared/lib/editorWorkspaceStorage'; import { apiAcceptInvite, @@ -171,28 +171,23 @@ export const WorkspaceMenu: React.FC<{ ))}
)} -

Workspace

-
    - {list.workspaces.map((w) => ( -
  • - -
  • - ))} -
+ {current && (