使用React js完成web前端

This commit is contained in:
LIm1tZz7 committed 2026-09-27 22:08:23 +08:00
1 parent 9e8c5f1ef0
commit 7600f0f9ed
130 files changed
+12708 -788

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
/* 工作台布局样式:编辑器面板、标签页、底部面板与提示。 */
.editorPane { display: grid; grid-template-rows: 30px minmax(0, 1fr); height: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.tabs { align-items: stretch; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; overflow-x: auto; }
.tab, .tabActive { align-items: center; background: var(--bg-base); border-right: 1px solid var(--border); border-top: 1px solid transparent; display: flex; flex: 0 0 auto; }
.tab { color: var(--fg-muted); }
.tab:hover { color: var(--fg-primary); }
.tabActive { border-top-color: var(--accent); color: var(--fg-primary); }
.tabLabel { background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; max-width: 180px; overflow: hidden; padding: 0 8px 0 12px; text-overflow: ellipsis; white-space: nowrap; }
.tabs button[aria-label] { flex-shrink: 0; height: 20px; margin-right: 4px; opacity: 0; width: 20px; }
.tab:hover button[aria-label], .tabActive button[aria-label], .tabs button[aria-label]:focus-visible { opacity: 1; }
.tabs svg { flex-shrink: 0; }
.notice, .welcome { color: var(--fg-muted); padding: 24px; }
.welcome { align-items: center; display: flex; height: 100%; justify-content: center; text-align: center; }
.bottomTabs { border-bottom: 1px solid var(--border); display: flex; gap: 3px; height: 28px; }
.bottomTabs button { background: transparent; border: 0; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.bottomTabs button:first-child { border-bottom: 1px solid var(--accent); }
.message { color: var(--fg-muted); padding: 8px; }
+150
View File
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChangeStore } from './store/changeStore';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
// App 组件测试:校验行删除、编辑提交与多标签编辑区。
vi.mock('./components/sql/SqlConsole', () => ({ SqlConsole: () => <div data-testid="sql-console" /> }));
describe('App table editing flow', () => {
beforeEach(() => useChangeStore.getState().clear());
it('stages row deletion on the first click and allows undo', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
fireEvent.click(screen.getByRole('button', { name: '删除选中行' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(1);
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('keeps edits local and commits the complete change set once', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const commit = vi.spyOn(adapter, 'commitChanges');
render(<App adapter={adapter} />);
expect(screen.getByText('资源管理器')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /打开表 users/ }));
const cell = await screen.findByText('User_001');
fireEvent.doubleClick(cell);
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: 'edited' } });
fireEvent.keyDown(editor, { key: 'Enter' });
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
expect(commit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(() => expect(commit).toHaveBeenCalledTimes(1));
expect(commit.mock.calls[0][0].changes).toHaveLength(1);
});
});
describe('App tabs', () => {
beforeEach(() => useChangeStore.getState().clear());
it('keeps two tables open and switches back to the first one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
expect(await screen.findByText('User_001')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(screen.getByRole('tab', { name: 'orders' })).toHaveAttribute('aria-selected', 'true');
fireEvent.click(screen.getByRole('tab', { name: 'users' }));
expect(screen.getByRole('tab', { name: 'users' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('does not add a second tab for a table that is already open', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('activates the neighbouring tab after closing the active one', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '打开表 orders' }));
fireEvent.click(screen.getByRole('button', { name: '关闭 orders' }));
expect(screen.getAllByRole('tab')).toHaveLength(1);
expect(screen.queryByRole('tab', { name: 'orders' })).not.toBeInTheDocument();
expect(await screen.findByText('User_001')).toBeInTheDocument();
});
it('opens one tab for every new query', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '新建查询' }));
fireEvent.click(screen.getByRole('button', { name: '新建查询' }));
expect(screen.getAllByRole('tab')).toHaveLength(2);
expect(await screen.findByTestId('sql-console')).toBeInTheDocument();
expect(screen.getAllByTestId('sql-console')).toHaveLength(1);
});
});
describe('App pending changes', () => {
beforeEach(() => useChangeStore.getState().clear());
it('shows a staged row edit as highlighted SQL instead of JSON', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.doubleClick(await screen.findByText('User_001'));
const editor = screen.getByRole('textbox', { name: '编辑 name' });
fireEvent.change(editor, { target: { value: "A'B" } });
fireEvent.keyDown(editor, { key: 'Enter' });
const code = await screen.findByTitle("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(code).toHaveTextContent("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(within(code).getByText('UPDATE')).toHaveStyle({ color: '#569CD6' });
expect(within(code).getByText("'A''B'")).toHaveStyle({ color: '#CE9178' });
expect(within(code).getByText('users')).toHaveStyle({ color: 'rgb(200, 200, 200)' });
expect(screen.queryByText(/\{"id":1/)).not.toBeInTheDocument();
});
it('counts a staged index change as a pending change and previews its SQL', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
await screen.findByText('User_001');
fireEvent.click(screen.getByRole('button', { name: '管理 users 的索引' }));
const indexRows = within(screen.getByRole('dialog')).getAllByRole('listitem');
fireEvent.click(within(indexRows[2]).getByRole('button', { name: '建索引' }));
expect(await screen.findByTitle('CREATE INDEX ON users (age);')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '提交 (1)' })).toBeEnabled();
});
it('creates a staged table only when the change set is committed', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
render(<App adapter={adapter} />);
fireEvent.click(await screen.findByRole('button', { name: '新建表' }));
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(await screen.findByTitle('CREATE TABLE items (id int, title str);')).toBeInTheDocument();
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '提交 (1)' }));
await waitFor(async () => {
expect((await adapter.listTables('chusql')).some((table) => table.name === 'items')).toBe(true);
});
expect(await screen.findByRole('button', { name: '打开表 items' })).toBeInTheDocument();
});
it('selects a single row on click, toggles with Ctrl and extends with Shift', async () => {
render(<App adapter={new MockAdapter({ latencyMs: 0 })} />);
fireEvent.click(await screen.findByRole('button', { name: '打开表 users' }));
fireEvent.click(await screen.findByText('User_001'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 2 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_002'), { ctrlKey: true });
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_001'));
fireEvent.click(screen.getByText('User_004'), { shiftKey: true });
expect(screen.getByText('已选 4 行')).toBeInTheDocument();
fireEvent.click(screen.getByText('User_003'));
expect(screen.getByText('已选 1 行')).toBeInTheDocument();
});
});
+126
View File
@@ -0,0 +1,126 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { MockAdapter } from './adapters/MockAdapter';
import { IconButton } from './components/common/IconButton';
import { TableEditor } from './components/grid/TableEditor';
import { BottomPanel } from './components/layout/BottomPanel';
import { Explorer } from './components/layout/Explorer';
import { Workbench } from './components/layout/Workbench';
import { SchemaDialog, type SchemaAction } from './components/layout/SchemaDialog';
import { SettingsPanel } from './components/settings/SettingsPanel';
import type { QueryHistoryEntry } from './components/sql/SqlConsole';
import { changeCount, toCommitPayload } from './core/changes';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab, type TabState } from './core/tabs';
import { syncUiSettings } from './core/uiSettings';
import { zhCN } from './i18n/zh-CN';
import { useChangeStore } from './store/changeStore';
import { fontFamilyValue, useSettingsStore } from './store/settingsStore';
import type { DataSourceAdapter, SchemaChange, StatementResult, TableMeta } from './types';
import styles from './App.module.css';
// 应用根组件:装配数据源、多标签编辑区、变更提交与设置视图。
interface AppProps { adapter?: DataSourceAdapter }
const SqlConsole = lazy(async () => ({ default: (await import('./components/sql/SqlConsole')).SqlConsole }));
function initialHistory(): QueryHistoryEntry[] {
localStorage.removeItem('chusql.sql.history.v1');
try { const value = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]'); return Array.isArray(value) ? value : []; }
catch { return []; }
}
export function App({ adapter: providedAdapter }: AppProps) {
const adapter = useMemo(() => providedAdapter ?? new MockAdapter(), [providedAdapter]);
const [tables, setTables] = useState<TableMeta[]>([]);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeId, setActiveId] = useState<string>();
const [totals, setTotals] = useState<Record<string, number>>({});
const [messages, setMessages] = useState<string[]>([]);
const [results, setResults] = useState<StatementResult[]>([]);
const [history, setHistory] = useState<QueryHistoryEntry[]>(initialHistory);
const [revision, setRevision] = useState(0);
const [committing, setCommitting] = useState(false);
const [schemaAction, setSchemaAction] = useState<SchemaAction>();
const idRef = useRef(0);
const queryRef = useRef(0);
const { changes, undoStack, redoStack, undoOne, undo, redo, clear, acknowledge } = useChangeStore();
const settings = useSettingsStore((state) => state.settings);
const count = changeCount(changes);
const activeTab = tabs.find((tab) => tab.id === activeId);
const activeTableTab = activeTab?.kind === 'table' ? activeTab : undefined;
const activeMeta = activeTableTab ? tables.find((table) => table.name === activeTableTab.table) : undefined;
const pushMessage = (message: string) => setMessages((current) => [...current, message].slice(-100));
const nextTabId = () => `tab-${(idRef.current += 1)}`;
const applyTabs = (state: TabState) => { setTabs(state.tabs); setActiveId(state.activeId); };
const forgetTotal = (id: string) => setTotals((current) => { const next = { ...current }; delete next[id]; return next; });
const loadTables = async () => {
try { setTables(await adapter.listTables(zhCN.database)); }
catch (reason) { pushMessage(`${zhCN.loadError}: ${reason instanceof Error ? reason.message : String(reason)}`); }
};
useEffect(() => { void loadTables(); }, [adapter]);
useEffect(() => syncUiSettings(), []);
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--ui-font', fontFamilyValue(settings.uiFonts));
root.style.setProperty('--grid-font', fontFamilyValue(settings.gridFonts));
root.style.setProperty('--grid-font-size', `${settings.gridFontSize}px`);
root.style.setProperty('--grid-row-height', `${settings.gridRowHeight}px`);
root.style.fontSize = `${settings.uiFontSize}px`;
}, [settings]);
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (count) event.preventDefault(); };
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [count]);
const rollback = () => { clear(); setRevision((value) => value + 1); };
const applySchema = async (change: SchemaChange) => {
if (change.op === 'createTable') await adapter.createTable(change.table, change.columns);
else if (change.op === 'dropTable') await adapter.dropTable(change.table);
else if (change.op === 'createIndex') await adapter.createIndex(change.table, change.column);
else await adapter.dropIndex(change.table, change.column);
};
const commit = async () => {
if (committing) return;
setCommitting(true);
try {
const rows = toCommitPayload(changes);
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
let applied = 0;
let failure = '';
try {
for (const change of creates) { await applySchema(change); applied += 1; }
if (rows.length) {
const result = await adapter.commitChanges({ db: zhCN.database, changes: rows });
applied += result.ok ? rows.length : result.applied;
if (!result.ok) failure = result.errors?.map((item) => item.message).join('; ') ?? '';
}
if (!failure) for (const change of drops) { await applySchema(change); applied += 1; }
} catch (reason) { failure = reason instanceof Error ? reason.message : String(reason); }
if (applied) acknowledge(changes, applied);
if (failure) pushMessage(`${zhCN.commitFailed} (${applied} applied; retry submits only the remainder): ${failure}`);
else pushMessage(zhCN.committed(applied));
await loadTables(); setRevision((value) => value + 1);
} finally { setCommitting(false); }
};
const requestCommit = () => { if (count) void commit(); };
const openTable = (name: string) => applyTabs(openTableTab(tabs, name, nextTabId()));
const newQuery = (sql?: string) => applyTabs(openQueryTab(tabs, zhCN.queryTab((queryRef.current += 1)), nextTabId(), sql));
const openSettings = () => applyTabs(openSettingsTab(tabs, zhCN.settings, nextTabId()));
const closeTabById = (tab: Tab) => {
forgetTotal(tab.id);
applyTabs(closeTab(tabs, tab.id, activeId));
};
let editor = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (tabs.length) {
let body = <div className={styles.welcome}>{zhCN.emptyWorkspace}</div>;
if (activeTab?.kind === 'sql') body = <Suspense fallback={<div className={styles.notice}>{zhCN.loading}</div>}><SqlConsole key={activeTab.id} adapter={adapter} settings={settings} initialSql={activeTab.sql} onResult={(result) => setResults((current) => [...current, result])} onMessage={pushMessage} onHistory={setHistory} /></Suspense>;
else if (activeTab?.kind === 'settings') body = <SettingsPanel />;
else if (activeTableTab && activeMeta) body = <TableEditor key={activeTableTab.id} adapter={adapter} table={activeMeta} settings={settings} revision={revision} onTotal={(value) => setTotals((current) => ({ ...current, [activeTableTab.id]: value }))} onMessage={pushMessage} />;
editor = <section className={styles.editorPane}><div className={styles.tabs} role="tablist">{tabs.map((tab) => <div key={tab.id} className={tab.id === activeId ? styles.tabActive : styles.tab}><button role="tab" aria-selected={tab.id === activeId} className={styles.tabLabel} onClick={() => setActiveId(tab.id)}>{tab.title}</button><IconButton compact icon={<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="m4 4 8 8m0-8-8 8" /></svg>} label={zhCN.closeTab(tab.title)} onClick={() => closeTabById(tab)} /></div>)}</div>{body}</section>;
}
return <><Workbench explorer={<Explorer tables={tables} activeTable={activeMeta?.name} history={history} onOpenTable={openTable} onOpenSql={newQuery} onCreateTable={() => setSchemaAction({ kind: 'create' })} onIndexes={(table) => setSchemaAction({ kind: 'indexes', table })} onDropTable={(table) => setSchemaAction({ kind: 'drop', table })} />} editor={editor} bottom={<BottomPanel changes={changes} results={results} messages={messages} onUndoOne={undoOne} />} status={<><span>{zhCN.connection}</span><span>{zhCN.database}</span>{activeMeta && <span>{activeMeta.name}</span>}{activeTableTab && activeMeta && <span>{zhCN.rowCount(totals[activeTableTab.id] ?? 0)}</span>}<span>{zhCN.pendingCount(count)}</span></>} changeCount={count} canUndo={undoStack.length > 0} canRedo={redoStack.length > 0} onCommit={requestCommit} onRollback={rollback} onUndo={undo} onRedo={redo} onSql={() => newQuery()} onSettings={openSettings} />{schemaAction && <SchemaDialog action={schemaAction} onClose={() => setSchemaAction(undefined)} onChanged={pushMessage} />}</>;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { MockAdapter } from './MockAdapter';
// MockAdapter 测试:列表行数、排序筛选与变更集提交。
describe('MockAdapter', () => {
it('ships users and orders with 500 rows each', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const tables = await adapter.listTables('chusql');
expect(tables.map((table) => [table.name, table.rowCount])).toEqual([
['users', 500],
['orders', 500],
]);
});
it('sorts and filters before paginating', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const result = await adapter.fetchRows({
db: 'chusql', table: 'users', page: 0, pageSize: 3,
sort: [{ column: 'age', dir: 'desc' }], filters: [{ column: 'age', expression: '>60' }],
});
expect(result.total).toBeGreaterThan(3);
expect(result.rows).toHaveLength(3);
expect(Number(result.rows[0].age)).toBeGreaterThanOrEqual(Number(result.rows[1].age));
});
it('applies a complete change set in one call', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const response = await adapter.commitChanges({
db: 'chusql',
changes: [
{ op: 'update', table: 'users', pk: 1, set: { name: 'edited' } },
{ op: 'delete', table: 'users', pk: 2 },
],
});
expect(response).toEqual({ ok: true, applied: 2 });
const page = await adapter.fetchRows({ db: 'chusql', table: 'users', page: 0, pageSize: 3, sort: [], filters: [] });
expect(page.rows[0].name).toBe('edited');
expect(page.rows.some((row) => row.id === 2)).toBe(false);
});
it('refuses an index on a column that holds duplicate values', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await expect(adapter.createIndex('users', 'age')).rejects.toThrow('duplicate values');
const users = (await adapter.listTables('chusql')).find((table) => table.name === 'users')!;
expect(users.columns.find((column) => column.name === 'age')?.indexed).toBeFalsy();
});
});
@@ -0,0 +1,178 @@
import { matchesFilter } from '../core/filters';
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// 内存数据源:内置 mock 表,供无后端环境演示与测试。
type TableData = { columns: ColumnMeta[]; rows: Array<Record<string, unknown>> };
function makeData(): Map<string, TableData> {
const users = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
name: `User_${String(index + 1).padStart(3, '0')}`,
age: 18 + (index % 55),
active: index % 4 !== 0,
joined_at: `202${index % 5}-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}`,
}));
const orders = Array.from({ length: 500 }, (_, index) => ({
id: index + 1,
user_id: (index % 500) + 1,
product: ['Keyboard', 'Monitor', 'Database Course', 'Hard Drive'][index % 4],
amount: 49 + ((index * 37) % 2400),
paid: index % 3 !== 0,
}));
return new Map([
['users', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'name', type: 'str' },
{ name: 'age', type: 'int' },
{ name: 'active', type: 'bool' },
{ name: 'joined_at', type: 'date' },
], rows: users,
}],
['orders', {
columns: [
{ name: 'id', type: 'int', primaryKey: true, indexed: true },
{ name: 'user_id', type: 'int', indexed: true },
{ name: 'product', type: 'str' },
{ name: 'amount', type: 'int' },
{ name: 'paid', type: 'bool' },
], rows: orders,
}],
]);
}
function compareValues(left: unknown, right: unknown): number {
if (left === right) return 0;
if (left === null || left === undefined) return -1;
if (right === null || right === undefined) return 1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), 'zh-CN', { numeric: true });
}
export class MockAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await this.delay();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || this.tables.has(name)) throw new Error('Invalid or already existing table name');
if (!columns.length || new Set(columns.map((column) => column.name)).size !== columns.length) throw new Error('Column names must be non-empty and unique');
this.tables.set(name, { columns: columns.map((column) => ({ ...column, primaryKey: column.name === 'id', indexed: column.name === 'id' })), rows: [] });
}
async dropTable(table: string): Promise<void> {
await this.delay();
if (!this.tables.delete(table)) throw new Error('Table does not exist');
}
async createIndex(table: string, column: string): Promise<void> {
await this.delay();
const found = this.tables.get(table);
const meta = found?.columns.find((item) => item.name === column);
if (!found || !meta || meta.type !== 'int' || meta.indexed) throw new Error('Only unindexed integer columns can be indexed');
if (new Set(found.rows.map((row) => row[column])).size !== found.rows.length) throw new Error('Index column contains duplicate values and must be unique');
meta.indexed = true;
}
async dropIndex(table: string, column: string): Promise<void> {
await this.delay();
const meta = this.tables.get(table)?.columns.find((item) => item.name === column);
if (!meta?.indexed || column === 'id') throw new Error('Index does not exist or is a built-in index that cannot be dropped');
meta.indexed = false;
}
private readonly tables = makeData();
private readonly latencyMs: number | [number, number];
constructor(options: { latencyMs?: number | [number, number] } = {}) {
this.latencyMs = options.latencyMs ?? [80, 200];
}
private async delay(): Promise<void> {
const milliseconds = Array.isArray(this.latencyMs)
? this.latencyMs[0] + Math.random() * (this.latencyMs[1] - this.latencyMs[0])
: this.latencyMs;
if (milliseconds > 0) await new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
async listTables(_db: string): Promise<TableMeta[]> {
await this.delay();
return Array.from(this.tables, ([name, table]) => ({ name, rowCount: table.rows.length, columns: table.columns }));
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
await this.delay();
const found = this.tables.get(table);
if (!found) throw new Error(`Unknown table: ${table}`);
return found.columns.map((column) => ({ ...column }));
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
await this.delay();
const found = this.tables.get(params.table);
if (!found) throw new Error(`Unknown table: ${params.table}`);
let rows = found.rows.filter((row) => params.filters.every((filter) => matchesFilter(row[filter.column], filter.expression)));
if (params.search?.trim()) {
const term = params.search.toLocaleLowerCase();
rows = rows.filter((row) => Object.values(row).some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}
if (params.sort.length) {
rows = [...rows].sort((left, right) => {
for (const sort of params.sort) {
const compared = compareValues(left[sort.column], right[sort.column]);
if (compared !== 0) return sort.dir === 'asc' ? compared : -compared;
}
return 0;
});
}
const start = params.page * params.pageSize;
return { rows: rows.slice(start, start + params.pageSize).map((row) => ({ ...row })), total: rows.length };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
await this.delay();
const working = new Map(Array.from(this.tables, ([name, table]) => [name, { columns: table.columns, rows: table.rows.map((row) => ({ ...row })) }]));
const errors: Array<{ index: number; message: string }> = [];
payload.changes.forEach((change, index) => {
const table = working.get(change.table);
if (!table) {
errors.push({ index, message: `Unknown table: ${change.table}` });
return;
}
if (change.op === 'insert') {
if (table.rows.some((row) => row.id === change.values.id)) errors.push({ index, message: `Duplicate id: ${String(change.values.id)}` });
else table.rows.push({ ...change.values });
return;
}
const rowIndex = table.rows.findIndex((row) => row.id === change.pk);
if (rowIndex < 0) {
errors.push({ index, message: `Row not found: ${String(change.pk)}` });
} else if (change.op === 'delete') {
table.rows.splice(rowIndex, 1);
} else {
table.rows[rowIndex] = { ...table.rows[rowIndex], ...change.set };
}
});
if (errors.length) return { ok: false, applied: 0, errors };
this.tables.clear();
for (const [name, table] of working) this.tables.set(name, table);
return { ok: true, applied: payload.changes.length };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
await this.delay();
const normalized = sql.trim().replace(/;$/, '');
const select = normalized.match(/^select\s+(.+?)\s+from\s+([A-Za-z_][A-Za-z0-9_]*)/is);
if (select) {
const table = this.tables.get(select[2]);
if (!table) return { type: 'error', durationMs: performance.now() - started, message: `Unknown table: ${select[2]}` };
const columns = select[1].trim() === '*' ? table.columns.map((column) => column.name) : select[1].split(',').map((item) => item.trim());
return {
type: 'rows', columns,
rows: table.rows.slice(0, 200).map((row) => columns.map((column) => row[column])),
durationMs: performance.now() - started,
};
}
if (/^(insert|update|delete|create|drop)\b/i.test(normalized)) {
return { type: 'affected', affected: 1, durationMs: performance.now() - started };
}
return { type: 'error', durationMs: performance.now() - started, message: 'MockAdapter cannot execute this statement' };
}
}
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RestAdapter } from './RestAdapter';
// RestAdapter 测试:接口路径编码、响应归一化与错误处理。
describe('RestAdapter', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses structured schema endpoints and encodes identifiers in paths', async () => {
const request = vi.fn().mockImplementation(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', request);
const adapter = new RestAdapter();
await adapter.createTable('items', [{ name: 'id', type: 'int' }]);
await adapter.createIndex('items', 'sku');
await adapter.dropIndex('items', 'sku');
await adapter.dropTable('a/b');
expect(request.mock.calls.map(([path, init]) => [path, init.method, init.body])).toEqual([
['/api/tables', 'POST', JSON.stringify({ name: 'items', columns: [{ name: 'id', type: 'int' }] })],
['/api/tables/items/indexes', 'POST', JSON.stringify({ column: 'sku' })],
['/api/tables/items/indexes/sku', 'DELETE', undefined],
['/api/tables/a%2Fb', 'DELETE', undefined],
]);
});
it('normalizes the existing Haskell catalog response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{
table: 'users', rowCount: 2,
columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }],
indexes: [{ column: 'id', builtIn: true }],
}]), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const tables = await new RestAdapter().listTables('chusql');
expect(tables).toEqual([{ name: 'users', rowCount: 2, columns: [{ name: 'id', type: 'int', primaryKey: true, indexed: true }] }]);
});
it('maps pages, sorting and filters to the existing rows endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
columns: ['id', 'name'], rows: [[1, 'Ada']], total: 1,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', request);
const result = await new RestAdapter().fetchRows({
db: 'chusql', table: 'users', page: 2, pageSize: 20,
sort: [{ column: 'name', dir: 'desc' }], filters: [{ column: 'name', expression: 'Ada' }],
});
expect(result).toEqual({ rows: [{ id: 1, name: 'Ada' }], total: 1 });
expect(String(request.mock.calls[0][0])).toContain('/api/tables/users/rows?');
expect(String(request.mock.calls[0][0])).toContain('offset=40');
expect(String(request.mock.calls[0][0])).toContain('sort=name');
});
it('turns non-JSON and structured API failures into safe errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<stack trace>', { status: 500 })));
await expect(new RestAdapter().listTables('chusql')).rejects.toThrow('Request failed with status 500');
});
});
@@ -0,0 +1,153 @@
import type {
ColumnMeta, CommitChange, DataSourceAdapter, FetchRowsParams, StatementResult, TableMeta,
} from '../types';
// REST 数据源:调用后端 JSON 接口并校验返回结构。
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function asColumns(value: unknown): ColumnMeta[] {
if (!Array.isArray(value)) throw new Error('The server returned an invalid column list.');
return value.map((entry) => {
if (!isObject(entry) || typeof entry.name !== 'string' || typeof entry.type !== 'string') {
throw new Error('The server returned an invalid column description.');
}
if (!['int', 'str', 'bool', 'date'].includes(entry.type)) throw new Error(`Unsupported column type: ${entry.type}`);
return {
name: entry.name,
type: entry.type as ColumnMeta['type'],
...(typeof entry.primaryKey === 'boolean' ? { primaryKey: entry.primaryKey } : {}),
...(typeof entry.indexed === 'boolean' ? { indexed: entry.indexed } : {}),
...(typeof entry.prime === 'boolean' ? { prime: entry.prime } : {}),
...(typeof entry.distinct === 'number' ? { distinct: entry.distinct } : {}),
...(typeof entry.statsCapped === 'boolean' ? { statsCapped: entry.statsCapped } : {}),
};
});
}
function asTable(value: unknown): TableMeta {
if (!isObject(value) || typeof value.table !== 'string' || typeof value.rowCount !== 'number') {
throw new Error('The server returned an invalid table description.');
}
return { name: value.table, rowCount: value.rowCount, columns: asColumns(value.columns) };
}
async function requestJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, { credentials: 'same-origin', ...init });
let body: unknown;
try {
body = await response.json();
} catch {
body = undefined;
}
if (!response.ok) {
const message = isObject(body) && typeof body.message === 'string'
? body.message
: `Request failed with status ${response.status}.`;
throw new Error(message);
}
return body;
}
function jsonInit(method: string, body?: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
};
}
function changeRequest(change: CommitChange): [string, RequestInit] {
const table = encodeURIComponent(change.table);
if (change.op === 'insert') return [`/api/tables/${table}/rows`, jsonInit('POST', { values: change.values })];
const pk = encodeURIComponent(String(change.pk));
if (change.op === 'delete') return [`/api/tables/${table}/rows/${pk}`, jsonInit('DELETE')];
return [`/api/tables/${table}/rows/${pk}`, jsonInit('PATCH', { values: change.set })];
}
export class RestAdapter implements DataSourceAdapter {
async createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void> {
await requestJson('/api/tables', jsonInit('POST', { name, columns }));
}
async dropTable(table: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}`, jsonInit('DELETE'));
}
async createIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes`, jsonInit('POST', { column }));
}
async dropIndex(table: string, column: string): Promise<void> {
await requestJson(`/api/tables/${encodeURIComponent(table)}/indexes/${encodeURIComponent(column)}`, jsonInit('DELETE'));
}
async listTables(_db: string): Promise<TableMeta[]> {
const body = await requestJson('/api/tables');
if (!Array.isArray(body)) throw new Error('The server returned an invalid table list.');
return body.map(asTable);
}
async getColumns(_db: string, table: string): Promise<ColumnMeta[]> {
return asTable(await requestJson(`/api/tables/${encodeURIComponent(table)}`)).columns;
}
async fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }> {
const query = new URLSearchParams({
limit: String(params.pageSize),
offset: String(params.page * params.pageSize),
});
const firstSort = params.sort[0];
if (firstSort) {
query.set('sort', firstSort.column);
query.set('dir', firstSort.dir);
}
if (params.filters.length) {
query.set('filter', JSON.stringify(params.filters.map((filter) => ({ column: filter.column, value: filter.expression }))));
}
const body = await requestJson(`/api/tables/${encodeURIComponent(params.table)}/rows?${query}`);
if (!isObject(body) || !Array.isArray(body.columns) || !body.columns.every((column) => typeof column === 'string') || !Array.isArray(body.rows) || typeof body.total !== 'number') {
throw new Error('The server returned an invalid row page.');
}
const columns = body.columns as string[];
const rows = body.rows.map((raw) => {
if (!Array.isArray(raw)) throw new Error('The server returned an invalid row.');
return Object.fromEntries(columns.map((column, index) => [column, raw[index]]));
});
return { rows, total: body.total };
}
async commitChanges(payload: { db: string; changes: CommitChange[] }): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }> {
let applied = 0;
for (const [index, change] of payload.changes.entries()) {
const [url, init] = changeRequest(change);
try {
await requestJson(url, init);
applied += 1;
} catch (error) {
return {
ok: false,
applied,
errors: [{ index, message: error instanceof Error ? error.message : String(error) }],
};
}
}
return { ok: true, applied };
}
async execStatement({ sql }: { db: string; sql: string }): Promise<StatementResult> {
const started = performance.now();
try {
const body = await requestJson('/api/query', jsonInit('POST', { sql }));
if (!isObject(body) || !Array.isArray(body.columns) || !Array.isArray(body.rows)) {
throw new Error('The server returned an invalid query result.');
}
const durationMs = performance.now() - started;
return body.columns.length
? { type: 'rows', columns: body.columns as string[], rows: body.rows as unknown[][], durationMs }
: { type: 'affected', affected: typeof body.rowCount === 'number' ? body.rowCount : 0, durationMs };
} catch (error) {
return { type: 'error', durationMs: performance.now() - started, message: error instanceof Error ? error.message : String(error) };
}
}
}
@@ -0,0 +1,25 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthGate } from './AuthGate';
// 覆盖登录门的会话校验与登录流程。
describe('AuthGate', () => {
afterEach(() => vi.unstubAllGlobals());
it('keeps REST content private until a successful login', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
render(<AuthGate><p>protected content</p></AuthGate>);
expect(await screen.findByRole('heading', { name: '登录 ChuSQL' })).toBeInTheDocument();
expect(screen.queryByText('protected content')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'chusql' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getByText('protected content')).toBeInTheDocument());
expect(request).toHaveBeenLastCalledWith('/api/login', expect.objectContaining({ method: 'POST' }));
});
});
@@ -0,0 +1,56 @@
import { type FormEvent, type ReactNode, useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import styles from './auth.module.css';
// 登录门组件:先查会话状态,未登录时渲染登录表单。
type AuthState = 'checking' | 'signed-out' | 'signed-in';
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>('checking');
const [user, setUser] = useState('root');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
void fetch('/api/session', { credentials: 'same-origin', signal: controller.signal })
.then((response) => setState(response.ok ? 'signed-in' : 'signed-out'))
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) setState('signed-out');
});
return () => controller.abort();
}, []);
const submit = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setFailed(false);
try {
const response = await fetch('/api/login', {
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, password }),
});
if (response.ok) setState('signed-in');
else setFailed(true);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
};
if (state === 'signed-in') return children;
if (state === 'checking') return <main className={styles.center}><p>{zhCN.signingIn}</p></main>;
return <main className={styles.center}>
<form className={styles.login} onSubmit={(event) => void submit(event)}>
<header><Database size={18} /><h1>{zhCN.signInTitle}</h1></header>
<label><span>{zhCN.userName}</span><input autoFocus autoComplete="username" value={user} onChange={(event) => setUser(event.target.value)} /></label>
<label><span>{zhCN.password}</span><input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{failed && <p role="alert">{zhCN.signInFailed}</p>}
<button disabled={busy || !user || !password}>{zhCN.signIn}</button>
</form>
</main>;
}
@@ -0,0 +1,19 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './common.module.css';
// 通用图标按钮:支持紧凑模式,标签兼作无障碍名。
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: ReactNode;
label: string;
compact?: boolean;
}
export function IconButton({ icon, label, compact = false, ...props }: IconButtonProps) {
return (
<button className={`${styles.iconButton} ${compact ? styles.compact : ''}`} aria-label={label} title={label} {...props}>
{icon}
{!compact && <span>{label}</span>}
</button>
);
}
@@ -0,0 +1,39 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from './IconButton';
import styles from './common.module.css';
// 通用模态对话框:标题栏、内容区与可选页脚。
interface ModalProps {
title: string;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
}
export function Modal({ title, children, footer, onClose }: ModalProps) {
const titleId = useId();
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return undefined;
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
return () => {
if (typeof dialog.close === 'function') dialog.close();
else dialog.removeAttribute('open');
};
}, []);
return (
<dialog ref={dialogRef} aria-labelledby={titleId} className={styles.dialog} onCancel={(event) => { event.preventDefault(); onClose(); }}>
<header className={styles.dialogHeader}>
<h2 id={titleId}>{title}</h2>
<IconButton icon={<X size={14} />} label={zhCN.close} compact onClick={onClose} />
</header>
<div className={styles.dialogBody}>{children}</div>
{footer && <footer className={styles.dialogFooter}>{footer}</footer>}
</dialog>
);
}
@@ -0,0 +1,11 @@
/* 登录页样式:居中卡片与表单控件。 */
.center { align-items: center; background: var(--bg-base); display: flex; height: 100%; justify-content: center; }
.center > p { color: var(--fg-muted); }
.login { background: var(--bg-elevated); border: 1px solid var(--border); display: grid; gap: 12px; padding: 18px; width: 320px; }
.login header { align-items: center; display: flex; gap: 8px; }
.login h1 { font-size: 14px; margin: 0; }
.login label { display: grid; gap: 5px; }
.login label span { color: var(--fg-muted); font-size: 11px; }
.login input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.login p { color: var(--danger); margin: 0; }
.login button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 26px; }
@@ -0,0 +1,41 @@
/* 通用组件样式:图标按钮与模态对话框。 */
.iconButton {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 2px;
color: var(--fg-primary);
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
height: 24px;
padding: 0 7px;
}
.iconButton:hover:not(:disabled) { background: var(--bg-active); }
.iconButton:focus-visible { border-color: var(--accent); outline: 0; }
.iconButton:disabled { color: var(--fg-disabled); cursor: default; }
.compact { justify-content: center; padding: 0; width: 24px; }
.dialog {
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: 2px;
color: var(--fg-primary);
max-height: min(720px, 85vh);
max-width: min(760px, 90vw);
padding: 0;
width: 620px;
}
.dialog::backdrop { background: rgb(0 0 0 / 55%); }
.dialogHeader { align-items: center; background: var(--bg-titlebar); display: flex; height: 34px; justify-content: space-between; padding: 0 8px; }
.dialogHeader h2 { font-size: 13px; margin: 0; }
.dialogBody { max-height: 60vh; overflow: auto; padding: 12px; }
.dialogFooter { border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; padding: 8px; }
.iconButton { flex-shrink: 0; }
.dialog button:not(.iconButton) { cursor: pointer; min-height: 26px; }
.dialog button:disabled { cursor: default; opacity: .5; }
.dialogFooter button, .dialogBody button { background: var(--bg-active); border: 1px solid var(--border); color: var(--fg-primary); padding: 4px 8px; }
.dialog [role="alert"] { color: var(--danger); }
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DataGrid } from './DataGrid';
// 数据网格测试:表头排序、筛选弹层、行选中模式与列宽调整。
describe('DataGrid column controls', () => {
it('keeps row selection out of the grid columns and exposes header sorting and filtering', () => {
const onFilter = vi.fn();
const onSort = vi.fn();
const onSelectRow = vi.fn();
const onWidth = vi.fn();
render(<DataGrid
table="users"
columns={[{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }]}
rows={[{ id: 1, name: 'Ada' }]}
changes={{ updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() }}
nullText="NULL"
filters={{}}
widths={{}}
selectedRows={new Set()}
onSort={onSort}
onFilter={onFilter}
onWidth={onWidth}
onSelectRow={onSelectRow}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
expect(screen.queryByText('#')).not.toBeInTheDocument();
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'name' }));
expect(onSort).toHaveBeenCalledWith('name');
fireEvent.click(screen.getByRole('button', { name: '筛选 name' }));
fireEvent.change(screen.getByRole('textbox', { name: '筛选 name' }), { target: { value: 'Ada' } });
expect(onFilter).toHaveBeenCalledWith('name', 'Ada');
fireEvent.click(screen.getByText('Ada'));
expect(onSelectRow).toHaveBeenCalledWith(0, 'single');
fireEvent.click(screen.getByText('Ada'), { ctrlKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'toggle');
fireEvent.click(screen.getByText('Ada'), { shiftKey: true });
expect(onSelectRow).toHaveBeenCalledWith(0, 'range');
fireEvent.keyDown(screen.getByText('Ada'), { key: ' ' });
expect(onSelectRow).toHaveBeenCalledTimes(4);
fireEvent.keyDown(screen.getByRole('separator', { name: '调整 name 列宽' }), { key: 'ArrowRight' });
expect(onWidth).toHaveBeenCalledWith('name', 150);
expect(screen.getByRole('table')).toHaveStyle({ width: '280px' });
fireEvent.keyDown(screen.getByRole('textbox', { name: '筛选 name' }), { key: 'Escape' });
expect(screen.queryByRole('textbox', { name: '筛选 name' })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,111 @@
import { useMemo, useState, type ClipboardEvent, type KeyboardEvent, type PointerEvent } from 'react';
import type { ChangeSet, ColumnMeta } from '../../types';
import { changeKey } from '../../core/changes';
import { zhCN } from '../../i18n/zh-CN';
import styles from './grid.module.css';
// 数据网格:列排序、筛选、列宽拖拽、行选中与单元格编辑。
export interface GridRow extends Record<string, unknown> { _tempId?: string }
export type SelectionMode = 'single' | 'toggle' | 'range';
interface DataGridProps {
table: string;
columns: ColumnMeta[];
rows: GridRow[];
changes: ChangeSet;
nullText: string;
sort?: { column: string; dir: 'asc' | 'desc' };
filters: Record<string, string>;
widths: Record<string, number>;
selectedRows: Set<string>;
onSort: (column: string) => void;
onFilter: (column: string, expression: string) => void;
onWidth: (column: string, width: number) => void;
onSelectRow: (index: number, mode: SelectionMode) => void;
onEdit: (row: GridRow, column: string, oldValue: unknown, newValue: unknown) => void;
onDelete: (row: GridRow) => void;
}
interface EditingCell { rowIndex: number; column: string; value: unknown }
// 行键:临时行用 tempId,其余用主键。
export function rowKey(row: GridRow, index: number): string { return row._tempId ?? String(row.id ?? index); }
function valueFor(row: GridRow, table: string, column: string, changes: ChangeSet): unknown {
if (row._tempId) return changes.inserts.get(row._tempId)?.values[column] ?? row[column];
return changes.updates.get(changeKey(table, row.id, column))?.newValue ?? row[column];
}
function Editor({ column, value, onChange, onKeyDown, onBlur }: { column: ColumnMeta; value: unknown; onChange: (value: unknown) => void; onKeyDown: (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => void; onBlur: () => void }) {
if (column.type === 'bool') return <select autoFocus aria-label={zhCN.editCell(column.name)} value={String(value)} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur}><option value="true">true</option><option value="false">false</option></select>;
return <input autoFocus aria-label={zhCN.editCell(column.name)} type={column.type === 'int' ? 'number' : column.type === 'date' ? 'date' : 'text'} value={String(value ?? '')} onChange={(event) => onChange(event.target.value)} onKeyDown={onKeyDown} onBlur={onBlur} />;
}
export function DataGrid(props: DataGridProps) {
const [editing, setEditing] = useState<EditingCell>();
const [filterColumn, setFilterColumn] = useState<string>();
const deleted = useMemo(() => new Set(Array.from(props.changes.deletes.values()).filter((item) => item.table === props.table).map((item) => String(item.pk))), [props.changes, props.table]);
const commitEdit = () => {
if (!editing) return;
const row = props.rows[editing.rowIndex];
const column = props.columns.find((item) => item.name === editing.column);
if (!row || !column) return setEditing(undefined);
let next: unknown = editing.value;
if (column.type === 'int') next = Number(editing.value);
if (column.type === 'bool') next = editing.value === true || editing.value === 'true';
props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), next);
setEditing(undefined);
};
const onEditorKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
if (event.key === 'Escape') setEditing(undefined);
if (event.key === 'Enter' || event.key === 'Tab') { event.preventDefault(); commitEdit(); }
};
const paste = (event: ClipboardEvent<HTMLTableCellElement>, rowIndex: number, columnIndex: number) => {
const matrix = event.clipboardData.getData('text/plain').replace(/\r/g, '').split('\n').filter((line) => line.length).map((line) => line.split('\t'));
if (!matrix.length) return;
event.preventDefault();
matrix.forEach((values, y) => values.forEach((value, x) => {
const row = props.rows[rowIndex + y];
const column = props.columns[columnIndex + x];
if (row && column && !column.primaryKey) props.onEdit(row, column.name, valueFor(row, props.table, column.name, props.changes), value);
}));
};
const beginResize = (event: PointerEvent<HTMLSpanElement>, column: string) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = props.widths[column] ?? 140;
const move = (next: globalThis.PointerEvent) => props.onWidth(column, Math.max(70, startWidth + next.clientX - startX));
const stop = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', stop);
};
return (
<table className={styles.grid} style={{ width: props.columns.reduce((sum, column) => sum + (props.widths[column.name] ?? 140), 0) }}>
<colgroup>{props.columns.map((column) => <col key={column.name} style={{ width: props.widths[column.name] ?? 140 }} />)}</colgroup>
<thead>
<tr>{props.columns.map((column) => <th key={column.name} aria-sort={props.sort?.column === column.name ? props.sort.dir === 'asc' ? 'ascending' : 'descending' : 'none'}>
<div className={styles.columnHeader}><button className={styles.columnButton} aria-label={column.name} onClick={() => props.onSort(column.name)}>{column.name}<span>{props.sort?.column === column.name ? props.sort.dir === 'asc' ? '▲' : '▼' : ''}</span></button>
<button className={styles.filterButton} aria-label={zhCN.filter(column.name)} aria-expanded={filterColumn === column.name} aria-pressed={Boolean(props.filters[column.name])} onClick={() => setFilterColumn(filterColumn === column.name ? undefined : column.name)}><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"><path d="M2 3h12L9.5 8v5l-3-1.5V8Z" /></svg></button></div>
{filterColumn === column.name && <div className={styles.filterPopover}><input autoFocus aria-label={zhCN.filter(column.name)} placeholder={zhCN.filterPlaceholder} value={props.filters[column.name] ?? ''} onChange={(event) => props.onFilter(column.name, event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape' || event.key === 'Enter') { setFilterColumn(undefined); event.currentTarget.closest('th')?.querySelector('button')?.focus(); } }} /><button onClick={() => props.onFilter(column.name, '')}>{zhCN.clear}</button></div>}
<span className={styles.resizeHandle} role="separator" aria-label={zhCN.resizeColumn(column.name)} aria-orientation="vertical" tabIndex={0} onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); props.onWidth(column.name, Math.max(70, (props.widths[column.name] ?? 140) + (event.key === 'ArrowRight' ? 10 : -10))); } }} onPointerDown={(event) => beginResize(event, column.name)} />
</th>)}</tr>
</thead>
<tbody>
{props.rows.map((row, rowIndex) => {
const key = rowKey(row, rowIndex);
const isDeleted = !row._tempId && deleted.has(String(row.id));
return <tr key={key} aria-selected={props.selectedRows.has(key)} className={`${isDeleted ? styles.deleted : ''} ${row._tempId ? styles.inserted : ''}`} onContextMenu={(event) => { event.preventDefault(); props.onDelete(row); }}>
{props.columns.map((column, columnIndex) => {
const value = valueFor(row, props.table, column.name, props.changes);
const dirty = row._tempId || props.changes.updates.has(changeKey(props.table, row.id, column.name));
const isEditing = editing?.rowIndex === rowIndex && editing.column === column.name;
return <td key={column.name} className={dirty ? styles.dirty : ''} tabIndex={0} onClick={(event) => { if (!isEditing && event.detail < 2) props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : event.shiftKey ? 'range' : 'single'); }} onKeyDown={(event) => { if (!isEditing && event.key === ' ') { event.preventDefault(); props.onSelectRow(rowIndex, event.ctrlKey || event.metaKey ? 'toggle' : 'single'); } }} onPaste={(event) => paste(event, rowIndex, columnIndex)} onDoubleClick={() => !column.primaryKey && setEditing({ rowIndex, column: column.name, value })}>
{isEditing ? <Editor column={column} value={editing.value} onChange={(value) => setEditing({ ...editing, value })} onKeyDown={onEditorKeyDown} onBlur={commitEdit} /> : value === null || value === undefined ? <span className={styles.nullValue}>{props.nullText}</span> : String(value)}
</td>;
})}
</tr>;
})}
</tbody>
</table>
);
}
@@ -0,0 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { RowDialog } from './RowDialog';
// 插入行对话框测试:整数校验与暂存值。
it('requires an explicit integer key and stages typed values without generating an id', () => {
const onInsert = vi.fn();
render(<RowDialog table={{ name: 'users', rowCount: 500, columns: [{ name: 'id', type: 'int', primaryKey: true }, { name: 'name', type: 'str' }, { name: 'active', type: 'bool' }] }} onClose={vi.fn()} onInsert={onInsert} />);
expect(screen.getByLabelText('id')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toHaveTextContent('id:请输入合法整数');
fireEvent.change(screen.getByLabelText('id'), { target: { value: '800' } });
fireEvent.change(screen.getByLabelText('name'), { target: { value: "Ada's" } });
fireEvent.click(screen.getByRole('button', { name: '加入待提交' }));
expect(onInsert).toHaveBeenCalledWith({ id: 800, name: "Ada's", active: false });
});
@@ -0,0 +1,26 @@
import { useState } from 'react';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import type { TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from './grid.module.css';
// 插入行对话框:按列类型校验并暂存待提交变更。
export function RowDialog({ table, onClose, onInsert }: { table: TableMeta; onClose: () => void; onInsert: (values: Record<string, unknown>) => void }) {
const [draft, setDraft] = useState<Record<string, string>>(() => Object.fromEntries(table.columns.map((column) => [column.name, column.type === 'bool' ? 'false' : ''])));
const [error, setError] = useState('');
const submit = () => {
const values: Record<string, unknown> = {};
for (const column of table.columns) {
const value = draft[column.name];
if (column.type === 'int' && (!/^-?\d+$/.test(value) || !Number.isSafeInteger(Number(value)))) return setError(zhCN.invalidInteger(column.name));
if (column.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(value)) return setError(zhCN.chooseDate(column.name));
values[column.name] = column.type === 'int' ? Number(value) : column.type === 'bool' ? value === 'true' : value;
}
onInsert(values);
};
return <Modal title={zhCN.insertRowTitle(table.name)} onClose={onClose} footer={<><button onClick={onClose}>{zhCN.cancel}</button><button className={styles.primaryButton} onClick={submit}>{zhCN.addToPending}</button></>}>
<p>{zhCN.insertRowHint}</p>
<div className={styles.formFields}>{table.columns.map((column) => <label key={column.name}><span>{column.name} <small>{columnTypeLabels[column.type] ?? column.type}{column.primaryKey ? zhCN.primaryKeyBadge : ''}</small></span>{column.type === 'bool' ? <select aria-label={column.name} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })}><option value="false">{zhCN.booleanFalse}</option><option value="true">{zhCN.booleanTrue}</option></select> : <input aria-label={column.name} type={column.type === 'date' ? 'date' : 'text'} inputMode={column.type === 'int' ? 'numeric' : undefined} value={draft[column.name]} onChange={(event) => setDraft({ ...draft, [column.name]: event.target.value })} />}</label>)}</div>
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -0,0 +1,113 @@
import { Plus, RefreshCw, Replace, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { DataSourceAdapter, FilterExpr, TableMeta } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { useChangeStore } from '../../store/changeStore';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import { DataGrid, rowKey, type GridRow, type SelectionMode } from './DataGrid';
import { RowDialog } from './RowDialog';
import styles from './grid.module.css';
// 表数据编辑器:分页加载、工具栏操作与批量删除/替换。
interface TableEditorProps { adapter: DataSourceAdapter; table: TableMeta; settings: IdeSettings; revision: number; onTotal: (count: number) => void; onMessage: (message: string) => void }
export function TableEditor({ adapter, table, settings, revision, onTotal, onMessage }: TableEditorProps) {
const [rows, setRows] = useState<GridRow[]>([]);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<{ column: string; dir: 'asc' | 'desc' }>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [widths, setWidths] = useState<Record<string, number>>(() => JSON.parse(localStorage.getItem(`chusql.widths.${table.name}`) ?? '{}'));
const [selectedRows, setSelectedRows] = useState(new Set<string>());
const [selectionAnchor, setSelectionAnchor] = useState<number>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [replaceOpen, setReplaceOpen] = useState(false);
const [find, setFind] = useState('');
const [replacement, setReplacement] = useState('');
const [insertOpen, setInsertOpen] = useState(false);
const requestId = useRef(0);
const { changes, updateCell, insertRow, updateInsert, deleteRow } = useChangeStore();
const inserted = useMemo(() => Array.from(changes.inserts.values()).filter((item) => item.table === table.name).map((item): GridRow => ({ ...item.values, _tempId: item.tempId })), [changes, table.name]);
const visibleRows: GridRow[] = [...rows, ...inserted];
const activeFilters: FilterExpr[] = Object.entries(filters).filter(([, value]) => value.trim()).map(([column, expression]) => ({ column, expression }));
const load = async (nextPage: number, append: boolean) => {
const id = ++requestId.current; setLoading(true); setError('');
try {
const result = await adapter.fetchRows({ db: zhCN.database, table: table.name, page: nextPage, pageSize: settings.pageSize, sort: sort ? [sort] : [], filters: activeFilters });
if (id !== requestId.current) return;
setRows((current) => append ? [...current, ...result.rows] : result.rows);
if (!append) { setSelectedRows(new Set()); setSelectionAnchor(undefined); }
setTotal(result.total); setPage(nextPage); onTotal(result.total);
} catch (reason) { if (id === requestId.current) setError(reason instanceof Error ? reason.message : String(reason)); }
finally { if (id === requestId.current) setLoading(false); }
};
useEffect(() => { const timer = window.setTimeout(() => void load(0, false), 180); return () => window.clearTimeout(timer); }, [table.name, settings.pageSize, sort?.column, sort?.dir, JSON.stringify(filters), revision]);
const sortColumn = (column: string) => setSort((current) => current?.column !== column ? { column, dir: 'asc' } : { column, dir: current.dir === 'asc' ? 'desc' : 'asc' });
const setWidth = (column: string, width: number) => setWidths((current) => { const next = { ...current, [column]: width }; localStorage.setItem(`chusql.widths.${table.name}`, JSON.stringify(next)); return next; });
const addRow = (values: Record<string, unknown>) => {
const tempId = `temp-${crypto.randomUUID()}`;
insertRow(table.name, tempId, values);
setInsertOpen(false);
onMessage(zhCN.rowStaged);
};
const stageDelete = (row: GridRow) => {
deleteRow(table.name, row._tempId ?? row.id, row, Boolean(row._tempId));
};
const selectRow = (index: number, mode: SelectionMode) => {
const key = rowKey(visibleRows[index], index);
if (mode === 'range' && selectionAnchor !== undefined) {
const from = Math.min(selectionAnchor, index);
const to = Math.max(selectionAnchor, index);
setSelectedRows(new Set(visibleRows.slice(from, to + 1).map((row, offset) => rowKey(row, from + offset))));
return;
}
if (mode === 'toggle') {
setSelectedRows((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; });
setSelectionAnchor(index);
return;
}
setSelectedRows(new Set([key]));
setSelectionAnchor(index);
};
const removeSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => { if (selectedRows.has(row._tempId ?? String(row.id ?? index))) stageDelete(row); });
setSelectedRows(new Set());
setSelectionAnchor(undefined);
};
const replaceSelected = () => {
if (!selectedRows.size) return onMessage(zhCN.selectedRowsRequired);
visibleRows.forEach((row, index) => {
const key = row._tempId ?? String(row.id ?? index); if (!selectedRows.has(key)) return;
table.columns.filter((column) => !column.primaryKey && column.type === 'str').forEach((column) => {
const oldValue = row._tempId ? changes.inserts.get(row._tempId)?.values[column.name] : row[column.name];
if (String(oldValue ?? '').includes(find)) {
const next = String(oldValue ?? '').replaceAll(find, replacement);
if (row._tempId) updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column.name]: next });
else updateCell(table.name, row.id, column.name, row[column.name], next);
}
});
}); setReplaceOpen(false);
};
return <section className={styles.tableEditor}>
<div className={styles.toolbar}>
<IconButton icon={<RefreshCw size={14} />} label={zhCN.refresh} onClick={() => void load(0, false)} />
<IconButton icon={<Plus size={14} />} label={zhCN.insertRow} onClick={() => setInsertOpen(true)} />
<IconButton icon={<Trash2 size={14} />} label={zhCN.deleteRows} disabled={!selectedRows.size} onClick={removeSelected} />
<IconButton icon={<Replace size={14} />} label={zhCN.batchReplace} onClick={() => selectedRows.size ? setReplaceOpen(true) : onMessage(zhCN.selectedRowsRequired)} />
<span className={styles.spacer} /><span title={zhCN.selectRowsHint}>{zhCN.selectedRows(selectedRows.size)}</span><span>{zhCN.rowCount(total)}</span>
</div>
{replaceOpen && <div className={styles.replaceBar}><label>{zhCN.findText}<input value={find} onChange={(event) => setFind(event.target.value)} /></label><label>{zhCN.replaceText}<input value={replacement} onChange={(event) => setReplacement(event.target.value)} /></label><button onClick={replaceSelected}>{zhCN.replaceSelected}</button><button onClick={() => setReplaceOpen(false)}>{zhCN.close}</button></div>}
<div className={styles.viewport} onScroll={(event) => { const target = event.currentTarget; if (!loading && rows.length < total && target.scrollTop + target.clientHeight >= target.scrollHeight - 32) void load(page + 1, true); }}>
{error ? <div role="alert" className={styles.empty}>{zhCN.loadError}: {error} <button onClick={() => void load(0, false)}>{zhCN.retry}</button></div> : <DataGrid table={table.name} columns={table.columns} rows={visibleRows} changes={changes} nullText={settings.nullText} sort={sort} filters={filters} widths={widths} selectedRows={selectedRows} onSort={sortColumn} onFilter={(column, expression) => setFilters((current) => ({ ...current, [column]: expression }))} onWidth={setWidth} onSelectRow={selectRow} onEdit={(row, column, oldValue, newValue) => row._tempId ? updateInsert(row._tempId, { ...(changes.inserts.get(row._tempId)?.values ?? row), [column]: newValue }) : updateCell(table.name, row.id, column, oldValue, newValue)} onDelete={stageDelete} />}
{loading && <div className={styles.loading}>{zhCN.loading}</div>}
{!loading && !visibleRows.length && !error && <div className={styles.empty}>{zhCN.noRows}</div>}
</div>
{insertOpen && <RowDialog table={table} onClose={() => setInsertOpen(false)} onInsert={addRow} />}
</section>;
}
@@ -0,0 +1,54 @@
/* 数据网格与表结构对话框的样式 */
.tableEditor { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.spacer { flex: 1; }
.replaceBar { align-items: center; background: var(--bg-overlay); border-bottom: 1px solid var(--border); display: flex; gap: 10px; min-height: 32px; padding: 3px 7px; }
.replaceBar label { align-items: center; display: flex; gap: 5px; }
.replaceBar input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.replaceBar button, .empty button { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; height: 24px; }
.viewport { flex: 1; min-height: 0; overflow: auto; position: relative; }
.grid { border-collapse: collapse; font-family: var(--grid-font); font-size: var(--grid-font-size); table-layout: fixed; }
.grid th, .grid td { border-bottom: 1px solid var(--border-subtle); border-right: 1px solid var(--border); height: var(--grid-row-height); overflow: hidden; padding: 0 7px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.grid thead { background: var(--bg-elevated); position: sticky; top: 0; z-index: 2; }
.grid thead th { color: var(--info); font-weight: 400; position: relative; overflow: visible; }
.filterRow th { background: var(--bg-elevated); position: sticky; top: var(--grid-row-height); }
.filterRow input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 20px; padding: 0 4px; width: 100%; }
.columnButton { background: transparent; border: 0; color: var(--info); cursor: pointer; font: inherit; height: 100%; padding: 0; text-align: left; }
.columnButton span { color: var(--fg-primary); margin-left: 5px; }
.resizeHandle { bottom: 0; cursor: col-resize; position: absolute; right: -3px; top: 0; width: 7px; }
.grid thead small { color: var(--fg-muted); float: right; font-size: 10px; }
.grid tbody tr:hover { background: rgb(255 255 255 / 4%); }
.rowNumber { background: var(--bg-elevated); color: var(--fg-muted); min-width: 42px !important; text-align: right !important; width: 42px; }
.selectColumn { width: 28px; }
.numberColumn { width: 42px; }
.dirty { background: rgb(226 192 141 / 8%); position: relative; }
.dirty::before { border-right: 5px solid transparent; border-top: 5px solid var(--modified); content: ''; left: 0; position: absolute; top: 0; }
.deleted { color: var(--fg-disabled); text-decoration: line-through; }
.inserted { border-left: 2px solid var(--modified); }
.nullValue { color: var(--fg-muted); font-style: italic; }
.grid input, .grid select { background: var(--bg-base); border: 1px solid var(--accent); color: var(--fg-primary); font: inherit; height: calc(var(--grid-row-height) - 2px); margin: 0 -6px; outline: 0; padding: 0 5px; width: calc(100% + 12px); }
.empty { color: var(--fg-muted); padding: 28px; text-align: center; }
.loading { background: var(--bg-elevated); bottom: 0; color: var(--info); left: 0; padding: 4px 8px; position: sticky; }
.primaryButton { background: var(--accent-2); border: 1px solid var(--accent); color: #fff; min-height: 24px; }
.columnHeader { display: flex; align-items: center; height: var(--grid-row-height); gap: 4px; }
.columnButton { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.filterButton { display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 2px; color: var(--fg-muted); flex-shrink: 0; cursor: pointer; }
.filterButton:hover, .filterButton[aria-pressed="true"] { color: var(--info); background: var(--bg-active); }
.filterPopover { position: absolute; top: 100%; left: 0; z-index: 4; padding: 8px; width: 220px; background: var(--bg-overlay); border: 1px solid var(--border); }
.filterPopover input { width: 100%; margin: 0 0 6px; }
.filterPopover button { background: var(--bg-active); color: var(--fg-primary); border: 1px solid var(--border); }
.grid tbody tr[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--bg-base)); }
.grid td:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.resizeHandle { touch-action: none; z-index: 3; }
.toolbar, .replaceBar { flex-shrink: 0; }
.formFields { display: grid; gap: 12px; }
.formFields label { display: grid; gap: 5px; }
.formFields input, .formFields select, .columnDefinition input, .columnDefinition select { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); padding: 5px 7px; min-width: 0; }
.formFields small, .indexList small { color: var(--fg-muted); }
.columnDefinition { display: grid; grid-template-columns: minmax(0, 1fr) 100px 56px; gap: 8px; }
.indexList { list-style: none; padding: 0; margin: 12px 0; }
.indexList li { display: grid; grid-template-columns: minmax(80px, 1fr) 120px 90px; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.operationConfirm { border: 1px solid var(--border); padding: 10px; }
.operationConfirm button + button { margin-left: 8px; }
.dangerButton { background: transparent; border: 1px solid var(--danger); color: var(--danger); padding: 4px 8px; }
.dangerButton:disabled { opacity: .45; }
@@ -0,0 +1,44 @@
import { Undo2 } from 'lucide-react';
import { useState } from 'react';
import type { ChangeSet, SchemaChange, StatementResult } from '../../types';
import { deleteSql, insertSql, schemaSql, updateSql } from '../../core/changeSql';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './bottom.module.css';
// 底部面板:待提交变更、查询结果与消息三个标签页。
interface BottomPanelProps { changes: ChangeSet; results: StatementResult[]; messages: string[]; onUndoOne: (kind: keyof ChangeSet, key: string) => void }
// 结构变更的中文描述。
function schemaLabel(change: SchemaChange): string {
if (change.op === 'createTable') return zhCN.createTableChange(change.table);
if (change.op === 'dropTable') return zhCN.dropTableChange(change.table);
if (change.op === 'createIndex') return zhCN.createIndexChange(change.table, change.column);
return zhCN.dropIndexChange(change.table, change.column);
}
// 按执行器的 token 配色渲染一条 SQL。
function SqlText({ sql }: { sql: string }) {
return <>{tokenizeSql(sql).map((token, index) => <span key={index} style={{ color: token.kind === 'plain' ? SQL_PLAIN_COLOR : SQL_TOKEN_COLORS[token.kind] }}>{token.text}</span>)}</>;
}
export function BottomPanel({ changes, results, messages, onUndoOne }: BottomPanelProps) {
const [tab, setTab] = useState<'changes' | 'results' | 'messages'>('changes');
const [resultIndex, setResultIndex] = useState(0);
const entries = [
...Array.from(changes.updates, ([key, value]) => ({ kind: 'updates' as const, key, label: zhCN.updateChange(value.table, value.column), detail: updateSql(value.table, value.pk, value.column, value.newValue) })),
...Array.from(changes.inserts, ([key, value]) => ({ kind: 'inserts' as const, key, label: zhCN.insertChange(value.table), detail: insertSql(value.table, value.values) })),
...Array.from(changes.deletes, ([key, value]) => ({ kind: 'deletes' as const, key, label: zhCN.deleteChange(value.table), detail: deleteSql(value.table, value.pk) })),
...Array.from(changes.schemas, ([key, value]) => ({ kind: 'schemas' as const, key, label: schemaLabel(value), detail: schemaSql(value) })),
];
const result = results[resultIndex];
return <div className={styles.panel}>
<nav><button className={tab === 'changes' ? styles.active : ''} onClick={() => setTab('changes')}>{zhCN.pendingChanges}</button><button className={tab === 'results' ? styles.active : ''} onClick={() => setTab('results')}>{zhCN.results}</button><button className={tab === 'messages' ? styles.active : ''} onClick={() => setTab('messages')}>{zhCN.messages}</button></nav>
<div className={styles.body}>
{tab === 'changes' && (entries.length ? <ul className={styles.changes}>{entries.map((entry) => <li key={`${entry.kind}-${entry.key}`}><strong>{entry.label}</strong><code title={entry.detail}><SqlText sql={entry.detail} /></code><IconButton compact icon={<Undo2 size={13} />} label={zhCN.undoChange} onClick={() => onUndoOne(entry.kind, entry.key)} /></li>)}</ul> : <p>{zhCN.noPendingChanges}</p>)}
{tab === 'results' && (result ? <><select aria-label={zhCN.results} value={resultIndex} onChange={(event) => setResultIndex(Number(event.target.value))}>{results.map((item, index) => <option key={index} value={index}>{zhCN.resultLabel(index + 1, item.rows?.length ?? item.affected ?? 0, item.durationMs)}</option>)}</select>{result.type === 'rows' ? <div className={styles.resultScroll}><table><thead><tr>{result.columns?.map((column) => <th key={column}>{column}</th>)}</tr></thead><tbody>{result.rows?.map((row, rowIndex) => <tr key={rowIndex}>{row.map((value, columnIndex) => <td key={columnIndex}>{value === null ? zhCN.nullValue : String(value)}</td>)}</tr>)}</tbody></table></div> : <p>{result.message ?? zhCN.executionAffected(result.affected ?? 0, result.durationMs)}</p>}</> : <p>{zhCN.noResults}</p>)}
{tab === 'messages' && <ol className={styles.messages}>{messages.map((message, index) => <li key={`${index}-${message}`}>{message}</li>)}</ol>}
</div>
</div>;
}
@@ -0,0 +1,23 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import { Explorer } from './Explorer';
// 资源管理器测试:列图标、展开收起与打开表。
it('expands columns with distinct primary key, index and ordinary column icons', () => {
const onOpenTable = vi.fn();
render(<Explorer tables={[{ name: 'users', rowCount: 1, columns: [
{ name: 'id', type: 'int', primaryKey: true },
{ name: 'email', type: 'str', indexed: true },
{ name: 'name', type: 'str' },
] }]} history={[]} onOpenTable={onOpenTable} onOpenSql={vi.fn()} />);
const expand = screen.getByRole('button', { name: '展开 users 的列' });
expect(screen.queryByText('email')).not.toBeInTheDocument();
fireEvent.click(expand);
expect(expand).toHaveAttribute('aria-expanded', 'true');
for (const name of ['主键列', '索引列', '普通列']) expect(screen.getByRole('img', { name })).toBeInTheDocument();
expect(onOpenTable).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '打开表 users' }));
expect(onOpenTable).toHaveBeenCalledWith('users');
fireEvent.click(expand);
expect(screen.queryByText('email')).not.toBeInTheDocument();
});
@@ -0,0 +1,59 @@
import { ChevronDown, Database, FileClock, GitCompareArrows, Table2, Plus, ListFilter, Trash2 } from 'lucide-react';
import { IconButton } from '../common/IconButton';
import type { ColumnMeta, TableMeta } from '../../types';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
import { useState } from 'react';
// 资源管理器:库-表-列树、变更集与查询历史入口。
interface ExplorerProps {
tables: TableMeta[];
activeTable?: string;
history: Array<{ sql: string; at: string }>;
onOpenTable: (name: string) => void;
onOpenSql: (sql?: string) => void;
onCreateTable?: () => void;
onIndexes?: (table: TableMeta) => void;
onDropTable?: (table: TableMeta) => void;
}
// 列图标的可访问名,按主键/索引/普通区分。
function columnKind(column: ColumnMeta): string {
if (column.primaryKey) return zhCN.columnPrimaryKey;
return column.indexed ? zhCN.columnIndexed : zhCN.columnOrdinary;
}
export function Explorer({ tables, activeTable, history, onOpenTable, onOpenSql, onCreateTable, onIndexes, onDropTable }: ExplorerProps) {
const [expanded, setExpanded] = useState(new Set<string>());
return (
<aside className={styles.sidebar} aria-label={zhCN.explorer}>
<h2>{zhCN.explorer}</h2>
{onCreateTable && <IconButton icon={<Plus size={14} />} label={zhCN.newTable} onClick={onCreateTable} />}
<div className={styles.sectionTitle}><ChevronDown size={13} />{zhCN.databaseSection}</div>
<div className={styles.treeRoot}><Database size={14} /><span>{zhCN.database}</span></div>
<ul className={styles.tableList}>
{tables.map((table) => (
<li key={table.name}>
<div className={styles.tableEntry}>
<button className={styles.expandButton} aria-label={zhCN.expandColumns(table.name)} aria-expanded={expanded.has(table.name)} onClick={() => setExpanded((current) => { const next = new Set(current); if (next.has(table.name)) next.delete(table.name); else next.add(table.name); return next; })}><svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d={expanded.has(table.name) ? 'm4 6 4 4 4-4' : 'm6 4 4 4-4 4'} /></svg></button>
<button
className={activeTable === table.name ? styles.activeTreeItem : styles.treeItem}
aria-label={zhCN.openTable(table.name)}
onClick={() => onOpenTable(table.name)}
>
<Table2 size={13} /><span>{table.name}</span><small>{table.rowCount}</small>
</button>
{onIndexes && <IconButton compact icon={<ListFilter size={13} />} label={zhCN.manageIndexes(table.name)} onClick={() => onIndexes(table)} />}
{onDropTable && <IconButton compact icon={<Trash2 size={13} />} label={zhCN.dropTableAction(table.name)} onClick={() => onDropTable(table)} />}
</div>
{expanded.has(table.name) && <ul className={styles.columnList}>{table.columns.map((column) => <li key={column.name}><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" role="img" aria-label={columnKind(column)}><title>{columnKind(column)}</title>{column.primaryKey ? <><circle cx="5" cy="5" r="3" /><path d="m7 7 6 6m-3-3 2-2m0 4 2-2" /></> : column.indexed ? <><path d="M2 3h9v10H2ZM5 3v10M2 6h9M2 10h9m10-5 4 3-4 3" /></> : <><rect x="4" y="2" width="8" height="12" rx="1" /><path d="M4 6h8M4 10h8" /></>}</svg><span>{column.name}</span><small>{columnTypeLabels[column.type] ?? column.type}</small></li>)}</ul>}
</li>
))}
</ul>
<div className={styles.sectionTitle}><GitCompareArrows size={13} />{zhCN.changesSection}</div>
<div className={styles.sectionTitle}><FileClock size={13} />{zhCN.historySection}</div>
<button className={styles.treeItem} onClick={() => onOpenSql()}>{zhCN.newQuery}</button>
<ul className={styles.historyList}>{history.slice(0, 8).map((entry) => <li key={`${entry.at}-${entry.sql}`}><button title={entry.sql} onClick={() => onOpenSql(entry.sql)}>{entry.sql}</button></li>)}</ul>
</aside>
);
}
@@ -0,0 +1,73 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { MockAdapter } from '../../adapters/MockAdapter';
import { useChangeStore } from '../../store/changeStore';
import { SchemaDialog } from './SchemaDialog';
// 建表/删表/索引对话框测试:校验、直接响应与结构变更暂存。
afterEach(cleanup);
beforeEach(() => useChangeStore.getState().clear());
it('stages a create table change with explicit columns and rejects duplicate columns', () => {
const onChanged = vi.fn();
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={onChanged} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'id' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(screen.getByRole('alert')).toHaveTextContent('列名重复');
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
fireEvent.change(screen.getByLabelText('列名 2'), { target: { value: 'title' } });
fireEvent.click(screen.getByRole('button', { name: '建表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE TABLE items (id int, title str);');
expect(onClose).toHaveBeenCalled();
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] },
]);
});
it('stages a drop table change on the first click without asking for the name', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'drop', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.queryByLabelText(/输入/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '永久删除表' }));
expect(onChanged).toHaveBeenCalledWith('已暂存:DROP TABLE users;');
expect([...useChangeStore.getState().changes.schemas.values()]).toEqual([{ op: 'dropTable', table: 'users' }]);
expect((await adapter.listTables('chusql')).some((item) => item.name === 'users')).toBe(true);
});
it('protects the built-in primary key index and non-integer columns', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
const table = (await adapter.listTables('chusql'))[0];
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={vi.fn()} />);
const rows = screen.getAllByRole('listitem');
expect(within(rows[0]).getByRole('button')).toBeDisabled();
expect(within(rows[1]).getByRole('button')).toBeDisabled();
expect(within(rows[2]).getByRole('button')).toBeEnabled();
});
it('stages index creation and deletion on the first click and reflects the pending state', async () => {
const adapter = new MockAdapter({ latencyMs: 0 });
await adapter.createTable('items', [{ name: 'id', type: 'int' }, { name: 'sku', type: 'int' }]);
const table = (await adapter.listTables('chusql')).find((item) => item.name === 'items')!;
const onChanged = vi.fn();
render(<SchemaDialog action={{ kind: 'indexes', table }} onClose={vi.fn()} onChanged={onChanged} />);
expect(screen.getByText('无索引')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '建索引' }));
expect(screen.getByText('唯一索引')).toBeInTheDocument();
expect(onChanged).toHaveBeenCalledWith('已暂存:CREATE INDEX ON items (sku);');
fireEvent.click(within(screen.getAllByRole('listitem')[1]).getByRole('button', { name: '删索引' }));
expect(screen.getByText('无索引')).toBeInTheDocument();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:items:sku', 'dropIndex:items:sku']);
});
it('leaves nothing staged when the create dialog is cancelled', () => {
const onClose = vi.fn();
render(<SchemaDialog action={{ kind: 'create' }} onClose={onClose} onChanged={vi.fn()} />);
fireEvent.change(screen.getByLabelText('表名'), { target: { value: 'items' } });
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(onClose).toHaveBeenCalled();
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
});
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { schemaSql } from '../../core/changeSql';
import { columnTypeLabels, zhCN } from '../../i18n/zh-CN';
import { useChangeStore } from '../../store/changeStore';
import type { ColumnMeta, SchemaChange, TableMeta } from '../../types';
import { Modal } from '../common/Modal';
import styles from '../grid/grid.module.css';
// 建表、删表与索引对话框:结构变更一律暂存,提交时才下发。
export type SchemaAction = { kind: 'create' } | { kind: 'drop' | 'indexes'; table: TableMeta };
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 列类型的中文名,未知类型原样显示。
function typeLabel(type: string): string {
return columnTypeLabels[type] ?? type;
}
export function SchemaDialog({ action, onClose, onChanged }: {
action: SchemaAction; onClose: () => void; onChanged: (message: string) => void;
}) {
const [name, setName] = useState('');
const [columns, setColumns] = useState<Array<Pick<ColumnMeta, 'name' | 'type'>>>([{ name: 'id', type: 'int' }, { name: '', type: 'str' }]);
const [error, setError] = useState('');
const stageSchema = useChangeStore((state) => state.stageSchema);
const schemas = useChangeStore((state) => state.changes.schemas);
const target = action.kind === 'create' ? undefined : action.table;
const stagedIndex = new Map<string, boolean>();
for (const change of schemas.values()) {
if (change.table !== target?.name) continue;
if (change.op === 'createIndex') stagedIndex.set(change.column, true);
else if (change.op === 'dropIndex') stagedIndex.set(change.column, false);
}
const stage = (change: SchemaChange) => { stageSchema(change); onChanged(zhCN.stagedMessage(schemaSql(change))); };
const create = () => {
if (!identifier.test(name) || columns.some((column) => !identifier.test(column.name))) return setError(zhCN.invalidIdentifier);
if (new Set(columns.map((column) => column.name)).size !== columns.length) return setError(zhCN.duplicateColumns);
stage({ op: 'createTable', table: name, columns });
onClose();
};
const drop = () => {
if (!target) return;
stage({ op: 'dropTable', table: target.name });
onClose();
};
const changeIndex = (column: string, indexed: boolean) => {
if (!target) return;
stage(indexed
? { op: 'dropIndex', table: target.name, column }
: { op: 'createIndex', table: target.name, column });
};
const title = action.kind === 'create' ? zhCN.newTableTitle : `${action.kind === 'drop' ? zhCN.dropTableTitle : zhCN.indexManagerTitle} · ${target?.name}`;
return <Modal title={title} onClose={onClose} footer={<><button onClick={onClose}>{action.kind === 'indexes' ? zhCN.done : zhCN.cancel}</button>{action.kind === 'create' && <button className={styles.primaryButton} onClick={create}>{zhCN.createTable}</button>}{action.kind === 'drop' && <button className={styles.dangerButton} onClick={drop}>{zhCN.dropTablePermanently}</button>}</>}>
{action.kind === 'create' ? <div className={styles.formFields}>
<label>{zhCN.tableName}<input autoFocus aria-label={zhCN.tableName} value={name} onChange={(event) => setName(event.target.value)} /></label>
<p>{zhCN.createTableHint}</p>
{columns.map((column, index) => <div className={styles.columnDefinition} key={index}><input aria-label={zhCN.columnName(index + 1)} value={column.name} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, name: event.target.value } : item))} /><select aria-label={zhCN.columnType(index + 1)} value={column.type} disabled={index === 0} onChange={(event) => setColumns(columns.map((item, i) => i === index ? { ...item, type: event.target.value as ColumnMeta['type'] } : item))}><option value="int">{zhCN.typeInt}</option><option value="str">{zhCN.typeStr}</option><option value="bool">{zhCN.typeBool}</option></select><button disabled={index === 0} aria-label={zhCN.removeColumn(index + 1)} onClick={() => setColumns(columns.filter((_, i) => i !== index))}>{zhCN.remove}</button></div>)}
<button onClick={() => setColumns([...columns, { name: '', type: 'str' }])}>{zhCN.addColumn}</button>
</div> : action.kind === 'drop' ? <>
<p>{zhCN.dropTableHint(target?.name ?? '', target?.rowCount ?? 0)}</p>
</> : <>
<p>{zhCN.indexHint}</p>
<ul className={styles.indexList}>{target?.columns.map((column) => {
const indexed = stagedIndex.get(column.name) ?? Boolean(column.indexed);
return <li key={column.name}><span>{column.name} <small>{typeLabel(column.type)}</small></span><span>{column.primaryKey || column.name === 'id' ? zhCN.builtinIndex : indexed ? zhCN.uniqueIndex : zhCN.noIndex}</span><button disabled={column.name === 'id' || (!indexed && column.type !== 'int')} onClick={() => changeIndex(column.name, indexed)}>{indexed ? zhCN.deleteIndex : zhCN.createIndex}</button></li>;
})}</ul>
</>}
{error && <p role="alert">{error}</p>}
</Modal>;
}
@@ -0,0 +1,149 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { zhCN } from '../../i18n/zh-CN';
import { Workbench } from './Workbench';
// 工作台布局测试:拖动、方向键、夹紧范围与本地存储持久化。
// 用最简桩节点渲染工作台
function renderWorkbench() {
render(<Workbench explorer={<div />} editor={<div />} bottom={<div />} status={<div />} changeCount={0} canUndo={false} canRedo={false} onCommit={vi.fn()} onRollback={vi.fn()} onUndo={vi.fn()} onRedo={vi.fn()} onSql={vi.fn()} onSettings={vi.fn()} />);
}
// 取侧边栏与船坞两条拖动条
function splitters() {
return { sidebar: screen.getByRole('separator', { name: zhCN.resizeSidebar }), dock: screen.getByRole('separator', { name: zhCN.resizeDock }) };
}
// 取底部船坞默认高度
function defaultDock() { return Math.round(window.innerHeight * 0.22); }
// 取底部船坞高度上限
function dockCeiling() { return Math.max(96, Math.round(window.innerHeight * 0.7)); }
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('resizes the sidebar and the dock by 16px with arrow keys', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '266');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 16));
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('clamps the sidebar to 160..560 and the dock to 96 and above', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
fireEvent.keyDown(sidebar, { key: 'ArrowLeft' });
expect(sidebar).toHaveAttribute('aria-valuenow', '160');
for (let i = 0; i < 40; i += 1) fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
for (let i = 0; i < 30; i += 1) fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
fireEvent.keyDown(dock, { key: 'ArrowDown' });
expect(dock).toHaveAttribute('aria-valuenow', '96');
for (let i = 0; i < 60; i += 1) fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(dock).toHaveAttribute('aria-valuenow', String(dockCeiling()));
});
it('exposes separator roles with orientation, value and bounds', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-orientation', 'vertical');
expect(sidebar).toHaveAttribute('aria-valuemin', '160');
expect(sidebar).toHaveAttribute('aria-valuemax', '560');
expect(sidebar).toHaveAttribute('tabindex', '0');
expect(dock).toHaveAttribute('aria-orientation', 'horizontal');
expect(dock).toHaveAttribute('aria-valuemin', '96');
expect(dock).toHaveAttribute('aria-valuemax', String(dockCeiling()));
expect(dock).toHaveAttribute('tabindex', '0');
});
it('stores changed sizes in localStorage under chusql.layout.v1', () => {
renderWorkbench();
const { sidebar, dock } = splitters();
fireEvent.keyDown(sidebar, { key: 'ArrowRight' });
fireEvent.keyDown(dock, { key: 'ArrowUp' });
expect(JSON.parse(localStorage.getItem('chusql.layout.v1') ?? '{}')).toEqual({ sidebarWidth: 266, dockHeight: defaultDock() + 16 });
});
it('restores saved sizes from localStorage on the first render', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 300, dockHeight: 150 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '300');
expect(dock).toHaveAttribute('aria-valuenow', '150');
});
it('clamps saved sizes that fall outside the allowed range', () => {
localStorage.setItem('chusql.layout.v1', JSON.stringify({ sidebarWidth: 9999, dockHeight: 1 }));
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '560');
expect(dock).toHaveAttribute('aria-valuenow', '96');
});
it('falls back to default sizes when the stored layout is unreadable', () => {
localStorage.setItem('chusql.layout.v1', '{not json');
renderWorkbench();
const { sidebar, dock } = splitters();
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock()));
});
it('widens the sidebar by 40px when the vertical splitter is dragged right', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 340, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '290');
});
it('makes the dock taller by 40px when the horizontal splitter is dragged up', () => {
renderWorkbench();
const { dock } = splitters();
act(() => {
dock.dispatchEvent(new MouseEvent('pointerdown', { clientY: 500, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientY: 460, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
expect(dock).toHaveAttribute('aria-valuenow', String(defaultDock() + 40));
});
it('stops resizing after the pointer is released', () => {
renderWorkbench();
const { sidebar } = splitters();
act(() => {
sidebar.dispatchEvent(new MouseEvent('pointerdown', { clientX: 300, bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
act(() => {
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, bubbles: true }));
});
expect(sidebar).toHaveAttribute('aria-valuenow', '250');
});
@@ -0,0 +1,121 @@
import { Database, RotateCcw, Settings, TerminalSquare, Undo2 } from 'lucide-react';
import { useEffect, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
import { IconButton } from '../common/IconButton';
import { zhCN } from '../../i18n/zh-CN';
import styles from './layout.module.css';
// 工作台外壳:标题栏、活动栏与编辑器/底栏/状态栏布局,面板边界可拖动。
interface WorkbenchProps {
explorer: ReactNode;
editor: ReactNode;
bottom: ReactNode;
status: ReactNode;
changeCount: number;
canUndo: boolean;
canRedo: boolean;
onCommit: () => void;
onRollback: () => void;
onUndo: () => void;
onRedo: () => void;
onSql: () => void;
onSettings: () => void;
}
type SplitterKind = 'sidebar' | 'dock';
interface LayoutSize { sidebar: number; dock: number }
interface DragState { kind: SplitterKind; start: number; size: number }
const STORAGE_KEY = 'chusql.layout.v1';
const SIDEBAR_MIN = 160;
const SIDEBAR_MAX = 560;
const DOCK_MIN = 96;
const KEY_STEP = 16;
// 底部船坞高度上限取窗口高度的七成
function dockMax() { return Math.max(DOCK_MIN, Math.round(window.innerHeight * 0.7)); }
// 把尺寸夹紧到允许区间并取整
function clampSize(kind: SplitterKind, value: number) {
const rounded = Math.round(value);
return kind === 'sidebar' ? Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, rounded)) : Math.min(dockMax(), Math.max(DOCK_MIN, rounded));
}
// 读取本地存储的面板尺寸,异常时用默认值
function readLayout(): LayoutSize {
const fallback = { sidebar: 250, dock: Math.round(window.innerHeight * 0.22) };
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '') as { sidebarWidth?: unknown; dockHeight?: unknown };
if (typeof raw.sidebarWidth !== 'number' || typeof raw.dockHeight !== 'number') return fallback;
return { sidebar: clampSize('sidebar', raw.sidebarWidth), dock: clampSize('dock', raw.dockHeight) };
} catch { return fallback; }
}
// 写入面板尺寸到本地存储,失败时静默忽略
function writeLayout(size: LayoutSize) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sidebarWidth: size.sidebar, dockHeight: size.dock })); } catch { return; }
}
export function Workbench(props: WorkbenchProps) {
const [layout, setLayout] = useState<LayoutSize>(readLayout);
const [drag, setDrag] = useState<DragState>();
const { sidebar, dock } = layout;
useEffect(() => { writeLayout(layout); }, [layout]);
useEffect(() => {
if (!drag) return;
const move = (event: globalThis.PointerEvent) => setLayout((current) => {
const delta = drag.kind === 'sidebar' ? event.clientX - drag.start : drag.start - event.clientY;
const next = drag.size + delta;
return drag.kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', next) } : { ...current, dock: clampSize('dock', next) };
});
const stop = () => setDrag(undefined);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', stop); };
}, [drag]);
const resize = (kind: SplitterKind, value: number) => setLayout((current) => (kind === 'sidebar' ? { ...current, sidebar: clampSize('sidebar', value) } : { ...current, dock: clampSize('dock', value) }));
const startDrag = (kind: SplitterKind, event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
setDrag({ kind, start: kind === 'sidebar' ? event.clientX : event.clientY, size: kind === 'sidebar' ? sidebar : dock });
};
const onSplitterKeyDown = (kind: SplitterKind, event: KeyboardEvent<HTMLButtonElement>) => {
const step = kind === 'sidebar'
? (event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : 0)
: (event.key === 'ArrowUp' ? KEY_STEP : event.key === 'ArrowDown' ? -KEY_STEP : 0);
if (!step) return;
event.preventDefault();
resize(kind, (kind === 'sidebar' ? sidebar : dock) + step);
};
return (
<div className={styles.workbench} style={{ '--sidebar-width': `${sidebar}px`, '--dock-height': `${dock}px` } as CSSProperties}>
<header className={styles.titlebar}>
<span className={styles.brand}><Database size={15} />{zhCN.connection}</span>
<nav>
<IconButton icon={<Database size={14} />} label={zhCN.commit(props.changeCount)} disabled={!props.changeCount} onClick={props.onCommit} />
<IconButton icon={<RotateCcw size={14} />} label={zhCN.rollback} disabled={!props.changeCount} onClick={props.onRollback} />
<IconButton icon={<Undo2 size={14} />} label={zhCN.undo} disabled={!props.canUndo} onClick={props.onUndo} />
<IconButton icon={<Undo2 size={14} transform="rotate(180)" />} label={zhCN.redo} disabled={!props.canRedo} onClick={props.onRedo} />
<IconButton icon={<Settings size={14} />} label={zhCN.settings} onClick={props.onSettings} />
</nav>
</header>
<div className={styles.activitybar} aria-label={zhCN.activityBar}><button aria-label={zhCN.databaseSection}><Database size={20} /></button><button aria-label={zhCN.sqlConsole} onClick={props.onSql}><TerminalSquare size={20} /></button><button aria-label={zhCN.settings} onClick={props.onSettings}><Settings size={20} /></button><GitMark /></div>
{props.explorer}
<main className={styles.editor}>{props.editor}</main>
<section className={styles.bottom}>{props.bottom}</section>
<footer className={styles.statusbar}>{props.status}</footer>
<button type="button" className={`${styles.splitterV}${drag?.kind === 'sidebar' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="vertical" aria-label={zhCN.resizeSidebar} aria-valuenow={sidebar} aria-valuemin={SIDEBAR_MIN} aria-valuemax={SIDEBAR_MAX} tabIndex={0} onPointerDown={(event) => startDrag('sidebar', event)} onKeyDown={(event) => onSplitterKeyDown('sidebar', event)} />
<button type="button" className={`${styles.splitterH}${drag?.kind === 'dock' ? ` ${styles.dragging}` : ''}`} role="separator" aria-orientation="horizontal" aria-label={zhCN.resizeDock} aria-valuenow={dock} aria-valuemin={DOCK_MIN} aria-valuemax={dockMax()} tabIndex={0} onPointerDown={(event) => startDrag('dock', event)} onKeyDown={(event) => onSplitterKeyDown('dock', event)} />
</div>
);
}
function GitMark() {
return <span aria-hidden="true">⌘</span>;
}
@@ -0,0 +1,15 @@
/* 底部面板:标签页、变更列表与结果表格样式 */
.panel { display: grid; grid-template-rows: 28px minmax(0, 1fr); height: 100%; }
.panel nav { border-bottom: 1px solid var(--border); display: flex; gap: 2px; }
.panel nav button { background: transparent; border: 0; border-bottom: 1px solid transparent; color: var(--fg-primary); font: inherit; padding: 0 9px; }
.panel nav .active { border-bottom-color: var(--accent); }
.body { min-height: 0; overflow: auto; padding: 6px 8px; }
.body p { color: var(--fg-muted); margin: 4px; }
.changes, .messages { list-style: none; margin: 0; padding: 0; }
.changes li { align-items: center; border-bottom: 1px solid var(--border-subtle); display: grid; gap: 10px; grid-template-columns: 200px minmax(0, 1fr) 24px; min-height: 26px; }
.changes code { color: rgb(200 200 200); font-family: var(--ui-font); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages li { border-bottom: 1px solid var(--border-subtle); min-height: 22px; }
.body select { background: var(--bg-overlay); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; }
.resultScroll { overflow: auto; }
.resultScroll table { border-collapse: collapse; margin-top: 6px; }
.resultScroll th, .resultScroll td { border: 1px solid var(--border); min-width: 100px; padding: 3px 6px; text-align: left; }
@@ -0,0 +1,43 @@
/* 工作台整体网格布局与资源管理器样式 */
.workbench { --sidebar-width: 250px; --dock-height: 22vh; display: grid; grid-template: 34px minmax(0, 1fr) var(--dock-height) 22px / 48px var(--sidebar-width) minmax(0, 1fr); height: 100vh; min-height: 360px; position: relative; }
.titlebar { align-items: center; background: var(--bg-titlebar); display: flex; grid-column: 1 / -1; justify-content: space-between; padding: 0 6px 0 12px; }
.titlebar nav { display: flex; gap: 2px; }
.brand { align-items: center; display: inline-flex; gap: 7px; }
.activitybar { align-items: center; background: #333; color: var(--fg-muted); display: flex; flex-direction: column; gap: 18px; grid-column: 1; grid-row: 2 / 4; padding-top: 13px; }
.activitybar button { align-items: center; background: transparent; border: 0; color: inherit; display: flex; height: 28px; justify-content: center; padding: 0; width: 100%; }
.activitybar button:hover, .activitybar button:focus-visible { color: var(--fg-primary); }
.sidebar { background: var(--bg-elevated); border-right: 1px solid var(--border); grid-column: 2; grid-row: 2 / 4; min-width: 0; overflow: auto; }
.sidebar h2 { font-size: 12px; letter-spacing: .12em; margin: 0; padding: 11px 12px; text-transform: uppercase; }
.sectionTitle { align-items: center; display: flex; font-size: 11px; font-weight: 700; gap: 4px; letter-spacing: .08em; padding: 6px 8px; text-transform: uppercase; }
.treeRoot { align-items: center; display: flex; gap: 5px; height: 22px; padding-left: 19px; }
.tableList { list-style: none; margin: 0 0 8px; padding: 0; }
.treeItem, .activeTreeItem { align-items: center; background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); cursor: pointer; display: flex; font: inherit; gap: 6px; height: 22px; padding: 0 8px 0 32px; text-align: left; width: 100%; }
.treeItem:hover { background: var(--bg-active); }
.activeTreeItem { background: var(--bg-active); border-left-color: var(--accent); }
.treeItem small, .activeTreeItem small { color: var(--fg-muted); margin-left: auto; }
.historyList { list-style: none; margin: 0; padding: 0 8px 8px 32px; }
.historyList button { background: transparent; border: 0; color: var(--fg-muted); font: inherit; height: 22px; max-width: 100%; overflow: hidden; padding: 0; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.editor { background: var(--bg-base); grid-column: 3; grid-row: 2; min-height: 0; min-width: 0; overflow: hidden; }
.bottom { background: var(--bg-elevated); border-top: 1px solid var(--border); grid-column: 3; grid-row: 3; min-height: 0; overflow: auto; }
.statusbar { align-items: center; background: var(--bg-statusbar); color: #fff; display: flex; gap: 16px; grid-column: 1 / -1; padding: 0 8px; }
.splitterV { position: absolute; top: 34px; bottom: 22px; left: calc(48px + var(--sidebar-width) - 2px); width: 5px; cursor: col-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterH { position: absolute; left: calc(48px + var(--sidebar-width)); right: 0; bottom: calc(22px + var(--dock-height) - 2px); height: 5px; cursor: row-resize; background: transparent; border: 0; padding: 0; z-index: 6; }
.splitterV:hover, .splitterV:focus-visible, .splitterV.dragging { background: var(--accent); }
.splitterH:hover, .splitterH:focus-visible, .splitterH.dragging { background: var(--accent); }
@media (max-width: 720px) {
.workbench { grid-template-columns: 40px minmax(0, 1fr); }
.sidebar { display: none; }
.splitterV { display: none; }
.splitterH { left: 40px; }
.editor, .bottom { grid-column: 2; }
.titlebar nav .iconButton span { display: none; }
}
.tableEntry { display: flex; align-items: center; padding-left: 16px; }
.tableEntry .treeItem, .tableEntry .activeTreeItem { padding-left: 4px; min-width: 0; }
.expandButton { display: flex; flex-shrink: 0; padding: 3px; background: transparent; border: 0; color: var(--fg-muted); cursor: pointer; }
.columnList { list-style: none; padding: 0 10px 4px 42px; }
.columnList li { display: flex; align-items: center; gap: 6px; min-height: 24px; }
.columnList svg { flex-shrink: 0; color: var(--info); }
.columnList svg[aria-label="Primary key"] { color: var(--modified); }
.columnList small { margin-left: auto; color: var(--fg-muted); }
@@ -0,0 +1,24 @@
import { GripVertical, Plus, Trash2 } from 'lucide-react';
import { useState, type CSSProperties, type DragEvent, type ReactNode } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { IconButton } from '../common/IconButton';
import styles from './settings.module.css';
// 字体链编辑器:增删、拖动排序并实时预览。
interface FontChainEditorProps { label: string; value: string[]; onChange: (fonts: string[]) => void; previewStyle: CSSProperties; children?: ReactNode }
export function FontChainEditor({ label, value, onChange, previewStyle, children }: FontChainEditorProps) {
const [dragged, setDragged] = useState<number>();
const move = (event: DragEvent, target: number) => {
event.preventDefault();
if (dragged === undefined || dragged === target) return;
const next = [...value]; const [font] = next.splice(dragged, 1); next.splice(target, 0, font); onChange(next); setDragged(target);
};
return <fieldset className={styles.fontGroup}><legend>{label}</legend>
<div className={styles.fontList}>{value.map((font, index) => <div className={styles.fontRow} key={index} onDragOver={(event) => move(event, index)}><span draggable onDragStart={() => setDragged(index)} onDragEnd={() => setDragged(undefined)} title={zhCN.moveFont}><GripVertical size={13} /></span><input aria-label={`${label} ${zhCN.fontName} ${index + 1}`} value={font} onChange={(event) => onChange(value.map((item, itemIndex) => itemIndex === index ? event.target.value : item))} /><IconButton icon={<Trash2 size={13} />} compact label={zhCN.removeFont(font)} disabled={value.length === 1} onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))} /></div>)}</div>
<IconButton icon={<Plus size={13} />} label={zhCN.addFallback} onClick={() => onChange([...value, ''])} />
{children}
<div className={styles.preview} style={previewStyle}><strong>{zhCN.preview}</strong><span>{zhCN.previewText}</span></div>
</fieldset>;
}
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, expect, it } from 'vitest';
import { SettingsPanel } from './SettingsPanel';
import { useSettingsStore } from '../../store/settingsStore';
// 覆盖设置面板的输入聚焦与数值提交。
beforeEach(() => useSettingsStore.getState().reset());
it('preserves the font input and focus while typing and deleting', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('textbox')[0];
input.focus();
fireEvent.change(input, { target: { value: 'Consola' } });
expect(screen.getAllByRole('textbox')[0]).toBe(input);
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue('');
expect(input).toHaveFocus();
});
it('allows an empty numeric draft and commits a bounded value on blur', () => {
render(<SettingsPanel />);
const input = screen.getAllByRole('spinbutton')[0];
fireEvent.change(input, { target: { value: '' } });
expect(input).toHaveValue(null);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(13);
fireEvent.change(input, { target: { value: '15' } });
fireEvent.blur(input);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
fireEvent.change(input, { target: { value: '99' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(input).toHaveValue(16);
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(input).toHaveValue(16);
});
@@ -0,0 +1,46 @@
import { RotateCcw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { zhCN } from '../../i18n/zh-CN';
import { fontFamilyValue, useSettingsStore } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import { FontChainEditor } from './FontChainEditor';
import styles from './settings.module.css';
// 设置面板:外观与行为两组选项及字体预览。
function NumberField({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
if (!draft.trim() || !Number.isFinite(Number(draft))) { setDraft(String(value)); return; }
const next = Math.min(max, Math.max(min, Math.round(Number(draft))));
setDraft(String(next)); onChange(next);
};
return <label className={styles.field}><span>{label}</span><input type="number" min={min} max={max} value={draft} onChange={(event) => setDraft(event.target.value)} onBlur={commit} onKeyDown={(event) => { if (event.key === 'Enter') commit(); if (event.key === 'Escape') setDraft(String(value)); }} /></label>;
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (value: boolean) => void }) {
return <label className={styles.toggle}><input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} /><span>{label}</span></label>;
}
export function SettingsPanel() {
const [section, setSection] = useState<'appearance' | 'behavior'>('appearance');
const { settings, setNumber, setBoolean, setText, setFonts, reset } = useSettingsStore();
return <div className={styles.settings}>
<aside><h2>{zhCN.settings}</h2><button className={section === 'appearance' ? styles.active : ''} onClick={() => setSection('appearance')}>{zhCN.settingsAppearance}</button><button className={section === 'behavior' ? styles.active : ''} onClick={() => setSection('behavior')}>{zhCN.settingsBehavior}</button></aside>
<section><header><h1>{section === 'appearance' ? zhCN.settingsAppearance : zhCN.settingsBehavior}</h1></header>
{section === 'appearance' ? <>
<FontChainEditor label={zhCN.interfaceFont} value={settings.uiFonts} onChange={(value) => setFonts('uiFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.uiFonts), fontSize: settings.uiFontSize }}><NumberField label={zhCN.fontSize} value={settings.uiFontSize} min={11} max={16} onChange={(value) => setNumber('uiFontSize', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.gridFont} value={settings.gridFonts} onChange={(value) => setFonts('gridFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.gridFonts), fontSize: settings.gridFontSize, lineHeight: `${settings.gridRowHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.gridFontSize} min={10} max={18} onChange={(value) => setNumber('gridFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.gridRowHeight} min={18} max={40} onChange={(value) => setNumber('gridRowHeight', value)} /></FontChainEditor>
<FontChainEditor label={zhCN.sqlFont} value={settings.sqlFonts} onChange={(value) => setFonts('sqlFonts', value)} previewStyle={{ fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: `${settings.sqlLineHeight}px` }}><NumberField label={zhCN.fontSize} value={settings.sqlFontSize} min={10} max={20} onChange={(value) => setNumber('sqlFontSize', value)} /><NumberField label={zhCN.rowHeight} value={settings.sqlLineHeight} min={16} max={40} onChange={(value) => setNumber('sqlLineHeight', value)} /></FontChainEditor>
</> : <div className={styles.behavior}>
<NumberField label={zhCN.pageSize} value={settings.pageSize} min={10} max={500} onChange={(value) => setNumber('pageSize', value)} />
<label className={styles.field}><span>{zhCN.nullDisplay}</span><input value={settings.nullText} onChange={(event) => setText('nullText', event.target.value)} /></label>
<NumberField label={zhCN.tabSize} value={settings.sqlTabSize} min={1} max={8} onChange={(value) => setNumber('sqlTabSize', value)} />
<Toggle label={zhCN.showLineNumbers} checked={settings.sqlLineNumbers} onChange={(value) => setBoolean('sqlLineNumbers', value)} />
<Toggle label={zhCN.autocomplete} checked={settings.autocomplete} onChange={(value) => setBoolean('autocomplete', value)} />
<Toggle label={zhCN.minimap} checked={settings.minimap} onChange={(value) => setBoolean('minimap', value)} />
</div>}
<footer><IconButton icon={<RotateCcw size={14} />} label={zhCN.restoreDefaults} onClick={reset} /></footer>
</section>
</div>;
}
@@ -0,0 +1,29 @@
/* 设置面板样式:侧边分组、字段与字体预览。 */
.settings { display: grid; grid-template-columns: 210px minmax(0, 1fr); height: 100%; overflow: hidden; }
.settings aside { background: var(--bg-elevated); border-right: 1px solid var(--border); padding-top: 8px; }
.settings aside h2 { font-size: 12px; letter-spacing: .12em; margin: 8px 12px 12px; text-transform: uppercase; }
.settings aside button { background: transparent; border: 0; border-left: 2px solid transparent; color: var(--fg-primary); display: block; font: inherit; height: 28px; padding: 0 12px; text-align: left; width: 100%; }
.settings aside button:hover, .settings aside .active { background: var(--bg-active); }
.settings aside .active { border-left-color: var(--accent); }
.settings > section { overflow: auto; padding: 0 18px 20px; }
.settings header { background: var(--bg-base); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 2; }
.settings h1 { font-size: 18px; font-weight: 500; margin: 0; padding: 16px 0 10px; }
.fontGroup { border: 1px solid var(--border); margin: 14px 0; padding: 9px; }
.fontGroup legend { color: var(--info); font-weight: 700; padding: 0 5px; }
.fontList { display: grid; gap: 3px; margin-bottom: 6px; }
.fontRow { align-items: center; display: grid; gap: 5px; grid-template-columns: 16px minmax(160px, 360px) 24px; }
.fontRow input, .field input { background: var(--bg-base); border: 1px solid var(--border); color: var(--fg-primary); height: 24px; padding: 0 6px; }
.field { align-items: center; display: grid; gap: 10px; grid-template-columns: 150px 120px; margin: 7px 0; }
.toggle { align-items: center; display: flex; gap: 8px; margin: 10px 0; }
.preview { background: var(--bg-base); border: 1px solid var(--border-subtle); display: grid; gap: 4px; margin-top: 9px; min-height: 52px; padding: 7px; }
.preview strong { color: var(--fg-muted); font-family: var(--ui-font); font-size: 11px; }
.behavior { padding: 12px 0; }
.settings footer { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 8px; }
@media (max-width: 720px) { .settings { grid-template-columns: 140px minmax(0, 1fr); } .settings > section { padding-inline: 10px; } }
@media (max-width: 720px) {
.settings { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.settings aside { display: flex; border-right: 0; border-bottom: 1px solid var(--border); padding: 0; }
.settings aside h2 { display: none; }
.fontRow { grid-template-columns: 16px minmax(0, 1fr) 24px; }
.field { grid-template-columns: minmax(0, 1fr) 90px; }
}
@@ -0,0 +1,119 @@
import { Play, PlayCircle, Square, TextSelect } from 'lucide-react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import 'monaco-editor/min/vs/editor/editor.main.css';
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { useEffect, useMemo, useRef, useState } from 'react';
import { splitSqlStatements, type SqlStatement } from '../../core/sql';
import { SQL_TOKEN_COLORS } from '../../core/sqlHighlight';
import { zhCN } from '../../i18n/zh-CN';
import type { DataSourceAdapter, StatementResult } from '../../types';
import type { IdeSettings } from '../../store/settingsStore';
import { IconButton } from '../common/IconButton';
import styles from './sql.module.css';
import { restoreEditorLayout } from './editorLayout';
import { fontFamilyValue } from '../../store/settingsStore';
// SQL 控制台:Monaco 编辑器、语句切分与逐条执行。
(self as unknown as { MonacoEnvironment: { getWorker: () => Worker } }).MonacoEnvironment = { getWorker: () => new EditorWorker() };
export interface QueryHistoryEntry { sql: string; at: string; ok: boolean; durationMs: number }
interface SqlConsoleProps {
adapter: DataSourceAdapter;
settings: IdeSettings;
initialSql?: string;
onResult: (result: StatementResult) => void;
onMessage: (message: string) => void;
onHistory: (history: QueryHistoryEntry[]) => void;
}
type RunStatus = { state: 'pending' | 'running' | 'success' | 'error'; durationMs?: number };
function readHistory(): QueryHistoryEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem('chusql.sql.history.v2') ?? '[]');
return Array.isArray(parsed) ? parsed.slice(0, 50) : [];
} catch { return []; }
}
export function SqlConsole({ adapter, settings, initialSql = 'SELECT * FROM users;', onResult, onMessage, onHistory }: SqlConsoleProps) {
const host = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor>();
const stopRequested = useRef(false);
const [sql, setSql] = useState(initialSql);
const [running, setRunning] = useState(false);
const [statuses, setStatuses] = useState<Record<string, RunStatus>>({});
const statements = useMemo(() => splitSqlStatements(sql), [sql]);
useEffect(() => {
if (!host.current) return undefined;
const stopLayoutRestore = restoreEditorLayout(host.current);
monaco.editor.defineTheme('chusql-dark', {
base: 'vs-dark', inherit: true,
rules: [
{ token: 'keyword', foreground: SQL_TOKEN_COLORS.keyword.slice(1) },
{ token: 'string', foreground: SQL_TOKEN_COLORS.string.slice(1) },
{ token: 'number', foreground: SQL_TOKEN_COLORS.number.slice(1) },
{ token: 'comment', foreground: SQL_TOKEN_COLORS.comment.slice(1) },
],
colors: { 'editor.background': '#1e1e1e', 'editorLineNumber.foreground': '#858585', 'editor.selectionBackground': '#264f78' },
});
const instance = monaco.editor.create(host.current, {
value: sql, language: 'sql', theme: 'chusql-dark', glyphMargin: true,
automaticLayout: true, minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts),
fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize,
lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete,
});
editor.current = instance;
const subscription = instance.onDidChangeModelContent(() => setSql(instance.getValue()));
return () => { stopLayoutRestore(); subscription.dispose(); instance.dispose(); editor.current = undefined; };
}, []);
useEffect(() => { editor.current?.updateOptions({ minimap: { enabled: settings.minimap }, fontFamily: fontFamilyValue(settings.sqlFonts), fontSize: settings.sqlFontSize, lineHeight: settings.sqlLineHeight, tabSize: settings.sqlTabSize, lineNumbers: settings.sqlLineNumbers ? 'on' : 'off', quickSuggestions: settings.autocomplete }); }, [settings]);
useEffect(() => { setStatuses(Object.fromEntries(statements.map((statement) => [statement.id, { state: 'pending' }]))); }, [sql]);
useEffect(() => {
const instance = editor.current;
if (!instance) return;
const decorations = statements.map((statement) => {
const status = statuses[statement.id]?.state ?? 'pending';
return { range: new monaco.Range(statement.startLine, 1, statement.endLine, 1), options: { isWholeLine: status === 'running', className: status === 'running' ? styles.runningLine : undefined, linesDecorationsClassName: status === 'running' ? styles.runningEdge : undefined, glyphMarginClassName: styles[`glyph${status[0].toUpperCase()}${status.slice(1)}`] } };
});
const ids = instance.deltaDecorations([], decorations);
return () => { instance.deltaDecorations(ids, []); };
}, [statements, statuses]);
const recordHistory = (statement: SqlStatement, result: StatementResult) => {
const next = [{ sql: statement.sql, at: new Date().toISOString(), ok: result.type !== 'error', durationMs: result.durationMs }, ...readHistory()].slice(0, 50);
localStorage.setItem('chusql.sql.history.v2', JSON.stringify(next)); onHistory(next);
};
const run = async (items: SqlStatement[]) => {
if (!items.length) return onMessage(zhCN.noStatement);
stopRequested.current = false; setRunning(true);
for (const statement of items) {
if (stopRequested.current) break;
setStatuses((current) => ({ ...current, [statement.id]: { state: 'running' } }));
const result = await adapter.execStatement({ db: zhCN.database, sql: statement.sql });
setStatuses((current) => ({ ...current, [statement.id]: { state: result.type === 'error' ? 'error' : 'success', durationMs: result.durationMs } }));
recordHistory(statement, result); onResult(result);
if (result.type === 'error') onMessage(result.message ?? zhCN.statementFailed);
else if (result.type === 'rows') onMessage(zhCN.executionSuccess(result.rows?.length ?? 0, result.durationMs));
else onMessage(zhCN.executionAffected(result.affected ?? 0, result.durationMs));
}
if (stopRequested.current) onMessage(zhCN.executionStopped);
setRunning(false);
};
const pending = () => statements.find((statement) => statuses[statement.id]?.state !== 'success');
const selection = () => {
const instance = editor.current; const range = instance?.getSelection();
if (!instance || !range || range.isEmpty()) return [];
return splitSqlStatements(instance.getModel()?.getValueInRange(range) ?? '');
};
return <section className={styles.console}>
<div className={styles.toolbar}><IconButton icon={<PlayCircle size={14} />} label={zhCN.runNext} disabled={running} onClick={() => { const next = pending(); void run(next ? [next] : []); }} /><IconButton icon={<Play size={14} />} label={zhCN.runAll} disabled={running} onClick={() => void run(statements)} /><IconButton icon={<TextSelect size={14} />} label={zhCN.runSelection} disabled={running} onClick={() => void run(selection())} /><IconButton icon={<Square size={13} />} label={zhCN.stop} disabled={!running} onClick={() => { stopRequested.current = true; }} /></div>
<div className={styles.editor} ref={host} aria-label={zhCN.sqlConsole} />
<ol className={styles.statementList}>{statements.map((statement) => { const status = statuses[statement.id] ?? { state: 'pending' }; return <li key={statement.id}><span>{status.state === 'pending' ? '○' : status.state === 'running' ? '▶' : status.state === 'success' ? '✓' : '✗'}</span><span>{status.state === 'pending' ? zhCN.statementPending : status.state === 'running' ? zhCN.statementRunning : status.state === 'success' ? zhCN.statementSuccess : zhCN.statementFailed}</span>{status.durationMs !== undefined && <small>{status.durationMs.toFixed(1)} ms</small>}</li>; })}</ol>
</section>;
}
@@ -0,0 +1,25 @@
import { expect, it, vi } from 'vitest';
import { restoreEditorLayout } from './editorLayout';
// editorLayout 测试:只恢复布局声明,不碰已生效样式。
it('restores only layout declarations from CSP-blocked editor markup', () => {
const host = document.createElement('div');
const line = document.createElement('div');
line.setAttribute('style', 'left:20px;width:36px;background-image:url(https://example.invalid/x);');
const setProperty = vi.fn();
Object.defineProperty(line, 'style', { value: { length: 0, setProperty } });
host.append(line);
const stop = restoreEditorLayout(host);
expect(setProperty.mock.calls).toEqual([['left', '20px'], ['width', '36px']]);
stop();
});
it('leaves already active CSSOM styles untouched', () => {
const host = document.createElement('div');
host.style.width = '200px';
const setProperty = vi.spyOn(host.style, 'setProperty');
const stop = restoreEditorLayout(host);
expect(setProperty).not.toHaveBeenCalled();
stop();
});
@@ -0,0 +1,36 @@
// 修复被 CSP 拦截的 Monaco 内联样式:用 CSSOM 重放布局属性。
const layoutProperties = new Set([
'left', 'right', 'top', 'bottom', 'width', 'height', 'min-width', 'max-width',
'position', 'display', 'line-height', 'font-size', 'font-family', 'font-weight',
'font-style', 'letter-spacing', 'vertical-align', 'white-space', 'transform',
'color', 'background-color', 'border-color', 'border-width', 'border-style',
'padding-left', 'padding-right', 'margin-left', 'margin-right', 'opacity',
]);
export function restoreEditorLayout(host: HTMLElement): () => void {
const parsed = document.createElement('span').style;
const restore = (element: Element) => {
if (!(element instanceof HTMLElement) || element.style.length) return;
const raw = element.getAttribute('style');
if (!raw) return;
parsed.cssText = raw;
for (const property of Array.from(parsed)) {
const value = parsed.getPropertyValue(property);
if (layoutProperties.has(property) && !/url\s*\(|expression\s*\(/i.test(value)) element.style.setProperty(property, value);
}
};
const restoreTree = (element: Element) => {
restore(element);
element.querySelectorAll('[style]').forEach(restore);
};
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes' && record.target instanceof Element) restore(record.target);
else record.addedNodes.forEach((node) => { if (node instanceof Element) restoreTree(node); });
}
});
observer.observe(host, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
restoreTree(host);
return () => observer.disconnect();
}
@@ -0,0 +1,15 @@
/* SQL 控制台样式:工具栏、编辑器与语句状态条。 */
.console { display: grid; grid-template: 30px minmax(0, 1fr) auto / minmax(0, 1fr); height: 100%; min-height: 0; min-width: 0; overflow: hidden; }
.toolbar { align-items: center; background: var(--bg-elevated); border-bottom: 1px solid var(--border); display: flex; gap: 2px; padding: 2px 5px; }
.editor { min-height: 0; min-width: 0; overflow: hidden; position: relative; }
.statementList { background: var(--bg-elevated); border-top: 1px solid var(--border); display: flex; gap: 12px; list-style: none; margin: 0; min-height: 28px; overflow-x: auto; padding: 4px 8px; }
.statementList li { align-items: center; color: var(--fg-muted); display: flex; gap: 5px; white-space: nowrap; }
.statementList small { color: var(--success); }
.runningLine { background: rgb(0 122 204 / 14%); }
.runningEdge { border-left: 2px solid var(--accent); margin-left: 3px; }
.glyphPending::before { color: var(--fg-muted); content: '○'; }
.glyphRunning::before { color: var(--info); content: '▶'; }
.glyphSuccess::before { color: var(--success); content: '✓'; }
.glyphError::before { color: var(--danger); content: '✗'; }
.editor > :global(.monaco-editor) { position: absolute; inset: 0; }
.statementList { max-height: 64px; }
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { createEmptyChangeSet } from './changes';
import { changeSql, changesSql, deleteSql, insertSql, schemaSql, sqlIdentifier, sqlLiteral, updateSql } from './changeSql';
// 覆盖展示用 SQL 的字面量、标识符与结构语句。
describe('sql literals and identifiers', () => {
it('doubles single quotes and keeps null, numbers and booleans bare', () => {
expect(sqlLiteral("O'Brien")).toBe("'O''Brien'");
expect(sqlLiteral(7)).toBe('7');
expect(sqlLiteral(false)).toBe('false');
expect(sqlLiteral(null)).toBe('NULL');
expect(sqlLiteral(undefined)).toBe('NULL');
});
it('quotes identifiers only when they leave the whitelist', () => {
expect(sqlIdentifier('users')).toBe('users');
expect(sqlIdentifier('we"ird')).toBe('"we""ird"');
});
});
describe('row statements', () => {
it('renders update, insert and delete', () => {
expect(updateSql('users', 1, 'name', "A'B")).toBe("UPDATE users SET name = 'A''B' WHERE id = 1;");
expect(insertSql('users', { id: 9, name: 'Ada' })).toBe("INSERT INTO users (id, name) VALUES (9, 'Ada');");
expect(deleteSql('users', 2)).toBe('DELETE FROM users WHERE id = 2;');
});
});
describe('schema statements', () => {
it('matches the statements the server Actions build', () => {
expect(schemaSql({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }, { name: 'title', type: 'str' }] })).toBe('CREATE TABLE items (id int, title str);');
expect(schemaSql({ op: 'dropTable', table: 'items' })).toBe('DROP TABLE items;');
expect(schemaSql({ op: 'createIndex', table: 'items', column: 'sku' })).toBe('CREATE INDEX ON items (sku);');
expect(schemaSql({ op: 'dropIndex', table: 'items', column: 'sku' })).toBe('DROP INDEX ON items (sku);');
});
});
describe('changesSql', () => {
it('lists creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changesSql(changes)).toEqual([
'CREATE TABLE items (id int);',
"UPDATE items SET name = 'B' WHERE id = 1;",
'DROP TABLE items;',
]);
});
it('renders a single pending change through changeSql', () => {
expect(changeSql({ op: 'insert', table: 'users', values: { id: 3 } })).toBe('INSERT INTO users (id) VALUES (3);');
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { ChangeSet, PendingChange, SchemaChange } from '../types';
import { pendingChanges } from './changes';
// 展示用 SQL 生成:标识符走白名单,字符串单引号翻倍。
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
// 字面量:字符串加引号并翻倍单引号。
export function sqlLiteral(value: unknown): string {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return `'${String(value).replaceAll("'", "''")}'`;
}
// 标识符:白名单外才加双引号。
export function sqlIdentifier(name: string): string {
return identifier.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
}
// 单元格修改语句。
export function updateSql(table: string, pk: unknown, column: string, value: unknown): string {
return `UPDATE ${sqlIdentifier(table)} SET ${sqlIdentifier(column)} = ${sqlLiteral(value)} WHERE id = ${sqlLiteral(pk)};`;
}
// 插入语句。
export function insertSql(table: string, values: Record<string, unknown>): string {
const columns = Object.keys(values);
return `INSERT INTO ${sqlIdentifier(table)} (${columns.map(sqlIdentifier).join(', ')}) VALUES (${columns.map((column) => sqlLiteral(values[column])).join(', ')});`;
}
// 删除语句。
export function deleteSql(table: string, pk: unknown): string {
return `DELETE FROM ${sqlIdentifier(table)} WHERE id = ${sqlLiteral(pk)};`;
}
// 结构变更语句,与 Actions 拼法一致。
export function schemaSql(change: SchemaChange): string {
if (change.op === 'createTable') {
const columns = change.columns.map((column) => `${sqlIdentifier(column.name)} ${column.type}`).join(', ');
return `CREATE TABLE ${sqlIdentifier(change.table)} (${columns});`;
}
if (change.op === 'dropTable') return `DROP TABLE ${sqlIdentifier(change.table)};`;
if (change.op === 'createIndex') return `CREATE INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
return `DROP INDEX ON ${sqlIdentifier(change.table)} (${sqlIdentifier(change.column)});`;
}
// 渲染一条提交项为展示用 SQL。
export function changeSql(change: PendingChange): string {
if (change.op === 'insert') return insertSql(change.table, change.values);
if (change.op === 'delete') return deleteSql(change.table, change.pk);
if (change.op === 'update') {
const assignments = Object.entries(change.set).map(([column, value]) => `${sqlIdentifier(column)} = ${sqlLiteral(value)}`).join(', ');
return `UPDATE ${sqlIdentifier(change.table)} SET ${assignments} WHERE id = ${sqlLiteral(change.pk)};`;
}
return schemaSql(change);
}
// 按提交顺序渲染整套变更。
export function changesSql(changes: ChangeSet): string[] {
return pendingChanges(changes).map(changeSql);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { changeCount, createEmptyChangeSet, pendingChanges, toCommitPayload } from './changes';
// 覆盖提交载荷的合并、顺序与结构变更。
describe('toCommitPayload', () => {
it('coalesces cell updates into one row update', () => {
const changes = createEmptyChangeSet();
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
changes.updates.set('users:1:age', { table: 'users', pk: 1, column: 'age', oldValue: 20, newValue: 21 });
expect(toCommitPayload(changes)).toEqual([
{ op: 'update', table: 'users', pk: 1, set: { name: 'B', age: 21 } },
]);
});
it('keeps inserts and deletes in a stable order', () => {
const changes = createEmptyChangeSet();
changes.inserts.set('tmp-1', { table: 'users', tempId: 'tmp-1', values: { id: 9, name: 'N' } });
changes.deletes.set('users:2', { table: 'users', pk: 2, row: { id: 2, name: 'D' } });
expect(toCommitPayload(changes)).toEqual([
{ op: 'insert', table: 'users', values: { id: 9, name: 'N' } },
{ op: 'delete', table: 'users', pk: 2 },
]);
});
it('leaves schema changes out of the row payload', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
expect(toCommitPayload(changes)).toEqual([]);
});
});
describe('pendingChanges', () => {
it('puts creates first, then row changes, then drops', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createTable:items', { op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
changes.updates.set('items:1:name', { table: 'items', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(pendingChanges(changes)).toEqual([
{ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] },
{ op: 'update', table: 'items', pk: 1, set: { name: 'B' } },
{ op: 'dropTable', table: 'items' },
]);
});
it('counts structural changes together with row changes', () => {
const changes = createEmptyChangeSet();
changes.schemas.set('dropTable:items', { op: 'dropTable', table: 'items' });
changes.schemas.set('createIndex:users:age', { op: 'createIndex', table: 'users', column: 'age' });
changes.updates.set('users:1:name', { table: 'users', pk: 1, column: 'name', oldValue: 'A', newValue: 'B' });
expect(changeCount(changes)).toBe(3);
});
});
+72
View File
@@ -0,0 +1,72 @@
import type { ChangeSet, CommitChange, PendingChange, SchemaChange } from '../types';
// 变更集工具:创建、克隆、计数与提交载荷组装。
// 建一个空的变更集。
export function createEmptyChangeSet(): ChangeSet {
return { updates: new Map(), inserts: new Map(), deletes: new Map(), schemas: new Map() };
}
// 深拷贝变更集,供撤销栈留底。
export function cloneChangeSet(changes: ChangeSet): ChangeSet {
return {
updates: new Map(changes.updates),
inserts: new Map(changes.inserts),
deletes: new Map(changes.deletes),
schemas: new Map(changes.schemas),
};
}
// 统计待提交项,含结构变更。
export function changeCount(changes: ChangeSet): number {
return changes.updates.size + changes.inserts.size + changes.deletes.size + changes.schemas.size;
}
// 结构变更的稳定标识,撤销与确认共用。
export function schemaKey(change: SchemaChange): string {
return change.op === 'createIndex' || change.op === 'dropIndex'
? `${change.op}:${change.table}:${change.column}`
: `${change.op}:${change.table}`;
}
// 组装 DML 提交载荷:同行的多列修改并成一条。
export function toCommitPayload(changes: ChangeSet): CommitChange[] {
const updates = new Map<string, Extract<CommitChange, { op: 'update' }>>();
for (const update of changes.updates.values()) {
const key = `${update.table}:${JSON.stringify(update.pk)}`;
const existing = updates.get(key);
if (existing) {
existing.set[update.column] = update.newValue;
} else {
updates.set(key, {
op: 'update',
table: update.table,
pk: update.pk,
set: { [update.column]: update.newValue },
});
}
}
return [
...updates.values(),
...Array.from(changes.inserts.values(), (insert): CommitChange => ({
op: 'insert', table: insert.table, values: { ...insert.values },
})),
...Array.from(changes.deletes.values(), (deleted): CommitChange => ({
op: 'delete', table: deleted.table, pk: deleted.pk,
})),
];
}
// 按执行顺序合并结构变更与 DML 变更。
export function pendingChanges(changes: ChangeSet): PendingChange[] {
const schemas = Array.from(changes.schemas.values());
const creates = schemas.filter((change) => change.op === 'createTable' || change.op === 'createIndex');
const drops = schemas.filter((change) => change.op === 'dropIndex' || change.op === 'dropTable');
return [...creates, ...toCommitPayload(changes), ...drops];
}
// 单元格变更的键,表 + 主键 + 列。
export function changeKey(table: string, pk: unknown, column: string): string {
return `${table}:${JSON.stringify(pk)}:${column}`;
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { matchesFilter } from './filters';
// 覆盖筛选表达式的数值、通配与布尔匹配。
describe('matchesFilter', () => {
it('supports numeric comparisons', () => {
expect(matchesFilter(101, '>100')).toBe(true);
expect(matchesFilter(100, '>100')).toBe(false);
});
it('supports SQL-like wildcard matching without regular-expression injection', () => {
expect(matchesFilter('Alphabet', 'like %pha%')).toBe(true);
expect(matchesFilter('[abc]', 'like %[abc]%')).toBe(true);
expect(matchesFilter('aXXb', 'like a.b')).toBe(false);
});
it('matches booleans, null and plain text', () => {
expect(matchesFilter(true, 'true')).toBe(true);
expect(matchesFilter(null, 'null')).toBe(true);
expect(matchesFilter('Alice', 'alice')).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
// 网格筛选表达式解析:比较、like 通配与纯文本匹配。
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function likePattern(raw: string): RegExp {
const escaped = escapeRegExp(raw).replaceAll('%', '.*').replaceAll('_', '.');
return new RegExp(`^${escaped}$`, 'i');
}
export function matchesFilter(value: unknown, rawExpression: string): boolean {
const expression = rawExpression.trim();
if (!expression) return true;
const normalized = expression.toLowerCase();
if (normalized === 'null') return value === null || value === undefined;
if (normalized === 'true' || normalized === 'false') return value === (normalized === 'true');
const like = expression.match(/^like\s+(.+)$/i);
if (like) return likePattern(like[1]).test(String(value ?? ''));
const comparison = expression.match(/^(>=|<=|>|<|=|!=)\s*(-?\d+(?:\.\d+)?)$/);
if (comparison) {
const left = Number(value);
const right = Number(comparison[2]);
if (!Number.isFinite(left)) return false;
switch (comparison[1]) {
case '>': return left > right;
case '<': return left < right;
case '>=': return left >= right;
case '<=': return left <= right;
case '!=': return left !== right;
default: return left === right;
}
}
return String(value ?? '').toLocaleLowerCase().includes(normalized);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { splitSqlStatements } from './sql';
// 覆盖语句切分的分号、注释与行号计算。
describe('splitSqlStatements', () => {
it('splits a script into executable statements', () => {
expect(splitSqlStatements('SELECT 1; SELECT 2;').map((item) => item.sql)).toEqual([
'SELECT 1',
'SELECT 2',
]);
});
it('keeps semicolons inside strings and comments', () => {
const sql = "SELECT 'a;b'; -- keep ; here\nSELECT 2 /* and ; here */;";
expect(splitSqlStatements(sql).map((item) => item.sql)).toEqual([
"SELECT 'a;b'",
'-- keep ; here\nSELECT 2 /* and ; here */',
]);
});
it('reports one-based source lines for gutter decorations', () => {
const statements = splitSqlStatements('\nSELECT 1;\n\nSELECT 2');
expect(statements.map(({ startLine, endLine }) => [startLine, endLine])).toEqual([
[2, 2],
[4, 4],
]);
});
});
+76
View File
@@ -0,0 +1,76 @@
// SQL 脚本切分:识别字符串、注释并按分号拆分语句。
export interface SqlStatement {
id: string;
sql: string;
startLine: number;
endLine: number;
}
function lineAt(source: string, index: number): number {
let line = 1;
for (let cursor = 0; cursor < index; cursor += 1) {
if (source[cursor] === '\n') line += 1;
}
return line;
}
export function splitSqlStatements(source: string): SqlStatement[] {
const spans: Array<[number, number]> = [];
let start = 0;
let mode: 'normal' | 'single' | 'double' | 'line-comment' | 'block-comment' = 'normal';
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (mode === 'single') {
if (char === "'" && next === "'") index += 1;
else if (char === "'") mode = 'normal';
continue;
}
if (mode === 'double') {
if (char === '"' && next === '"') index += 1;
else if (char === '"') mode = 'normal';
continue;
}
if (mode === 'line-comment') {
if (char === '\n') mode = 'normal';
continue;
}
if (mode === 'block-comment') {
if (char === '*' && next === '/') {
mode = 'normal';
index += 1;
}
continue;
}
if (char === "'") mode = 'single';
else if (char === '"') mode = 'double';
else if (char === '-' && next === '-') {
mode = 'line-comment';
index += 1;
} else if (char === '/' && next === '*') {
mode = 'block-comment';
index += 1;
} else if (char === ';') {
spans.push([start, index]);
start = index + 1;
}
}
spans.push([start, source.length]);
return spans.flatMap(([from, to], ordinal) => {
const raw = source.slice(from, to);
const leading = raw.search(/\S/);
if (leading < 0) return [];
const sql = raw.trim();
const absoluteStart = from + leading;
const startLine = lineAt(source, absoluteStart);
return [{
id: `statement-${ordinal + 1}`,
sql,
startLine,
endLine: startLine + (sql.match(/\n/g)?.length ?? 0),
}];
});
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { SQL_PLAIN_COLOR, SQL_TOKEN_COLORS, tokenizeSql } from './sqlHighlight';
// 覆盖 SQL 高亮切分与执行器配色常量。
describe('tokenizeSql', () => {
it('marks keywords, strings and numbers while keeping the text intact', () => {
const sql = "UPDATE users SET name = 'A''B' WHERE id = 12;";
const tokens = tokenizeSql(sql);
expect(tokens.map((token) => token.text).join('')).toBe(sql);
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['UPDATE', 'SET', 'WHERE']);
expect(tokens.filter((token) => token.kind === 'string').map((token) => token.text)).toEqual(["'A''B'"]);
expect(tokens.filter((token) => token.kind === 'number').map((token) => token.text)).toEqual(['12']);
});
it('recognises line comments and leaves unknown words plain', () => {
expect(tokenizeSql('SELECT * FROM users -- note').at(-1)).toEqual({ text: '-- note', kind: 'comment' });
expect(tokenizeSql('select users').map((token) => token.kind)).toEqual(['keyword', 'plain']);
});
it('splits schema statements used by the pending list', () => {
const tokens = tokenizeSql('CREATE TABLE items (id int, title str);');
expect(tokens.filter((token) => token.kind === 'keyword').map((token) => token.text)).toEqual(['CREATE', 'TABLE', 'int', 'str']);
expect(tokens.map((token) => token.text).join('')).toBe('CREATE TABLE items (id int, title str);');
});
});
describe('SQL_TOKEN_COLORS', () => {
it('matches the colours the SQL console theme uses', () => {
expect(SQL_TOKEN_COLORS).toEqual({ keyword: '#569CD6', string: '#CE9178', number: '#B5CEA8', comment: '#6A9955' });
});
it('paints everything that is not a keyword in the plain grey', () => {
expect(SQL_PLAIN_COLOR).toBe('rgb(200, 200, 200)');
});
});
@@ -0,0 +1,76 @@
export const SQL_TOKEN_COLORS = {
keyword: '#569CD6',
string: '#CE9178',
number: '#B5CEA8',
comment: '#6A9955',
} as const;
export const SQL_PLAIN_COLOR = 'rgb(200, 200, 200)';
export type SqlTokenKind = 'plain' | keyof typeof SQL_TOKEN_COLORS;
export interface SqlToken { text: string; kind: SqlTokenKind }
// SQL 词法高亮:与执行器共用同一套 token 配色。
const keywords = new Set([
'select', 'from', 'where', 'insert', 'into', 'values', 'update', 'set', 'delete',
'create', 'table', 'index', 'on', 'drop', 'alter', 'and', 'or', 'not', 'null',
'order', 'by', 'group', 'having', 'limit', 'offset', 'join', 'left', 'right',
'inner', 'outer', 'as', 'distinct', 'primary', 'key', 'int', 'integer', 'str',
'text', 'bool', 'boolean', 'true', 'false',
]);
const wordChar = /[A-Za-z0-9_]/;
// 切分 SQL 为关键字、字符串、数字、注释与普通片段。
export function tokenizeSql(sql: string): SqlToken[] {
const tokens: SqlToken[] = [];
let plain = '';
let index = 0;
const flush = () => { if (plain) { tokens.push({ text: plain, kind: 'plain' }); plain = ''; } };
while (index < sql.length) {
const char = sql[index];
if (char === "'") {
flush();
let end = index + 1;
while (end < sql.length) {
if (sql[end] !== "'") { end += 1; continue; }
if (sql[end + 1] === "'") { end += 2; continue; }
end += 1;
break;
}
tokens.push({ text: sql.slice(index, end), kind: 'string' });
index = end;
continue;
}
if (char === '-' && sql[index + 1] === '-') {
flush();
const end = sql.indexOf('\n', index);
tokens.push({ text: end < 0 ? sql.slice(index) : sql.slice(index, end), kind: 'comment' });
index = end < 0 ? sql.length : end;
continue;
}
if (char >= '0' && char <= '9') {
flush();
let end = index;
while (end < sql.length && (wordChar.test(sql[end]) || sql[end] === '.')) end += 1;
tokens.push({ text: sql.slice(index, end), kind: 'number' });
index = end;
continue;
}
if (wordChar.test(char)) {
let end = index;
while (end < sql.length && wordChar.test(sql[end])) end += 1;
const word = sql.slice(index, end);
if (keywords.has(word.toLowerCase())) { flush(); tokens.push({ text: word, kind: 'keyword' }); }
else plain += word;
index = end;
continue;
}
plain += char;
index += 1;
}
flush();
return tokens;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { closeTab, openQueryTab, openSettingsTab, openTableTab, type Tab } from './tabs';
// 覆盖标签页打开与关闭的状态迁移。
const base: Tab[] = [
{ id: 'tab-1', kind: 'table', title: 'users', table: 'users' },
{ id: 'tab-2', kind: 'table', title: 'orders', table: 'orders' },
{ id: 'tab-3', kind: 'sql', title: '查询 1' },
];
describe('openTableTab', () => {
it('activates an existing tab instead of appending a duplicate', () => {
const state = openTableTab(base, 'users', 'tab-9');
expect(state.tabs).toHaveLength(3);
expect(state.activeId).toBe('tab-1');
expect(state.tabs).toBe(base);
});
it('appends a new tab for a table that is not open yet', () => {
const state = openTableTab(base, 'products', 'tab-9');
expect(state.tabs).toHaveLength(4);
expect(state.tabs[3]).toEqual({ id: 'tab-9', kind: 'table', title: 'products', table: 'products' });
expect(state.activeId).toBe('tab-9');
});
});
describe('openQueryTab', () => {
it('always appends so queries can be opened multiple times', () => {
const first = openQueryTab(base, '查询 1', 'tab-9');
const second = openQueryTab(first.tabs, '查询 2', 'tab-10', 'SELECT 1');
expect(second.tabs).toHaveLength(5);
expect(second.activeId).toBe('tab-10');
expect(second.tabs[4]).toEqual({ id: 'tab-10', kind: 'sql', title: '查询 2', sql: 'SELECT 1' });
});
});
describe('openSettingsTab', () => {
it('keeps a single settings tab and activates it', () => {
const first = openSettingsTab(base, '设置', 'tab-9');
const second = openSettingsTab(first.tabs, '设置', 'tab-10');
expect(second.tabs.filter((tab) => tab.kind === 'settings')).toHaveLength(1);
expect(second.tabs).toHaveLength(4);
expect(second.activeId).toBe('tab-9');
});
});
describe('closeTab', () => {
it('activates the tab that shifts in from the right', () => {
const state = closeTab(base, 'tab-1', 'tab-1');
expect(state.tabs.map((tab) => tab.id)).toEqual(['tab-2', 'tab-3']);
expect(state.activeId).toBe('tab-2');
});
it('falls back to the left neighbour when the last tab closes', () => {
const state = closeTab(base, 'tab-3', 'tab-3');
expect(state.activeId).toBe('tab-2');
});
it('keeps the active tab when a different one closes', () => {
const state = closeTab(base, 'tab-2', 'tab-3');
expect(state.tabs).toHaveLength(2);
expect(state.activeId).toBe('tab-3');
});
it('clears the active id when the only tab closes', () => {
const state = closeTab([base[0]], 'tab-1', 'tab-1');
expect(state.tabs).toHaveLength(0);
expect(state.activeId).toBeUndefined();
});
it('does not mutate the input array', () => {
const snapshot = JSON.stringify(base);
closeTab(base, 'tab-2', 'tab-2');
openTableTab(base, 'products', 'tab-9');
openQueryTab(base, '查询 2', 'tab-10');
openSettingsTab(base, '设置', 'tab-11');
expect(JSON.stringify(base)).toBe(snapshot);
});
});
+35
View File
@@ -0,0 +1,35 @@
// 标签页状态迁移:打开与关闭均返回新状态,不改入参数组。
export type TabKind = 'table' | 'sql' | 'settings';
export interface Tab { id: string; kind: TabKind; title: string; table?: string; sql?: string }
export interface TabState { tabs: Tab[]; activeId: string | undefined }
// 打开表标签:同表已开则激活,否则追加。
export function openTableTab(tabs: Tab[], name: string, id: string): TabState {
const existing = tabs.find((tab) => tab.kind === 'table' && tab.table === name);
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'table', title: name, table: name }], activeId: id };
}
// 打开查询标签:每次追加,可选初始 SQL。
export function openQueryTab(tabs: Tab[], title: string, id: string, sql?: string): TabState {
return { tabs: [...tabs, { id, kind: 'sql', title, sql }], activeId: id };
}
// 打开设置标签:只保留一个,已存在则激活。
export function openSettingsTab(tabs: Tab[], title: string, id: string): TabState {
const existing = tabs.find((tab) => tab.kind === 'settings');
if (existing) return { tabs, activeId: existing.id };
return { tabs: [...tabs, { id, kind: 'settings', title }], activeId: id };
}
// 关闭标签:关当前标签时激活右邻,无则左邻。
export function closeTab(tabs: Tab[], id: string, activeId: string | undefined): TabState {
const index = tabs.findIndex((tab) => tab.id === id);
if (index < 0) return { tabs, activeId };
const remaining = tabs.filter((tab) => tab.id !== id);
if (activeId !== id) return { tabs: remaining, activeId };
return { tabs: remaining, activeId: (remaining[index] ?? remaining[index - 1])?.id };
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS, useSettingsStore } from '../store/settingsStore';
import { loadUiSettings, saveUiSettings, syncUiSettings } from './uiSettings';
// 覆盖远端设置的校验读取、写回与防抖同步。
describe('uiSettings', () => {
let stop: (() => void) | undefined;
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().reset();
});
afterEach(() => {
stop?.();
stop = undefined;
vi.unstubAllGlobals();
localStorage.clear();
});
it('keeps known keys only and clamps the remote payload', async () => {
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({
uiFonts: ['', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', ' '],
gridFonts: 'Consolas',
sqlFonts: ['Consolas'],
uiFontSize: 99,
gridFontSize: 9,
gridRowHeight: 17.4,
sqlFontSize: 13.5,
sqlLineHeight: 60,
sqlTabSize: 0,
pageSize: 2,
nullText: 'NULL',
sqlLineNumbers: false,
autocomplete: 'yes',
minimap: true,
confirmBeforeCommit: 1,
unknownKey: 5,
}), { status: 200 }));
vi.stubGlobal('fetch', request);
await expect(loadUiSettings()).resolves.toEqual({
uiFonts: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10'],
sqlFonts: ['Consolas'],
uiFontSize: 16,
gridFontSize: 10,
gridRowHeight: 18,
sqlFontSize: 14,
sqlLineHeight: 40,
sqlTabSize: 1,
pageSize: 10,
nullText: 'NULL',
sqlLineNumbers: false,
minimap: true,
});
expect(request).toHaveBeenCalledWith('/api/ui-settings', { credentials: 'same-origin' });
});
it('returns undefined instead of throwing when the read fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not json', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 })));
await expect(loadUiSettings()).resolves.toBeUndefined();
});
it('puts the whole settings object back to the endpoint', async () => {
const request = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(true);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('/api/ui-settings', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(DEFAULT_SETTINGS),
});
});
it('reports a failed write as false', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
await expect(saveUiSettings(DEFAULT_SETTINGS)).resolves.toBe(false);
});
it('merges remote settings first and then debounces one write', async () => {
const request = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ uiFonts: ['Consolas'], uiFontSize: 15, autocomplete: false }), { status: 200 }))
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', request);
stop = syncUiSettings();
await vi.waitFor(() => expect(useSettingsStore.getState().settings.autocomplete).toBe(false));
expect(useSettingsStore.getState().settings.uiFonts).toEqual(['Consolas']);
expect(useSettingsStore.getState().settings.uiFontSize).toBe(15);
expect(request).toHaveBeenCalledTimes(1);
useSettingsStore.getState().setNumber('pageSize', 300);
useSettingsStore.getState().setNumber('pageSize', 320);
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2), { timeout: 2000 });
const [url, init] = request.mock.calls[1] as [string, RequestInit];
expect(url).toBe('/api/ui-settings');
expect(init.method).toBe('PUT');
expect(JSON.parse(String(init.body)).pageSize).toBe(320);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { useSettingsStore, type IdeSettings } from '../store/settingsStore';
// 远端 IDE 设置的读取、校验与防抖回写。
const endpoint = '/api/ui-settings';
const debounceMs = 400;
const fontKeys = ['uiFonts', 'gridFonts', 'sqlFonts'] as const;
const booleanKeys = ['sqlLineNumbers', 'autocomplete', 'minimap'] as const;
const numberKeys = ['uiFontSize', 'gridFontSize', 'gridRowHeight', 'sqlFontSize', 'sqlLineHeight', 'sqlTabSize', 'pageSize'] as const;
type NumberKey = (typeof numberKeys)[number];
const numberRanges: Record<NumberKey, [number, number]> = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
// 收敛字体链:去空串并截断到 10 项。
function readFonts(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.filter((font): font is string => typeof font === 'string' && font.trim().length > 0).slice(0, 10);
}
// 只取已知键,数值夹紧,类型不符即丢弃。
function parseUiSettings(body: unknown): Partial<IdeSettings> {
const parsed: Partial<IdeSettings> = {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return parsed;
const source = body as Record<string, unknown>;
for (const key of fontKeys) {
const fonts = readFonts(source[key]);
if (fonts) parsed[key] = fonts;
}
for (const key of numberKeys) {
const value = source[key];
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
const [min, max] = numberRanges[key];
parsed[key] = Math.min(max, Math.max(min, Math.round(value)));
}
for (const key of booleanKeys) {
const value = source[key];
if (typeof value === 'boolean') parsed[key] = value;
}
if (typeof source.nullText === 'string') parsed.nullText = source.nullText;
return parsed;
}
// 读取远端设置,失败时返回 undefined。
export async function loadUiSettings(): Promise<Partial<IdeSettings> | undefined> {
try {
const response = await fetch(endpoint, { credentials: 'same-origin' });
if (!response.ok) return undefined;
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
return parseUiSettings(body);
} catch {
return undefined;
}
}
// 全量写回远端设置,失败时返回 false。
export async function saveUiSettings(settings: IdeSettings): Promise<boolean> {
try {
const response = await fetch(endpoint, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
return response.ok;
} catch {
return false;
}
}
// 先并入远端设置,再防抖回写后续变更。
export function syncUiSettings(): () => void {
let stopped = false;
let unsubscribe: (() => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; } };
void (async () => {
const remote = await loadUiSettings();
if (stopped) return;
if (remote) useSettingsStore.setState((state) => ({ settings: { ...state.settings, ...remote } }));
unsubscribe = useSettingsStore.subscribe((state, previous) => {
if (state.settings === previous.settings) return;
cancel();
timer = setTimeout(() => {
timer = undefined;
void saveUiSettings(useSettingsStore.getState().settings);
}, debounceMs);
});
})();
return () => {
stopped = true;
unsubscribe?.();
cancel();
};
}
+161
View File
@@ -0,0 +1,161 @@
// 中文界面文案表:全站 UI 文本的唯一来源。
export const zhCN = {
appName: 'ChuSQL 数据库终端',
signingIn: '正在检查登录状态…',
signInTitle: '登录 ChuSQL',
userName: '用户名',
password: '密码',
signIn: '登录',
signInFailed: '登录失败,请检查用户名和密码',
connection: '本地连接',
database: 'chusql',
explorer: '资源管理器',
databaseSection: '数据库',
activityBar: '活动栏',
selectionColumn: '选择',
changesSection: '变更集',
historySection: '历史',
sqlConsole: 'SQL 控制台',
newQuery: '新建查询',
runNext: '单步执行',
runAll: '全部执行',
runSelection: '执行选中',
stop: '停止',
statementPending: '待执行',
statementRunning: '执行中',
statementSuccess: '成功',
statementFailed: '失败',
executionStopped: '已停止;已发出的语句可能仍在服务端运行',
executionSuccess: (rows: number, duration: number) => `执行成功,返回 ${rows} 行 (${duration.toFixed(1)} ms)`,
executionAffected: (rows: number, duration: number) => `执行成功,影响 ${rows} 行 (${duration.toFixed(1)} ms)`,
resultLabel: (index: number, rows: number, duration: number) => `结果 ${index} (${rows} 行, ${duration.toFixed(1)} ms)`,
noStatement: '没有可执行的 SQL 语句',
queryHistory: '查询历史',
settings: '设置',
settingsAppearance: '外观与字体',
settingsBehavior: '数据与编辑器',
interfaceFont: '界面字体',
gridFont: '表格字体',
sqlFont: 'SQL 编辑器字体',
fontName: '字体名称',
addFallback: '添加备用字体',
removeFont: (font: string) => `删除字体 ${font}`,
moveFont: '拖动调整字体顺序',
fontSize: '字号',
rowHeight: '行高',
tabSize: 'Tab 宽度',
showLineNumbers: '显示行号',
pageSize: '分页大小',
nullDisplay: 'NULL 显示文本',
autocomplete: '自动补全',
minimap: 'Minimap',
restoreDefaults: '恢复默认',
preview: '实时预览',
previewText: 'ChuSQL 数据库终端 SELECT * FROM users;',
commit: (count: number) => `提交 (${count})`,
rollback: '回滚',
undo: '撤销',
redo: '重做',
insertRow: '插入行',
deleteRows: '删除选中行',
batchReplace: '批量替换',
findText: '查找内容',
replaceText: '替换为',
replaceSelected: '替换选中区域',
selectedRowsRequired: '请先选择要操作的行',
refresh: '刷新',
filter: (column: string) => `筛选 ${column}`,
openTable: (table: string) => `打开表 ${table}`,
closeTab: (table: string) => `关闭 ${table}`,
editCell: (column: string) => `编辑 ${column}`,
rowSelect: (row: number) => `选择第 ${row} 行`,
noRows: '没有符合条件的数据',
loading: '正在加载…',
loadError: '加载失败',
retry: '重试',
previousPage: '上一页',
nextPage: '下一页',
loadMore: '加载更多',
pendingChanges: '待提交变更',
updateChange: (table: string, column: string) => `修改 ${table}.${column}`,
insertChange: (table: string) => `向 ${table} 插入一行`,
deleteChange: (table: string) => `从 ${table} 删除一行`,
createTableChange: (table: string) => `新建表 ${table}`,
dropTableChange: (table: string) => `删除表 ${table}`,
createIndexChange: (table: string, column: string) => `为 ${table}.${column} 建索引`,
dropIndexChange: (table: string, column: string) => `删除 ${table}.${column} 的索引`,
undoChange: '撤销此变更',
noPendingChanges: '没有待提交变更',
noResults: '尚无查询结果',
results: '结果',
messages: '消息',
cancel: '取消',
committed: (count: number) => `已提交 ${count} 项变更`,
commitFailed: '提交失败,变更已保留',
rowCount: (count: number) => `${count} 行`,
pendingCount: (count: number) => `待提交 ${count} 项`,
nullValue: 'NULL',
emptyWorkspace: '从左侧资源管理器打开一张表,或打开 SQL 控制台。',
queryTab: (index: number) => `查询 ${index}`,
resizeSidebar: '拖动调整侧边栏宽度',
resizeDock: '拖动调整底部面板高度',
close: '关闭',
booleanTrue: '是',
booleanFalse: '否',
done: '完成',
clear: '清空',
remove: '移除',
newTable: '新建表',
expandColumns: (table: string) => `展开 ${table} 的列`,
manageIndexes: (table: string) => `管理 ${table} 的索引`,
dropTableAction: (table: string) => `删除表 ${table}`,
columnPrimaryKey: '主键列',
columnIndexed: '索引列',
columnOrdinary: '普通列',
newTableTitle: '新建表',
dropTableTitle: '删除表',
indexManagerTitle: '索引管理',
tableName: '表名',
columnName: (index: number) => `列名 ${index}`,
columnType: (index: number) => `列类型 ${index}`,
removeColumn: (index: number) => `移除第 ${index} 列`,
addColumn: '添加列',
createTable: '建表',
dropTablePermanently: '永久删除表',
typeInt: '整数',
typeStr: '文本',
typeBool: '布尔',
typeDate: '日期',
stagedMessage: (sql: string) => `已暂存:${sql}`,
builtinIndex: '内建主键索引',
uniqueIndex: '唯一索引',
noIndex: '无索引',
createIndex: '建索引',
deleteIndex: '删索引',
createTableHint: '作为待提交变更暂存,提交时建表。整数 id 固定为内建主键;至少填一列,并删掉多余的空列。',
dropTableHint: (table: string, rows: number) => `作为待提交变更暂存:提交后会删掉 ${table} 的全部 ${rows} 行、列定义与索引,且不可撤销。`,
indexHint: '索引变更先暂存,提交时才下发。目前只支持唯一整数索引,列里有重复值时提交会失败;内建 id 索引不能删除。',
invalidIdentifier: '表名与列名必须以字母或下划线开头,且只能包含字母、数字与下划线。',
duplicateColumns: '列名重复',
insertRowTitle: (table: string) => `插入行 · ${table}`,
addToPending: '加入待提交',
insertRowHint: '每个字段都要填。加入后仍可修改或撤销,点顶部的「提交」才会写入数据库。',
primaryKeyBadge: ' · 主键(必填且唯一)',
invalidInteger: (column: string) => `${column}:请输入合法整数`,
chooseDate: (column: string) => `${column}:请选择日期`,
selectRowsHint: '单击选中一行;Ctrl+单击多选,Shift+单击连选,空格键也可选中',
selectedRows: (count: number) => `已选 ${count} 行`,
rowStaged: '新行已加入待提交',
filterPlaceholder: '筛选表达式',
resizeColumn: (column: string) => `调整 ${column} 列宽`,
} as const;
export const columnTypeLabels: Record<string, string> = {
int: zhCN.typeInt,
str: zhCN.typeStr,
bool: zhCN.typeBool,
date: zhCN.typeDate,
};
export type Messages = typeof zhCN;
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { MockAdapter } from './adapters/MockAdapter';
import { RestAdapter } from './adapters/RestAdapter';
import { AuthGate } from './components/common/AuthGate';
import './styles/global.css';
// 前端入口:按环境选择 mock/rest 数据源并挂载根组件。
const adapterName = import.meta.env.VITE_CHUSQL_ADAPTER ?? (import.meta.env.DEV ? 'mock' : 'rest');
const adapter = adapterName === 'rest' ? new RestAdapter() : new MockAdapter();
const root = document.getElementById('root');
if (!root) throw new Error('Root element is missing.');
const application = <App adapter={adapter} />;
createRoot(root).render(<StrictMode>{adapterName === 'rest' ? <AuthGate>{application}</AuthGate> : application}</StrictMode>);
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useChangeStore } from './changeStore';
// 覆盖变更记录、结构变更暂存与部分提交确认。
describe('changeStore', () => {
beforeEach(() => useChangeStore.getState().clear());
it('removes successful inserts after a partial commit so retries cannot duplicate them', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-a', { id: 901 });
store.insertRow('users', 'temp-b', { id: 902 });
const submitted = useChangeStore.getState().changes;
store.acknowledge(submitted, 1);
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']);
store.undo();
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-b']);
});
it('records edits without writing through and supports undo/redo', () => {
const store = useChangeStore.getState();
store.updateCell('users', 1, 'name', 'A', 'B');
expect(useChangeStore.getState().changes.updates.size).toBe(1);
useChangeStore.getState().undo();
expect(useChangeStore.getState().changes.updates.size).toBe(0);
useChangeStore.getState().redo();
expect(useChangeStore.getState().changes.updates.get('users:1:name')?.newValue).toBe('B');
});
it('removes a new row instead of staging a delete for it', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-1', { name: 'N' });
useChangeStore.getState().deleteRow('users', 'temp-1', { name: 'N' }, true);
expect(useChangeStore.getState().changes.inserts.size).toBe(0);
expect(useChangeStore.getState().changes.deletes.size).toBe(0);
});
it('stages structural changes and can undo a single one', () => {
const store = useChangeStore.getState();
store.stageSchema({ op: 'createIndex', table: 'users', column: 'age' });
store.stageSchema({ op: 'dropTable', table: 'items' });
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']);
useChangeStore.getState().undoOne('schemas', 'createIndex:users:age');
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['dropTable:items']);
useChangeStore.getState().undo();
expect([...useChangeStore.getState().changes.schemas.keys()]).toEqual(['createIndex:users:age', 'dropTable:items']);
});
it('keeps unapplied changes when the commit stops after a structural change', () => {
const store = useChangeStore.getState();
store.stageSchema({ op: 'createTable', table: 'items', columns: [{ name: 'id', type: 'int' }] });
store.insertRow('users', 'temp-a', { id: 901 });
store.acknowledge(useChangeStore.getState().changes, 1);
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
expect([...useChangeStore.getState().changes.inserts.keys()]).toEqual(['temp-a']);
});
it('drops a structural change once the whole queue is acknowledged', () => {
const store = useChangeStore.getState();
store.insertRow('users', 'temp-a', { id: 901 });
store.stageSchema({ op: 'dropTable', table: 'items' });
store.acknowledge(useChangeStore.getState().changes, 2);
expect(useChangeStore.getState().changes.schemas.size).toBe(0);
expect(useChangeStore.getState().changes.inserts.size).toBe(0);
});
});
@@ -0,0 +1,108 @@
import { create } from 'zustand';
import { changeKey, cloneChangeSet, createEmptyChangeSet, pendingChanges, schemaKey } from '../core/changes';
import type { ChangeSet, SchemaChange } from '../types';
// 待提交变更状态:增删改记录、撤销重做与提交确认。
interface ChangeState {
changes: ChangeSet;
undoStack: ChangeSet[];
redoStack: ChangeSet[];
updateCell: (table: string, pk: unknown, column: string, oldValue: unknown, newValue: unknown) => void;
insertRow: (table: string, tempId: string, values: Record<string, unknown>) => void;
updateInsert: (tempId: string, values: Record<string, unknown>) => void;
deleteRow: (table: string, pk: unknown, row: Record<string, unknown>, isInserted?: boolean) => void;
stageSchema: (change: SchemaChange) => void;
undoOne: (kind: keyof ChangeSet, key: string) => void;
undo: () => void;
redo: () => void;
clear: () => void;
acknowledge: (submitted: ChangeSet, applied: number) => void;
}
function withHistory(state: ChangeState, mutate: (next: ChangeSet) => void): Partial<ChangeState> {
const next = cloneChangeSet(state.changes);
mutate(next);
return {
changes: next,
undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100),
redoStack: [],
};
}
export const useChangeStore = create<ChangeState>((set) => ({
changes: createEmptyChangeSet(),
undoStack: [],
redoStack: [],
updateCell: (table, pk, column, oldValue, newValue) => set((state) => withHistory(state, (next) => {
const key = changeKey(table, pk, column);
if (Object.is(oldValue, newValue)) next.updates.delete(key);
else next.updates.set(key, { table, pk, column, oldValue, newValue });
})),
insertRow: (table, tempId, values) => set((state) => withHistory(state, (next) => {
next.inserts.set(tempId, { table, tempId, values: { ...values } });
})),
updateInsert: (tempId, values) => set((state) => withHistory(state, (next) => {
const insert = next.inserts.get(tempId);
if (insert) next.inserts.set(tempId, { ...insert, values: { ...values } });
})),
deleteRow: (table, pk, row, isInserted = false) => set((state) => withHistory(state, (next) => {
if (isInserted) {
next.inserts.delete(String(pk));
return;
}
for (const [key, update] of next.updates) {
if (update.table === table && Object.is(update.pk, pk)) next.updates.delete(key);
}
next.deletes.set(`${table}:${JSON.stringify(pk)}`, { table, pk, row: { ...row } });
})),
stageSchema: (change) => set((state) => withHistory(state, (next) => {
next.schemas.set(schemaKey(change), change);
})),
undoOne: (kind, key) => set((state) => withHistory(state, (next) => {
next[kind].delete(key);
})),
undo: () => set((state) => {
const previous = state.undoStack.at(-1);
if (!previous) return state;
return {
changes: cloneChangeSet(previous),
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, cloneChangeSet(state.changes)].slice(-100),
};
}),
redo: () => set((state) => {
const next = state.redoStack.at(-1);
if (!next) return state;
return {
changes: cloneChangeSet(next),
undoStack: [...state.undoStack, cloneChangeSet(state.changes)].slice(-100),
redoStack: state.redoStack.slice(0, -1),
};
}),
clear: () => set({ changes: createEmptyChangeSet(), undoStack: [], redoStack: [] }),
acknowledge: (submitted, applied) => set((state) => {
const next = cloneChangeSet(state.changes);
const completed = pendingChanges(submitted).slice(0, applied);
const inserted = Array.from(submitted.inserts.entries());
let insertIndex = 0;
for (const operation of completed) {
if (operation.op === 'insert') {
const [key, value] = inserted[insertIndex++];
if (next.inserts.get(key) === value) next.inserts.delete(key);
} else if (operation.op === 'delete') {
const key = `${operation.table}:${JSON.stringify(operation.pk)}`;
if (next.deletes.get(key) === submitted.deletes.get(key)) next.deletes.delete(key);
} else if (operation.op === 'update') {
for (const column of Object.keys(operation.set)) {
const key = changeKey(operation.table, operation.pk, column);
if (next.updates.get(key) === submitted.updates.get(key)) next.updates.delete(key);
}
} else {
const key = schemaKey(operation);
if (next.schemas.get(key) === submitted.schemas.get(key)) next.schemas.delete(key);
}
}
return { changes: next, undoStack: [], redoStack: [] };
}),
}));
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_SETTINGS, fontFamilyValue, useSettingsStore } from './settingsStore';
// 覆盖字体链转义与数值范围收敛。
describe('settingsStore', () => {
beforeEach(() => {
localStorage.clear();
useSettingsStore.getState().reset();
});
it('quotes a user-defined fallback chain safely', () => {
expect(fontFamilyValue(['JetBrains Mono', 'Consolas'])).toBe('"JetBrains Mono", "Consolas", monospace');
expect(fontFamilyValue(['A"B', ' '])).toBe('"A\\"B", monospace');
});
it('clamps numeric settings to the supported range', () => {
useSettingsStore.getState().setNumber('gridRowHeight', 200);
expect(useSettingsStore.getState().settings.gridRowHeight).toBe(40);
useSettingsStore.getState().setNumber('pageSize', 1);
expect(useSettingsStore.getState().settings.pageSize).toBe(10);
expect(DEFAULT_SETTINGS.uiFontSize).toBe(13);
});
});
@@ -0,0 +1,78 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// IDE 设置状态:默认值、范围收敛与本地持久化。
export interface IdeSettings {
uiFonts: string[];
uiFontSize: number;
gridFonts: string[];
gridFontSize: number;
gridRowHeight: number;
sqlFonts: string[];
sqlFontSize: number;
sqlLineHeight: number;
sqlTabSize: number;
sqlLineNumbers: boolean;
pageSize: number;
nullText: string;
autocomplete: boolean;
minimap: boolean;
}
export const DEFAULT_SETTINGS: IdeSettings = Object.freeze({
uiFonts: ['JetBrains Mono', 'Consolas', 'Cascadia Code'],
uiFontSize: 13,
gridFonts: ['JetBrains Mono', 'Consolas'],
gridFontSize: 12,
gridRowHeight: 24,
sqlFonts: ['JetBrains Mono', 'Consolas'],
sqlFontSize: 13,
sqlLineHeight: 20,
sqlTabSize: 2,
sqlLineNumbers: true,
pageSize: 200,
nullText: 'NULL',
autocomplete: true,
minimap: false,
});
const ranges: Record<string, [number, number]> = {
uiFontSize: [11, 16],
gridFontSize: [10, 18],
gridRowHeight: [18, 40],
sqlFontSize: [10, 20],
sqlLineHeight: [16, 40],
sqlTabSize: [1, 8],
pageSize: [10, 500],
};
export function fontFamilyValue(chain: string[]): string {
const fonts = chain.map((font) => font.trim()).filter(Boolean).map((font) => `"${font.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`);
return [...fonts, 'monospace'].join(', ');
}
interface SettingsState {
settings: IdeSettings;
setNumber: (key: keyof IdeSettings, value: number) => void;
setBoolean: (key: keyof IdeSettings, value: boolean) => void;
setText: (key: keyof IdeSettings, value: string) => void;
setFonts: (key: 'uiFonts' | 'gridFonts' | 'sqlFonts', value: string[]) => void;
reset: () => void;
}
export const useSettingsStore = create<SettingsState>()(persist((set) => ({
settings: { ...DEFAULT_SETTINGS },
setNumber: (key, value) => set((state) => {
const range = ranges[key];
const next = range ? Math.min(range[1], Math.max(range[0], Math.round(value))) : value;
return { settings: { ...state.settings, [key]: next } };
}),
setBoolean: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })),
setText: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value } })),
setFonts: (key, value) => set((state) => ({ settings: { ...state.settings, [key]: value.slice(0, 10) } })),
reset: () => set({ settings: { ...DEFAULT_SETTINGS } }),
}), {
name: 'chusql.ide.settings.v1',
partialize: (state) => ({ settings: state.settings }),
}));
+48
View File
@@ -0,0 +1,48 @@
/* 全局样式:主题变量、基础重置与滚动条。 */
:root {
--bg-base: #1e1e1e;
--bg-elevated: #252526;
--bg-overlay: #2d2d30;
--bg-active: #37373d;
--bg-titlebar: #3c3c3c;
--bg-statusbar: #007acc;
--border: #3c3c3c;
--border-subtle: #2d2d2d;
--fg-primary: #cccccc;
--fg-muted: #858585;
--fg-disabled: #5a5a5a;
--accent: #007acc;
--accent-2: #0e639c;
--success: #4ec9b0;
--warning: #cca700;
--danger: #f14c4c;
--modified: #e2c08d;
--info: #9cdcfe;
--syn-keyword: #569cd6;
--syn-control: #c586c0;
--syn-string: #ce9178;
--syn-number: #b5cea8;
--syn-comment: #6a9955;
--syn-function: #dcdcaa;
--syn-type: #4ec9b0;
--syn-variable: #9cdcfe;
--ui-font: "JetBrains Mono", Consolas, "Cascadia Code", monospace;
--grid-font: "JetBrains Mono", Consolas, monospace;
--grid-font-size: 12px;
--grid-row-height: 24px;
color: var(--fg-primary);
font-family: var(--ui-font);
font-size: 13px;
font-synthesis: none;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg-base); }
button, input, select, textarea { font-family: inherit; }
button { border-radius: 2px; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
::-webkit-scrollbar { height: 10px; width: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg-active); }
@media (prefers-reduced-motion: no-preference) { button, input, select { transition: background-color 100ms ease-out, border-color 100ms ease-out; } }
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 测试环境初始化:注册 jest-dom 断言与自动清理。
afterEach(cleanup);
+92
View File
@@ -0,0 +1,92 @@
// 共享类型定义:数据源适配器接口、元数据与查询结果结构。
export type DataValue = string | number | boolean | null;
export interface ColumnMeta {
name: string;
type: 'int' | 'str' | 'bool' | 'date';
primaryKey?: boolean;
indexed?: boolean;
prime?: boolean;
distinct?: number;
statsCapped?: boolean;
}
export interface TableMeta {
name: string;
rowCount: number;
columns: ColumnMeta[];
}
export interface FilterExpr {
column: string;
expression: string;
}
export interface FetchRowsParams {
db: string;
table: string;
page: number;
pageSize: number;
sort: Array<{ column: string; dir: 'asc' | 'desc' }>;
filters: FilterExpr[];
search?: string;
}
export type SchemaChange =
| { op: 'createTable'; table: string; columns: Array<Pick<ColumnMeta, 'name' | 'type'>> }
| { op: 'dropTable'; table: string }
| { op: 'createIndex'; table: string; column: string }
| { op: 'dropIndex'; table: string; column: string };
export type CommitChange =
| { op: 'update'; table: string; pk: unknown; set: Record<string, unknown> }
| { op: 'insert'; table: string; values: Record<string, unknown> }
| { op: 'delete'; table: string; pk: unknown };
export type PendingChange = CommitChange | SchemaChange;
export interface StatementResult {
type: 'rows' | 'affected' | 'error';
columns?: string[];
rows?: unknown[][];
affected?: number;
durationMs: number;
message?: string;
}
export interface DataSourceAdapter {
createTable(name: string, columns: Array<Pick<ColumnMeta, 'name' | 'type'>>): Promise<void>;
dropTable(table: string): Promise<void>;
createIndex(table: string, column: string): Promise<void>;
dropIndex(table: string, column: string): Promise<void>;
listTables(db: string): Promise<TableMeta[]>;
getColumns(db: string, table: string): Promise<ColumnMeta[]>;
fetchRows(params: FetchRowsParams): Promise<{ rows: Record<string, unknown>[]; total: number }>;
commitChanges(payload: {
db: string;
changes: CommitChange[];
}): Promise<{ ok: boolean; applied: number; errors?: Array<{ index: number; message: string }> }>;
execStatement(params: { db: string; sql: string }): Promise<StatementResult>;
}
export interface ChangeSet {
updates: Map<string, {
table: string;
pk: unknown;
column: string;
oldValue: unknown;
newValue: unknown;
}>;
inserts: Map<string, {
table: string;
tempId: string;
values: Record<string, unknown>;
}>;
deletes: Map<string, {
table: string;
pk: unknown;
row: Record<string, unknown>;
}>;
schemas: Map<string, SchemaChange>;
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
// Vite 环境类型声明:客户端环境变量与 CSS Modules 模块。
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}