diff --git a/frontend/src/App.css b/frontend/src/App.css index a3d2f0a..1dfec7c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -649,9 +649,11 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check } } .gonavi-query-editor-link-hint { + color: #1677ff !important; cursor: pointer; text-decoration: underline; - text-decoration-style: dashed; + text-decoration-style: solid; + text-decoration-color: currentColor; text-decoration-thickness: 1px; text-underline-offset: 3px; } @@ -672,6 +674,10 @@ body[data-theme='dark'] .gonavi-query-editor-object-token { color: #7dd3fc; } +body[data-theme='dark'] .gonavi-query-editor-link-hint { + color: #69b1ff !important; +} + body[data-theme='dark'] .gonavi-query-editor-column-token { color: #5eead4; } diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 14f02b9..67b8022 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1079,6 +1079,13 @@ describe('QueryEditor external SQL save', () => { expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint'); }); + it('keeps query editor hyperlink decorations blue with a solid underline', () => { + const css = readFileSync(new URL('../App.css', import.meta.url), 'utf8'); + + expect(css).toMatch(/\.gonavi-query-editor-link-hint\s*\{[^}]*color:\s*#1677ff\s*!important;[^}]*text-decoration:\s*underline;[^}]*text-decoration-style:\s*solid;[^}]*text-decoration-color:\s*currentColor;/s); + expect(css).toMatch(/body\[data-theme='dark'\]\s+\.gonavi-query-editor-link-hint\s*\{[^}]*color:\s*#69b1ff\s*!important;/s); + }); + it('opens a view tab on ctrl left click inside the editor', async () => { editorState.value = 'select * from reporting.active_users'; autoFetchState.visible = true; @@ -1209,6 +1216,24 @@ describe('QueryEditor external SQL save', () => { schemaName: 'reporting', sidebarLocateKey: 'conn-1-main-routine-reporting.refresh_stats', }); + expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'gonavi:locate-sidebar-object', + detail: expect.objectContaining({ + tabId: 'conn-1-main-trigger-audit.users_bi-audit.users', + triggerName: 'audit.users_bi', + schemaName: 'audit', + objectGroup: 'triggers', + }), + })); + expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'gonavi:locate-sidebar-object', + detail: expect.objectContaining({ + tabId: 'conn-1-main-routine-reporting.refresh_stats', + routineName: 'reporting.refresh_stats', + schemaName: 'reporting', + objectGroup: 'routines', + }), + })); }); it('switches current database on cmd left click for database identifiers', async () => { diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index 93af743..ccae775 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -309,6 +309,111 @@ describe('sidebarLocate', () => { ]); }); + it('finds schema objects when tree nodes use unqualified names or different case', () => { + const viewTarget = resolveSidebarLocateTarget({ + tabId: 'conn-1-main-view-reporting.active_users', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'reporting.active_users', + schemaName: 'reporting', + objectGroup: 'views', + }, { groupBySchema: true }); + + const routineTarget = resolveSidebarLocateTarget({ + tabId: 'conn-1-main-routine-reporting.refresh_stats', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'reporting.refresh_stats', + schemaName: 'reporting', + objectGroup: 'routines', + }, { groupBySchema: true }); + + const triggerTarget = resolveSidebarLocateTarget({ + tabId: 'conn-1-main-trigger-audit.users_bi-audit.users', + connectionId: 'conn-1', + dbName: 'main', + tableName: 'audit.users_bi', + schemaName: 'audit', + objectGroup: 'triggers', + }, { groupBySchema: true }); + + const tree = [ + { + key: 'conn-1', + children: [ + { + key: 'conn-1-main', + dataRef: { id: 'conn-1', dbName: 'main' }, + children: [ + { + key: 'conn-1-main-schema-REPORTING', + children: [ + { + key: 'conn-1-main-schema-REPORTING-views', + children: [ + { + key: 'conn-1-main-view-ACTIVE_USERS', + type: 'view', + dataRef: { id: 'conn-1', dbName: 'main', viewName: 'ACTIVE_USERS', schemaName: 'REPORTING' }, + }, + ], + }, + { + key: 'conn-1-main-schema-REPORTING-routines', + children: [ + { + key: 'conn-1-main-routine-REFRESH_STATS', + type: 'routine', + dataRef: { id: 'conn-1', dbName: 'main', routineName: 'REFRESH_STATS', schemaName: 'REPORTING' }, + }, + ], + }, + ], + }, + { + key: 'conn-1-main-schema-AUDIT', + children: [ + { + key: 'conn-1-main-schema-AUDIT-triggers', + children: [ + { + key: 'conn-1-main-trigger-USERS_BI-AUDIT.USERS', + type: 'db-trigger', + dataRef: { id: 'conn-1', dbName: 'main', triggerName: 'USERS_BI', tableName: 'AUDIT.USERS', schemaName: 'AUDIT' }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]; + + expect(findSidebarNodePathForLocate(tree, viewTarget)).toEqual([ + 'conn-1', + 'conn-1-main', + 'conn-1-main-schema-REPORTING', + 'conn-1-main-schema-REPORTING-views', + 'conn-1-main-view-ACTIVE_USERS', + ]); + expect(findSidebarNodePathForLocate(tree, routineTarget)).toEqual([ + 'conn-1', + 'conn-1-main', + 'conn-1-main-schema-REPORTING', + 'conn-1-main-schema-REPORTING-routines', + 'conn-1-main-routine-REFRESH_STATS', + ]); + expect(findSidebarNodePathForLocate(tree, triggerTarget)).toEqual([ + 'conn-1', + 'conn-1-main', + 'conn-1-main-schema-AUDIT', + 'conn-1-main-schema-AUDIT-triggers', + 'conn-1-main-trigger-USERS_BI-AUDIT.USERS', + ]); + }); + it('finds external SQL file paths from loaded tree data', () => { const target = resolveSidebarLocateTarget({ filePath: 'C:\\Users\\me\\sql\\report.sql', diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index 2f33287..cf1574e 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -1,3 +1,5 @@ +import { splitQualifiedNameLast } from './qualifiedName'; + export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines' | 'externalSqlFiles'; export type SidebarLocateDatabaseObjectGroup = Exclude; @@ -66,11 +68,10 @@ const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedStri export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const raw = toTrimmedString(qualifiedName); if (!raw) return { schemaName: '', objectName: '' }; - const idx = raw.lastIndexOf('.'); - if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw }; + const parsed = splitQualifiedNameLast(raw); return { - schemaName: raw.substring(0, idx).trim(), - objectName: raw.substring(idx + 1).trim(), + schemaName: parsed.parentPath, + objectName: parsed.objectName, }; }; @@ -251,16 +252,23 @@ export const findSidebarNodePathByKey = ( const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => { const normalizedNodeName = toTrimmedString(nodeObjectName); if (!normalizedNodeName) return false; - if (normalizedNodeName === target.tableName) return true; - - if (!target.schemaName) return false; const nodeParsed = splitSidebarQualifiedName(normalizedNodeName); const targetParsed = splitSidebarQualifiedName(target.tableName); const nodeObject = nodeParsed.objectName || normalizedNodeName; const targetObject = targetParsed.objectName || target.tableName; const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName; - return resolvedNodeSchema === target.schemaName && nodeObject === targetObject; + const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName; + const normalize = (value: string): string => toTrimmedString(value).toLowerCase(); + + if (normalize(normalizedNodeName) === normalize(target.tableName)) return true; + + if (!resolvedTargetSchema) { + return !resolvedNodeSchema && normalize(nodeObject) === normalize(targetObject); + } + + return normalize(resolvedNodeSchema) === normalize(resolvedTargetSchema) + && normalize(nodeObject) === normalize(targetObject); }; const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => {