feat(workbench): 丰富主页快捷资源与工作入口

This commit is contained in:
Syngnat
2026-07-13 22:52:01 +08:00
parent b22a0894b8
commit c9ddd8919e
11 changed files with 424 additions and 207 deletions

View File

@@ -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') {

View File

@@ -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',
}),
]);
});
});

View File

@@ -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>
);

View 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');
});
});

View File

@@ -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 ─ */