使用React js完成web前端
This commit is contained in:
1 parent
9e8c5f1ef0
commit
7600f0f9ed
130 files changed
+12708
-788
No files matched your search
Whitespace-only changes.
@@ -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)"
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI 占位入口:功能尚未实现,仅提示用户。
|
||||
|
||||
console.log('ChuSQL CLI is not implemented yet. Coming soon.');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+2226
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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; } }
|
||||
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
// 测试环境初始化:注册 jest-dom 断言与自动清理。
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -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
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite 环境类型声明:客户端环境变量与 CSS Modules 模块。
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>;
|
||||
export default classes;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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]',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Vitest 配置:jsdom 环境与测试初始化脚本。
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
resolver: lts-24.59
|
||||
|
||||
packages:
|
||||
- .
|
||||
- ../chusql-engine
|
||||
@@ -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
Reference in new issue
Block a user