mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-17 04:14:10 +08:00
✨ feat(workbench): 丰富主页快捷资源与工作入口
This commit is contained in:
@@ -1280,6 +1280,16 @@ const Sidebar: React.FC<{
|
||||
getActiveContext: () => useStore.getState().activeContext,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkbenchAddExternalSQLDirectory = () => {
|
||||
void handleAddExternalSQLDirectory({ type: 'external-sql-root' });
|
||||
};
|
||||
window.addEventListener('gonavi:add-external-sql-directory', handleWorkbenchAddExternalSQLDirectory);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:add-external-sql-directory', handleWorkbenchAddExternalSQLDirectory);
|
||||
};
|
||||
}, [handleAddExternalSQLDirectory]);
|
||||
|
||||
const getNodeDatabaseContext = (node: any): { connectionId: string; dbName: string; dbNodeKey: string } | null => {
|
||||
if (!node) return null;
|
||||
if (node.type === 'database') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRecentConnectionShortcuts } from './TabManager';
|
||||
import { buildPinnedTableShortcuts, buildRecentConnectionShortcuts } from './TabManager';
|
||||
import type { SavedConnection } from '../types';
|
||||
|
||||
const connection = (id: string, type: string): SavedConnection => ({
|
||||
@@ -32,4 +32,25 @@ describe('recent workbench shortcuts', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('only exposes valid pinned tables whose connection still exists', () => {
|
||||
const shortcuts = buildPinnedTableShortcuts([
|
||||
connection('mysql-1', 'mysql'),
|
||||
], [
|
||||
JSON.stringify(['mysql-1', 'orders', 'public', 'line_items']),
|
||||
JSON.stringify(['missing-1', 'orders', '', 'orphaned_table']),
|
||||
'{bad json',
|
||||
JSON.stringify(['mysql-1', '', '', 'missing_database']),
|
||||
JSON.stringify(['mysql-1', 'orders', 'public', 'line_items']),
|
||||
]);
|
||||
|
||||
expect(shortcuts).toEqual([
|
||||
expect.objectContaining({
|
||||
connection: expect.objectContaining({ id: 'mysql-1' }),
|
||||
dbName: 'orders',
|
||||
schemaName: 'public',
|
||||
tableName: 'line_items',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Dropdown, message, Tabs, Tooltip } from 'antd';
|
||||
import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, FileTextOutlined, HistoryOutlined, PlusOutlined, RightOutlined, RobotOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, FileTextOutlined, FolderOpenOutlined, HistoryOutlined, PlusOutlined, PushpinOutlined, RightOutlined, RobotOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useStore, type RecentConnectionTarget, type RecentSQLFile } from '../store';
|
||||
import type { SavedConnection, SavedQuery, TabData } from '../types';
|
||||
import type { ExternalSQLDirectory, SavedConnection, SavedQuery, TabData } from '../types';
|
||||
import { t } from '../i18n';
|
||||
import {
|
||||
buildTabDisplayModel,
|
||||
@@ -70,6 +70,19 @@ type RecentConnectionShortcut = {
|
||||
dbName?: string;
|
||||
};
|
||||
|
||||
export type PinnedTableShortcut = {
|
||||
connection: SavedConnection;
|
||||
dbName: string;
|
||||
schemaName?: string;
|
||||
tableName: string;
|
||||
};
|
||||
|
||||
type LinkedExternalSQLDirectoryShortcut = {
|
||||
connection: SavedConnection;
|
||||
dbName?: string;
|
||||
directory: ExternalSQLDirectory;
|
||||
};
|
||||
|
||||
const RECENT_WORKBENCH_ITEM_LIMIT = 6;
|
||||
|
||||
export const buildRecentConnectionShortcuts = (
|
||||
@@ -107,6 +120,58 @@ export const buildRecentConnectionShortcuts = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const buildPinnedTableShortcuts = (
|
||||
connections: SavedConnection[],
|
||||
pinnedTableKeys: string[],
|
||||
): PinnedTableShortcut[] => {
|
||||
const connectionById = new Map(connections.map((connection) => [connection.id, connection]));
|
||||
const seen = new Set<string>();
|
||||
const result: PinnedTableShortcut[] = [];
|
||||
|
||||
for (const rawKey of pinnedTableKeys) {
|
||||
if (result.length >= RECENT_WORKBENCH_ITEM_LIMIT) break;
|
||||
try {
|
||||
const parsed = JSON.parse(rawKey);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 4) continue;
|
||||
const [rawConnectionId, rawDbName, rawSchemaName, rawTableName] = parsed;
|
||||
const connectionId = String(rawConnectionId || '').trim();
|
||||
const dbName = String(rawDbName || '').trim();
|
||||
const schemaName = String(rawSchemaName || '').trim();
|
||||
const tableName = String(rawTableName || '').trim();
|
||||
const connection = connectionById.get(connectionId);
|
||||
const key = `${connectionId}::${dbName}::${schemaName}::${tableName}`;
|
||||
if (!connection || !dbName || !tableName || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({
|
||||
connection,
|
||||
dbName,
|
||||
...(schemaName ? { schemaName } : {}),
|
||||
tableName,
|
||||
});
|
||||
} catch {
|
||||
// 旧版本或损坏的本地偏好不应阻塞工作台首页。
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const buildLinkedExternalSQLDirectoryShortcuts = (
|
||||
connections: SavedConnection[],
|
||||
directories: ExternalSQLDirectory[],
|
||||
): LinkedExternalSQLDirectoryShortcut[] => {
|
||||
const connectionById = new Map(connections.map((connection) => [connection.id, connection]));
|
||||
return [...directories]
|
||||
.sort((left, right) => Number(right.createdAt || 0) - Number(left.createdAt || 0))
|
||||
.flatMap((directory) => {
|
||||
const connectionId = String(directory.connectionId || '').trim();
|
||||
const connection = connectionById.get(connectionId);
|
||||
if (!connection) return [];
|
||||
const dbName = String(directory.dbName || connection.config.database || '').trim() || undefined;
|
||||
return [{ connection, ...(dbName ? { dbName } : {}), directory }];
|
||||
})
|
||||
.slice(0, RECENT_WORKBENCH_ITEM_LIMIT);
|
||||
};
|
||||
|
||||
const buildWorkbenchQueryTabId = (): string =>
|
||||
`query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
@@ -453,8 +518,10 @@ const TabManager: React.FC = React.memo(() => {
|
||||
const detachedWorkbenchWindows = useStore(state => state.detachedWorkbenchWindows);
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const externalSQLDirectories = useStore(state => state.externalSQLDirectories);
|
||||
const recentConnectionTargets = useStore(state => state.recentConnectionTargets);
|
||||
const recentSQLFiles = useStore(state => state.recentSQLFiles);
|
||||
const pinnedSidebarTables = useStore(state => state.pinnedSidebarTables);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const languagePreference = useStore(state => state.languagePreference);
|
||||
@@ -882,6 +949,14 @@ const TabManager: React.FC = React.memo(() => {
|
||||
.slice(0, RECENT_WORKBENCH_ITEM_LIMIT),
|
||||
[connectionById, recentSQLFiles],
|
||||
);
|
||||
const pinnedTableShortcuts = useMemo(
|
||||
() => buildPinnedTableShortcuts(queryCapableConnections, pinnedSidebarTables),
|
||||
[pinnedSidebarTables, queryCapableConnections],
|
||||
);
|
||||
const linkedExternalSQLDirectoryShortcuts = useMemo(
|
||||
() => buildLinkedExternalSQLDirectoryShortcuts(queryCapableConnections, externalSQLDirectories),
|
||||
[externalSQLDirectories, queryCapableConnections],
|
||||
);
|
||||
|
||||
const handleOpenConnectionModal = () => {
|
||||
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
|
||||
@@ -892,6 +967,14 @@ const TabManager: React.FC = React.memo(() => {
|
||||
setAIPanelVisible(true);
|
||||
};
|
||||
|
||||
const handleFocusObjectSearch = () => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search'));
|
||||
};
|
||||
|
||||
const handleAddExternalSQLDirectory = () => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:add-external-sql-directory'));
|
||||
};
|
||||
|
||||
const handleOpenRecentConnection = useCallback((shortcut: RecentConnectionShortcut) => {
|
||||
addTab({
|
||||
id: buildWorkbenchQueryTabId(),
|
||||
@@ -903,6 +986,24 @@ const TabManager: React.FC = React.memo(() => {
|
||||
});
|
||||
}, [addTab]);
|
||||
|
||||
const handleOpenPinnedTable = useCallback((shortcut: PinnedTableShortcut) => {
|
||||
const displayName = shortcut.schemaName
|
||||
? `${shortcut.schemaName}.${shortcut.tableName}`
|
||||
: shortcut.tableName;
|
||||
addTab({
|
||||
id: `pinned-table:${[shortcut.connection.id, shortcut.dbName, shortcut.schemaName || '', shortcut.tableName]
|
||||
.map(encodeURIComponent)
|
||||
.join(':')}`,
|
||||
title: displayName,
|
||||
type: 'table',
|
||||
connectionId: shortcut.connection.id,
|
||||
dbName: shortcut.dbName,
|
||||
tableName: shortcut.tableName,
|
||||
...(shortcut.schemaName ? { schemaName: shortcut.schemaName } : {}),
|
||||
objectType: 'table',
|
||||
});
|
||||
}, [addTab]);
|
||||
|
||||
const handleOpenSavedQuery = useCallback((query: SavedQuery) => {
|
||||
if (!connectionById.has(query.connectionId)) {
|
||||
message.error(t('sidebar.message.connection_config_not_found'));
|
||||
@@ -988,130 +1089,177 @@ const TabManager: React.FC = React.memo(() => {
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
|
||||
{t('query.new')}
|
||||
</Button>
|
||||
<Tooltip title={t('tab_manager.empty.quick.search.description')}>
|
||||
<Button icon={<SearchOutlined />} onClick={handleFocusObjectSearch}>
|
||||
{t('tab_manager.empty.quick.search.title')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
|
||||
{t('tab_manager.empty.action.open_ai')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="gn-v2-empty-recent" aria-label={t('tab_manager.empty.recent.aria')}>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><HistoryOutlined />{t('tab_manager.empty.recent.connection.heading')}</span>
|
||||
<em>{recentConnectionShortcuts.length}</em>
|
||||
</section>
|
||||
<section className="gn-v2-empty-recent" aria-label={t('tab_manager.empty.recent.aria')}>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><HistoryOutlined />{t('tab_manager.empty.recent.connection.heading')}</span>
|
||||
<em>{recentConnectionShortcuts.length}</em>
|
||||
</div>
|
||||
{recentConnectionShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentConnectionShortcuts.map((shortcut) => (
|
||||
<button
|
||||
key={`${shortcut.connection.id}::${shortcut.dbName || ''}`}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
onClick={() => handleOpenRecentConnection(shortcut)}
|
||||
>
|
||||
<DatabaseOutlined />
|
||||
<span>
|
||||
<strong title={shortcut.connection.name}>{shortcut.connection.name}</strong>
|
||||
<small>{shortcut.dbName || t('tab_manager.empty.recent.connection.default_database')}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{recentConnectionShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentConnectionShortcuts.map((shortcut) => (
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.connection.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><FileTextOutlined />{t('tab_manager.empty.recent.saved_query.heading')}</span>
|
||||
<em>{recentSavedQueries.length}</em>
|
||||
</div>
|
||||
{recentSavedQueries.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentSavedQueries.map((query) => {
|
||||
const connection = connectionById.get(query.connectionId);
|
||||
return (
|
||||
<button
|
||||
key={`${shortcut.connection.id}::${shortcut.dbName || ''}`}
|
||||
key={query.id}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
onClick={() => handleOpenRecentConnection(shortcut)}
|
||||
onClick={() => handleOpenSavedQuery(query)}
|
||||
>
|
||||
<DatabaseOutlined />
|
||||
<FileTextOutlined />
|
||||
<span>
|
||||
<strong title={shortcut.connection.name}>{shortcut.connection.name}</strong>
|
||||
<small>{shortcut.dbName || t('tab_manager.empty.recent.connection.default_database')}</small>
|
||||
<strong title={query.name || t('sidebar.tree.untitled_query')}>
|
||||
{query.name || t('sidebar.tree.untitled_query')}
|
||||
</strong>
|
||||
<small>{`${connection?.name || query.connectionId} · ${query.dbName || t('tab_manager.empty.recent.connection.default_database')}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.connection.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><FileTextOutlined />{t('tab_manager.empty.recent.saved_query.heading')}</span>
|
||||
<em>{recentSavedQueries.length}</em>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{recentSavedQueries.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentSavedQueries.map((query) => {
|
||||
const connection = connectionById.get(query.connectionId);
|
||||
return (
|
||||
<button
|
||||
key={query.id}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
onClick={() => handleOpenSavedQuery(query)}
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span>
|
||||
<strong title={query.name || t('sidebar.tree.untitled_query')}>
|
||||
{query.name || t('sidebar.tree.untitled_query')}
|
||||
</strong>
|
||||
<small>{`${connection?.name || query.connectionId} · ${query.dbName || t('tab_manager.empty.recent.connection.default_database')}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.saved_query.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><ConsoleSqlOutlined />{t('tab_manager.empty.recent.sql_file.heading')}</span>
|
||||
<em>{recentSQLFileShortcuts.length}</em>
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.saved_query.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="gn-v2-empty-recent-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><ConsoleSqlOutlined />{t('tab_manager.empty.recent.sql_file.heading')}</span>
|
||||
<em>{recentSQLFileShortcuts.length}</em>
|
||||
</div>
|
||||
{recentSQLFileShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentSQLFileShortcuts.map((file) => {
|
||||
const connection = connectionById.get(file.connectionId);
|
||||
const openKey = `${file.connectionId}::${file.dbName || ''}::${file.filePath}`;
|
||||
return (
|
||||
<button
|
||||
key={openKey}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
disabled={openingRecentSQLFileKey === openKey}
|
||||
onClick={() => void handleOpenRecentSQLFile(file)}
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span>
|
||||
<strong title={file.fileName}>{file.fileName}</strong>
|
||||
<small>{`${connection?.name || file.connectionId} · ${file.dbName || t('tab_manager.empty.recent.connection.default_database')}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{recentSQLFileShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{recentSQLFileShortcuts.map((file) => {
|
||||
const connection = connectionById.get(file.connectionId);
|
||||
const openKey = `${file.connectionId}::${file.dbName || ''}::${file.filePath}`;
|
||||
return (
|
||||
<button
|
||||
key={openKey}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
disabled={openingRecentSQLFileKey === openKey}
|
||||
onClick={() => void handleOpenRecentSQLFile(file)}
|
||||
>
|
||||
<FileTextOutlined />
|
||||
<span>
|
||||
<strong title={file.fileName}>{file.fileName}</strong>
|
||||
<small>{`${connection?.name || file.connectionId} · ${file.dbName || t('tab_manager.empty.recent.connection.default_database')}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.sql_file.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<p className="gn-v2-empty-recent-empty">{t('tab_manager.empty.recent.sql_file.empty')}</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
<section className="gn-v2-empty-panel" aria-label={t('tab_manager.empty.quick.aria')}>
|
||||
<div className="gn-v2-panel-heading">
|
||||
<span>{t('tab_manager.empty.quick.heading')}</span>
|
||||
<AppstoreOutlined />
|
||||
</div>
|
||||
<button type="button" onClick={handleOpenConnectionModal}>
|
||||
<DatabaseOutlined />
|
||||
<span>
|
||||
<strong>{t('tab_manager.empty.quick.configure_source.title')}</strong>
|
||||
<small>{t('tab_manager.empty.quick.configure_source.description')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
|
||||
<ConsoleSqlOutlined />
|
||||
<span>
|
||||
<strong>{t('tab_manager.empty.quick.sql_workspace.title')}</strong>
|
||||
<small>{t('tab_manager.empty.quick.sql_workspace.description')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={handleOpenAI}>
|
||||
<RobotOutlined />
|
||||
<span>
|
||||
<strong>{t('tab_manager.empty.quick.ai_assist.title')}</strong>
|
||||
<small>{t('tab_manager.empty.quick.ai_assist.description')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<section className="gn-v2-empty-resources" aria-label={t('tab_manager.empty.recent.aria')}>
|
||||
<section className="gn-v2-empty-resource-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><PushpinOutlined />{t('sidebar.action.pin_table')}</span>
|
||||
<em>{pinnedTableShortcuts.length}</em>
|
||||
</div>
|
||||
{pinnedTableShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{pinnedTableShortcuts.map((shortcut) => {
|
||||
const displayName = shortcut.schemaName
|
||||
? `${shortcut.schemaName}.${shortcut.tableName}`
|
||||
: shortcut.tableName;
|
||||
return (
|
||||
<button
|
||||
key={`${shortcut.connection.id}::${shortcut.dbName}::${shortcut.schemaName || ''}::${shortcut.tableName}`}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
onClick={() => handleOpenPinnedTable(shortcut)}
|
||||
>
|
||||
<DatabaseOutlined />
|
||||
<span>
|
||||
<strong title={displayName}>{displayName}</strong>
|
||||
<small>{`${shortcut.connection.name} · ${shortcut.dbName}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gn-v2-empty-resource-empty">
|
||||
<PushpinOutlined />
|
||||
<p>{t('tab_manager.empty.resource.pinned_tables.empty')}</p>
|
||||
<Button type="link" onClick={handleFocusObjectSearch}>{t('sidebar.command_search.label')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="gn-v2-empty-resource-card">
|
||||
<div className="gn-v2-empty-recent-heading">
|
||||
<span><FolderOpenOutlined />{t('sidebar.external_sql.root')}</span>
|
||||
<em>{linkedExternalSQLDirectoryShortcuts.length}</em>
|
||||
</div>
|
||||
{linkedExternalSQLDirectoryShortcuts.length > 0 ? (
|
||||
<div className="gn-v2-empty-recent-list">
|
||||
{linkedExternalSQLDirectoryShortcuts.map((shortcut) => (
|
||||
<button
|
||||
key={shortcut.directory.id}
|
||||
type="button"
|
||||
className="gn-v2-empty-recent-item"
|
||||
onClick={() => handleOpenRecentConnection(shortcut)}
|
||||
>
|
||||
<FolderOpenOutlined />
|
||||
<span>
|
||||
<strong title={shortcut.directory.name}>{shortcut.directory.name}</strong>
|
||||
<small>{`${shortcut.connection.name} · ${shortcut.dbName || t('tab_manager.empty.recent.connection.default_database')}`}</small>
|
||||
</span>
|
||||
<RightOutlined className="gn-v2-empty-recent-arrow" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gn-v2-empty-resource-empty">
|
||||
<FolderOpenOutlined />
|
||||
<p>{t('tab_manager.empty.resource.sql_directory.empty')}</p>
|
||||
<Button type="link" onClick={handleAddExternalSQLDirectory}>{t('sidebar.menu.add_sql_directory')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
27
frontend/src/components/TabManager.workbench-layout.test.ts
Normal file
27
frontend/src/components/TabManager.workbench-layout.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const themeSource = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
const readRule = (selector: string): string => {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = themeSource.match(new RegExp(`${escapedSelector}\\s*\\{(?<body>[^}]*)\\}`, 's'));
|
||||
expect(match, `missing CSS rule for ${selector}`).not.toBeNull();
|
||||
return match?.groups?.body ?? '';
|
||||
};
|
||||
|
||||
describe('empty workbench layout', () => {
|
||||
it('keeps the start page in a single compact content column', () => {
|
||||
const workbenchRule = readRule('body[data-ui-version="v2"] .gn-v2-empty-workbench');
|
||||
|
||||
expect(workbenchRule).toContain('display: flex;');
|
||||
expect(workbenchRule).toContain('flex-direction: column;');
|
||||
expect(workbenchRule).not.toContain('grid-template-columns');
|
||||
});
|
||||
|
||||
it('removes the oversized quick-workflow side panel', () => {
|
||||
expect(themeSource).not.toContain('gn-v2-empty-panel');
|
||||
expect(themeSource).not.toContain('gn-v2-panel-heading');
|
||||
});
|
||||
});
|
||||
@@ -3182,17 +3182,19 @@ body[data-ui-version="v2"] .gn-v2-sidebar-sql-audit-button:hover {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-workbench {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
|
||||
align-content: stretch;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: var(--gn-bg-panel-2);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel {
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero {
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -3206,7 +3208,7 @@ body[data-ui-version="v2"] .gn-v2-empty-hero {
|
||||
justify-content: flex-start;
|
||||
min-height: 0;
|
||||
background: var(--gn-bg-panel-2);
|
||||
padding: 28px 28px 24px 30px;
|
||||
padding: 24px 28px 18px 30px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-eyebrow {
|
||||
@@ -3222,9 +3224,9 @@ body[data-ui-version="v2"] .gn-v2-empty-eyebrow {
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero h1 {
|
||||
max-width: 720px;
|
||||
margin: 12px 0 0;
|
||||
margin: 10px 0 0;
|
||||
color: var(--gn-fg-1);
|
||||
font-size: 34px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -3237,7 +3239,7 @@ body[data-ui-version="v2"] .gn-v2-empty-hero h1 {
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero p {
|
||||
max-width: 560px;
|
||||
margin: 12px 0 0;
|
||||
margin: 8px 0 0;
|
||||
color: var(--gn-fg-3);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
@@ -3247,7 +3249,7 @@ body[data-ui-version="v2"] .gn-v2-empty-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-actions .ant-btn {
|
||||
@@ -3256,14 +3258,17 @@ body[data-ui-version="v2"] .gn-v2-empty-actions .ant-btn {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent {
|
||||
width: min(100%, 1040px);
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
margin: 0;
|
||||
padding: 0 28px 18px 30px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent-card {
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent-card,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-card {
|
||||
min-width: 0;
|
||||
min-height: 166px;
|
||||
display: flex;
|
||||
@@ -3274,6 +3279,20 @@ body[data-ui-version="v2"] .gn-v2-empty-recent-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resources {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 0 28px 28px 30px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-card {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3345,6 +3364,12 @@ body[data-ui-version="v2"] .gn-v2-empty-recent-item:hover:not(:disabled) {
|
||||
background: var(--gn-bg-hover);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent-item:focus-visible,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty .ant-btn:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--gn-accent) 72%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent-item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.62;
|
||||
@@ -3400,105 +3425,47 @@ body[data-ui-version="v2"] .gn-v2-empty-recent-empty {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel {
|
||||
border-left: 0.5px solid var(--gn-br-1);
|
||||
background: var(--gn-bg-panel-2);
|
||||
padding: 16px 16px 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: var(--gn-font-sans);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--gn-fg-1);
|
||||
font-family: var(--gn-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel button {
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty {
|
||||
flex: 1 1 auto;
|
||||
min-height: 106px;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
border: 0.5px solid var(--gn-br-1);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--gn-bg-panel-2) 88%, var(--gn-bg-panel) 12%);
|
||||
color: var(--gn-fg-1);
|
||||
cursor: pointer;
|
||||
font-family: var(--gn-font-sans);
|
||||
padding: 10px 12px;
|
||||
padding: 14px 14px 14px 12px;
|
||||
color: var(--gn-fg-4);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel button:hover {
|
||||
border-color: color-mix(in srgb, var(--gn-accent) 42%, var(--gn-br-1));
|
||||
background: var(--gn-bg-hover);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel button > .anticon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty > .anticon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--gn-accent);
|
||||
border-radius: 7px;
|
||||
background: var(--gn-accent-soft);
|
||||
color: var(--gn-accent);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel strong,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel small {
|
||||
display: block;
|
||||
font-family: var(--gn-font-sans);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel strong {
|
||||
color: var(--gn-fg-1);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel small {
|
||||
color: var(--gn-fg-4);
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty .ant-btn {
|
||||
padding-inline: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
body[data-ui-version="v2"] .gn-v2-empty-workbench {
|
||||
grid-template-columns: minmax(0, 1.28fr) minmax(260px, 0.72fr);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero {
|
||||
padding: 24px 22px 22px 24px;
|
||||
padding: 22px 22px 16px 24px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel {
|
||||
padding: 14px 14px 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
body[data-ui-version="v2"] .gn-v2-empty-workbench {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-panel {
|
||||
border-left: 0;
|
||||
border-top: 0.5px solid var(--gn-br-1);
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resources {
|
||||
padding-left: 24px;
|
||||
padding-right: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3512,6 +3479,26 @@ body[data-ui-version="v2"] .gn-v2-empty-panel small {
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resources {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-hero,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-recent,
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resources {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty {
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-empty-resource-empty .ant-btn {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Full V2 workbench shell: app / topbar / workspace ─ */
|
||||
|
||||
Reference in New Issue
Block a user