使用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

View File
Whitespace-only changes.
+344
View File
@@ -0,0 +1,344 @@
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import ChuSQL.Web.Api (AppEnv (..), Live (..), newAppEnv, setLive, webApp)
import ChuSQL.Web.Auth (SessionPolicy (..), newSessionStore)
import ChuSQL.Web.Backend (Backend, ipcBackend)
import ChuSQL.Web.Demo (SeedReport (..), seedDemo)
import ChuSQL.Web.Config (
WebConfig (..),
defaultPassword,
defaultUser,
loadWebConfig,
resolveCredential,
resolveStaticDir,
staticDirCandidates,
usingDefaultCredentials,
)
import ChuSQL.Web.RateLimit (newRateLimiter)
import ChuSQL.Web.Settings (effectiveSettings)
import ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
startStorageProcess,
stopStorageProcess,
waitForStorage,
)
import Control.Exception (IOException, bracket, try)
import Data.Char (toLower)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Clock (getCurrentTime)
import Network.Wai (Application)
import qualified Network.Wai.Handler.Warp as Warp
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
import Text.Read (readMaybe)
-- Web 管理端入口:读配置、备凭据、拉起存储进程,最后起 Warp。
data Options = Options
{ optHost :: Maybe String
, optPort :: Maybe Int
, optStatic :: Maybe FilePath
, optStorage :: Maybe FilePath
, optUser :: Maybe Text
, optCookieSecure :: Maybe Bool
, optBodyLimit :: Maybe Int
, optSessionIdle :: Maybe Int
, optSessionMax :: Maybe Int
, optLoginMaxAttempts :: Maybe Int
, optLoginWindow :: Maybe Int
, optPageSize :: Maybe Int
, optMaxPageSize :: Maybe Int
, optMaxRows :: Maybe Int
, optMaxSqlLength :: Maybe Int
, optSeedDemo :: Maybe Bool
, optHelp :: Bool
}
-- | 一个开关都没给
emptyOptions :: Options
emptyOptions =
Options
{ optHost = Nothing
, optPort = Nothing
, optStatic = Nothing
, optStorage = Nothing
, optUser = Nothing
, optCookieSecure = Nothing
, optBodyLimit = Nothing
, optSessionIdle = Nothing
, optSessionMax = Nothing
, optLoginMaxAttempts = Nothing
, optLoginWindow = Nothing
, optPageSize = Nothing
, optMaxPageSize = Nothing
, optMaxRows = Nothing
, optMaxSqlLength = Nothing
, optSeedDemo = Nothing
, optHelp = False
}
-- | 解析参数
parseArgs :: [String] -> Either String Options
parseArgs = go emptyOptions
where
go opts [] = Right opts
go opts ("--help" : rest) = go opts{optHelp = True} rest
go opts ("-h" : rest) = go opts{optHelp = True} rest
go opts ("--host" : v : rest) = go opts{optHost = Just v} rest
go opts ("--static" : v : rest) = go opts{optStatic = Just v} rest
go opts ("--storage" : v : rest) = go opts{optStorage = Just v} rest
go opts ("--user" : v : rest) = go opts{optUser = Just (T.pack v)} rest
go opts ("--port" : v : rest) = withInt "--port" v (\n -> go opts{optPort = Just n} rest)
go opts ("--body-limit" : v : rest) = withInt "--body-limit" v (\n -> go opts{optBodyLimit = Just n} rest)
go opts ("--session-idle" : v : rest) = withInt "--session-idle" v (\n -> go opts{optSessionIdle = Just n} rest)
go opts ("--session-max" : v : rest) = withInt "--session-max" v (\n -> go opts{optSessionMax = Just n} rest)
go opts ("--login-max-attempts" : v : rest) = withInt "--login-max-attempts" v (\n -> go opts{optLoginMaxAttempts = Just n} rest)
go opts ("--login-window" : v : rest) = withInt "--login-window" v (\n -> go opts{optLoginWindow = Just n} rest)
go opts ("--page-size" : v : rest) = withInt "--page-size" v (\n -> go opts{optPageSize = Just n} rest)
go opts ("--max-page-size" : v : rest) = withInt "--max-page-size" v (\n -> go opts{optMaxPageSize = Just n} rest)
go opts ("--max-rows" : v : rest) = withInt "--max-rows" v (\n -> go opts{optMaxRows = Just n} rest)
go opts ("--max-sql-length" : v : rest) = withInt "--max-sql-length" v (\n -> go opts{optMaxSqlLength = Just n} rest)
go opts ("--seed" : v : rest) = go opts{optSeedDemo = Just (isTrue v)} rest
go opts ("--cookie-secure" : v : rest) = go opts{optCookieSecure = Just (isTrue v)} rest
go _ (a : _) = Left ("unrecognized argument or missing value: " ++ a)
-- | 读一个整数开关
withInt :: String -> String -> (Int -> Either String Options) -> Either String Options
withInt name v k = maybe (Left (name ++ " needs an integer, got: " ++ v)) k (readMaybe v)
-- | 布尔开关认 1/true/yes/on
isTrue :: String -> Bool
isTrue s = map toLower s `elem` ["1", "true", "yes", "on"]
-- | 帮助
usage :: IO ()
usage = do
putStrLn "usage: chusql-web [options]"
putStrLn ""
putStrLn " --host H listen address (CHUSQL_WEB_HOST, default 127.0.0.1)"
putStrLn " --port N listen port (CHUSQL_WEB_PORT, default 7777)"
putStrLn " --static DIR static asset directory (CHUSQL_WEB_STATIC, default static)"
putStrLn " --user NAME account name (CHUSQL_WEB_USER, default admin)"
putStrLn " --storage SERVER Rust storage exe to spawn (CHUSQL_STORAGE_SERVER)"
putStrLn " --cookie-secure BOOL add Secure to the cookie (CHUSQL_WEB_COOKIE_SECURE, default false)"
putStrLn " --body-limit N max request body bytes (CHUSQL_WEB_BODY_LIMIT, default 65536)"
putStrLn " --session-idle N session idle timeout, sec (CHUSQL_WEB_SESSION_IDLE, default 28800)"
putStrLn " --session-max N session max age, sec (CHUSQL_WEB_SESSION_MAX, default 86400)"
putStrLn " --login-max-attempts N failed logins before 429 (CHUSQL_WEB_LOGIN_MAX_ATTEMPTS, default 5)"
putStrLn " --login-window N lockout window, sec (CHUSQL_WEB_LOGIN_WINDOW, default 300)"
putStrLn " --page-size N rows per page by default (CHUSQL_WEB_PAGE_SIZE, default 25)"
putStrLn " --max-page-size N rows per page ceiling (CHUSQL_WEB_MAX_PAGE_SIZE, default 500)"
putStrLn " --max-rows N rows returned per query (CHUSQL_WEB_MAX_ROWS, default 1000)"
putStrLn " --max-sql-length N max SQL characters (CHUSQL_WEB_MAX_SQL_LENGTH, default 20000)"
putStrLn " --seed BOOL load demo data when the db is empty (CHUSQL_WEB_SEED, default false)"
putStrLn ""
putStrLn "password: CHUSQL_WEB_PASSWORD_HASH > CHUSQL_WEB_PASSWORD > built-in demo password"
putStrLn ("without any of them the demo account is " ++ T.unpack defaultUser ++ " / " ++ T.unpack defaultPassword)
putStrLn "storage knobs for the spawned process: CHUSQL_PAGE_SIZE / CHUSQL_BTREE_ORDER /"
putStrLn " CHUSQL_BUFFER_POOL_SIZE / CHUSQL_LOG / CHUSQL_PIPE / CHUSQL_DATA_DIR"
-- | 命令行覆盖配置
applyOptions :: WebConfig -> Options -> WebConfig
applyOptions cfg opts =
cfg
{ wcHost = orElse (optHost opts) (wcHost cfg)
, wcPort = orElse (optPort opts) (wcPort cfg)
, wcStaticDir = orElse (optStatic opts) (wcStaticDir cfg)
, wcStorageServer = case optStorage opts of
Just p -> Just p
Nothing -> wcStorageServer cfg
, wcUser = orElse (optUser opts) (wcUser cfg)
, wcCookieSecure = orElse (optCookieSecure opts) (wcCookieSecure cfg)
, wcBodyLimit = orElse (optBodyLimit opts) (wcBodyLimit cfg)
, wcSessionIdle = orElse (optSessionIdle opts) (wcSessionIdle cfg)
, wcSessionMax = orElse (optSessionMax opts) (wcSessionMax cfg)
, wcLoginMaxAttempts = orElse (optLoginMaxAttempts opts) (wcLoginMaxAttempts cfg)
, wcLoginWindow = orElse (optLoginWindow opts) (wcLoginWindow cfg)
, wcPageSize = orElse (optPageSize opts) (wcPageSize cfg)
, wcMaxPageSize = orElse (optMaxPageSize opts) (wcMaxPageSize cfg)
, wcMaxRows = orElse (optMaxRows opts) (wcMaxRows cfg)
, wcMaxSqlLength = orElse (optMaxSqlLength opts) (wcMaxSqlLength cfg)
, wcSeedDemo = orElse (optSeedDemo opts) (wcSeedDemo cfg)
}
where
-- | 开关给了用开关的,否则保留原值
orElse :: Maybe a -> a -> a
orElse (Just x) _ = x
orElse Nothing y = y
-- | 配了存储进程路径就拉起来,没配就假设外面已经跑着
startStorageIfWanted :: WebConfig -> IO (Either String (Maybe StorageProcess))
startStorageIfWanted cfg = case wcStorageServer cfg of
Nothing -> pure (Right Nothing)
Just given -> do
found <- findStorageServer (Just given)
case found of
Left e -> pure (Left e)
Right bin -> fmap (fmap Just) (startStorageProcess bin)
-- | 入口
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
args <- getArgs
case parseArgs args of
Left err -> do
putStrLn ("bad arguments: " ++ err)
usage
exitFailure
Right opts
| optHelp opts -> usage
| otherwise -> run opts
-- | 起服务
run :: Options -> IO ()
run opts = do
base <- loadWebConfig
let cfg = applyOptions base opts
foundStatic <- resolveStaticDir (wcStaticDir cfg)
case foundStatic of
Nothing -> do
putStrLn ("static directory not found; looked in: " ++ unwords (staticDirCandidates (wcStaticDir cfg)))
exitFailure
Just staticDir -> do
cred <- resolveCredential cfg
storage <- startStorageIfWanted cfg
case storage of
Left err -> do
putStrLn err
exitFailure
Right maybeStorage -> do
backend <- ipcBackend
let policy =
SessionPolicy
(fromIntegral (wcSessionIdle cfg))
(fromIntegral (wcSessionMax cfg))
sessions <- newSessionStore getCurrentTime policy
limiter <-
newRateLimiter
getCurrentTime
(wcLoginMaxAttempts cfg)
(fromIntegral (wcLoginWindow cfg))
env0 <- newAppEnv backend sessions cred limiter staticDir
let env =
env0
{ aeCookieSecure = wcCookieSecure cfg
, aeEffective = effectiveSettings cfg
, aeLog = \msg -> TIO.putStrLn ("[web] " <> msg)
}
setLive env (liveFromConfig cfg)
app <- webApp env
printBanner cfg staticDir maybeStorage
reportCredential cfg
waitForStorageReport
seedIfWanted cfg backend
let settings =
Warp.setPort (wcPort cfg) (Warp.setHost (fromString (wcHost cfg)) Warp.defaultSettings)
case maybeStorage of
Nothing -> serve settings app
Just sp -> bracket (pure sp) stopStorageProcess (const (serve settings app))
-- | 本次启动的配置摊成热改参数
liveFromConfig :: WebConfig -> Live
liveFromConfig cfg =
Live
{ lvBodyLimit = wcBodyLimit cfg
, lvSessionIdle = wcSessionIdle cfg
, lvSessionMax = wcSessionMax cfg
, lvLoginMaxAttempts = wcLoginMaxAttempts cfg
, lvLoginWindow = wcLoginWindow cfg
, lvPageSize = wcPageSize cfg
, lvMaxPageSize = wcMaxPageSize cfg
, lvMaxRows = wcMaxRows cfg
, lvMaxSqlLength = wcMaxSqlLength cfg
}
-- | 起 HTTP 服务,绑不上端口给人话
serve :: Warp.Settings -> Application -> IO ()
serve settings app = do
result <- try (Warp.runSettings settings app) :: IO (Either IOException ())
case result of
Right () -> pure ()
Left e -> do
putStrLn ("failed to serve HTTP: " ++ show e)
putStrLn " the port is probably taken or reserved by the system; try another one: --port 7778"
putStrLn " on Windows: netsh interface ipv4 show excludedportrange protocol=tcp"
exitFailure
-- | 口令是从哪来的(用了内置默认口令就明确警告)
reportCredential :: WebConfig -> IO ()
reportCredential cfg
| usingDefaultCredentials cfg = do
putStrLn ""
putStrLn " !! DEMO CREDENTIALS IN USE:"
putStrLn (" !! user " ++ T.unpack (wcUser cfg) ++ " / password " ++ T.unpack defaultPassword)
putStrLn " !! set CHUSQL_WEB_PASSWORD (or CHUSQL_WEB_PASSWORD_HASH) before exposing this server."
putStrLn ""
| otherwise = putStrLn ("password: from CHUSQL_WEB_PASSWORD(_HASH) (user " ++ T.unpack (wcUser cfg) ++ ")")
-- | 配了 seed 就补一份演示数据
seedIfWanted :: WebConfig -> Backend -> IO ()
seedIfWanted cfg backend
| not (wcSeedDemo cfg) = pure ()
| otherwise = do
result <- seedDemo backend
case result of
Left e -> TIO.putStrLn ("[web] demo data skipped: " <> T.pack e)
Right report ->
TIO.putStrLn
( "[web] demo data: "
<> (if null (srCreated report) then "already present" else "created " <> T.pack (unwords (srCreated report)))
<> (if null (srSkipped report) then "" else ", kept " <> T.pack (unwords (srSkipped report)))
<> (if null (srIndexes report) then "" else ", index on " <> T.pack (unwords (srIndexes report)))
)
-- | 报一下存储进程通不通
waitForStorageReport :: IO ()
waitForStorageReport = do
up <- waitForStorage 20
putStrLn
( if up
then "storage: up (named pipe answered ping)"
else "storage: DOWN (no ping reply; start the Rust storage process or pass --storage)"
)
-- | 启动横幅
printBanner :: WebConfig -> FilePath -> Maybe StorageProcess -> IO ()
printBanner cfg staticDir maybeStorage = do
putStrLn "==================== ChuSQL Web ===================="
putStrLn ("url: http://" ++ wcHost cfg ++ ":" ++ show (wcPort cfg) ++ "/")
putStrLn ("static dir: " ++ staticDir)
putStrLn
( "limits: body "
++ show (wcBodyLimit cfg)
++ "B, page "
++ show (wcPageSize cfg)
++ "/"
++ show (wcMaxPageSize cfg)
++ " rows, query rows "
++ show (wcMaxRows cfg)
++ ", sql "
++ show (wcMaxSqlLength cfg)
++ " chars"
)
putStrLn
( "auth: session idle "
++ show (wcSessionIdle cfg)
++ "s / max "
++ show (wcSessionMax cfg)
++ "s, lockout after "
++ show (wcLoginMaxAttempts cfg)
++ " failures in "
++ show (wcLoginWindow cfg)
++ "s"
)
case maybeStorage of
Just sp -> putStrLn ("storage proc: " ++ spPipe sp ++ " data dir " ++ spDataDir sp)
Nothing -> putStrLn "storage proc: assumed to run elsewhere (pipe name from CHUSQL_PIPE)"
+87
View File
@@ -0,0 +1,87 @@
cabal-version: 2.4
name: chusql-web
version: 1.0.0
synopsis: ChuSQL Web 管理端:Scotty + REST API + 静态页面
description: ChuSQL 的浏览器管理端。负责 HTTP 交付、鉴权、静态文件,
以及把用户 SQL 转交给 chusql-engine(再由 IPC 打到 Rust 存储进程)。
build-type: Simple
library
hs-source-dirs: src
exposed-modules: ChuSQL.Web.Actions
ChuSQL.Web.Api
ChuSQL.Web.Auth
ChuSQL.Web.Backend
ChuSQL.Web.Config
ChuSQL.Web.Demo
ChuSQL.Web.RateLimit
ChuSQL.Web.Secure
ChuSQL.Web.Settings
ChuSQL.Web.Static
ChuSQL.Web.StorageProcess
ChuSQL.Web.UiSettings
build-depends: base >= 4.14 && < 5
, aeson
, bytestring
, chusql-engine
, containers
, crypton
, directory
, filepath
, http-types
, memory
, process
, scientific
, scotty
, text
, time
, vector
, wai
default-language: Haskell2010
ghc-options: -Wall
executable chusql-web
main-is: Main.hs
hs-source-dirs: app
build-depends: base
, bytestring
, chusql-engine
, chusql-web
, crypton
, directory
, filepath
, http-types
, memory
, text
, time
, wai
, warp
default-language: Haskell2010
ghc-options: -Wall
test-suite chusql-web-test
type: exitcode-stdio-1.0
main-is: Spec.hs
hs-source-dirs: test
build-depends: base
, aeson
, bytestring
, chusql-engine
, chusql-web
, containers
, directory
, filepath
, hspec
, hspec-wai
, http-client
, http-types
, process
, text
, time
, vector
, wai
, wai-extra
, warp
default-language: Haskell2010
ghc-options: -Wall
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env node
// CLI 占位入口:功能尚未实现,仅提示用户。
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { parseArgs } from './lib/args.js';
import {
defaultConfigPath, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig,
} from './lib/config.js';
import { openBrowser } from './lib/open.js';
// ChuSQL 前端 CLI:启动开发服务器并管理本地配置。
const cyan = (text) => `\u001b[36m${text}\u001b[0m`;
const green = (text) => `\u001b[32m${text}\u001b[0m`;
const red = (text) => `\u001b[31m${text}\u001b[0m`;
const help = `Usage: chusql <command> [options]
Commands:
web Start the web server
config list List all configuration
config get <key> Get a configuration value
config set <key> <val> Set a configuration value
config unset <key> Remove a configuration value
config path Print the configuration file path
help Show this help message
Global options:
--port <n> Specify port
--host <h> Specify host
--no-open Do not open the browser automatically
--config <path> Use a custom configuration file
--adapter <mock|rest> Specify the data source adapter
--help, -h Show help
--version, -v Show version
Run "chusql <command> --help" for command-specific options.`;
function configCommand(args, configPath) {
const config = readConfig(configPath);
const [key, rawValue] = args.operands;
if (!args.action || args.action === 'list') console.log(JSON.stringify(config, null, 2));
else if (args.action === 'path') console.log(configPath);
else if (args.action === 'get') {
if (!key) throw new Error('Usage: chusql config get <key>');
const value = getAtPath(config, key);
if (value === undefined) throw new Error(`Configuration key not found: ${key}`);
console.log(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
} else if (args.action === 'set') {
if (!key || rawValue === undefined) throw new Error('Usage: chusql config set <key> <value>');
setAtPath(config, key, parseConfigValue(rawValue));
writeConfig(configPath, config);
console.log(green(`Saved ${key}.`));
} else if (args.action === 'unset') {
if (!key) throw new Error('Usage: chusql config unset <key>');
if (!unsetAtPath(config, key)) throw new Error(`Configuration key not found: ${key}`);
writeConfig(configPath, config);
console.log(green(`Removed ${key}.`));
} else throw new Error(`Unknown config command: ${args.action}`);
}
async function webCommand(args, configPath) {
const stored = readConfig(configPath);
const config = {
...stored,
...(args.port === undefined ? {} : { port: args.port }),
...(args.host === undefined ? {} : { host: args.host }),
...(args.adapter === undefined ? {} : { adapter: args.adapter }),
...(args.autoOpenBrowser === undefined ? {} : { autoOpenBrowser: args.autoOpenBrowser }),
};
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('Configured port must be an integer between 1 and 65535.');
if (!['mock', 'rest'].includes(config.adapter)) throw new Error('Configured adapter must be mock or rest.');
process.env.VITE_CHUSQL_ADAPTER = config.adapter;
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const server = await createServer({ root, server: { host: config.host, port: config.port, strictPort: true } });
await server.listen();
const url = `http://${config.host}:${config.port}/`;
console.log(`${cyan('➜')} ${green('ChuSQL Web is running at:')}`);
console.log(` ${url}`);
console.log('Press Ctrl+C to stop.');
if (config.autoOpenBrowser) openBrowser(url);
}
try {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.configPath ?? defaultConfigPath());
if (args.help || args.command === 'help') console.log(help);
else if (args.command === 'version') console.log('ChuSQL 1.0.0');
else if (args.command === 'config') configCommand(args, configPath);
else if (args.command === 'web') await webCommand(args, configPath);
else throw new Error(`Unknown command: ${args.command}`);
} catch (error) {
console.error(red(`Error: ${error instanceof Error ? error.message : String(error)}`));
process.exitCode = 1;
}
+54
View File
@@ -0,0 +1,54 @@
// 命令行参数解析:命令、动作、全局选项与取值校验。
const HOST_PATTERN = /^[A-Za-z0-9.:[\]-]+$/;
function valueAfter(argv, index, label) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${label} requires a value.`);
return value;
}
export function parseArgs(argv) {
const result = { command: 'help', action: '', operands: [], autoOpenBrowser: undefined };
let index = 0;
if (argv[index] && !argv[index].startsWith('-')) {
result.command = argv[index];
index += 1;
} else if (argv.length) {
result.command = argv[0] === '--version' || argv[0] === '-v' ? 'version' : 'help';
}
if (result.command === 'config' && argv[index] && !argv[index].startsWith('-')) {
result.action = argv[index];
index += 1;
}
while (index < argv.length) {
const token = argv[index];
if (token === '--help' || token === '-h') result.help = true;
else if (token === '--no-open') result.autoOpenBrowser = false;
else if (token === '--port') {
const raw = valueAfter(argv, index, 'Port');
if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) throw new Error('Port must be an integer between 1 and 65535.');
result.port = Number(raw);
index += 1;
} else if (token === '--host') {
const host = valueAfter(argv, index, 'Host');
if (!HOST_PATTERN.test(host)) throw new Error('Host contains invalid characters.');
result.host = host;
index += 1;
} else if (token === '--adapter') {
const adapter = valueAfter(argv, index, 'Adapter');
if (adapter !== 'mock' && adapter !== 'rest') throw new Error('Adapter must be mock or rest.');
result.adapter = adapter;
index += 1;
} else if (token === '--config') {
result.configPath = valueAfter(argv, index, 'Config');
index += 1;
} else if (token.startsWith('-')) {
throw new Error(`Unknown option: ${token}`);
} else {
result.operands.push(token);
}
index += 1;
}
return result;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseArgs } from './args.js';
// 覆盖 CLI 参数解析与非法取值拒绝。
describe('parseArgs', () => {
it('parses command-specific and global options', () => {
expect(parseArgs(['web', '--port', '9000', '--no-open', '--adapter', 'rest'])).toMatchObject({
command: 'web', port: 9000, autoOpenBrowser: false, adapter: 'rest',
});
});
it('rejects unsafe or invalid values', () => {
expect(() => parseArgs(['web', '--port', 'nope'])).toThrow('Port must be an integer');
expect(() => parseArgs(['web', '--host', 'x & calc'])).toThrow('Host contains invalid characters');
expect(() => parseArgs(['web', '--adapter', 'other'])).toThrow('Adapter must be mock or rest');
});
});
+76
View File
@@ -0,0 +1,76 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// CLI 配置文件读写:默认值、点号路径访问与类型解析。
export const DEFAULT_CONFIG = Object.freeze({
port: 5173,
host: '127.0.0.1',
autoOpenBrowser: true,
adapter: 'mock',
});
export function defaultConfigPath() {
return path.join(os.homedir(), '.chusql', 'config.json');
}
export function readConfig(file = defaultConfigPath()) {
if (!fs.existsSync(file)) {
writeConfig(file, DEFAULT_CONFIG);
return { ...DEFAULT_CONFIG };
}
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
throw new Error(`Configuration file is not valid JSON: ${file}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`Configuration must be a JSON object: ${file}`);
return { ...DEFAULT_CONFIG, ...parsed };
}
export function writeConfig(file, value) {
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
export function parseConfigValue(raw) {
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
if (raw === 'true') return true;
if (raw === 'false') return false;
return raw;
}
const FORBIDDEN_PATH_PARTS = new Set(['__proto__', 'prototype', 'constructor']);
function configPathParts(dottedPath) {
const parts = dottedPath.split('.').filter(Boolean);
if (!parts.length) throw new Error('Configuration key must not be empty.');
if (parts.some((part) => FORBIDDEN_PATH_PARTS.has(part))) throw new Error('Configuration key contains a forbidden path segment.');
return parts;
}
export function getAtPath(value, dottedPath) {
return configPathParts(dottedPath).reduce((current, part) => current && typeof current === 'object' ? current[part] : undefined, value);
}
export function setAtPath(value, dottedPath, nextValue) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object' || Array.isArray(cursor[part])) cursor[part] = {};
cursor = cursor[part];
}
cursor[parts.at(-1)] = nextValue;
}
export function unsetAtPath(value, dottedPath) {
const parts = configPathParts(dottedPath);
let cursor = value;
for (const part of parts.slice(0, -1)) {
if (!cursor[part] || typeof cursor[part] !== 'object') return false;
cursor = cursor[part];
}
return parts.length ? delete cursor[parts.at(-1)] : false;
}
@@ -0,0 +1,49 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { DEFAULT_CONFIG, getAtPath, parseConfigValue, readConfig, setAtPath, unsetAtPath, writeConfig } from './config.js';
// 覆盖 CLI 配置读写、取值解析与路径安全。
const temporaryDirectories = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
});
function temporaryConfig() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'chusql-cli-'));
temporaryDirectories.push(directory);
return path.join(directory, 'nested', 'config.json');
}
describe('CLI configuration', () => {
it('creates the formatted default configuration on first read', () => {
const file = temporaryConfig();
expect(readConfig(file)).toEqual(DEFAULT_CONFIG);
expect(fs.readFileSync(file, 'utf8')).toBe(`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
});
it('persists dotted paths and can remove them', () => {
const file = temporaryConfig();
const config = readConfig(file);
setAtPath(config, 'db.host', 'localhost');
writeConfig(file, config);
expect(getAtPath(readConfig(file), 'db.host')).toBe('localhost');
expect(unsetAtPath(config, 'db.host')).toBe(true);
expect(getAtPath(config, 'db.host')).toBeUndefined();
});
it('parses numbers and booleans without changing ordinary text', () => {
expect(parseConfigValue('5173')).toBe(5173);
expect(parseConfigValue('true')).toBe(true);
expect(parseConfigValue('rest')).toBe('rest');
});
it('rejects prototype-polluting configuration paths', () => {
const config = {};
expect(() => setAtPath(config, '__proto__.polluted', true)).toThrow('forbidden path segment');
expect({}.polluted).toBeUndefined();
});
});
+11
View File
@@ -0,0 +1,11 @@
import { spawn } from 'node:child_process';
// 用系统默认浏览器打开本地地址,并校验 URL 合法性。
export function openBrowser(url) {
if (!/^http:\/\/[A-Za-z0-9.:[\]-]+:\d+\/$/.test(url)) throw new Error('Refusing to open an invalid local URL.');
const command = process.platform === 'win32' ? 'cmd.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'start', '', url] : [url];
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
child.unref();
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<!-- ChuSQL 前端页面壳:挂载根节点并加载入口脚本。 -->
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>ChuSQL 数据库终端</title>
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "chusql-web-ide",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "ChuSQL database Web IDE and command-line launcher",
"bin": {
"chusql": "./bin/chusql.js",
"chusql-cli": "./bin/chusql-cli.js"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run test && npm run build"
},
"dependencies": {
"lucide-react": "0.468.0",
"monaco-editor": "0.52.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"vite": "8.3.1",
"zustand": "5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@types/node": "22.20.4",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "6.1.1",
"jsdom": "25.0.1",
"typescript": "5.7.2",
"vitest": "5.0.2"
},
"engines": {
"node": ">=20.19"
}
}
+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;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts", "vitest.config.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Vite 配置:React 插件、Monaco nonce 注入与构建输出。
export default defineConfig({
plugins: [react(), {
name: 'monaco-style-nonce',
enforce: 'pre',
transform(code, id) {
if (!id.split('\\').join('/').includes('/monaco-editor/esm/')) return;
const source = "document.createElement('style')";
if (!code.includes(source)) return;
return code.split(source).join(`Object.assign(document.createElement('style'), { nonce: document.querySelector('meta[name="chusql-style-nonce"]')?.content || '' })`);
},
}],
base: '/static/',
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
outDir: '../static',
emptyOutDir: true,
assetsDir: '',
rollupOptions: {
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: (asset) => asset.names?.some((name) => name.endsWith('.css')) ? 'style.css' : '[name][extname]',
},
},
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
// Vitest 配置:jsdom 环境与测试初始化脚本。
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
restoreMocks: true,
},
});
+237
View File
@@ -0,0 +1,237 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Actions (
ColumnSpec (..),
CreateTableSpec (..),
isIdentifier,
columnTypeOf,
columnTypeName,
coerceValue,
sqlLiteral,
escapeStringLiteral,
createTableSql,
dropTableSql,
createIndexSql,
dropIndexSql,
dropColumnSql,
insertRowSql,
insertRowsSql,
updateRowSql,
deleteRowSql,
selectRowsSql,
) where
import ChuSQL.Model (Column (..), Value (..))
import qualified Data.Aeson as A
import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.List (intercalate)
import Data.Scientific (floatingOrInteger)
import Data.Text (Text)
import qualified Data.Text as T
import Text.Read (readMaybe)
-- 把界面上的一键操作翻译成安全 SQL:标识符过白名单,值一律转义。
data ColumnSpec = ColumnSpec
{ csName :: Text
, csType :: Text
}
deriving (Show, Eq)
data CreateTableSpec = CreateTableSpec
{ ctsTable :: Text
, ctsColumns :: [ColumnSpec]
}
deriving (Show, Eq)
-- | 表名/列名的白名单
isIdentifier :: Text -> Bool
isIdentifier t =
not (T.null t)
&& T.length t <= 64
&& case T.unpack t of
(c : cs) -> (isAsciiLower c || isAsciiUpper c || c == '_') && all restChar cs
[] -> False
where
restChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '_'
-- | 检查一个标识符,不合法就报错(带上"是表名还是列名")
checkIdent :: Text -> Text -> Either String Text
checkIdent what name
| isIdentifier name = Right name
| otherwise = Left (T.unpack what ++ " name is not a plain identifier: " ++ T.unpack name)
-- | 列表不能空
requireNonEmpty :: String -> [a] -> Either String [a]
requireNonEmpty what xs
| null xs = Left (what ++ " must not be empty")
| otherwise = Right xs
-- | 线上列类型名(和数据字典里的写法一致)转本地类型
columnTypeOf :: Text -> Maybe Column
columnTypeOf t = case T.toLower (T.strip t) of
"int" -> Just TInt
"integer" -> Just TInt
"str" -> Just TStr
"text" -> Just TStr
"varchar" -> Just TStr
"bool" -> Just TBool
"boolean" -> Just TBool
_ -> Nothing
-- | 本地类型转线上名字
columnTypeName :: Column -> Text
columnTypeName TInt = "int"
columnTypeName TStr = "str"
columnTypeName TBool = "bool"
-- | 字符串字面量转义:单引号翻倍(引擎的词法器就是这么折回去的)
escapeStringLiteral :: String -> String
escapeStringLiteral = concatMap (\c -> if c == '\'' then "''" else [c])
-- | 值转 SQL 字面量
sqlLiteral :: Value -> String
sqlLiteral (VInt n) = show n
sqlLiteral (VStr s) = "'" ++ escapeStringLiteral s ++ "'"
sqlLiteral (VBool True) = "TRUE"
sqlLiteral (VBool False) = "FALSE"
-- | JSON 值按列类型转成引擎的值;类型对不上就报错
coerceValue :: Column -> A.Value -> Either String Value
coerceValue TInt v = case v of
A.Number n -> case floatingOrInteger n :: Either Double Integer of
Right i -> Right (VInt (fromIntegral i))
Left _ -> Left "expected an integer"
A.String s -> case readMaybe (T.unpack (T.strip s)) :: Maybe Int of
Just i -> Right (VInt i)
Nothing -> Left ("expected an integer, got: " ++ T.unpack s)
_ -> Left "expected an integer"
coerceValue TStr v = case v of
A.String s -> Right (VStr (T.unpack s))
_ -> Left "expected a string"
coerceValue TBool v = case v of
A.Bool b -> Right (VBool b)
A.String s -> case T.toLower (T.strip s) of
"true" -> Right (VBool True)
"false" -> Right (VBool False)
_ -> Left ("expected true/false, got: " ++ T.unpack s)
_ -> Left "expected true/false"
-- | 构造 CREATE TABLE 语句
createTableSql :: CreateTableSpec -> Either String String
createTableSql spec = do
tbl <- checkIdent "table" (ctsTable spec)
cols <- requireNonEmpty "columns" (ctsColumns spec)
defs <- mapM columnDef cols
pure ("CREATE TABLE " ++ T.unpack tbl ++ " (" ++ intercalate ", " defs ++ ")")
where
columnDef (ColumnSpec name ty) = do
col <- checkIdent "column" name
ctype <- maybe (Left ("unknown column type: " ++ T.unpack ty)) Right (columnTypeOf ty)
pure (T.unpack col ++ " " ++ T.unpack (columnTypeName ctype))
-- | 构造 DROP TABLE 语句
dropTableSql :: Text -> Either String String
dropTableSql table = do
tbl <- checkIdent "table" table
pure ("DROP TABLE " ++ T.unpack tbl)
-- | 构造 CREATE INDEX 语句
createIndexSql :: Text -> Text -> Either String String
createIndexSql table column = do
tbl <- checkIdent "table" table
col <- checkIdent "column" column
pure ("CREATE INDEX ON " ++ T.unpack tbl ++ " (" ++ T.unpack col ++ ")")
-- | 构造 DROP INDEX 语句
dropIndexSql :: Text -> Text -> Either String String
dropIndexSql table column = do
tbl <- checkIdent "table" table
col <- checkIdent "column" column
pure ("DROP INDEX ON " ++ T.unpack tbl ++ " (" ++ T.unpack col ++ ")")
-- | 删列:列定义、索引与数据一起没
dropColumnSql :: Text -> Text -> Either String String
dropColumnSql table column = do
tbl <- checkIdent "table" table
col <- checkIdent "column" column
pure ("ALTER TABLE " ++ T.unpack tbl ++ " DROP COLUMN " ++ T.unpack col)
-- | 构造单行 INSERT 语句
insertRowSql :: Text -> [(String, Value)] -> Either String String
insertRowSql table row = do
tbl <- checkIdent "table" table
cols <- requireNonEmpty "values" row
names <- mapM (checkIdent "column" . T.pack . fst) cols
pure
( "INSERT INTO "
++ T.unpack tbl
++ " ("
++ intercalate ", " (map T.unpack names)
++ ") VALUES ("
++ intercalate ", " (map (sqlLiteral . snd) cols)
++ ")"
)
-- | 一次插多行,N 行只落一次盘
insertRowsSql :: Text -> [[(String, Value)]] -> Either String String
insertRowsSql table rows = do
tbl <- checkIdent "table" table
rs <- requireNonEmpty "rows" rows
case rs of
[] -> Left "rows must not be empty"
(first : _) -> do
names <- mapM (checkIdent "column" . T.pack . fst) first
let widths = map length rs
if any (/= length first) widths
then Left "all rows must have the same columns"
else
pure
( "INSERT INTO "
++ T.unpack tbl
++ " ("
++ intercalate ", " (map T.unpack names)
++ ") VALUES "
++ intercalate ", " ["(" ++ intercalate ", " (map (sqlLiteral . snd) r) ++ ")" | r <- rs]
)
-- | 构造按 id 改行的 UPDATE 语句
updateRowSql :: Text -> Int -> [(String, Value)] -> Either String String
updateRowSql table key assigns = do
tbl <- checkIdent "table" table
cols <- requireNonEmpty "values" assigns
sets <- mapM (\(n, v) -> (\c -> T.unpack c ++ " = " ++ sqlLiteral v) <$> checkIdent "column" (T.pack n)) cols
pure ("UPDATE " ++ T.unpack tbl ++ " SET " ++ intercalate ", " sets ++ " WHERE id = " ++ show key)
-- | 按 id 删一行,存储层原地删
deleteRowSql :: Text -> Int -> Either String String
deleteRowSql table key = do
tbl <- checkIdent "table" table
pure ("DELETE FROM " ++ T.unpack tbl ++ " WHERE id = " ++ show key)
-- | 浏览一张表的 SELECT,带排序与等值过滤
selectRowsSql :: Text -> Maybe (Text, Bool) -> [(Text, Value)] -> Either String String
selectRowsSql table order filters = do
tbl <- checkIdent "table" table
whereSql <- whereClause filters
orderSql <- orderClause order
pure ("SELECT * FROM " ++ T.unpack tbl ++ whereSql ++ orderSql)
-- | 拼 WHERE 子句,无条件给空串
whereClause :: [(Text, Value)] -> Either String String
whereClause filters = case filters of
[] -> pure ""
_ -> do
terms <- mapM term filters
pure (" WHERE " ++ intercalate " AND " terms)
where
term (column, value) = do
col <- checkIdent "column" column
pure (T.unpack col ++ " = " ++ sqlLiteral value)
-- | 拼 ORDER BY 子句,无排序给空串
orderClause :: Maybe (Text, Bool) -> Either String String
orderClause Nothing = pure ""
orderClause (Just (column, ascending)) = do
col <- checkIdent "column" column
pure (" ORDER BY " ++ T.unpack col ++ (if ascending then " ASC" else " DESC"))
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Auth (
Credential (..),
defaultIterations,
hashPassword,
hashPasswordWith,
hashLooksValid,
verifyPassword,
SessionPolicy (..),
defaultSessionPolicy,
SessionStore,
newSessionStore,
sessionPolicy,
setSessionPolicy,
createSession,
lookupSession,
deleteSession,
deleteOtherSessions,
sessionToken,
) where
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newMVar)
import Crypto.Hash (SHA256 (..))
import qualified Crypto.KDF.PBKDF2 as PBKDF2
import Crypto.Random (getRandomBytes)
import qualified Data.ByteString as BS
import Data.ByteArray (constEq)
import Data.ByteArray.Encoding (Base (Base16), convertFromBase, convertToBase)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import qualified Data.Map.Strict as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
import Text.Read (readMaybe)
-- 口令与 Session:口令只存 PBKDF2 编码串,会话是服务端内存状态。
data Credential = Credential
{ credUser :: Text
, credEncoded :: Text
}
deriving (Show, Eq)
-- | 默认迭代次数(10 万次)
defaultIterations :: Int
defaultIterations = 100000
-- | 派生密钥长度(字节)
keyLength :: Int
keyLength = 32
-- | 盐长度(字节)
saltLength :: Int
saltLength = 16
-- | 迭代次数的上界:配置串被人改坏时不至于把自己算死
maxIterations :: Int
maxIterations = 10000000
-- | PBKDF2-HMAC-SHA256 派生
deriveKey :: Int -> BS.ByteString -> BS.ByteString -> BS.ByteString
deriveKey iters salt password =
PBKDF2.generate (PBKDF2.prfHMAC SHA256) (PBKDF2.Parameters iters keyLength) password salt
-- | 字节转十六进制
hexEncode :: BS.ByteString -> Text
hexEncode = TE.decodeUtf8 . convertToBase Base16
-- | 十六进制转字节(长度不对/有非法字符就给 Nothing)
hexDecode :: Text -> Maybe BS.ByteString
hexDecode t = case convertFromBase Base16 (TE.encodeUtf8 t) of
Left _ -> Nothing
Right bs -> Just bs
-- | 编码成 `pbkdf2-sha256$次数$盐$哈希`
encodeHash :: Int -> BS.ByteString -> BS.ByteString -> Text
encodeHash iters salt digest =
T.intercalate "$" ["pbkdf2-sha256", T.pack (show iters), hexEncode salt, hexEncode digest]
-- | 解回 (次数, 盐, 哈希),坏格式给 Nothing
parseHash :: Text -> Maybe (Int, BS.ByteString, BS.ByteString)
parseHash t = case T.splitOn "$" t of
[alg, itersT, saltT, digestT]
| alg == "pbkdf2-sha256" -> do
iters <- readMaybe (T.unpack itersT) :: Maybe Int
salt <- hexDecode saltT
digest <- hexDecode digestT
if iters < 1000 || iters > maxIterations || BS.null salt || BS.length digest /= keyLength
then Nothing
else Just (iters, salt, digest)
_ -> Nothing
-- | 用给定盐生成编码串(给定盐是为了能写确定性测试)
hashPasswordWith :: Int -> BS.ByteString -> Text -> Text
hashPasswordWith iters salt password =
encodeHash iters salt (deriveKey iters salt (TE.encodeUtf8 password))
-- | 给一个口令配随机盐,生成编码串
hashPassword :: Text -> IO Text
hashPassword password = do
salt <- getRandomBytes saltLength
pure (hashPasswordWith defaultIterations salt password)
-- | 恒定时间校验口令是否匹配
verifyPassword :: Text -> Text -> Bool
verifyPassword encoded candidate = case parseHash encoded of
Nothing -> False
Just (iters, salt, digest) -> constEq digest (deriveKey iters salt (TE.encodeUtf8 candidate))
-- | 编码串本身是否合法(不看口令)
hashLooksValid :: Text -> Bool
hashLooksValid = isJust . parseHash
data Session = Session
{ sessUser :: Text
, sessCreated :: UTCTime
, sessSeen :: UTCTime
}
data SessionPolicy = SessionPolicy
{ spIdleSeconds :: NominalDiffTime
, spAbsoluteSeconds :: NominalDiffTime
}
-- | 默认:空闲 8 小时、绝对 24 小时
defaultSessionPolicy :: SessionPolicy
defaultSessionPolicy = SessionPolicy (8 * 3600) (24 * 3600)
data SessionStore = SessionStore
{ storeMap :: MVar (Map.Map Text Session)
, storeClock :: IO UTCTime
, storePolicy :: IORef SessionPolicy
}
-- | 造一个空仓库
newSessionStore :: IO UTCTime -> SessionPolicy -> IO SessionStore
newSessionStore clock policy = do
ref <- newMVar Map.empty
policyRef <- newIORef policy
pure (SessionStore ref clock policyRef)
-- | 现在用的是哪套过期策略
sessionPolicy :: SessionStore -> IO SessionPolicy
sessionPolicy = readIORef . storePolicy
-- | 换一套过期策略(设置页热改)
setSessionPolicy :: SessionStore -> SessionPolicy -> IO ()
setSessionPolicy store = writeIORef (storePolicy store)
-- | 这条会话过期了吗
expired :: UTCTime -> SessionPolicy -> Session -> Bool
expired now pol s =
diffUTCTime now (sessSeen s) > spIdleSeconds pol
|| diffUTCTime now (sessCreated s) > spAbsoluteSeconds pol
-- | 清掉过期会话
prune :: UTCTime -> SessionPolicy -> Map.Map Text Session -> Map.Map Text Session
prune now pol = Map.filter (not . expired now pol)
-- | 随机令牌:32 字节,十六进制 64 个字符
sessionToken :: IO Text
sessionToken = hexEncode <$> getRandomBytes 32
-- | 开一条会话,返回令牌
createSession :: SessionStore -> Text -> IO Text
createSession store user = do
token <- sessionToken
now <- storeClock store
pol <- sessionPolicy store
modifyMVar_ (storeMap store) $ \m ->
pure (Map.insert token (Session user now now) (prune now pol m))
pure token
-- | 查令牌并刷新最后活动时间
lookupSession :: SessionStore -> Text -> IO (Maybe Text)
lookupSession store token = do
now <- storeClock store
pol <- sessionPolicy store
modifyMVar (storeMap store) $ \m -> do
let kept = prune now pol m
pure $ case Map.lookup token kept of
Nothing -> (kept, Nothing)
Just s -> (Map.insert token s{sessSeen = now} kept, Just (sessUser s))
-- | 删掉一条会话(退出登录)
deleteSession :: SessionStore -> Text -> IO ()
deleteSession store token = modifyMVar_ (storeMap store) (pure . Map.delete token)
-- | 删掉除这条外的全部会话,返回条数
deleteOtherSessions :: SessionStore -> Text -> IO Int
deleteOtherSessions store keep = do
removed <- modifyMVar (storeMap store) $ \m -> do
let kept = Map.filterWithKey (\k _ -> k == keep) m
pure (kept, Map.size m - Map.size kept)
pure removed
+125
View File
@@ -0,0 +1,125 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Backend (
StatementResult (..),
Backend (..),
columnsFromStatement,
ipcBackend,
memoryBackend,
tableInfoOf,
) where
import ChuSQL.Engine (runStatement, runStatementM)
import ChuSQL.Model
import ChuSQL.Storage (MonadStorage (schema))
import ChuSQL.Storage.IPC (
IPCStorage (..),
Request (..),
Response (..),
SchemaColumn (..),
TableInfo (..),
doListCatalog,
sendRequest,
)
import ChuSQL.Syntax.AST
import ChuSQL.Syntax.Parser (parseStatement)
import Control.Concurrent.MVar (MVar, modifyMVar, newMVar, readMVar, withMVar)
import Data.List (nub)
-- 后端:跑一条语句与取数据字典,分 IPC(真存储)和内存(测试)两套。
data StatementResult = StatementResult
{ srColumns :: [String]
, srRows :: [Row]
}
data Backend = Backend
{ beStatement :: String -> IO (Either String StatementResult)
, beCatalog :: IO (Either String [TableInfo])
, bePing :: IO Bool
}
-- | 按给定列名取列,固定列顺序
columnsFromStatement :: Database -> Statement -> [String]
columnsFromStatement db stmt = case stmt of
Select cols from _ _ _
| cols == [allColumns] -> wildcard from
| otherwise -> cols
_ -> []
where
-- | 展开 *:单表按列序,JOIN 左表在前
wildcard (FromTable mAlias tbl) = case lookup tbl db of
Nothing -> []
Just t -> [qualify mAlias c | c <- colNames t]
wildcard (FromJoin left mAlias tbl _) = wildcard left ++ wildcard (FromTable mAlias tbl)
-- | 走命名管道的后端(真存储)
ipcBackend :: IO Backend
ipcBackend = do
lock <- newMVar ()
pure
Backend
{ beStatement = \sql -> withMVar lock $ \_ -> runIpc sql
, beCatalog = withMVar lock $ \_ -> do
result <- doListCatalog
pure (either Left Right result)
, bePing = withMVar lock $ \_ -> do
resp <- sendRequest ReqPing
pure (case resp of RespPong -> True; _ -> False)
}
where
-- | 解析并执行,空结果时补一次表头
runIpc :: String -> IO (Either String StatementResult)
runIpc sql = case parseStatement sql of
Left e -> pure (Left e)
Right stmt -> do
result <- runIPCStorage (runStatementM stmt)
case result of
Left e -> pure (Left e)
Right rows -> do
cols <- case rows of
[] -> do
db <- runIPCStorage (schema :: IPCStorage Database)
pure (columnsFromStatement db stmt)
(r : _) -> pure (map fst r)
pure (Right (StatementResult cols rows))
-- | 内存后端(测试):状态就是一个 `Database`
memoryBackend :: MVar Database -> Backend
memoryBackend ref =
Backend
{ beStatement = \sql -> modifyMVar ref $ \db -> case parseStatement sql of
Left e -> pure (db, Left e)
Right stmt -> case runStatement db stmt of
Left e -> pure (db, Left e)
Right (db', rows) -> pure (db', Right (StatementResult (colsOf db stmt rows) rows))
, beCatalog = do
db <- readMVar ref
pure (Right (map tableInfoOf db))
, bePing = pure True
}
where
-- | 有行用行上的键,空结果回退语法树
colsOf db stmt rows = case rows of
[] -> columnsFromStatement db stmt
(r : _) -> map fst r
-- | 一张表的线上信息(内存实现:没有索引,统计现算)
tableInfoOf :: (String, Table) -> TableInfo
tableInfoOf (name, tbl) =
TableInfo
{ tiTable = name
, tiColumns = [SchemaColumn c (wireType ty) | (c, ty) <- tableCols tbl]
, tiRows = length (tableRows tbl)
, tiIndexes = []
, tiStats = [(c, distinctOf c, False) | (c, _) <- tableCols tbl]
}
where
-- | 某一列有几个不同值
distinctOf c = length (nub [v | r <- tableRows tbl, Just v <- [lookup c r]])
-- | 本地列类型转线上字符串(和 IPC 那边同一套写法)
wireType :: Column -> String
wireType TInt = "int"
wireType TStr = "str"
wireType TBool = "bool"
+187
View File
@@ -0,0 +1,187 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Config (
WebConfig (..),
defaultWebConfig,
defaultUser,
defaultPassword,
usingDefaultCredentials,
loadWebConfig,
resolveCredential,
resolveStaticDir,
staticDirCandidates,
) where
import ChuSQL.Web.Auth (Credential (..), hashPassword)
import Data.Char (toLower)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesDirectoryExist)
import System.Environment (lookupEnv)
import Text.Read (readMaybe)
-- 运行参数:命令行 > 环境变量 > 内置默认;默认只听本机 127.0.0.1:7777。
-- | 默认账号名
defaultUser :: Text
defaultUser = "root"
-- | 默认口令(只为方便本地测试;生产/对外必须覆盖)
defaultPassword :: Text
defaultPassword = "chusql"
data WebConfig = WebConfig
{ wcHost :: String
, wcPort :: Int
, wcStaticDir :: FilePath
, wcUser :: Text
, wcPasswordHash :: Maybe Text
, wcPassword :: Maybe Text
, wcStorageServer :: Maybe FilePath
, wcPipeName :: Maybe String
, wcDataDir :: Maybe FilePath
, wcCookieSecure :: Bool
, wcBodyLimit :: Int
, wcSessionIdle :: Int
, wcSessionMax :: Int
, wcLoginMaxAttempts :: Int
, wcLoginWindow :: Int
, wcPageSize :: Int
, wcMaxPageSize :: Int
, wcMaxRows :: Int
, wcMaxSqlLength :: Int
, wcSeedDemo :: Bool
}
deriving (Show, Eq)
-- | 内置默认
defaultWebConfig :: WebConfig
defaultWebConfig =
WebConfig
{ wcHost = "127.0.0.1"
, wcPort = 7777
, wcStaticDir = "static"
, wcUser = defaultUser
, wcPasswordHash = Nothing
, wcPassword = Nothing
, wcStorageServer = Nothing
, wcPipeName = Nothing
, wcDataDir = Nothing
, wcCookieSecure = False
, wcBodyLimit = 65536
, wcSessionIdle = 8 * 3600
, wcSessionMax = 24 * 3600
, wcLoginMaxAttempts = 5
, wcLoginWindow = 5 * 60
, wcPageSize = 25
, wcMaxPageSize = 500
, wcMaxRows = 1000
, wcMaxSqlLength = 20000
, wcSeedDemo = False
}
-- | 这份配置用的还是内置默认口令吗
usingDefaultCredentials :: WebConfig -> Bool
usingDefaultCredentials cfg = wcPasswordHash cfg == Nothing && wcPassword cfg == Nothing
-- | 备一份凭据:哈希 > 明文 > 内置默认
resolveCredential :: WebConfig -> IO Credential
resolveCredential cfg = case (wcPasswordHash cfg, wcPassword cfg) of
(Just encoded, _) -> pure (Credential (wcUser cfg) encoded)
(Nothing, Just plain) -> do
encoded <- hashPassword plain
pure (Credential (wcUser cfg) encoded)
(Nothing, Nothing) -> do
encoded <- hashPassword defaultPassword
pure (Credential (wcUser cfg) encoded)
-- | 从环境变量读配置,缺省用内置默认
loadWebConfig :: IO WebConfig
loadWebConfig = do
let d = defaultWebConfig
host <- envOr "CHUSQL_WEB_HOST" (wcHost d)
port <- envInt "CHUSQL_WEB_PORT" (wcPort d)
staticDir <- envOr "CHUSQL_WEB_STATIC" (wcStaticDir d)
user <- T.pack <$> envOr "CHUSQL_WEB_USER" (T.unpack (wcUser d))
passwordHash <- envText "CHUSQL_WEB_PASSWORD_HASH"
password <- envText "CHUSQL_WEB_PASSWORD"
server <- envMaybe "CHUSQL_STORAGE_SERVER"
pipe <- envMaybe "CHUSQL_PIPE"
dataDir <- envMaybe "CHUSQL_DATA_DIR"
secure <- envBool "CHUSQL_WEB_COOKIE_SECURE" (wcCookieSecure d)
limit <- envInt "CHUSQL_WEB_BODY_LIMIT" (wcBodyLimit d)
idle <- envInt "CHUSQL_WEB_SESSION_IDLE" (wcSessionIdle d)
sessionMax <- envInt "CHUSQL_WEB_SESSION_MAX" (wcSessionMax d)
loginMax <- envInt "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" (wcLoginMaxAttempts d)
loginWindow <- envInt "CHUSQL_WEB_LOGIN_WINDOW" (wcLoginWindow d)
pageSize <- envInt "CHUSQL_WEB_PAGE_SIZE" (wcPageSize d)
maxPageSize <- envInt "CHUSQL_WEB_MAX_PAGE_SIZE" (wcMaxPageSize d)
maxRows <- envInt "CHUSQL_WEB_MAX_ROWS" (wcMaxRows d)
maxSqlLength <- envInt "CHUSQL_WEB_MAX_SQL_LENGTH" (wcMaxSqlLength d)
seedDemo <- envBool "CHUSQL_WEB_SEED" (wcSeedDemo d)
pure
WebConfig
{ wcHost = host
, wcPort = port
, wcStaticDir = staticDir
, wcUser = user
, wcPasswordHash = passwordHash
, wcPassword = password
, wcStorageServer = server
, wcPipeName = pipe
, wcDataDir = dataDir
, wcCookieSecure = secure
, wcBodyLimit = limit
, wcSessionIdle = idle
, wcSessionMax = sessionMax
, wcLoginMaxAttempts = loginMax
, wcLoginWindow = loginWindow
, wcPageSize = pageSize
, wcMaxPageSize = maxPageSize
, wcMaxRows = maxRows
, wcMaxSqlLength = maxSqlLength
, wcSeedDemo = seedDemo
}
-- | 静态目录候选位置,按顺序取第一个存在的
staticDirCandidates :: FilePath -> [FilePath]
staticDirCandidates given =
[given, "static", "chusql-web/static", "../chusql-web/static", "../static"]
-- | 找出真正能用的静态目录
resolveStaticDir :: FilePath -> IO (Maybe FilePath)
resolveStaticDir given = go (staticDirCandidates given)
where
go [] = pure Nothing
go (d : ds) = do
ok <- doesDirectoryExist d
if ok then pure (Just d) else go ds
-- | 读一个字符串,缺省给默认值
envOr :: String -> String -> IO String
envOr name def = fromMaybe def <$> lookupEnv name
-- | 读一个环境变量(空串当没设)
envMaybe :: String -> IO (Maybe String)
envMaybe name = do
v <- lookupEnv name
pure (case v of Just s | not (null s) -> Just s; _ -> Nothing)
-- | 读成 Text
envText :: String -> IO (Maybe Text)
envText name = fmap (fmap T.pack) (envMaybe name)
-- | 读成整数,解析不出来就用默认值
envInt :: String -> Int -> IO Int
envInt name def = do
v <- envMaybe name
pure (fromMaybe def (v >>= \s -> readMaybe s))
-- | 读成布尔(1/true/yes/on 都算真)
envBool :: String -> Bool -> IO Bool
envBool name def = do
v <- envMaybe name
pure $ case v of
Nothing -> def
Just s -> map toLower s `elem` ["1", "true", "yes", "on"]
+166
View File
@@ -0,0 +1,166 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Demo (
SeedReport (..),
demoTableSpecs,
demoRows,
demoIndexes,
demoNames,
demoPlan,
seedDemo,
) where
import ChuSQL.Model (Value (..))
import ChuSQL.Storage.IPC (TableInfo (..))
import ChuSQL.Web.Actions (
ColumnSpec (..),
CreateTableSpec (..),
createIndexSql,
createTableSql,
insertRowsSql,
)
import ChuSQL.Web.Backend (Backend (..))
import qualified Data.Text as T
-- 演示数据:三张表加一个二级索引,语句与界面走同一套拼 SQL 的代码。
-- | 演示表(建表顺序即依赖顺序;没有外键约束,纯展示)
demoTableSpecs :: [CreateTableSpec]
demoTableSpecs =
[ CreateTableSpec "users" [ColumnSpec "id" "int", ColumnSpec "name" "str", ColumnSpec "age" "int"]
, CreateTableSpec "orders" [ColumnSpec "id" "int", ColumnSpec "user_id" "int", ColumnSpec "product" "str", ColumnSpec "amount" "int"]
, CreateTableSpec "products" [ColumnSpec "id" "int", ColumnSpec "name" "str", ColumnSpec "sku" "int", ColumnSpec "price" "int"]
]
-- | 每张表的演示行
demoRows :: [(T.Text, [[(String, Value)]])]
demoRows =
[ ( "users"
, [ [("id", VInt i), ("name", VStr name), ("age", VInt age)]
| (i, (name, age)) <- zip [1 :: Int ..] userRecords
]
)
, ( "orders"
, [ [("id", VInt i), ("user_id", VInt uid), ("product", VStr p), ("amount", VInt a)]
| (i, (uid, p, a)) <- zip [1 :: Int ..] orderRecords
]
)
, ( "products"
, [ [("id", VInt i), ("name", VStr n), ("sku", VInt sku), ("price", VInt price)]
| (i, (n, sku, price)) <- zip [1 :: Int ..] productRecords
]
)
]
-- | 演示索引:products.sku 取值唯一
demoIndexes :: [(T.Text, T.Text)]
demoIndexes = [("products", "sku")]
-- | 演示数据涉及的表名
demoNames :: [T.Text]
demoNames = map ctsTable demoTableSpecs
-- | 完整的一套语句(建表 + 插行 + 建索引),按顺序执行
demoPlan :: Either String [String]
demoPlan = do
creates <- mapM createTableSql demoTableSpecs
inserts <- mapM (uncurry insertRowsSql) demoRows
indexes <- mapM (uncurry createIndexSql) demoIndexes
pure (creates ++ inserts ++ indexes)
data SeedReport = SeedReport
{ srCreated :: [String]
, srSkipped :: [String]
, srIndexes :: [String]
}
deriving (Show, Eq)
-- | 缺什么补什么,可反复点不重复灌
seedDemo :: Backend -> IO (Either String SeedReport)
seedDemo backend = do
catalog <- beCatalog backend
case catalog of
Left e -> pure (Left e)
Right infos -> do
let existingTables = map tiTable infos
missing = [spec | spec <- demoTableSpecs, T.unpack (ctsTable spec) `notElem` existingTables]
skipped = [T.unpack (ctsTable spec) | spec <- demoTableSpecs, T.unpack (ctsTable spec) `elem` existingTables]
missingNames = map ctsTable missing
wantedIndexes =
[ (t, c)
| (t, c) <- demoIndexes
, t `elem` missingNames || indexMissing infos t c
]
created <- runAll (map createTableSql missing)
case created of
Left e -> pure (Left e)
Right () -> do
rowsDone <- runAll [insertRowsSql t rs | (t, rs) <- demoRows, t `elem` missingNames]
case rowsDone of
Left e -> pure (Left e)
Right () -> do
indexes <-
runAll [createIndexSql t c | (t, c) <- wantedIndexes]
pure $
fmap
(const (SeedReport (map T.unpack missingNames) skipped (map (T.unpack . snd) wantedIndexes)))
indexes
where
-- | 逐条执行,遇到错就停
runAll :: [Either String String] -> IO (Either String ())
runAll [] = pure (Right ())
runAll (sql : rest) = case sql of
Left e -> pure (Left e)
Right statement -> do
result <- beStatement backend statement
case result of
Left e -> pure (Left e)
Right _ -> runAll rest
-- | 这张表上是否还没有这个索引
indexMissing :: [TableInfo] -> T.Text -> T.Text -> Bool
indexMissing infos t c = case lookup (T.unpack t) [(tiTable i, tiIndexes i) | i <- infos] of
Nothing -> True
Just cols -> T.unpack c `notElem` cols
userRecords :: [(String, Int)]
userRecords =
[ ("Alice", 24)
, ("Bob", 31)
, ("Carol", 45)
, ("Dave", 19)
, ("Erin", 38)
, ("Frank", 52)
, ("Grace", 27)
, ("Heidi", 33)
, ("Ivan", 41)
, ("Judy", 29)
]
orderRecords :: [(Int, String, Int)]
orderRecords =
[ (1, "Laptop", 1299)
, (1, "Mouse", 25)
, (2, "Keyboard", 79)
, (3, "Monitor", 349)
, (3, "Webcam", 89)
, (4, "Headset", 119)
, (5, "SSD", 139)
, (5, "RAM", 99)
, (6, "Dock", 199)
, (7, "Cable", 12)
, (8, "Stand", 45)
, (9, "Lamp", 39)
]
productRecords :: [(String, Int, Int)]
productRecords =
[ ("Laptop", 1001, 1299)
, ("Mouse", 1002, 25)
, ("Keyboard", 1003, 79)
, ("Monitor", 1004, 349)
, ("Webcam", 1005, 89)
, ("Headset", 1006, 119)
, ("SSD", 1007, 139)
, ("RAM", 1008, 99)
]
+72
View File
@@ -0,0 +1,72 @@
module ChuSQL.Web.RateLimit (
RateLimiter,
newRateLimiter,
setRateLimit,
rateLimitBlock,
rateLimitRecord,
rateLimitClear,
) where
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, readMVar)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime)
-- 登录失败限流:按用户名数一个滑动窗口里的失败次数。
data Attempt = Attempt
{ attCount :: Int
, attStart :: UTCTime
}
data RateLimitPolicy = RateLimitPolicy
{ rlpMax :: Int
, rlpWindow :: NominalDiffTime
}
data RateLimiter = RateLimiter
{ rlMap :: MVar (Map.Map Text Attempt)
, rlPolicy :: IORef RateLimitPolicy
, rlClock :: IO UTCTime
}
-- | 造一个限流器:多少次失败、窗口多长
newRateLimiter :: IO UTCTime -> Int -> NominalDiffTime -> IO RateLimiter
newRateLimiter clock maxFailures window = do
ref <- newMVar Map.empty
policyRef <- newIORef (RateLimitPolicy maxFailures window)
pure (RateLimiter ref policyRef clock)
-- | 换一套限流阈值(设置页热改)
setRateLimit :: RateLimiter -> Int -> NominalDiffTime -> IO ()
setRateLimit rl maxFailures window =
writeIORef (rlPolicy rl) (RateLimitPolicy maxFailures window)
-- | 这个键被锁住了吗?锁住就返回还要等多少秒
rateLimitBlock :: RateLimiter -> Text -> IO (Maybe NominalDiffTime)
rateLimitBlock rl key = do
now <- rlClock rl
pol <- readIORef (rlPolicy rl)
m <- readMVar (rlMap rl)
pure $ case Map.lookup key m of
Just a
| attCount a >= rlpMax pol ->
let left = rlpWindow pol - diffUTCTime now (attStart a)
in if left > 0 then Just left else Nothing
_ -> Nothing
-- | 记一次失败
rateLimitRecord :: RateLimiter -> Text -> IO ()
rateLimitRecord rl key = do
now <- rlClock rl
pol <- readIORef (rlPolicy rl)
modifyMVar_ (rlMap rl) $ \m ->
let kept = Map.filter (\a -> diffUTCTime now (attStart a) < rlpWindow pol) m
old = Map.findWithDefault (Attempt 0 now) key kept
fresh = if attCount old == 0 then Attempt 1 now else old{attCount = attCount old + 1}
in pure (Map.insert key fresh kept)
-- | 登录成功就把失败记录清掉
rateLimitClear :: RateLimiter -> Text -> IO ()
rateLimitClear rl key = modifyMVar_ (rlMap rl) (pure . Map.delete key)
+99
View File
@@ -0,0 +1,99 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Secure (
securityHeaders,
contentSecurityPolicy,
sameOriginOnly,
bodyLimit,
bodyLimitDynamic,
) where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import Network.HTTP.Types (
Header,
Method,
hContentLength,
hOrigin,
methodPost,
status403,
status413,
)
import Network.Wai (Middleware, Request (..), mapResponseHeaders, responseLBS)
-- 三个 WAI 安全中间件:响应头、同源校验、请求体上限。
-- | 所有响应都带上的安全头
securityHeaders :: Middleware
securityHeaders app req respond =
app req (respond . mapResponseHeaders (\headers -> filter (\(name, _) -> name /= "Content-Security-Policy" || lookup name headers == Nothing) extraHeaders ++ headers))
-- | 只给编辑器样式发随机 nonce
contentSecurityPolicy :: Maybe Text -> Text
contentSecurityPolicy nonce =
"default-src 'self'; script-src 'self'; style-src 'self'"
<> maybe "" (\value -> " 'nonce-" <> value <> "'") nonce
<> "; font-src 'self'; worker-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
-- | 统一加的安全响应头
extraHeaders :: [Header]
extraHeaders =
[ ("Content-Security-Policy", TE.encodeUtf8 (contentSecurityPolicy Nothing))
, ("X-Content-Type-Options", "nosniff")
, ("X-Frame-Options", "DENY")
, ("Referrer-Policy", "no-referrer")
, ("Cache-Control", "no-store")
]
-- | 变更类请求要求同源,无 Origin 放行
sameOriginOnly :: Middleware
sameOriginOnly app req respond =
if isMutating (requestMethod req) && not (originAllowed req)
then respond (responseLBS status403 [] "cross-origin request rejected")
else app req respond
-- | 这个方法算不算会改数据的
isMutating :: Method -> Bool
isMutating m = m `elem` [methodPost, "PUT", "PATCH", "DELETE"]
-- | Origin 与 Host 是否一致
originAllowed :: Request -> Bool
originAllowed req = case lookup hOrigin (requestHeaders req) of
Nothing -> True
Just origin -> case lookup "Host" (requestHeaders req) of
Nothing -> False
Just host -> authorityOf origin == host || authorityOf origin == stripPort host
-- | 取出 host:port,去掉 scheme 与路径
authorityOf :: BS.ByteString -> BS.ByteString
authorityOf raw =
let noScheme = case BSC.breakSubstring "//" raw of
(_, rest) | not (BSC.null rest) -> BSC.drop 2 rest
_ -> raw
in fst (BSC.break (== '/') noScheme)
-- | 去掉端口 host:8080 -> host
stripPort :: BS.ByteString -> BS.ByteString
stripPort = fst . BSC.break (== ':')
-- | 按 Content-Length 拦下过大的请求体
bodyLimit :: Int -> Middleware
bodyLimit limit = bodyLimitDynamic (pure limit)
-- | 同上,但上限每次请求现取
bodyLimitDynamic :: IO Int -> Middleware
bodyLimitDynamic limitOf app req respond = do
limit <- limitOf
if declaredTooBig limit req
then respond (responseLBS status413 [] "request body too large")
else app req respond
-- | Content-Length 是否超限
declaredTooBig :: Int -> Request -> Bool
declaredTooBig limit req = case lookup hContentLength (requestHeaders req) of
Nothing -> False
Just v -> case BSC.readInt v of
Just (n, rest) -> BSC.null rest && n > limit
Nothing -> False
+249
View File
@@ -0,0 +1,249 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Settings (
SettingItem (..),
settingCatalogue,
findItem,
isRootOnly,
isRestartRequired,
settingsFileCandidates,
resolveSettingsFile,
readSettingsFile,
writeSettingsFile,
applySettings,
liveKeys,
defaultOf,
effectiveSettings,
) where
import ChuSQL.Web.Config (WebConfig (..))
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Control.Exception (IOException, try)
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- Web 配置项目录,与启动脚本共用一份设置文件,支持热改与重启生效。
data SettingItem = SettingItem
{ siKey :: Text
, siLabel :: Text
, siGroup :: Text
, siKind :: Text
, siEnv :: Text
, siDefault :: Text
, siRootOnly :: Bool
, siRestart :: Bool
}
deriving (Show, Eq)
-- | 全部可配置项(顺序即界面里的顺序)
settingCatalogue :: [SettingItem]
settingCatalogue =
[ item "port" "HTTP port" "server" "int" "CHUSQL_WEB_PORT" "7777" True True
, item "host" "Listen address" "server" "text" "CHUSQL_WEB_HOST" "127.0.0.1" True True
, item "static-dir" "Static directory" "server" "text" "CHUSQL_WEB_STATIC" "static" True True
, item "cookie-secure" "Secure cookie (behind TLS)" "server" "bool" "CHUSQL_WEB_COOKIE_SECURE" "0" True False
, item "body-limit" "Max request body (bytes)" "limits" "int" "CHUSQL_WEB_BODY_LIMIT" "65536" False False
, item "session-idle" "Session idle timeout (s)" "limits" "int" "CHUSQL_WEB_SESSION_IDLE" "28800" False False
, item "session-max" "Session max age (s)" "limits" "int" "CHUSQL_WEB_SESSION_MAX" "86400" False False
, item "login-max-attempts" "Failed logins before lockout" "limits" "int" "CHUSQL_WEB_LOGIN_MAX_ATTEMPTS" "5" False False
, item "login-window" "Lockout window (s)" "limits" "int" "CHUSQL_WEB_LOGIN_WINDOW" "300" False False
, item "rows-per-page" "Rows per page" "limits" "int" "CHUSQL_WEB_PAGE_SIZE" "25" False False
, item "max-page-size" "Rows per page ceiling" "limits" "int" "CHUSQL_WEB_MAX_PAGE_SIZE" "500" False False
, item "max-rows" "Rows per query" "limits" "int" "CHUSQL_WEB_MAX_ROWS" "1000" False False
, item "max-sql-length" "Max SQL characters" "limits" "int" "CHUSQL_WEB_MAX_SQL_LENGTH" "20000" False False
, item "user" "Account name" "auth" "text" "CHUSQL_WEB_USER" "root" True True
, item "password" "Password (plain, hashed at start)" "auth" "secret" "CHUSQL_WEB_PASSWORD" "" True True
, item "password-hash" "Password hash (pbkdf2)" "auth" "secret" "CHUSQL_WEB_PASSWORD_HASH" "" True True
, item "data-dir" "Data directory" "storage" "text" "CHUSQL_DATA_DIR" "" True True
, item "pipe-name" "Named pipe" "storage" "text" "CHUSQL_PIPE" "" True True
, item "storage-page-size" "Storage page size" "storage" "int" "CHUSQL_PAGE_SIZE" "4096" True True
, item "storage-btree-order" "Storage B+tree order" "storage" "int" "CHUSQL_BTREE_ORDER" "4" True True
, item "storage-buffer-pool" "Storage buffer pool pages" "storage" "int" "CHUSQL_BUFFER_POOL_SIZE" "1024" True True
, item "storage-log" "Storage log level" "storage" "text" "CHUSQL_LOG" "info" True True
, item "seed" "Seed demo data on start" "storage" "bool" "CHUSQL_WEB_SEED" "1" True True
]
where
item k l g kind env def rootOnly restart =
SettingItem
{ siKey = k
, siLabel = l
, siGroup = g
, siKind = kind
, siEnv = env
, siDefault = def
, siRootOnly = rootOnly
, siRestart = restart
}
-- | 按 key 找一项
findItem :: Text -> Maybe SettingItem
findItem key = case [i | i <- settingCatalogue, siKey i == key] of
(i : _) -> Just i
[] -> Nothing
-- | 这项是不是重要参数(只有配置账号能改)
isRootOnly :: Text -> Bool
isRootOnly key = maybe False siRootOnly (findItem key)
-- | 这项是不是要重启才生效
isRestartRequired :: Text -> Bool
isRestartRequired key = maybe False siRestart (findItem key)
-- | 改完立刻生效的配置键
liveKeys :: [Text]
liveKeys =
[ "body-limit"
, "session-idle"
, "session-max"
, "login-max-attempts"
, "login-window"
, "rows-per-page"
, "max-page-size"
, "max-rows"
, "max-sql-length"
]
-- | 设置文件的候选位置
settingsFileCandidates :: [FilePath]
settingsFileCandidates =
[ "script" </> "chusql.settings.json"
, ".." </> "script" </> "chusql.settings.json"
, "chusql.settings.json"
]
-- | 找现成的设置文件,否则挑目录已存在的候选
resolveSettingsFile :: IO FilePath
resolveSettingsFile = do
found <- firstExistingFile settingsFileCandidates
case found of
Just path -> pure path
Nothing -> do
ready <- firstExistingDir settingsFileCandidates
pure (maybe (headOr settingsFileCandidates "chusql.settings.json") id ready)
where
firstExistingFile [] = pure Nothing
firstExistingFile (p : ps) = do
ok <- doesFileExist p
if ok then pure (Just p) else firstExistingFile ps
-- | 候选所在目录是否已存在
firstExistingDir [] = pure Nothing
firstExistingDir (p : ps) = do
let dir = takeDirectory p
ok <- if null dir then pure True else doesDirectoryExist dir
if ok then pure (Just p) else firstExistingDir ps
-- | 候选列表里的第一个
headOr :: [a] -> a -> a
headOr (x : _) _ = x
headOr [] d = d
-- | 读设置文件:键 -> 字符串值(文件不存在或坏掉都当空)
readSettingsFile :: FilePath -> IO (Map.Map Text Text)
readSettingsFile path = do
exists <- doesFileExist path
if not exists
then pure Map.empty
else do
raw <- readFileUtf8 path
pure $ case A.eitherDecodeStrict' raw of
Left _ -> Map.empty
Right (A.Object o) -> Map.fromList [(K.toText k, jsonToText v) | (k, v) <- KM.toList o]
Right _ -> Map.empty
where
-- | JSON 值当字符串看
jsonToText v = case v of
A.String s -> s
A.Number n -> T.pack (show n)
A.Bool b -> if b then "1" else "0"
_ -> ""
-- | 读文件(UTF-8 字节读,避免依赖系统编码)
readFileUtf8 :: FilePath -> IO BS.ByteString
readFileUtf8 = BS.readFile
-- | 写设置文件,只写非空值
writeSettingsFile :: FilePath -> Map.Map Text Text -> IO (Either String ())
writeSettingsFile path values = do
let dir = takeDirectory path
prepared <- try (ensureDir dir) :: IO (Either IOException ())
case prepared of
Left e -> pure (Left ("cannot create the settings directory " ++ dir ++ ": " ++ show e))
Right () -> do
let encoded =
A.encode
( A.Object
( KM.fromList
[ (K.fromText k, A.String v)
| (k, v) <- Map.toList values
, not (T.null v)
]
)
)
written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ())
pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written)
where
ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir
-- | 把界面上传来的值合并进现有文件内容(只接受目录里有的键)
applySettings :: Map.Map Text Text -> Map.Map Text Text -> Either String (Map.Map Text Text)
applySettings current = go (Right current) . Map.toList
where
go (Left e) _ = Left e
go (Right acc) [] = Right acc
go (Right acc) ((key, value) : rest) = case findItem key of
Nothing -> Left ("unknown setting: " ++ T.unpack key)
Just spec -> case checkKind spec value of
Left e -> Left e
Right () -> go (Right (Map.insert key value acc)) rest
-- | 按类型校验一个值(空串表示"清掉",一律放行)
checkKind :: SettingItem -> Text -> Either String ()
checkKind spec value
| T.null (T.strip value) = Right ()
| siKind spec == "int" = case reads (T.unpack (T.strip value)) :: [(Int, String)] of
[(_, "")] -> Right ()
_ -> Left (T.unpack (siKey spec) ++ " needs an integer, got: " ++ T.unpack value)
| siKind spec == "bool" = case T.toLower (T.strip value) of
v | v `elem` ["0", "1", "true", "false", "yes", "no", "on", "off"] -> Right ()
_ -> Left (T.unpack (siKey spec) ++ " needs a boolean, got: " ++ T.unpack value)
| otherwise = Right ()
-- | 一项的内置默认值(设置被清掉、也没有别处给值时用它)
defaultOf :: Text -> Text
defaultOf key = maybe "" siDefault (findItem key)
-- | 本进程启动时生效的配置,摊成键值对
effectiveSettings :: WebConfig -> Map.Map Text Text
effectiveSettings cfg =
Map.fromList
[ ("port", tshow (wcPort cfg))
, ("host", T.pack (wcHost cfg))
, ("static-dir", T.pack (wcStaticDir cfg))
, ("cookie-secure", boolText (wcCookieSecure cfg))
, ("body-limit", tshow (wcBodyLimit cfg))
, ("session-idle", tshow (wcSessionIdle cfg))
, ("session-max", tshow (wcSessionMax cfg))
, ("login-max-attempts", tshow (wcLoginMaxAttempts cfg))
, ("login-window", tshow (wcLoginWindow cfg))
, ("rows-per-page", tshow (wcPageSize cfg))
, ("max-page-size", tshow (wcMaxPageSize cfg))
, ("max-rows", tshow (wcMaxRows cfg))
, ("max-sql-length", tshow (wcMaxSqlLength cfg))
, ("user", wcUser cfg)
, ("seed", boolText (wcSeedDemo cfg))
, ("data-dir", maybe "" T.pack (wcDataDir cfg))
, ("pipe-name", maybe "" T.pack (wcPipeName cfg))
]
where
tshow = T.pack . show
boolText True = "1"
boolText False = "0"
+61
View File
@@ -0,0 +1,61 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.Static (
safeRelative,
readStatic,
contentTypeOf,
) where
import qualified Data.ByteString as BS
import Data.Char (isAsciiLower, isAsciiUpper, isDigit, toLower)
import Data.Text (Text)
import qualified Data.Text as T
import System.Directory (doesFileExist)
import System.FilePath ((</>), takeExtension)
-- 静态文件交付:文件名白名单加扩展名白名单,不做目录列举。
-- | 文件名长度上限
maxNameLength :: Int
maxNameLength = 128
-- | 允许交付的扩展名(只有前端资产)
allowedExtensions :: [String]
allowedExtensions = [".html", ".css", ".js", ".svg", ".png", ".ico", ".ttf", ".woff2"]
-- | 把请求里的文件名变成安全的相对路径;不合法给 Nothing
safeRelative :: Text -> Maybe FilePath
safeRelative t
| T.null t = Nothing
| T.length t > maxNameLength = Nothing
| ".." `T.isInfixOf` t = Nothing
| T.any badChar t = Nothing
| map toLower (takeExtension (T.unpack t)) `notElem` allowedExtensions = Nothing
| otherwise = Just (T.unpack t)
where
-- | 只允许字母数字与 ._-
badChar c = not (isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String))
-- | 读一个静态文件;不存在(或是目录)给 Nothing
readStatic :: FilePath -> FilePath -> IO (Maybe BS.ByteString)
readStatic dir rel = case safeRelative (T.pack rel) of
Nothing -> pure Nothing
Just name -> do
let path = dir </> name
ok <- doesFileExist path
if ok then Just <$> BS.readFile path else pure Nothing
-- | 按扩展名给 Content-Type
contentTypeOf :: FilePath -> Text
contentTypeOf path = case map toLower (takeExtension path) of
".html" -> "text/html; charset=utf-8"
".css" -> "text/css; charset=utf-8"
".js" -> "text/javascript; charset=utf-8"
".json" -> "application/json; charset=utf-8"
".svg" -> "image/svg+xml"
".png" -> "image/png"
".ico" -> "image/x-icon"
".ttf" -> "font/ttf"
".woff2" -> "font/woff2"
".txt" -> "text/plain; charset=utf-8"
_ -> "application/octet-stream"
+119
View File
@@ -0,0 +1,119 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.StorageProcess (
StorageProcess (..),
findStorageServer,
startStorageProcess,
stopStorageProcess,
waitForStorage,
) where
import ChuSQL.Storage.IPC (Request (..), Response (..), closeConnection, sendRequest)
import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try)
import Control.Monad (filterM)
import Data.Time.Clock.POSIX (getPOSIXTime)
import System.Directory (createDirectoryIfMissing, doesFileExist, getTemporaryDirectory)
import System.Environment (getEnvironment, setEnv)
import System.Exit (ExitCode)
import System.FilePath ((</>))
import System.IO (Handle)
import System.Process (
CreateProcess (..),
ProcessHandle,
StdStream (..),
createProcess,
proc,
terminateProcess,
waitForProcess,
)
-- 拉起并管理 Rust 存储进程,起来后等管道应答 ping 再继续。
data StorageProcess = StorageProcess
{ spProcess :: ProcessHandle
, spPipe :: String
, spDataDir :: FilePath
}
-- | 找存储进程,没有就给路径提示
findStorageServer :: Maybe FilePath -> IO (Either String FilePath)
findStorageServer (Just p) = do
ok <- doesFileExist p
pure (if ok then Right p else Left ("storage server not found: " ++ p))
findStorageServer Nothing = do
let dirs =
[ "chusql-storage/target"
, "../chusql-storage/target"
, "../../chusql-storage/target"
, "target"
]
modes = ["release", "debug"]
exes = ["chusql-storage.exe", "chusql-storage"]
candidates = [d </> m </> e | d <- dirs, m <- modes, e <- exes]
found <- filterM doesFileExist candidates
pure $ case found of
(p : _) -> Right p
[] -> Left "no Rust storage server found; run `cargo build --release` in chusql-storage, or pass --storage <path>"
-- | 起存储进程并等它就绪
startStorageProcess :: FilePath -> IO (Either String StorageProcess)
startStorageProcess bin = do
stamp <- round . (* 1000000) <$> getPOSIXTime :: IO Integer
tmp <- getTemporaryDirectory
let pipe = "chusql-web-" ++ show stamp
dir = tmp </> pipe
createDirectoryIfMissing True dir
envs <-
childEnv
[ ("CHUSQL_PIPE", pipe)
, ("CHUSQL_DATA_DIR", dir)
]
started <-
try (createProcess (proc bin []){env = Just envs, std_out = NoStream, std_err = Inherit}) ::
IO (Either SomeException (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle))
case started of
Left e -> pure (Left ("failed to start the storage process: " ++ show e))
Right (_, _, _, ph) -> do
setEnv "CHUSQL_PIPE" pipe
setEnv "CHUSQL_DATA_DIR" dir
ready <- waitForStorage (200 :: Int)
if ready
then pure (Right (StorageProcess ph pipe dir))
else do
_ <- try (terminateProcess ph) :: IO (Either SomeException ())
pure (Left "storage process did not become ready within 5 seconds (no ping reply on the pipe)")
-- | 停掉存储进程,保留数据目录
stopStorageProcess :: StorageProcess -> IO ()
stopStorageProcess sp = do
closeConnection
_ <- try (terminateProcess (spProcess sp)) :: IO (Either SomeException ())
_ <- try (waitForProcess (spProcess sp)) :: IO (Either SomeException ExitCode)
pure ()
-- | 轮询 ping 直到对面应答
waitForStorage :: Int -> IO Bool
waitForStorage 0 = pure False
waitForStorage n = do
r <- try (sendRequest ReqPing) :: IO (Either SomeException Response)
case r of
Right RespPong -> pure True
_ -> threadDelay 25000 >> waitForStorage (n - 1)
-- | 子进程环境:继承并覆盖两个专用变量
childEnv :: [(String, String)] -> IO [(String, String)]
childEnv extra = do
base <- getEnvironment
let ours = ["CHUSQL_PIPE", "CHUSQL_DATA_DIR"]
kept = filter (\(k, _) -> k `notElem` ours) base
missing (k, _) = not (any ((== k) . fst) kept)
defaults = filter missing storageDefaults
pure (kept ++ defaults ++ extra)
-- | 存储进程的调参默认值(可以被用户环境里的同名变量覆盖)
storageDefaults :: [(String, String)]
storageDefaults =
[ ("CHUSQL_PAGE_SIZE", "4096")
, ("CHUSQL_BTREE_ORDER", "4")
, ("CHUSQL_LOG", "info")
]
+180
View File
@@ -0,0 +1,180 @@
{-# LANGUAGE OverloadedStrings #-}
module ChuSQL.Web.UiSettings (
uiSettingsFileCandidates,
resolveUiSettingsFile,
validateUiSettings,
readUiSettings,
writeUiSettings,
) where
import Control.Exception (IOException, try)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as Map
import Data.Scientific (floatingOrInteger)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)
import System.FilePath (takeDirectory, (</>))
-- 前端 IDE 设置单独存一份文件,与启动配置 chusql.settings.json 互不干扰。
-- | IDE 设置文件的候选位置
uiSettingsFileCandidates :: [FilePath]
uiSettingsFileCandidates =
[ "script" </> "chusql.ui.settings.json"
, ".." </> "script" </> "chusql.ui.settings.json"
, "chusql.ui.settings.json"
]
-- | 找现成的设置文件,否则挑目录已存在的候选
resolveUiSettingsFile :: IO FilePath
resolveUiSettingsFile = do
found <- firstExistingFile uiSettingsFileCandidates
case found of
Just path -> pure path
Nothing -> do
ready <- firstExistingDir uiSettingsFileCandidates
pure (maybe firstCandidate id ready)
where
-- | 候选清单里的第一个
firstCandidate = case uiSettingsFileCandidates of
(p : _) -> p
[] -> "chusql.ui.settings.json"
firstExistingFile [] = pure Nothing
firstExistingFile (p : ps) = do
ok <- doesFileExist p
if ok then pure (Just p) else firstExistingFile ps
-- | 候选所在目录是否已存在
firstExistingDir [] = pure Nothing
firstExistingDir (p : ps) = do
let dir = takeDirectory p
ok <- if null dir then pure True else doesDirectoryExist dir
if ok then pure (Just p) else firstExistingDir ps
-- | 校验一份 IDE 设置,未知键与越界都给错
validateUiSettings :: A.Value -> Either String (Map.Map Text A.Value)
validateUiSettings (A.Object o) = Map.fromList <$> mapM checkField (KM.toList o)
where
checkField (rawKey, value) =
let key = K.toText rawKey
in case key of
"uiFonts" -> pair key <$> fontChain key value
"gridFonts" -> pair key <$> fontChain key value
"sqlFonts" -> pair key <$> fontChain key value
"uiFontSize" -> pair key <$> boundedInt key 11 16 value
"gridFontSize" -> pair key <$> boundedInt key 10 18 value
"gridRowHeight" -> pair key <$> boundedInt key 18 40 value
"sqlFontSize" -> pair key <$> boundedInt key 10 20 value
"sqlLineHeight" -> pair key <$> boundedInt key 16 40 value
"sqlTabSize" -> pair key <$> boundedInt key 1 8 value
"pageSize" -> pair key <$> boundedInt key 10 500 value
"nullText" -> pair key <$> shortText key value
"sqlLineNumbers" -> pair key <$> boolValue key value
"autocomplete" -> pair key <$> boolValue key value
"minimap" -> pair key <$> boolValue key value
"confirmBeforeCommit" -> pair key <$> boolValue key value
_ -> Left ("unknown setting: " ++ T.unpack key)
pair key value = (key, value)
validateUiSettings _ = Left "settings must be a JSON object"
fontChainMax :: Int
fontChainMax = 10
fontNameMax :: Int
fontNameMax = 64
nullTextMax :: Int
nullTextMax = 64
-- | 字体链:最多 10 项,每项非空且不超 64 字
fontChain :: Text -> A.Value -> Either String A.Value
fontChain key value = case value of
A.Array xs
| V.length xs > fontChainMax -> bad
| otherwise -> A.Array . V.fromList <$> mapM item (V.toList xs)
_ -> bad
where
bad =
Left
( T.unpack key
++ " expects at most "
++ show fontChainMax
++ " non-empty font names of at most "
++ show fontNameMax
++ " characters"
)
item (A.String s) =
let trimmed = T.strip s
in if not (T.null trimmed) && T.length trimmed <= fontNameMax
then Right (A.String trimmed)
else bad
item _ = bad
-- | 区间内的整数,顺便把它整成规范写法
boundedInt :: Text -> Int -> Int -> A.Value -> Either String A.Value
boundedInt key lo hi value = case value of
A.Number n -> case floatingOrInteger n :: Either Double Integer of
Right i
| i >= fromIntegral lo && i <= fromIntegral hi -> Right (A.Number (fromIntegral i))
_ -> bad
_ -> bad
where
bad = Left (T.unpack key ++ " expects an integer in " ++ show lo ++ ".." ++ show hi)
-- | 空值占位文本:字符串且不超 64 字
shortText :: Text -> A.Value -> Either String A.Value
shortText key value = case value of
A.String s
| T.length s <= nullTextMax -> Right (A.String s)
_ ->
Left
( T.unpack key
++ " expects a string of at most "
++ show nullTextMax
++ " characters"
)
-- | 布尔开关
boolValue :: Text -> A.Value -> Either String A.Value
boolValue key value = case value of
A.Bool b -> Right (A.Bool b)
_ -> Left (T.unpack key ++ " expects a boolean")
-- | 读 IDE 设置文件,缺文件或坏内容都给空对象
readUiSettings :: FilePath -> IO A.Value
readUiSettings path = do
exists <- doesFileExist path
if not exists
then pure emptyObject
else do
raw <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)
pure $ case raw of
Left _ -> emptyObject
Right bytes -> case A.eitherDecodeStrict' bytes of
Right (A.Object o) -> A.Object o
_ -> emptyObject
where
emptyObject = A.Object KM.empty
-- | 写 IDE 设置文件,先建目录再落盘
writeUiSettings :: FilePath -> Map.Map Text A.Value -> IO (Either String ())
writeUiSettings path values = do
prepared <- try (ensureDir (takeDirectory path)) :: IO (Either IOException ())
case prepared of
Left e -> pure (Left ("cannot create the settings directory " ++ takeDirectory path ++ ": " ++ show e))
Right () -> do
let encoded = A.encode (A.Object (KM.fromList [(K.fromText k, v) | (k, v) <- Map.toList values]))
written <- try (BS.writeFile path (BL.toStrict encoded)) :: IO (Either IOException ())
pure (either (Left . \e -> "cannot write " ++ path ++ ": " ++ show e) Right written)
where
ensureDir dir = if null dir then pure () else createDirectoryIfMissing True dir
+5
View File
@@ -0,0 +1,5 @@
resolver: lts-24.59
packages:
- .
- ../chusql-engine
+12
View File
@@ -0,0 +1,12 @@
# This file was autogenerated by Stack.
# You should not edit this file by hand.
# For more information, please see the documentation at:
# https://docs.haskellstack.org/en/stable/topics/lock_files
packages: []
snapshots:
- completed:
sha256: 0f728e30e843d7f00460ad56858b995a3b227fca683659c7df9f2fc3988a3ce1
size: 732465
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/24/59.yaml
original: lts-24.59
File diff suppressed because it is too large. Load diff